Skip to main content

Authentication

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.

Every Integration API request requires a bearer key. Write requests additionally require an HMAC signature and an idempotency key. These three layers are independent: all three must be present on a write, or the request is rejected.

Three auth layers on a write request

LayerHeaderApplies to
Bearer keyAuthorization: Bearer pk_live_...Every request
HMAC signatureX-Traceable-Signature: t=<unix>, v1=<hex>Writes (POST, PUT, PATCH, DELETE)
Idempotency keyIdempotency-Key: <unique-value>Writes (POST, PUT, PATCH, DELETE)

Read requests (GET) need only the bearer key.

Default-deny

A missing or invalid bearer key returns 401. An unknown resource, or one owned by another tenant, returns 404 (never 403): the API never confirms another tenant's data exists. The tenant (companyId) is always derived from the key, never from the request body or path. See Errors and conventions.

Integration API key format

Integration API keys begin with the prefix pk_live_. A pk_live_ key identifies your company (tenant) and carries the scopes assigned at mint time. You never send a companyId: the key determines the tenant, and any request that includes a companyId in the body is rejected.

Treat a pk_live_ key like a password. Do not put it in a URL, query string, log line, error report, or git.

How to mint an Integration API key

  1. Log in to app.traceable.digital with an operator account
  2. Navigate to SettingsAPI Keys
  3. Click New Integration Key
  4. Assign a name and the scopes your integration requires
  5. Copy both the key (pk_live_...) and the signing secret immediately

The key and signing secret are each shown once only. If either is lost, rotate the key — the old values cannot be recovered.

The signing secret

Each Integration API key has a corresponding signing secret used for HMAC request signing. The signing secret:

  • Is issued once when the key is minted (or rotated)
  • Is never stored by Traceable in recoverable form after it is displayed to you
  • Must be stored in your secret manager immediately
  • Cannot be retrieved later; rotate the key if it is lost

Store it in a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, or equivalent). Never put it in environment files committed to version control.

HMAC signing (write requests)

All write requests (POST, PUT, PATCH, DELETE) must be signed using HMAC-SHA256. The signature covers the timestamp, method, path, and the entire raw request body, preventing replay attacks and detecting body tampering.

Canonical string

{timestamp}.{METHOD}.{pathWithQuery}.{sha256Hex(rawBody)}

Where:

  • timestamp is the current Unix time in whole seconds (integer)
  • METHOD is the HTTP method in uppercase (POST, PUT, PATCH, DELETE)
  • pathWithQuery is the full path including any query string (e.g. /api/v1/products/abc123/submit)
  • sha256Hex(rawBody) is the hex-encoded SHA-256 hash of the whole raw request body exactly as sent. For a JSON write, that is the exact JSON string. For a multipart upload, that is the entire raw multipart body (boundaries and part headers included), not the file bytes alone. For bodyless requests (the submit endpoint), use the hash of the empty string.

Signature header format

X-Traceable-Signature: t=<timestamp>, v1=<hex>

The v1 value is the hex-encoded output of HMAC-SHA256(signingSecret, canonicalString).

Replay window

The server accepts timestamps within a short window (order of minutes) of its own clock — a recent-past allowance plus a small tolerance for future clock skew. Requests outside this window are rejected with 401 INVALID_SIGNATURE. Keep your system clock synchronised via NTP.

The server compares signatures using a constant-time function. Use one in your implementation — never compare signatures with == or string equality.

Node.js signing helper

Use this helper as written. Do not hand-roll the canonical string construction.

import { createHmac, createHash } from "node:crypto";

export function sign({ secret, method, pathWithQuery, rawBody }) {
const t = Math.floor(Date.now() / 1000);
const bodyHash = createHash("sha256")
.update(rawBody ?? "")
.digest("hex");
const v1 = createHmac("sha256", secret)
.update(`${t}.${method.toUpperCase()}.${pathWithQuery}.${bodyHash}`)
.digest("hex");
return `t=${t}, v1=${v1}`;
}

Usage for a JSON request:

