Skip to main content

Errors and conventions

Availability

The Integration API is GA on production (app.traceable.digital) as of v0.99.6.1. The API returns 503 FEATURE_DISABLED on any environment where it is not enabled. See Availability.

This page covers the behaviour shared by every endpoint: default-deny auth, the error envelope, the error codes, field-level validation, idempotency, rate limits, optimistic locking, and request correlation. For per-endpoint request and response schemas, see the API reference.

Default-deny​

  • A missing or invalid bearer key returns 401.
  • An unknown resource, or a resource owned by another tenant, returns 404: never 403. The API never confirms that another tenant's data exists (no existence oracle).
  • The tenant (companyId) is derived from the key only. It is never read from the request body or path; sending it is rejected.

Error envelope​

Every 4xx and 5xx response uses the same shape:

{
"error": {
"code": "MACHINE_READABLE_CODE",
"message": "Human-readable description",
"requestId": "..."
}
}

Always branch on code, never on message. Some errors add extra keys inside the error object, for example fieldErrors, currentLockVersion, fields or missingFields.

Field-level validation (and its privacy guarantee)​

Batch field writes (PUT /api/v1/products/{id}/form-data) are fully validated and fail closed: on any failure the whole batch is rejected with 422 FIELD_VALIDATION_ERROR, and error.fieldErrors[] lists each problem as {field_id, label, code, message}. Unknown fields are rejected, not silently dropped.

fieldErrors[] references the field, never the value you submitted. The API does not echo your data back in errors. This is a deliberate privacy guarantee: an error report or log line cannot leak the content an ERP sent.

Each fieldErrors[] entry carries a stable code, one of: REQUIRED_MISSING, WRONG_TYPE, INVALID_OPTION, OUT_OF_RANGE, PATTERN_MISMATCH, UNKNOWN_FIELD, FILE_REFERENCE_UNRESOLVED. The last one applies to a file field whose document id cannot be used; see File fields.

{
"error": {
"code": "FIELD_VALIDATION_ERROR",
"message": "One or more formData fields failed validation.",
"requestId": "…",
"reason": "FIELD_VALIDATION_ERROR",
"fieldErrors": [
{ "field_id": "nominal_voltage", "label": "Nominal Voltage",
"code": "WRONG_TYPE", "message": "'Nominal Voltage' must be a valid number." }
]
}
}

Error codes​

