Sell with hosted checkout
Sell a catalog product once through Flint's hosted checkout or a payment link, grant what the buyer paid for from a webhook, and take it back after a refund or chargeback. It suits a course, a template pack, a download, a booked service, or goods, sold once rather than on a plan.
What you build#
- Your server sends POST /v1/orders with a catalog variant and your user ID to Flint
- Your server sends POST /v1/checkout-sessions for the order to Flint
- Flint returns the session and its url to Your server
- Your server sends redirect to url to Buyer
- Buyer sends pays on hosted checkout to Flint
- Flint sends order.paid webhook to Your server
- Your server sends GET /v1/orders/{order_id}, then grant access to Flint
- Flint sends refund or dispute events, later to Your server
- Your server sends read the refunds or the dispute, then revoke or restore to Flint
Steps#
1. Create the product once#
Create a catalog product with product_type: "digital" for a download or course, or "service" for work you do, and give it one priced variant. Its default_variant_id is what you sell. Use quick_pay only for a one-off amount with no product behind it: its item holds only a name, an amount, and tax, so the order doesn't record what was bought. Create a product with one price covers the fields.
curl -X POST https://api.withflintpay.com/v1/products \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: product-course-intro" \
-H "Content-Type: application/json" \
-d '{
"variant_mode": "simple",
"name": "Intro to ceramics course",
"product_type": "digital",
"default_variant": {
"sku": "COURSE-CERAMICS-101",
"unit_price_money": { "amount": 4900, "currency": "USD" }
}
}'
2. Choose how buyers reach checkout#
Use a checkout session when your app knows which of its users is buying: your server creates the order, puts your user ID in its metadata, and sends that buyer to the session. Use a payment link when one URL serves everyone, such as a buy button on a public page. A link's metadata is copied to every order it creates, so it can't carry a buyer's ID: require the buyer's email with buyer_fields.require_email and match each paid order to an account by its buyer_contact.email. Payment links vs checkout sessions vs invoices compares the options.
3. Create the order and send the buyer to checkout#
Create an order with a variant line. A catalog line takes its name, price, and tax from the catalog.
curl -X POST https://api.withflintpay.com/v1/orders \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: order-user-4821-course-intro" \
-H "Content-Type: application/json" \
-d '{
"line_items": [
{ "type": "variant", "variant_id": "var_1kmn0aExample", "quantity": 1 }
],
"metadata": { "user_id": "user_4821" }
}'
Then create a checkout session for it and redirect the buyer to its url exactly as returned. Send the buyer to checkout explains why the #checkout_token fragment must stay.
curl -X POST https://api.withflintpay.com/v1/checkout-sessions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: checkout-user-4821-course-intro" \
-H "Content-Type: application/json" \
-d '{
"type": "order",
"order_id": "ord_1kmn0aExample",
"redirects": {
"success_url": "https://example.com/thanks",
"cancel_url": "https://example.com/courses"
}
}'
4. Grant access on order.paid#
Verify the delivery, skip a webhook_event_id you already processed, read the order, check that payment_status is paid, and grant each line to the user in metadata. Accept a payment with Next.js has the handler code, and Deduplicate and process asynchronously shows the job that does the work.
For orders also paid through manual capture, a final capture can settle the order below its original price. settlement_amounts.released_money records the discharged debt; use paid_money and net_collected_money for actual collections. Hosted checkout itself uses automatic capture.
If the buyer closes checkout while a payment is processing, Flint continues recording its outcome. Keep waiting for order.paid before granting access. Closing checkout does not prove that the payment failed.
5. Deliver what was bought#
Flint doesn't host downloads or issue license keys: your server serves the file, generates the key, or opens the course after order.paid. A digital or service catalog line needs no fulfillment, so the order's fulfillment_status is not_applicable. For goods you ship or hand over, follow Add shipping to a checkout and Fulfillment.
6. Revoke after a refund or dispute#
Refunds and chargebacks don't remove anything you granted. Take access back yourself, by the rules in Revoke access after a refund or dispute.
Events to handle#
Subscribe your endpoint to these events. Choosing the right event explains why order.paid, and not a session or payment event, means the sale is paid.
Never act on refund.created. A pending refund can still fail.
Revoke access after a refund or dispute#
Events repeat and arrive in any order. Each rule below reads current state and recomputes from it, so the order they arrive in doesn't matter.
- Refunds. On any refund or order refund event, list the order's refunds with
GET /v1/refunds?order_id=...&status=succeeded,partially_succeeded. Refunds carryorder_id. For each line, subtract thequantityin those refunds'line_item_allocationsfrom the line'squantity, and grant what is left. Don't use the order line'srefunded_quantityfor this: it moves when the card network accepts a refund, before the refund succeeds, and moves back if it fails. Anamountrefund counts a unit only once the line's refunded money reaches a whole unit's price, so a small goodwill refund revokes nothing. If your policy differs, decide from the refund'samount_moneyinstead. - Disputes. On any dispute event, read the dispute with
GET /v1/disputes/{dispute_id}and act on itsstatus. Disputes carryorder_id.
Dispute status | Access |
|---|---|
inquiry_needs_response, inquiry_under_review, inquiry_closed | No change. An inquiry isn't a chargeback. |
needs_response, under_review | Suspend while the chargeback is open. |
other | Suspend. Flint treats an unrecognized state as open, and you read it again on the next event. |
lost | Revoke. |
won, prevented | Restore. |
Restore means returning to what the refund rule leaves, not the full quantity: compute access from the refund-adjusted quantity, then apply any dispute hold on top. Statuses describes each value, and Refund events the refund outcomes.
What to store#
| Value | From | Why |
|---|---|---|
order_id | The order you create, or the order.paid event | The key for every grant, refund, and support lookup |
order_line_item_id and variant_id for each line | The order | Which product a grant came from, so you can revoke by line |
| Your user ID | You set it in the order's metadata | Matches a paid order to an account |
webhook_event_id of each processed event | Each delivery | Skips repeats |
The order version you last applied | The order | Skips older snapshots |
Deadlines#
| What runs out | How long | Field | When it passes | What to do |
|---|---|---|---|---|
| Checkout session | 24 hours after creation by default. Set it per session with expires_in_seconds, or for every session with settings.checkout.default_expires_in_seconds. | CheckoutSession.expires_at | Status changes: The session becomes expired and Flint sends checkout_session.expired. A payment already in progress, such as a 3D Secure screen, can still finish (see payment recovery). | Read expires_at instead of adding a duration to the creation time. Create a new session for a buyer who comes back late. Affirm needs at least 15 minutes left in an embedded session's payment window; inside the last 15 minutes the payment fails with PAYMENT_OPTION_UNAVAILABLE and reason action_window_too_short, so offer card. |
| Payment link sales | Until sales_end_at when you set one. An event link without it stops selling at event_at. | PaymentLink.sales_end_atPublicPaymentLink.sales_end_at | Computed at read: The link stops starting checkouts, and buyers see "Sales have ended" or your inactive_message. Flint sends no event. A buyer who opened checkout before the end has 15 minutes after it to start paying. | To keep selling, send a later sales_end_at, or null, in a PATCH to the link. |
| Dispute evidence | Until evidence_due_at. There is none when the dispute has no response window, such as a bank return. | Dispute.evidence_due_at | Act by: Once evidence_due_at has passed, reads show is_evidence_deadline_passed: true, is_action_required: false, and no accept in supported_actions. Webhook payloads keep the values from when Flint recorded the change. Flint sends no event. | Respond or accept before evidence_due_at. Subscribe to dispute.needs_response to learn when action is required. |
| Answering a webhook delivery | 10 seconds per attempt. | None | Request: The attempt fails as webhook_timeout, and Flint retries on its schedule. | Return a 2xx within 10 seconds and do the real work in a background job. |
| Webhook delivery retries | Up to 9 automatic attempts over about 3 days. The ninth lands 71 to 86 hours after the first. | WebhookDelivery.next_retry_at | Request: Flint makes the next attempt. After the ninth failed attempt the delivery is failed, and you can resend it. | Deduplicate deliveries before you process them, and resend failed ones after you fix the endpoint. |
| Idempotency key | 24 hours from the original request on routes where the key is optional. On routes that require it, as long as the command's effects exist, except POST /v1/customer-sessions/refresh, which holds it for 24 hours. | None | Request: On an optional route, the same key runs as a new request. A required route returns the original result however late the retry arrives. | Keep automated retries well inside 24 hours, and never reuse an old key for a new action. |
What you must handle#
Before launch#
- Keep the secret key on your server, and give each key only the scopes it needs. Flint has no publishable key, so a key in browser or app code is a leaked key. See Keep keys on the server.
- Register a live webhook endpoint with your live key. The endpoint you tested with receives sandbox events only. See Register your production webhook endpoint.
- Verify every delivery against the raw request body before acting on it. Anyone who finds your URL can send it requests. See Verify the signature.
- Sell catalog variants, not
quick_pay. A quick pay line holds only a name and an amount, so the order can't tell you what was bought. See Quick pay.
Each sale#
- Send an
Idempotency-Keyon every create, and reuse it when you retry. A retry without one can create a second order. See Send a key. - Redirect to
urlexactly as returned,#checkout_tokenincluded, and keep it out of logs and analytics. The fragment is what opens the session for that buyer. See Send the buyer to checkout. - Grant on
order.paid, never on the redirect tosuccess_url. A buyer can close the tab first, and anyone can open the URL. See Choosing the right event. - Process each
webhook_event_idonce. The same event can arrive more than once. See How delivery works. - Read the order before an irreversible step, and skip snapshots older than the
versionyou applied. Deliveries arrive out of order. See Read statuses from webhooks. - Read totals from
pricing_amountsinstead of computing them. Your own math can be off by a cent. See Computed order amounts. - Create a new session for a buyer who comes back later. A session expires 24 hours after creation by default. See Expiration.
After the sale#
- Watch
refund_status, notpayment_status. A fully refunded order still readspayment_status: paid. See Orders. - Revoke access yourself. A refund never cancels a fulfillment or restocks anything on its own. See When a payment stops shipping.
- Act on a refund's current
status. Asucceededrefund can still fail when the buyer's bank rejects the credit. See Track the refund to completion. - Don't refund a chargeback. While it is open or lost, the refund fails with
409 ACTION_NOT_ALLOWED. See Decide what to do. - Answer a chargeback before
evidence_due_at, with your own earlier cutoff. No event fires when it passes. See Build the response queue.
Test it#
Run the whole sale in your sandbox. No account yet? Sign up and you get one.
- Create the product, an order, and a session with your test key, and pay with a test card. Check that
order.paidreached your endpoint and the user got access. Test webhook deliveries covers receiving events on your machine. - Refund the order in full and check that access is gone once the refund succeeds.
- Pay a new order with the dispute test card in Simulate a dispute outcome. Check that access is suspended, then simulate a
wonruling and check that it comes back.
Going further#
Accept a payment with Next.jsPayment linksCatalog setupAdd shipping to a checkoutRefundsDisputes and chargebacksPayments in mobile appsReady to sell? Create a Flint account and take your first test payment.
