Subscription billing
Flint runs recurring billing for you: you define a plan, attach a customer and a saved payment method, and choose whether Flint or your integration supplies each billing date. Flint charges each cycle, retries failed payments, and tells you about every state change over webhooks. Every charge is a real order, so renewals get the same receipts, refunds, and reporting as any other Flint payment.
Model your pricing, sign a customer up, grant access from webhooks, and handle renewals and payment failures.
For hosted signup without frontend work, create a subscription plan and a payment link. Renewals, failed payments, pausing, and canceling work the same no matter how the customer signed up.
How subscription billing works#
plan (pricing) + customer + payment method -> subscription -> order + payment every cycle
Four objects carry the whole system:
| Object | What it is |
|---|---|
| Subscription plan | Reusable pricing: what you charge, how often, the trial, any setup fee or contract term, and for physical products the delivery methods subscribers can choose. One plan serves many subscribers. To sell subscriptions across your catalog instead of one fixed set of items, use a subscription offer. |
| Customer | Who is subscribed. Also holds the default payment method. |
| Payment method | A saved card that Flint can charge without the customer present. |
| Subscription | One customer on one plan or offer: current status, schedule owner, period dates, optional next billing date, and for physical products where each shipment goes. |
Each billing cycle, Flint creates an order from the subscription (its origin is subscription and it carries the subscription_id), charges the saved payment method, and emits webhooks for the outcome. With a flint schedule, Flint computes the next date. With an external schedule, your integration supplies it and Flint waits after each successful cycle until you supply the next one.
Prices are locked at signup. The subscription snapshots the plan's line items when it's created. Editing a plan later changes what new subscribers pay, never what existing subscribers pay. Shipping is the exception: on a plan that ships, each renewal quotes shipping again (see Ship physical products). A line added or swapped to another variant after signup takes that variant's price at the time of the change (see Change cadence, quantity, and items).
Subscription statuses#
A subscription is always in exactly one of six states. Key your access logic on them:
The six statuses, and what to do with access in each, are defined on the Subscriptions reference. In short: trialing and active mean provision, incomplete means wait for the first subscription.payment_succeeded, past_due means the card failed and Flint is retrying, paused means billing is suspended, and canceled means revoke.
There is no resurrection from canceled. To bring a customer back, create a new subscription.
Choose your signup surface#
Signup is the only part with a frontend, so it's the only real integration decision:
| Surface | Use when | Guide |
|---|---|---|
Payment link with subscription_plan_id | One public URL per plan: pricing pages, ads, QR codes. Zero frontend code. | Payment links |
Checkout session with subscription_plan_id | Your app triggers signup for one known buyer and redirects them to a Flint-hosted page. | Checkout sessions |
Embedded checkout with subscription_plan_id | Your own signup UI collects a fresh payment method for an initial charge or a zero-balance trial. | Headless subscription signup |
| Direct subscription API | Create a subscription with a saved payment method, or save one separately before signup. | Steps 1 to 6 below |
The hosted surfaces collect the buyer's details and payment method, create the customer and the subscription, and hand you the result over the same webhooks as the API-driven flow. If you use one of them, skip ahead to grant access from webhooks.
The direct subscription API flow is:
- Create a subscription plan (once per price)
- Create the customer
- Save a payment method
- Confirm card setup in the browser
- Create the subscription
- Grant access from webhooks
Every POST below sends an Idempotency-Key header so a timed-out request can be retried safely. See Idempotency.
Step 1: create a subscription plan#
A plan is what you'd put on a pricing page: a name, line items, and a billing interval. Create it once and reuse it for every subscriber. Amounts are integers in the currency's minor unit, so 2900 is $29.00.
curl -X POST https://api.withflintpay.com/v1/subscription-plans \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: plan-pro-monthly-v1" \
-d '{
"name": "Pro Monthly",
"billing_interval": "monthly",
"billing_interval_count": 1,
"currency": "USD",
"trial_period_days": 14,
"line_items": [
{
"name": "Pro Plan",
"unit_price_money": {"amount": 2900, "currency": "USD"},
"quantity": 1
}
]
}'
{
"data": {
"subscription_plan_id": "plan_1kmn0aExample",
"name": "Pro Monthly",
"status": "active",
"billing_interval": "monthly",
"billing_interval_count": 1,
"currency": "USD",
"trial_period_days": 14,
"line_items": [
{
"subscription_plan_line_item_id": "spli_1kmn0aExample",
"name": "Pro Plan",
"quantity": 1,
"unit_price_money": {"amount": 2900, "currency": "USD"}
}
],
"created_at": "2026-07-02T17:04:05Z"
}
}
Save data.subscription_plan_id.
billing_intervalisdaily,weekly,monthly, oryearly;billing_interval_countspaces cycles, somonthlywith a count of3bills quarterly.- Line items can be ad hoc (a
nameand aunit_price_money, as above) or sold from your catalog by passingvariant_idorbundle_idinstead. Catalog-backed items can also carrymodifiers. A catalog line for a physical product ships on every cycle; see Ship physical products for what such a plan needs. billing_interval_optionsandquantity_optionslet the buyer choose a cadence and a quantity at signup, and change them later. See Offer intervals and quantities.trial_period_daysstarts every subscriber with a free trial (up to 365 days). Plans with physical lines can't have one. See Free Trials.setup_fee_moneycharges a one-time fee when the subscription starts, in the plan's currency. If the setup fee payment fails, the subscription is canceled.contract_term_months(1 to 120) records a commitment period, andearly_termination_fee_moneyan associated fee; both surface on the subscription so your integration can act on them.
Plans are versioned by you, not mutated in place: because subscribers snapshot pricing at signup, the standard way to change a price is to create a new plan and archive the old one.
Step 2: create the customer#
The subscription needs a durable customer record: it's who gets charged, emailed, and looked up in support.
curl -X POST https://api.withflintpay.com/v1/customers \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: customer-ada-001" \
-d '{
"name": "Ada Lovelace",
"email": "ada@example.com"
}'
Save data.customer_id. If your app already creates Flint customers at account signup, reuse that ID here.
Step 3: save a payment method#
Recurring billing charges the customer while they're not present, so the card must be saved and authorized for future use, not just charged once. Save a card and charge it later covers the same flow in more depth, plus default cards, removal, and one-off charges. Start the card setup from your backend:
curl -X POST https://api.withflintpay.com/v1/payment-methods \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: save-pm-ada-001" \
-d '{
"customer_id": "cus_1kmn0aExample"
}'
{
"data": {
"payment_method": {
"payment_method_id": "pm_1kmn0aExample",
"customer_id": "cus_1kmn0aExample",
"type": "card",
"status": "pending"
},
"client_setup": {
"stripe": {
"account_id": "acct_1AbcPlaceholder",
"publishable_key": "pk_test_51AbcPlaceholder",
"setup_intent": {
"stripe_js_call": "confirm_setup",
"client_secret": "seti_1AbcPlaceholder_secret_XyzPlaceholder"
}
}
}
}
}
Two things to notice:
- The payment method exists immediately but its status is
pending. It can't fund a subscription until the browser completes card setup and it becomesactive. client_setup.stripecarries everything your frontend needs. Itssetup_intent.stripe_js_callnames the operation to perform now, andsetup_intent.client_secretis scoped to that operation. Flint processes cards on Stripe; you don't need your own Stripe account, and your Flint API key never leaves your backend.
Send setup_intent.client_secret, setup_intent.stripe_js_call, account_id, and publishable_key to your frontend.
Step 4: confirm card setup in the browser#
Collect the card with the Stripe Payment Element and confirm the setup with the credentials from step 3. The card fields run in Stripe-hosted iframes, so raw card data never touches your servers.
<script src="https://js.stripe.com/v3/"></script>
<form id="setup-form">
<div id="payment-element"></div>
<button id="submit" type="submit">Save card</button>
<div id="setup-message" role="alert"></div>
</form>
// From your backend (step 3): publishableKey, accountId, clientSecret.
const stripe = Stripe(publishableKey, {
stripeAccount: accountId, // required: the setup lives on this account
});
const elements = stripe.elements({ clientSecret });
const paymentElement = elements.create("payment");
paymentElement.mount("#payment-element");
const form = document.querySelector("#setup-form");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const { error, setupIntent } = await stripe.confirmSetup({
elements,
confirmParams: {
return_url: "https://example.com/billing/setup-complete",
},
redirect: "if_required",
});
if (error) {
showMessage(error.message); // validation problem or card refused; let them retry
return;
}
if (setupIntent.status === "succeeded") {
// Tell your backend setup finished; it creates the subscription (step 5).
await fetch("/billing/subscribe", { method: "POST" });
}
});
confirmSetup handles 3D Secure inline when the bank requires it; with redirect: "if_required", card setups resolve on the page and return_url is only used by redirect-based flows. If your integration still uses the legacy Card Element, stripe.confirmCardSetup(clientSecret, { payment_method: { card } }) completes the same setup.
Wait for the payment method to activate#
Browser confirmation isn't the finish line. Flint finalizes the saved card moments later: the payment method's status flips from pending to active, its card details (brand, last4, expiry) fill in, and the payment_method.saved webhook fires. Creating a subscription against a still-pending payment method fails with PAYMENT_METHOD_NOT_READY.
Gate subscription creation on either signal:
- Webhook (recommended): create the subscription when
payment_method.savedarrives for thispayment_method_id. - Poll:
GET /v1/payment-methods/pm_1kmn0aExampleuntildata.statusisactive. The window is brief, so a short retry loop onPAYMENT_METHOD_NOT_READYalso works.
If setup fails instead (the bank refused authorization), the payment method becomes failed. Start over from step 3 with a fresh save; failed payment methods don't retry.
Optionally, make the card the customer's default so future subscription creates can omit payment_method_id:
curl -X POST https://api.withflintpay.com/v1/payment-methods/pm_1kmn0aExample/set-default \
-H "Authorization: Bearer YOUR_API_KEY"
The default lives on the customer as default_payment_method_id.
Step 5: create the subscription#
curl -X POST https://api.withflintpay.com/v1/subscriptions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: sub-ada-pro-001" \
-d '{
"subscription_plan_id": "plan_1kmn0aExample",
"customer_id": "cus_1kmn0aExample",
"payment_method_id": "pm_1kmn0aExample",
"billing_start": {"type": "immediate"},
"billing_schedule": {"owner": "flint"}
}'
{
"data": {
"subscription_id": "sub_1kmn0aExample",
"subscription_plan_id": "plan_1kmn0aExample",
"customer_id": "cus_1kmn0aExample",
"payment_method_id": "pm_1kmn0aExample",
"status": "trialing",
"billing_schedule_owner": "flint",
"awaiting_billing_schedule": false,
"billing_anchor_day": 2,
"cancel_at_period_end": false,
"current_period_start": "2026-07-02T17:04:05Z",
"current_period_end": "2026-07-16T17:04:05Z",
"next_billing_at": "2026-07-16T17:04:05Z",
"trial_end": "2026-07-16T17:04:05Z",
"line_items": [
{
"subscription_line_item_id": "sli_1kmn0aExample",
"name": "Pro Plan",
"quantity": 1,
"unit_price_money": {"amount": 2900, "currency": "USD"},
"subtotal_money": {"amount": 2900, "currency": "USD"}
}
],
"created_at": "2026-07-02T17:04:05Z"
}
}
payment_method_idis optional when the customer has a default payment method; without either, the call fails withPAYMENT_METHOD_REQUIRED.billing_startis required. Useimmediate,scheduled, orimported.billing_schedule.ownerisflintorexternal. If omitted, the effective subscription setting supplies the owner.- The initial
statusfollows the plan:trialingwhen the plan has a trial, otherwiseincompleteuntil the first payment succeeds. billing_anchor_day(1 to 31, defaults to the signup day) sets which day of the month renewals bill on. Send it only with an explicit"billing_schedule": {"owner": "flint"}, since an external schedule sets each date directly.line_itemsis the pricing snapshot this subscription will bill on, every cycle, regardless of later plan edits. Each line has a stablesubscription_line_item_id, and itsquantityis the per-unit quantity from the plan line.service_locationcan snapshot either a customer address or an inline address when the subscription represents work at a physical location.deliverysays where each shipment goes. It is required when the plan has physical lines and rejected when it has none. See The delivery preference.billing_intervalwithbilling_interval_count, andquantity, pick one of the plan's offered intervals and quantities. Send the interval pair together, or neither to use the plan's own interval.quantitydefaults to 1.- A customer can hold multiple independent subscriptions to the same plan.
Choose when billing starts#
billing_start is a closed tagged union. Send exactly one branch:
| Type | Fields | Behavior |
|---|---|---|
immediate | type | Starts now. A plan without a trial begins the first charge flow immediately. |
scheduled | type, starts_at | Starts at a future RFC3339 timestamp. Flint creates no order or payment attempt before that time. A scheduled start cannot be combined with a plan trial, and a past starts_at fails with SUBSCRIPTION_STARTS_AT_NOT_FUTURE. |
imported | type, period_started_at, optional completed_cycles | Imports an already-paid current period without charging. Give the start of the period the buyer already paid for; Flint computes the period end from the plan interval. A period that has already ended fails with SUBSCRIPTION_IMPORT_PERIOD_NOT_CURRENT. |
A scheduled subscription reports its start on starts_at and stays incomplete until that moment arrives.
Use imported when you are moving subscribers from another billing system. It is what keeps a migrated buyer from being charged twice for a month they already paid for somewhere else. Add completed_cycles to the imported branch with the number of cycles the buyer already paid for there, so the next renewal's subscription_cycle continues from it. To check a whole batch before creating anything, see Import subscribers from another tool.
Import an agreement that was paid elsewhere:
curl -X POST https://api.withflintpay.com/v1/subscriptions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: import-sub-ada-pro-001" \
-d '{
"subscription_plan_id": "plan_1kmn0aExample",
"customer_id": "cus_1kmn0aExample",
"payment_method_id": "pm_1kmn0aExample",
"billing_start": {
"type": "imported",
"period_started_at": "2026-07-01T00:00:00Z"
},
"billing_schedule": {"owner": "external"},
"service_location": {
"source": "customer_address",
"customer_address_id": "caddr_1kmn0aExample"
}
}'
For an inline service address, send {"source":"address","address":{...}} instead. Flint snapshots the resolved address on the subscription, so later customer-address edits do not rewrite the agreement.
For plans without a trial, the create response is incomplete, not proof of payment. Provision access when the first subscription.payment_succeeded event arrives (or when a fetch shows status: "active"), just like you'd never fulfill an order from a redirect alone.
Step 6: grant access from webhooks#
Subscriptions change state on Flint's schedule, not during your API calls: trials convert overnight, renewals succeed or fail, retries exhaust. Webhooks are how your entitlement logic keeps up. Register an endpoint once:
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-billing-001" \
-d '{
"url": "https://example.com/webhooks/flint",
"enabled_events": [
"subscription.created",
"subscription.updated",
"subscription.activated",
"subscription.trial_ending",
"subscription.renewal_upcoming",
"subscription.payment_succeeded",
"subscription.payment_failed",
"subscription.past_due",
"subscription.dunning_exhausted",
"subscription.paused",
"subscription.resumed",
"subscription.cancellation_scheduled",
"subscription.reactivated",
"subscription.canceled",
"payment_method.saved",
"payment_method.removed"
]
}'
Store data.secret and verify signatures as described in Webhooks. The full event set for recurring billing:
Event payloads carry identifiers (subscription_id, subscription_plan_id when the subscription has a plan, and event-specific keys), not a full subscription object. Treat each event as a signal to fetch the subscription and reconcile your state against its status; that keeps handlers idempotent and immune to out-of-order delivery.
A minimal entitlement handler needs exactly three transitions: provision on subscription.created (when trialing) or the first subscription.payment_succeeded, prompt for a new card on subscription.past_due, and revoke on subscription.canceled.
How renewals work#
At next_billing_at, Flint creates an order from the subscription's snapshotted line items, charges the saved payment method, and moves the period forward. On the subscription you'll see fresh current_period_start and current_period_end; over webhooks you'll get subscription.payment_succeeded with the cycle's order_id and payment_intent_id. Flint-owned schedules also compute a fresh next_billing_at. External schedules clear it and set awaiting_billing_schedule: true until your integration supplies the next date.
On a plan with physical lines, the renewal order also gets the subscriber's address, a shipping charge, and a fulfillment. See What a physical renewal does.
Control the billing schedule#
billing_schedule_owner says who supplies billing dates:
flint: Flint computes dates from the plan interval andbilling_anchor_day.next_billing_atis required when you explicitly update this schedule.external: Your integration sets eachnext_billing_at. A missing date means Flint waits without changing the subscription status.
Set or transfer the schedule with a complete replacement request. owner and initiated_by are always required, and initiated_by is one of buyer, merchant, or integration:
curl -X PATCH https://api.withflintpay.com/v1/subscriptions/sub_1kmn0aExample/billing-schedule \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: schedule-sub-ada-002" \
-d '{
"owner": "external",
"next_billing_at": "2026-08-15T16:00:00Z",
"initiated_by": "integration"
}'
For an external schedule, send "next_billing_at": null to clear the timer. The response keeps the lifecycle status, reports awaiting_billing_schedule: true, and stamps billing_schedule_waiting_started_at so you can see how long a subscription has been waiting on you. List these subscriptions with GET /v1/subscriptions?billing_schedule_owner=external&awaiting_billing_schedule=true.
To move a Flint-owned schedule, include billing_anchor_day with owner: "flint". External schedules reject billing_anchor_day because the integration controls the timestamp directly.
Four rules decide whether a schedule change is accepted:
| Rule | Failure |
|---|---|
canceled, past_due, and incomplete reject the change. The one exception is an incomplete subscription waiting on a scheduled start. | SUBSCRIPTION_SCHEDULE_MUTATION_NOT_ALLOWED |
next_billing_at is in the future. | SUBSCRIPTION_NEXT_BILLING_AT_NOT_FUTURE |
next_billing_at is not before current_period_start. | SUBSCRIPTION_NEXT_BILLING_AT_BEFORE_PERIOD_START |
A flint schedule moves at most one billing interval past current_period_end. External schedules have no such ceiling. | SUBSCRIPTION_NEXT_BILLING_AT_TOO_FAR |
To move a start that has not arrived yet, keep the same owner and send a new next_billing_at. Clearing the date or transferring ownership before the subscription starts is rejected.
A skip moves next_billing_at forward by one of the subscription's own billing intervals, its current billing_interval and billing_interval_count, which can differ from the plan's. The next renewal isn't charged and nothing ships for it. The subscription must be active, not set to cancel at the end of its period, and already have a next_billing_at. A trialing subscription, one with cancel_at_period_end, and an external one awaiting a date all reject the skip with SUBSCRIPTION_SCHEDULE_MUTATION_NOT_ALLOWED. initiated_by defaults to merchant; send buyer when you skip at the buyer's request:
curl -X POST https://api.withflintpay.com/v1/subscriptions/sub_1kmn0aExample/skip-cycle \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: skip-sub-ada-001" \
-d '{"initiated_by": "merchant"}'
Buyers can skip their own next renewal with POST /v1/me/subscriptions/{subscription_id}/skip-cycle, when the store allows it and the subscription is on a Flint-owned schedule (billing_schedule_owner is flint). The same state rules apply. The body takes only an optional expected_version; Flint records the buyer as the initiator. See Buyer self-service.
Schedule updates and skips return the updated subscription and emit one subscription.updated event when an effective value changed. A skip also emits subscription.cycle_skipped. Retrying the same idempotent request returns the original result. A no-op schedule update returns the unchanged subscription without emitting an event or changing any public values.
Because every cycle is an order:
- Refund a bad cycle with the standard refunds API against that cycle's order. There's no subscription-specific refund machinery to learn.
- Reconciliation and reporting see subscription revenue the same way they see any other order; filter by the order's
subscription_idor itsoriginofsubscription. - The customer gets a normal Flint receipt per cycle.
The subscription also emits the standard order and payment events for each cycle. On a plan with physical lines, the renewal order also carries the subscriber's delivery destination and a shipping charge, and gets a fulfillment that you ship like any other order, with the same order.fulfillment.* events. Renewals of plans without physical lines have no fulfillment.
When a renewal payment fails#
Cards expire, limits get hit, banks decline. A failed cycle charge is a normal event with a built-in recovery path, not an edge case:
- The subscription moves to
past_due. You getsubscription.payment_failedandsubscription.past_due, and Flint emails the customer a payment-failure notice. - Flint retries the charge on a decaying schedule: by default four retries over 16 days (roughly days 1, 4, 9, and 16 after the failure). The retry window is a merchant billing setting.
- Any successful retry restores the subscription to
activeand advances the period; you getsubscription.payment_succeeded. - If every retry fails, Flint emits
subscription.dunning_exhaustedwith the resolved end action. Flint-owned subscriptions default tocancel. External subscriptions default tonotify_only, which leaves the subscriptionpast_dueand ready for manual recovery. You can configure owner-specificcancel,pause, ornotify_onlybehavior in subscription settings.
The fix for past_due is a working card, not an API state change. Have the customer save a new payment method (steps 3 and 4), then point the subscription at it:
curl -X POST https://api.withflintpay.com/v1/subscriptions/sub_1kmn0aExample/payment-method \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{"payment_method_id": "pm_0newcardExample"}'
Charges always use the subscription's current payment method, so the next scheduled retry bills the new card. Calling resume on a past_due subscription fails with CANNOT_RESUME_PAST_DUE_PAYMENT_REQUIRED for exactly this reason: there's an unpaid cycle to collect, and collection is what recovers it.
Start a manual recovery attempt after updating the card:
curl -X POST https://api.withflintpay.com/v1/subscriptions/sub_1kmn0aExample/payment-retries \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: retry-sub-ada-001" \
-d '{}'
Idempotency-Key is required here, and the body must be empty or {}. The subscription must be past_due, with no delivery_hold and no inventory_wait; anything else fails with 409 SUBSCRIPTION_PAYMENT_RETRY_NOT_ALLOWED. During a delivery hold, fix delivery instead: the held renewal is charged once the hold clears. An inventory_wait means the renewal is waiting on stock. This call charges a card, so it sits in the external_provider_action rate limit class rather than the plain write class.
The 201 response contains a pollable subscription_payment_retry_id with status: "pending":
curl https://api.withflintpay.com/v1/subscriptions/sub_1kmn0aExample/payment-retries/spr_1kmn0aExample \
-H "Authorization: Bearer YOUR_API_KEY"
Status advances through processing to exactly one terminal state, succeeded or failed. Successful attempts carry the order_id and order_payment_attempt_id they produced; failed ones carry a failure. GET /v1/subscriptions/{subscription_id}/payment-retries lists a subscription's attempts newest first. Reusing the same idempotency key returns the same retry resource and does not create another charge attempt. Flint checks the key before the subscription's state, so a replay returns the original retry even after the subscription has left past_due or entered a hold. A failed manual attempt does not consume the automatic retry budget or move the next scheduled retry. Buyers can also start retries through POST /v1/me/subscriptions/{subscription_id}/payment-retries; their limit is 3 retries per billing period, counting retries started by the store too, while merchant requests keep their existing retry behavior. The same state rules apply to buyers, and their retry_payment action reads unavailable_reason: "not_in_state" during a delivery hold or a wait on stock.
Free trials#
Set trial_period_days on the plan and every subscriber starts with:
status: "trialing"from the moment of signup, so provision access immediately.- A saved card but no charge. The card is collected and validated up front, which is what makes trial-end conversion automatic instead of a dunning email asking the customer to return.
trial_end(andnext_billing_at) marking the conversion moment.
At trial end, Flint charges the first cycle. Success makes the subscription active and emits subscription.activated; a failed conversion charge follows the same recovery path as any other failed payment. Canceling during a trial always takes effect immediately, since there's no paid time to run out.
Plans with physical lines can't have a trial: a trial would ship goods before any payment. Creating or updating such a plan with trial_period_days above 0 fails with SUBSCRIPTION_TRIAL_NOT_SUPPORTED_FOR_PHYSICAL. Use a first-shipment offer instead.
Manage the lifecycle#
Pause and resume#
Pausing suspends billing without ending the relationship: seasonal businesses, hardship holds, "skip a month" features.
curl -X POST https://api.withflintpay.com/v1/subscriptions/sub_1kmn0aExample/pause \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{"pause_duration_cycles": 2}'
pause_duration_cycles skips that many billing cycles; omit it to pause indefinitely until an explicit resume. Only active subscriptions can pause (CANNOT_PAUSE otherwise). One set to cancel at the end of its billing period (cancel_at_period_end) is reactivated first, since a paused subscription doesn't reach its scheduled cancellation. Pausing an already-paused subscription is a harmless no-op.
A buyer pausing their own subscription, in Flint's buyer account or with a customer session, is held to the store's customer_account.buyer_capabilities.pause: with pausing off the request returns PAUSE_NOT_ALLOWED, and with max_cycles set it must send pause_duration_cycles from 1 to that limit. Requests made with your API key aren't limited by it.
curl -X POST https://api.withflintpay.com/v1/subscriptions/sub_1kmn0aExample/resume \
-H "Authorization: Bearer YOUR_API_KEY"
Resume is asynchronous: the response can still show paused while Flint processes the request. Retrieve the subscription again or listen for subscription.resumed to follow its status. Grant paid access only when the subscription is active.
Resuming preserves time the customer already paid for. Remaining prepaid days pick up where they left off. If a payment is overdue or no prepaid time remains, Flint collects the unpaid period before restoring active; a failed collection does not grant paid access.
A subscription paused during a delivery hold reports pause_reason: "delivery_action_required", whether the hold ran out or the buyer or you paused it. The pause Flint starts when a hold runs out has no end date and never resumes on its own. A pause with a length resumes when the length is up, and starts a new hold if delivery still can't be quoted. Resuming by hand needs a delivery preference that Flint can quote, or fails with 409 SUBSCRIPTION_DELIVERY_UNAVAILABLE and stays paused. A resume then starts a new billing period, charges and ships right away, and stays active while it charges; the cycle that was held is never charged. See When a renewal can't ship.
Cancel#
curl -X POST https://api.withflintpay.com/v1/subscriptions/sub_1kmn0aExample/cancel \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{}'
By default this is the customer-friendly cancel: cancel_at_period_end becomes true, the subscription stays active through the time already paid for, and it becomes canceled at current_period_end (emitting subscription.canceled with reason: "period_end"). Pass {"cancel_immediately": true} to end it on the spot. Subscriptions that are trialing, paused, or incomplete always cancel immediately, since there's no paid period to honor. Canceling an already-canceled subscription returns success idempotently.
On a plan that ships, canceling never cancels a renewal order that is already paid. That shipment still goes out, and the cancellation email tells the buyer so. To stop it, refund and cancel that order with the refunds and order APIs. When the subscription becomes canceled, Flint clears its delivery; each past renewal order keeps its own destination.
A buyer can always cancel their own subscription. Whether they may also end it right away is the store's choice: unless customer_account.buyer_capabilities.cancellation_timing is buyer_chooses, a buyer's cancel_immediately: true returns CANCEL_IMMEDIATELY_NOT_ALLOWED, except for a subscription that would end right away anyway.
Record why#
Send the reason with the cancellation:
curl -X POST https://api.withflintpay.com/v1/subscriptions/sub_1kmn0aExample/cancel \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{"cancellation_reason_code": "too_expensive", "cancellation_comment": "Found a cheaper plan"}'
cancellation_reason_code is one of too_expensive, missing_features, switched_service, unused, customer_service, too_complex, low_quality, or other. When the store lists cancellation_reasons, a buyer's code must be one of them (CANCELLATION_REASON_NOT_OFFERED otherwise); neither field is required. cancellation_comment holds up to 500 characters.
The subscription then carries cancellation_details: reason_code, comment, requested_by (buyer or merchant), and requested_at. Buyers never receive comment. Asking again to cancel at the period's end keeps the first request's details. Undoing the scheduled cancellation removes them, and a cancellation after failed payments has none.
A scheduled cancellation can be reversed any time before the period ends:
curl -X POST https://api.withflintpay.com/v1/subscriptions/sub_1kmn0aExample/reactivate \
-H "Authorization: Bearer YOUR_API_KEY"
Scheduling and undoing a cancellation each send a webhook, whether the buyer or you made the change. subscription.cancellation_scheduled carries the cancellation_details without comment, and its cancel_at is the subscription's current_period_end. subscription.reactivated carries initiated_by: buyer or merchant. Asking again for a cancellation that is already scheduled, or reactivating a subscription that isn't set to cancel, sends neither. Ending a subscription right away sends only subscription.canceled.
The cancel route returns the subscription. Before contract_end_at, it includes early_termination_fee_money when the frozen contract terms carry a fee. The same field appears on reads and other action results. Later plan changes do not change the fee. Use contract_start_at and contract_end_at to determine the remaining term.
Update#
PATCH /v1/subscriptions/{subscription_id} accepts metadata, external_reference_id, delivery (see Change the delivery preference), and the plan's offered billing_interval with billing_interval_count and quantity (see Change cadence, quantity, and items). Add, swap, and remove items through the subscription's line-items collection. Change the payment method with POST /v1/subscriptions/{subscription_id}/payment-method, using an active payment method belonging to the same customer. Undo a scheduled cancellation with POST /v1/subscriptions/{subscription_id}/reactivate. The prices of existing lines are fixed by the snapshot; changing them means a new subscription on a new plan.
Manage plans over time#
Plans have their own management surface:
PATCH /v1/subscription-plans/{subscription_plan_id}updates presentation and terms for future subscribers:name,description,external_reference_id,billing_interval,billing_interval_count,trial_period_days,setup_fee_money,contract_term_months,early_termination_fee_money,subscription_delivery_method_ids,billing_interval_options,quantity_options,inventory_routing_source,metadata,images, andline_items. An omitted field keeps its value. Sendnullto cleartrial_period_days,setup_fee_money,contract_term_months, orearly_termination_fee_money. The other fields can't be cleared withnull: send[]forsubscription_delivery_method_idsto offer the store's checkout default methods,[]forbilling_interval_optionsorquantity_optionsto offer only the plan's own interval or a quantity of 1, and a new object forinventory_routing_sourceto replace it.metadatamerges by key; set a key tonullto remove it.- To replace the ordered image gallery, send the complete
imagesarray with the plan's currentversionasexpected_version. Sendimages: []to clear it. - To change line items, send the complete
line_itemsarray in the samePATCH, with the plan's currentversionasexpected_version. See Replace plan line items. - Removing a delivery method from
subscription_delivery_method_idschanges what new subscribers can choose. Current subscribers keep the method until it is archived or stops serving their address. - Removing an interval from
billing_interval_optionsor a quantity fromquantity_optionschanges what new subscribers can choose and what current subscribers can change to. A current subscriber keeps the interval and quantity they have.
Remember the snapshot rule: all of this affects subscribers who sign up after the change. Existing subscriptions keep billing on the prices they signed up under.
Archive a plan#
curl -X DELETE https://api.withflintpay.com/v1/subscription-plans/plan_1kmn0aExample \
-H "Authorization: Bearer YOUR_API_KEY"
DELETE archives a plan and retires it from sale: new subscriptions against it fail with PLAN_NOT_ACTIVE. A plan can't be archived while anything still sells or bills on it; you'll get a 409 naming the blocker (PLAN_HAS_ACTIVE_SUBSCRIPTIONS, PLAN_HAS_ACTIVE_PAYMENT_LINKS, or PLAN_HAS_OPEN_CHECKOUT_SESSIONS). Deactivate those first: cancel or migrate the subscribers, deactivate the links, close the sessions. Archived plans remain readable for history.
Retrieve and list#
Fetch one subscription, expanding related records in the same call:
curl "https://api.withflintpay.com/v1/subscriptions/sub_1kmn0aExample?expand=customer,payment_method,subscription_plan" \
-H "Authorization: Bearer YOUR_API_KEY"
List with filters for dashboards and back-office tooling:
curl "https://api.withflintpay.com/v1/subscriptions?status=past_due&sort_by=next_billing_at" \
-H "Authorization: Bearer YOUR_API_KEY"
Subscriptions filter by status, customer_id, subscription_plan_id, delivery_method_id, hold_reason, created/updated time bounds, next_billing_at bounds (handy for "what bills this week"), and a free-text query over customer name, email, and plan name. Plans filter by status (active or archived), query, and created bounds. Both use standard cursor pagination.
Ship physical products#
A plan can sell physical products from your catalog: the same 60-count bottle every 30 days, a coffee club, or a monthly box sold as one box variant whose contents you decide at packing time. Each renewal is an order with the subscriber's address, a shipping charge, tax for that address, and a fulfillment you ship like any other order. Stripe Checkout offers shipping options in payment mode only, and Stripe subscription shipping covers charging and shipping each renewal there if you are moving from it.
Schedules are rolling. Each subscriber renews one interval after their last paid shipment, so subscribers to the same plan renew on different days.
Set up a physical plan#
Give each product a delivery profile, as you would for one-time sales. Then create the plan with catalog lines and the delivery methods subscribers can choose:
curl -X POST https://api.withflintpay.com/v1/subscription-plans \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: plan-greens-refill-v1" \
-d '{
"name": "Daily Greens refill",
"billing_interval": "weekly",
"billing_interval_count": 4,
"currency": "USD",
"line_items": [
{"variant_id": "var_1kmn0aExample", "quantity": 1}
],
"inventory_routing_source": {"type": "fixed_location", "location_id": "loc_1kmn0aExample"},
"subscription_delivery_method_ids": ["dmet_1kmn0aExample", "dmet_1kmn0bExample"]
}'
inventory_routing_source says where each renewal's stock comes from: a fixed Location, an allocation policy, or one immutable policy version. It's required when any line tracks inventory and optional otherwise. Each new subscription copies the plan's source. See Stock source.
subscription_delivery_method_ids lists the methods offered to subscribers. Leave it empty to offer the store's checkout default methods. Every renewal is quoted without the buyer present, so a plan can't list a method with caller_supplied pricing or one that makes the buyer choose a delivery window, and checkout drops those methods from the store defaults too. Recurring pickup isn't supported.
A plan with physical lines must pass these checks when you create it or change its lines, methods, or trial.
| Check | Error |
|---|---|
| Every physical line has a delivery profile. | SUBSCRIPTION_DELIVERY_PROFILE_MISSING |
| Each profile is set up, not waiting on an action. | SUBSCRIPTION_DELIVERY_PROFILE_ACTION_REQUIRED |
Each profile allows shipment or local_delivery. A pickup-only profile can't be sold on a subscription. | SUBSCRIPTION_FULFILLMENT_NOT_SUPPORTED |
All physical lines can ship together, so one method covers every shipment. A profile whose combination_policy is separate_profile, separate_line_item, or fulfill_alone splits lines apart. Lines from several origins are fine. | SUBSCRIPTION_DELIVERY_LINES_NOT_COMBINABLE |
| At least one offered method can be priced without the buyer and serves every physical line, and no listed method needs the buyer present. | SUBSCRIPTION_DELIVERY_METHOD_UNSUPPORTED |
| The plan has no trial. | SUBSCRIPTION_TRIAL_NOT_SUPPORTED_FOR_PHYSICAL |
Each of these errors names what failed with fields on the error object. variant_id and product_id name a variant line, or the item inside a bundle line that failed. blocking_resources can list the line's variant or bundle, and always lists it for a bundle line. delivery_method_id names a listed method that fails the check. The trial and combination checks, and a method that can't serve the lines, name the plan's first physical line. When the error carries blocking_resources or delivery_method_id, error.details has one item with the same fields; otherwise it has no details. Match these IDs to your own lines instead of counting array positions.
Any plan whose lines track inventory, physical or digital, also needs an inventory_routing_source. Without one, the request fails with INVENTORY_ROUTING_SOURCE_REQUIRED and param inventory_routing_source, and the error names no line.
A catalog item with no kind is treated as physical, so a legacy product without a kind needs a delivery profile before it can be sold on a plan.
Free shipping for subscribers#
To ship free or cheaper only to subscribers, create a delivery method whose eligibility uses the subscription_purchase condition, and offer it on the plan:
"eligibility": {"subscription_purchase": {"value": true}}
The condition matches when the quote is for a subscription: the signup checkout for a plan, each renewal, and subscription delivery previews. It does not depend on the order's source, so the first shipment at signup qualifies too. {"value": false} matches one-time purchases only.
Offer intervals and quantities#
One plan can offer several cadences and quantities, whether or not it ships. The buyer picks one of each at signup and can change them later.
{
"billing_interval": "weekly",
"billing_interval_count": 4,
"billing_interval_options": [
{"billing_interval": "weekly", "billing_interval_count": 2},
{"billing_interval": "weekly", "billing_interval_count": 4},
{"billing_interval": "weekly", "billing_interval_count": 6}
],
"quantity_options": [1, 2, 3]
}
billing_interval_optionsholds up to 12 distinct pairs ofbilling_intervalandbilling_interval_count. It must include the plan's own pair, which is the default. Send[]to offer only the plan's own interval.quantity_optionsholds up to 10 distinct numbers from 1 to 100, and must include 1, the default. Send[]to sell quantity 1 only.- On
PATCH, omit either field to keep it, or send a new array to replace it. To change the plan'sbilling_intervalto a pair the stored options don't include, send the new options in the same request. - Responses always carry the effective lists:
billing_interval_optionsstarts with the plan's own pair, andquantity_optionsis ascending.
A quantity multiplies the whole plan, so a two-line plan never ships its lines at different multiples. The subscription reports the choice as quantity, and each of its line_items keeps the per-unit quantity from the plan line. Each renewal order ships the line quantity times quantity: a line of 2 bottles at quantity 3 ships 6 bottles. The subscription's recurring_amount_money includes the multiplier.
Invalid options fail with INVALID_BILLING_INTERVAL_OPTIONS or INVALID_QUANTITY_OPTIONS. A choice the plan doesn't offer fails with SUBSCRIPTION_BILLING_INTERVAL_NOT_OFFERED or SUBSCRIPTION_QUANTITY_NOT_OFFERED.
Multiplied, a line can carry at most 9,999 units, so a line of 5,000 can't be sold at quantity 2. Saving a plan doesn't check its quantity_options against this limit. A request that would put a line over 9,999 units, or make the amount too large, fails with INVALID_QUANTITY. That covers creating a subscription, changing its quantity, adding, changing, or swapping a line, and checkout.
Discounts and first-shipment offers#
Physical plans can't have a trial. Discount shipments with a promotion applied to the signup checkout instead. The promotion's application_method.recurrence decides which charged cycles it covers:
recurrence.type | What it discounts |
|---|---|
once | The signup order, which is the first shipment, and nothing after it. |
repeating | The first period_count charged cycles, starting with the signup order. |
forever | Every charged cycle for the life of the subscription. Use it for subscribe-and-save. |
When you send recurrence, its type is required (400 INVALID_RECURRENCE_TYPE otherwise). A promotion without recurrence covers the signup order only, like once.
Flint counts charged cycles, the same numbers as subscription_cycle, so a skipped or held cycle doesn't use up a discounted one. A promotion that takes 100% off the items still leaves the shipping charge, so "first box free, pay shipping" works. You can also set the plan's price below your one-time price.
Sign up a subscriber#
Hosted checkout and payment links handle a physical plan the same way as a one-time order with shipping: they collect the shipping address, show the plan's methods with their prices, and let the buyer pick the plan's offered interval and quantity. The signup order is the first shipment. It is quoted, taxed, paid, and fulfilled like any shipped order, and the subscription stores the buyer's address, recipient, and method as its delivery.
The recurring price is shown with shipping, because shipping is part of every charge:
| Method pricing | Shown as |
|---|---|
fixed | One amount: "$35.00 every month, including $5.00 shipping." It changes only if you edit the method. |
| Any other strategy | Items plus shipping: "$30.00 every month, plus shipping ($5.00 today)." Shipping is quoted again on each renewal and can change when rates change. |
The session's subscription_terms.recurring_shipping reports the shipping part of the recurring price: price_type (fixed or quoted), shipping_money for one shipment, and delivery_method_name. subscription_terms.recurring_total_money includes the chosen quantity.
In your own checkout UI, read the choices from the session's subscription_terms.billing_interval_options and subscription_terms.quantity_options, and send the buyer's choice as a subscription_terms object with billing_interval, billing_interval_count, and quantity. Every field is optional, but the interval pair goes together. Send it on POST /v1/checkout-sessions, or on PATCH /v1/checkout-sessions/{checkout_session_id} with your API key or the session's checkout credential.
- Only sessions with a
subscription_plan_idaccept it, whether you created the session with one or a plan payment link did. - A change is accepted while the session is open and no payment is in progress. Otherwise it fails with
409. - It reprices the signup order and releases any delivery selection, because quantity changes the weight. Quote delivery again afterward.
Payment links take no extra fields. The buyer chooses on the checkout session the link opens.
To create a subscription directly, send delivery with the create request. Without it, a plan that ships fails with SUBSCRIPTION_DELIVERY_REQUIRED. Flint previews delivery for that method and address before creating anything, so a method that can't serve the address fails at create, not at the first renewal.
curl -X POST https://api.withflintpay.com/v1/subscriptions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: sub-ada-greens-001" \
-d '{
"subscription_plan_id": "plan_1kmn0aExample",
"customer_id": "cus_1kmn0aExample",
"payment_method_id": "pm_1kmn0aExample",
"billing_start": {"type": "immediate"},
"billing_interval": "weekly",
"billing_interval_count": 2,
"quantity": 2,
"delivery": {
"type": "shipment",
"delivery_method_id": "dmet_1kmn0aExample",
"destination": {
"type": "customer_address",
"customer_address_id": "caddr_1kmn0aExample"
},
"recipient": {"name": "Ada Lovelace", "phone": "+15035550100"}
}
}'
The delivery preference#
A subscription that ships carries delivery:
"delivery": {
"type": "shipment",
"delivery_method_id": "dmet_1kmn0aExample",
"destination": {
"customer_address_id": "caddr_1kmn0aExample",
"address": {
"line1": "12 Oak St",
"city": "Portland",
"state": "OR",
"postal_code": "97205",
"country": "US"
}
},
"recipient": {"name": "Ada Lovelace", "phone": "+15035550100"},
"revision": 3,
"address_verification": {"state": "verified"},
"delivery_method": {
"name": "Standard shipping",
"description": "3 to 5 business days",
"type": "shipment",
"price_type": "fixed"
},
"shipping_money": {"amount": 500, "currency": "USD"},
"updated_at": "2026-07-02T17:04:05Z"
}
typeisshipmentorlocal_delivery.destinationis set from a saved customer address ("type": "customer_address"with acustomer_address_id) or an inline address ("type": "address"with anaddress). Flint copies the address whendeliveryis written. Editing the customer's saved address later doesn't move this subscription's shipments; updatedeliveryto do that. Acustomer_address_idthat isn't a saved address of the subscription's customer fails withDELIVERY_DESTINATION_INVALID(paramdelivery.destination.customer_address_id, ordestination.customer_address_idin a delivery options preview). Responses returndestinationwithout atype: the copiedaddress, pluscustomer_address_idwhen it came from a saved address, as a reference for display only.recipientcan name someone other than the buyer, so a buyer can ship to another person.revisiongoes up by one on every change.delivery_methodnames the stored method for display:name,description,type, andprice_type.price_typeisfixedwhen shipping costs the same every time, andquotedwhen it is quoted again for each shipment.shipping_moneyis the shipping for one shipment of this subscription: the method's price for afixedmethod, and the shipping charged on the latest shipment for aquotedone. It is absent until aquotedmethod has charged once.address_verification.stateisverified,needs_review,unverified, orunverifiable. Flint verifies the address wheneverdeliveryis written: at signup, on update, on import, and when a migration moves it. Renewals use the stored result and never verify again, so an address accepted at signup can't hold a later renewal.deliveryis kept while the subscription isactive,paused, orpast_due, and cleared when it becomescanceled. Each renewal order keeps its own destination.
Change the delivery preference#
Replace delivery as a whole with PATCH /v1/subscriptions/{subscription_id} and the subscription's current version as expected_version:
curl -X PATCH https://api.withflintpay.com/v1/subscriptions/sub_1kmn0aExample \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"expected_version": 7,
"delivery": {
"type": "shipment",
"delivery_method_id": "dmet_1kmn0aExample",
"destination": {
"type": "address",
"address": {
"line1": "48 Birch Ave",
"city": "Portland",
"state": "OR",
"postal_code": "97214",
"country": "US"
}
},
"recipient": {"name": "Ada Lovelace"}
}
}'
Flint previews delivery before saving. If the chosen method can't serve the new address, the request fails with SUBSCRIPTION_DELIVERY_UNAVAILABLE, which describes that one method's outcome. To see which methods do serve the address, run a delivery options preview first. The method must be one the plan offers (SUBSCRIPTION_DELIVERY_METHOD_NOT_OFFERED otherwise). A subscriber can keep a method you have since removed from the plan, but can't switch back to it after changing. Flint emails the buyer whenever you change their delivery, and emits subscription.delivery_updated with changed_by: "merchant".
A change applies from the next renewal that hasn't been paid:
- If the current renewal isn't paid yet, including one waiting on stock or held, Flint cancels that unpaid order and starts the renewal again with the new preference.
- If the current renewal is paid but hasn't shipped, it still goes to the address it was paid with.
subscription.delivery_updatedcarries itsopen_renewal_order_id, and the order reportssubscription_delivery_changed_at, so you can contact the buyer, or refund and cancel before packing. List these orders withGET /v1/orders?subscription_delivery_changed=true. - If a renewal payment is already in flight, that renewal keeps the address it was quoted with. Once it is paid, it is flagged as above.
Send a paid renewal to the new address#
A paid order that hasn't shipped can go to the new address when doing so changes nothing the buyer paid. Send the new delivery_destination with the order's current order_revision to PATCH /v1/orders/{order_id}:
curl -X PATCH https://api.withflintpay.com/v1/orders/ord_1kmn0aExample \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"order_revision": 4,
"delivery_destination": {
"address": {
"line1": "48 Birch Ave",
"city": "Portland",
"state": "OR",
"postal_code": "97214",
"country": "US"
}
}
}'
It works only with your API key or the dashboard, on an order that belongs to a subscription, is paid, and has nothing shipped. Flint quotes the order's delivery method at the new address and recalculates tax there. If the shipping charge and the tax both stay the same, the order and its unshipped fulfillments move to the new address, subscription_delivery_changed_at is cleared, and order_revision goes up. Charges and the payment don't change. You receive order.updated, and order.fulfillment.updated for each fulfillment that moved.
| Error | When |
|---|---|
ORDER_DELIVERY_DESTINATION_REPRICE_REQUIRED (409) | The new address would change what the buyer paid. error.details gives the reason (shipping_changed, tax_changed, or method_unavailable) and, when Flint computed them, shipping_money and requoted_shipping_money, or tax_money and requoted_tax_money. Refund and cancel the order, or contact the buyer. |
DELIVERY_DESTINATION_FROZEN (409) | The order isn't a paid subscription order, something has already shipped, or the request didn't come from you. |
ORDER_REVISION_REQUIRED (400) | order_revision is missing. A paid order needs it whenever you send delivery_destination. |
ORDER_CHANGED_REFRESH_REQUIRED (409) | order_revision is stale. Read the order again; the details carry the current revision. |
When a delivery preference can't ship#
SUBSCRIPTION_DELIVERY_UNAVAILABLE means the request is valid, but Flint can't quote the method for the address and lines it would ship. The subscription is left as it was.
| Status | When | param |
|---|---|---|
400 | Creating a subscription with delivery, or replacing it with PATCH /v1/subscriptions/{subscription_id} or POST /v1/me/subscriptions/{subscription_id}/delivery. | delivery, or delivery.delivery_method_id when the method itself is unavailable |
400 | A change that rechecks the stored preference: a new billing interval or quantity, or adding, changing, swapping, or removing a line. | None, because the stored preference failed, not a field you sent |
400 | A delivery options preview, when Flint can't read current shipping measurements for the subscription's lines. | None |
409 | Resuming a subscription paused with pause_reason: "delivery_action_required", by you or by the buyer. It stays paused. | subscription_id |
409 | Order now, when the renewal can't be quoted for delivery. | None |
error.details[] has one item with reason, the same value as delivery_hold.reason: method_unavailable, destination_not_served, or rate_unavailable. When Flint knows them, it also carries delivery_method_id and the method's unavailable_reason or failure_category, with the same values as delivery previews.
For method_unavailable and destination_not_served, choose a method the plan offers that serves the address, or change the address, then retry. error.remediation.retryable is false. For rate_unavailable, the method can serve the address but couldn't be priced right now, so retryable is true: retry later, or fix the rate callback or the product's delivery profile.
Preview delivery options#
Before saving a new address, ask which of the plan's methods serve it and what they cost:
curl -X POST https://api.withflintpay.com/v1/subscription-previews \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"mode": "delivery_options",
"subscription_id": "sub_1kmn0aExample",
"destination": {
"type": "address",
"address": {
"line1": "48 Birch Ave",
"city": "Portland",
"state": "OR",
"postal_code": "97214",
"country": "US"
}
}
}'
The response lists delivery_methods: the plan's offered methods, then the subscription's current method if the plan no longer offers it. Each entry carries availability (available, unavailable, or cannot_calculate), amount_money for one shipment of this subscription's lines when available, price_type (fixed or quoted), current, and selectable. Unavailable methods carry the same unavailable_reason and failure_category values as delivery previews. The response also reports the address verification result for the destination. Nothing is saved. A buyer's own account calls the same preview at POST /v1/me/subscription-previews.
What a physical renewal does#
- Reminder. Three days before billing, or 30 days when the subscription renews yearly or every 12 or more months, Flint emits
subscription.renewal_upcomingand emails the buyer the items, the address, the method, and the estimated total with shipping and tax, with links to skip, change the address, or pause. If delivery already can't be quoted, the buyer and you hear about it then, so it can be fixed before the billing date: the subscription reportsupcoming_delivery_holdand the buyer gets the hold email. - Charge. On the billing date Flint checks the stored method against the stored address, creates the renewal order, checks stock, quotes and selects delivery, adds the shipping charge, calculates tax for the shipping address, and charges the saved payment method. Flint never charges for a shipment it can't route.
- Fulfill. The paid order has an unfulfilled shipment and sends
order.fulfillment.createdlike any other order. Ship it with your usual fulfillment calls; tracking updates reach the buyer through Flint's fulfillment emails.
The renewal order carries origin: "subscription", subscription_id, its delivery_destination, and subscription_cycle. subscription_cycle is 1 for the first charged cycle (the signup order, or the first renewal of a subscription created without one) and goes up by one only when a cycle is charged. Skipped and held cycles that were never charged don't use a number, and retries of one cycle keep it, so subscription_cycle: 4 is always the fourth shipment. Use it for first-box inserts or milestone gifts. The subscription's completed_cycles reports how many cycles have been charged so far. Each order line's quantity is the subscription line's quantity times the subscription's quantity.
Shipping is quoted again on every renewal, using each product's current weight and dimensions. A fixed method charges the same every time; other methods follow your current rates. Item prices stay as they were at signup.
Late renewals re-anchor. When a physical renewal on a Flint-owned schedule is paid more than 24 hours after its billing date, for any reason (a delivery hold, a stock-out, or a declined card), the next billing date becomes the payment date plus one interval, and billing_anchor_day moves to the payment day for monthly and yearly plans. Shipments stay one interval apart instead of arriving back to back. Subscriptions with an external schedule keep the dates you set.
When a renewal can't ship#
If the stored method can't be quoted for the stored address, Flint holds the renewal instead of charging, and the subscription doesn't enter dunning. Flint creates no renewal order while the hold lasts. If an unpaid renewal order already exists for this billing date, for example after a declined charge or while waiting on stock, Flint cancels it, and a new order is created when the hold clears. The subscription reports the hold in delivery_hold, and you receive subscription.delivery_action_required:
delivery_hold.reason | Cause | fixable_by | Rechecked |
|---|---|---|---|
method_unavailable | The method was archived or no longer serves the address, and another offered method does. | buyer | Daily |
destination_not_served | No offered method serves the address. | merchant | Daily |
rate_unavailable | The method serves the address but can't be priced: a rate callback error or timeout, a calculated rate failure, or a product whose delivery profile has no usable weight. | merchant | Hourly, backing off to daily |
delivery_hold also carries started_at and ends_at. The buyer gets an email for the reason: a request to choose another method, or for destination_not_served, a notice that the store doesn't ship to their address right now, with a link to change it. Each recheck uses the current delivery preference and product weights, so fixing the profile or the address clears the hold at the next recheck. Changing delivery or moving the subscription with a migration rechecks at once.
The hold ends 14 days or one billing interval after the billing date, whichever is sooner. Three days before that, Flint emits subscription.delivery_pause_upcoming and reminds the buyer; when the whole window is shorter than three days, both happen when the hold starts. A hold that is resolved in time charges and ships right away, then re-anchors the schedule. A hold that runs out pauses the subscription with pause_reason: "delivery_action_required", and the held cycle is never charged.
Flint never switches a subscriber to another method or a cheaper rate on its own. That would charge the buyer for something they didn't choose. To move subscribers yourself, use a delivery method migration.
What a buyer's action does during a hold:
| Action | Effect |
|---|---|
| Change address or method | Rechecked at once. If it passes, the held renewal is charged and shipped. |
| Skip | Ends the hold without a charge. The next billing date is the held billing date plus one interval. |
| Pause | Ends the hold without a charge and pauses with pause_reason: "delivery_action_required", the same when you pause. A resume by hand needs a delivery preference Flint can quote, or fails with 409 SUBSCRIPTION_DELIVERY_UNAVAILABLE. An automatic resume at the end of the chosen pause length starts a new hold if delivery still can't be quoted. |
| Cancel | Cancels right away without charging the held cycle, even when the buyer asked to cancel at period end. |
| Retry payment | Refused with 409 SUBSCRIPTION_PAYMENT_RETRY_NOT_ALLOWED, from you or the buyer. A past_due subscription can enter a hold when a scheduled retry finds delivery can't be quoted; fixing delivery clears the hold and charges the renewal. |
Out-of-stock renewals work as they do for every plan: the inventory policy's subscription_inventory_block_action decides what happens, with no time limit.
Find held subscriptions with GET /v1/subscriptions?hold_reason=destination_not_served, or every subscription using one method with delivery_method_id.
Renewals that need attention#
Two more fields report renewals that aren't shipping yet but aren't held:
upcoming_delivery_holdmeans a delivery check before the billing date failed, so the renewal will be held unless delivery is fixed first. It has the samereasonandfixable_byasdelivery_hold, plusdetected_at(when the check failed) andrenewal_at(the billing date that will be held). Flint sets it when the renewal reminder's delivery check fails. It goes away when a later check passes, and a delivery change by the buyer, you, or a migration runs one at once. It also goes away when the cycle is skipped, the subscription is paused or canceled, or the billing date moves. At the billing date it becomesdelivery_holdif delivery still can't be quoted. The two are never present together. Flint emitssubscription.updatedwhen it is set and when it is cleared. Buyers see it too, through/v1/me.inventory_waitmeans the current renewal is waiting on stock under the invoice path ofsubscription_inventory_block_action. It carriesstarted_at, the blockedorder_id, and itsinvoice_id, and goes away when the renewal is paid or replaced, or the cycle is skipped, paused, or canceled. A stock-out on the past-due path shows aspast_dueinstead. Only your API key reads it;/v1/meomits it.
GET /v1/subscriptions?needs_attention=true lists every subscription that needs you. A subscription matches when any of these holds:
- it is
past_dueorincomplete, oractivewith a renewal more than 24 hours overdue; - it has a
delivery_hold, from the moment the hold starts; - it has an
upcoming_delivery_holdor aninventory_wait; - it is
pausedwithpause_reason: "delivery_action_required".
needs_attention=false returns every other subscription. Both combine with the other filters. hold_reason matches delivery_hold only, not upcoming_delivery_hold.
Buyer self-service#
Buyers manage their subscriptions from Flint's buyer account, or from yours through a customer session:
| Route | What it does |
|---|---|
POST /v1/me/subscriptions/{subscription_id}/delivery | Replaces the delivery preference. The body takes delivery, the same shape as above. If the current method doesn't serve the new address, the buyer picks one that does in the same request. |
POST /v1/me/subscriptions/{subscription_id}/skip-cycle | Skips the next renewal. |
POST /v1/me/subscriptions/{subscription_id}/billing-interval | Changes how often it ships. The body takes billing_interval and billing_interval_count, one of the plan's billing_interval_options. |
POST /v1/me/subscriptions/{subscription_id}/quantity | Changes quantity to one of the plan's quantity_options. |
PATCH /v1/me/subscriptions/{subscription_id}/line-items/{subscription_line_item_id} | Swaps a line to another variant. The body takes variant_id: one of the plan line's swap_variant_ids, or the plan line's own variant to swap back. |
POST /v1/me/subscriptions/{subscription_id}/renew | Orders the next renewal now. Send an Idempotency-Key. See Order now. |
POST /v1/me/subscription-previews | mode: "delivery_options" only, for the buyer's own subscriptions. |
Each write takes an optional expected_version, the subscription's version when the buyer loaded it, and fails with 409 if the subscription has changed since. Buyers can't add or remove lines, or change a line's own quantity.
The subscription's buyer_actions reports update_delivery, skip, update_billing_interval, update_quantity, swap_items, and renew, each with whether it is available now. An action the plan doesn't allow is unavailable with reason store_policy: update_billing_interval when the plan offers one interval, update_quantity when it offers only quantity 1, and swap_items when no line has swap_variant_ids. Calling the route anyway returns 403: BILLING_INTERVAL_CHANGE_NOT_ALLOWED (param billing_interval), QUANTITY_CHANGE_NOT_ALLOWED (param quantity), or SWAP_NOT_ALLOWED (param subscription_line_item_id) for a line whose plan line has no swap_variant_ids. Store settings in customer_account.buyer_capabilities govern delivery changes and skips:
| Field | Default | What it controls |
|---|---|---|
can_update_delivery | true | Whether buyers may change their address and method. |
skip.enabled | true | Whether buyers may skip a renewal. |
skip.max_consecutive_skips | no limit | How many renewals in a row a buyer may skip, 1 to 12. At the limit, skip is unavailable with reason limit_reached, and the route fails with SKIP_LIMIT_REACHED, until the next charged renewal resets the count. |
Every skip counts toward the limit, including yours and a skip that ends a delivery hold, but your own skips aren't bound by it. With can_update_delivery set to false, a method_unavailable hold is reported as fixable_by: "merchant", because the buyer can't fix it.
Change cadence, quantity, and items#
A subscription can change after signup, within what its plan offers. Every change raises version, takes an optional expected_version, and applies to the next renewal that hasn't been paid. A renewal that is already paid ships as it was. A change made during a delivery hold is rechecked at once, like a delivery change.
- Cadence. Send
billing_intervalandbilling_interval_counttogether withPATCH /v1/subscriptions/{subscription_id}. The pair must be in the plan'sbilling_interval_options.next_billing_atstays the same, and the new interval applies from that date on. To move the date itself, use the billing schedule or Order now. - Quantity. Send
quantitywith the samePATCH. It must be in the plan'squantity_options, and each line times the new quantity must stay at 9,999 units or fewer (INVALID_QUANTITY,paramquantity). - Items. Line items are a collection under the subscription:
| Route | What it does |
|---|---|
POST /v1/subscriptions/{subscription_id}/line-items | Adds a catalog line: variant_id or bundle_id, and quantity. |
PATCH /v1/subscriptions/{subscription_id}/line-items/{subscription_line_item_id} | Changes the line's variant_id, its per-unit quantity, or both. |
DELETE /v1/subscriptions/{subscription_id}/line-items/{subscription_line_item_id} | Removes the line. Send expected_version as a query parameter. The last line can't be removed (409). |
A line added or changed to a new variant takes that variant's current catalog price, which then stays fixed like the rest. Adding or changing a line fails with INVALID_QUANTITY and no param when a line times the subscription's quantity would be over 9,999 units or the amount would be too large. On a subscription that ships, Flint checks the new lines against the stored delivery method with the same rules as a plan's physical lines, so a line that can't ship with the others is rejected.
To let buyers swap variants themselves, list the allowed variants on the plan line as swap_variant_ids: up to 25 other variants of the same product, not including the line's own variant. [] means no buyer swaps. Only variant lines can list swaps; bundle and ad hoc lines can't. An invalid list fails with INVALID_SWAP_VARIANTS.
Order now#
POST /v1/subscriptions/{subscription_id}/renew bills the next renewal now and ships it when the subscription has delivery. Send an Idempotency-Key, and optionally expected_version. Buyers use POST /v1/me/subscriptions/{subscription_id}/renew. The subscription must be active on a Flint-owned billing schedule, with no trial, delivery hold, or renewal in progress. One set to cancel at the end of its period (cancel_at_period_end) can't order now: reactivate it first, because ordering now would move its cancellation date one interval later.
curl -X POST https://api.withflintpay.com/v1/subscriptions/sub_1kmn0aExample/renew \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: renew-sub-ada-001" \
-d '{"expected_version": 9}'
Flint runs the normal renewal at once: it previews delivery, checks stock, quotes shipping, and charges the saved payment method. On success, a new billing period starts now, next_billing_at becomes now plus one interval, completed_cycles advances, and the order gets the next subscription_cycle. Consecutive skips reset. The route returns the subscription. The renewal order is a normal renewal: find it from subscription.payment_succeeded or GET /v1/orders?subscription_id=sub_1kmn0aExample. The buyer gets the regular order receipt.
If the charge or delivery fails, nothing changes: there is no dunning and no hold, and the subscription renews on its usual date. Payment retries don't apply, because the subscription isn't past_due.
| Error | When |
|---|---|
SUBSCRIPTION_RENEW_PAYMENT_FAILED (402) | The charge was declined, blocked, or failed authentication. The error has no param. Its one error.details[] item carries failure_code, the decline reason in the same vocabulary as the payment's last_payment_error.code (payment_failed when the reason isn't recognized), the renewal's order_id, and the failed order_payment_attempt_id. Update the subscription's payment method, then order again with a new Idempotency-Key; the same key returns the same result. |
PAYMENT_METHOD_REQUIRED, PAYMENT_METHOD_NOT_ACTIVE, PAYMENT_METHOD_ON_SESSION_ONLY, or PAYMENT_METHOD_CUSTOMER_MISMATCH (409) | The subscription has no payment method, or the one it has isn't active, can be used only with the buyer present, or belongs to another customer. Flint checks this before charging. param is subscription_id. |
SUBSCRIPTION_DELIVERY_UNAVAILABLE (409) | Delivery can't be quoted. |
INVENTORY_INSUFFICIENT (409) | Stock can't cover the shipment. |
SUBSCRIPTION_RENEW_NOT_ALLOWED (409) | The subscription can't be renewed now. reason says why. |
MERCHANT_PROCESSING_RESTRICTED and PAYMENT_PROCESSOR_UNAVAILABLE return with their usual status. A payment provider outage or timeout returns 502 EXTERNAL_SERVICE_ERROR or a 500, never a 402.
SUBSCRIPTION_RENEW_NOT_ALLOWED carries one error.details[] item with a reason, also set at the top level. Flint checks these in order and reports the first that applies:
reason | Meaning |
|---|---|
not_active | The subscription isn't active. current_status names its status. |
cancellation_scheduled | The subscription is set to cancel at the end of its period. Reactivate it first. |
trial_in_progress | The trial hasn't ended. |
external_billing_schedule | An external system owns the billing schedule. |
delivery_hold | The renewal is held. Fix delivery instead. |
renewal_in_progress | A renewal payment is already in progress. This is the only retryable reason: wait for it to finish, then try again. |
New values can be added, so treat an unknown reason as not renewable now.
Move subscribers to another delivery method#
Archiving a method that subscribers use holds each of them at their next renewal. Removing it from a plan's subscription_delivery_method_ids only stops new signups from choosing it. Before archiving, move the subscribers:
curl -X POST https://api.withflintpay.com/v1/subscription-delivery-migrations \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: move-express-to-standard-001" \
-d '{
"from_delivery_method_id": "dmet_1kmn0bExample",
"to_delivery_method_id": "dmet_1kmn0aExample"
}'
Add subscription_plan_id to move only one plan's subscribers; without it, every live subscription on the method moves, including subscriptions from offers. Idempotency-Key is required. The request returns 202 Accepted with the migration, whose ID starts with sdmig_. Read progress with GET /v1/subscription-delivery-migrations/{subscription_delivery_migration_id}, which sends Retry-After until it finishes:
statusispending,running, orcompleted.total_countis fixed when the migration starts, andmoved_count,failed_count, andpending_countalways add up to it.failure_reason_countslists{reason, count}for each reason with at least one failure.
List each failed subscription, with its reason and a message, with GET /v1/subscription-delivery-migrations/{subscription_delivery_migration_id}/failures. Reasons are method_not_offered, destination_not_served, rate_unavailable, method_unavailable (the new method was archived during the move), no_longer_applicable (the subscription changed method or was canceled before its turn), and not_movable (the subscription is still live on the old method, but Flint couldn't move it for another reason or kept failing to; open it to review its delivery). Find a migration again with GET /v1/subscription-delivery-migrations?from_delivery_method_id=dmet_1kmn0bExample. Only one migration at a time can move subscriptions off a method; a second fails with SUBSCRIPTION_DELIVERY_MIGRATION_IN_PROGRESS, naming the one in progress.
For each subscription, Flint checks that its plan or offer offers the new method and previews delivery to its address. It moves the ones that pass and reports the rest; it never moves a subscription to a method its plan or offer doesn't offer. Each moved subscription emits subscription.delivery_updated with changed_by: "migration", and its buyer gets an email naming the new method and the estimated shipping on their next renewal. A held subscription that moves is rechecked at once. When the migration finishes, Flint emits subscription_delivery_migration.completed.
To see who a change affects first, read subscription_counts on a delivery method, or delivery_method_subscription_counts on a plan. Both count active, paused, and past_due subscriptions. Before you change a method's eligibility, send the change to POST /v1/subscription-previews with mode: "delivery_method_update", the delivery_method_id, and the delivery_method body you plan to send. It returns subscription_counts and no_longer_eligible_counts, the subscribers whose addresses would stop qualifying, and saves nothing.
Import subscribers from another tool#
Validate every subscriber before you create any. mode: "create" takes a complete create request, runs every check a real create runs, including the delivery preview, and creates nothing:
curl -X POST https://api.withflintpay.com/v1/subscription-previews \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"mode": "create",
"subscription": {
"subscription_plan_id": "plan_1kmn0aExample",
"customer_id": "cus_1kmn0aExample",
"payment_method_id": "pm_1kmn0aExample",
"billing_start": {
"type": "imported",
"period_started_at": "2026-07-01T00:00:00Z",
"completed_cycles": 10
},
"delivery": {
"type": "shipment",
"delivery_method_id": "dmet_1kmn0aExample",
"destination": {
"type": "customer_address",
"customer_address_id": "caddr_1kmn0aExample"
}
}
}
}'
The response carries is_valid and errors, which lists every failure, not only the first, each with a code, a message, and, when they apply, param and details. When the body is valid, is_valid is true and errors is []. Run the preview for every subscriber, fix what fails, then send the same bodies to POST /v1/subscriptions. completed_cycles makes the next shipment's subscription_cycle continue from the old tool's count, so a subscriber with ten shipments behind them doesn't get a first-box insert. The dashboard's CSV import and the Flint CLI use the same preview.
Saved cards from another processor aren't moved by this flow.
Delivery webhooks#
Renewal shipments use the regular order.fulfillment.* events. Route them by the order's subscription_id, and spot first shipments by subscription_cycle.
Sell subscriptions across your catalog#
A plan sells one fixed set of items, which works for a refill or a box. To let buyers subscribe to any coffee in your store from a product page or a cart, create a subscription offer instead.
Subscribe-and-save offers#
An offer names the products or variants it covers, the cadences buyers choose from, an optional discount, and the delivery methods subscribers can use:
curl -X POST https://api.withflintpay.com/v1/subscription-offers \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: offer-coffee-v1" \
-d '{
"name": "Coffee subscribe and save",
"product_ids": ["prod_1kmn0aExample"],
"billing_interval_options": [
{"billing_interval": "weekly", "billing_interval_count": 2},
{"billing_interval": "weekly", "billing_interval_count": 3},
{"billing_interval": "weekly", "billing_interval_count": 4}
],
"promotion_id": "promo_1kmn0aExample",
"subscription_delivery_method_ids": ["dmet_1kmn0aExample"]
}'
product_idsandvariant_idssay what the offer covers, and at least one must be non-empty. A variant matches when its own ID or its product's ID is listed. A variant can match only oneactiveoffer: creating or activating an offer that overlaps another fails with409, naming the other offer.billing_interval_optionsis required and uses the same item shape and limits as a plan's. An offer has no default interval, so the buyer always picks one.promotion_idis optional and must name a promotion withrecurrence.type: "forever", so the discount applies to every shipment (SUBSCRIPTION_OFFER_PROMOTION_NOT_FOREVERotherwise). A promotion that is archived, disabled, expired, not yet started, used up (max_usesreached), or doesn't target the offer's products fails withSUBSCRIPTION_OFFER_PROMOTION_UNAVAILABLE. An offer applies its promotion without a code, so a code-gated promotion with no active codes works: use one to make a discount only subscribers get. SendnullonPATCHto remove the discount for new signups.- While an
activeoffer uses a promotion, changing that promotion'srecurrence.typeaway fromforeverfails with409 PROMOTION_HAS_ACTIVE_SUBSCRIPTION_OFFERS, listing the offers inblocking_resources. Remove the promotion from those offers or make theminactivefirst. You can still disable, archive, or end the promotion, or let it reachmax_uses: while the promotion can't apply, the offer takes no new signups, and current subscribers keep their discount. Renewals don't count towardmax_uses; only a signup uses the promotion. subscription_delivery_method_idsfollows the same rules as a plan's.- Offers have no
quantity_options. The quantity of the line in the cart is the quantity each shipment carries. statusisactive,inactive, orarchived. Switch betweenactiveandinactivewithPATCH /v1/subscription-offers/{subscription_offer_id}, and archive withDELETE. Archiving is final. Every change affects new signups only; current subscribers keep their interval, delivery methods, and discount.
List offers with GET /v1/subscription-offers, filtered by status, product_id, variant_id, or query, and read one with GET /v1/subscription-offers/{subscription_offer_id}. status=active&variant_id= returns the one active offer for a variant, if any. Find an offer's subscribers with GET /v1/subscriptions?subscription_offer_id=.
Mixed carts#
One checkout can mix one-time and subscribed items. Subscribed lines live on the order: create it with POST /v1/orders, marking each subscribed line with a subscription object, then open checkout for it with POST /v1/checkout-sessions and its order_id. A checkout session takes no line items of its own.
"line_items": [
{
"variant_id": "var_1kmn0aExample",
"quantity": 2,
"subscription": {
"subscription_offer_id": "soffer_1kmn0aExample",
"billing_interval": "weekly",
"billing_interval_count": 2
}
},
{"variant_id": "var_1kmn0bExample", "quantity": 1}
]
subscription_offer_id,billing_interval, andbilling_interval_countare all required, and the interval must be one of the offer's options. A line withoutsubscriptionis a one-time purchase.POST /v1/orders/{order_id}/line-itemstakes the same field.- To switch a line before the order is paid, send
subscription(the object, ornullto make it one-time) with the line's currentversionasexpected_versiontoPATCH /v1/orders/{order_id}/line-items/{order_line_item_id}. Flint's hosted checkout uses the same route with the session's credentials when the buyer switches a line. Once the order has received a payment, the line can't switch (PAID_LINE_ITEM_PRICE_CHANGE_FORBIDDEN). - A subscribed line fails with
SUBSCRIPTION_OFFER_NOT_FOUNDwhen the offer doesn't exist,SUBSCRIPTION_OFFER_NOT_ACTIVEwhen the offer is inactive or archived,SUBSCRIPTION_OFFER_VARIANT_NOT_ELIGIBLEwhen the offer doesn't cover the line's variant or the line isn't a catalog variant,SUBSCRIPTION_BILLING_INTERVAL_NOT_OFFEREDwhen the interval isn't one of the offer's, andSUBSCRIPTION_OFFER_PROMOTION_UNAVAILABLEwhen its discount can no longer apply. SUBSCRIPTION_FULFILLMENT_NOT_SUPPORTED, withparamline_items[i].subscription, means the line is a physical item that can't be shipped or delivered through a delivery method.SUBSCRIPTION_OFFER_ORDER_NOT_ELIGIBLEmeans the order can't start a subscription. A subscribed line starts its own subscription, so it can't be on a subscription renewal order, an order already tied to a subscription or a subscription plan checkout, or an order whose checkout pays an invoice or is a quick pay.paramissubscriptionwhen you switch an existing line,line_items[i].subscriptionwhen you create the order or add lines, andorder_idwhen you open a checkout session for such an order withPOST /v1/checkout-sessionsor pay it. Remove the subscription from the line, or sell the subscribed item in its own checkout.- The signup order holds every line and is paid once. When it is paid, Flint creates one subscription per distinct cadence, holding the subscribed lines with that cadence: two lines every 2 weeks and one every 4 weeks make two subscriptions. They all share the delivery the buyer chose at checkout.
- Each subscribed order line reports the
subscription_idit started, and each subscription line reports itssubscription_offer_id. - A subscription created from an offer has no
subscription_plan_id. Read its cadence and items from the subscription itself; the checkout session'ssubscription_termsdescribes plan signups only. - The
subscription_purchasedelivery condition matches the signup checkout when any line is subscribed. - A payment that starts or renews a subscription carries the 0.40% subscription fee on the whole amount Flint collects, including shipping, tax, and any one-time items in the same cart. To keep one-time items outside it, sell them in a separate checkout.
Each subscription then renews, ships, and offers buyer self-service like one created from a plan, with the offer's intervals as its options.
Show subscribe and save on your product page#
To offer the choice on your own product page, look up the active offer for the variant the buyer is viewing:
curl "https://api.withflintpay.com/v1/subscription-offers?variant_id=var_1kmn0aExample&status=active" \
-H "Authorization: Bearer YOUR_API_KEY"
A variant matches at most one active offer, so the list has one offer or none. With one, show "One-time" next to the offer's billing_interval_options, and its discount if it has a promotion_id. When the buyer subscribes, create the order with that line's subscription set to the offer and the interval they chose, then open checkout with POST /v1/checkout-sessions and the order's order_id, as in Mixed carts. Buyers can also switch a line between one-time and subscribed on Flint's hosted checkout, so a page that only links to checkout still offers the choice. Payment link lines don't carry subscription.
Test your integration#
Billing runs on real schedules; there's no clock to fast-forward. Two habits make renewals testable:
- Use a
dailyplan in your sandbox so renewal events arrive within a day instead of a month. - Pick the test card for the scenario. All of Stripe's test cards work with your
flint_test_...key (any future expiry, any CVC):
| Card number | What it exercises |
|---|---|
4242 4242 4242 4242 | Card setup and every renewal succeed. |
4000 0025 0000 3155 | 3D Secure challenge during card setup (step 4's inline authentication). |
4000 0000 0000 0341 | Saves successfully, then every charge fails: the full past_due and retry path from your webhook feed, without waiting for a real decline. |
Testing has the full card matrix and the webhook test tooling. Cancel test subscriptions when you're done so they stop generating billing noise.
Errors you will hit#
Error handling covers the envelope these arrive in.
Common mistakes#
- Creating the subscription the instant the browser confirms setup. The payment method is briefly still
pending. Gate onpayment_method.savedor a short retry, or you'll ship a race that fails only in production. - Provisioning on the create response for no-trial plans.
incompletemeans unpaid. The firstsubscription.payment_succeededis the provisioning signal. - Expecting plan edits to reprice existing subscribers. Pricing is snapshotted at signup. Price changes are new plans.
- Treating cancel as immediate. The default honors the paid period. Pass
cancel_immediately: truewhen you really mean now. - Trying to resume a
past_duesubscription. Resume is forpaused. Past-due recovers when a charge succeeds, so fix the card instead. - Reading
line_items[].quantityas the shipped quantity. It is the per-unit quantity. Each shipment carries it times the subscription'squantity. - Archiving a delivery method that subscribers use. Each of them is held at their next renewal. Move them with a delivery method migration first; Flint never picks a new method for them.
Next steps#
- Payment links: the hosted, no-frontend signup surface for a plan.
- Webhooks: signature verification, retries, and event inspection.
- Testing: renewal and dunning rehearsal in your sandbox.
- Subscriptions API Reference, Subscription Plans API Reference, Payment Methods API Reference: every field on every endpoint.
- Refunds: refunding a specific billing cycle's order.
