Skip to main content

Lifecycle quickstart

Availability

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 with scopes dpp:draft:write, dpp:gate:read, dpp:submit, and document:write. This walkthrough uses a sandbox key (pk_test_...) so nothing touches real passports; see Sandbox
  • 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 / fieldTraceable formData key
product.template.barcode (EAN-13 GTIN)gtin
product.template.nameproductName (top-level, not in formData)
product.product.default_codemodel_number
stock.production.lot.name (serial number)serial_number
BoM lines / mrp.bommaterial_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";

// A sandbox key (pk_test_...) runs against the production base URL; the mode is a property of the key.
const BASE = "https://app.traceable.digital";
const BEARER = process.env.TRACEABLE_KEY; // pk_test_...
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}`,
"x-traceable-mode": "test", // required with a pk_test_ key; drop it for pk_live_
"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}`, "x-traceable-mode": "test" },
});
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)
// industryCode, categoryCode and formTemplateMockId are required. The codes come from
// GET /api/v1/industries and GET /api/v1/categories; see the note below the script for formTemplateMockId.
const created = await write("POST", "/api/v1/products", {
productName: odoo.name,
industryCode: "BAT",
categoryCode: "BAT-EV-001",
formTemplateMockId: process.env.TRACEABLE_FORM_TEMPLATE_ID,
formData: {
gtin: odoo.barcode,
model_number: odoo.default_code,
serial_number: 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.product.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, rated_energy_kwh: 0.72 },
expectedLockVersion: 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)}`);
}

The create call is strict: it requires productName, industryCode, categoryCode, formTemplateMockId and formData, and an incomplete body returns 400 VALIDATION_ERROR with the missing keys listed in error.fields. The discovery endpoints (GET /api/v1/industries, GET /api/v1/categories) return the industry and category codes, but they do not return formTemplateMockId, and the API reference does not yet document where an integrator reads it. Until it does, the script reads it from the TRACEABLE_FORM_TEMPLATE_ID environment variable, and you need to obtain the value for your category from Traceable. Only use field ids that the category schema lists (GET /api/v1/categories/{code}/schema); unknown field ids are rejected.

Expected status at each step​

StepSuccessCommon non-success and action
Create201409 GTIN_IN_USE: GTIN is already registered; 422 PLAN_LIMIT: contact support
Update200409 LOCK_CONFLICT: re-fetch the product (GET /products/{id}), apply your changes to the fresh record, retry with the new lockVersion
Gate check200404 NOT_FOUND: incorrect product ID
Submit200422 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 the review queue
  • Approved: published, the DPP is live at publicUrl
  • Draft: returned for revision (check the operator notes in the portal)
  • Withdrawn, Deprecated and Revoked: no longer current

The publicUrl returned at create time is the permanent URL. It becomes accessible to the public once the status is Approved.

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.

// Continues the script above: `BASE`, `BEARER`, `id`, `randomUUID` and `sign` are defined there.
// FormData and Blob are globals in Node 18+.
import { readFileSync } from "node:fs";

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}`,
"x-traceable-mode": "test",
"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 is generated from the canonical OpenAPI spec and covers the full API surface (discovery, products, lifecycle, operational, ERP mapping, key management, webhooks, and GDPR) organised into one folder per tag. Set the bearer token variable to your sandbox key. Note that Postman will not compute the HMAC signature on write requests 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.

Was this page helpful?