Este artículo aún no está disponible en español; se muestra a continuación en inglés.
On this pageSet up an endpoint
Instead of polling, let us tell you. Every change worth knowing about becomes a signed HTTPS POST to your endpoint, retried until it gets through.
Set up an endpoint
Register an endpoint in the app under Organisation → API → Webhooks, or over the API with `POST /webhooks`. Choose the events it receives — none selected means all; `GET /webhooks/event-types` lists them. Copy its signing secret (whsec_…) into your secret store, then send a signed ping with `POST /webhooks/{id}/test` — or from the app — and check your receiver answers 2xx.
- The URL must use
httpsand resolve to a public address; private, loopback and link-local addresses are refused. - Credentials in the URL are not allowed — the signature is the authentication.
Over the API you can also update or pause an endpoint (enabled: false), rotate its secret, read its delivery log and resend a delivery. Endpoints are paused rather than deleted over the API, so their history survives.
Events
| Type | When | data contains |
|---|---|---|
passport.created | A battery and its draft passport were created (once per battery in a batch). | passport |
passport.published | A passport was published. | passport |
passport.status_changed | A passport’s status changed (PATCH /passports/{id}/status). | passport, status; forked (passportId, uniqueIdentifier of the new linked passport) for a repurpose, re-use or remanufacture — none for WASTE |
passport.recycled | A passport ceased because the battery was recycled. | passport |
passport.event_recorded | Dynamic data was recorded. | passport, event (id, eventType, payload, recordedAt) |
model.created | A model was created. | model |
model.updated | A model was changed. | model |
document.uploaded | A document was uploaded to a model. | document (id, modelId, name, type, point, createdAt) |
supplier.submitted | A supplier answered a data request — values or a file. | request (id, supplierName, modelId, kind), and fields or file |
passport is { id, uniqueIdentifier, serialNumber, itemId, modelId, status, lifecycleState, publishedAt, publicUrl }; model is { id, name, category, capacityKwh, chemistry, updatedAt }. Payloads are deliberately small: fetch the full record with its id when you need more. The test event is ping, with data.message. Each event type is also described in the webhooks section of the OpenAPI document.
The delivery
POST /hooks/dpp HTTP/1.1Content-Type: application/jsonUser-Agent: Collecta-DPP-Webhooks/1.0DPP-Event: passport.publishedDPP-Event-Id: evt_5f0c1d2e3a4b4c6d8e9f0a1b2c3d4e5fDPP-Delivery: cm1qf3g4h0060l70fm5n6b7v8DPP-Signature: t=1799920931,v1=6c1f0e…9b2a{"id": "evt_5f0c1d2e3a4b4c6d8e9f0a1b2c3d4e5f","type": "passport.published","created_at": "2027-01-15T10:02:11.214Z","data": {"passport": {"id": "cm1q8y0bq0006l70fx9y8z7w6","uniqueIdentifier": "C-7KQ2M9XHT4R","serialNumber": "C48-2027-0001","itemId": "cm1q8y0bq0005l70fq2r3t4u5","modelId": "cm1q8x2k40003l70f9a8s7d6e","status": "ORIGINAL","lifecycleState": "ACTIVE","publishedAt": "2027-01-15T10:02:11.000Z","publicUrl": "https://dppapi-production.up.railway.app/p/C-7KQ2M9XHT4R"}}}Verify the signature
DPP-Signature is t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of the string "<t>.<raw body>" keyed with your endpoint secret. To verify a delivery:
- 1Read the raw request body, byte for byte. Parsing and re-serialising JSON changes it and breaks the signature.
- 2Split the header on
,and each part on the first=; taketandv1. - 3Reject the delivery if
tis more than five minutes from your clock — that blocks replays of captured requests. - 4Compute HMAC-SHA256 of
t + "." + bodywith the secret, as hex, and compare it tov1in constant time.
// Node.js 18+, no dependencies. Verify against the RAW body — not re-serialised JSON.import { createHmac, timingSafeEqual } from 'node:crypto';import { createServer } from 'node:http';const SECRET = process.env.DPP_WEBHOOK_SECRET; // whsec_…const TOLERANCE_SECONDS = 300;export function verifyDppSignature(rawBody, header, secret = SECRET) {const parts = Object.fromEntries(String(header ?? '').split(',').map((pair) => pair.trim().split('=', 2)),);const timestamp = Number(parts.t);if (!Number.isInteger(timestamp) || !parts.v1) return false;if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest();const received = Buffer.from(parts.v1, 'hex');return received.length === expected.length && timingSafeEqual(received, expected);}createServer((req, res) => {const chunks = [];req.on('data', (chunk) => chunks.push(chunk));req.on('end', () => {const raw = Buffer.concat(chunks).toString('utf8');if (!verifyDppSignature(raw, req.headers['dpp-signature'])) {res.writeHead(400).end();return;}const event = JSON.parse(raw);res.writeHead(200).end(); // acknowledge first, then do the workconsole.log(event.id, event.type, event.data);});}).listen(8080);Responding and retries
- Answer with any
2xxwithin 10 seconds. Acknowledge first and do the work afterwards, on a queue. - Anything else — another status, a timeout, a connection error, or a redirect (redirects are not followed) — is a failed attempt.
- Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours. After the sixth attempt the delivery is marked failed.
- The delivery log in the app shows each attempt, the status and body your endpoint returned, and lets you resend.
At least once, in any order
A delivery can arrive more than once — a retry after your endpoint processed it but timed out — and deliveries can arrive out of order. Make the handler idempotent: store the event id (also in DPP-Event-Id) and skip ids you have seen. Where order matters, compare created_at, or fetch the current state of the record by its id.