Lifecycle quickstart
This walkthrough uses the full Integration API lifecycle — create, update, gate, submit, document upload, and the richer steps (discovery, form-data ingest, readiness, traceability, components, GS1). Every one of these is GA on production (app.traceable.digital) as of release v0.99.6.1. See Availability, and use a sandbox key (pk_test_) to exercise writes without side effects.
This page walks through the complete Integration API lifecycle using a worked example: mapping an Odoo product record to a Traceable DPP and submitting it for human review.
The example ends at submit for review. A human operator publishes the DPP in the Traceable portal — the API does not publish. See Regulatory boundary.
Prerequisites
- An Integration API key (
pk_live_...) with scopesdpp:draft:write,dpp:gate:read,dpp:submit, anddocument:write - The signing secret issued at key mint
- Both stored in your secret manager, never in code or environment files committed to version control
Field mapping: Odoo to Traceable
This table shows how Odoo fields map to Traceable DPP formData keys.
| Odoo model / field | Traceable formData key |
|---|---|
product.template.barcode (EAN-13 GTIN) | gtin |
product.template.name | productName (top-level, not in formData) |
product.product.default_code | model_number |
stock.production.lot.name (serial number) | battery_serial |
BoM lines / mrp.bom | material_composition[] |
Compliance attachments (ir.attachment) | Upload via POST .../documents (step 3) |
Complete example script (odoo-to-dpp.mjs)
The script below is runnable as a single Node.js ESM file. Replace the environment variable names with your actual secret manager references — never hardcode TRACEABLE_KEY or TRACEABLE_SECRET.
import { createHmac, createHash, randomUUID } from "node:crypto";
// TODO(staging-surface PR): base URL points at staging.traceable.digital; staging's status as a
// public testing surface is owned by a separate PR — do not re-describe it here.
const BASE = "https://staging.traceable.digital";
const BEARER = process.env.TRACEABLE_KEY; // pk_live_...
const SIGNING_SECRET = process.env.TRACEABLE_SECRET;
// Canonical string: t.METHOD.pathWithQuery.sha256Hex(rawBody)
function sign(method, pathWithQuery, rawBody = "") {
const t = Math.floor(Date.now() / 1000);
const bodyHash = createHash("sha256").update(rawBody).digest("hex");
const v1 = createHmac("sha256", SIGNING_SECRET)
.update(`${t}.${method.toUpperCase()}.${pathWithQuery}.${bodyHash}`)
.digest("hex");
return `t=${t}, v1=${v1}`;
}
async function write(method, path, bodyObj) {
const raw = bodyObj ? JSON.stringify(bodyObj) : "";
const res = await fetch(BASE + path, {
method,
headers: {
authorization: `Bearer ${BEARER}`,
"idempotency-key": randomUUID(),
"x-traceable-signature": sign(method, path, raw),
...(bodyObj ? { "content-type": "application/json" } : {}),
},
body: bodyObj ? raw : undefined,
});
return { status: res.status, body: await res.json().catch(() => ({})) };
}
async function read(path) {
const res = await fetch(BASE + path, {
headers: { authorization: `Bearer ${BEARER}` },
});
return { status: res.status, body: await res.json().catch(() => ({})) };
}
// --- map one Odoo product ---
const odoo = {
name: "VoltRide 48V Pack",
barcode: "09506000134352",
default_code: "VR-48-720",
lot: "SN-2026-0001",
};
// Step 1: create draft (expect 201)
const created = await write("POST", "/api/v1/products", {
productName: odoo.name,
formData: {
gtin: odoo.barcode,
model_number: odoo.default_code,
battery_serial: odoo.lot,
},
});
if (created.status !== 201) {
throw new Error(
`create failed ${created.status}: ${JSON.stringify(created.body)}`
);
}
const id = created.body.product.id;
console.log("created", id, "publicUrl", created.body.publicUrl);
// Step 2: update more fields (expect 200; on 409 LOCK_CONFLICT: re-GET then retry)
await write("PATCH", `/api/v1/products/${id}`, {
formData: { nominal_voltage_v: 48, nominal_capacity_wh: 720 },
lockVersion: 1,
});
// Step 3: check the gate (expect 200 with gap list)
const gate = await read(`/api/v1/products/${id}/gate`);
console.log("gate ready?", gate.body.ready, "gaps:", gate.body.gapCount);
// Gaps with resolver: "erp" can be closed via PATCH or document upload.
// Gaps with resolver: "operator-ui" or "verifier" need a human in the Traceable portal.
// Step 4: submit for review (expect 200 Submitted, or 422 SUBMIT_BLOCKED listing missing fields)
const submit = await write("POST", `/api/v1/products/${id}/submit`);
if (submit.status === 200) {
console.log("submitted — status:", submit.body.product.status); // "Submitted"
} else if (submit.status === 422) {
// Do NOT retry-loop. Surface the gap list to the Odoo user so they can fix the data.
console.log("blocked — missing fields:", submit.body.error.missingFields);
} else {
throw new Error(`submit failed ${submit.status}: ${JSON.stringify(submit.body)}`);
}
Expected status at each step
| Step | Success | Common non-success and action |
|---|---|---|
| Create | 201 | 409 GTIN_IN_USE — GTIN is already registered; 422 PLAN_LIMIT — contact support |
| Update | 200 | 409 LOCK_CONFLICT — re-fetch the product (GET /products/{id}), apply your changes to the fresh record, retry with the new lockVersion |
| Gate check | 200 | 404 NOT_FOUND — incorrect product ID |
| Submit | 200 | 422 SUBMIT_BLOCKED — mandatory fields are missing; surface missingFields to the user and fix before retrying |
What happens next
Once submitted, the DPP is in the Traceable review queue. A human operator reviews it in the portal and either publishes it or returns it for revision.
Poll GET /api/v1/products/{id} to observe status changes:
Submitted— in review queueApproved— operator has approved; publication imminentPublished— DPP is live atpublicUrlDraft— returned for revision (check the operator notes in the portal)
The publicUrl returned at create time is the permanent URL. It becomes accessible to the public once the DPP reaches Published state.
Adding evidence documents (optional, before submit)
Attach compliance certificates, test reports, and declarations before calling submit. The gate report will indicate which document types are expected.
import { createReadStream, statSync } from "node:fs";
import { FormData, Blob } from "node:fetch"; // Node 18+
const fileBuffer = readFileSync("un38_3_test_report.pdf");
const form = new FormData();
form.append("file", new Blob([fileBuffer], { type: "application/pdf" }), "un38_3_test_report.pdf");
form.append("documentName", "UN38.3 Test Report 2026");
form.append("documentType", "Test Report");
// Sign over the ENTIRE raw multipart body (boundaries + part headers + file), NOT the file bytes
// alone. Serialize the body to bytes once, sign those exact bytes, and send those exact bytes.
// See Authentication > Signing multipart requests.
const req = new Request(`${BASE}/api/v1/products/${id}/documents`, { method: "POST", body: form });
const rawBytes = Buffer.from(await req.arrayBuffer());
const res = await fetch(`${BASE}/api/v1/products/${id}/documents`, {
method: "POST",
headers: {
authorization: `Bearer ${BEARER}`,
"content-type": req.headers.get("content-type"), // multipart/form-data; boundary=…
"idempotency-key": randomUUID(),
"x-traceable-signature": sign("POST", `/api/v1/products/${id}/documents`, rawBytes),
},
body: rawBytes,
});
See POST /api/v1/products/{id}/documents in the API reference for the full schema, allowed file types, and error codes.
Postman collection
Prefer to click through the lifecycle before writing code? Import the Postman collection:
It contains the create, get, update, gate, and submit requests against the sandbox base URL. Set the BEARER and SIGNING_SECRET collection variables to your sandbox key and signing secret. Note that Postman will not compute the HMAC signature for you: the signing recipe is in Authentication, and for scripted runs the Node helper in this quickstart is the simplest path.
The full reference
Every endpoint, request schema, and response code is rendered from the canonical OpenAPI spec in the API reference, which also has a built-in request console.