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#

text
plan (pricing) + customer + payment method -> subscription -> order + payment every cycle

Four objects carry the whole system:

ObjectWhat it is
Subscription planReusable pricing: what you charge, how often, the trial, and any setup fee or contract term. One plan serves many subscribers.
CustomerWho is subscribed. Also holds the default payment method.
Payment methodA saved card that Flint can charge without the customer present.
SubscriptionOne 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:

StatusMeaningWhat your app should do
incompleteCreated, 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.
trialingIn the free trial. The card is saved but hasn't been charged.Provision access.
activePaid and current.Provision access.
past_dueA renewal charge failed and Flint is retrying.Keep or degrade access (your call) and prompt the customer to update their card.
pausedBilling is suspended: by you, by the customer, or after retries ran out (if you configure that).Suspend access if your product pauses with billing.
canceledTerminal. 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:

SurfaceUse whenGuide
Payment link with plan_idOne public URL per plan: pricing pages, ads, QR codes. Zero frontend code.Payment Links
Checkout session with plan_idYour 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:

  1. Create a subscription plan (once per price)
  2. Create the customer
  3. Save a payment method
  4. Confirm card setup in the browser
  5. Create the subscription
  6. 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.

Bash
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
      }
    ]
  }'
JSON
{
  "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_interval is daily, weekly, monthly, or yearly; billing_interval_count spaces cycles, so monthly with a count of 3 bills quarterly.
  • Line items can be ad hoc (a name and a unit_price_money, as above) or sold from your catalog by passing variant_id or bundle_id instead. Catalog-backed items can also carry modifiers.
  • trial_period_days starts every subscriber with a free trial (up to 365 days). See Free Trials.
  • setup_fee_money charges 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, and early_termination_fee_money an 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.

Bash
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:

Bash
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"
  }'
JSON
{
  "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 becomes active.
  • client_setup.stripe carries 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.

HTML
<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>
JavaScript
// 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.saved arrives for this payment_method_id.
  • Poll: GET /v1/payment-methods/pm_1kmn0aExample until data.status is active. The window is brief, so a short retry loop on PAYMENT_METHOD_NOT_READY also 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:

Bash
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#

Bash
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"
  }'
JSON
{
  "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_id is optional when the customer has a default payment method; without either, the call fails with PAYMENT_METHOD_REQUIRED.
  • The initial status follows the plan: trialing when the plan has a trial, otherwise incomplete until 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_items is 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:

Bash
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:

EventFires when
subscription.createdA subscription is created, on any surface: API, checkout session, or payment link. Payload includes the initial status and next billing date.
subscription.activatedThe trial ends and the subscription becomes paying.
subscription.payment_succeededA cycle's charge succeeds. Payload includes the order_id and payment_intent_id for that cycle.
subscription.payment_failedA charge fails. Payload includes the order_id when one exists.
subscription.past_dueThe subscription enters past_due after a failed charge.
subscription.pausedThe subscription pauses, by request or because retries exhausted with a pause policy.
subscription.resumedA paused subscription resumes.
subscription.canceledThe 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.savedA saved card finishes setup and becomes usable. Payload includes the card summary.
payment_method.removedA 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_id or its source of subscription.
  • 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:

  1. The subscription moves to past_due. You get subscription.payment_failed and subscription.past_due, and Flint emails the customer a payment-failure notice.
  2. 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.
  3. Any successful retry restores the subscription to active and advances the period; you get subscription.payment_succeeded.
  4. If every retry fails, the subscription is canceled by default (subscription.canceled with reason: "dunning_exhausted"). You can configure the end action to pause instead, which yields subscription.paused and 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:

Bash
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 (and next_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.

Bash
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.

Bash
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#

Bash
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:

Bash
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. Send null to clear an optional field.
  • Line items have dedicated endpoints: POST .../line-items adds, PATCH .../line-items/{subscription_plan_line_item_id} edits, and DELETE .../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#

Bash
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:

Bash
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:

Bash
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 daily plan 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 numberWhat it exercises
4242 4242 4242 4242Card setup and every renewal succeed.
4000 0025 0000 31553D Secure challenge during card setup (step 4's inline authentication).
4000 0000 0000 0341Saves 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#

StatusCodeWhat to do
400PAYMENT_METHOD_NOT_READYCard setup hasn't finished. Wait for payment_method.saved (or poll until active), then retry.
400PAYMENT_METHOD_REQUIREDNo payment_method_id and the customer has no default. Save a card first.
400PAYMENT_METHOD_CUSTOMER_MISMATCHThat payment method belongs to a different customer.
400DUPLICATE_SUBSCRIPTIONThe customer already has a live subscription to this plan. Fetch and manage it instead of creating another.
400PLAN_NOT_ACTIVEThe plan is archived. Subscribe against a current plan.
400CANNOT_PAUSEOnly active subscriptions pause.
400CANNOT_RESUME_PAST_DUE_PAYMENT_REQUIREDpast_due recovers through payment, not resume. Update the payment method and let the retry collect.
400USE_CANCEL_SUBSCRIPTIONPATCH can only clear cancel_at_period_end. Schedule cancellation with the cancel endpoint.
409PLAN_HAS_ACTIVE_SUBSCRIPTIONSArchive 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 on payment_method.saved or a short retry, or you'll ship a race that fails only in production.
  • Provisioning on the create response for no-trial plans. incomplete means unpaid. The first subscription.payment_succeeded is 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: true when you really mean now.
  • Trying to resume a past_due subscription. Resume is for paused. Past-due recovers when a charge succeeds, so fix the card instead.

Next Steps#

Rate this doc