Skip to content

Subscriptions that ship

Some subscriptions send something to the buyer's door each cycle. Plans can ship physical goods, Flint holds a renewal when delivery can't be quoted, and subscribe-and-save offers sell across your catalog, including in a cart with one-time items. Subscription billing covers signup, renewals, failed payments, and cancellation for every subscription, so read it first.

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 variant whose contents you decide when you pack it. 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. Schedules are rolling: each subscriber renews one interval after their last paid shipment, so subscribers to one plan renew on different days.

Moving from another platform? Stripe subscription shipping covers charging and shipping each renewal on Stripe, and physical products on merchant-of-record platforms compares Lemon Squeezy, Paddle, and Polar, which don't allow physical products.

Set up a physical plan#

Give each product a delivery profile, as for one-time sales. Then create the plan with catalog lines and the delivery methods subscribers can choose:

cURL
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": [
      {"type": "variant", "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 policy revision. It's required when any line tracks inventory, physical or digital, and each new subscription copies it. See Stock source.
  • subscription_delivery_method_ids lists the methods subscribers can choose. 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 asks the buyer to choose a delivery window, and those methods are left out of the defaults too. Pickup can't recur.

A plan with physical lines must pass these checks when you create it or change its lines, methods, or trial:

CheckError
Every physical line has a delivery profile, and each profile is set up.409 ACTION_NOT_ALLOWED, reason configuration_required
Each profile allows shipment or local_delivery.400 INVALID_REQUEST, detail code NOT_ALLOWED
All physical lines can ship together. A profile whose combination_policy is separate_profile, separate_line_item, or fulfill_alone splits them. Lines from several origins are fine.400 INVALID_REQUEST, detail code NOT_ALLOWED, for request fields; 409 ACTION_NOT_ALLOWED, reason configuration_required, for stored configuration
At least one offered method can be priced without the buyer and serves every physical line, and no listed method needs the buyer present.400 INVALID_REQUEST, detail code NOT_ALLOWED
The plan has no trial.400 INVALID_REQUEST, detail code NOT_ALLOWED
Lines that track inventory have an inventory_routing_source.400 INVALID_REQUEST, param inventory_routing_source; 409 ACTION_NOT_ALLOWED, reason configuration_required, when replacing lines on a plan without one

Each error names what failed on details[0]: variant_id and product_id for a variant line or the item inside a bundle, blocking_resources for the line's variant or bundle, and delivery_method_id for a listed method. Match these IDs to your lines rather than counting array positions. A catalog item with no kind counts as physical, so it needs a delivery profile before it can go 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:

Response
"eligibility": {"subscription_purchase": {"value": true}}

It matches whenever the quote is for a subscription: the signup checkout, each renewal, and subscription delivery previews, so the first shipment qualifies too. {"value": false} matches one-time purchases only.

Sign up a subscriber#

Hosted checkout and payment links handle a physical plan like a one-time order with shipping: they collect the address, show the plan's methods with prices, and let the buyer pick an offered interval and quantity. The signup order is the first shipment, 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 pricingShown as
fixedOne amount: "$35.00 every month, including $5.00 shipping."
Any otherItems plus shipping: "$30.00 every month, plus shipping ($5.00 today)." Shipping is quoted again each renewal.

The checkout session's subscription_terms.recurring_shipping reports the shipping part of the price (price_type, shipping_money for one shipment, and delivery_method_name), and 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 subscription_terms with billing_interval, billing_interval_count, and quantity, on POST /v1/checkout-sessions or PATCH /v1/checkout-sessions/{checkout_session_id}. Each field is optional, but the interval pair goes together.

  • Only sessions with a subscription_plan_id accept it, including sessions a plan payment link opened. Payment links themselves take no extra fields.
  • A change during a payment fails with 409 OPERATION_IN_PROGRESS.
  • 409 VERSION_CONFLICT without param means the order's recurring lines no longer match the terms the session froze: read the session again, then send the choice against what it shows.
  • A change reprices the signup order and releases any delivery selection, because quantity changes the weight. Quote delivery again afterward.

To create a physical subscription through the API, send delivery with the create. Without it, the create fails with 400 INVALID_REQUEST, detail code REQUIRED, param delivery. Flint quotes delivery for the method and address before creating anything, so a method that can't serve the address fails now rather than at the first renewal.

cURL
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 '{
    "terms": {
      "type": "subscription_plan",
      "subscription_plan_id": "plan_1kmn0aExample",
      "billing_interval": "weekly",
      "billing_interval_count": 2,
      "quantity": 2
    },
    "customer_id": "cus_1kmn0aExample",
    "payment_method_id": "pm_1kmn0aExample",
    "billing_start": {"type": "immediate"},
    "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:

Response
"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"},
  "delivery_version": 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_by": "buyer",
  "updated_at": "2026-07-02T17:04:05Z"
}
  • type is shipment or local_delivery.
  • destination comes from a saved customer address ("type": "customer_address") or an inline one ("type": "address"). Flint copies the address when delivery is written, so editing the customer's address book later doesn't move shipments; update delivery to do that. A customer_address_id of another customer fails with 400 INVALID_REQUEST. Responses return the copied address, plus customer_address_id for display when it came from a saved address.
  • recipient can name someone other than the buyer.
  • delivery_version goes up by one each time delivery changes, separately from the subscription's version.
  • delivery_method describes the stored method for display. Its price_type is fixed when shipping costs the same every time, and quoted when it's quoted for each shipment.
  • shipping_money is one shipment's shipping: the method's price for fixed, or the latest shipment's charge for quoted. It's absent until a quoted method has charged once.
  • address_verification.state is verified, needs_review, unverified, or unverifiable. Flint verifies whenever delivery is written. Renewals use the stored result and never verify again, so an address accepted at signup never holds a renewal.
  • updated_by is buyer, merchant, or migration.

