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

FieldTypeMeaning
return_urlstring, requiredWhere Stripe sends the payer afterwards. Must be on the environment's origin allowlist.
price_checksumstring, requiredThe price_checksum from the estimate the customer confirmed — a 64-character lower-case hex digest. A stale value is refused. See below.
ui_modeembedded | hosted, default embeddedHow Stripe renders.
unit_idsarray of strings, optionalBillable units to buy. Omit to buy the whole task.
planstarter | growth | pro, optionalBuy 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

Required. 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-estimate cannot create a checkout. Quote first, always.
  • Omitting it is a 400 from 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:

CodeWhendetails
409 CHECKOUT_PLAN_DOWNGRADEplan is a lower tier than the live one{ plan_key, requested }
409 CHECKOUT_UPGRADE_IN_PROGRESSAn 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

Matched 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

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

ModeYou getUse it for
embeddedclient_secret + publishable_keyMounting Stripe's form inside your own page
hostedurlHanding 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.

FieldMeaning
client_secret / urlOne is set, the other is null, per ui_mode.
publishable_keyThe 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_idStripe's cs_
. The callback's lookup key.
statuspending until the payment resolves it to paid or expired.
units_expectedThe credits the payment grants. See below.
checkout_sourcefixture or computed — which pricer produced the figures.
pricing_versionThe billable-unit rule these figures came from.
price_checksumThe digest of the priced fields behind this session. Equal to the estimate's for the same selection.
units, checkout_items, amount_*, taxAs on the estimate. All amounts in minor units.
checkout_items_grainrow or bundle — which mapping produced the items. See below.

units_expected is what the payment grants

It 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_byFunded byCounted in units_expected?
balanceThe organisation's credit ledger — whether those units came from a tier's grant or a purchased packNo
exempt, not_pricedNothing: not charged and not settledNo

The run reserves the whole selection against that one balance. A selection of 5 units, all chargeable except 2:

CoverageChargedunits_expectedRun reserves
Balance covers 2335 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

fixture 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

For 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

CodeMeaning
200Session created, or the existing one returned.
204GET only: nothing in flight.
400Malformed 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.
401Missing or invalid API key.
403The 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.
404No such task, or it belongs to another organisation. The two are deliberately indistinguishable.
409CHECKOUT_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 }.
422CHECKOUT_TASK_NOT_ELIGIBLE, CHECKOUT_NOTHING_CHARGEABLE, or CHECKOUT_SELECTION_TOO_LARGE.
502CHECKOUT_GATEWAY_REJECTED — Stripe refused the request. Not retryable: the same request fails identically until the cause is fixed.
503CHECKOUT_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 namesMeaning
the balance already covers this selectionthe credits are already held; proceed without a checkout
credits are not enforcedthe organisation is exempt; proceed
nothing to pricethe ingestion is empty — there is no work to buy
cannot be pricedthe 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" }
  • units is 1 to 10060 (UNITS_EXPECTED_MAX). Each credit costs the pay-as-you-go price, which GET /organizations/:id/credits/subscriptions returns as payg_unit_amount. There is no estimate and no price_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 needs billing:write.
  • An organisation holds one pending pack at a time. Sending the same units and ui_mode again 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" }
  • plan is starter, growth or pro. The body is strict: an unknown key is a 400, and so is plan: null — omit it or name a tier.
  • return_url is matched on the parsed origin against the same allowlist the other checkout routes use, so an unlisted origin is a 400.
  • ui_mode defaults to hosted, which suits a subscription page. embedded returns a client_secret instead of a url.
  • Only a self-serve org key or a member key can buy, and the route needs billing:write. Any other org key gets 422 CREDIT_CHECKOUT_NOT_ELIGIBLE; an organisation outside the key's scope gets 404.
// 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:

SituationAnswer
The same plan is requested again while a session is openThe open session, resumed — no second claim
No live subscription, and the slot is freeA new session
A live subscription on the same tier, on a tier that cannot be upgraded, or on a negotiated plan key409 CHECKOUT_SUBSCRIPTION_EXISTS, details { plan_key, status }
plan is a lower tier than the live one409 CHECKOUT_PLAN_DOWNGRADE, details { plan_key, requested }
An upgrade is already waiting for payment409 CHECKOUT_UPGRADE_IN_PROGRESS, details { plan_key, requested }
A credit pack is mid-payment409 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.