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 422 too, deliberately. Under-reporting paid work is the one direction that misleads a paywall, so an unknown kind is treated as full, not as screening.

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.

FieldTypeMeaning
unit_idsarray of strings, optionalBillable units to price. Omit to price the whole task.
planstarter | growth | pro, optionalPreview a tier the organisation has not bought.

unit_ids — two id forms

A billable unit is not always one row, so the id says which kind it is:

FormNamesExample
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

actionMeaningWhat to do
readyThe credit balance covers the required amount.Show the breakdown. Skip payment. amount_total is 0.
top_upThe balance falls short.Show the breakdown. Charge for the shortfall. This is the normal paid path.
exemptCredits are switched off for this organisation.Show the rows, with no paywall. Every row is covered_by: "exempt".
unpriceableThe 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_byMeaningPaid for?
nullThe row is charged. amount > 0 and stripe is populated.—
balanceSettled 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
exemptCredits are not enforced for this organisation.No
not_pricedThe 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:

kindCarriesNever carries
supplier_locationsrow_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
subscriptionplan_key, included_unitsunit_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.

FieldMeaning
row_keyStable 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_nameWhat 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_countLocations in the unit. Display only and NEVER a multiplier.
creditsAlways 1: one supplier unit per row, what the run reserves and what the row is charged for.
credits_covered / credits_chargeableThe split. They sum to at most credits: a unit can be neither charged nor paid for.
amount_full / amount_dueWhat 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_idsEvery 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_idsThe 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 bundle grain 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 (action exempt or unpriceable), because neither sells anything. units[] still lists what was selected.

sum(supplier_items[].amount_due) equals amount_total.

Top-level fields:

FieldMeaning
creditsavailable, reserved, required, shortfall, sufficient. available is already net of reserved — do not add them together.
subscriptionnull when the organisation is pay-as-you-go and no plan was previewed. Otherwise reports the plan in use or previewed.
selected_countHow many units were priced. Equals units.length.
covered_countRows paid for — covered_by is balance.
chargeable_countRows being charged for.
not_payable_countRows neither charged nor paid for — covered_by is exempt or not_priced.
amount_subtotal, amount_discount, amount_tax, amount_totalSee below. All in minor units.
taxbehavior, exempt, tax_ids, determined.

price_checksum: carry it to the checkout

Every 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 checksumDoes not change it
pricing_version, currencyestimate_source
amount_subtotal, amount_discount, amount_totalamount_tax (see below)
Any unit's unit_amount, discount_amount, amountlocation, producer, type, note
Any unit's covered_by — it changes who paysmember_count
The set of unit_ids, and the order of the returned units[]The whole subscription block
A subscription item's plan_keyaction, and the four counts
tax.behavior and tax.exempt — they change what you settle without moving amount_totaltax.determined and tax.tax_ids

Two exclusions are deliberate and worth knowing:

  • amount_tax is not in the digest. It is null until 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_source is 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 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

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

CodeMeaning
200Estimate returned.
400Malformed request body — a unit_id that is not loc:<uuid> or cluster:<n>, an unknown body field, or more than 500 unit_ids.
403Missing the billing:read scope.
404The task does not exist, or does not belong to your organisation.
422The 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.