ap@northstreet.example
Invoices
Paid is paid, however it arrived.
Your customer pays the hosted invoice by card. The check that turns up in the mail, the wire, the cash: you record those against the same invoice, and the same balance moves. One record answers what is still owed, what bounced, and whether they ever opened the bill.
one record · outstanding_money runs to zero · Stripe processes the cards
01The problem
A receivable is a join nobody owns.
The invoice lives in one tool, the card lands in another, and the check arrives by mail on a Tuesday.
Ask a team using an invoicing product plus a separate processor what is still owed on an invoice and watch what happens. The total is in the invoicing tool. The card payment, if it came, is in the processor. The check is in a spreadsheet column somebody named paid? and the follow-up is in an email thread. The balance is not stored anywhere; it is a subtraction, done by hand, by whoever asked.
Flint puts the whole receivable on one record, because the invoice is backed by an order and owns collection on it once sent. Here is every question that stack cannot answer without a join, and the endpoint that answers it here.
What is still owed on this invoice, right now.
outstanding_moneyThe check for part of it, recorded where the balance can see it.
POST /v1/invoices/{invoice_id}/manual-paymentsThat same check, returned unpaid two weeks later.
POST /v1/invoices/{invoice_id}/manual-payments/reverseWhether the customer ever opened the bill.
viewed_atWhether the reminder reached their inbox at all.
GET /v1/invoices/{invoice_id}/delivery-attemptsEveryone who is late, as a query rather than a report.
is_overdueReviewed 2026-07-25 against the published API.
02The balance
Two rails, one number.
Card payments are collected. Everything else is recorded. The invoice does not treat those differently once the money is on it.
curl -X POST https://api.withflintpay.com/v1/invoices \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: invoice-north-street-07" \
-d '{
"quick_pay": {
"customer_id": "cus_1kmn0aExample",
"line_items": [
{ "name": "Site audit", "quantity": 1,
"unit_price_money": { "amount": 120000, "currency": "USD" } },
{ "name": "Retainer hours, July", "quantity": 5,
"unit_price_money": { "amount": 10000, "currency": "USD" } }
],
"internal_note": "PO 4417, net 15"
},
"recipient_email": "ap@northstreet.example",
"due_at": "2026-07-15T00:00:00Z",
"memo": "Thanks for the work this month."
}'quick_pay builds the backing order for you. Pass order_id instead when the order already exists, and exactly one of the two.
{
"data": {
"invoice_id": "inv_1kmn0aExample",
"invoice_number": "INV-0231",
"order_id": "ord_1kmn0aExample",
"status": "partially_paid",
"paid_money": { "amount": 130000, "currency": "USD" },
"outstanding_money": { "amount": 53600, "currency": "USD" },
"refunded_money": { "amount": 0, "currency": "USD" },
"refund_status": "none",
"is_overdue": true,
"sent_at": "2026-07-02T15:31:00Z",
"viewed_at": "2026-07-02T16:04:11Z",
"due_at": "2026-07-15T00:00:00Z"
}
}No join. The balance, the delivery, and the refund state are fields on one read.
{
"event_type": "invoice.manual_payment_recorded",
"data": {
"invoice_id": "inv_1kmn0aExample",
"status": "paid",
"paid_money": { "amount": 183600, "currency": "USD" },
"outstanding_money": { "amount": 0, "currency": "USD" }
}
}An offline payment that clears the balance arrives as invoice.manual_payment_recorded carrying the new status. It does not also fire invoice.paid, so a fulfillment handler listening only for that event would miss the check that paid the bill.
The rule, stated once
No amount field on the hosted page. It collects the whole balance.
POST /v1/invoices/{invoice_id}/checkout-sessionRecording accepts any positive amount up to the balance, which is how an invoice becomes partly paid.
amount_moneyA reversal cannot exceed what was recorded offline, so card money is never quietly undone.
POST /v1/invoices/{invoice_id}/manual-payments/reverseCard collection needs the merchant's card capability to report ready. Until it does, the hosted page has nothing to offer the buyer and says so rather than failing at the end.
03The receivable
What the person chasing it sees.
Same record, read from the other side. These are the dashboard's own components and its own field set.
And the list it came from
The daily worklist is one query. Filter on is_overdue together with a status, not on its own: it is computed on read and excludes only paid and void invoices, so a draft you wrote last month with a date in the past reports overdue too.
curl -G https://api.withflintpay.com/v1/invoices \
-H "Authorization: Bearer YOUR_API_KEY" \
-d is_overdue=true \
-d status=open \
-d page_size=50The status filter is not decoration. Without it the worklist includes past-due drafts that were never sent to anyone.
Invoice status, the complete set
- draft
- open
- partially_paid
- paid
- void
Overdue is not among them, because it is derived rather than stored. Neither is refunded: that lives on its own axis so a settled invoice stays settled.
04Delivery
Unpaid and never arrived are different problems.
Most invoicing APIs tell you an invoice was sent. That is the least useful thing they could tell you.
Reminders, on purpose
- Cadence
- None. One call sends one reminder.
- Cap
- None. The polite pacing is yours.
- What due_at does
- Flips is_overdue, and nothing else.
{
"data": [
{
"invoice_delivery_attempt_id": "idel_1kmn0aExample",
"delivery_type": "send",
"channel": "email",
"to_email": "ap@northstreet.example",
"status": "sent",
"sent_at": "2026-07-02T15:31:00Z"
},
{
"invoice_delivery_attempt_id": "idel_2bqr7dExample",
"delivery_type": "reminder",
"channel": "email",
"to_email": "ap@northstreet.example",
"status": "failed",
"error_message": "mailbox full"
}
]
}The second attempt failed and says why. invoice.delivery_failed carries the same news to your webhook endpoint, so a receivables screen can flag it without polling.
# the link leaked, so retire it
curl -X POST \
https://api.withflintpay.com/v1/invoices/inv_1kmn0aExample/regenerate-public-link \
-H "Authorization: Bearer YOUR_API_KEY"The old link stops working immediately, along with any checkout credentials taken from it. The session and a payment already in flight survive, so rotating a leaked link does not cost you money mid-collection.
The invoice event family, all eighteen
- invoice.created
- invoice.sent
- invoice.opened
- invoice.updated
- invoice.paid
- invoice.partially_paid
- invoice.payment_failed
- invoice.payment_attempt_canceled
- invoice.payment_attempt_expired
- invoice.manual_payment_recorded
- invoice.manual_payment_reversed
- invoice.refunded
- invoice.partially_refunded
- invoice.delivery_succeeded
- invoice.delivery_failed
- invoice.voided
- invoice.collection_blocked
- invoice.collection_block_resolved
05Underneath
It was an order the whole time.
Which is why refunding an invoice needs no invoice-specific machinery, and no credit-note object.
Refund the order the invoice describes, item by item, with the same call any other order uses.
POST /v1/refundsThe invoice tracks the money returned on its own axis, so paid never moves backward.
refund_statusVoid is for a bill that should not have existed. It is not a refund and it is not reversible.
POST /v1/invoices/{invoice_id}/voidA sent invoice is immutable, and one order carries one live invoice at a time.
snapshotThe audit trail is readable rather than reconstructed: the events route returns the invoice's own history, from draft through every reminder, payment, and reversal. It is a narrower vocabulary than the webhook family, so treat it as the story of the record and the webhooks as the integration surface.
# refund the order the invoice describes, item by item
curl -X POST https://api.withflintpay.com/v1/refunds \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: refund-inv-0231-01" \
-d '{
"order_id": "ord_1kmn0aExample",
"reason": "requested_by_customer",
"line_items": [
{ "order_line_item_id": "li_1kmn0aExample", "quantity": 1 }
]
}'No invoice id in the body. The refund targets the order, and the invoice's refunded_money and refund_status follow from it.
The invoice
Create from an order or a quick_pay block, send, collect, void. Status and balance are computed, never set.
- POST /v1/invoices
- quick_pay.line_items
- outstanding_money
Offline money
Record a check, a wire, or cash with the reference that identifies it, and reverse it when it fails.
- POST /v1/invoices/{invoice_id}/manual-payments
- external_reference_id
- received_at
The document
A frozen snapshot after send, a PDF rendered from it, and a public link you can retire on demand.
- GET /v1/invoices/{invoice_id}/pdf
- POST /v1/invoices/{invoice_id}/regenerate-public-link
- snapshot
Delivery
Every send and reminder logged with its outcome, plus the first time the customer opened the page.
- GET /v1/invoices/{invoice_id}/delivery-attempts
- viewed_at
- invoice.delivery_failed
Follow-up
One reminder per call, and a computed overdue flag to build a worklist from. No cadence you did not write.
- POST /v1/invoices/{invoice_id}/send-reminder
- is_overdue
- due_at
Refunds and history
Order-backed refunds on their own axis, and the invoice's own event log for support to read.
- POST /v1/refunds
- refunded_money
- refund_status
06Limits
What this does not do.
The hosted invoice page collects the whole balance or nothing. It offers the payment options your hosted checkout has enabled and always asks for the full remaining amount. A wire, a check, or any payment you take outside it is money you record here, which keeps the balance honest but does mean Flint is not the one collecting it.
Nothing chases the customer for you. There is no dunning schedule, no escalation ladder, and no cap on reminders. That is a deliberate default rather than a missing feature, but if you wanted the invoicing product to own follow-up policy, it does not.
No e-invoicing compliance. Government mandates, clearance models, and networks like Peppol are a specialist vendor's territory. What is here is a US-focused itemized document, a hosted payment page, a PDF, and receivables state you can query.
No credit notes and no proration. Money comes back through the refunds API against the backing order. If your accounting process needs a credit-note document as its own artifact, that is not something this produces.
One currency per invoice. It comes from the order, and a recorded payment has to match it. There is no conversion and no per-line currency.
The CLI covers four commands. Create, send, get, and list. Voiding, reminders, recording a payment, and the PDF are REST calls.
07Start
Send one to yourself in about five minutes.
CLI
flint invoices create \
--order ord_1kmn0aExample \
--recipient-email ap@northstreet.example
flint invoices send @last.inv
flint invoices get @last.inv
flint invoices list --status openCreate it against an order, send it, then read it back. The recipient address is yours in a sandbox, so the email arrives where you can see it.
A sandbox invoice is a real invoice with fake money: the hosted page renders, the email sends, and 4242 4242 4242 4242 pays it. Record a check against it with the manual-payments call and watch the balance move without touching a card at all.
FAQ
The questions that decide it.
What can a customer actually pay an invoice with?
The hosted invoice page is a checkout session, so it offers whatever payment options your hosted checkout has enabled, and it always collects the full remaining balance. Anything you collect outside that page, a check, a wire, cash, or a bank payment taken elsewhere, you record against the invoice with the manual-payments endpoint. Both move outstanding_money and status the same way, which is why a partly-paid invoice is a normal state rather than a workaround.
How do partial payments work?
Offline. The hosted page has no amount field and always asks for the whole remaining balance, so a partial payment happens when you record one: manual payments accept any positive amount up to the balance. Record $800 against an $1,836 invoice and it becomes partially_paid with $1,036 still collectible online.
What happens when a check bounces?
Reverse it. The reverse endpoint takes an amount and a reference and puts the balance back, and it works on a paid invoice as well as an open one, so a check that clears an invoice and then returns unpaid reopens it correctly. It can only undo offline amounts, never card money, and it cannot exceed what was recorded manually.
Do reminders send on a schedule?
No, and there is no cap either. The send-reminder endpoint sends exactly one reminder when you call it. due_at passing flips is_overdue and does nothing else. For anything built on Flint that is the right default: a follow-up cadence is a feature of your product, and a vendor's hardcoded schedule would be a setting your users inherit rather than a behavior you own.
Can I tell an unpaid invoice from one that never arrived?
Yes, and they are different rows. Every send and reminder is logged as a delivery attempt with a status and, when it fails, an error message, and invoice.delivery_failed fires on a bounce. The first time the customer opens the hosted page stamps viewed_at. So "sent Tuesday, opened Wednesday, still unpaid" and "never reached their inbox" are two different reads rather than the same silence.
How do refunds work when the money came in on two rails?
Refund the order the invoice describes, item by item, with the standard refunds endpoint. There is no invoice-specific refund call and no credit-note object. The invoice tracks refunded_money and refund_status as their own axis, so paid never moves backward and reporting can tell a settled invoice from a settled-then-refunded one.
Does Flint handle e-invoicing mandates?
No. This is US-focused collection: an itemized document, a hosted payment page, a PDF snapshot, and receivables state you can query. Government e-invoicing mandates, clearance models, and networks like Peppol are a dedicated compliance vendor's job, and pretending otherwise would be the expensive kind of wrong.