delivery is cleared when the subscription is canceled. Each renewal order keeps its own destination.

Change the delivery preference#

Replace delivery as a whole with PATCH /v1/subscriptions/{subscription_id} and the current version as expected_version:

cURL
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 quotes delivery before saving, and refuses a method that can't serve the new address with a delivery error. To see which methods serve an address first, preview delivery options. The method must be one the plan offers (400 INVALID_REQUEST otherwise). A subscriber keeps a method you've since removed from the plan, but can't switch back to it after changing. Flint emails the buyer about the change and sends subscription.delivery_updated with delivery.updated_by: "merchant".

The change applies from the next renewal that isn't paid:

  • If it 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 it's paid but not shipped, it still goes to the address it was paid with. subscription.delivery_updated carries its open_renewal_order_id, and the order reports subscription_delivery_changed_at, so you can contact the buyer, redirect it, or refund and cancel it before packing. List these orders with GET /v1/orders?subscription_delivery_changed=true.
  • If its payment is in flight, it keeps the address it was quoted with, and is flagged as above once paid.

Send a paid renewal to the new address#

A paid order that hasn't shipped can go to the new address when that changes nothing the buyer paid. Send the new delivery_destination with the order's current version as expected_version:

cURL
curl -X PATCH https://api.withflintpay.com/v1/orders/ord_1kmn0aExample \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{
    "expected_version": 4,
    "delivery_destination": {
      "address": {
        "line1": "48 Birch Ave",
        "city": "Portland",
        "state": "OR",
        "postal_code": "97214",
        "country": "US"
      }
    }
  }'

It works with your API key or the dashboard, on a paid subscription order with nothing shipped. Flint quotes the order's method at the new address and recalculates tax there. If shipping and tax both stay the same, the order and its unshipped fulfillments move, subscription_delivery_changed_at clears, and you get order.updated and fulfillment.updated. The charges and payment don't change.

  • HTTP 409
    Reason payment_collected: the new address would change the shipping or tax the buyer paid. details carries shipping_money and requoted_shipping_money, or tax_money and requoted_tax_money, when Flint computed them. Refund and cancel the order, or contact the buyer.
  • HTTP 409
    Reason field_locked: the order isn't a paid subscription order, something already shipped, or the request didn't come from you.
  • HTTP 400
    Detail code REQUIRED, param expected_version: a paid order needs expected_version whenever you send delivery_destination.
  • HTTP 409
    VERSION_CONFLICTRefresh, then retry
    expected_version is stale. Read the order again; details[0].current_version has its current version.

