Checkout Sessions
Create a Stripe Checkout Session for a screening ingestion, and resume one that is already in flight.
The amount is derived on the server from the stored selection. The request
body cannot influence it â a body carrying amount, units_expected or
quantity is rejected with 400, not ignored.
Prices come from the same estimator as Cost Estimates, so a quote and the charge it becomes cannot disagree.
â¶Quickstart
curl -sg -X POST \
"$FM_API/ingestions/$TASK_ID/checkout-session" \
-H "accept: application/json" \
-H "content-type: application/json" \
-H "x-api-key: $FM_API_KEY" \
-d '{"ui_mode":"embedded","return_url":"https://app.epoch.blue/pay/done","price_checksum":"ef70656fâŠ"}'
{
"meta": {},
"links": { "self": "/ingestions/âŠ/checkout-session" },
"data": {
"type": "checkout_session",
"attributes": {
"ui_mode": "embedded",
"client_secret": "cs_test_âŠ_secret_âŠ", // mount the embedded form with this
"publishable_key": "pk_test_âŠ", // pass to loadStripe() first
"url": null, // hosted mode sets this instead
"stripe_session_id": "cs_test_âŠ",
"status": "pending",
"units_expected": 5,
"checkout_source": "fixture",
"pricing_version": 1,
"price_checksum": "ef70656fâŠ", // equal to the estimate's, for the same selection
"currency": "usd",
"units": [ /* one entry per billable unit, as on the estimate */ ],
"checkout_items": [ /* one entry per rolled-up row, as on the estimate */ ],
"amount_subtotal": 75000,
"amount_discount": 30000,
"amount_tax": null,
"amount_total": 45000,
"tax": { "behavior": "exclusive", "exempt": "none", "tax_ids": [], "determined": false }
}
}
}
The Stripe session and the checkout_session row are written in one
request. Stripe can deliver the webhook within milliseconds, so a row written
afterwards would race it.
â¶Eligibility
The task must be a screening run, and the caller's org API key must have been
issued for the self-serve journey (key_type is self_serve) if it is an org
key. A full run is not chargeable through this route, and neither is an org key of
another type â both return 422 CHECKOUT_TASK_NOT_ELIGIBLE. A member key carries
no key_type, so the rule does not apply to it.
The same rule applies to whoever created the task. An ingestion uploaded with a
self_serve org key or by a member (a member key or an OAuth bearer) is
chargeable. One a direct org key created is not, whichever key asks, and neither
is an ingestion created before the creator was recorded. Both return the same
422 CHECKOUT_TASK_NOT_ELIGIBLE.
The key needs the billing:write scope, which is admin-only. The GET needs
only billing:read.
â¶The request
| Field | Type | Meaning |
|---|---|---|
return_url | string, required | Where Stripe sends the payer afterwards. Must be on the environment's origin allowlist. |
price_checksum | string, required | The price_checksum from the estimate the customer confirmed â a 64-character lower-case hex digest. A stale value is refused. See below. |
ui_mode | embedded | hosted, default embedded | How Stripe renders. |
unit_ids | array of strings, optional | Billable units to buy. Omit to buy the whole task. |
plan | starter | growth | pro, optional | Buy a self-serve tier as a Stripe Subscription. See below. |
unit_ids takes the same two forms as the estimate: loc:<uuid> for a facility
or shed, cluster:<n> for a plot cluster. At most 500.
The body is strict. Any field not listed above is a 400, including
anything that looks like it might set the price.
â¶price_checksum ties the quote to the charge
price_checksum ties the quote to the chargeRequired. Quote the selection with POST /ingestions/:id/cost-estimate, keep the price_checksum it returns beside whatever you rendered, and send that value back here.
The server recomputes the estimate and compares. If the price moved since the quote, the checkout is refused â neither the old figure nor the new one is charged.
// 400 CHECKOUT_PRICE_CHECKSUM_MISMATCH
{
"data": {
"error": "The price changed since this quote. Confirm the new estimate.",
"code": "CHECKOUT_PRICE_CHECKSUM_MISMATCH",
"tracking_id": "âŠ",
"details": {
"expected": "a8c2bb35âŠ", // what the selection costs now
"received": "ef70656fâŠ", // what you sent
"estimate": { /* the full fresh estimate */ }
}
}
}
details.estimate is the whole estimate â the same shape /cost-estimate returns, including action, credits, subscription and the counts. Re-render the paywall from it and ask the customer to confirm the new price. You do not need a second call to /cost-estimate.
Do not retry the same body. It will fail identically. The new checksum is in details.estimate.price_checksum; use it only after a human has seen the new price.
Three things follow from the field being required:
- A caller that has never called
/cost-estimatecannot create a checkout. Quote first, always. - Omitting it is a
400from schema validation (VALIDATION_ERROR), not the mismatch code above. - A refused request takes no claim and makes no Stripe call, so nothing needs cleaning up.
This is not a security control. The amount is derived server-side from the stored selection and no figure in the body is ever trusted, so a wrong checksum cannot change a price. It detects drift â a correctness and support problem, not tampering.
â¶Buying a tier
Send plan and the session is created in Stripe's subscription mode instead
of a one-off payment: Stripe creates a real Subscription, which renews on its
own interval, alongside a one-time overage line on the same initial invoice if
the tier's own allowance does not cover everything already selected on the
task.
An organisation holds at most one live subscription at a time. Sending
plan while a pending, incomplete, trialing, active, past_due or
unpaid subscription already exists is refused with 409 CHECKOUT_SUBSCRIPTION_EXISTS, carrying { plan_key, status } for the
subscription that already holds the slot:
// 409 CHECKOUT_SUBSCRIPTION_EXISTS
{
"data": {
"error": "This organisation already has a subscription.",
"code": "CHECKOUT_SUBSCRIPTION_EXISTS",
"tracking_id": "âŠ",
"details": { "plan_key": "starter", "status": "active" }
}
}
An organisation on an active or trialing self-serve tier can send a
higher tier (starter < growth < pro). That is an upgrade: the customer
pays the full price of the new tier and a new 12-month period starts at
payment. Nothing carries. The new tier's units reach the organisation's
credit balance the way any purchase's do â the grant a completed checkout
writes â and the old tier's unused units stay in that same balance, because
there is only one. An estimate for the higher tier therefore reports the
pending grant through credits.shortfall, not through a separate allowance
figure. Two more refusals apply:
| Code | When | details |
|---|---|---|
409 CHECKOUT_PLAN_DOWNGRADE | plan is a lower tier than the live one | { plan_key, requested } |
409 CHECKOUT_UPGRADE_IN_PROGRESS | An upgrade for the organisation is already waiting for payment | { plan_key, requested } |
A same-tier request, a live subscription that is not active or trialing,
and a negotiated plan all keep 409 CHECKOUT_SUBSCRIPTION_EXISTS.
The slot is claimed before the Stripe call, so this refusal never reaches Stripe and never creates a claim of its own. It is enforced by a partial unique index, not a read, so two concurrent purchase attempts for one organisation cannot both win.
POST /ingestions/:id/cost-estimate prices a tier the same way whether or not
it has been bought yet â see
docs/fm-api/subscription-lifecycle.md
for the full purchase-to-activation lifecycle, which webhook writes which
column, and how a completed checkout grants the tier's units into the one
credit ledger.
A plan can also be bought with no task behind it â see Buying a plan without an ingestion below.
To cancel or resume a subscription, see Subscriptions.
â¶return_url must be on the allowlist
return_url must be on the allowlistMatched on the parsed origin, never a prefix â so
https://app.epoch.blue.evil.com is refused even though it starts with an
allowed origin. Without this the route would be an authenticated open redirect.
An unlisted origin is a 400.
â¶ui_mode is our vocabulary, not Stripe's
ui_mode is our vocabulary, not Stripe'sSend embedded or hosted. fm-api maps these to whatever the pinned Stripe API
version accepts, so a Stripe-native value such as embedded_page is a 400.
| Mode | You get | Use it for |
|---|---|---|
embedded | client_secret + publishable_key | Mounting Stripe's form inside your own page |
hosted | url | Handing a link to a human â an MCP client, an email |
â¶The response
Identical in shape to the cost estimate, plus the session fields. The same component renders both.
| Field | Meaning |
|---|---|
client_secret / url | One is set, the other is null, per ui_mode. |
publishable_key | The pk_⊠key of the Stripe account that created the session. Pass it to loadStripe(), then mount client_secret. Always set; a hosted caller can ignore it. |
stripe_session_id | Stripe's cs_âŠ. The callback's lookup key. |
status | pending until the payment resolves it to paid or expired. |
units_expected | The credits the payment grants. See below. |
checkout_source | fixture or computed â which pricer produced the figures. |
pricing_version | The billable-unit rule these figures came from. |
price_checksum | The digest of the priced fields behind this session. Equal to the estimate's for the same selection. |
units, checkout_items, amount_*, tax | As on the estimate. All amounts in minor units. |
checkout_items_grain | row or bundle â which mapping produced the items. See below. |
â¶units_expected is what the payment grants
units_expected is what the payment grantsIt is the chargeable units, and nothing else:
units_expected = chargeable
Covered units are already funded, so granting them would hand the customer a second copy. There is one pool, and one coverage reason that means "paid for":
covered_by | Funded by | Counted in units_expected? |
|---|---|---|
balance | The organisation's credit ledger â whether those units came from a tier's grant or a purchased pack | No |
exempt, not_priced | Nothing: not charged and not settled | No |
The run reserves the whole selection against that one balance. A selection of 5 units, all chargeable except 2:
| Coverage | Charged | units_expected | Run reserves |
|---|---|---|---|
| Balance covers 2 | 3 | 3 | 5 from the balance (2 held + 3 granted) |
When the balance covers every unit there is nothing to pay: the checkout
refuses with 422 CHECKOUT_NOTHING_CHARGEABLE, and the run is started
directly with PUT /ingestions/assets.
Summing checkout_items[].credits over-reports the grant, because a row's
credits counts its covered credits too. Read units_expected.
â¶A tier bought in the same session
A unit the new tier covers is priced free and is not granted twice: the
grant the completed checkout writes IS its funding, and credits.shortfall
already counts that pending grant, so the checkout charges only for what is
still owed after it lands. When the tier covers every unit, units_expected
is 0 â allowed only because the same session buys a plan, which is what the
checkout_session__units_expected_sane CHECK's plan IS NOT NULL arm is for.
â¶How rows become Stripe lines
Every session sends at most two supplier lines, whatever the task size: a
zero-priced line for the covered credits ("Locations included at no charge")
and a charged line for the rest ("Additional locations"). Either is omitted
when its count is zero â Stripe refuses quantity: 0. The mapping cannot
express a discount, because it builds each line from unit_amount Ă quantity,
so coverage is a zero-priced line rather than a reduction. The per-unit ids
the folded lines cannot carry travel in the session's metadata.
The fold is what keeps a facility- or shed-heavy task inside Stripe's caps:
100 lines in payment mode, and 20 one-time items in subscription mode, where
the recurring tier line rides beside them. checkout_items_grain does not
describe the Stripe lines: it says how checkout_items grouped the selection
(row or bundle), which shapes that list and the price checksum only. The
amounts are identical either way.
â¶checkout_source tells you whether the figures are real
checkout_source tells you whether the figures are realfixture means invented amounts, served by a fixture driver. computed means
the real pricer. A staging or production caller seeing fixture should treat it
as a misconfiguration, not a price.
â¶Repeating a request
A repeated submit for the same task and selection returns the existing
session. It does not create a second one. The guarantee is a partial unique
index on (asset_task_id, selection_digest) for pending rows, not a
read-then-write, so two concurrent requests cannot both win.
The selection is what identifies a purchase â unit_ids, with order and
duplicates ignored. A loc: id's uuid is matched case-insensitively, so two
spellings of one id are one purchase. (A cluster:<n> id is digits, so case
cannot arise.) ui_mode and return_url are excluded: opening the same
purchase in an embedded form and in a browser is still one purchase.
Rarely, a concurrent request holds the claim but has not yet recorded its Stripe
session id. That answers 409 CHECKOUT_SESSION_PENDING and is retryable.
â¶Resuming: GET /ingestions/:id/checkout-session
GET /ingestions/:id/checkout-sessionFor a page that reloads mid-payment.
curl -sg "$FM_API/ingestions/$TASK_ID/checkout-session" \
-H "accept: application/json" -H "x-api-key: $FM_API_KEY"
Returns the pending session in the same shape as the POST, with a fresh
client_secret, or 204 when nothing is in flight.
It carries no body, so it cannot name a selection â it returns the newest
pending session for the task, priced against the selection that session was
created with. 204 also covers a claim that has not finished being created.
â¶Tax
Every session is created with automatic_tax, tax_id_collection,
billing_address_collection and customer_update set, so Stripe collects a VAT
number and computes tax against a billing address.
amount_tax is null until Stripe determines it, and 0 when none is due.
They are different answers. Rendering null as zero shows a confident zero
where the real answer is "not yet known".
Stripe only collects tax where the account holds an active registration, and reports no error where it does not. A zero tax figure is not proof that no tax is due.
Each line states its own tax_behavior, taken from the estimate rather than
left to the Stripe account default. One of the three account settings treats a
non-USD amount as tax inclusive, which would quietly contradict the
exclusive the response reports.
product_data.tax_code is specified but not implemented. Stripe therefore
applies the account's default tax category to every line. Raise it with the
billing owner before relying on a per-product rate.
â¶Status codes
| Code | Meaning |
|---|---|
200 | Session created, or the existing one returned. |
204 | GET only: nothing in flight. |
400 | Malformed body, an unknown field, a missing price_checksum, or a return_url outside the allowlist. Also CHECKOUT_PRICE_CHECKSUM_MISMATCH â the price moved since the quote; the fresh estimate is in details.estimate. Not retryable as sent. |
401 | Missing or invalid API key. |
403 | The key lacks billing:write (billing:read for the GET). Scopes are matched exactly, so a billing:write-only key is refused by the GET: a key that can create a session cannot read it back unless it also carries billing:read. |
404 | No such task, or it belongs to another organisation. The two are deliberately indistinguishable. |
409 | CHECKOUT_SESSION_PENDING â a concurrent request is creating this session. Retry. Or CHECKOUT_SESSION_CONFLICT â the session could not be recorded; the claim is released, so a retry is clean. Or CHECKOUT_SUBSCRIPTION_EXISTS â plan was sent and the organisation already holds a live subscription that this request cannot upgrade; carries { plan_key, status }. Or CHECKOUT_PLAN_DOWNGRADE / CHECKOUT_UPGRADE_IN_PROGRESS â see Buying a tier; both carry { plan_key, requested }. |
422 | CHECKOUT_TASK_NOT_ELIGIBLE, CHECKOUT_NOTHING_CHARGEABLE, or CHECKOUT_SELECTION_TOO_LARGE. |
502 | CHECKOUT_GATEWAY_REJECTED â Stripe refused the request. Not retryable: the same request fails identically until the cause is fixed. |
503 | CHECKOUT_UPSTREAM_ERROR â Stripe could not be reached. Retryable: the claim is released, so the same request works once Stripe recovers. Or CHECKOUT_NOT_CONFIGURED â the deployment has no Stripe gateway at all, which no retry fixes. |
CHECKOUT_NOTHING_CHARGEABLE is not always a failure. Read the message, not
just the code â it carries four causes, and only two mean "nothing to pay":
| Message names | Meaning |
|---|---|
| the balance already covers this selection | the credits are already held; proceed without a checkout |
| credits are not enforced | the organisation is exempt; proceed |
| nothing to price | the ingestion is empty â there is no work to buy |
| cannot be priced | the unit count is not trustworthy â fix the ingestion |
â¶After payment
Stripe calls POST /callbacks/stripe, which resolves the row to paid and
appends a grant of units_expected to the credit ledger. The grant is keyed on
the Stripe event id, so a redelivered event does not grant twice.
Nothing in that path reads a unit count from Stripe metadata. The row is the only source of the granted figure, because metadata is writable by anyone holding an API key.
â¶Is this task paid for?
GET /ingestions/:id?include=payment answers this after the payment moment.
A session buys credits into your organisation's balance. It does not buy locations. A run spends credits when it completes, one for each billable location that it delivered. So the block counts the locations that this task's own run spent credits on:
paid: the run spent credits on every billable location.partially_paid: the run spent credits on some locations. The others failed, and their credits went back to your balance. A skipped location is not billable, so it counts in neither figure.unpaid: the run has not spent credits, or it ran before billing.
An organisation that is not billed in credits (for example, one on an invoice)
reads unpaid on every task, because its runs never spend credits. So do not
use unpaid alone as a reason to ask for payment. Read the cost estimate for
that decision.
A reprocess (PUT /ingestions/assets) can create a new task id. That task
reports its own run, not the run of the task that it replaced.
A refund does not change status. The run already spent its credits, so the
figures stand. A refund reverses the credits that the session bought, and
latest_session.status shows it. So a task can read paid next to a refunded
session.
The figures are those of the task's last settled run. When you run the same
task again, the block keeps the previous run's figures until the new run
settles. So paid can appear next to an open_session.
open_session is the task's live checkout session. latest_session is its
newest session of any status. Both carry stripe_session_id, the cs_⊠id
that GET /organizations/:id/checkout-sessions/:sessionId takes. It is null
for the moment between the claim and Stripe's answer.
â¶Buying credits without an ingestion
POST /organizations/:id/checkout-sessions buys a pack of credits into
the organisation's balance, with no task behind it. It is what the billing page
uses to top up.
{ "units": 40, "ui_mode": "hosted", "return_url": "https://app.epoch.blue/settings/billing" }
unitsis 1 to 10060 (UNITS_EXPECTED_MAX). Each credit costs the pay-as-you-go price, whichGET /organizations/:id/credits/subscriptionsreturns aspayg_unit_amount. There is no estimate and noprice_checksum, because nothing about the price depends on an ingestion. Stripe adds tax at the payment form.- Only a self-serve org key or a member key can buy. Any other org key gets
422 CREDIT_CHECKOUT_NOT_ELIGIBLE, and so does a member key for an organisation whose credits are not enforced, since its runs never spend them. The route needsbilling:write. - An organisation holds one pending pack at a time. Sending the same
unitsandui_modeagain returns the open session. Sending different ones expires the open session at Stripe and creates a new one, so an abandoned checkout never blocks the next purchase. - While the previous pack is completing payment, the route answers
409 CHECKOUT_SESSION_PENDING. The balance updates once that payment settles.
After payment the callback grants units_expected, exactly as for a task
checkout. Poll GET /organizations/:id/checkout-sessions/:sessionId with the
stripe_session_id from the response until it reads paid. A refund reverses
the pack's credits, up to the balance still available.
â¶Buying a plan without an ingestion
POST /organizations/:id/subscription/checkout-session buys a subscription
plan â or upgrades the one the organisation holds â with no task behind it. It
is the org-level twin of the pack route above: a pack grants the units bought,
a plan grants that tier's yearly allowance. Both write to the same credit
ledger, so there is one balance either way.
{ "plan": "growth", "ui_mode": "hosted", "return_url": "https://app.epoch.blue/settings/billing" }
planisstarter,growthorpro. The body is strict: an unknown key is a400, and so isplan: nullâ omit it or name a tier.return_urlis matched on the parsed origin against the same allowlist the other checkout routes use, so an unlisted origin is a400.ui_modedefaults tohosted, which suits a subscription page.embeddedreturns aclient_secretinstead of aurl.- Only a self-serve org key or a member key can buy, and the route needs
billing:write. Any other org key gets422 CREDIT_CHECKOUT_NOT_ELIGIBLE; an organisation outside the key's scope gets404.
// 200
{
"meta": {},
"links": { "self": "/organizations/âŠ/subscription/checkout-session" },
"data": {
"type": "subscription_checkout_session",
"attributes": {
"ui_mode": "hosted",
"client_secret": null, // set for `embedded`, null for `hosted`
"publishable_key": "pk_test_âŠ",
"url": "https://checkout.stripe.com/c/pay/cs_test_âŠ", // null for `embedded`
"stripe_session_id": "cs_test_âŠ",
"units_expected": 28, // Growth credits 28 supplier locations a year
"plan_key": "growth",
"amount_subtotal": 300000, // the annual price in cents, before tax
"currency": "usd"
}
}
}
units_expected is what the completed payment grants into the ledger: exactly
the tier's yearly allowance. This route sells no units of its own, so there is
no top-up term beside it â that figure belongs to the ingestion checkout above.
amount_subtotal is the annual price of the tier alone: Starter 100000,
Growth 300000, Pro 500000. Stripe adds tax at the payment form.
â¶One slot, claimed before Stripe
An organisation holds one pending plan purchase at a time, claimed by a partial unique index before any Stripe call:
| Situation | Answer |
|---|---|
The same plan is requested again while a session is open | The open session, resumed â no second claim |
| No live subscription, and the slot is free | A new session |
| A live subscription on the same tier, on a tier that cannot be upgraded, or on a negotiated plan key | 409 CHECKOUT_SUBSCRIPTION_EXISTS, details { plan_key, status } |
plan is a lower tier than the live one | 409 CHECKOUT_PLAN_DOWNGRADE, details { plan_key, requested } |
| An upgrade is already waiting for payment | 409 CHECKOUT_UPGRADE_IN_PROGRESS, details { plan_key, requested } |
| A credit pack is mid-payment | 409 CHECKOUT_SESSION_PENDING |
An upgrade claims a second pending row pointing at the live one. The live
subscription keeps reporting the OLD tier until the payment completes, so a
cost estimate taken in between still reads the tier the organisation is on.
A deployment with no Stripe gateway answers 503 CHECKOUT_NOT_CONFIGURED
before the ladder above runs, so the 409s are unreachable there.
â¶After payment
The checkout.session.completed webhook grants units_expected into the
credit ledger exactly as for any other checkout, then â for an upgrade â swaps
the rows: the old subscription is set to canceled, the new one is activated,
and only then is the old Stripe subscription cancelled. Nothing carries:
the new tier's units arrive as that grant, and whatever the old tier left
unspent stays in the same balance. Poll
GET /organizations/:id/checkout-sessions/:sessionId with the
stripe_session_id until it reads paid.
For the full lifecycle â which webhook writes which column, renewal grants,
and the upgrade failure modes â see
docs/fm-api/subscription-lifecycle.md.
To cancel or resume, see Subscriptions.