Cet article n'est pas encore disponible en français ; il est affiché ci-dessous en anglais.
On this pageInstall
The official TypeScript client, @collecta/dpp-sdk: every request and response typed, an idempotency key on every write, automatic retries, auto-pagination and webhook verification — with no runtime dependencies.
Install
npm install @collecta/dpp-sdk# pnpm add @collecta/dpp-sdk · yarn add @collecta/dpp-sdkIt uses the global fetch, so it runs on Node 18+, Deno, Bun, Cloudflare Workers and Vercel Edge. It ships as ESM, with a second entry point, @collecta/dpp-sdk/webhooks, for receivers.
Quickstart
import { CollectaDpp } from '@collecta/dpp-sdk';const dpp = new CollectaDpp({ apiKey: process.env.DPP_API_KEY! });// 1. A battery model: the data every battery of this type shares.const model = await dpp.models.create({category: 'EV',name: 'NV-EV-90',capacityKwh: 90,chemistry: 'NMC811',fields: { manufacturerName: 'Nordvolt Energy GmbH', modelDesignation: 'NV-EV-90' },});// 2. What is still missing for an EV passport?const report = await dpp.models.completeness(model.id);console.log(report.missing.map((gap) => gap.label));// 3. One battery = one passport (Art. 77(1)), with an identifier that never changes.const battery = await dpp.batteries.create({modelId: model.id,serialNumber: 'NV-0001',placeOfManufacture: 'Berlin, DE',});// 4. Publish it, and fetch its print-grade QR code.await dpp.passports.publish(battery.passport.id);const png = await dpp.passports.qrPng(battery.passport.id); // Uint8ArrayThe Quickstart shows every step with an SDK tab next to the raw HTTP samples.
Configuration
const dpp = new CollectaDpp({apiKey: 'dpp_live_…', // requiredbaseUrl: 'https://dppapi-production.up.railway.app/v1', // default; include /v1maxRetries: 3, // retries after the first attempttimeoutMs: 30_000, // per attemptfetch: customFetch, // defaults to globalThis.fetchuserAgent: 'acme-erp/4.2', // appended to the SDK's user agentonResponse: (r) => log(r.requestId, r.status, r.rateLimit?.remaining),});dpp.mode; // "live" | "test", read from the key prefixEvery method also takes a final options argument:
{ idempotencyKey?: string; dryRun?: boolean; signal?: AbortSignal; timeoutMs?: number; maxRetries?: number }Resources
| Resource | Methods |
|---|---|
dpp.models | list, listAll, get, create, update, validate, completeness, uploadDocument |
dpp.batteries | create, createMany, get, listForModel |
dpp.passports | list, listAll, get, publish, publishMany, recordEvent, events, eventsAll, changeStatus, recycle, qrSvg, qrPng, versions |
dpp.fleet | bulkLifecycle, importUpdates |
dpp.documents | list, listAll, get, download |
dpp.audit | list, listAll |
dpp.webhooks | list, create, get, update, rotateSecret, test, deliveries, deliveriesAll, resend, eventTypes |
dpp.export | json, archive |
| Client | me(), fieldCatalog(), request() for any other endpoint |
Models
import { readFile } from 'node:fs/promises';const page = await dpp.models.list({ category: 'EV', complete: false, updatedSince: '2027-01-01', limit: 50 });page.data[0].requiredFilled; // completeness is part of every list rowconst model = await dpp.models.get(id); // with conformity, documents, declarationsawait dpp.models.update(id, { fields: { ratedCapacity: 225 } }); // fields merge; omitted keys are keptconst check = await dpp.models.validate({category: 'EV',name: 'X',capacityKwh: 90,chemistry: 'LFP',documentTypes: ['EU Declaration of Conformity'],});check.complete; check.missing; check.unknownFields; // nothing storedawait dpp.models.uploadDocument(id, {file: await readFile('DoC.pdf'), // Buffer, Uint8Array, ArrayBuffer, Blob or Filefilename: 'DoC.pdf',type: 'EU Declaration of Conformity',point: 1, // 1 public (default), 3 authority only});Batteries
const battery = await dpp.batteries.create({modelId,serialNumber: 'NV-0001',dateOfManufacture: new Date('2027-01-15'),placeOfManufacture: 'Berlin, DE',});battery.uniqueIdentifier; // never changesbattery.passport.id; // the draft passport// Up to 1,000 in one transaction: explicit serials …await dpp.batteries.createMany({ modelId, rows: [{ serialNumber: 'A-1' }, { serialNumber: 'A-2' }] });// … or a count with a prefix (NV-0001, NV-0002, …)await dpp.batteries.createMany({ modelId, count: 500, serialPrefix: 'NV-', placeOfManufacture: 'Berlin, DE' });await dpp.batteries.get(itemId); // with model, passport, registrationawait dpp.batteries.listForModel(modelId, { status: 'draft', q: 'NV-00', page: 1, pageSize: 100 });Passports
await dpp.passports.list({ modelId, status: 'attention', published: true, updatedSince: lastSync });const passport = await dpp.passports.get(id); // every field, events, lineageawait dpp.passports.publish(id); // 422 publish_blocked if not readyconst batch = await dpp.passports.publishMany(ids); // per-row resultsbatch.results.filter((r) => !r.ok);await dpp.passports.recordEvent(id, { eventType: 'SOH_READING', payload: { stateOfCertifiedEnergy: 96.4 } });for await (const e of dpp.passports.eventsAll(id, { eventType: 'CYCLE_COUNT' })) { /* … */ }const result = await dpp.passports.changeStatus(id, { status: 'REPURPOSED', note: 'Second life: storage' });if ('forked' in result) console.log('new passport', result.childUid);await dpp.passports.recycle(id, { note: 'Shredded at plant 3' }); // the public page answers 410 from now onconst svg = await dpp.passports.qrSvg(id); // stringconst versions = await dpp.passports.versions(id); // the published archive, newest firstWebhook endpoints
const endpoint = await dpp.webhooks.create({url: 'https://erp.example.com/dpp-webhooks',events: ['passport.published', 'passport.recycled'], // omit for every event});saveSecret(endpoint.secret); // shown once: store it nowawait dpp.webhooks.update(endpoint.id, { enabled: false }); // paused, not deletedconst { secret } = await dpp.webhooks.rotateSecret(endpoint.id); // the old secret stops verifying at onceawait dpp.webhooks.test(endpoint.id); // sends a ping, answers with your receiver's replyfor await (const d of dpp.webhooks.deliveriesAll(endpoint.id, { status: 'failed' })) {await dpp.webhooks.resend(d.id);}Anything else
Endpoints without a dedicated method — facilities, supplier requests — go through dpp.request() with the same authentication, retries, idempotency and errors:
const plants = await dpp.request<Array<{ id: string; name: string }>>({ method: 'GET', path: '/plants' });Pagination
// Every item, fetched lazily, page by pagefor await (const passport of dpp.passports.listAll({ limit: 200 })) { /* … */ }// Collect, with an optional capconst first500 = await dpp.passports.listAll().toArray({ max: 500 });// Whole pagesfor await (const page of dpp.audit.listAll().pages()) console.log(page.data.length);// Or by handlet page = await dpp.passports.list({ limit: 200 });while (page.has_more) page = await dpp.passports.list({ limit: 200, cursor: page.next_cursor! });listAll is available on models, passports (plus eventsAll), documents, audit and webhooks.deliveriesAll.
Idempotency and retries
Every write is sent with an Idempotency-Key. When you pass none, the SDK generates a UUID and reuses it for each retry of that call, so a timeout after the server created 500 batteries returns the stored answer instead of creating 500 more. Pass your own key to stay safe across processes, such as a job queue re-running a task:
await dpp.batteries.createMany({ modelId, count: 500 }, { idempotencyKey: `order-${orderId}-batteries` });- Retried automatically: network errors, timeouts,
429,5xxand409 idempotency_in_progress. Other4xxare never retried. - Backoff: 500 ms · 2^attempt, capped at 30 s, with 50–100 % jitter;
retry-afteris honoured up to the cap. maxRetries(default 3) andtimeoutMs(default 30 s per attempt) on the client or per call; anAbortSignalcancels the call and any pending retry wait.
Dry runs
With a live key, dryRun: true validates a write and stores nothing, sends no webhook and counts no usage. The return type changes to DryRunResult. A test key dry-runs every write; isDryRunResult() handles both in one code path.
import { isDryRunResult } from '@collecta/dpp-sdk';const check = await dpp.passports.publish(id, { dryRun: true });if (!check.would_publish) console.log(check.blockers); // e.g. missing_place_of_manufactureconst result = await dpp.batteries.create(input);if (isDryRunResult(result)) console.log('validated only:', result.checks);Typed errors
| Class | Status | Retried |
|---|---|---|
BadRequestError | 400 — details maps fields to messages | No |
AuthenticationError | 401 | No |
PaymentRequiredError | 402 — requiredPlan names the plan | No |
PermissionDeniedError | 403 | No |
NotFoundError | 404 | No |
ConflictError | 409 | Only idempotency_in_progress |
GoneError | 410 | No |
UnprocessableEntityError | 422 — publish_blocked, idempotency_key_reused, … | No |
RateLimitError | 429 — retryAfterSeconds | Yes |
InternalServerError | 5xx | Yes |
DppConnectionError, DppTimeoutError | No answer | Yes |
All API errors extend DppApiError and carry status, code (the error field), message, details, requestId and the raw body. For publish_blocked, details holds the blockers.
import { DppApiError, UnprocessableEntityError } from '@collecta/dpp-sdk';try {await dpp.passports.publish(id);} catch (error) {if (error instanceof UnprocessableEntityError && error.code === 'publish_blocked') {for (const blocker of error.details as Array<{ reason: string; message: string }>) console.log(blocker.message);} else if (error instanceof DppApiError) {console.error(error.status, error.code, error.requestId);} else {throw error;}}Response metadata
const { data, response } = await dpp.passports.publish(id).withResponse();response.requestId; // x-request-idresponse.mode; // "live" | "test"response.dryRun; // x-dpp-dry-runresponse.idempotentReplayed; // idempotent-replayedresponse.idempotencyKey; // the key that was sentresponse.rateLimit; // { limit, remaining, resetSeconds }response.attempt; // 0 on the first try, 1 after one retry, …Verifying webhooks
verifyWebhook checks DPP-Signature in constant time, refuses deliveries outside toleranceSeconds (default 300) to block replays, and returns the typed event. It uses node:crypto on Node and WebCrypto elsewhere. Pass the raw body — parsing and re-serialising JSON changes the bytes and the signature will not match.
import express from 'express';import { verifyWebhook, WebhookVerificationError } from '@collecta/dpp-sdk/webhooks';const app = express();// Raw body for this route only; do not put express.json() in front of it.app.post('/dpp-webhooks', express.raw({ type: 'application/json' }), async (req, res) => {try {const event = await verifyWebhook({payload: req.body, // a Bufferheader: req.headers['dpp-signature'],secret: process.env.DPP_WEBHOOK_SECRET!,});res.sendStatus(200);await handle(event);} catch (error) {if (error instanceof WebhookVerificationError) return res.status(400).send(error.reason);throw error;}});import { verifyWebhook, WebhookVerificationError } from '@collecta/dpp-sdk/webhooks';export async function POST(request: Request) {const payload = await request.text(); // the raw bodytry {const event = await verifyWebhook({payload,header: request.headers.get('dpp-signature'),secret: process.env.DPP_WEBHOOK_SECRET!,});await handle(event);return new Response(null, { status: 200 });} catch (error) {if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });throw error;}}Next.js Pages Router: export config = { api: { bodyParser: false } } and read the raw body from the request stream before verifying.
Typed events
import type { WebhookEvent } from '@collecta/dpp-sdk/webhooks';async function handle(event: WebhookEvent) {switch (event.type) {case 'passport.published':await markLive(event.data.passport.uniqueIdentifier, event.data.passport.publicUrl);break;case 'passport.event_recorded':console.log(event.data.event.eventType, event.data.event.payload);break;case 'passport.status_changed':if (event.data.forked) console.log('new passport', event.data.forked.uniqueIdentifier);break;case 'supplier.submitted':console.log(event.data.request.supplierName, event.data.fields ?? event.data.file);break;case 'ping':break;}}- Secret rotation: pass both secrets while you deploy the new one —
secret: [newSecret, oldSecret]. - Testing a receiver without the API:
generateWebhookSignatureHeader({ payload, secret })from@collecta/dpp-sdk/webhooksproduces a valid header. - Respond
2xxquickly and work afterwards; de-duplicate onevent.id.
TypeScript types
import type {ModelCreateInput, BatteryModel, ModelSummary, CompletenessReport,BatteryCreateInput, CreatedBattery, PassportListRow, PassportDetail,DynamicEventInput, DynamicEventType, DryRunResult, Page, ResponseMeta,} from '@collecta/dpp-sdk';import type { WebhookEvent, WebhookEventOf } from '@collecta/dpp-sdk/webhooks';type Published = WebhookEventOf<'passport.published'>;Response dates are ISO 8601 strings; dates you send may be Date objects or strings. Within v1 the API only adds fields, so expect extra properties on responses.