Subscription Billing
Flint runs recurring billing for you: you define a plan, attach a customer and a saved payment method, and 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.
This guide is the complete integration path: model your pricing, sign a customer up, grant access from webhooks, and handle everything that happens after the first payment, which is where most subscription integrations are won or lost.
If you just want a hosted signup page and no frontend work, you only need the first section and a payment link. The rest of this guide still applies once subscriptions exist: 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, and any setup fee or contract term. One plan serves many subscribers. |
| 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: current status, period dates, and the next billing date. |
Each billing cycle, Flint creates an order from the subscription (its source is subscription and it carries the subscription_id), charges the saved payment method, and emits webhooks for the outcome. Your systems observe the subscription's status; Flint does the scheduling, charging, and retrying.
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.
Subscription Statuses#
A subscription is always in exactly one of six states. Key your access logic on them:
| Status | Meaning | What your app should do |
|---|---|---|
incomplete | Created, but the first payment hasn't succeeded yet. Plans without a trial start here. | Don't provision yet. Wait for the first subscription.payment_succeeded. |
trialing | In the free trial. The card is saved but hasn't been charged. | Provision access. |
active | Paid and current. | Provision access. |
past_due | A renewal charge failed and Flint is retrying. | Keep or degrade access (your call) and prompt the customer to update their card. |
paused | Billing is suspended: by you, by the customer, or after retries ran out (if you configure that). | Suspend access if your product pauses with billing. |
canceled | Terminal. Either canceled immediately or reached the end of its final paid period. | Revoke access. |
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 plan_id | One public URL per plan: pricing pages, ads, QR codes. Zero frontend code. | Payment Links |
Checkout session with plan_id | Your app triggers signup for one known buyer and redirects them to a Flint-hosted page. | Checkout Sessions |
| API-driven (this guide) | Signup happens inside your own UI: your form, your styling, your flow. | 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 API-driven 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": {
"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.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. trial_period_daysstarts every subscriber with a free trial (up to 365 days). 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 at cancel time 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. 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": {
"client_secret": "seti_1AbcPlaceholder_secret_XyzPlaceholder",
"account_id": "acct_1AbcPlaceholder",
"publishable_key": "pk_test_51AbcPlaceholder"
}
}
}
}
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. Flint processes cards on Stripe; you don't need your own Stripe account, and your Flint API key never leaves your backend.
Send client_secret, 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 '{
"plan_id": "plan_1kmn0aExample",
"customer_id": "cus_1kmn0aExample",
"payment_method_id": "pm_1kmn0aExample"
}'
{
"data": {
"subscription_id": "sub_1kmn0aExample",
"plan_id": "plan_1kmn0aExample",
"customer_id": "cus_1kmn0aExample",
"payment_method_id": "pm_1kmn0aExample",
"status": "trialing",
"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": [
{
"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.- 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.line_itemsis the pricing snapshot this subscription will bill on, every cycle, regardless of later plan edits.- A customer can hold one live subscription per plan; a second create returns
DUPLICATE_SUBSCRIPTION.
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.activated",
"subscription.payment_succeeded",
"subscription.payment_failed",
"subscription.past_due",
"subscription.paused",
"subscription.resumed",
"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 | Fires when |
|---|---|
subscription.created | A subscription is created, on any surface: API, checkout session, or payment link. Payload includes the initial status and next billing date. |
subscription.activated | The trial ends and the subscription becomes paying. |
subscription.payment_succeeded | A cycle's charge succeeds. Payload includes the order_id and payment_intent_id for that cycle. |
subscription.payment_failed | A charge fails. Payload includes the order_id when one exists. |
subscription.past_due | The subscription enters past_due after a failed charge. |
subscription.paused | The subscription pauses, by request or because retries exhausted with a pause policy. |
subscription.resumed | A paused subscription resumes. |
subscription.canceled | The subscription ends: immediately, at period end, or after retries exhausted. Where relevant the payload's reason says why, for example period_end or dunning_exhausted. |
payment_method.saved | A saved card finishes setup and becomes usable. Payload includes the card summary. |
payment_method.removed | A payment method is removed. |
Event payloads carry identifiers (subscription_id, plan_id, 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, current_period_end, and next_billing_at; over webhooks you'll get subscription.payment_succeeded with the cycle's order_id and payment_intent_id.
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 itssourceofsubscription. - The customer gets a normal Flint receipt per cycle.
The subscription also emits the standard order and payment events for each cycle, so order-keyed fulfillment pipelines keep working without special cases.
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, the subscription is
canceledby default (subscription.canceledwithreason: "dunning_exhausted"). You can configure the end action to pause instead, which yieldssubscription.pausedand preserves the subscription for manual recovery.
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 PATCH https://api.withflintpay.com/v1/subscriptions/sub_1kmn0aExample \
-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.
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 begging email.
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.
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), and pausing an already-paused subscription is a harmless no-op.
curl -X POST https://api.withflintpay.com/v1/subscriptions/sub_1kmn0aExample/resume \
-H "Authorization: Bearer YOUR_API_KEY"
Resuming is fair to the customer by construction: time already paid for is preserved. If the subscription was paused mid-period, the remaining prepaid days pick up where they left off; only if the paid period lapsed entirely during the pause does a fresh cycle (and charge) begin.
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.
A scheduled cancellation can be reversed any time before the period ends:
curl -X PATCH https://api.withflintpay.com/v1/subscriptions/sub_1kmn0aExample \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{"cancel_at_period_end": false}'
(Scheduling a cancel always goes through the cancel endpoint; PATCH only accepts cancel_at_period_end: false, and setting true returns USE_CANCEL_SUBSCRIPTION.)
If the plan has a contract term, the cancel response includes contract_info alongside the subscription: whether the cancellation falls inside the term (is_within_contract_term), the remaining_months, and any early_termination_fee_money, so your integration can present or apply the consequences.
Update#
PATCH /v1/subscriptions/{subscription_id} accepts payment_method_id (an active payment method belonging to the same customer), cancel_at_period_end: false, and metadata. Everything else about a running subscription (its pricing, its interval) is fixed by the snapshot; changing the deal means a new subscription on a new plan.
Manage Plans Over Time#
Plans have their own management surface:
PATCH /v1/subscription-plans/{plan_id}updates presentation and terms for future subscribers:name,description,trial_period_days,setup_fee_money,contract_term_months,early_termination_fee_money,image_url,metadata. Sendnullto clear an optional field.- Line items have dedicated endpoints:
POST .../line-itemsadds,PATCH .../line-items/{subscription_plan_line_item_id}edits, andDELETE .../line-items/{subscription_plan_line_item_id}removes.
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 POST https://api.withflintpay.com/v1/subscription-plans/plan_1kmn0aExample/archive \
-H "Authorization: Bearer YOUR_API_KEY"
Archiving retires a plan 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. There is no delete; 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, plan_id, 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.
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#
| Status | Code | What to do |
|---|---|---|
| 400 | PAYMENT_METHOD_NOT_READY | Card setup hasn't finished. Wait for payment_method.saved (or poll until active), then retry. |
| 400 | PAYMENT_METHOD_REQUIRED | No payment_method_id and the customer has no default. Save a card first. |
| 400 | PAYMENT_METHOD_CUSTOMER_MISMATCH | That payment method belongs to a different customer. |
| 400 | DUPLICATE_SUBSCRIPTION | The customer already has a live subscription to this plan. Fetch and manage it instead of creating another. |
| 400 | PLAN_NOT_ACTIVE | The plan is archived. Subscribe against a current plan. |
| 400 | CANNOT_PAUSE | Only active subscriptions pause. |
| 400 | CANNOT_RESUME_PAST_DUE_PAYMENT_REQUIRED | past_due recovers through payment, not resume. Update the payment method and let the retry collect. |
| 400 | USE_CANCEL_SUBSCRIPTION | PATCH can only clear cancel_at_period_end. Schedule cancellation with the cancel endpoint. |
| 409 | PLAN_HAS_ACTIVE_SUBSCRIPTIONS | Archive blocked while subscribers remain (likewise PLAN_HAS_ACTIVE_PAYMENT_LINKS, PLAN_HAS_OPEN_CHECKOUT_SESSIONS). |
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.
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.