When a delivery preference can't ship#

409 ACTION_NOT_ALLOWED means the delivery method, address, or stored input must change, and the subscription is left as it was. Its reasons are method_unavailable, destination_not_served, and configuration_required (an invalid stored recipient, or input the method needs), with remediation.retry: none. delivery_method_id names the method, and when they apply, unavailable_reason and failure_category take the same values as delivery previews. param names the delivery field to change when the request supplied it.

When a rate can't be calculated, the response is 502 EXTERNAL_SERVICE_ERROR, reason rate_unavailable, remediation.retry: same_request: retry with the same Idempotency-Key after a backoff. When your own rate callback fails, times out, or answers badly, the reason is rate_callback_failed instead, with remediation.retry: same_request and error_source: merchant_endpoint. Restore the callback, then send the request again.

These errors can block saving delivery, resuming from a delivery pause, Order now, a plan change, and a next-charge preview.

Preview delivery options#

Before saving a new address, ask which of the plan's methods serve it and what they cost:

cURL
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's delivery_methods lists the plan's methods, then the subscription's current method if the plan no longer offers it. Each has availability (available, unavailable, or cannot_calculate), amount_money for one shipment when available, price_type, current, and selectable; unavailable methods carry unavailable_reason and failure_category. The response also gives address_verification for the destination. Nothing is saved, so every preview mode needs only the commerce.subscriptions.read scope. Buyers call the same preview at POST /v1/me/subscription-previews.

What a physical renewal does#

  1. Reminder. Before billing, Flint sends subscription.renewal_upcoming and emails the buyer the items, address, method, and estimated total, with links to skip, change the address, or pause. If delivery already can't be quoted, the subscription reports upcoming_delivery_hold and the buyer gets the hold email, so it can be fixed before the billing date.
  2. Charge. On the billing date, Flint checks the method against the address, creates the renewal order, checks stock, quotes and selects delivery, adds shipping, calculates tax for the address, and charges the card. Flint never charges for a shipment it can't route.
  3. Fulfill. The paid order has an unfulfilled shipment and sends fulfillment.created. Ship it with your usual fulfillment calls; tracking reaches the buyer through Flint's fulfillment emails.

The renewal order carries origin: "subscription", subscription_id, delivery_destination, and subscription_cycle. subscription_cycle is 1 for the first charged cycle and goes up only when a cycle is charged: skipped and held cycles don't use a number, and retries keep theirs, so subscription_cycle: 4 is always the fourth shipment. Use it for first-box inserts or milestone gifts. 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 variant's current unit_weight: a fixed method charges the same every time, and other methods follow your current rates. Item prices stay as they were at signup.

Late renewals move the schedule. When a physical renewal on a flint schedule is paid more than 24 hours after its billing date, for any reason, the next billing date becomes the payment date plus one interval, and for monthly and yearly plans billing_anchor_day moves to the payment day. Shipments stay one interval apart instead of arriving back to back. External schedules 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. No renewal order exists while the hold lasts; an unpaid one for that billing date, such as after a decline, is canceled, and a new one is created when the hold clears. The subscription reports delivery_hold, and Flint sends subscription.delivery_action_required:

delivery_hold.reasonCausefixable_byChecked again
method_unavailableThe method was archived or no longer serves the address, and another offered method does.buyer, or merchant when buyer_capabilities.can_update_delivery is falseDaily
destination_not_servedNo offered method serves the address.merchantDaily
rate_unavailableThe method serves the address but can't be priced: a rate callback error or timeout, a failed calculated rate, or a variant with no unit_weight.merchantHourly, backing off to daily

delivery_hold also carries started_at and ends_at. The buyer gets an email for the reason, asking them to choose another method, or saying the store doesn't ship to their address, with a link to change it. Each check uses the current delivery preference and variant weights, so fixing the variant or the address clears the hold at the next check. Changing delivery, or moving the subscription with a migration, checks at once.