HTTPCodesNotes
400INVALID_JSON, MALFORMED_BODY, VALIDATION_ERROR, IDEMPOTENCY_KEY_REQUIRED, FILE_REQUIRED, INVALID_ID, VALIDATION, INVALID_MAPPING, MODE_HEADER_MISMATCHMalformed or incomplete request. VALIDATION: a product {id} path segment is not a UUID. INVALID_ID: used only by the /api/v1/keys/{id} routes. INVALID_MAPPING: the ?mapping= reference on PUT .../form-data is invalid or not available to the key. MODE_HEADER_MISMATCH: a sandbox (pk_test_) key was used without the correct X-Traceable-Mode: test header, or a live key sent X-Traceable-Mode: test. VALIDATION_ERROR on POST .../withdraw: the body is incomplete or a field is not allowed, including a missing freezeOrdered or directiveAuthority for the reason RegulatoryDirective, and any of the three directive fields sent with another reason. The message names each problem
401UNAUTHENTICATED, INVALID_API_KEY, INVALID_SIGNATURE, SIGNING_NOT_CONFIGUREDUNAUTHENTICATED: no bearer key presented, or the Authorization header is malformed. INVALID_API_KEY: the bearer key is unknown, revoked, or expired. INVALID_SIGNATURE: the HMAC signature check failed
403FORBIDDEN_SCOPE, FORBIDDEN_MODULE, COMPANY_INACTIVE, CROSS_MODE_DENIEDThe key lacks the required scope or module, the company is suspended or inactive (COMPANY_INACTIVE, returned by every write except key revoke and bulk revoke, webhook endpoint delete and GDPR erase), or the key is the wrong mode for the endpoint (for example a sandbox key on a live-only GDPR endpoint)
402PLAN_LIMIT_REACHEDPOST /api/v1/products/{id}/units would exceed the plan's battery passport limit. The limit is checked for the whole call before the first unit is created, so nothing was created
404NOT_FOUND, NO_FILE, SUPPLIER_NOT_ACCESSIBLE, CHILD_PRODUCT_NOT_AVAILABLE, LINKED_DPP_NOT_AVAILABLEUnknown, cross-tenant, or inaccessible resource. Default-deny returns 404 (never a 403 "exists but forbidden" oracle) for unknown or cross-tenant resources
409LOCK_CONFLICT, PRODUCT_FROZEN, INVALID_STATE, STATUS_CHANGED, PRODUCT_NOT_PUBLISHED, GTIN_IN_USE, NAME_IN_USE, IDEMPOTENCY_IN_PROGRESSA conflict with current state; do not blind-retry. STATUS_CHANGED: the product's status changed while a withdraw request was being processed; re-read the product, then decide whether to send the request again. PRODUCT_NOT_PUBLISHED: POST .../units was sent for a model that is not published. IDEMPOTENCY_IN_PROGRESS: an identical request with the same Idempotency-Key is still running; it carries Retry-After: 5, so retry with the same key
413TOO_MANY_UNITSPOST .../units carried more than 1,000 distinct serials. Send a larger fleet as several calls
415UNSUPPORTED_MEDIA_TYPEDocument file type not allowed
422FIELD_VALIDATION_ERROR, SUBMIT_BLOCKED, SUBMIT_REJECTED, FILE_INVALID, INVALID_METADATA, STORAGE_LIMIT, PLAN_LIMIT, SELF_REFERENCE, CIRCULAR_REFERENCE, MAX_COMPONENTS, GS1_PREFIX_INVALID, SUCCESSOR_INVALID, DEMO_PRODUCT, NO_MAPPING_FOR_SOURCE, UNKNOWN_FIELDS, CERT_RULE_OUT_OF_SCOPE, DOCUMENT_REJECTED, PATCH_REJECTED, ROTATE_REJECTED, ENDPOINT_REJECTED, REJECTEDDeterministic rejection; fix the input. NO_MAPPING_FOR_SOURCE: a column in formData has no entry in the referenced mapping. UNKNOWN_FIELDS: an ERP mapping references field ids that do not exist. PATCH_REJECTED, ROTATE_REJECTED and ENDPOINT_REJECTED come from the key and webhook endpoint routes. REJECTED: a well-formed request the platform cannot accept
429RATE_LIMITEDPer-key limit (reads and writes); back off
502UPLOAD_FAILED, DOWNLOAD_FAILED, WITHDRAW_FAILEDTransient storage error; safe to retry. DOWNLOAD_FAILED also has a second cause that is not transient: see Document download refusals
500INTERNAL_ERRORAn unexpected server error; retry after a short delay and contact support with the requestId if it persists
503FEATURE_DISABLEDThe API is not enabled on this environment

The set can grow; treat an unlisted code as an error of its HTTP class.

A few worth calling out:

  • LOCK_CONFLICT (409): an optimistic-lock clash on update. Re-GET, re-apply, retry with the fresh lockVersion.
  • PRODUCT_FROZEN (409): the product is no longer editable (for example, already submitted).
  • INVALID_STATE (409): for example, withdrawing a product that is not in a withdrawable state.
  • STATUS_CHANGED (409): the product's status changed while a withdraw request was being processed. Re-read the product before you decide whether to send the request again.
  • SELF_REFERENCE / CIRCULAR_REFERENCE / MAX_COMPONENTS (422): component-graph violations.
  • SUBMIT_BLOCKED (422): mandated fields are missing; error.missingFields lists them. Surface to the user; do not retry-loop.

Document download refusals​

GET /api/v1/documents/{id}/download answers 502 DOWNLOAD_FAILED for two different causes. Tell them apart by error.message, not by the status code.

