Cost Estimates
POST /ingestions/:id/cost-estimate prices the billable units on a screening ingestion. It reports whether the organisation's credit balance covers them.
It computes only. It reserves no credit, charges nothing, and mutates no task. Call it on every render, and call it twice in a row: both calls return byte-identical JSON.
The figures are real. estimate_source reads "computed": the amounts come from the live catalogue and the organisation's own balance, and are what a checkout will charge. A response carrying "fixture" means the deployment is misconfigured — COST_ESTIMATE_DRIVER defaults to computed, and fixture is refused outright when STRIPE_LIVE_MODE is on. Treat it as a misconfiguration, not a price.
â–¶Quickstart
POST /ingestions/9f3c0000-0000-4000-a000-000000000002/cost-estimate
Accept: application/json
Content-Type: application/json
{}
An empty body prices the whole task. Response, abbreviated:
{
"meta": {},
"links": { "self": "/ingestions/9f3c0000-0000-4000-a000-000000000002/cost-estimate" },
"data": {
"type": "cost_estimate",
"attributes": {
"estimate_source": "computed",
"pricing_version": 2,
"price_checksum": "ef70656f9fcc248d68943c54dc018fa547b13c0e9f5a1d2b3c4e5f60718293a4",
"currency": "usd",
"credits": {
"enabled": true, "available": 2, "reserved": 0,
"required": 5, "shortfall": 3, "sufficient": false
},
"action": "top_up",
// What the plan IS, never what it funds. `included_units` is the grant
// one period of Starter writes into the ledger; the 2 units left in
// `credits.available` above are what is actually there to spend — the
// same pool, not a second one.
"subscription": {
"in_use": true, "source": "existing", "plan_key": "starter",
"name": "Starter", "status": "active",
"current_period_end": "2027-09-04T00:00:00Z",
"included_units": 8
},
"selected_count": 5, "covered_count": 2,
"chargeable_count": 3, "not_payable_count": 0,
"units": [
// 5 units total. One facility, one shed, and three plot groups
// holding 412 plots between them — elided here, full shape in
// "The response" below.
],
"checkout_items_grain": "row",
// Three rows for five units: the plot groups fold into ONE row, and
// the facility and shed each keep their own.
"checkout_items": [
{
"kind": "supplier_locations",
// `member_count` is the locations folded in; `credits` is what the
// run reserves. A 412-plot row costs 3 credits, not 412.
"row_key": "plot",
"label": null,
"location_type": "plot",
"member_count": 412,
"credits": 3,
"credits_covered": 1,
"credits_chargeable": 2,
"description": "Plot detection and analysis — 412 locations",
"quantity": 2,
// The CHARGEABLE ids only, so `quantity` and this length agree.
"unit_ids": ["unit:f31bec2a24ee", "unit:a90c1de44f27"],
"currency": "usd",
"amount_subtotal": 30000, "amount_discount": 0,
"amount_tax": null,
// The top-up due now. `amount_full` is the row before coverage,
// so a covered row still says what the balance paid for.
"amount_total": 30000,
"amount_full": 45000,
"price": {
"id": null, "lookup_key": "epoch_payg_supplier_location_v1",
"unit_amount": 15000, "currency": "usd", "recurring": null,
"product": "payg_supplier_location", "tax_behavior": "exclusive"
}
},
{
"kind": "supplier_locations",
// A site takes its own name, so two facilities never produce two
// identical entries in this list. The Stripe receipt folds rows
// together; see checkout-sessions.md.
"row_key": "unit:7d799688194a",
"label": "Nawa Wet Mill 1",
"location_type": "facility",
"member_count": 1,
"credits": 1, "credits_covered": 0, "credits_chargeable": 1,
"description": "Nawa Wet Mill 1",
"quantity": 1,
"unit_ids": ["unit:7d799688194a"],
"currency": "usd",
"amount_subtotal": 15000, "amount_discount": 0,
"amount_tax": null, "amount_total": 15000, "amount_full": 15000,
"price": {
"id": null, "lookup_key": "epoch_payg_supplier_location_v1",
"unit_amount": 15000, "currency": "usd", "recurring": null,
"product": "payg_supplier_location", "tax_behavior": "exclusive"
}
},
{
"kind": "supplier_locations",
"row_key": "unit:c3a9e0a4794b",
"label": "Nawa Collection Point 2",
"location_type": "shed",
"member_count": 1,
"credits": 1, "credits_covered": 1, "credits_chargeable": 0,
"description": "Nawa Collection Point 2",
// Fully covered: nothing is due, but the row still cost a credit.
"quantity": 0,
"unit_ids": [],
"currency": "usd",
"amount_subtotal": 0, "amount_discount": 0,
"amount_tax": null, "amount_total": 0, "amount_full": 15000,
"price": {
"id": null, "lookup_key": "epoch_payg_supplier_location_v1",
"unit_amount": 15000, "currency": "usd", "recurring": null,
"product": "payg_supplier_location", "tax_behavior": "exclusive"
}
}
],
// Chargeable rows only (EPO-3332): the 2 units the balance paid for are
// not part of what this checkout asks for, so they are in neither the
// subtotal nor a discount line. 3 x 15000 = 45000 = amount_total.
// Each row's `amount_full` still says what the balance covered.
"amount_subtotal": 45000, "amount_discount": 0,
"amount_tax": null, "amount_total": 45000,
"tax": {
"behavior": "exclusive", "exempt": "none",
"tax_ids": [], "determined": false
}
}
}
}
â–¶Eligibility
Only a screening ingestion can be estimated. Send POST to any other run kind and you get a 422:
- A full run — one that asked for metrics, an audit trail, or an artifact — is not estimable.
- A task whose run kind is unknown returns
422too, deliberately. Under-reporting paid work is the one direction that misleads a paywall, so an unknown kind is treated asfull, not asscreening.
The task must also belong to your organisation. A task that exists but is not yours returns 404, not 403 — so you cannot use this endpoint to probe which task ids exist.
â–¶The request
Both fields are optional. An empty body ({}) is valid and prices the whole task.
| Field | Type | Meaning |
|---|---|---|
unit_ids | array of strings, optional | Billable units to price. Omit to price the whole task. |
plan | starter | growth | pro, optional | Preview a tier the organisation has not bought. |
▶unit_ids — two id forms
unit_ids — two id formsA billable unit is not always one row, so the id says which kind it is:
| Form | Names | Example |
|---|---|---|
loc:<uuid> | A facility or a shed. One staged_location row. | loc:b1e40000-0000-4000-a000-000000000001 |
cluster:<n> | A plot cluster. One billable unit, spanning many plot rows. | cluster:1 |
A cluster gets its own naming because it is not one row. A plot cluster groups many plots that sit close together into one billable unit, at one flat price, whatever its size. cluster:1 might cover 4 plots or 412 — see member_count below.
Both forms are published in the OpenAPI schema as anyOf patterns, so a generated client validates them. The uuid is case-insensitive; ids are matched lower-cased.
Membership is not yet validated. A well-formed unit_id that is not actually on the task is not rejected. It is silently absent from units[] in the response. A 200 with a shorter units[] than you expected can mean either "not selected" or "not on this task" — the response does not yet distinguish the two.
â–¶The four action values
action valuesaction | Meaning | What to do |
|---|---|---|
ready | The credit balance covers the required amount. | Show the breakdown. Skip payment. amount_total is 0. |
top_up | The balance falls short. | Show the breakdown. Charge for the shortfall. This is the normal paid path. |
exempt | Credits are switched off for this organisation. | Show the rows, with no paywall. Every row is covered_by: "exempt". |
unpriceable | The unit count is not trustworthy. | Show the rows, explain that the count cannot be trusted yet, and offer to fix the file. This is a state, not an error — re-upload and it clears. |
On exempt and unpriceable nothing was checked against the balance, so credits.sufficient is false on both. It answers "we looked, and the balance covers this" — never treat false on those two as "the customer cannot afford it".
â–¶The response
Two arrays, each answering a different question.
units[] is your table: one entry per billable unit that was selected, whether it ended up charged or covered.
{
"unit_id": "cluster:1",
"unit_kind": "plot_cluster", // facility | shed | plot_cluster
"location": "Nawa cluster 1",
"producer": "Nawa Farmer Cooperative 1 (Demo)",
"type": "plantation",
"member_count": 412, // informational — see below
"unit_amount": 15000,
"discount_amount": 0,
"amount": 15000,
"covered_by": null, // see the table below
"note": null,
"stripe": {
"price_id": null,
"product_id": "payg_supplier_location",
"lookup_key": "epoch_payg_supplier_location_v1"
}
}
stripe is null on any row that is not charged — that row is not sent to Stripe at all. covered_by says why, and the three values are not interchangeable:
covered_by | Meaning | Paid for? |
|---|---|---|
null | The row is charged. amount > 0 and stripe is populated. | — |
balance | Settled from the organisation's credit balance — the one pool, whether the units in it came from a tier's grant or a purchased pack. | Yes |
exempt | Credits are not enforced for this organisation. | No |
not_priced | The unit count is not trustworthy, so the row was never priced. | No |
Only balance means the work was paid for. A row marked exempt or not_priced is not charged and not settled either — rendering it as "covered by your balance" tells the customer a balance was drawn when none was read.
covered_count + chargeable_count + not_payable_count == selected_count on every action, so every row is accounted for under exactly one reason.
checkout_items[] is what gets sent to Stripe. It is a union discriminated on kind, and each kind carries only its own fields:
kind | Carries | Never carries |
|---|---|---|
supplier_locations | row_key, label, location_type, member_count, credits, credits_covered, credits_chargeable, amount_full, unit_ids — exactly quantity of them, each present in units[] | plan_key, included_units |
subscription | plan_key, included_units | unit_ids, the row fields |
Switch on kind and the fields are guaranteed — a supplier_locations row always has unit_ids, so no null check is needed. A subscription line appears here only, never in units[], because a plan is a thing being bought, not a billable unit.
One entry per rolled-up row, not per price. Plots fold to a single row per task, matching GET /ingestions/assets?rollup=true; facilities and sheds are 1:1 with a supplier unit and never fold. So credits exceeds one only on the plot row.
member_count is display only and NEVER a multiplier — a 412-plot row costs its credit count, not 412. credits_covered is met from the credit balance; credits_chargeable is what amount_total charges for. The two halves sum to at most credits rather than exactly: a row can also hold units that are neither charged nor paid for (covered_by exempt or not_priced).
amount_total is the top-up due now, because that figure must sum to what Stripe charges. amount_full is what the row costs before coverage, so a fully covered row reports what the balance paid for instead of reading as free.
checkout_items_grain says how the items were grouped. Past 40 rows it reports bundle and the items collapse to one entry per coverage reason, keeping the list short for a facility- or shed-heavy task. The grain shapes checkout_items and the price checksum only: every Stripe session folds the selection into at most two supplier lines whatever the grain (see checkout-sessions.md). The amounts are identical either way.
supplier_items[] is one row per supplier unit, priced for DISPLAY rather than for Stripe: the grain of units[] and GET /ingestions/assets?rollup=unit. Each plot unit is its own row here, although checkout_items folds plots into one. Render the checkout from this array, not from checkout_items.
| Field | Meaning |
|---|---|
row_key | Stable identity: the unit id. A site's matches its checkout_items row. The folded plot checkout row (row_key plot) covers every supplier_items row whose location_type is plot; its unit_ids list only the chargeable ones. |
display_name | What to show. A site, or a plot unit of one location, takes its own name; a larger plot unit takes the kind-and-size wording ("Plot detection and analysis — 67 locations"), because naming a cluster after whichever member sorts first reads as fact. |
member_count | Locations in the unit. Display only and NEVER a multiplier. |
credits | Always 1: one supplier unit per row, what the run reserves and what the row is charged for. |
credits_covered / credits_chargeable | The split. They sum to at most credits: a unit can be neither charged nor paid for. |
amount_full / amount_due | What the row costs before coverage, and the top-up due now. A covered row reports what the balance paid for rather than reading as free. |
location_ids | Every staged location in the unit. The only link from a priced row back to GET /ingestions/assets, since a plot unit of several locations has no name. |
unit_ids | The chargeable ids only. |
Two differences from checkout_items decide which to read:
- It never folds or collapses. Plot units keep a row each, and the
bundlegrain exists for Stripe's line cap, which a list nobody pays through does not have. A 41-unit task shows 41 rows here and two items there. - It is EMPTY on a refusal (
actionexemptorunpriceable), because neither sells anything.units[]still lists what was selected.
sum(supplier_items[].amount_due) equals amount_total.
Top-level fields:
| Field | Meaning |
|---|---|
credits | available, reserved, required, shortfall, sufficient. available is already net of reserved — do not add them together. |
subscription | null when the organisation is pay-as-you-go and no plan was previewed. Otherwise reports the plan in use or previewed. |
selected_count | How many units were priced. Equals units.length. |
covered_count | Rows paid for — covered_by is balance. |
chargeable_count | Rows being charged for. |
not_payable_count | Rows neither charged nor paid for — covered_by is exempt or not_priced. |
amount_subtotal, amount_discount, amount_tax, amount_total | See below. All in minor units. |
tax | behavior, exempt, tax_ids, determined. |
â–¶price_checksum: carry it to the checkout
price_checksum: carry it to the checkoutEvery response carries price_checksum, a digest of the fields that determine the charge. POST /ingestions/:id/checkout-session requires it. Send back the value from the estimate the customer actually confirmed.
// 1. Quote. Keep the checksum with whatever you rendered.
POST /ingestions/:id/cost-estimate → { "price_checksum": "ef70656f…", "amount_total": 45000, … }
// 2. Buy. Send it back.
POST /ingestions/:id/checkout-session
{ "return_url": "https://…", "price_checksum": "ef70656f…" }
If the price moved between the two calls, the checkout is refused with 400 CHECKOUT_PRICE_CHECKSUM_MISMATCH rather than charging either figure. The refusal carries the fresh estimate, so you can re-render and ask the customer to confirm the new price without a second round-trip. See Checkout Sessions.
It detects drift, not tampering. The server recomputes every amount and never trusts a figure from a request body, so a wrong checksum cannot change a price — it can only tell you the quote your customer accepted is no longer the one that would be charged.
What moves it and what does not:
| Changes the checksum | Does not change it |
|---|---|
pricing_version, currency | estimate_source |
amount_subtotal, amount_discount, amount_total | amount_tax (see below) |
Any unit's unit_amount, discount_amount, amount | location, producer, type, note |
Any unit's covered_by — it changes who pays | member_count |
The set of unit_ids, and the order of the returned units[] | The whole subscription block |
A subscription item's plan_key | action, and the four counts |
tax.behavior and tax.exempt — they change what you settle without moving amount_total | tax.determined and tax.tax_ids |
Two exclusions are deliberate and worth knowing:
amount_taxis not in the digest. It isnulluntil Stripe determines it. Including it would mean every quote taken before tax resolution failed against every recompute after it — so every checkout would be refused.estimate_sourceis not in the digest. Provenance is not price. A fixture quote and a computed quote at the same figures checksum alike.
Unit-id case is folded, matching how a selection is identified: loc:ABC… and loc:abc… are one selection at one price. The order you send unit_ids in is irrelevant — the estimator returns units[] in its own canonical order, and that is what the digest covers.
â–¶Two things the response will not stop you getting wrong
â–¶member_count is never a multiplier
member_count is never a multipliermember_count reports how many staged locations a unit covers. It is informational only. A plot cluster is priced flat, at one unit_amount, no matter how many plots it holds — a cluster of 412 plots costs the same as a cluster of 1. Multiplying unit_amount by member_count overcharges by orders of magnitude. A facility or shed always has member_count: 1.
â–¶credits.available is the only pool
credits.available is the only poolThere is one balance. credits.available is what the organisation can spend, and it is the only figure a run draws on. A tier's included_units is not a second pool sitting beside it: it is the size of the grant the ledger receives when that tier's checkout completes, and once granted those units are indistinguishable from credits bought as a pack.
A selected tier's grant is pending until its checkout completes. Quote plan: "growth" and the response shows Growth in subscription with source: "selected", but the units it includes are not in credits.available yet — they land when the payment settles. That is why credits.shortfall is not always required - available: it counts the pending grant of a tier bought in the same session, so the checkout charges for what will actually be owed after the grant lands.
An existing plan reports what it is — tier, status, what one period grants — and funds nothing by itself. An organisation that has spent its balance still holds an active subscription and still gets charged for the next unit.
That is why units_expected on a checkout is the chargeable count alone — see Checkout Sessions.
â–¶amount_tax: null is not 0
amount_tax: null is not 0amount_tax: null means tax has not been determined yet. amount_tax: 0 means no tax is due. Treating them the same shows a confident $0.00 when the real answer is "we do not know yet." Check tax.determined if you would rather branch on a boolean.
â–¶Money
All money fields are integers in minor units (cents for usd). Never treat them as floats, and never sum them in the browser to reconstruct a total — use amount_total.
amount_subtotal is the list price of the chargeable units plus any plan line in the same checkout — not of every unit quoted. A unit the balance already paid for is not something this checkout asks the customer to buy, so it appears in neither the subtotal nor amount_discount; each row's amount_full is where the covered cost survives. The identity holds on every action:
amount_subtotal - amount_discount + (amount_tax ?? 0) == amount_total
â–¶Status codes
| Code | Meaning |
|---|---|
200 | Estimate returned. |
400 | Malformed request body — a unit_id that is not loc:<uuid> or cluster:<n>, an unknown body field, or more than 500 unit_ids. |
403 | Missing the billing:read scope. |
404 | The task does not exist, or does not belong to your organisation. |
422 | The task is not a screening run; or it was created by a direct org key, or before its creator was recorded (a self_serve org key or a member must have uploaded it); or the caller's org key was not issued for the self-serve journey (key_type is not self_serve; member keys are unaffected). Code COST_ESTIMATE_TASK_NOT_ELIGIBLE. |