The hold ends 14 days or one billing interval after the billing date, whichever is sooner. Three days before, Flint sends subscription.delivery_pause_upcoming and reminds the buyer. A hold resolved in time charges and ships right away, then moves the schedule. A hold that runs out pauses the subscription with pause_reason: "delivery_action_required", with no end date, and the held cycle is never charged. Resuming it needs a delivery preference Flint can quote; the resume then starts a new period and charges and ships right away.

Flint never moves a subscriber to another method or rate on its own, because that would charge for something they didn't choose. To move subscribers yourself, use a delivery method migration.

During a hold, the buyer or youResult
Change the address or methodChecked at once. If it passes, the held renewal is charged and shipped.
SkipEnds the hold without a charge. The next billing date is the held date plus one interval.
PauseEnds the hold without a charge and pauses with pause_reason: "delivery_action_required". A pause with a length that ends while delivery still can't be quoted starts a new hold.
CancelCancels now without charging the held cycle, even when asked for period end.
Retry paymentRefused with reason delivery_hold. Fixing delivery charges the renewal.

Out-of-stock renewals work as they do on every plan: the inventory policy's subscription_inventory_block_action decides, with no time limit.

Renewals that need attention#

Two more fields report renewals that aren't shipping yet but aren't held:

  • upcoming_delivery_hold means a delivery check before the billing date failed, so the renewal will be held unless delivery is fixed first. It has the same reason and fixable_by as delivery_hold, plus detected_at and renewal_at (the billing date at risk). Flint sets it when the renewal reminder's check fails, and clears it when a later check passes, the cycle is skipped, the subscription is paused or canceled, or the billing date moves. At the billing date it becomes delivery_hold if delivery still can't be quoted. Flint sends subscription.updated when it's set and cleared, and buyers see it through /v1/me.
  • inventory_wait means the renewal is waiting on stock under the invoice path of subscription_inventory_block_action. It carries started_at, the blocked order_id, and its invoice_id, and clears when the renewal is paid or replaced, or the cycle is skipped, paused, or canceled. A stock-out on the past-due path shows as past_due instead. Only your API key reads it. Collection blocks covers that invoice, and GET /v1/invoices?subscription_id= lists the subscription's invoices.

GET /v1/subscriptions?needs_attention=true lists every subscription that needs you:

  • past_due, or active or incomplete with a billing date more than 24 hours overdue;
  • with a delivery_hold, an upcoming_delivery_hold, or an inventory_wait;
  • paused with pause_reason: "delivery_action_required".

needs_attention=false returns the rest. hold_reason filters by delivery_hold.reason only.

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 only stops new signups from choosing it. Before archiving, move the subscribers:

cURL
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 one plan's subscribers; without it, every live subscription on the method moves, including offer subscriptions. The request returns 202 Accepted with the migration (sdmig_...). Follow it with GET /v1/subscription-delivery-migrations/{subscription_delivery_migration_id}, which sends Retry-After until it finishes:

  • status is pending, processing, or completed. processing means Flint is checking subscriptions and moving those that can use the new delivery method.
  • moved_count, failed_count, and pending_count always add up to total_count.
  • failure_reason_counts lists {reason, count} for each reason with a failure.

For each subscription, Flint checks that its plan or offer offers the new method and quotes delivery to its address, moves the ones that pass, and never moves one to a method its plan or offer doesn't offer. Each move sends subscription.delivery_updated with updated_by: "migration" and emails the buyer the new method and estimated shipping. A held subscription that moves is checked at once. Flint sends subscription_delivery_migration.completed at the end.

List failures with GET /v1/subscription-delivery-migrations/{subscription_delivery_migration_id}/failures, each with a reason and message:

reasonMeaning
method_not_offeredThe plan or offer doesn't offer the new method.
destination_not_served, rate_unavailableThe new method can't serve or price the subscriber's address.
method_unavailableThe new method was archived during the move.
no_longer_applicableThe subscription changed method or was canceled before its turn.
not_movableStill on the old method, but Flint couldn't move it. Open it to review its delivery.

Only one migration at a time can move subscribers off a method; a second fails with 409 OPERATION_IN_PROGRESS, naming the first. Find migrations with GET /v1/subscription-delivery-migrations?from_delivery_method_id=.

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

Delivery webhooks#