CauseWhat the message saysWhat to do
A transient storage errorFailed to generate a download link.Retry with backoff
The file is still being checked for malwareThis file is still being checked for malware. Try again in a few minutes.Retry later, once the check has finished. Do not retry in a tight loop
The file is infectedBlocked by malware scan.Do not retry. The same request returns the same refusal
The file's check could not be completedThis file could not be checked for malware yet, so it cannot be downloaded. Try again later, or upload it again if this keeps happening.Do not retry in a loop. The same request returns the same refusal. Upload the file again

Treat any other message that says a file was not checked for malware as a refusal too, and do not retry it in a loop.

A document whose file was removed because it is infected is answered 404 NO_FILE instead. Branch on code first, then read message only to separate these cases.

Error response examples​

404 cross-tenant or not-owned supplier (for example POST .../traceability referencing a supplier you do not own):

{ "error": { "code": "SUPPLIER_NOT_ACCESSIBLE", "message": "Supplier not available…",
"requestId": "…", "reason": "SUPPLIER_NOT_ACCESSIBLE" } }

409 optimistic lock (PUT .../form-data, PATCH .../products/{id}): carries the current lockVersion so you can re-read and retry:

{ "error": { "code": "LOCK_CONFLICT", "message": "…re-read and retry…",
"requestId": "…", "currentLockVersion": 5 } }

409 frozen product (a write against an Approved or Deprecated product):

{ "error": { "code": "PRODUCT_FROZEN", "message": "…", "requestId": "…", "reason": "PRODUCT_FROZEN" } }

409 invalid state (for example withdrawing a product that is not Approved):

{ "error": { "code": "INVALID_STATE", "message": "…", "requestId": "…", "reason": "INVALID_TRANSITION" } }

The remaining classes follow the same envelope: 401 for a missing or invalid key or a bad signature, 403 FORBIDDEN_SCOPE for a missing scope, 429 RATE_LIMITED (fail-closed) when rate-limited, and 503 FEATURE_DISABLED where the API is not enabled.

Retry guidance​

  • Transient (429, 502, other 5xx): retry with exponential backoff, reusing the same Idempotency-Key. The exception is a DOWNLOAD_FAILED that refuses an infected file or one whose check failed: it returns the same refusal on every retry (see Document download refusals).
  • Deterministic (4xx with a specific code): the result will not change on retry. Fix the input.
  • LOCK_CONFLICT: re-fetch, re-apply, retry with the new lockVersion.

Idempotency​

Every write must send an Idempotency-Key header (a fresh value per operation). The server caches the response, scoped to the key plus the path id plus a hash of the body, for 24 hours (1 hour for key mint and rotate, signing-secret rotate, and webhook endpoint create and secret rotate), so retries on transient failures are safe.

Rate limits​

The Integration API is rate-limited per API key at 100 requests / minute for each operation bucket, counting both reads and writes. GET /api/v1/products, GET /api/v1/products/{id} and GET /api/v1/products/{id}/gate are not counted against the per-key limit and do not return RateLimit-* headers; the per-IP edge limit still applies to them. Live (pk_live_) and sandbox (pk_test_) keys have separate quotas. The limiter fails closed: if the backend is unavailable, requests are rejected with 429, not allowed through unchecked. Back off and retry, honouring the Retry-After response header. See Rate limiting for the full three-layer picture.

Optimistic locking​

PATCH /api/v1/products/{id} is optimistic-locked. Send expectedLockVersion with the lockVersion from your last read; a stale value returns 409 LOCK_CONFLICT. PUT /api/v1/products/{id}/form-data takes the same expectedLockVersion field. The request body schema is strict, so a body key named lockVersion is rejected with 400 VALIDATION_ERROR.

Request correlation​

Every response includes an X-Traceable-Request-Id header, mirrored as requestId in any error envelope. Persist it: it links your request to the entry on the Traceable audit trail.

Was this page helpful?