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#

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, schedule owner, period dates, and optional next billing date.

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.

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:

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
Embedded checkout with plan_idYour own signup UI collects a fresh payment method for an initial charge or a zero-balance trial.Headless subscription signup
Direct subscription APICreate 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:

  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": {
        "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 becomes active.
  • client_setup.stripe carries everything your frontend needs. Its setup_intent.stripe_js_call names the operation to perform now, and setup_intent.client_secret is 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.

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",
    "billing_start": {"type": "immediate"},
    "billing_schedule": {"owner": "flint"}
  }'
JSON
{
  "data": {
    "subscription_id": "sub_1kmn0aExample",
    "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_id is optional when the customer has a default payment method; without either, the call fails with PAYMENT_METHOD_REQUIRED.
  • billing_start is required. Use immediate, scheduled, or imported.
  • billing_schedule.owner is flint or external. If omitted, the effective subscription setting supplies the owner.
  • 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. Send it only with an explicit "billing_schedule": {"owner": "flint"}, since an external schedule sets each date directly.
  • line_items is the pricing snapshot this subscription will bill on, every cycle, regardless of later plan edits. Each line has a stable subscription_line_item_id.
  • service_location can snapshot either a customer address or an inline address when the subscription represents work at a physical location.
  • 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:

TypeFieldsBehavior
immediatetypeStarts now. A plan without a trial begins the first charge flow immediately.
scheduledtype, starts_atStarts 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.
importedtype, period_started_atImports 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.

Import an agreement that was paid elsewhere:

Bash
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 '{
    "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:

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.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.canceled",
      "payment_method.saved",
      "payment_method.removed"
    ]
  }'

Store data.secret and verify signatures as described in Webhooks. The full event set for recurring billing:

A subscription is created, on any surface: API, checkout session, or payment link. Payload includes the initial status and optional next_billing_at.
Schedule fields changed. The compact payload carries public changed_fields, previous_values, and initiated_by; fetch the subscription for current state.
The trial ends and the subscription becomes paying.
A trial is approaching its end.
The scheduled renewal is approaching. Rescheduling produces a new reminder for the new schedule revision.
A cycle's charge succeeds. Payload includes the order_id and payment_intent_id for that cycle.
A charge fails. Payload includes the order_id when one exists.
The subscription enters past_due after a failed charge.
Automatic retries are exhausted. Payload includes the resolved end action: cancel, pause, or notify_only.
The subscription pauses, by request or because retries exhausted with a pause policy.
A paused subscription resumes.
The subscription ends immediately, at period end, or under a cancel action after retries are exhausted.
A saved card finishes setup and becomes usable. Payload includes the card summary.
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 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.

Control the billing schedule#

billing_schedule_owner says who supplies billing dates:

  • flint: Flint computes dates from the plan interval and billing_anchor_day. next_billing_at is required when you explicitly update this schedule.
  • external: Your integration sets each next_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:

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

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

Skip exactly one plan interval. The subscription must be active and already have a next_billing_at, so a trialing subscription and an external one awaiting a date both reject the skip:

Bash
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"}'

Schedule updates and skips return the updated subscription and emit one subscription.updated event when an effective value changed. 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_id or its origin 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, Flint emits subscription.dunning_exhausted with the resolved end action. Flint-owned subscriptions default to cancel. External subscriptions default to notify_only, which leaves the subscription past_due and ready for manual recovery. You can configure owner-specific cancel, pause, or notify_only behavior 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:

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.

Start a manual recovery attempt after updating the card:

Bash
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; anything else fails with SUBSCRIPTION_PAYMENT_RETRY_NOT_ALLOWED. 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":

Bash
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 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. A failed manual attempt does not consume the automatic retry budget or move the next scheduled retry.

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 (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 preserves time the customer already paid for. 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, metadata, and images. Send null to clear an optional field.
  • To replace the ordered image gallery, send the complete images array with the plan's current version as expected_version. Send images: [] to clear it.
  • 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 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:

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#

400
Card setup hasn't finished. Wait for payment_method.saved (or poll until active), then retry.
400
No payment_method_id and the customer has no default. Save a card first.
400
That payment method belongs to a different customer.
400
The plan is archived. Subscribe against a current plan.
400
billing_start is missing, or the branch carries a field that doesn't belong to it. Send exactly one of immediate, scheduled, or imported.
400
A scheduled start on a plan that has a trial. Pick one.
400
The imported period has already ended, or period_started_at is in the future. Send the start of the period the buyer is in now.
400
billing_anchor_day arrived without an explicit "billing_schedule": {"owner": "flint"}.
400
The customer_address_id doesn't resolve, or belongs to a different customer.
409
The status blocks the schedule change. Read the status in the error details.
400
A Flint-owned schedule can move at most one billing interval forward. Move it in steps, or hand the schedule to external.
400
Only an external schedule can clear next_billing_at.
400
External schedules set each date directly and reject billing_anchor_day.
409
Manual retries are for past_due subscriptions only.
409
Only active subscriptions pause.
409
past_due recovers through payment, not resume. Update the payment method and create a payment retry.
400
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 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