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.
Payment links are the fastest way to sell with Flint because there is nothing to build. No frontend code, no per-buyer API calls, no session management. If you can share a URL, you can take payment.
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 cart 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 completed checkout, source: "payment_link")
Three consequences worth internalizing:
- One link produces many orders. There is no single permanent order behind a link. Each completed checkout creates its own order, 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 withsource: "payment_link", and the link'smetadatais copied onto every order it produces. - Configuration is snapshotted per visit. A buyer who is mid-checkout keeps the configuration their session started with. Edits to the link apply to future visits.
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. - 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. A min_quantity of 0 makes the line item optional, which is how you attach an opt-in add-on to a main product.
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 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 coupons 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",
"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:
- 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, not advisory.
max_total_quantitycaps tickets across all tiers. When a buyer starts checkout, their requested tickets are held so a sold-out event cannot oversell during a rush; a hold is released if the buyer walks away (held sessions expire after at most 15 minutes). Devices that repeatedly open checkouts without paying get aTOO_MANY_HOLDSerror instead of tying up inventory. - 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 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",
"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.
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.
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, manage fields with the dedicated sub-resource endpoints: POST /v1/payment-links/{id}/custom-fields to add, and PATCH or DELETE on /custom-fields/{payment_link_custom_field_id} to change or remove one. Use position to control display order.
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_url | Page title, brand colors, and a product or campaign image |
| 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 |
| Coupons | coupon.enabled | Whether buyers can enter a coupon code at checkout |
| 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.
Control Availability#
A link keeps selling until you say otherwise. You have three levers:
Deactivate and reactivate. Turn a link off and on without breaking the URL:
curl -X POST https://api.withflintpay.com/v1/payment-links/pl_1kmn0aExample/deactivate \
-H "Authorization: Bearer YOUR_API_KEY"
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. POST /activate turns 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.
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 '{
"description": "Final week",
"max_completions": 250
}'
Line items and custom fields are collections with their own endpoints: POST /v1/payment-links/{id}/line-items adds items, and PATCH or DELETE on /line-items/{payment_link_line_item_id} edits or removes one. Custom fields work the same way under /custom-fields.
A payment link is a public surface. An update takes effect for every future visit immediately, wherever the URL has already spread. Buyers mid-checkout keep the configuration they started with. For changes buyers should never see mixed, such as a price increase during an active campaign, create a new link and deactivate the old one instead.
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.payment_succeeded"
],
"description": "Payment link fulfillment"
}'
Store data.secret immediately and verify signatures as described in the Webhooks guide.
order.payment_succeededis the money signal: the order is paid, fulfill it. The order carriessource: "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 and carries the session'spayment_link_id, which tells you exactly which link converted.
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?source=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. The other is POST /v1/payment-links/{id}/resolve, which creates a checkout session from the link and returns the session plus a ready-to-open hosted checkout URL. This is the endpoint the hosted page itself uses, and it requires no API key, so your own buyer-facing surface (a product page, a kiosk, an agent) can call it directly.
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 -X POST https://api.withflintpay.com/v1/payment-links/pl_1kmn0aExample/resolve \
-H "Content-Type: application/json" \
-d '{
"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"
},
"hosted_checkout": {
"url": "https://checkout.withflintpay.com/checkout/cs_1kmn0aExample#checkout_token=..."
}
}
}
Send the buyer to hosted_checkout.url exactly as returned; the #checkout_token fragment is what authenticates them, so never strip or rebuild the URL.
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" \
-d '{
"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 actually 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:
- Card number:
4242 4242 4242 4242 - Expiration: any future date
- CVC: any 3 digits
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.payment_succeeded, and GET /v1/orders?source=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, all returned from resolve or the hosted page:
| Status | Code | Meaning |
|---|---|---|
404 | NOT_FOUND | No payment link with that ID (check test vs live mode: links are mode-scoped like everything else) |
409 | PAYMENT_LINK_INACTIVE | The link is deactivated; buyers see the inactive_message |
409 | PAYMENT_LINK_MAX_COMPLETIONS_REACHED | max_completions reached, counting in-flight checkouts |
400 | DONATION_AMOUNT_REQUIRED | Resolve call on a donation link without unit_price_overrides["donation"] |
400 | INVALID_REQUEST | Validation failure; error.details[].param pinpoints the field |
429 | TOO_MANY_HOLDS | Too many unpaid checkout starts from one device against a capacity-limited link; retry after a pause |
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.