const raw = JSON.stringify(body);
const headers = {
"Authorization": `Bearer ${TRACEABLE_KEY}`,
"Idempotency-Key": randomUUID(),
"X-Traceable-Signature": sign({
secret: TRACEABLE_SECRET,
method: "POST",
pathWithQuery: "/api/v1/products",
rawBody: raw,
}),
"Content-Type": "application/json",
};

A complete client (copy-paste)

A minimal headless /api/v1 client covering reads, HMAC-signed JSON writes, and multipart upload. Node 18+ (native fetch and crypto).

// traceable-client.mjs
import crypto from "node:crypto";

const BASE = process.env.TRACEABLE_BASE_URL ?? "https://app.traceable.digital";
const API_KEY = process.env.TRACEABLE_API_KEY; // "pk_live_…" (Authorization: Bearer)
const SIGNING_SECRET = process.env.TRACEABLE_SIGNING_SECRET; // "whsec_…" (shown ONCE at key creation)

const sha256Hex = (s) => crypto.createHash("sha256").update(s).digest("hex");

// Canonical v1 string binds timestamp + METHOD + path(+query) + body hash, so a captured
// signature cannot be replayed against a different method or path.
// Header: "X-Traceable-Signature: t=<unix_seconds>, v1=<hex>" (replay window: order of minutes, with a small future-skew allowance)
function signWrite(method, pathWithQuery, rawBody) {
const t = Math.floor(Date.now() / 1000);
const canonical = `${t}.${method.toUpperCase()}.${pathWithQuery}.${sha256Hex(rawBody)}`;
const v1 = crypto.createHmac("sha256", SIGNING_SECRET).update(canonical).digest("hex");
return `t=${t}, v1=${v1}`;
}

// Reads need only the bearer.
export async function apiRead(pathWithQuery) {
const res = await fetch(BASE + pathWithQuery, { headers: { authorization: `Bearer ${API_KEY}` } });
return { status: res.status, body: await res.json().catch(() => null) };
}

// JSON writes need: bearer + Idempotency-Key + HMAC signature over the EXACT body string.
export async function apiWrite(method, pathWithQuery, bodyObj) {
const rawBody = JSON.stringify(bodyObj ?? {});
const res = await fetch(BASE + pathWithQuery, {
method,
headers: {
authorization: `Bearer ${API_KEY}`,
"content-type": "application/json",
"idempotency-key": crypto.randomUUID(), // unique per logical operation
"x-traceable-signature": signWrite(method, pathWithQuery, rawBody),
},
body: rawBody,
});
return { status: res.status, body: await res.json().catch(() => null) };
}

// Multipart document upload: the HMAC is computed over the ENTIRE RAW MULTIPART BODY
// (boundaries + part headers + file), NOT the file bytes alone. Serialize the body to raw
// bytes ONCE, sign those exact bytes, and send those exact bytes.
export async function apiUpload(pathWithQuery, form /* FormData */) {
const req = new Request("http://local" + pathWithQuery, { method: "POST", body: form });
const rawMultipart = Buffer.from(await req.arrayBuffer()); // exact bytes on the wire
const contentType = req.headers.get("content-type"); // multipart/form-data; boundary=…
const sig = signWrite("POST", pathWithQuery, rawMultipart); // hash over the whole multipart body
const res = await fetch(BASE + pathWithQuery, {
method: "POST",
headers: {
authorization: `Bearer ${API_KEY}`,
"content-type": contentType,
"idempotency-key": crypto.randomUUID(),
"x-traceable-signature": sig,
},
body: rawMultipart,
});
return { status: res.status, body: await res.json().catch(() => null) };
}

Driving the headless wizard with it:

const industries = await apiRead("/api/v1/industries");
const categories = await apiRead("/api/v1/categories");
const schema = await apiRead("/api/v1/categories/BAT-EV-001/schema");
const written = await apiWrite("PUT", "/api/v1/products/<id>/form-data", { formData: { nominal_voltage: 3.7 } });
const readiness = await apiRead("/api/v1/products/<id>/readiness");
// when readiness.body.ok === true:
const submitted = await apiWrite("POST", "/api/v1/products/<id>/submit", {});
Common signing pitfalls
  • Sign the exact bytes you send. Re-serialising the body after signing (for example letting fetch stringify a different object) changes the body hash and breaks the signature.
  • Include the query string. pathWithQuery must contain ?... when present; signing the bare path fails when the request has a query.
  • One idempotency key per logical write. Reusing a key with a different body re-executes the operation rather than replaying a cached response.

