Payment links
A payment link is a reusable hosted checkout page with a stable URL. You create it once, put the URL anywhere (an email, a QR code, an ad, a creator bio, a text message), and every buyer who opens it gets their own checkout. The link never gets used up: one URL can take payment from one buyer or ten thousand.
Share a payment link to collect payment through hosted checkout without frontend code, per-buyer API calls, or session management. If a site with no backend used Stripe's redirectToCheckout, Stripe redirectToCheckout removed covers why it fails on Stripe.js Clover and later and how a link replaces it.
If the question is "what URL do I put in this email, ad, QR code, or pricing page?", the answer is a payment link. If your app needs a hosted checkout for one specific order right now, use a checkout session instead. Unsure? See Payment links vs checkout sessions vs invoices.
Payment links are the wrong tool when a specific customer owes a specific amount (that is an invoice, with reminders, PDFs, and receivables tracking) or when your app already owns the order state and wants to control each checkout attempt (that is a checkout session).
How a Link becomes a payment#
A payment link is a template, not a transaction. Flint materializes the transaction objects when each buyer shows up:
payment link (reusable template, active until you deactivate it)
└─ buyer opens the URL
└─ checkout session (one per visit, carries payment_link_id)
└─ order (one per resolved checkout, origin: "payment_link")
Each payment link has these behaviors:
- One link produces many orders. There is no single permanent order behind a link. Each resolved checkout creates its own order before payment, so fulfillment and reconciliation happen per order, not per link.
- Everything is traceable back to the link. Each checkout session records the
payment_link_idit came from, each order is created withorigin: "payment_link", and the link'smetadatais copied onto every order it produces. - Edits affect future checkouts only. Each resolved checkout is an immutable snapshot of the link at that visit. Changing or deactivating the link affects later resolves, but it does not rewrite or revoke an already issued checkout. Live capacity remains shared and is checked again when payment starts.
A link is active or inactive. It is created active and stays usable until you deactivate it, it hits max_completions, or (for events) it sells out.
Create a payment Link#
name is the only required field. This example sells one product at a fixed price and lets buyers pick a quantity:
curl -X POST https://api.withflintpay.com/v1/payment-links \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: payment-link-spring-tee-001" \
-d '{
"name": "Spring drop: Limited Tee",
"description": "Limited release t-shirt",
"line_items": [{
"name": "Limited Tee",
"quantity": 1,
"unit_price_money": {"amount": 3500, "currency": "USD"},
"allow_quantity_adjustment": true,
"min_quantity": 1,
"max_quantity": 5
}],
"customer_collection": {"require_email": true},
"redirects": {
"success_redirect_url": "https://example.com/thanks",
"cancel_redirect_url": "https://example.com/shop"
},
"metadata": {"campaign": "spring_drop"}
}'
{
"data": {
"payment_link_id": "pl_1kmn0aExample",
"url": "https://checkout.withflintpay.com/pay/pl_1kmn0aExample",
"status": "active",
"payment_link_type": "standard",
"completed_count": 0,
"line_items": [{
"payment_link_line_item_id": "plli_1kmn0aExample",
"key": "limited-tee",
"name": "Limited Tee",
"quantity": 1,
"unit_price_money": {"amount": 3500, "currency": "USD"},
"allow_quantity_adjustment": true
}]
}
}
The link is live the moment it is returned. Save data.payment_link_id for later management calls and share data.url as-is.
Amounts are integers in the currency's minor unit, so 3500 is $35.00. The Idempotency-Key header is optional but worth the habit: retrying a create with the same key returns the original link instead of minting a duplicate. See Idempotency.
Pricing line items#
Each line item is priced one of three ways:
- Ad hoc: pass
nameandunit_price_moneydirectly, like the example above. Nothing else needs to exist first. - From your catalog: pass
variant_id(a product variant) orbundle_id(a bundle) instead. Name and price come from the catalog object, so you cannot also passnameorunit_price_moneyon that line item. Catalog setup creates variants and bundles to sell. - Buyer-chosen: set
allow_unit_price_adjustment: trueand the buyer picks the price. Constrain it withmin_unit_price_moneyandmax_unit_price_money, and offer preset buttons withsuggested_unit_price_money_options. This is the pay-what-you-want building block. (For a whole page dedicated to a flexible amount, use a donation link instead.)
Quantity works the same across all three: quantity is what the buyer gets by default, and allow_quantity_adjustment with min_quantity and max_quantity opens it up. min_quantity must be at least 1, so on a standard link a buyer cannot lower a line item to zero. Only an event link lets a buyer leave a ticket tier at zero.
Every line item gets a key, either the one you pass or one generated from its name ("Limited Tee" becomes limited-tee). Keys identify line items when you launch checkout programmatically and are stamped on each resulting order line item's metadata as payment_link_line_item_key, so you can always tell which link line item a sale came from.
Share the URL#
Use data.url exactly as returned. The same URL works everywhere a buyer can tap or scan:
- buy buttons and pricing pages
- email and SMS campaigns
- QR codes on packaging, receipts, flyers, and event posters
- social bios and creator pages
- support conversations, as a "pay here" shortcut
The URL stays stable for the life of the link. If you need to change the offer in a way buyers should never see mixed (a price change mid-campaign, for example), create a new link and deactivate the old one instead of editing in place; the old URL then shows your inactive_message rather than the new price.
Link types#
payment_link_type selects the checkout experience: standard (the default, shown above), donation, or event. A fourth flavor, subscription signup, is selected by passing subscription_plan_id instead of line items.
Donation links#
A donation link presents a single amount picker instead of a product list. Configure it with the top-level donation fields, not line_items:
curl -X POST https://api.withflintpay.com/v1/payment-links \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: payment-link-annual-fund-001" \
-d '{
"name": "Annual Fund",
"payment_link_type": "donation",
"donation_suggested_amount_money_options": [
{"amount": 2500, "currency": "USD"},
{"amount": 10000, "currency": "USD"},
{"amount": 50000, "currency": "USD"}
],
"donation_min_amount_money": {"amount": 500, "currency": "USD"},
"metadata": {"fund": "annual_2026"}
}'
Donation links come tuned for giving out of the box:
- Flint creates the single donation line item for you; its buyer-facing label is the link's
name. - Tips, taxes, and promotion codes are disabled. None of them make sense on a donation, so the page never shows them.
- If you do not set a
success_redirect_url, donors land on a built-in thank-you page instead of an error-prone dead end.
Event links#
An event link sells ticket tiers with a shared capacity, and the checkout page presents them as an event: date, venue, and per-tier quantity selectors.
curl -X POST https://api.withflintpay.com/v1/payment-links \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: payment-link-spring-gala-001" \
-d '{
"name": "Spring Gala",
"payment_link_type": "event",
"event_config": {
"event_at": "2026-05-12T19:00:00Z",
"timezone": "America/New_York",
"venue": "Pier 57, New York",
"max_total_quantity": 500,
"ticket_prefix": "GAL",
"send_ticket_emails": true
},
"line_items": [
{
"name": "General Admission",
"quantity": 1,
"unit_price_money": {"amount": 7500, "currency": "USD"}
},
{
"name": "VIP",
"quantity": 1,
"unit_price_money": {"amount": 20000, "currency": "USD"},
"max_quantity": 4
}
]
}'
Event behavior to know:
- Set the venue timezone.
timezoneis an IANA name such asAmerica/New_York. Flint uses it to showevent_atin venue time at checkout and in ticket emails. Older links without a timezone show an explicitly labeled fallback until you confirm the venue timezone. - Tiers are quantity-adjustable by default, with a maximum of 10 per tier unless you set
max_quantity. Buyers choose how many tickets they want in each tier, and can skip a tier entirely. - Capacity is enforced when payment starts.
max_total_quantitycaps tickets across all tiers. Opening checkout does not reserve tickets. The payment attempt acquires the durable capacity hold immediately before processor confirmation, so an older open checkout can be rejected if another buyer consumes the remaining capacity first. - Buyers get tickets, not just a receipt. After payment, buyers land on a ticket confirmation page (your
success_redirect_url, if set, becomes a "Continue" button there). Withsend_ticket_emails: true, tickets are emailed too, and email collection is automatically required.ticket_prefixbrands the ticket numbers, so"GAL"yields tickets likeGAL042. total_quantity_soldon the link tracks tickets sold across all tiers.
Subscription signup links#
Pass subscription_plan_id (a subscription plan) instead of line_items and the link becomes a hosted recurring-billing signup page. Every completed checkout creates a subscription for that buyer:
curl -X POST https://api.withflintpay.com/v1/payment-links \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: payment-link-pro-monthly-001" \
-d '{
"name": "Pro Plan Signup",
"subscription_plan_id": "plan_1kmn0aExample",
"customer_collection": {"require_email": true}
}'
This is the standard pattern for pricing pages and self-serve upgrades: one link per plan, shared everywhere that plan is sold. The full flow, including plan creation and subscription lifecycle webhooks, is in Subscription billing.
The buyer's email decides which customer owns the subscription. When the first payment succeeds, Flint attaches the subscription to your customer with that email, or creates one. When nothing is due at signup, such as a free trial without a setup fee, checkout saves the card without charging it, and Flint attaches the card and the subscription to that customer once the card is saved. A returning customer can start a trial with the email they already use with you.
Collect custom fields#
Custom fields add your own questions to the checkout page: t-shirt size, meal preference, an "anything we should know?" box. Four field types are supported: text, textarea, dropdown, and checkbox.
You can define up to 20 custom fields. Each key can contain up to 243 characters. Custom field keys, dropdown options, and line item keys must not start or end with whitespace.
Text answers default to a limit of 255 characters; textarea answers default to 1,000. Set max_length from 1 to 4,096 to choose a different limit. Lengths count Unicode code points, so an emoji can count as more than one character when it contains multiple code points. Each dropdown option can contain up to 4,096 characters.
Your metadata pairs plus custom fields must total 44 or fewer. Flint reserves six of the resulting order's 50 metadata pairs. Metadata keys and values, answer keys and the largest possible answers, and Flint's metadata must also fit within 32,768 UTF-8 bytes. Text answers count as up to four bytes per character for this check, including optional fields; dropdowns count their longest option in bytes. If the configuration exceeds this budget, lower max_length on some fields, use fewer custom fields, or reduce metadata. These limits apply when you create or update standard and plan links.
curl -X POST https://api.withflintpay.com/v1/payment-links \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: payment-link-workshop-001" \
-d '{
"name": "Letterpress Workshop",
"line_items": [{
"name": "Workshop Seat",
"quantity": 1,
"unit_price_money": {"amount": 12000, "currency": "USD"}
}],
"custom_fields": [
{
"key": "experience_level",
"label": "Experience level",
"custom_field_type": "dropdown",
"required": true,
"options": ["Beginner", "Intermediate", "Advanced"]
},
{
"key": "accessibility_needs",
"label": "Accessibility needs",
"custom_field_type": "textarea",
"placeholder": "Optional",
"max_length": 500
}
]
}'
Buyer answers are recorded on the resulting order's metadata, keyed as custom_field_<key>. The workshop order above would carry "custom_field_experience_level": "Intermediate", so the answers travel with the order into your fulfillment flow and stay queryable later.
After creation, replace custom_fields through PATCH /v1/payment-links/{id}. Include each retained field's payment_link_custom_field_id, omit the ID for a new field, and leave an existing field out to remove it. Send the current link version as expected_version; the replacement and the rest of the patch commit atomically.
Customize the checkout page#
Everything on the hosted page is configurable at create time and editable later. Details for each object are in the API reference.
| Area | Fields | What it controls |
|---|---|---|
| Branding | theme.title, theme.primary_color, theme.accent_color, image | Page title, brand colors, and a product or campaign image. A color the link leaves unset comes from your checkout branding settings |
| Copy | description, custom_text.order_summary_message | Text shown alongside the offer and in the order summary |
| Payment methods | payments.enabled_payment_options, payments.payment_note | Which payment options the page offers (for example card, apple_pay, google_pay, link) and a note shown near the pay button |
| Buyer info | customer_collection.require_email, customer_collection.enable_address_autocomplete | Whether email is required and whether address entry autocompletes |
| Tips | tip.enabled plus percentage or smart-tip options | Tipping prompts on the payment page |
| Tax | tax.enabled | Whether tax is calculated and shown on the order |
| Promotions | promotion_config.automatic_enabled, promotion_config.codes_enabled | Automatic promotions and the payment-link override for hosted promotion-code entry |
| Legal | legal.terms_of_service_url, legal.require_terms_of_service, legal.refund_policy_url, legal.shipping_policy_url | Policy links and an "agree to terms" requirement |
| Redirects | redirects.success_redirect_url, redirects.cancel_redirect_url | Where buyers go after paying or backing out |
| Correlation | metadata, payments.external_reference_id | Your own tags; copied onto every order the link creates, and carried on each checkout session for cross-system correlation |
Buyer data collection on payment links is intentionally narrow: require_email and enable_address_autocomplete are the only knobs. A payment link is a broadly shareable page for buyers you have not met yet. When you need per-buyer prefill, a known customer_id, or deep collection requirements, create a checkout session instead.
For promotion codes, omit promotion_config.codes_enabled to inherit checkout.promotion_code_entry_enabled from merchant settings. Send true to show promotion-code entry for this link, or false to hide it for this link. On PATCH, send "promotion_config": {"codes_enabled": null} to clear the stored override and return to inheriting the merchant default. promotions.codes_enabled remains the merchant redemption policy; explicit false rejects promotion-code application even if a link asks to show code entry.
Control availability#
A link keeps selling until you say otherwise. You have three levers:
Deactivate and reactivate. Set the link's status without breaking the URL. Use the current version as the write fence:
curl -X PATCH https://api.withflintpay.com/v1/payment-links/pl_1kmn0aExample \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"status":"inactive"}'
Buyers who open an inactive link see your inactive_message ("The spring drop has ended. Follow us for the next one.") instead of a generic error, so set one on any link you plan to retire. PATCH status to active to turn it back on.
Cap completions. max_completions stops new checkouts once the cap is reached, counting both completed checkouts and ones currently in flight, so a burst of simultaneous buyers cannot blow past the limit. Use it for limited drops ("first 100 customers") or capped registrations.
Expire each visit. expiration.expires_in_seconds bounds how long any single buyer's checkout stays open after they start it, and expiration.expiration_url is where they land if it lapses. This limits how long a quoted configuration can linger, but note it is per visit; it does not schedule the link itself to turn off. To end a campaign at a fixed time, deactivate the link.
When a payment-link checkout expires unpaid, its order closes with closed_reason: "Checkout expired before payment" and emits order.closed if nothing was paid or authorized, no invoice or other active collection owns it, and it is not linked to a return. Flint releases pending discounts and inventory reservations and clears the unpaid delivery destination. Resolving the link again creates a new checkout and order.
Update a live Link#
Template fields (name, description, theme, payments, redirects, metadata, caps, and so on) update with a PATCH:
curl -X PATCH https://api.withflintpay.com/v1/payment-links/pl_1kmn0aExample \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"expected_version": 3,
"description": "Final week",
"max_completions": 250
}'
Line items and custom fields are replaced through the parent PATCH /v1/payment-links/{id} operation. Send the complete desired collection and the current link version as expected_version. Include an existing child ID to retain it, omit the ID for a new child, and leave an existing child out to remove it. Both collections and the rest of the patch commit atomically.
A payment link is a public surface. An update takes effect for every checkout resolved after the update commits. Buyers who already resolved checkout keep the earlier line items, prices, and configuration, even after the link is changed or deactivated. For a clean campaign boundary, create a new link and deactivate the old one before distributing the replacement. Deactivation stops new resolves; it is not bulk revocation of checkout sessions that were already issued.
Fulfill and reconcile#
The buyer seeing a success screen is UX, not truth. Fulfill from webhooks or a backend fetch, never from the redirect alone: a buyer can close the tab before redirecting, and a redirect URL can be opened by anyone.
Register a webhook endpoint for the two events that matter for links, plus the subscription events if you sell plans:
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-payment-links-001" \
-d '{
"url": "https://example.com/webhooks/flint",
"enabled_events": [
"checkout_session.completed",
"order.paid"
],
"description": "Payment link fulfillment"
}'
Store data.secret immediately and verify signatures as described in the Webhooks guide.
order.paidis the money signal: the order is paid, fulfill it. Fetch the order by the payload'sorder_idto readorigin: "payment_link", the link'smetadata, custom field answers undercustom_field_<key>, and each line item'spayment_link_line_item_key.checkout_session.completedfires per finished checkout withcheckout_session_id,order_id, andpayment_intent_id. Fetch the session to read itspayment_link_id, or reconcile withGET /v1/checkout-sessions?payment_link_id=....
For pull-based reconciliation and reporting, everything is queryable:
GET /v1/checkout-sessions?payment_link_id=pl_...lists every checkout a link generated.GET /v1/orders?origin=payment_linklists all link-driven orders; combine with your metadata tags (campaign,channel) to split by campaign.completed_counton the link (andtotal_quantity_soldfor events) gives you at-a-glance conversion totals.
Because the link's metadata lands on every order it creates, tagging links at creation time is the cheapest reporting investment you can make: one "campaign": "spring_drop" on the link means every downstream order, payment, and payout reconciles by campaign for free.
Launch checkout programmatically#
Opening the hosted URL is one way to start checkout. A buyer-facing client can instead read GET /v1/payment-links/{id}/public, then call POST /v1/payment-links/{id}/resolve. The read returns the sanitized link plus a private resolution_context and its exact expiry. It also returns checkout_theme, the theme the resolved checkout uses: your checkout branding settings with the link's own theme over them. Style a page that leads to checkout with it, so the two match. Resolve atomically creates the backing order and checkout, then returns a ready-to-open hosted checkout URL. Neither endpoint requires an API key.
Resolve is where the link's buyer-adjustable knobs get set from code: preselect quantities, fix a pay-what-you-want amount, and prefill custom field answers.
curl https://api.withflintpay.com/v1/payment-links/pl_1kmn0aExample/public
Keep data.resolution_context private. Do not put it in a URL, log, analytics event, or shared cache. Generate a strong idempotency key for this browser operation, then send both values:
curl -X POST https://api.withflintpay.com/v1/payment-links/pl_1kmn0aExample/resolve \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 04e2e86d-9f39-4dce-9e54-54bc798584ed" \
-d '{
"resolution_context": "plrc_v1.example_signed_resolution_context",
"quantity_overrides": {"limited-tee": 2},
"custom_field_values": {"experience_level": "Beginner"}
}'
{
"data": {
"checkout_session": {
"checkout_session_id": "cs_1kmn0aExample",
"status": "open",
"order_id": "ord_1kmn0aExample",
"payment_link_id": "pl_1kmn0aExample",
"url": "https://checkout.withflintpay.com/checkout/cs_1kmn0aExample#checkout_token=..."
},
"checkout_access": {
"checkout_auth_token": "ckat_v1..."
}
}
}
Send the buyer to checkout_session.url exactly as returned; the #checkout_token fragment is what authenticates them, so never strip or rebuild the URL.
For an uncertain transport failure, retry with the exact same request body, resolution_context, and Idempotency-Key. Stop retaining the pair at resolution_context_expires_at. PAYMENT_LINK_CHANGED_RETRY means no publication committed, so read the public link again and start a new operation with the newly issued context and a new key.
Override maps are keyed by line item key. quantity_overrides must respect the link's quantity rules, and unit_price_overrides only applies to line items with allow_unit_price_adjustment. For donation links, the buyer-chosen amount is required and its key is always donation:
curl -X POST https://api.withflintpay.com/v1/payment-links/pl_donationExample/resolve \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 819797f7-893f-4860-9c8d-08135ec5cc7d" \
-d '{
"resolution_context": "plrc_v1.example_signed_resolution_context",
"unit_price_overrides": {"donation": {"amount": 2500, "currency": "USD"}}
}'
Line items backed by catalog variants can also carry modifier selections via modifiers; see the API reference for the shape.
Each resolve call creates a real checkout session (and, except for subscription links, its backing order). Call it when a buyer is heading to checkout, not to preview pricing.
Test a Link end to end#
Create a link with your test key (flint_test_...), open data.url in a browser, and pay with the standard test card:
If your sandbox can't take card payments yet, the hosted page shows "no payment methods available" instead of a card form. Check GET /v1/capabilities?capability=accept_card_payments; once it reports ready, reload the page.
Then confirm the full loop the way production will run it: your webhook endpoint received order.paid, and GET /v1/orders?origin=payment_link shows the paid order carrying your link metadata. Declines, 3D Secure cards, and other scenarios are in the Testing guide.
Errors you will encounter#
All errors use the standard error envelope. The link-specific codes:
Related docs#
- Payment Links API Reference: every field on every endpoint.
- Payment links vs checkout sessions vs invoices: pick the right primitive.
- Subscription billing: plan creation, hosted signup, and the full recurring-billing lifecycle.
- Checkout sessions: one hosted checkout for a specific order.
- Webhooks: signature verification and delivery semantics.
- Why Flint is orders-first: how orders anchor payments, refunds, and reporting.