Renewal shipments use the regular fulfillment.*, shipment.*, and package.* 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 suits 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.

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
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_ids, variant_ids. What the offer covers; at least one must be non-empty. A variant matches when its ID or its product's ID is listed, and it can match only one active offer: an overlapping create or activation fails with 409, naming the other offer.
  • billing_interval_options (required). Same shape and limits as a plan's. An offer has no default, so the buyer always picks.
  • promotion_id. Optional, and must have recurrence.type: "forever" so it discounts every shipment (400 INVALID_REQUEST, detail code NOT_ALLOWED). The promotion must be available, or unavailable only because it has no active codes: an offer applies its promotion without a code, so a code-gated promotion with no codes makes a discount only subscribers get. A promotion that can't apply, or doesn't target the offer's products, fails with 409 ACTION_NOT_ALLOWED, reason unsupported_state. Send null on PATCH to drop the discount for new signups.
  • subscription_delivery_method_ids. Same rules as a plan's.
  • external_reference_id. Optional, 1 to 255 characters: the offer's ID in another system, such as a selling plan. Not unique. Send null on PATCH to clear it.
  • status. active or inactive on create and PATCH; DELETE archives an offer for good.

Offers have no quantity_options: the line's quantity in the cart is what each shipment carries. Every change to an offer affects new signups only; current subscribers keep their interval, delivery methods, and discount.

While an active offer uses a promotion, changing that promotion's recurrence.type away from forever fails with 409 RESOURCE_IN_USE, listing the offers. You can still disable, archive, or end the promotion, or let it reach max_uses: until it can apply again, the offer takes no new signups, and current subscribers keep their discount. Only a checkout signup counts toward max_uses; renewals and API-created subscriptions don't.

List offers with GET /v1/subscription-offers, filtered by status, product_id, variant_id, external_reference_id (exact match), query (name, ID, and reference), and created or updated time. 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 subscription, then open checkout with POST /v1/checkout-sessions, type: "order", and its order_id.

Response
"line_items": [
  {
    "type": "variant",
    "variant_id": "var_1kmn0aExample",
    "quantity": 2,
    "subscription": {
      "subscription_offer_id": "soffer_1kmn0aExample",
      "billing_interval": "weekly",
      "billing_interval_count": 2
    }
  },
  {"type": "variant", "variant_id": "var_1kmn0bExample", "quantity": 1}
]
  • subscription_offer_id, billing_interval, and billing_interval_count are all required, and the interval must be one of the offer's options. A line without subscription is a one-time purchase. POST /v1/orders/{order_id}/line-items takes the same field.
  • To switch a line before the order is paid, send subscription (or null for one-time) with the order's version as expected_version to PATCH /v1/orders/{order_id}/line-items/{order_line_item_id}. Flint's hosted checkout uses the same route when the buyer switches. After a payment, the line can't switch (409 ACTION_NOT_ALLOWED, reason payment_collected).
  • When the order is paid, Flint creates one subscription per distinct cadence: two lines every 2 weeks and one every 4 weeks make two subscriptions, sharing the delivery chosen at checkout. Each subscribed order line reports the subscription_id it started.
  • A subscribed line can't go on an order that can't start a subscription: a renewal, an order already tied to a subscription or a plan checkout, or one paid through an invoice or quick pay (400 INVALID_REQUEST, detail code NOT_ALLOWED). Sell the subscribed item in its own checkout.
  • The subscription fee applies to the whole payment that starts a subscription, including shipping, tax, and one-time items in the same cart. To keep one-time items outside it, sell them in a separate checkout.
A subscribed line fails withWhen
404 RESOURCE_NOT_FOUND, param naming subscription_offer_idThe offer doesn't exist.
409 ACTION_NOT_ALLOWED, reason unsupported_stateThe offer is inactive or archived, or its discount can't apply.
400 INVALID_REQUESTThe offer doesn't cover the line's variant, the line isn't a catalog variant, or the interval isn't one of the offer's.
400 INVALID_REQUEST, detail code NOT_ALLOWED, param line_items[i].subscriptionThe line is a physical item that no delivery method can ship.

Create or import an offer subscription directly#

To start an offer subscription without a checkout, such as when you move a subscribe-and-save subscriber from another tool, send terms with type: "subscription_offer":

