Skip to content

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#

A one-time sale on Flint-hosted checkoutResponse
Your serverFlintBuyerPOST /v1/orders with a catalog variant and your user IDPOST /v1/checkout-sessions for the orderthe session and its urlredirect to urlpays on hosted checkoutorder.paid webhookGET /v1/orders/{order_id}, then grant accessrefund or dispute events, laterread the refunds or the dispute, then revoke or restore
  1. Your server sends POST /v1/orders with a catalog variant and your user ID to Flint
  2. Your server sends POST /v1/checkout-sessions for the order to Flint
  3. Flint returns the session and its url to Your server
  4. Your server sends redirect to url to Buyer
  5. Buyer sends pays on hosted checkout to Flint
  6. Flint sends order.paid webhook to Your server
  7. Your server sends GET /v1/orders/{order_id}, then grant access to Flint
  8. Flint sends refund or dispute events, later to Your server
  9. 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
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
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
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 carry order_id. For each line, subtract the quantity in those refunds' line_item_allocations from the line's quantity, and grant what is left. Don't use the order line's refunded_quantity for this: it moves when the card network accepts a refund, before the refund succeeds, and moves back if it fails. An amount refund 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's amount_money instead.
  • Disputes. On any dispute event, read the dispute with GET /v1/disputes/{dispute_id} and act on its status. Disputes carry order_id.
Dispute statusAccess
inquiry_needs_response, inquiry_under_review, inquiry_closedNo change. An inquiry isn't a chargeback.
needs_response, under_reviewSuspend while the chargeback is open.
otherSuspend. Flint treats an unrecognized state as open, and you read it again on the next event.
lostRevoke.
won, preventedRestore.

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#

ValueFromWhy
order_idThe order you create, or the order.paid eventThe key for every grant, refund, and support lookup
order_line_item_id and variant_id for each lineThe orderWhich product a grant came from, so you can revoke by line
Your user IDYou set it in the order's metadataMatches a paid order to an account
webhook_event_id of each processed eventEach deliverySkips repeats
The order version you last appliedThe orderSkips older snapshots

Deadlines#

What runs outHow longFieldWhen it passesWhat to do
Checkout session24 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.
Dispute evidenceUntil 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 delivery10 seconds per attempt.NoneRequest: 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 retriesUp 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 key24 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.NoneRequest: 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#

  1. 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.
  2. Register a live webhook endpoint with your live key. The endpoint you tested with receives sandbox events only. See Register your production webhook endpoint.
  3. 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.
  4. 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#

  1. Send an Idempotency-Key on every create, and reuse it when you retry. A retry without one can create a second order. See Send a key.
  2. Redirect to url exactly as returned, #checkout_token included, and keep it out of logs and analytics. The fragment is what opens the session for that buyer. See Send the buyer to checkout.
  3. Grant on order.paid, never on the redirect to success_url. A buyer can close the tab first, and anyone can open the URL. See Choosing the right event.
  4. Process each webhook_event_id once. The same event can arrive more than once. See How delivery works.
  5. Read the order before an irreversible step, and skip snapshots older than the version you applied. Deliveries arrive out of order. See Read statuses from webhooks.
  6. Read totals from pricing_amounts instead of computing them. Your own math can be off by a cent. See Computed order amounts.
  7. 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#

  1. Watch refund_status, not payment_status. A fully refunded order still reads payment_status: paid. See Orders.
  2. Revoke access yourself. A refund never cancels a fulfillment or restocks anything on its own. See When a payment stops shipping.
  3. Act on a refund's current status. A succeeded refund can still fail when the buyer's bank rejects the credit. See Track the refund to completion.
  4. Don't refund a chargeback. While it is open or lost, the refund fails with 409 ACTION_NOT_ALLOWED. See Decide what to do.
  5. 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.

  1. Create the product, an order, and a session with your test key, and pay with a test card. Check that order.paid reached your endpoint and the user got access. Test webhook deliveries covers receiving events on your machine.
  2. Refund the order in full and check that access is gone once the refund succeeds.
  3. Pay a new order with the dispute test card in Simulate a dispute outcome. Check that access is suspended, then simulate a won ruling and check that it comes back.

Going further#

Accept a payment with Next.jsThe checkout route and the order.paid handler in TypeScript.Payment linksOne URL for every buyer, with quantities, variants, and questions.Catalog setupProducts with sizes, modifiers, and bundles.Add shipping to a checkoutDelivery options and shipping rates for goods.RefundsFull, partial, and line-item refunds, and what each changes.Disputes and chargebacksA response queue, evidence, and test outcomes.Payments in mobile appsWhen an iOS or Android app can use Flint, and when it needs in-app purchase.

Ready to sell? Create a Flint account and take your first test payment.

Was this helpful?