Payment Links vs Checkout Sessions vs Invoices
Flint has three hosted ways to collect a payment. All three end in the same place, a paid order, so this is not a decision you can get irreversibly wrong. It is a decision about who starts the payment, how many buyers share the URL, and when the money is due.
- A payment link is a reusable hosted checkout page with a stable URL. Create it once, put the URL anywhere, and every buyer who opens it gets their own checkout.
- A checkout session is one hosted checkout for one buyer. Your app decides someone should pay right now and sends them to a single-use URL.
- An invoice is a billed receivable. It records that a specific customer owes a specific amount, emails them, reminds them, accepts partial and offline payments, and renders a PDF.
Rule of thumb: payment links share, checkout sessions collect, invoices chase.
The Two-Question Decision#
1. Will more than one buyer use the same URL?
Yes: use a payment link. It is the only one of the three built to be shared. A checkout session URL belongs to one buyer, and an invoice belongs to one customer.
No: keep going.
2. Is the money due right now, or are you sending a bill?
Due now, and your app owns the moment: use a checkout session.
It is a bill, meaning the customer may pay in days or weeks, partially, or by check or wire: use an invoice.
If your situation pattern-matches one of these, you are done:
Share a URL
Payment link
- A buy button on your marketing site
- A QR code on packaging or a flyer
- A pricing page selling plan signups
- A donation page with suggested amounts
- An event selling 500 tickets across tiers
Collect right now
Checkout session
- The checkout button in your app's cart
- A quote the buyer just accepted
- An in-product subscription upgrade
- A quick charge for a known amount
- An agent or backend driving a purchase
Bill and track
Invoice
- Consulting work billed net 15
- B2B sales that route through accounts payable
- A balance paid half by card, half by check
- Anything finance wants a PDF and number for
- Receivables that need reminder emails
Side by Side#
| Payment link | Checkout session | Invoice | |
|---|---|---|---|
| What it is | A reusable template that mints checkouts | One payment attempt for one buyer | A record of what a customer owes |
| The URL | Stable and shareable; each buyer gets their own checkout | Single-use; the #checkout_token fragment authenticates the buyer | A hosted public_url per invoice; revocable via regenerate-public-link |
| Who starts payment | The buyer, whenever they find the link | Your app, at the moment of sale | The customer, on their schedule, against a balance you issued |
| Created from | line_items, donation amounts, event_config, or plan_id | Exactly one of order_id, quick_pay_item, or plan_id | order_id or quick_pay, plus recipient_email and due_at |
| Lifecycle | active and inactive | open, then paid, expired, closed, or invalidated | draft, open, partially_paid, paid, or void |
| Orders | One new order per completed checkout, source: "payment_link" | Pays an existing order, or creates one for quick pay and plans | One backing order; the billing snapshot freezes at send |
| Subscriptions | Yes, pass plan_id | Yes, pass plan_id | No |
| Due dates | No | No; sessions expire instead | due_at, with a computed is_overdue |
| Email delivery and reminders | No, you distribute the URL | No, you redirect the buyer | Yes: Flint emails at send, send-reminder follows up, delivery attempts are tracked |
| Partial payments | No | No | Yes: partially_paid with a running amount_due_money |
| Offline payments | No | No | Yes: manual payments with external_reference_id |
| Document artifact | No | No | PDF rendered from the frozen snapshot |
| Expiration | Per visit only; the link lives until deactivated | expires_at, 24 hours by default | None; open until paid or voided |
| Turning it off | POST /deactivate, buyers see your inactive_message | POST /close | POST /void, unpaid invoices only |
| Done signal | order.payment_succeeded per sale | checkout_session.completed | invoice.paid |
| Guide | Payment Links | Checkout Sessions | Invoicing |
One Engine Underneath#
The three surfaces are not three payment systems. They converge on the same two objects: a checkout session collects the money, and an order records what was sold.
payment link buyer opens the shared URL ─▶ checkout session ─▶ order
your backend you create it for one buyer ─▶ checkout session ─▶ order
invoice customer opens the hosted page ─▶ checkout session ─▶ order
you record a check or wire ─▶ manual payment ─▶ invoice + order
Every session records where it came from (source, plus payment_link_id or invoice_id when applicable), and every order joins back to whatever collected it. That shared foundation is what makes the choice low-stakes:
- The aftermath is identical. Refunds, receipts, disputes, payouts, and reporting all hang off the order, no matter which surface collected payment.
GET /v1/ordersis one reconciliation surface for all three. - Switching later is cheap. Nothing about starting with payment links locks you out of building cart checkout on sessions next quarter, or adding invoices when your sales cycle grows an approval step. The orders your first surface produced sit in the same ledger as the ones your next surface will.
- Combining is normal. Plenty of merchants run all three at once: a payment link on the pricing page, checkout sessions inside the product, invoices for enterprise deals. Nothing needs to know which path a given order took except the code that started it.
The Same Sale, Three Ways#
One $120 workshop seat, sold through each surface. Amounts are integers in the currency's minor unit, so 12000 is $120.00.
One call, then the URL does the selling:
curl -X POST https://api.withflintpay.com/v1/payment-links \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: workshop-link-001" \
-d '{
"name": "Letterpress Workshop",
"line_items": [{
"name": "Workshop Seat",
"quantity": 1,
"unit_price_money": {"amount": 12000, "currency": "USD"}
}],
"customer_collection": {"require_email": true}
}'
data.url is live the moment it returns, and the same URL works for every buyer: the website button, the email blast, the QR code on the flyer. Each completed checkout creates its own order carrying source: "payment_link". The seat keeps selling until you deactivate the link or cap it with max_completions.
Right when: seats are sold to the public and a shared URL is the storefront.
Your app creates the order (the durable record), then a checkout for this buyer:
curl -X POST https://api.withflintpay.com/v1/orders \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: workshop-order-001" \
-d '{
"line_items": [{
"name": "Workshop Seat",
"quantity": 1,
"unit_price_money": {"amount": 12000, "currency": "USD"}
}]
}'
curl -X POST https://api.withflintpay.com/v1/checkout-sessions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: workshop-checkout-001" \
-d '{
"order_id": "ord_1kmn0aExample",
"redirects": {
"success_redirect_url": "https://example.com/thanks",
"cancel_redirect_url": "https://example.com/workshop"
}
}'
Send the buyer to data.url exactly as returned. The URL belongs to this buyer alone and expires after 24 hours by default. When there is no cart to model, quick_pay_item collapses both calls into one and Flint creates the backing order for you.
Right when: a buyer clicked Register in your app and should pay now.
Bill the customer, then let them pay on their schedule. quick_pay creates the backing order:
curl -X POST https://api.withflintpay.com/v1/invoices \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: workshop-invoice-001" \
-d '{
"quick_pay": {
"customer_id": "cus_1kmn0aExample",
"line_items": [{
"name": "Workshop Seat",
"quantity": 1,
"unit_price_money": {"amount": 12000, "currency": "USD"}
}]
},
"recipient_email": "ap@example.com",
"due_at": "2026-07-17T00:00:00Z",
"memo": "Net 15. Thank you!"
}'
curl -X POST https://api.withflintpay.com/v1/invoices/inv_1kmn0aExample/send \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: workshop-invoice-send-001"
Send freezes the billing snapshot, emails the customer, and returns the hosted public_url where they can pay by card. If the check arrives by mail instead, record it with a manual payment; a partial amount moves the invoice to partially_paid and amount_due_money tracks the rest.
Right when: a company is registering its team and payment routes through accounts payable.
Boundaries Worth Knowing#
The two questions settle most cases. These are the borders where teams actually deliberate.
Subscriptions: link or session, never invoice#
Payment links and checkout sessions both accept plan_id, and the split is the same as for one-time payments. A public pricing page wants a subscription signup link: one link per plan, shared everywhere that plan is sold. An in-product upgrade for a signed-in user wants a session, because sessions can carry a customer_id and prefilled_customer_info that a broadly shared link cannot.
Invoices do not do recurring billing. An invoice is one balance, billed once. Recurring charges belong to a subscription plan, which bills on schedule without new API calls from you.
"They'll pay later": session or invoice#
A checkout session's whole life is its expiration window, 24 hours by default. That is a collection window, not a receivable. If payment might land days later, arrive in pieces, or show up as a check, you want the object that can wait: the invoice, with due_at, reminders, and a running amount_due_money.
The litmus test: do you need to ask "how much is still owed?" Only invoices can answer that.
Quick pay exists on both sessions and invoices#
quick_pay_item on a session and quick_pay on an invoice both skip explicit order creation; Flint builds the backing order either way. So do not choose based on whether you have an order yet. Choose by collection semantics: pay-now is a session, bill-and-track is an invoice.
A deposit now, the balance later#
Mixed timing means mixed tools, and that is fine. Take the deposit through a payment link or checkout session at booking, then bill the remainder as an invoice when the work is done. Both payments land on orders in the same ledger, so reporting stays whole.
An invoiced order belongs to its invoice#
Once an order is invoiced, collect through the invoice: POST /v1/invoices/{invoice_id}/checkout-session returns an invoice-owned session that keeps the payment attached to the invoice lifecycle. Creating a generic checkout session against the backing order instead sidesteps the receivable: the invoice's balance, reminders, and events stop reflecting reality. Flint blocks the obvious collisions (send is rejected while a generic session is open on the order, and manual payments are rejected while online collection is active), but the correct move is simply to let the invoice own collection.
Fulfillment Is One Pattern#
Whatever mix of surfaces you run, one webhook endpoint covers it. order.payment_succeeded fires when an order is paid through hosted checkout on any of the three; invoices add their own events for the parts orders cannot see, like offline payments and email delivery.
curl -X POST https://api.withflintpay.com/v1/webhook-endpoints \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: webhook-fulfillment-001" \
-d '{
"url": "https://example.com/webhooks/flint",
"enabled_events": [
"order.payment_succeeded",
"checkout_session.completed",
"invoice.paid",
"invoice.partially_paid"
],
"description": "Fulfillment across all hosted surfaces"
}'
| Surface | The events that matter | What the payload tells you |
|---|---|---|
| Payment link | order.payment_succeeded, checkout_session.completed | The order carries source: "payment_link" and the link's metadata; the session carries payment_link_id, so you know which link converted |
| Checkout session | checkout_session.completed, order.payment_succeeded | The session carries its order_id; fulfill from either object |
| Invoice | invoice.paid, invoice.partially_paid, invoice.manual_payment_recorded, delivery events | The invoice carries order_id and the remaining amount_due_money |
| Subscription signup (link or session) | subscription.created, subscription.activated | The new subscription, ready to manage via the API |
On every surface, the buyer's redirect is user experience, not proof of payment. Fulfill from the webhook or a backend fetch, never from the success URL alone. See Webhooks for signature verification and delivery semantics.
Common Mistakes#
- Texting one checkout session URL to a hundred buyers. A session is one checkout for one buyer; the first payment puts it in a terminal state and everyone else finds a dead page. A shared URL is a payment link.
- Minting a payment link per customer to collect what they owe. A link is a template with no concept of a balance: nothing tracks what is outstanding, reminds anyone, or records the check that arrives. Customer-owes-money is an invoice.
- Using invoices for recurring billing. An invoice bills one balance once. Recurring revenue belongs on subscription plans collected through a link or session.
- Collecting an invoiced order through a generic checkout session. Use
POST /v1/invoices/{invoice_id}/checkout-sessionso the payment lands on the invoice, not just the order underneath it. - Fulfilling from the redirect. Buyers close tabs, and success URLs can be opened by anyone. The durable signals are
order.payment_succeeded,checkout_session.completed, and theinvoice.*events.
Related Docs#
- Payment Links: the full guide to reusable hosted checkout.
- Checkout Sessions: one hosted checkout for one buyer, end to end.
- Invoicing: drafts, sending, reminders, manual payments, and PDFs.
- Why Flint Is Orders-First: the shared engine all three surfaces feed.
- Subscription signup links: the recurring-billing flavor of links.
- Webhooks: durable payment notifications for fulfillment.
- Payment Links API, Checkout Sessions API, Invoices API: every field on every endpoint.
