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 -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_sourcesays 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_idslists 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 withcaller_suppliedpricing 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:
| Check | Error |
|---|---|
| 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:
"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 pricing | Shown as |
|---|---|
fixed | One amount: "$35.00 every month, including $5.00 shipping." |
| Any other | Items 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_idaccept 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_CONFLICTwithoutparammeans 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 -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:
"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"
}
typeisshipmentorlocal_delivery.destinationcomes from a saved customer address ("type": "customer_address") or an inline one ("type": "address"). Flint copies the address whendeliveryis written, so editing the customer's address book later doesn't move shipments; updatedeliveryto do that. Acustomer_address_idof another customer fails with400 INVALID_REQUEST. Responses return the copiedaddress, pluscustomer_address_idfor display when it came from a saved address.recipientcan name someone other than the buyer.delivery_versiongoes up by one each timedeliverychanges, separately from the subscription'sversion.delivery_methoddescribes the stored method for display. Itsprice_typeisfixedwhen shipping costs the same every time, andquotedwhen it's quoted for each shipment.shipping_moneyis one shipment's shipping: the method's price forfixed, or the latest shipment's charge forquoted. It's absent until aquotedmethod has charged once.address_verification.stateisverified,needs_review,unverified, orunverifiable. Flint verifies wheneverdeliveryis written. Renewals use the stored result and never verify again, so an address accepted at signup never holds a renewal.updated_byisbuyer,merchant, ormigration.
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 -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_updatedcarries itsopen_renewal_order_id, and the order reportssubscription_delivery_changed_at, so you can contact the buyer, redirect it, or refund and cancel it before packing. List these orders withGET /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 -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.
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 -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#
- Reminder. Before billing, Flint sends
subscription.renewal_upcomingand 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 reportsupcoming_delivery_holdand the buyer gets the hold email, so it can be fixed before the billing date. - 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.
- 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.reason | Cause | fixable_by | Checked again |
|---|---|---|---|
method_unavailable | The method was archived or no longer serves the address, and another offered method does. | buyer, or merchant when buyer_capabilities.can_update_delivery is false | Daily |
destination_not_served | No offered method serves the address. | merchant | Daily |
rate_unavailable | The method serves the address but can't be priced: a rate callback error or timeout, a failed calculated rate, or a variant with no unit_weight. | merchant | Hourly, 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 you | Result |
|---|---|
| Change the address or method | Checked at once. If it passes, the held renewal is charged and shipped. |
| Skip | Ends the hold without a charge. The next billing date is the held date plus one interval. |
| Pause | Ends 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. |
| Cancel | Cancels now without charging the held cycle, even when asked for period end. |
| Retry payment | Refused 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_holdmeans a delivery check before the billing date failed, so the renewal will be held unless delivery is fixed first. It has the samereasonandfixable_byasdelivery_hold, plusdetected_atandrenewal_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 becomesdelivery_holdif delivery still can't be quoted. Flint sendssubscription.updatedwhen it's set and cleared, and buyers see it through/v1/me.inventory_waitmeans the renewal is waiting on stock under the invoice path ofsubscription_inventory_block_action. It carriesstarted_at, the blockedorder_id, and itsinvoice_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 aspast_dueinstead. Only your API key reads it. Collection blocks covers that invoice, andGET /v1/invoices?subscription_id=lists the subscription's invoices.
GET /v1/subscriptions?needs_attention=true lists every subscription that needs you:
past_due, oractiveorincompletewith a billing date more than 24 hours overdue;- with a
delivery_hold, anupcoming_delivery_hold, or aninventory_wait; pausedwithpause_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 -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:
statusispending,processing, orcompleted.processingmeans Flint is checking subscriptions and moving those that can use the new delivery method.moved_count,failed_count, andpending_countalways add up tototal_count.failure_reason_countslists{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:
reason | Meaning |
|---|---|
method_not_offered | The plan or offer doesn't offer the new method. |
destination_not_served, rate_unavailable | The new method can't serve or price the subscriber's address. |
method_unavailable | The new method was archived during the move. |
no_longer_applicable | The subscription changed method or was canceled before its turn. |
not_movable | Still 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 -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 oneactiveoffer: an overlapping create or activation fails with409, 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 haverecurrence.type: "forever"so it discounts every shipment (400 INVALID_REQUEST, detail codeNOT_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 with409 ACTION_NOT_ALLOWED, reasonunsupported_state. SendnullonPATCHto 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. SendnullonPATCHto clear it.status.activeorinactiveon create andPATCH;DELETEarchives 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.
"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, andbilling_interval_countare all required, and the interval must be one of the offer's options. A line withoutsubscriptionis a one-time purchase.POST /v1/orders/{order_id}/line-itemstakes the same field.- To switch a line before the order is paid, send
subscription(ornullfor one-time) with the order'sversionasexpected_versiontoPATCH /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, reasonpayment_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_idit 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 codeNOT_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 with | When |
|---|---|
404 RESOURCE_NOT_FOUND, param naming subscription_offer_id | The offer doesn't exist. |
409 ACTION_NOT_ALLOWED, reason unsupported_state | The offer is inactive or archived, or its discount can't apply. |
400 INVALID_REQUEST | The 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].subscription | The 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 -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_optionsof every line's offer (400 INVALID_REQUEST, detail codeINVALID_VALUE,paramterms.billing_interval). The subscription's own options are the ones all its offers share. - Lines. Each names a
variant_idand thesubscription_offer_idthat covers it, with aquantityfrom 1 to 9,999 (default 1). A variant can appear once. Lines can come from several offers and all start in one subscription, whose ownquantityis 1. - Offers. Each must be
active, including for an import (409 ACTION_NOT_ALLOWED, reasonunsupported_state). An unknown offer is404 RESOURCE_NOT_FOUND, and a variant the offer doesn't cover is400 INVALID_REQUEST, detail codeINVALID_VALUE.paramnames the entry and field, such asterms.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, neverimmediate, 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_type | Meaning |
|---|---|
subscription_plan | The terms come from the plan in subscription_plan_id. |
subscription_offer | The 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 whethersubscription_plan_idis present. - For
subscription_offer, decide what the subscriber gets fromline_items[].variant_idandline_items[].subscription_offer_id. One subscription can hold lines from several offers, and a line you add later has nosubscription_offer_id. terms_typenever changes, even when lines are added, changed, or removed.GET /v1/subscriptions?terms_type=subscription_offerlists every offer subscription.
Show subscribe and save on your product page#
Look up the active offer for the variant the buyer is viewing:
curl "https://api.withflintpay.com/v1/subscription-offers?variant_id=var_1kmn0aExample&status=active" \
-H "Authorization: Bearer YOUR_API_KEY"
A variant matches at most one active offer, so the list has one offer or none. With one, show "One-time" 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[].quantityas the shipped quantity. It's per unit; each shipment carries it times the subscription'squantity. - Archiving a delivery method subscribers use. Each of them is held at their next renewal. Migrate them first.
