Errors and conventions
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: never403. 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β
| HTTP | Codes | Notes |
|---|---|---|
| 400 | INVALID_JSON, MALFORMED_BODY, VALIDATION_ERROR, IDEMPOTENCY_KEY_REQUIRED, FILE_REQUIRED, INVALID_ID, VALIDATION, INVALID_MAPPING, MODE_HEADER_MISMATCH | Malformed 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 |
| 401 | UNAUTHENTICATED, INVALID_API_KEY, INVALID_SIGNATURE, SIGNING_NOT_CONFIGURED | UNAUTHENTICATED: 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 |
| 403 | FORBIDDEN_SCOPE, FORBIDDEN_MODULE, COMPANY_INACTIVE, CROSS_MODE_DENIED | The 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) |
| 402 | PLAN_LIMIT_REACHED | POST /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 |
| 404 | NOT_FOUND, NO_FILE, SUPPLIER_NOT_ACCESSIBLE, CHILD_PRODUCT_NOT_AVAILABLE, LINKED_DPP_NOT_AVAILABLE | Unknown, cross-tenant, or inaccessible resource. Default-deny returns 404 (never a 403 "exists but forbidden" oracle) for unknown or cross-tenant resources |
| 409 | LOCK_CONFLICT, PRODUCT_FROZEN, INVALID_STATE, STATUS_CHANGED, PRODUCT_NOT_PUBLISHED, GTIN_IN_USE, NAME_IN_USE, IDEMPOTENCY_IN_PROGRESS | A 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 |
| 413 | TOO_MANY_UNITS | POST .../units carried more than 1,000 distinct serials. Send a larger fleet as several calls |
| 415 | UNSUPPORTED_MEDIA_TYPE | Document file type not allowed |
| 422 | FIELD_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, REJECTED | Deterministic 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 |
| 429 | RATE_LIMITED | Per-key limit (reads and writes); back off |
| 502 | UPLOAD_FAILED, DOWNLOAD_FAILED, WITHDRAW_FAILED | Transient storage error; safe to retry. DOWNLOAD_FAILED also has a second cause that is not transient: see Document download refusals |
| 500 | INTERNAL_ERROR | An unexpected server error; retry after a short delay and contact support with the requestId if it persists |
| 503 | FEATURE_DISABLED | The 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 freshlockVersion.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.missingFieldslists 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.
| Cause | What the message says | What to do |
|---|---|---|
| A transient storage error | Failed to generate a download link. | Retry with backoff |
| The file is still being checked for malware | This 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 infected | Blocked by malware scan. | Do not retry. The same request returns the same refusal |
| The file's check could not be completed | This 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, other5xx): retry with exponential backoff, reusing the sameIdempotency-Key. The exception is aDOWNLOAD_FAILEDthat refuses an infected file or one whose check failed: it returns the same refusal on every retry (see Document download refusals). - Deterministic (
4xxwith a specific code): the result will not change on retry. Fix the input. LOCK_CONFLICT: re-fetch, re-apply, retry with the newlockVersion.
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.