| 400 | BAD_REQUEST | varies | Generic bad request for malformed or invalid input that does not fit a more specific code. |
| 400 | CHECKOUT_PRICE_CHECKSUM_MISMATCH | The price changed since this quote. Confirm the new estimate. | POST /ingestions/:id/checkout-session carried a price_checksum that no longer matches what the selection costs. Neither figure is charged. details carries expected, received and estimate — the full fresh estimate, so the caller re-renders the price and asks the customer to confirm again. Not retryable as sent: the same body fails identically. Detects drift, not tampering — the amount is always recomputed server-side. |
| 400 | MISSING_FILE | Missing file upload in `file` field | A multipart request to an ingestion endpoint did not contain a file part, or the part had a different field name. |
| 400 | MISSING_LOCATION_TYPE | Missing required field "location_type"; allowed values: […] | The location_type multipart field was not provided. Must be facility, plot, or shed. |
| 400 | INVALID_LOCATION_TYPE | Invalid location_type "…"; allowed values: […] | The location_type value is not in the allowed set. |
| 400 | INVALID_COMMODITY_TYPE | Invalid commodity_type "…"; allowed values: […] | The commodity_type value is not in the allowed set (cattle, cocoa, coffee, palm, rubber, soy, timber, shrimp), or the field was sent empty (omit it entirely for auto-detection). Common aliases (e.g. palm oil / oil palm → palm) are normalised to a canonical value rather than rejected. |
| 400 | INVALID_COMMODITY_COLUMN | Invalid commodity_column; expected one non-empty column name of at most 255 characters | The multipart commodity_column field was sent empty, sent more than once, or is longer than 255 characters. Omit it to let the commodity column be detected. |
| 400 | MULTIPART_REQUIRED | File uploads must use multipart/form-data with a `file` field | The request body is not multipart/form-data on an upload endpoint. |
| 400 | INVALID_MULTIPART | Failed to parse multipart request | The multipart request body could not be parsed (malformed boundaries, truncated stream, etc.). |
| 400 | MISSING_CONTENT_LENGTH | Content-Length header is required for file uploads | The Content-Length header was not provided on a file upload request. |
| 400 | VALIDATION_ERROR | Request validation failed | Fastify/Zod validation failed for request params, query, body, or headers. Returns a summary such as Request validation failed: params/id: Invalid UUID. Details in meta.validation and meta.validationContext. |
| 400 | WEBHOOK_URL_INVALID | Webhook URL must use HTTPS / cannot target localhost | The webhook subscription URL failed validation: must use HTTPS (HTTP allowed in dev only) and must not target localhost, 127.0.0.1, or 0.0.0.0. |
| 400 | WEBHOOK_VERIFICATION_FAILED | Verification failed | The webhook URL verification handshake failed (target did not respond correctly to the challenge). |
| 400 | UNSUPPORTED_MEDIA_TYPE | Unsupported file type; expected CSV, Parquet, JSON, GeoJSON, GeoPackage, Shapefile, or Excel | The file's Content-Type and/or extension are not in the allowed set during ingestion. |
| 400 | AUTHORIZATION_TEXT_VERSION_MISMATCH | Authorisation text_version "…" is not the current version | A DDS authorisation was submitted referencing a retired legal-text version. The current version is returned in meta.expected. |
| 401 | MISSING_API_KEY | Missing API key | The x-api-key header is absent or empty. Sent by the global API key auth preHandler before any route runs. |
| 401 | INVALID_API_KEY | Invalid key | The x-api-key header was present but the key could not be validated: wrong prefix, key not found, revoked/expired, or lookup failure. |
| 401 | UNAUTHORIZED | Unauthorized | The route requires a member-level context but the client used an org-level key, or auth context is missing entirely. |
| 403 | FORBIDDEN | Access denied | The authenticated caller does not have access to the requested resource (organization mismatch). |
| 403 | FORBIDDEN | Insufficient scope | The API key does not have the required scope. Only applies to member keys (sk-epoch-) with explicit scopes set. |
| 403 | SERVICE_NOT_ENABLED | Cadastral service is not enabled for this organisation | The caller invoked an endpoint for a service (e.g. cadastral parcel search) that the organisation is not entitled to use. |
| 404 | NOT_FOUND | varies (e.g. "Facility not found", "Plot not found", "Supply shed not found") | The requested resource does not exist. Returned by individual route handlers when a lookup by ID yields no result. |
| 404 | SUBSCRIPTION_NOT_FOUND | This organisation has no live subscription. | DELETE /organizations/:id/subscription or POST /organizations/:id/subscription/resume found no live subscription. A subscription past its period end is canceled and is not live. |
| 406 | NOT_ACCEPTABLE | Accept header must include one of application/json, application/x-ndjson, … | The Accept header is missing or does not include a supported content type. |
| 409 | CONFLICT | Task status is '…'; ingestion requires 'completed' or 'completed_with_errors' | The task is not in a valid state for the requested operation (e.g. parcel ingestion on a non-completed task, or requesting a summary before completion). |
| 409 | TAG_NAME_CONFLICT | A tag named '…' already exists | A tag rename was attempted but the target name is already in use within the organization. |
| 409 | CHECKOUT_SESSION_PENDING | A checkout session for this selection is being created. | POST /ingestions/:id/checkout-session lost the race for the same task and selection, and the winner has not yet recorded its Stripe session id. Retryable — the same request succeeds once the winner finishes. On POST /organizations/:id/checkout-sessions it means another credit pack is being created, or the previous one is completing payment. On POST /organizations/:id/subscription/checkout-session it means a plan purchase or upgrade for the organisation is already waiting for payment. |
| 409 | CHECKOUT_SESSION_CONFLICT | This checkout session could not be recorded. Try again. | POST /ingestions/:id/checkout-session created a Stripe session but could not record it: another row already holds that session id, or the claimed row had moved on. The claim is released, so a retry is clean. |
| 409 | CHECKOUT_SUBSCRIPTION_EXISTS | This organisation already has a subscription. | POST /ingestions/:id/checkout-session carried a plan, or POST /organizations/:id/subscription/checkout-session was called, while the organisation already holds a live subscription (pending, incomplete, trialing, active, past_due or unpaid) — including a same-tier request, a live row that is not active or trialing, and a negotiated plan key. details carries plan_key and status for the subscription that already holds the slot. Enforced by a partial unique index before any Stripe call, so two concurrent purchase attempts cannot both win. |
| 409 | CHECKOUT_PLAN_DOWNGRADE | A self-serve plan can only move to a higher tier. | POST /organizations/:id/subscription/checkout-session or POST /ingestions/:id/checkout-session asked for a tier below the live one (starter < growth < pro). details carries plan_key (the live tier) and requested. Nothing is claimed and Stripe is never called. |
| 409 | CHECKOUT_UPGRADE_IN_PROGRESS | An upgrade for this organisation is already waiting for payment. | POST /organizations/:id/subscription/checkout-session or POST /ingestions/:id/checkout-session tried to claim a second upgrade row while one is already pending. details carries plan_key and requested. Complete or abandon the waiting upgrade; one per organisation is enforced by a unique index. |
| 409 | SUBSCRIPTION_NOT_CANCELLABLE | varies — names the status, a missing Stripe subscription, or a change Stripe applied | DELETE /organizations/:id/subscription or POST /organizations/:id/subscription/resume found a pending or incomplete subscription, a live row with no Stripe subscription yet, a negotiated plan, or Stripe applied the change but reports a status the route does not show (for example paused). details.status names the status. |
| 409 | INGESTION_RUN_IN_PROGRESS | Part of this ingestion is still being read. Quote it once every task in the run has finished. | POST /ingestions/:id/cost-estimate was called on a plot upload that also carried address-only rows, while one of its two tasks is still pending, processing or validating. The address rows are geocoded as facilities on a sibling task; poll run.overall_status on either task and quote once it leaves processing. |
| 413 | PAYLOAD_TOO_LARGE | Uploaded file is too large (max 100MB) | The uploaded file exceeds the configured size limit. Detected from Content-Length or mid-stream. |
| 415 | UNSUPPORTED_MEDIA_TYPE | Unsupported Media Type | Fastify rejected an unregistered content type at the framework level (distinct from the ingestion-level 400 check). |
| 422 | COST_ESTIMATE_TASK_NOT_ELIGIBLE | A cost estimate applies only to a screening run, requested through the self-serve journey. | POST /ingestions/:id/cost-estimate was called on a task whose run kind is not screening (a full run, or a task with an unknown run kind); on a task a direct org key created, or one created before its creator was recorded; or the caller's org API key was not issued for the self-serve journey (key_type is not self_serve). A member key carries no key_type and is unaffected as a caller, and a task a member created is eligible. |
| 422 | CHECKOUT_TASK_NOT_ELIGIBLE | A checkout session applies only to a screening run, bought through the self-serve journey. | The same three causes as the row above, on POST /ingestions/:id/checkout-session. Named for the checkout route so a consumer branching on code is not handed a cost-estimate-named failure. |
| 422 | CREDIT_CHECKOUT_NOT_ELIGIBLE | A credit purchase is available only to the self-serve journey. | POST /organizations/:id/checkout-sessions was called with an org API key whose key_type is not self_serve, or with a member key for an organisation whose credits are not enforced (its runs never spend credits, so a pack would sit unused). |
| 422 | CHECKOUT_NOTHING_CHARGEABLE | Varies — names the cause: the balance, exemption, or nothing to price. | POST /ingestions/:id/checkout-session found nothing to charge for. FOUR causes share this code and the message names which one. Covered (the one credit balance) and exempt both mean there is no payment to make, so proceed without a checkout. An empty or unpriceable ingestion means there is no priceable work — fix the ingestion instead. |
| 422 | CHECKOUT_SELECTION_TOO_LARGE | A selection may cover at most 10060 units. | The selection's total entitlement exceeds the per-session cap. Split it into several checkouts. |
| 422 | OPERATOR_INCOMPLETE | varies | A DDS record PATCH referenced an operator that is incomplete (missing required operator fields) for the requested update. |
| 422 | PRE_SUBMISSION_BLOCKED | DDS submission blocked by pre-flight checks | A DDS submission failed pre-flight validation. The blocking reasons are listed in data.details.blockers. |
| 422 | GEOCODE_NO_RESULT | could not geocode supplier | The geocoder returned no usable result for the supplier — zero features, or the top feature has no valid WGS84 Point geometry. Returned by GET /discovery/validate/{taskId}/results, and as the failed task's error_code on …/status. |
| 409 | TRACES_REFERENCE_PENDING | TRACES has not yet issued a reference/verification number for this statement | The TRACES read-through statement routes need the statement's reference and verification number, which TRACES issues asynchronously after a submit. Retry once the reconciliation sweep has recorded them. |
| 429 | RATE_LIMIT_EXCEEDED | varies | The client has exceeded the rate limit. Retry after the duration indicated in the Retry-After response header. |
| 502 | CHECKOUT_GATEWAY_REJECTED | Stripe refused the checkout session. Retrying will not help. | POST /ingestions/:id/checkout-session, DELETE /organizations/:id/subscription or POST /organizations/:id/subscription/resume reached Stripe and Stripe refused the request — an invalid parameter, a bad key, a missing price. Not transient: the same request fails identically until the cause is fixed. |
| 502 | TRACES_REQUEST_REJECTED | varies | A TRACES read-through or lookup call reached TRACES and TRACES answered with an authoritative fault. Not transient: the same request fails identically until the cause is fixed. |
| 503 | TRACES_UNAVAILABLE | varies | A TRACES read-through or lookup call could not reach TRACES — network failure, timeout, or client configuration. Transient: safe to retry. |
| 503 | CHECKOUT_UPSTREAM_ERROR | Could not reach Stripe to create the session. Try again. | POST /ingestions/:id/checkout-session, DELETE /organizations/:id/subscription or POST /organizations/:id/subscription/resume could not reach Stripe (a connection or API fault), or Stripe rate-limited the call. The subscription routes change nothing on this error. The claim it took is released before responding, so a retry succeeds once Stripe recovers rather than 409ing forever. |
| 503 | CHECKOUT_NOT_CONFIGURED | Checkout is not enabled on this deployment. | The deployment has no Stripe gateway configured. Also returned by the subscription cancel and resume routes. Distinct from SERVICE_NOT_ENABLED, which is a 403 about the cadastral service: this is a deployment gap, not an entitlement one. |
| 500 | INTERNAL_SERVER_ERROR | Internal Server Error | An uncaught exception occurred. The client always receives this generic message; the real error is logged server-side. |
| 500 | STORAGE_ERROR | Failed to persist uploaded file | The storage layer failed to persist an uploaded file (disk error, permissions, driver failure, etc.). |
| 502 | TASK_DISPATCH_FAILED | Failed to dispatch task | The API accepted the request but could not hand the long-running job off to the task runner (dispatch/transport failure). |
| 502 | GEOCODE_UPSTREAM_ERROR | geocoding service is unavailable | A transient upstream/transport failure while geocoding (sco2api 5xx, network error, or request timeout) — lets agent callers tell "retry me" apart from an unresolvable supplier. Returned by POST /discovery/validate (the task could not be started) and GET /discovery/validate/{taskId}/results, and as the failed task's error_code on …/status. |
| 422 | COMMODITY_COLUMN_NOT_FOUND | commodity_column '…' is not a column of this file. | POST /ingestions/profile named a commodity_column the file does not have. details carries commodity_column and columns, the file's column names. An upload with the same column fails its task with this message. |
| 502 | PROFILE_UPSTREAM_ERROR | Could not profile the file: the upstream profiler did not respond successfully. | The file profiler was unreachable or failed. Distinct from a 400/415 on the file itself, so a caller can tell "retry me" from "your file is wrong". Returned by POST /ingestions/profile. |
| 503 | CREDIT_LEDGER_UNAVAILABLE | Credit ledger is temporarily unavailable; try again shortly | The credit ledger is not reachable, so no balance can be read. A fault on our side, not an entitlement refusal — never read it as a zero balance. Carries retry-after. Returned by GET /organizations/:id/credits. |