cURL
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-coffee-001" \
  -d '{
    "terms": {
      "type": "subscription_offer",
      "billing_interval": "weekly",
      "billing_interval_count": 2,
      "line_items": [
        {
          "variant_id": "var_1kmn0aExample",
          "subscription_offer_id": "soffer_1kmn0aExample",
          "quantity": 2
        }
      ]
    },
    "customer_id": "cus_1kmn0aExample",
    "payment_method_id": "pm_1kmn0aExample",
    "billing_start": {"type": "immediate"},
    "delivery": {
      "type": "shipment",
      "delivery_method_id": "dmet_1kmn0aExample",
      "destination": {
        "type": "customer_address",
        "customer_address_id": "caddr_1kmn0aExample"
      }
    }
  }'
  • Interval. Required, and it must be in the billing_interval_options of every line's offer (400 INVALID_REQUEST, detail code INVALID_VALUE, param terms.billing_interval). The subscription's own options are the ones all its offers share.
  • Lines. Each names a variant_id and the subscription_offer_id that covers it, with a quantity from 1 to 9,999 (default 1). A variant can appear once. Lines can come from several offers and all start in one subscription, whose own quantity is 1.
  • Offers. Each must be active, including for an import (409 ACTION_NOT_ALLOWED, reason unsupported_state). An unknown offer is 404 RESOURCE_NOT_FOUND, and a variant the offer doesn't cover is 400 INVALID_REQUEST, detail code INVALID_VALUE. param names the entry and field, such as terms.line_items[1].subscription_offer_id.
  • Prices. Taken from the catalog at create, then fixed. All lines must share one currency. An offer subscription has no trial, setup fee, or contract term.
  • Delivery. Required when any line ships and rejected otherwise. The method must be one every shipping line's offer allows; an offer that lists none allows the store's checkout defaults. Flint quotes delivery before it creates anything.
  • Billing start. Works as for a plan. Use imported, never immediate, for a subscriber who already paid elsewhere.

Discounts. When an offer has a promotion, Flint attaches it to the subscription as part of the create, before any charge, so an immediate start charges the discounted price from the first cycle, under the promotion's own conditions. It covers only lines from offers carrying that promotion, not lines from other offers or lines you add later. Editing or ending the promotion afterward doesn't change the subscription. A promotion that can't apply now, including one at max_uses, blocks the create with 409 ACTION_NOT_ALLOWED, reason unsupported_state; to import a subscriber whose discount has lapsed, fix or replace the promotion on the offer first.

Entitlements for offer subscriptions#

terms_type says where a subscription's terms come from. It's on every subscription you read, on /v1/me/subscriptions, and on every subscription.* webhook:

terms_typeMeaning
subscription_planThe terms come from the plan in subscription_plan_id.
subscription_offerThe terms come from offers. There's no subscription_plan_id, and each line from an offer carries its subscription_offer_id.
  • Branch on terms_type, not on whether subscription_plan_id is present.
  • For subscription_offer, decide what the subscriber gets from line_items[].variant_id and line_items[].subscription_offer_id. One subscription can hold lines from several offers, and a line you add later has no subscription_offer_id.
  • terms_type never changes, even when lines are added, changed, or removed.
  • GET /v1/subscriptions?terms_type=subscription_offer lists every offer subscription.

Show subscribe and save on your product page#

Look up the active offer for the variant the buyer is viewing:

cURL
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" beside the offer's billing_interval_options, and its discount if it has a promotion_id. When the buyer subscribes, create the order with the line's subscription set, then open checkout, 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.

Common mistakes#

  • Reading line_items[].quantity as the shipped quantity. It's per unit; each shipment carries it times the subscription's quantity.
  • Archiving a delivery method subscribers use. Each of them is held at their next renewal. Migrate them first.

Next steps#

Subscription billingPlans, signup, renewals, failed payments, and cancellation for every subscription.Delivery optionsDelivery methods, profiles, and pricing that a plan or offer lists.Subscription plans API referenceEvery plan field, including the stock source and delivery methods.Subscription offers API referenceCreate, list, and change subscribe-and-save offers.

Was this helpful?