Signing multipart requests (document upload)

For POST /api/v1/products/{id}/documents, the body is a multipart payload. The HMAC is computed over the entire raw multipart body (the full encoded payload including boundaries and part headers), not the file bytes alone and not a JSON string:

  1. Serialize the multipart body to its exact wire bytes once (for example via new Request(url, { method: "POST", body: form }) then await req.arrayBuffer()).
  2. Compute sha256Hex(rawMultipartBytes) over those exact bytes (the signWrite helper above does this when you pass the Buffer).
  3. Send those same bytes as the request body, with the matching Content-Type: multipart/form-data; boundary=… header.

Hashing only the file bytes, or re-serializing the form after signing (which produces a new boundary), changes the body hash and causes the signature to be rejected.

Bodyless requests (submit)

POST /api/v1/products/{id}/submit has no request body. Use the hash of the empty string:

sha256Hex("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

The signing helper handles this automatically: pass rawBody: "" or omit it (the helper defaults to "").

Idempotency-Key

Every write request (POST, PUT, PATCH, DELETE) must include an Idempotency-Key header. Use a fresh UUID for each attempt:

Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

The server caches responses keyed by (api_key_id, path, body_hash, idempotency_key) for 24 hours. If you retry a request with the same idempotency key, you receive the original response without re-executing the operation. This makes retries on transient failures (429, 502, network timeouts) safe.

Do not reuse an idempotency key across different requests. Reusing a key on a different body or path returns 400 IDEMPOTENCY_KEY_REQUIRED (the key is present but the body hash does not match the cached entry) or returns the cached response from the original request, which may be wrong for the new request.

Key rotation

To rotate without downtime:

  1. Mint a new key (and new signing secret) in Settings → API Keys
  2. Deploy the new key and secret to your integration
  3. Verify the new credentials work (a GET /api/v1/products with the new key is sufficient)
  4. Revoke the old key in Settings → API Keys

A revoked key and an unknown key both return 401. Do not rely on distinguishing between them.

Sandbox mode

Every key is either live (pk_live_*) or sandbox (pk_test_*). A sandbox key authors throwaway data that is never linked to a real published passport and has its own rate-limit quota, so you can exercise writes without side effects. The mode is a property of the key, not the base URL.

  • A sandbox request must send the header X-Traceable-Mode: test. If it is missing or wrong, the request fails loud with 400 MODE_HEADER_MISMATCH — it never silently writes to the wrong place.
  • Every authenticated response echoes the mode it was served in.
  • Live and sandbox rate-limit quotas are counted separately.
  • The GDPR endpoints are live only: a sandbox key there returns 403 CROSS_MODE_DENIED.

Build with a pk_test_ key, then switch to pk_live_ for real passports.

Per-key IP allow-list

Each key can carry an IP allow-list, allowedCidrs (a list of CIDR ranges). When it is non-empty, requests from any address outside the list are rejected. The check is fail-closed. Set it via key management (PATCH /api/v1/keys/{id}) or the Company Portal; an empty array removes the restriction.

Programmatic key management

Keys can be managed over the API under /api/v1/keys (mint, list, read, update, rotate, revoke, bulk-revoke, and rotate the signing secret), not only from the portal. This requires the privileged keys:admin scope (below).

Rotation hygiene. Integration API keys carry an expiry (expiresAt) — they do not last forever. A response served with a key old enough to warrant rotation carries a Warning: 299 header, so you are warned ahead of expiry. Rotate keys and signing secrets periodically.

Privileged scopes

Two scopes sit outside the normal grantable set and cannot be requested through the standard scope picker. They are minted only from the Company Portal admin-key dialog, each always in isolation (a key never carries a privileged scope alongside data scopes):

ScopeGrants
keys:adminManage API keys programmatically via /api/v1/keys (mint, rotate, revoke, IP allow-lists). Requesting keys:admin in a mint body is rejected.
gdpr:adminCall the GDPR data-subject endpoints /api/v1/gdpr/*. Live mode only.

See Scopes for the full vocabulary.