OpenDPP Integration API

v1.12.1 OpenAPI 3.1 69 endpoints · 5 webhook events · 117 schemas openapi.json openapi.yaml

OpenDPP is a B2B platform for EU Digital Product Passports (DPPs), aligned with the ESPR (Regulation (EU) 2024/1781) data requirements and the EU Battery Regulation (Regulation (EU) 2023/1542). This specification documents the public integration surface: everything an external system needs to create, validate, seal, publish, resolve and verify passports.

Authentication

Authenticate with a tenant API key sent as a Bearer token: Authorization: Bearer op_dpp_token_…. Keys are created in the Client Console (Developers → API keys), are shown once at creation, carry a role plus optional narrowed permissions and optional expiry, and can be revoked at any time. API-key clients are exempt from CSRF requirements. Public endpoints (tagged Public Resolution, plus the public validators and the audit verifier) need no credentials.

Tenancy

Tenant identity is token-bound — it is derived from your API key, never from the request host. The same paths work on the apex host and on tenant workspace hosts (https://<workspace>.opendpp-node.eu); when a workspace host is used, it must match the key's tenant (requests across workspaces are rejected with 403).

Versioning & compatibility

This contract carries a SemVer version, readable at runtime from GET /api/v1/version. Pin the MAJOR. It equals the /api/v1 URL major, so a breaking change ships as a new path major (/api/v2) that you adopt deliberately — not as an edit to the contract you already integrated against.

Within a major line:

  • MINOR is additive — a new endpoint, a new optional parameter, a new field on a response. A client that ignores what it does not recognise keeps working. Do not treat unknown response fields as errors.
  • PATCH is documentation only: wording, examples, descriptions. Nothing observable in the contract changes.

The tier is not asserted by hand. Every change is diffed structurally against the previous contract in CI, and a version bump lower than the diff requires fails the build — so the number you pin to is derived from the contract itself.

One exception, disclosed rather than hidden. While this contract is pre-GA, a breaking change may exceptionally ship on the existing major line under a recorded waiver (API_CONTRACT_RESET) instead of forcing a new path major. It is not a standing option: it requires a maintainer to enable it for a single merge, and every use is recorded with its justification in the project's versioning audit trail. It has been used during the pre-GA period. Once this line reaches GA the waiver is retired, and the MAJOR promise above becomes unconditional. If you need a contract that cannot move under you before then, pin the exact version you generated your client from and upgrade deliberately.

Errors

Authenticated endpoints return { success: false, error, message } (some omit success). Across the developer-facing write/ingest surface (passport / operator / unit / resolver / facility / events / webhooks) the body also carries a machine-stable code you can branch on instead of parsing message — see the code enum on the shared Error schema for the full set. ESPR metadata validation failures return the richer shape documented as ValidationFailed with per-field errors[]/warnings[] (localizable via ?lang= or Accept-Language; 28 languages). Bulk endpoints report row-level problems as errors: string[]. Malformed JSON and query-string violations return Fastify's default { statusCode, code, error, message } body.

Every response — success or error — carries an X-Request-Id header; generic (server-error / framework) bodies also include it as requestId. Quote it to support to correlate with server logs. Send your own well-formed X-Request-Id and it is adopted for end-to-end tracing.

Advisories: warnings[] & notices[]

Success responses may carry two non-blocking advisory channels of coded items (AdvisoryItem: { code, path?, message, friendlyMessage }). warnings[] are heads-ups the request still succeeded on (NON_GS1_PRODUCT_ID, PII_SHAPE_DETECTED, UNIT_NO_SCANNABLE_LINK, DRAFT_DEMOTED, EORI_NOT_FOUND); notices[] are informational — helpful things the API did (OPERATOR_AUTO_ATTRIBUTED, GTIN_AUTO_COPIED). Branch on the STABLE code; treat message (developer English) and friendlyMessage (end-user, localized via ?lang=/Accept-Language across 28 languages) as display text that may be reworded. Interfaces may also map a code to their own localized string.

Rate limits

Two limits apply, and the one that bites first depends on how you call us.

Per API key (authenticated calls). Each key gets a per-minute budget set by the plan: Growth 120, Scale 600, Enterprise unlimited. A second ceiling of 3x that rate applies across all of a workspace's keys together, so issuing more keys divides throughput fairly between your own systems rather than multiplying it. Plans below Growth do not include API access. Exceeding either budget returns 429 with a Retry-After header giving the seconds to wait.

Per IP (all traffic). A ceiling of 100 requests/min per IP applies to anonymous traffic, raised for requests that carry an Authorization header so that several integrations behind one egress address are not held to the anonymous budget, and for verified crawler user-agents. x-ratelimit-* response headers report the applicable ceiling. Every plan that can reach the API sits at or above the anonymous figure, so an authenticated caller never meets a stricter limit than the number above.

Public passport resolution is additionally limited to 30 requests/min per IP (no headers). The public validator is limited to 10 requests/min per IP.

Stay under these limits with client-side queueing; on 429, back off and retry after the indicated window. A 429 never indicates a credential problem — an invalid or revoked key returns 401, so do not rotate a key in response to rate limiting.

Sealing & verification

Passport seals are eIDAS advanced electronic seals (ECDSA P-256 over a Merkle root of the passport content, optional RFC 3161 timestamp). Anyone can verify a seal — no account required. POST /api/v1/audit/verify recomputes every Merkle leaf from the submitted values, so it requires the unredacted document (caller-supplied redacted-leaf hashes are deliberately not trusted). Redacted documents remain verifiable offline: masked fields keep their true leaf hashes in proof.redactedLeaves, letting any verifier rebuild the sealed root without the privileged values.

Public access tiers

Public resolution endpoints serve tiered views of the same URL: the public tier for anonymous callers; a restricted tier for holders of legitimate-interest (dpp_li_…) or authority (dpp_auth_…) capability tokens (presented as a Bearer token or ?grant= query parameter); and the owner tier for the issuing tenant's own credentials.

Webhooks

Subscribe to passport lifecycle events (passport.ingested, passport.sealed, passport.recalled, or *). Deliveries are HMAC-SHA256-signed; see the webhooks section of this document for the exact signature scheme, retry schedule, and payloads.

This document is also served machine-readably at /openapi.json and /openapi.yaml.

Open interoperability kit

The interoperability boundary — the official AAS + UNTP/W3C-VC schemas, live-reproducible samples, an offline conformance validator, and the field mappings — is open source at github.com/OpenDPP/opendpp-interop (Apache-2.0). It lets any integrator validate and verify OpenDPP's standards-conformant output without access to the product source.

Passports

Create, validate, read, update, seal and manage the lifecycle of Digital Product Passports. Passport metadata is category-specific: machine-readable JSON Schemas are served live at GET /api/v1/schemas/{category} for textiles, batteries, electronics, chemicals and construction; the remaining categories (cosmetics, toys, iron-steel, aluminium) are validated by built-in rules — use the dry-run validators to check payloads for any category.

GET /api/v1/passports API key

List passports in your workspace (paginated JSON-LD)

Returns the non-archived passports of every economic operator bound to your workspace, newest first (createdAt DESC). Operator-scoped API keys only see passports of their bound operator.

Permission: passport:read (read-only — no subscription/402 gate).

Filtering: category and originCountry are exact-match filters on the top-level metadata keys of the same name. Known metadata.category values: textiles, batteries, electronics, cosmetics, toys, iron-steel, aluminium, chemicals, construction; originCountry is ISO 3166-1 alpha-2.

Pagination: page (default 1) and limit (default 10) are numeric strings matching ^[0-9]+$ — any other value is rejected with the framework's default 400 validation body (see 400). Parsed values are clamped server-side to page >= 1 and 1 <= limit <= 100. There is no total count; page until you receive fewer than limit items.

Serialization caveats: - The redaction tier of each item depends on the credential's role: only BRAND_OPERATOR credentials receive the unredacted owner-tier document. Every other role — including TENANT_ADMIN — receives the public tier: facilityDetails (and, for batteries, detailedPerformance / lifecycleAndInUse / circularityAndDisassembly) are masked to the literal string "[REDACTED - Privileged Access Required]". - economicOperator.role is absent from list items and manufacturingFacility is always null here — fetch a single passport (GET /api/v1/passports/{id}) for the facility node and operator role. - The response passes through a declared response schema: top-level keys other than success, page, limit, passports are stripped. Passport items allow additional properties, so undeclared item keys (status, archivedAt, retentionUntil, manufacturingFacility, the flattened metadata keys) pass through intact — but two declared item keys are mangled by their subschemas: the @context term-map object (second array element) is always emptied to {}, and proof is emptied to {} on sealed items (null on unsealed) — signatureValue, merkleRoot, redactedLeaves, x5c and rfc3161 are all stripped from list output. Fetch a single passport (GET /api/v1/passports/{id}) or the public resolver for the verifiable proof block.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
page query integer optional

1-based page number (digits only).

default 1 · min 1
limit query integer optional

Page size.

default 10 · min 1 · max 100
category query string optional

Exact-match filter on metadata.category. Known values: textiles, batteries, electronics, cosmetics, toys, iron-steel, aluminium, chemicals, construction.

originCountry query string optional

Exact-match filter on metadata.originCountry (ISO 3166-1 alpha-2, e.g. PT).

Example

curl -X GET "https://opendpp-node.eu/api/v1/passports" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

Paginated list of JSON-LD passport documents. No total count is returned. Note the list-specific mangling: the @context term map is emptied to {} and proof contents are stripped (see operation description).

application/json PassportListResponse
Example 200 response
{
  "success": true,
  "page": 1,
  "limit": 10,
  "passports": [
    {
      "@context": [
        "https://opendpp-node.eu/contexts/dpp/v1",
        {}
      ],
      "@type": "DigitalProductPassport",
      "@id": "https://opendpp-node.eu/01/09501101530003",
      "id": "9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d",
      "productId": "09501101530003",
      "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003",
      "digitalSeal": null,
      "signingPublicKey": null,
      "status": "ACTIVE",
      "archivedAt": null,
      "retentionUntil": null,
      "proof": null,
      "createdAt": "2026-06-12T09:41:00.000Z",
      "updatedAt": "2026-06-12T09:41:00.000Z",
      "economicOperator": {
        "@type": "EconomicOperator",
        "id": "f2d6a9c1-4b3e-4d2a-8c1f-0a9b8c7d6e5f",
        "name": "Aurora Textiles GmbH",
        "regId": "EU-DEFAULT-001"
      },
      "manufacturingFacility": null,
      "metadata": {
        "category": "textiles",
        "originCountry": "PT",
        "size": "M",
        "facilityDetails": "[REDACTED - Privileged Access Required]"
      },
      "category": "textiles",
      "originCountry": "PT",
      "size": "M",
      "facilityDetails": "[REDACTED - Privileged Access Required]"
    }
  ]
}
400

Route validation failure (framework default body — note statusCode/code keys, no success field): page or limit did not match ^[0-9]+$.

application/json object
statusCode integer required
code string
error string required
message string required
Example 400 response
{
  "statusCode": 400,
  "code": "FST_ERR_VALIDATION",
  "error": "Bad Request",
  "message": "querystring/page must match pattern \"^[0-9]+$\""
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
POST /api/v1/passports API key

Create (ingest) a Digital Product Passport

Creates a SKU/type-level Digital Product Passport.

Permission: passport:create (Bearer op_dpp_token_… API key or session JWT; cookie sessions must also send the X-CSRF-Token double-submit header). Write operations are subject to subscription gating (402) and, where the workspace enforces it, MFA (403).

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After. Body limit: 1 MiB (1,048,576 bytes)413 beyond that.

Validation. Unless draft: true, metadata is validated against the ESPR category rules for metadata.category plus cross-field rules (e.g. materialComposition percentages must sum to 100 ±0.1, originCountry must be a real ISO 3166-1 alpha-2 code), and the product's EPCIS traceability lineage is audited. For five categories (textiles, batteries, electronics, chemicals, construction) the authoritative per-category JSON Schema is served live at GET /api/v1/schemas/{category}; the other four (cosmetics, toys, iron-steel, aluminium) are validated by built-in server-side rules and GET /api/v1/schemas/{category} returns 404 for them. Failure returns the 400 Validation Failed body with per-field errors[] (plus warnings[] when any exist — the key is omitted entirely when there are none). A passing payload may still produce non-blocking warnings[], echoed in the 201 — including a privacy-by-design advisory (#400) when the metadata *looks* like it carries personal data (a clearly-personal field name such as email/firstName, or an email-shaped value; scanned one level deep, at most one such advisory). A DPP should carry PRODUCT data, not PII (ESPR FAQ Q16); this advisory never blocks the save. friendlyMessage texts are localized via ?lang= or Accept-Language (default en); category-validity errors (metadata.category missing or unknown) carry no friendlyMessage.

Drafts. draft: true skips ALL validation, stores the passport with status: "DRAFT" (not publicly resolvable), returns message: "Draft passport saved" with warnings: [], and does not emit a webhook.

Identifier handling. productId may be a GTIN-14 (14 digits, GS1 mod-10 check digit), a GRAI (14-digit numeric asset id + up to 16 alphanumeric serial chars), or a free-form SKU. A 14-digit productId whose GS1 mod-10 check digit is invalid is rejected with 400 (a typo'd GTIN is never silently downgraded to a SKU); a non-numeric or non-14-digit productId is accepted as a non-GS1 SKU and carries a non-blocking warnings[] advisory that it resolves via /passport/{id} with no scannable GS1 QR. A valid GTIN-14 is auto-copied into metadata.gtin (a GRAI into metadata.grai) before storage. The server mints a UUID passport id and a GS1 Digital Link URI https://opendpp-node.eu/{01|8003}/{productId}.

Operator binding. With operatorId omitted, the passport is attributed to the first economic operator bound to your workspace; if no operator is bound at all the request fails 400 (the API never fabricates an operator identity — register one via POST /api/v1/operators). An operatorId not bound to your workspace → 403. Operator-scoped API keys force their own operator and 403 on mismatch. The (productId, operatorId) pair is unique → 409 on duplicates. An optional facilityId must reference a Facility in your workspace (400 otherwise).

Webhook: non-draft creation transactionally enqueues a passport.ingested event whose payload is the public redacted JSON-LD passport document (same masking as the 201 passport field). Drafts emit nothing.

Response caveats: the 201 passport field is the public, redacted JSON-LD representation — even for the creator. The owner-only metadata key facilityDetails is replaced with the literal placeholder "[REDACTED - Privileged Access Required]" (it appears as the placeholder even when you did not supply it), and for category: "batteries" the restricted legitimate-interest keys detailedPerformance, lifecycleAndInUse and circularityAndDisassembly (Battery Reg. Annex XIII parts 2-4) are masked the same way when present. enrichment is stored outside the validated metadata and Merkle seal and never appears in the JSON-LD document. The 201 body's top-level fields are success, message, passport, warnings, and the vcReady/vcReadyReason UNTP Verifiable-Credential readiness signal (#247).

Other 400 bodies: non-validation failures (whitespace-only productId, no bound operator, unknown facilityId) reuse status 400 with the plain {"success": false, "error": "Bad Request", "message": …} triple and no errors/warnings arrays. Requests rejected before the handler runs — request-body schema violations (e.g. missing productId) and malformed JSON — come back as just {"error": "Bad Request", "message": …}.

Parameters

NameInTypeDescription
Idempotency-Key header string optional

Optional client idempotency key (≤255 characters, no control characters). Retrying this request with the same Idempotency-Key replays the ORIGINAL response — same status and body, plus an idempotent-replayed: true response header — instead of creating a duplicate passport or returning 409. Scoped per (workspace, endpoint, key) and consulted within a 24-hour window; a malformed key returns 400. Best-effort: the replay is recorded after the write commits, so in the rare window between commit and recording (or across an instance restart) a retry falls back to normal processing — never a double write, but you may then see the normal 409 instead of a replay.

max length 255
lang query string optional

Locale for localized friendlyMessage validation texts. One of: en, bg, hr, cs, da, nl, et, fi, fr, de, el, hu, ga, it, lv, lt, mt, pl, pt, ro, sk, sl, es, sv, no, is, uk, tr. Unknown values are ignored; falls back to Accept-Language, then en.

Request body required

application/json PassportCreateRequest
Example request
{
  "productId": "09501101530003",
  "operatorId": "5c1e0f3a-7b2d-4c8e-a91f-2d3e4f5a6b7c",
  "metadata": {
    "category": "iron-steel",
    "originCountry": "DE",
    "materialComposition": [
      {
        "material": "Recycled steel",
        "percentage": 62.5
      },
      {
        "material": "Virgin steel",
        "percentage": 37.5
      }
    ],
    "facilityDetails": [
      {
        "facilityName": "Musterstahl Works Duisburg",
        "location": "Duisburg, DE",
        "activity": "Hot rolling"
      }
    ],
    "regulatoryCompliance": {
      "ceMarking": true,
      "certificates": [
        {
          "name": "EN 10025-2 Mill Certificate",
          "referenceNumber": "MC-2026-00417",
          "issuer": "TUV Rheinland"
        }
      ]
    },
    "scrapMetalContentRatio": 62.5,
    "tensileStrengthClass": "S355",
    "carbonEmissionIntensityPerTon": 1.42
  },
  "enrichment": {
    "tagline": "Low-carbon structural steel",
    "images": [
      {
        "url": "https://cdn.example.com/steel-beam.jpg",
        "caption": "S355 beam"
      }
    ]
  }
}

Example

curl -X POST "https://opendpp-node.eu/api/v1/passports" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "productId": "09501101530003",
    "operatorId": "5c1e0f3a-7b2d-4c8e-a91f-2d3e4f5a6b7c",
    "metadata": {
      "category": "iron-steel",
      "originCountry": "DE",
      "materialComposition": [
        {
          "material": "Recycled steel",
          "percentage": 62.5
        },
        {
          "material": "Virgin steel",
          "percentage": 37.5
        }
      ],
      "facilityDetails": [
        {
          "facilityName": "Musterstahl Works Duisburg",
          "location": "Duisburg, DE",
          "activity": "Hot rolling"
        }
      ],
      "regulatoryCompliance": {
        "ceMarking": true,
        "certificates": [
          {
            "name": "EN 10025-2 Mill Certificate",
            "referenceNumber": "MC-2026-00417",
            "issuer": "TUV Rheinland"
          }
        ]
      },
      "scrapMetalContentRatio": 62.5,
      "tensileStrengthClass": "S355",
      "carbonEmissionIntensityPerTon": 1.42
    },
    "enrichment": {
      "tagline": "Low-carbon structural steel",
      "images": [
        {
          "url": "https://cdn.example.com/steel-beam.jpg",
          "caption": "S355 beam"
        }
      ]
    }
  }'

Responses

201

Passport created (or draft saved). passport is the public redacted JSON-LD document; warnings is always present (empty array when none, and always empty for drafts).

application/json PassportIngestCreated
Example 201 response
{
  "success": true,
  "message": "Digital Product Passport successfully validated and ingested",
  "passport": {
    "@context": [
      "https://opendpp-node.eu/contexts/dpp/v1",
      {
        "DigitalProductPassport": "https://opendpp-node.eu/ns/dpp#DigitalProductPassport",
        "economicOperator": "https://opendpp-node.eu/ns/dpp#economicOperator",
        "manufacturingFacility": "https://opendpp-node.eu/ns/dpp#manufacturingFacility",
        "metadata": "https://opendpp-node.eu/ns/dpp#metadata",
        "digitalSeal": "https://opendpp-node.eu/ns/dpp#digitalSeal",
        "signingPublicKey": "https://opendpp-node.eu/ns/dpp#signingPublicKey",
        "status": "https://opendpp-node.eu/ns/dpp#status",
        "archivedAt": "https://opendpp-node.eu/ns/dpp#archivedAt",
        "retentionUntil": "https://opendpp-node.eu/ns/dpp#retentionUntil",
        "category": "https://opendpp-node.eu/contexts/dpp/v1#category",
        "originCountry": "https://opendpp-node.eu/contexts/dpp/v1#originCountry",
        "materialComposition": "https://opendpp-node.eu/contexts/dpp/v1#materialComposition",
        "facilityDetails": "https://opendpp-node.eu/contexts/dpp/v1#facilityDetails",
        "regulatoryCompliance": "https://opendpp-node.eu/contexts/dpp/v1#regulatoryCompliance",
        "scrapMetalContentRatio": "https://opendpp-node.eu/contexts/dpp/v1#scrapMetalContentRatio",
        "tensileStrengthClass": "https://opendpp-node.eu/contexts/dpp/v1#tensileStrengthClass",
        "carbonEmissionIntensityPerTon": "https://opendpp-node.eu/contexts/dpp/v1#carbonEmissionIntensityPerTon",
        "gtin": "https://opendpp-node.eu/contexts/dpp/v1#gtin"
      }
    ],
    "@type": "DigitalProductPassport",
    "@id": "https://opendpp-node.eu/01/09501101530003",
    "id": "9b2fa884-1c7d-4a0e-9d3b-5f6a7c8e9012",
    "productId": "09501101530003",
    "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003",
    "digitalSeal": null,
    "signingPublicKey": null,
    "status": "ACTIVE",
    "archivedAt": null,
    "retentionUntil": null,
    "proof": null,
    "createdAt": "2026-06-12T09:41:00.000Z",
    "updatedAt": "2026-06-12T09:41:00.000Z",
    "economicOperator": {
      "@type": "EconomicOperator",
      "id": "5c1e0f3a-7b2d-4c8e-a91f-2d3e4f5a6b7c",
      "name": "Demo Manufacturing GmbH",
      "regId": "EU-DEFAULT-001",
      "role": "MANUFACTURER"
    },
    "manufacturingFacility": null,
    "metadata": {
      "category": "iron-steel",
      "originCountry": "DE",
      "materialComposition": [
        {
          "material": "Recycled steel",
          "percentage": 62.5
        },
        {
          "material": "Virgin steel",
          "percentage": 37.5
        }
      ],
      "facilityDetails": "[REDACTED - Privileged Access Required]",
      "regulatoryCompliance": {
        "ceMarking": true,
        "certificates": [
          {
            "name": "EN 10025-2 Mill Certificate",
            "referenceNumber": "MC-2026-00417",
            "issuer": "TUV Rheinland"
          }
        ]
      },
      "scrapMetalContentRatio": 62.5,
      "tensileStrengthClass": "S355",
      "carbonEmissionIntensityPerTon": 1.42,
      "gtin": "09501101530003"
    },
    "category": "iron-steel",
    "originCountry": "DE",
    "materialComposition": [
      {
        "material": "Recycled steel",
        "percentage": 62.5
      },
      {
        "material": "Virgin steel",
        "percentage": 37.5
      }
    ],
    "facilityDetails": "[REDACTED - Privileged Access Required]",
    "regulatoryCompliance": {
      "ceMarking": true,
      "certificates": [
        {
          "name": "EN 10025-2 Mill Certificate",
          "referenceNumber": "MC-2026-00417",
          "issuer": "TUV Rheinland"
        }
      ]
    },
    "scrapMetalContentRatio": 62.5,
    "tensileStrengthClass": "S355",
    "carbonEmissionIntensityPerTon": 1.42,
    "gtin": "09501101530003"
  },
  "warnings": []
}
400

Three variants share this status: (1) Validation Failed — the metadata failed ESPR category / cross-field / traceability validation; carries per-field errors[] (and warnings[] only when at least one warning exists). (2) Bad Request triple — whitespace-only productId, a malformed GTIN-14 productId (14 digits failing the GS1 mod-10 check), no economic operator bound to the workspace, or unknown facilityId; {success, error, message} with no errors/warnings. (3) Pre-handler rejections — request-body schema violations (e.g. missing productId) and malformed JSON return only {error, message}.

application/json object | Error
success boolean required
always false
error string required
always Validation Failed
message string required
errors array<ValidationErrorItem> required

Blocking findings. Items produced by the category-validity check (metadata.category missing/unknown) carry no friendlyMessage.

warnings array<ValidationErrorItem>

Omitted entirely when there are no warnings.

Example 400 response
{
  "success": false,
  "error": "Validation Failed",
  "message": "Dynamic metadata payload failed ESPR category schema validation",
  "errors": [
    {
      "path": "tensileStrengthClass",
      "message": "tensileStrengthClass must be a non-empty string",
      "friendlyMessage": "Tensile Strength Class: a non-empty string"
    }
  ]
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
409

A passport already exists for this (productId, operatorId) pair.

application/json Error
Example 409 response
{
  "success": false,
  "error": "Conflict",
  "message": "A Product Passport already exists for productId: 09501101530003"
}
413

Body exceeds the 1 MiB (1,048,576-byte) body limit.

application/json object
statusCode integer required
always 413
code string
always FST_ERR_CTP_BODY_TOO_LARGE
error string required
always Payload Too Large
message string required
Example 413 response
{
  "statusCode": 413,
  "code": "FST_ERR_CTP_BODY_TOO_LARGE",
  "error": "Payload Too Large",
  "message": "Request body is too large"
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
POST /api/v1/passports/validate-only API key

Dry-run ESPR validation of passport metadata (nothing is stored)

Runs the full ESPR category schema validation on a metadata payload without persisting anything — intended for pre-flight checks in integration pipelines.

Permission: passport:create (Bearer API key or session JWT + CSRF for cookie sessions). Despite being read-only in effect, it is gated as a write permission, so subscription gating (402) applies.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After. Body limit: 262,144 bytes (256 KiB)413 beyond that.

Behavioral caveats: - The EPCIS traceability lineage audit is NOT run here (it only runs at real ingestion), so a payload can pass this dry-run and still fail POST /api/v1/passports on traceability errors. - operatorId is accepted by the body schema but ignored by the handler. - The 200 body always carries errors: []; warnings is omitted entirely when there are none (it is not an empty array). The same omission applies to warnings on the 400 Validation Failed body. - friendlyMessage localization via ?lang= / Accept-Language (28 languages, default en); category-validity errors (metadata.category missing or unknown) carry no friendlyMessage. - Structural rejections of the request body (e.g. missing productId, non-object metadata) and malformed JSON return just {"error": "Bad Request", "message": …}; the structurally bad inputs that reach the handler are a whitespace-only productId and a malformed GTIN-14 productId (14 digits failing the GS1 mod-10 check), each answered with the fuller Bad Request body shown below.

Parameters

NameInTypeDescription
lang query string optional

Locale for localized friendlyMessage validation texts (en, bg, hr, cs, da, nl, et, fi, fr, de, el, hu, ga, it, lv, lt, mt, pl, pt, ro, sk, sl, es, sv, no, is, uk, tr). Unknown values are ignored; falls back to Accept-Language, then en.

Request body required

Example request
{
  "productId": "09501101530003",
  "metadata": {
    "category": "iron-steel",
    "originCountry": "DE",
    "materialComposition": [
      {
        "material": "Recycled steel",
        "percentage": 62.5
      },
      {
        "material": "Virgin steel",
        "percentage": 37.5
      }
    ],
    "facilityDetails": [
      {
        "facilityName": "Musterstahl Works Duisburg",
        "location": "Duisburg, DE",
        "activity": "Hot rolling"
      }
    ],
    "regulatoryCompliance": {
      "ceMarking": true,
      "certificates": [
        {
          "name": "EN 10025-2 Mill Certificate",
          "referenceNumber": "MC-2026-00417",
          "issuer": "TUV Rheinland"
        }
      ]
    },
    "scrapMetalContentRatio": 62.5,
    "tensileStrengthClass": "S355",
    "carbonEmissionIntensityPerTon": 1.42
  }
}

Example

curl -X POST "https://opendpp-node.eu/api/v1/passports/validate-only" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "productId": "09501101530003",
    "metadata": {
      "category": "iron-steel",
      "originCountry": "DE",
      "materialComposition": [
        {
          "material": "Recycled steel",
          "percentage": 62.5
        },
        {
          "material": "Virgin steel",
          "percentage": 37.5
        }
      ],
      "facilityDetails": [
        {
          "facilityName": "Musterstahl Works Duisburg",
          "location": "Duisburg, DE",
          "activity": "Hot rolling"
        }
      ],
      "regulatoryCompliance": {
        "ceMarking": true,
        "certificates": [
          {
            "name": "EN 10025-2 Mill Certificate",
            "referenceNumber": "MC-2026-00417",
            "issuer": "TUV Rheinland"
          }
        ]
      },
      "scrapMetalContentRatio": 62.5,
      "tensileStrengthClass": "S355",
      "carbonEmissionIntensityPerTon": 1.42
    }
  }'

Responses

200

Metadata is valid for its ESPR category. errors is always an empty array; warnings (non-blocking findings) is present only when there is at least one warning.

Example 200 response
{
  "success": true,
  "message": "Passport metadata payload is valid against the ESPR category data schema",
  "category": "iron-steel",
  "errors": []
}
400

Validation failed, or the request body was structurally invalid. Three variants share this status: (1) ESPR validation failure (error: "Validation Failed", with errors[] and — only when at least one exists — warnings[]); (2) whitespace-only productId OR a malformed GTIN-14 productId (14 digits failing the GS1 mod-10 check) (error: "Bad Request", category: "unknown", errors: []); (3) request-body schema rejections and malformed JSON, returned as just {error, message}.

application/json PassportValidateOnlyError
Example 400 response
{
  "success": false,
  "error": "Validation Failed",
  "message": "Dynamic metadata payload failed ESPR category schema validation",
  "category": "iron-steel",
  "errors": [
    {
      "path": "tensileStrengthClass",
      "message": "tensileStrengthClass must be a non-empty string",
      "friendlyMessage": "Tensile Strength Class: a non-empty string"
    }
  ]
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
413

Body exceeds the 262,144-byte (256 KiB) route body limit.

application/json object
statusCode integer required
always 413
code string
always FST_ERR_CTP_BODY_TOO_LARGE
error string required
always Payload Too Large
message string required
Example 413 response
{
  "statusCode": 413,
  "code": "FST_ERR_CTP_BODY_TOO_LARGE",
  "error": "Payload Too Large",
  "message": "Request body is too large"
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
POST /api/v1/passports/validate-only-public API key

Permission-free dry-run ESPR metadata validation (strictly rate-limited)

Identical validation semantics to POST /api/v1/passports/validate-only, but requires no specific permission — any valid API key or Console session is accepted, so every plan including the free tier can call it. Nothing is persisted.

Authentication is required. Until contract 1.12.0 this endpoint was reachable anonymously; it is not any more, because it runs the full validation engine and was usable as free unauthenticated compute. An anonymous call now returns 401. The path keeps its -public segment for continuity — "public" here means *no permission and no tenant scope*, not *unauthenticated*.

Rate limit: 10 requests/min per IP — a strict per-route limit that replaces the global ceiling for this endpoint (emits x-ratelimit-limit / x-ratelimit-remaining / x-ratelimit-reset headers and retry-after on 429). Body limit: 65,536 bytes (64 KiB)413 beyond that. Both caps remain as defence in depth against authenticated abuse. The credential is checked before the body is parsed, so an anonymous oversized body is rejected as 401, not 413.

Behavioral caveats: - No tenant context: the EPCIS traceability lineage audit is not run, and operatorId is accepted but ignored. - The 200 body always carries errors: []; warnings is omitted entirely when there are none (same omission on the 400 Validation Failed body). - Error/warning friendlyMessage localization via ?lang= / Accept-Language (28 languages, default en); category-validity errors carry no friendlyMessage. - Structural rejections of the request body (e.g. missing productId) and malformed JSON return just {"error": "Bad Request", "message": …}; a whitespace-only productId or a malformed GTIN-14 productId (14 digits failing the GS1 mod-10 check) gets the fuller Bad Request body shown below.

Parameters

NameInTypeDescription
lang query string optional

Locale for localized friendlyMessage validation texts (en, bg, hr, cs, da, nl, et, fi, fr, de, el, hu, ga, it, lv, lt, mt, pl, pt, ro, sk, sl, es, sv, no, is, uk, tr). Unknown values are ignored; falls back to Accept-Language, then en.

Request body required

Example request
{
  "productId": "09501101530003",
  "metadata": {
    "category": "iron-steel",
    "originCountry": "DE",
    "materialComposition": [
      {
        "material": "Recycled steel",
        "percentage": 62.5
      },
      {
        "material": "Virgin steel",
        "percentage": 37.5
      }
    ],
    "facilityDetails": [
      {
        "facilityName": "Musterstahl Works Duisburg",
        "location": "Duisburg, DE",
        "activity": "Hot rolling"
      }
    ],
    "regulatoryCompliance": {
      "ceMarking": true,
      "certificates": [
        {
          "name": "EN 10025-2 Mill Certificate",
          "referenceNumber": "MC-2026-00417",
          "issuer": "TUV Rheinland"
        }
      ]
    },
    "scrapMetalContentRatio": 62.5,
    "tensileStrengthClass": "S355",
    "carbonEmissionIntensityPerTon": 1.42
  }
}

Example

curl -X POST "https://opendpp-node.eu/api/v1/passports/validate-only-public" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "productId": "09501101530003",
    "metadata": {
      "category": "iron-steel",
      "originCountry": "DE",
      "materialComposition": [
        {
          "material": "Recycled steel",
          "percentage": 62.5
        },
        {
          "material": "Virgin steel",
          "percentage": 37.5
        }
      ],
      "facilityDetails": [
        {
          "facilityName": "Musterstahl Works Duisburg",
          "location": "Duisburg, DE",
          "activity": "Hot rolling"
        }
      ],
      "regulatoryCompliance": {
        "ceMarking": true,
        "certificates": [
          {
            "name": "EN 10025-2 Mill Certificate",
            "referenceNumber": "MC-2026-00417",
            "issuer": "TUV Rheinland"
          }
        ]
      },
      "scrapMetalContentRatio": 62.5,
      "tensileStrengthClass": "S355",
      "carbonEmissionIntensityPerTon": 1.42
    }
  }'

Responses

200

Metadata is valid for its ESPR category. errors is always an empty array; warnings is present only when there is at least one non-blocking warning.

Example 200 response
{
  "success": true,
  "message": "Passport metadata payload is valid against the ESPR category data schema",
  "category": "iron-steel",
  "errors": []
}
400

Validation failed or the body was structurally invalid — same three variants as the authenticated validate-only endpoint.

application/json PassportValidateOnlyError
Example 400 response
{
  "success": false,
  "error": "Validation Failed",
  "message": "Dynamic metadata payload failed ESPR category schema validation",
  "category": "textiles",
  "errors": [
    {
      "path": "fiberComposition",
      "message": "fiberComposition must be an array for textiles",
      "friendlyMessage": "Fiber Composition: an array for textiles"
    }
  ]
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
413

Body exceeds the 65,536-byte (64 KiB) route body limit. Only reachable once authenticated — an anonymous oversized body is rejected as 401 before the body is read.

application/json object
statusCode integer required
always 413
code string
always FST_ERR_CTP_BODY_TOO_LARGE
error string required
always Payload Too Large
message string required
Example 413 response
{
  "statusCode": 413,
  "code": "FST_ERR_CTP_BODY_TOO_LARGE",
  "error": "Payload Too Large",
  "message": "Request body is too large"
}
429

Rate limit exceeded. This endpoint is capped at 10 requests/min per IP (a per-route limit that replaces the global ceiling). Inspect the x-ratelimit-* headers and retry after the indicated window.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Default rate-limit error body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "code": "FST_ERR_RATE_LIMIT",
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
POST /api/v1/passports/bulk API key

Bulk-ingest up to 200 passports with per-row error reporting

Ingests up to 200 passports in one request with partial-success semantics: each row is validated and inserted independently; failed rows are skipped and reported as human-readable strings in errors[]. Returns 201 as long as at least one row was inserted (even with row errors); returns 400 Bulk Ingestion Failed only when every row failed.

Permission: passport:create (Bearer API key or session JWT + CSRF for cookie sessions; subscription gating → 402).

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After. Body limit: 1 MiB (1,048,576 bytes)413 beyond that; in practice the maxItems: 200 envelope cap is the effective bound for typical rows. Envelope violations — empty array, more than 200 items, missing passports — are rejected before any row is processed, with the full default validation error body ({statusCode, code, error, message}).

Per-row behavior (differences from POST /api/v1/passports): - Rows are validated with the ESPR category engine only — the EPCIS traceability audit is NOT run for bulk rows. - No draft support: every inserted row is created with status: "ACTIVE". No enrichment support. - A valid GTIN-14/GRAI productId is not auto-copied into metadata.gtin/metadata.grai (unlike single ingestion). - Operator resolution per row: explicit operatorId must be bound to your workspace; otherwise the workspace's first bound operator is used; operator-scoped API keys force their operator. Lookups are cached within the request. - Duplicate (productId, operatorId) rows, unknown facilities, and per-row DB failures become errors[] strings (prefixed [SKU: <productId>]), never a request-level failure. - Each successfully inserted row transactionally enqueues a passport.ingested webhook event (public redacted JSON-LD payload). - Row validation messages use the localized friendlyMessage where the engine provides one (?lang= / Accept-Language); category-validity errors fall back to the technical path: message form.

Note the 400 Bulk Ingestion Failed body has no message field, and errors is an array of strings (not objects).

Parameters

NameInTypeDescription
Idempotency-Key header string optional

Optional client idempotency key (≤255 characters, no control characters). Retrying a real (non-dryRun) import with the same Idempotency-Key replays the ORIGINAL result — same status and body, plus an idempotent-replayed: true response header — instead of re-inserting the batch. A dryRun preview is never captured or replayed. Scoped per (workspace, endpoint, key) and consulted within a 24-hour window; a malformed key returns 400. Best-effort: the result is recorded after the batch commits, so in the rare window between commit and recording (or across an instance restart) a retry re-processes the batch normally (the (productId, operator) unique constraint still prevents duplicate rows).

max length 255
lang query string optional

Locale for the localized validation text inside per-row errors[] strings (en, bg, hr, cs, da, nl, et, fi, fr, de, el, hu, ga, it, lv, lt, mt, pl, pt, ro, sk, sl, es, sv, no, is, uk, tr). Unknown values are ignored; falls back to Accept-Language, then en.

Request body required

application/json PassportBulkRequest
Example request
{
  "passports": [
    {
      "productId": "09501101530003",
      "metadata": {
        "category": "iron-steel",
        "originCountry": "DE",
        "materialComposition": [
          {
            "material": "Recycled steel",
            "percentage": 62.5
          },
          {
            "material": "Virgin steel",
            "percentage": 37.5
          }
        ],
        "facilityDetails": [
          {
            "facilityName": "Musterstahl Works Duisburg",
            "location": "Duisburg, DE",
            "activity": "Hot rolling"
          }
        ],
        "regulatoryCompliance": {
          "ceMarking": true,
          "certificates": [
            {
              "name": "EN 10025-2 Mill Certificate",
              "referenceNumber": "MC-2026-00417",
              "issuer": "TUV Rheinland"
            }
          ]
        },
        "scrapMetalContentRatio": 62.5,
        "tensileStrengthClass": "S355",
        "carbonEmissionIntensityPerTon": 1.42
      }
    },
    {
      "productId": "09501101530010",
      "metadata": {
        "category": "iron-steel",
        "originCountry": "DE",
        "materialComposition": [
          {
            "material": "Recycled steel",
            "percentage": 100
          }
        ],
        "facilityDetails": [
          {
            "facilityName": "Musterstahl Works Duisburg",
            "location": "Duisburg, DE",
            "activity": "Cold rolling"
          }
        ],
        "regulatoryCompliance": {
          "ceMarking": true,
          "certificates": [
            {
              "name": "EN 10025-2 Mill Certificate",
              "referenceNumber": "MC-2026-00418",
              "issuer": "TUV Rheinland"
            }
          ]
        },
        "scrapMetalContentRatio": 100,
        "tensileStrengthClass": "S275",
        "carbonEmissionIntensityPerTon": 0.61
      }
    }
  ]
}

Example

curl -X POST "https://opendpp-node.eu/api/v1/passports/bulk" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "passports": [
      {
        "productId": "09501101530003",
        "metadata": {
          "category": "iron-steel",
          "originCountry": "DE",
          "materialComposition": [
            {
              "material": "Recycled steel",
              "percentage": 62.5
            },
            {
              "material": "Virgin steel",
              "percentage": 37.5
            }
          ],
          "facilityDetails": [
            {
              "facilityName": "Musterstahl Works Duisburg",
              "location": "Duisburg, DE",
              "activity": "Hot rolling"
            }
          ],
          "regulatoryCompliance": {
            "ceMarking": true,
            "certificates": [
              {
                "name": "EN 10025-2 Mill Certificate",
                "referenceNumber": "MC-2026-00417",
                "issuer": "TUV Rheinland"
              }
            ]
          },
          "scrapMetalContentRatio": 62.5,
          "tensileStrengthClass": "S355",
          "carbonEmissionIntensityPerTon": 1.42
        }
      },
      {
        "productId": "09501101530010",
        "metadata": {
          "category": "iron-steel",
          "originCountry": "DE",
          "materialComposition": [
            {
              "material": "Recycled steel",
              "percentage": 100
            }
          ],
          "facilityDetails": [
            {
              "facilityName": "Musterstahl Works Duisburg",
              "location": "Duisburg, DE",
              "activity": "Cold rolling"
            }
          ],
          "regulatoryCompliance": {
            "ceMarking": true,
            "certificates": [
              {
                "name": "EN 10025-2 Mill Certificate",
                "referenceNumber": "MC-2026-00418",
                "issuer": "TUV Rheinland"
              }
            ]
          },
          "scrapMetalContentRatio": 100,
          "tensileStrengthClass": "S275",
          "carbonEmissionIntensityPerTon": 0.61
        }
      }
    ]
  }'

Responses

201

Bulk run finished with at least one inserted row. errors is present only when at least one row failed.

application/json PassportBulkResult
Example 201 response
{
  "success": true,
  "message": "Bulk CSV ingestion finished. Registered 1 passports, skipped 1 rows with errors.",
  "insertedCount": 1,
  "results": [
    {
      "productId": "09501101530003",
      "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003"
    }
  ],
  "errors": [
    "[SKU: 09501101530010] Duplicate productId already exists in database for this operator"
  ]
}
400

Either every row failed (Bulk Ingestion Failed, with string errors[] and no message field), or the request never reached row processing: envelope violations of the passports array bounds and malformed JSON both return the full default error body ({statusCode, code?, error, message} — nothing is stripped on this operation).

application/json PassportBulkFailure | object
statusCode integer required
always 400
code string

Machine-readable error code; present for envelope (schema) violations (FST_ERR_VALIDATION), may be absent for malformed JSON.

error string required
always Bad Request
message string required
Example 400 response
{
  "success": false,
  "error": "Bulk Ingestion Failed",
  "errors": [
    "[SKU: 09501101530003] Validation failed: category: Category must be one of: textiles, batteries, electronics, cosmetics, toys, iron-steel, aluminium, chemicals, construction",
    "Missing or invalid productId in spreadsheet row"
  ]
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
413

Body exceeds the 1 MiB (1,048,576-byte) body limit.

application/json object
statusCode integer required
always 413
code string
always FST_ERR_CTP_BODY_TOO_LARGE
error string required
always Payload Too Large
message string required
Example 413 response
{
  "statusCode": 413,
  "code": "FST_ERR_CTP_BODY_TOO_LARGE",
  "error": "Payload Too Large",
  "message": "Request body is too large"
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
POST /api/v1/passports/aas/ingest API key

Ingest a passport from an AAS JSON Environment (seal-verified)

Ingests (creates or updates) a Digital Product Passport from an Industry-4.0 Asset Administration Shell (AAS) JSON Environment — the same format produced by OpenDPP's own AAS export.

Permission: passport:create (Bearer API key or session JWT + CSRF for cookie sessions; subscription gating → 402).

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After. Body limit: 262,144 bytes (256 KiB)413.

Parsing. The environment must contain a submodels array including a submodel with idShort: "ComplianceMetadata", whose submodelElements are parsed back into the metadata object; missing it fails 400 (Ingestion Failed). productId is resolved from metadata.gtin || metadata.grai || metadata.productId || the first shell's assetInformation.specificAssetIds entry named productId — unresolvable → 400 Bad Request. The parsed metadata then passes the full ESPR category validation plus the EPCIS traceability audit (400 Validation Failed with errors[]).

eIDAS seal verification. If the environment embeds an eidasVerificationSeal submodel (digitalSealHash / cryptographicSignature / pemPublicKey elements), the seal is verified against your tenant's server-held eIDAS public key — never the key embedded in the request (self-signing is rejected by design). An embedded seal that fails verification → 400 Signature Verification Failed; this includes the case where your workspace holds no matching key. isSealed/signatureVerified in the 201 echo the outcome (both false for unsealed documents).

Upsert semantics. If a passport already exists for the resolved (productId, operator) pair: a sealed existing passport refuses re-ingestion (403 — re-seal explicitly after changes); an unsealed one has its metadata, Merkle tree and seal fields replaced, still answering 201. Operator resolution: operator-scoped API keys use their own operator and 403 when that operator is not bound to your workspace; otherwise the workspace's first bound operator is used; none bound → 400.

Caveats: - NO webhook is emitted — unlike POST /api/v1/passports and /bulk, AAS ingestion never enqueues passport.ingested (or any other event). - The catch-all error path returns 400 Ingestion Failed with the underlying parse/processing message — even for internal failures (this handler does not emit its own 500). - Validation friendlyMessage localization via ?lang= / Accept-Language; category-validity errors carry no friendlyMessage.

Parameters

NameInTypeDescription
lang query string optional

Locale for localized friendlyMessage validation texts (en, bg, hr, cs, da, nl, et, fi, fr, de, el, hu, ga, it, lv, lt, mt, pl, pt, ro, sk, sl, es, sv, no, is, uk, tr). Unknown values are ignored; falls back to Accept-Language, then en.

Request body required

application/json AasEnvironmentInput
Example request
{
  "assetAdministrationShells": [
    {
      "id": "urn:opendpp:aas:9b2fa884-1c7d-4a0e-9d3b-5f6a7c8e9012",
      "idShort": "AAS_09501101530003",
      "assetInformation": {
        "assetKind": "Instance",
        "specificAssetIds": [
          {
            "name": "productId",
            "value": "09501101530003"
          }
        ]
      },
      "submodels": []
    }
  ],
  "submodels": [
    {
      "id": "urn:opendpp:submodel:compliance:09501101530003",
      "idShort": "ComplianceMetadata",
      "submodelElements": [
        {
          "idShort": "category",
          "modelType": "Property",
          "valueType": "xs:string",
          "value": "iron-steel"
        },
        {
          "idShort": "originCountry",
          "modelType": "Property",
          "valueType": "xs:string",
          "value": "DE"
        },
        {
          "idShort": "gtin",
          "modelType": "Property",
          "valueType": "xs:string",
          "value": "09501101530003"
        }
      ]
    }
  ]
}

Example

curl -X POST "https://opendpp-node.eu/api/v1/passports/aas/ingest" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "assetAdministrationShells": [
      {
        "id": "urn:opendpp:aas:9b2fa884-1c7d-4a0e-9d3b-5f6a7c8e9012",
        "idShort": "AAS_09501101530003",
        "assetInformation": {
          "assetKind": "Instance",
          "specificAssetIds": [
            {
              "name": "productId",
              "value": "09501101530003"
            }
          ]
        },
        "submodels": []
      }
    ],
    "submodels": [
      {
        "id": "urn:opendpp:submodel:compliance:09501101530003",
        "idShort": "ComplianceMetadata",
        "submodelElements": [
          {
            "idShort": "category",
            "modelType": "Property",
            "valueType": "xs:string",
            "value": "iron-steel"
          },
          {
            "idShort": "originCountry",
            "modelType": "Property",
            "valueType": "xs:string",
            "value": "DE"
          },
          {
            "idShort": "gtin",
            "modelType": "Property",
            "valueType": "xs:string",
            "value": "09501101530003"
          }
        ]
      }
    ]
  }'

Responses

201

Passport created or (if it existed unsealed) updated from the AAS environment. Returned for both create and update. No webhook event is emitted.

application/json AasIngestCreated
Example 201 response
{
  "success": true,
  "message": "Digital Product Passport successfully ingested from AAS",
  "passportId": "9b2fa884-1c7d-4a0e-9d3b-5f6a7c8e9012",
  "productId": "09501101530003",
  "isSealed": false,
  "signatureVerified": false
}
400

Four variants share this status: Bad Request (non-object body, unresolvable productId, no bound operator), Signature Verification Failed (embedded seal invalid/altered or no matching tenant key), Validation Failed (ESPR/traceability — carries errors[], and warnings[] when present), and Ingestion Failed (catch-all parse/processing error with the underlying message).

application/json Error | object
success boolean required
always false
error string required
always Validation Failed
message string required
errors array<ValidationErrorItem> required
warnings array<ValidationErrorItem>

Omitted entirely when there are no warnings.

Example 400 response
{
  "success": false,
  "error": "Bad Request",
  "message": "Could not resolve productId (GTIN/GRAI) from AAS metadata."
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
413

Body exceeds the 262,144-byte (256 KiB) route body limit.

application/json object
statusCode integer required
always 413
code string
always FST_ERR_CTP_BODY_TOO_LARGE
error string required
always Payload Too Large
message string required
Example 413 response
{
  "statusCode": 413,
  "code": "FST_ERR_CTP_BODY_TOO_LARGE",
  "error": "Payload Too Large",
  "message": "Request body is too large"
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
GET /api/v1/passports/vc-readiness API key

Catalog-wide UNTP Verifiable-Credential readiness report

A read-only, tenant-scoped report of which SKUs in your catalog can / can't emit a UNTP Verifiable Credential, and why — so you can fix a whole catalog before relying on VCs, instead of probing passports one at a time. It aggregates the same per-passport vcReady signal (#247) returned on every ingest response: a passport is VC-ready only when it links a manufacturing Facility with a country of production.

Permission: passport:read (read-only — no subscription/402 gate). Operator scoping + the non-archived filter match the passports list.

Shape: each results[] row is { id, productId, vcReady, blockers[] }blockers[] reuses the SAME actionable reason the single-passport signal exposes (empty when ready). The top-level ready / notReady rollup is catalog-wide (counts every non-archived passport), while results is paginatedpage (default 1) + limit (default 100, max 200), with total / totalPages. NOTE: because the rollup is catalog-wide but results is one page, ready is generally NOT the count of vcReady:true rows on the current page — page through all totalPages to enumerate every SKU.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
page query integer optional

1-based page number (digits only).

default 1 · min 1
limit query integer optional

Page size.

default 10 · min 1 · max 100

Example

curl -X GET "https://opendpp-node.eu/api/v1/passports/vc-readiness" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

The paginated VC-readiness report with a catalog-wide ready/notReady rollup.

application/json object
success boolean required
page integer required
limit integer required
total integer required

Total non-archived passports in the catalog (the rollup base, not the page size).

totalPages integer required
ready integer required

Catalog-wide count of VC-ready SKUs (a facility with a country of production).

notReady integer required

Catalog-wide count of not-yet-ready SKUs (= total - ready).

results array<object> required
id string required

The passport UUID.

productId string required
vcReady boolean required
blockers array<string> required

Actionable reasons this SKU can't emit a VC (same text as the single-passport signal); empty when vcReady is true.

Example 200 response
{
  "success": true,
  "page": 1,
  "limit": 100,
  "total": 3,
  "totalPages": 1,
  "ready": 2,
  "notReady": 1,
  "results": [
    {
      "id": "9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d",
      "productId": "09501101530003",
      "vcReady": true,
      "blockers": []
    },
    {
      "id": "1c2d3e4f-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
      "productId": "SKU-NO-FACILITY",
      "vcReady": false,
      "blockers": [
        "Link a manufacturing facility with a country of production to enable Verifiable Credential (UNTP) representations. The passport still publishes and resolves as AAS / JSON-LD / HTML."
      ]
    }
  ]
}
GET /api/v1/passports/{id} API key

Fetch a single passport (content-negotiated JSON-LD / AAS / HTML)

Owner-side alias of the public resolver. Accepts either the passport UUID or its caller-supplied productId (GTIN-14 / GRAI / SKU), scoped to operators bound to your workspace. After the scoped lookup the request is re-dispatched internally to GET /passport/{uuid}, forwarding all request headers, and the inner response (status, content type, body) is returned as-is.

Permission: passport:read (read-only — no subscription/402 gate).

Content negotiation (substring match on Accept): application/aas+json → role-filtered AAS environment; application/vc+jwt → enveloping UNTP Verifiable Credential; application/vc+ld+json → the same credential with an embedded ecdsa-jcs-2019 W3C Data Integrity proof; application/dc+sd-jwt (legacy vc+sd-jwt accepted) → SD-JWT-VC selective disclosure (these three return 406 Not Acceptable when the passport has no manufacturing facility with a country of production); text/html → SSR passport page; anything else (including application/json, */*, or no header) → JSON-LD with Content-Type: application/ld+json (the default). The VC and SD-JWT representations are forwarded verbatim from GET /passport/{id} — see that operation for the full credential semantics.

**Access-tier caveat (privilege is resolved from the *forwarded* headers, not the already-authenticated context): only database API keys (Authorization: Bearer op_dpp_token_…) of the owning or operator-bound tenant are recognized as owner by the inner resolver. Those callers get the owner-tier document: facilityDetails and battery restricted keys unmasked, manufacturingFacility includes streetAddress/city/postalCode, and DRAFT passports are visible. Callers authenticated with a JWT session (login cookie or bearer JWT) receive the public-redacted** tier instead, and DRAFT passports answer 404 with the forwarded public body (no success field).

Every successful resolution records an anonymized-IP access audit entry.

Rate limits: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys, under a per-IP ceiling raised for Authorization-bearing requests; x-ratelimit-* headers and a Retry-After on 429. Plus the forwarded public resolver's own limiter (30 req/min/IP, no headers) — both 429 shapes are possible (see 429).

Parameters

NameInTypeDescription
id path string required

Passport UUID or caller-supplied productId (GTIN-14 / GRAI / SKU). UUID is tried first, then productId.

min length 1

Example

curl -X GET "https://opendpp-node.eu/api/v1/passports/string" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

The resolved passport. Representation depends on the Accept header; redaction tier depends on the forwarded credential (see description). The @id/digitalLinkUri is the stored GS1 Digital Link URI {base}/01/{productId}/21/{passportUuid} (AI 8003 instead of 01 for GRAI ids; AI-21 carries the passport UUID at SKU level).

application/ld+json PublicPassportJsonLd
Example 200 response
{
  "@context": [
    "https://opendpp-node.eu/contexts/dpp/v1",
    {
      "DigitalProductPassport": "https://opendpp-node.eu/ns/dpp#DigitalProductPassport",
      "economicOperator": "https://opendpp-node.eu/ns/dpp#economicOperator",
      "manufacturingFacility": "https://opendpp-node.eu/ns/dpp#manufacturingFacility",
      "metadata": "https://opendpp-node.eu/ns/dpp#metadata",
      "digitalSeal": "https://opendpp-node.eu/ns/dpp#digitalSeal",
      "signingPublicKey": "https://opendpp-node.eu/ns/dpp#signingPublicKey",
      "status": "https://opendpp-node.eu/ns/dpp#status",
      "archivedAt": "https://opendpp-node.eu/ns/dpp#archivedAt",
      "retentionUntil": "https://opendpp-node.eu/ns/dpp#retentionUntil",
      "category": "https://opendpp-node.eu/contexts/dpp/v1#category",
      "originCountry": "https://opendpp-node.eu/contexts/dpp/v1#originCountry",
      "size": "https://opendpp-node.eu/contexts/dpp/v1#size",
      "facilityDetails": "https://opendpp-node.eu/contexts/dpp/v1#facilityDetails"
    }
  ],
  "@type": "DigitalProductPassport",
  "@id": "https://opendpp-node.eu/01/09501101530003",
  "id": "9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d",
  "productId": "09501101530003",
  "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003",
  "digitalSeal": null,
  "signingPublicKey": null,
  "status": "ACTIVE",
  "archivedAt": null,
  "retentionUntil": null,
  "proof": null,
  "createdAt": "2026-06-12T09:41:00.000Z",
  "updatedAt": "2026-06-12T09:41:00.000Z",
  "economicOperator": {
    "@type": "EconomicOperator",
    "id": "f2d6a9c1-4b3e-4d2a-8c1f-0a9b8c7d6e5f",
    "name": "Aurora Textiles GmbH",
    "regId": "EU-DEFAULT-001",
    "role": "Manufacturer"
  },
  "manufacturingFacility": {
    "@type": "Facility",
    "id": "5b21cf12-7d24-4a8e-9a3c-2f1f4f4f9d10",
    "gln": "0950110153000",
    "name": "Aurora Spinning Mill",
    "activity": "Spinning",
    "country": "PT",
    "streetAddress": "Rua das Flores 12",
    "city": "Porto",
    "postalCode": "4050-262"
  },
  "metadata": {
    "category": "textiles",
    "originCountry": "PT",
    "size": "M",
    "facilityDetails": [
      {
        "facilityName": "Aurora Spinning Mill",
        "location": "Porto, PT",
        "activity": "Spinning"
      }
    ]
  },
  "category": "textiles",
  "originCountry": "PT",
  "size": "M",
  "facilityDetails": [
    {
      "facilityName": "Aurora Spinning Mill",
      "location": "Porto, PT",
      "activity": "Spinning"
    }
  ]
}
application/aas+json PassportAasEnvironment
Example 200 response
{
  "assetAdministrationShells": [
    {
      "id": "urn:opendpp:aas:9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d",
      "idShort": "AAS_09501101530003",
      "assetInformation": {
        "assetKind": "Instance",
        "globalAssetId": "urn:opendpp:asset:f2d6a9c1-4b3e-4d2a-8c1f-0a9b8c7d6e5f:09501101530003",
        "specificAssetIds": [
          {
            "name": "productId",
            "value": "09501101530003",
            "externalSubjectId": {
              "type": "ExternalReference",
              "keys": [
                {
                  "type": "GlobalReference",
                  "value": "urn:gs1:gln:0950110153000"
                }
              ]
            }
          },
          {
            "name": "manufacturingFacilityGln",
            "value": "0950110153000"
          }
        ]
      },
      "submodels": [
        {
          "type": "ModelReference",
          "keys": [
            {
              "type": "Submodel",
              "value": "urn:opendpp:submodel:general:9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d"
            }
          ]
        },
        {
          "type": "ModelReference",
          "keys": [
            {
              "type": "Submodel",
              "value": "urn:opendpp:submodel:compliance"
            }
          ]
        }
      ]
    }
  ],
  "submodels": [
    {
      "id": "urn:opendpp:submodel:general:9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d",
      "idShort": "GeneralProductInformation",
      "semanticId": {
        "keys": [
          {
            "type": "Submodel",
            "value": "urn:opendpp:submodel-spec:general:1.0.0"
          }
        ]
      },
      "submodelElements": [
        {
          "idShort": "passportId",
          "modelType": "Property",
          "valueType": "xs:string",
          "value": "9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d"
        },
        {
          "idShort": "createdAt",
          "modelType": "Property",
          "valueType": "xs:dateTime",
          "value": "2026-06-12T09:41:00.000Z"
        },
        {
          "idShort": "manufacturingFacilityGln",
          "modelType": "Property",
          "valueType": "xs:string",
          "value": "0950110153000"
        },
        {
          "idShort": "manufacturingFacilityName",
          "modelType": "Property",
          "valueType": "xs:string",
          "value": "Aurora Spinning Mill"
        },
        {
          "idShort": "manufacturingFacilityCountry",
          "modelType": "Property",
          "valueType": "xs:string",
          "value": "PT"
        }
      ]
    },
    {
      "id": "urn:opendpp:submodel:compliance",
      "idShort": "ComplianceMetadata",
      "submodelElements": [
        {
          "idShort": "category",
          "modelType": "Property",
          "valueType": "xs:string",
          "value": "textiles"
        },
        {
          "idShort": "originCountry",
          "modelType": "Property",
          "valueType": "xs:string",
          "value": "PT"
        },
        {
          "idShort": "size",
          "modelType": "Property",
          "valueType": "xs:string",
          "value": "M"
        }
      ]
    }
  ],
  "conceptDescriptions": []
}
text/html string

Server-rendered passport page (returned when Accept contains text/html).

Example 200 response
"string"
application/vc+jwt string

Forwarded enveloping UNTP DigitalProductPassport credential (compact JWS, ES256), identical to GET /passport/{id} with Accept: application/vc+jwt.

Example 200 response
"string"
application/vc+ld+json object

Forwarded UNTP DigitalProductPassport credential with an embedded W3C Data Integrity proof (ecdsa-jcs-2019), identical to GET /passport/{id} with Accept: application/vc+ld+json.

Example 200 response
{}
application/dc+sd-jwt string

Forwarded SD-JWT-VC (selective disclosure) of the same credential, identical to GET /passport/{id} with Accept: application/dc+sd-jwt (legacy vc+sd-jwt accepted).

Example 200 response
"string"
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

Two distinct bodies. (1) Standard: the id/productId matched nothing under your workspace — {success:false, error:"Not Found", message:"Passport with ID or Product ID <id> not found under your Tenant workspace"}. (2) Forwarded from the public resolver (note: no success field): the passport is a DRAFT and the forwarded credential was not recognized as owner (e.g. JWT session); with Accept: text/html this case returns an HTML not-found page instead.

application/json Error | object
error string required
message string required
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Passport with ID or Product ID 09501101530003 not found under your Tenant workspace"
}
text/html string

Forwarded SSR not-found page (DRAFT + unrecognized credential + Accept: text/html).

Example 404 response
"string"
406

The requested representation cannot be produced for this resource. Returned when a Verifiable Credential media type (application/vc+jwt, application/vc+ld+json, or application/dc+sd-jwt) is requested for a passport that has no manufacturing facility with a country of production — a conformant UNTP credential cannot be issued without it. Request the default JSON-LD (or another supported representation) instead.

application/json Error
Example 406 response
{
  "error": "Not Acceptable",
  "message": "This passport cannot be represented as a UNTP Verifiable Credential: it has no manufacturing facility with a country of production."
}
429

Two possible sources. (1) Global limiter (100/min/IP): default rate-limit body (statusCode/error/message — no code field) with x-ratelimit-limit/x-ratelimit-remaining/x-ratelimit-reset + retry-after headers. (2) Forwarded from the inner public resolver's limiter (30/min/IP): two-field body, no rate-limit headers.

application/json object | object
statusCode integer required
error string required
message string required
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected failure. A failure inside the scoped lookup is not wrapped by the route and returns the framework's default error body (statusCode/error/message, no success field); an authentication-layer failure returns the standard envelope with message: "Authentication verification failed".

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "Authentication verification failed"
}
PUT /api/v1/passports/{id} API key

Update passport metadata (versioned to history)

Replaces the passport's metadata (the Merkle root and leaf hashes are recomputed) and snapshots the previous metadata into the passport's version history (version = count + 1, changedBy = user email or api-key:<id>, changeReason defaults to "API Update").

Permission: passport:update (write — subscription gating applies, see 402). Cookie sessions must send X-CSRF-Token (double-submit); Bearer/API-key clients are exempt.

Lookup: by passport UUID onlyproductId aliasing is NOT supported on this endpoint. The passport must belong to an operator bound to your workspace.

Draft semantics (draft flag): - draft: true skips ESPR validation entirely and forces status: "DRAFT" — note this also demotes an already-published (ACTIVE/RECALLED/DECOMMISSIONED) passport back to DRAFT. - draft absent/false: metadata is validated against the ESPR category rules (400 on failure — see below). If the passport was a DRAFT it is published: status becomes ACTIVE, a passport.ingested webhook is enqueued transactionally (public-redacted JSON-LD payload) and an in-app notification is created best-effort afterwards. Editing an already-published (live) passport leaves its status untouched and enqueues a passport.updated webhook instead (same public-redacted JSON-LD payload).

Validation divergence: the 400 validation body here contains errors but — unlike POST /api/v1/passportsnever a warnings array. friendlyMessage is localized via the lang query parameter or Accept-Language (28 languages, default en; unsupported values silently fall back).

Sealed passports are immutable in place: if digitalSeal is set the update is refused with 403 (message: "This passport is sealed and cannot be edited in place — editing would invalidate the eIDAS advanced electronic seal. Re-seal explicitly after any change.").

Facility: omit facilityId to leave it unchanged; pass null or "" to detach; pass a facility UUID owned by your tenant to attach (400 if not found in your workspace).

Enrichment: include the enrichment key (even as null/{}) to overwrite the presentational marketing block; omit it to leave it unchanged. Values are sanitized server-side (truncated/sliced, http(s) URLs only), never rejected.

Response caveat: the returned passport document is serialized at the public redaction tier — facilityDetails (and battery restricted keys) appear as "[REDACTED - Privileged Access Required]" even though you are the owner.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

Passport UUID. productId aliasing is NOT supported here.

lang query enum optional

Locale for friendlyMessage localization in validation errors. Falls back to Accept-Language, then en. Unsupported values are ignored (no error).

one of: en, bg, hr, cs, da, nl, et, fi, fr, de, el, hu, ga, it, lv, lt, mt, pl, pt, ro, sk, sl, es, sv, no, is, uk, tr

Request body required

application/json PassportUpdateRequest
Example request
{
  "metadata": {
    "category": "textiles",
    "originCountry": "PT",
    "materialComposition": [
      {
        "material": "Organic cotton",
        "percentage": 80
      },
      {
        "material": "Recycled polyester",
        "percentage": 20
      }
    ],
    "fiberComposition": [
      {
        "fiber": "cotton",
        "percentage": 80
      },
      {
        "fiber": "polyester",
        "percentage": 20
      }
    ],
    "careInstructions": "Machine wash cold, line dry",
    "size": "M",
    "facilityDetails": [
      {
        "facilityName": "Aurora Spinning Mill",
        "location": "Porto, PT",
        "activity": "Spinning",
        "eudrPlots": [
          {
            "plotId": "PLOT-001",
            "polygonType": "point",
            "coordinates": [
              {
                "lat": 41.1579,
                "lng": -8.6291
              }
            ]
          }
        ],
        "traceabilityDocs": [
          {
            "documentName": "GOTS scope certificate",
            "documentHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
            "documentUrl": "https://docs.aurora-textiles.example/gots.pdf"
          }
        ]
      }
    ],
    "regulatoryCompliance": {
      "ceMarking": true,
      "certificates": [
        {
          "name": "GOTS",
          "referenceNumber": "GOTS-2026-0042",
          "issuer": "Control Union",
          "validUntil": "2027-05-31"
        }
      ]
    }
  },
  "changeReason": "Updated fiber composition after supplier audit",
  "facilityId": "5b21cf12-7d24-4a8e-9a3c-2f1f4f4f9d10"
}

Example

curl -X PUT "https://opendpp-node.eu/api/v1/passports/string" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "metadata": {
      "category": "textiles",
      "originCountry": "PT",
      "materialComposition": [
        {
          "material": "Organic cotton",
          "percentage": 80
        },
        {
          "material": "Recycled polyester",
          "percentage": 20
        }
      ],
      "fiberComposition": [
        {
          "fiber": "cotton",
          "percentage": 80
        },
        {
          "fiber": "polyester",
          "percentage": 20
        }
      ],
      "careInstructions": "Machine wash cold, line dry",
      "size": "M",
      "facilityDetails": [
        {
          "facilityName": "Aurora Spinning Mill",
          "location": "Porto, PT",
          "activity": "Spinning",
          "eudrPlots": [
            {
              "plotId": "PLOT-001",
              "polygonType": "point",
              "coordinates": [
                {
                  "lat": 41.1579,
                  "lng": -8.6291
                }
              ]
            }
          ],
          "traceabilityDocs": [
            {
              "documentName": "GOTS scope certificate",
              "documentHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
              "documentUrl": "https://docs.aurora-textiles.example/gots.pdf"
            }
          ]
        }
      ],
      "regulatoryCompliance": {
        "ceMarking": true,
        "certificates": [
          {
            "name": "GOTS",
            "referenceNumber": "GOTS-2026-0042",
            "issuer": "Control Union",
            "validUntil": "2027-05-31"
          }
        ]
      }
    },
    "changeReason": "Updated fiber composition after supplier audit",
    "facilityId": "5b21cf12-7d24-4a8e-9a3c-2f1f4f4f9d10"
  }'

Responses

200

Updated (or published) passport. message is "Draft published" on a first publish, otherwise "Digital Product Passport successfully updated and history versioned". The passport document is public-tier redacted.

application/json PassportUpdateResponse
Example 200 response
{
  "success": true,
  "message": "Digital Product Passport successfully updated and history versioned",
  "passport": {
    "@context": [
      "https://opendpp-node.eu/contexts/dpp/v1",
      {
        "DigitalProductPassport": "https://opendpp-node.eu/ns/dpp#DigitalProductPassport",
        "economicOperator": "https://opendpp-node.eu/ns/dpp#economicOperator",
        "manufacturingFacility": "https://opendpp-node.eu/ns/dpp#manufacturingFacility",
        "metadata": "https://opendpp-node.eu/ns/dpp#metadata",
        "digitalSeal": "https://opendpp-node.eu/ns/dpp#digitalSeal",
        "signingPublicKey": "https://opendpp-node.eu/ns/dpp#signingPublicKey",
        "status": "https://opendpp-node.eu/ns/dpp#status",
        "archivedAt": "https://opendpp-node.eu/ns/dpp#archivedAt",
        "retentionUntil": "https://opendpp-node.eu/ns/dpp#retentionUntil",
        "category": "https://opendpp-node.eu/contexts/dpp/v1#category",
        "originCountry": "https://opendpp-node.eu/contexts/dpp/v1#originCountry",
        "materialComposition": "https://opendpp-node.eu/contexts/dpp/v1#materialComposition",
        "fiberComposition": "https://opendpp-node.eu/contexts/dpp/v1#fiberComposition",
        "careInstructions": "https://opendpp-node.eu/contexts/dpp/v1#careInstructions",
        "size": "https://opendpp-node.eu/contexts/dpp/v1#size",
        "facilityDetails": "https://opendpp-node.eu/contexts/dpp/v1#facilityDetails",
        "regulatoryCompliance": "https://opendpp-node.eu/contexts/dpp/v1#regulatoryCompliance"
      }
    ],
    "@type": "DigitalProductPassport",
    "@id": "https://opendpp-node.eu/01/09501101530003",
    "id": "9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d",
    "productId": "09501101530003",
    "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003",
    "digitalSeal": null,
    "signingPublicKey": null,
    "status": "ACTIVE",
    "archivedAt": null,
    "retentionUntil": null,
    "proof": null,
    "createdAt": "2026-06-12T09:41:00.000Z",
    "updatedAt": "2026-06-12T10:15:00.000Z",
    "economicOperator": {
      "@type": "EconomicOperator",
      "id": "f2d6a9c1-4b3e-4d2a-8c1f-0a9b8c7d6e5f",
      "name": "Aurora Textiles GmbH",
      "regId": "EU-DEFAULT-001",
      "role": "Manufacturer"
    },
    "manufacturingFacility": {
      "@type": "Facility",
      "id": "5b21cf12-7d24-4a8e-9a3c-2f1f4f4f9d10",
      "gln": "0950110153000",
      "name": "Aurora Spinning Mill",
      "activity": "Spinning",
      "country": "PT"
    },
    "metadata": {
      "category": "textiles",
      "originCountry": "PT",
      "materialComposition": [
        {
          "material": "Organic cotton",
          "percentage": 80
        },
        {
          "material": "Recycled polyester",
          "percentage": 20
        }
      ],
      "fiberComposition": [
        {
          "fiber": "cotton",
          "percentage": 80
        },
        {
          "fiber": "polyester",
          "percentage": 20
        }
      ],
      "careInstructions": "Machine wash cold, line dry",
      "size": "M",
      "facilityDetails": "[REDACTED - Privileged Access Required]",
      "regulatoryCompliance": {
        "ceMarking": true,
        "certificates": [
          {
            "name": "GOTS",
            "referenceNumber": "GOTS-2026-0042",
            "issuer": "Control Union",
            "validUntil": "2027-05-31"
          }
        ]
      }
    },
    "category": "textiles",
    "originCountry": "PT",
    "materialComposition": [
      {
        "material": "Organic cotton",
        "percentage": 80
      },
      {
        "material": "Recycled polyester",
        "percentage": 20
      }
    ],
    "fiberComposition": [
      {
        "fiber": "cotton",
        "percentage": 80
      },
      {
        "fiber": "polyester",
        "percentage": 20
      }
    ],
    "careInstructions": "Machine wash cold, line dry",
    "size": "M",
    "facilityDetails": "[REDACTED - Privileged Access Required]",
    "regulatoryCompliance": {
      "ceMarking": true,
      "certificates": [
        {
          "name": "GOTS",
          "referenceNumber": "GOTS-2026-0042",
          "issuer": "Control Union",
          "validUntil": "2027-05-31"
        }
      ]
    }
  }
}
400

Either a plain Bad Request — body is not a JSON object; metadata missing/not an object; facilityId not found in your workspace (Facility <facilityId> not found in your Tenant workspace) — or an ESPR validation failure. Divergence: unlike POST /api/v1/passports, the validation body has NO warnings array.

Example 400 response
{
  "success": false,
  "error": "Bad Request",
  "message": "metadata payload must be a valid object"
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

History snapshot or transactional update failure returns the standard envelope (message may echo the internal error text or fall back to "Failed to update passport"). Unexpected server error. Unhandled errors are normalized by the global error handler to the standard envelope with the generic message "An unexpected error occurred"; details are logged server-side, never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "Failed to update passport"
}
DELETE /api/v1/passports/{id} API key

Permanently delete a DRAFT passport

Hard-deletes a passport only while it is a DRAFT (never published, not publicly resolvable, no retention duty). Children (history, access logs, battery units) cascade on delete.

Published passports (ACTIVE/RECALLED/DECOMMISSIONED) are refused with 409 — they must be decommissioned/archived through the status lifecycle (PUT /api/v1/passports/{id}/status) to satisfy the ESPR Art. 9(2) persistence duty.

Permission: passport:update (write — subscription gating applies, see 402). Cookie sessions must send X-CSRF-Token; Bearer/API-key clients are exempt.

Lookup: by passport UUID only (no productId aliasing) and only within the passport's owning tenant — an operator-binding alone is not sufficient, unlike PUT.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

Passport UUID. productId aliasing is NOT supported here.

Example

curl -X DELETE "https://opendpp-node.eu/api/v1/passports/string" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

Draft deleted.

application/json object
success boolean required
message string required
always Draft passport deleted.
Example 200 response
{
  "success": true,
  "message": "Draft passport deleted."
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
409

The passport is not a DRAFT — published passports cannot be hard-deleted.

application/json Error
Example 409 response
{
  "success": false,
  "error": "Conflict",
  "message": "Only draft passports can be deleted. A published passport must be decommissioned/archived to satisfy the persistence duty (ESPR Art. 9(2))."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected failure. A failure in the lookup or delete itself is not wrapped by the route and returns the framework's default error body (statusCode/error/message, no success field); an authentication-layer failure returns the standard envelope with message: "Authentication verification failed".

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "Authentication verification failed"
}
POST /api/v1/passports/{id}/seal API key

Apply the tenant's eIDAS advanced electronic seal

Signs the passport's Merkle root (SHA-256 tree over the key-sorted top-level metadata entries) with the tenant's vault-held ECDSA P-256 (prime256v1) private key, producing an eIDAS advanced electronic seal (this is a local cryptographic seal — NOT a Commission/EU-registry registration, and NOT a qualified seal). The base64 signature is stored as digitalSeal together with the signing public key (PEM), the X.509 chain binding the key to the tenant's legal identity (surfaced as proof.x5c, leaf first, base64 DER), and — best-effort, opt-in — an RFC 3161 trusted timestamp over SHA-256(merkleRoot) (proof.rfc3161; a TSA outage or missing configuration never blocks sealing, the field is simply absent).

A passport.sealed webhook is enqueued transactionally with the update (payload: the public-redacted JSON-LD document including the full proof block).

Permission: passport:seal (write — subscription gating applies, see 402). Cookie sessions must send X-CSRF-Token; Bearer/API-key clients are exempt.

Lookup: passport UUID or productId (UUID tried first), restricted to the passport's owning tenant.

Behavioral caveats: - The route does not modify the passport's status — despite the success message's "and published" wording, a DRAFT stays a DRAFT after sealing. Publish via PUT /api/v1/passports/{id} (validated save) instead. - Re-sealing an already-sealed passport is allowed and overwrites the previous seal/timestamp. - Once sealed, in-place metadata edits are refused (403 on PUT /api/v1/passports/{id}). - Requires the tenant's eIDAS key pair to exist — otherwise 400. - The returned passport document is serialized at the public redaction tier (masked keys keep their true leaf hashes in proof.redactedLeaves, so the seal stays offline-verifiable after redaction).

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

Passport UUID or caller-supplied productId (GTIN-14 / GRAI / SKU). UUID is tried first.

min length 1

Example

curl -X POST "https://opendpp-node.eu/api/v1/passports/string/seal" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

Passport sealed. digitalSeal is the base64 ECDSA-P256-SHA256 signature over the Merkle root; the same value appears as passport.proof.signatureValue.

application/json PassportSealResponse
Example 200 response
{
  "success": true,
  "message": "Passport sealed with the tenant's eIDAS advanced electronic seal and published.",
  "digitalSeal": "MEUCIQDOJ9uZ9b1H0u4G7m2X8nF3kqL5wTzVbY1c2d3e4f5g6AIgKxN8mPqRsTuVwXyZ0a1b2c3d4e5f6g7h8i9j0kAbCdE=",
  "signingPublicKey": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1u2v3w4x5y6z7A8B9C0D1E2F3G4H\n5I6J7K8L9M0N1O2P3Q4R5S6T7U8V9W0X1Y2Z3a4b5c6d7e8f9g0h1i2j3k4l5m6n\n-----END PUBLIC KEY-----\n",
  "passport": {
    "@context": [
      "https://opendpp-node.eu/contexts/dpp/v1",
      {
        "DigitalProductPassport": "https://opendpp-node.eu/ns/dpp#DigitalProductPassport",
        "economicOperator": "https://opendpp-node.eu/ns/dpp#economicOperator",
        "manufacturingFacility": "https://opendpp-node.eu/ns/dpp#manufacturingFacility",
        "metadata": "https://opendpp-node.eu/ns/dpp#metadata",
        "digitalSeal": "https://opendpp-node.eu/ns/dpp#digitalSeal",
        "signingPublicKey": "https://opendpp-node.eu/ns/dpp#signingPublicKey",
        "status": "https://opendpp-node.eu/ns/dpp#status",
        "archivedAt": "https://opendpp-node.eu/ns/dpp#archivedAt",
        "retentionUntil": "https://opendpp-node.eu/ns/dpp#retentionUntil",
        "category": "https://opendpp-node.eu/contexts/dpp/v1#category",
        "originCountry": "https://opendpp-node.eu/contexts/dpp/v1#originCountry",
        "size": "https://opendpp-node.eu/contexts/dpp/v1#size",
        "facilityDetails": "https://opendpp-node.eu/contexts/dpp/v1#facilityDetails"
      }
    ],
    "@type": "DigitalProductPassport",
    "@id": "https://opendpp-node.eu/01/09501101530003",
    "id": "9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d",
    "productId": "09501101530003",
    "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003",
    "digitalSeal": "MEUCIQDOJ9uZ9b1H0u4G7m2X8nF3kqL5wTzVbY1c2d3e4f5g6AIgKxN8mPqRsTuVwXyZ0a1b2c3d4e5f6g7h8i9j0kAbCdE=",
    "signingPublicKey": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1u2v3w4x5y6z7A8B9C0D1E2F3G4H\n5I6J7K8L9M0N1O2P3Q4R5S6T7U8V9W0X1Y2Z3a4b5c6d7e8f9g0h1i2j3k4l5m6n\n-----END PUBLIC KEY-----\n",
    "status": "ACTIVE",
    "archivedAt": null,
    "retentionUntil": null,
    "proof": {
      "@type": [
        "MerkleTreeAttestationProof"
      ],
      "type": "MerkleTreeAttestationProof",
      "signatureAlgorithm": "ECDSA-P256-SHA256-over-MerkleRoot",
      "created": "2026-06-12T09:45:12.000Z",
      "proofPurpose": "assertionMethod",
      "verificationMethod": "https://opendpp-node.eu/passport/9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d#key-1",
      "signatureValue": "MEUCIQDOJ9uZ9b1H0u4G7m2X8nF3kqL5wTzVbY1c2d3e4f5g6AIgKxN8mPqRsTuVwXyZ0a1b2c3d4e5f6g7h8i9j0kAbCdE=",
      "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1u2v3w4x5y6z7A8B9C0D1E2F3G4H\n5I6J7K8L9M0N1O2P3Q4R5S6T7U8V9W0X1Y2Z3a4b5c6d7e8f9g0h1i2j3k4l5m6n\n-----END PUBLIC KEY-----\n",
      "x5c": [
        "MIIBszCCAVqgAwIBAgIUEjRWeJq83vEjRWeJq83vEjRWeJo",
        "MIIBqzCCAVGgAwIBAgIUZYxw9PqrstuvZYxw9Pqrstuvxyz"
      ],
      "rfc3161": {
        "genTime": "2026-06-12T09:45:13.000Z",
        "token": "MIIKExampleBase64DerEncodedRfc3161TimeStampRespToken"
      },
      "merkleRoot": "7f83b1657ff1fc53b92dc18148a1d65dff20fdcf2e57d8a1f6cbeab6db1ec9f3",
      "redactedLeaves": {
        "facilityDetails": "3c9909afec25354d551dae21590bb26e38d53f2173b8d3dc3eee4c047e7ab1c1"
      }
    },
    "createdAt": "2026-06-12T09:41:00.000Z",
    "updatedAt": "2026-06-12T09:45:12.000Z",
    "economicOperator": {
      "@type": "EconomicOperator",
      "id": "f2d6a9c1-4b3e-4d2a-8c1f-0a9b8c7d6e5f",
      "name": "Aurora Textiles GmbH",
      "regId": "EU-DEFAULT-001",
      "role": "Manufacturer"
    },
    "manufacturingFacility": null,
    "metadata": {
      "category": "textiles",
      "originCountry": "PT",
      "size": "M",
      "facilityDetails": "[REDACTED - Privileged Access Required]"
    },
    "category": "textiles",
    "originCountry": "PT",
    "size": "M",
    "facilityDetails": "[REDACTED - Privileged Access Required]"
  }
}
400

Missing identifier, or the tenant has no eIDAS key pair configured.

application/json Error
Example 400 response
{
  "success": false,
  "error": "Bad Request",
  "message": "eIDAS cryptographic keys are not configured for this Tenant. Please generate or rotate keys in your console first."
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
PUT /api/v1/passports/{id}/status API key

Transition passport lifecycle status (recall / decommission / reactivate)

Transitions a published passport between live lifecycle states. The request body carries only status (any other keys are ignored — there is no reason field; the history entry's change reason is auto-generated as Status changed: <from> → <to>).

Permission: passport:update (write — subscription gating applies, see 402). Cookie sessions must send X-CSRF-Token; Bearer/API-key clients are exempt.

Lookup: passport UUID or productId (UUID tried first), scoped to operators bound to your workspace.

Effects: - DECOMMISSIONED — sets retentionUntil = now + the configured retention period (default 15 years), starting the minimum-availability retention clock. The passport stays publicly resolvable. - ACTIVE (reactivation) — clears retentionUntil and archivedAt. - RECALLED — marks the product recalled. - The status change, the version-history entry (who/when/what) and the webhook enqueue are transactional; an in-app notification is created best-effort after the transaction commits (a notification failure never affects the response).

Webhooks: RECALLED enqueues passport.recalled; any other transition (DECOMMISSIONED, reactivate-to-ACTIVE) enqueues passport.status_updated — note that passport.status_updated is not an explicitly subscribable event filter, so only wildcard ("*") webhook subscriptions receive it. Payloads are the public-redacted JSON-LD document.

Caveats: DRAFT passports are refused with 409 (publish first via a validated PUT /api/v1/passports/{id}). Sealed passports CAN change status — status is stored alongside the document, not inside the sealed metadata Merkle tree. The returned passport document is serialized at the public redaction tier.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

Passport UUID or caller-supplied productId (GTIN-14 / GRAI / SKU). UUID is tried first.

min length 1

Request body required

Example request
{
  "status": "DECOMMISSIONED"
}

Example

curl -X PUT "https://opendpp-node.eu/api/v1/passports/string/status" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "status": "DECOMMISSIONED"
  }'

Responses

200

Status updated. status echoes the new lifecycle state; passport is the public-tier JSON-LD document.

Example 200 response
{
  "success": true,
  "message": "Passport status successfully updated to DECOMMISSIONED",
  "status": "DECOMMISSIONED",
  "passport": {
    "@context": [
      "https://opendpp-node.eu/contexts/dpp/v1",
      {
        "DigitalProductPassport": "https://opendpp-node.eu/ns/dpp#DigitalProductPassport",
        "economicOperator": "https://opendpp-node.eu/ns/dpp#economicOperator",
        "manufacturingFacility": "https://opendpp-node.eu/ns/dpp#manufacturingFacility",
        "metadata": "https://opendpp-node.eu/ns/dpp#metadata",
        "digitalSeal": "https://opendpp-node.eu/ns/dpp#digitalSeal",
        "signingPublicKey": "https://opendpp-node.eu/ns/dpp#signingPublicKey",
        "status": "https://opendpp-node.eu/ns/dpp#status",
        "archivedAt": "https://opendpp-node.eu/ns/dpp#archivedAt",
        "retentionUntil": "https://opendpp-node.eu/ns/dpp#retentionUntil",
        "category": "https://opendpp-node.eu/contexts/dpp/v1#category",
        "originCountry": "https://opendpp-node.eu/contexts/dpp/v1#originCountry",
        "size": "https://opendpp-node.eu/contexts/dpp/v1#size",
        "facilityDetails": "https://opendpp-node.eu/contexts/dpp/v1#facilityDetails"
      }
    ],
    "@type": "DigitalProductPassport",
    "@id": "https://opendpp-node.eu/01/09501101530003",
    "id": "9b2fa884-3f1e-4c2a-9d4b-5e6f7a8b9c0d",
    "productId": "09501101530003",
    "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003",
    "digitalSeal": null,
    "signingPublicKey": null,
    "status": "DECOMMISSIONED",
    "archivedAt": null,
    "retentionUntil": "2041-06-12T10:02:00.000Z",
    "proof": null,
    "createdAt": "2026-06-12T09:41:00.000Z",
    "updatedAt": "2026-06-12T10:02:00.000Z",
    "economicOperator": {
      "@type": "EconomicOperator",
      "id": "f2d6a9c1-4b3e-4d2a-8c1f-0a9b8c7d6e5f",
      "name": "Aurora Textiles GmbH",
      "regId": "EU-DEFAULT-001",
      "role": "Manufacturer"
    },
    "manufacturingFacility": null,
    "metadata": {
      "category": "textiles",
      "originCountry": "PT",
      "size": "M",
      "facilityDetails": "[REDACTED - Privileged Access Required]"
    },
    "category": "textiles",
    "originCountry": "PT",
    "size": "M",
    "facilityDetails": "[REDACTED - Privileged Access Required]"
  }
}
400

status missing or not one of the allowed values.

application/json Error
Example 400 response
{
  "success": false,
  "error": "Bad Request",
  "message": "status must be one of: ACTIVE, RECALLED, DECOMMISSIONED"
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
409

The passport is a DRAFT — drafts cannot transition to a live status here.

application/json Error
Example 409 response
{
  "success": false,
  "error": "Conflict",
  "message": "This passport is a draft. Open it and use Save & publish to validate and publish it before changing its status."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to the standard envelope with the generic message "An unexpected error occurred"; details are logged server-side, never returned.

application/json object

Standard envelope; message is omitted when the internal error carried no text.

success boolean required
always false
error string required
message string
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "Transaction failed"
}

Economic Operators

Register and manage the economic operators (manufacturers/brands, identified by EORI or national registry id) that passports are issued on behalf of. Operators with passports are archived — never hard-deleted — to preserve passport resolvability (ESPR Art. 9(2)).

GET /api/v1/operators API key

List economic operators bound to your workspace

Returns the economic operators bound to your workspace, ordered by name. Active operators only unless ?archived=true is passed (archived operators are off-boarded but their passports are retained and still publicly resolvable).

Each entry is the same OperatorRow shape returned by POST/PATCH /api/v1/operators — use the id to attribute a passport (operatorId on POST /api/v1/passports) or to address PATCH/DELETE.

Permission: operator:read. Requests authenticated with an operator-scoped API key see only their own operator.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
archived query enum optional

Set true to include archived (off-boarded) operators. Default returns active operators only.

one of: true, false

Example

curl -X GET "https://opendpp-node.eu/api/v1/operators" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

The bound operators.

application/json OperatorListResponse
Example 200 response
{
  "success": true,
  "count": 1,
  "operators": [
    {
      "id": "4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b",
      "name": "Demo Manufacturing UAB",
      "regId": "EU-DEFAULT-001",
      "regIdScheme": "EORI",
      "role": "MANUFACTURER",
      "archivedAt": null,
      "createdAt": "2026-06-12T09:41:00.000Z"
    }
  ]
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
POST /api/v1/operators API key

Register an economic operator and bind it to your workspace

Registers an economic operator (manufacturer, importer, supplier, …) and binds it to your workspace.

Permission: operator:create. Cookie-session clients must send the X-CSRF-Token header (double-submit); Bearer clients (API key / JWT) are exempt.

Deduplication (per workspace): operators are scoped to your workspace — regId is unique *within* your workspace, not across the platform. If your workspace already has an operator with the submitted regId, that existing record is returned and the submitted name, role and regIdScheme are ignored. A regId already used by *another* workspace is irrelevant — you always get your own operator row (so one workspace can never bind to, rename, or archive another's operator). The call is idempotent: re-registering an already-bound operator succeeds with 201 again. The per-workspace match includes archived operators: if your workspace's operator for that regId is archived, the archived record is returned as-is (archivedAt non-null) with 201 — registration does not un-archive it; use POST /api/v1/operators/{id}/restore to reactivate it.

Registration-id integrity: fabricated EORI-MOCK… ids are rejected on every path. When regIdScheme is EORI, regId must match ^[A-Z]{2}[A-Za-z0-9]{1,15}$ (2-letter ISO 3166 country prefix followed by up to 15 alphanumerics, e.g. DE1234567890). Validation is syntax-only by default. When the node operator enables the OPT-IN EORI_EXISTENCE_CHECK (#404), a declared EORI regId is additionally checked for EXISTENCE against the EU Commission EOS validation service and, if not found, a NON-BLOCKING advisory is added to the 201 warnings[] — the operator is still registered (best-effort, fail-open: a network error, or a freshly-issued / GB EORI, never blocks registration). With the check off, warnings is [].

Side effects: an operator.created audit event and an in-app notification are recorded.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Request body required

application/json RegisterOperatorRequest
Example request
{
  "name": "Default EU Manufacturing Operator",
  "regId": "EU-DEFAULT-001",
  "role": "MANUFACTURER"
}

Example

curl -X POST "https://opendpp-node.eu/api/v1/operators" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Default EU Manufacturing Operator",
    "regId": "EU-DEFAULT-001",
    "role": "MANUFACTURER"
  }'

Responses

201

Operator registered (or an existing operator with the same regId was bound to your workspace). Returned in both cases — inspect the returned operator to see whether your submitted name/role were applied or a pre-existing record was reused. A pre-existing record may even be archived (archivedAt non-null); binding does not un-archive it.

application/json RegisterOperatorResponse
Example 201 response
{
  "success": true,
  "message": "Economic Operator supplier registered successfully",
  "operator": {
    "id": "9b2fa884-7c1d-4e7a-9a64-2f8d3b5c6e01",
    "name": "Default EU Manufacturing Operator",
    "regId": "EU-DEFAULT-001",
    "regIdScheme": null,
    "role": "MANUFACTURER",
    "archivedAt": null,
    "createdAt": "2026-06-12T09:41:00.000Z"
  }
}
400

Two distinct bodies. Missing name/regId returns the minimal envelope without an error key. A regId/regIdScheme validation failure (whitespace-only regId, fabricated EORI-MOCK… id, unknown scheme, or invalid EORI syntax) returns the standard envelope with error: "Bad Request".

application/json OperatorMinimalError | Error
Example 400 response
{
  "success": false,
  "message": "Missing supplier parameters: name and regId are required"
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Database/handler failure. The route handler emits the minimal envelope ({success: false, message}, no error key, message taken from the underlying error); a failure inside the authentication layer instead emits the standard three-field envelope.

application/json OperatorMinimalError | Error
Example 500 response
{
  "success": false,
  "message": "Database connection lost"
}
GET /api/v1/operators/{id} API key

Fetch a single bound economic operator

Fetches one economic operator by UUID, scoped to your workspace (404 if no operator with that id exists in your workspace).

Permission: operator:read. An operator-scoped API key may only fetch its own operator (403 otherwise).

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

Operator UUID (EconomicOperator.id). Must be bound to your workspace.

Example

curl -X GET "https://opendpp-node.eu/api/v1/operators/string" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

The operator.

application/json OperatorGetResponse
Example 200 response
{
  "success": true,
  "operator": {
    "id": "4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b",
    "name": "Demo Manufacturing UAB",
    "regId": "EU-DEFAULT-001",
    "regIdScheme": "EORI",
    "role": "MANUFACTURER",
    "archivedAt": null,
    "createdAt": "2026-06-12T09:41:00.000Z"
  }
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
PATCH /api/v1/operators/{id} API key

Update an operator's name or role (regId is immutable)

Edits an operator bound to your workspace. Only name and role are editable; regId is the legal registry identifier and is intentionally immutable here (register the operator again under the correct id instead). Non-string or whitespace-only values are silently ignored; submitted values are trimmed. If no usable field is supplied (every field missing, non-string, or whitespace-only — including an empty object {} or an omitted body), the current operator row is returned unchanged with 200 and no audit event is written. The handler does not diff against current values: supplying a value identical to the current one still performs an update and writes an audit event.

Permission: operator:write. Cookie-session clients must send X-CSRF-Token; Bearer clients are exempt.

Tenant-scoped: operators are scoped to your workspace — regId is not globally unique. Registering a regId that another workspace also uses creates your own operator row; name/role edits never affect another workspace.

When a change is applied, an operator.updated audit event is recorded. Unhandled database errors are normalized by the global error handler to the standard {success: false, error, message} envelope with a generic message (details are logged server-side).

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

Operator UUID (EconomicOperator.id). Must be bound to your workspace.

Request body

application/json UpdateOperatorRequest
Example request
{
  "name": "Default EU Manufacturing Operator B.V.",
  "role": "IMPORTER"
}

Example

curl -X PATCH "https://opendpp-node.eu/api/v1/operators/string" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Default EU Manufacturing Operator B.V.",
    "role": "IMPORTER"
  }'

Responses

200

The (possibly unchanged) operator row. If the body contained no usable field, the current row is echoed back.

application/json UpdateOperatorResponse
Example 200 response
{
  "success": true,
  "operator": {
    "id": "9b2fa884-7c1d-4e7a-9a64-2f8d3b5c6e01",
    "name": "Default EU Manufacturing Operator B.V.",
    "regId": "EU-DEFAULT-001",
    "regIdScheme": null,
    "role": "IMPORTER",
    "archivedAt": null,
    "createdAt": "2026-06-12T09:41:00.000Z"
  }
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The operator does not exist or is not bound to your workspace. Note: minimal envelope without an error key.

application/json OperatorMinimalError
Example 404 response
{
  "success": false,
  "message": "Operator not found in your workspace."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Server-side failure. Unexpected server error. Unhandled errors are normalized by the global error handler to the standard envelope with the generic message "An unexpected error occurred"; details are logged server-side, never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "Authentication verification failed"
}
DELETE /api/v1/operators/{id} API key

Remove an operator (archives if it has passports, else hard-deletes)

Removes an operator, choosing automatically between two outcomes (ESPR Art. 9(2)/77 passport-persistence compliance — an operator that still has passports must never be hard-deleted):

  • Archive (soft delete) — if the operator has one or more passports, it is archived instead of deleted: archivedAt is set on the operator and every active passport of the operator is archived with a retentionUntil deadline set to a platform-configured retention period from now (default 15 years). Archived passports remain publicly resolvable (the persistence duty) but are excluded from active management lists. Response: {success: true, archived: true, archivedPassports: <n>}. Fully reversible via POST /api/v1/operators/{id}/restore.
  • Hard delete — if the operator has no passports it is permanently deleted (tenant bindings cascade-delete; user/facility/API-key references are set to null). Response: {success: true, archived: false} — no archivedPassports field.
  • Fallback — if the hard delete fails on a residual foreign-key reference, the operator is archived instead and the response is {success: true, archived: true} without archivedPassports. If even the fallback archive fails, 409 is returned.

Permission: operator:write. Cookie-session clients must send X-CSRF-Token; Bearer clients are exempt. The operator must be bound to your workspace (404 otherwise).

Tenant-scoped: this affects only your workspace's operator and passports — operators are not shared across workspaces.

Side effects: an operator.archived or operator.deleted audit event plus an in-app notification — on the primary archive and hard-delete paths only; the foreign-key fallback archive writes no audit event or notification. Unhandled database errors are normalized by the global error handler to the standard {success: false, error, message} envelope with a generic message (details are logged server-side).

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

Operator UUID (EconomicOperator.id). Must be bound to your workspace.

Example

curl -X DELETE "https://opendpp-node.eu/api/v1/operators/string" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

Operator removed. archived: true = soft-deleted (passports retained and still publicly resolvable; restorable); archived: false = hard-deleted. archivedPassports is present only on the primary archive path — it is absent on hard deletes and on the foreign-key fallback archive.

application/json DeleteOperatorResponse
Example 200 response
{
  "success": true,
  "archived": true,
  "archivedPassports": 12
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The operator does not exist or is not bound to your workspace. Note: minimal envelope without an error key.

application/json OperatorMinimalError
Example 404 response
{
  "success": false,
  "message": "Operator not found in your workspace."
}
409

The operator could neither be hard-deleted nor archived (both attempts failed). Note: minimal envelope without an error key.

application/json OperatorMinimalError
Example 409 response
{
  "success": false,
  "message": "Operator could not be removed; it is still referenced."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Server-side failure. Unexpected server error. Unhandled errors are normalized by the global error handler to the standard envelope with the generic message "An unexpected error occurred"; details are logged server-side, never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "Authentication verification failed"
}
POST /api/v1/operators/{id}/restore API key

Restore an archived operator and its archived passports

Un-archives an operator that was soft-deleted by DELETE /api/v1/operators/{id} and brings its archived passports back into the active catalogue: clears the operator's archivedAt, then clears archivedAt and retentionUntil on every archived passport of the operator except passports that were independently DECOMMISSIONED (those keep their own retention clock and stay archived).

Safe to call on a non-archived operator — it simply restores any archived passports the operator may have (restoredPassports may be 0). No request body.

Permission: operator:write. Cookie-session clients must send X-CSRF-Token; Bearer clients are exempt. 404 if the operator is not bound to your workspace.

Side effects: an operator.restored audit event and an in-app notification. Unhandled database errors are normalized by the global error handler to the standard {success: false, error, message} envelope with a generic message (details are logged server-side).

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

Operator UUID (EconomicOperator.id). Must be bound to your workspace.

Example

curl -X POST "https://opendpp-node.eu/api/v1/operators/string/restore" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

Operator un-archived. restoredPassports is the number of passports returned to the active catalogue (excludes independently DECOMMISSIONED passports).

application/json RestoreOperatorResponse
Example 200 response
{
  "success": true,
  "restoredPassports": 12
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The operator does not exist or is not bound to your workspace. Note: minimal envelope without an error key.

application/json OperatorMinimalError
Example 404 response
{
  "success": false,
  "message": "Operator not found in your workspace."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Server-side failure. Unexpected server error. Unhandled errors are normalized by the global error handler to the standard envelope with the generic message "An unexpected error occurred"; details are logged server-side, never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "Authentication verification failed"
}

Battery Units

Per-unit battery serialization (real serials, GS1 AI 21) under a SKU-level passport, plus append-only telemetry events (state of health, charge cycles, status changes) per the EU Battery Regulation.

POST /api/v1/passports/{passportId}/units/validate API key

Pre-flight: validate battery-unit identifiers without persisting (#263)

NON-MUTATING pre-flight for bulk unit import. Runs the SAME engine-backed AI-21 / GS1 Digital Link conformance + field checks as POST /api/v1/passports/{passportId}/units and returns a per-item verdict — persisting nothing. Send a single unit or {"units": [...]} (≤200). Lets a bulk importer ask "would these serials be GS1-conformant?" before committing a batch.

Permission: battery:write (gated as the write permission, like other validate-only checks; subscription gating → 402). Validation: serialNumber charset/length (^[A-Za-z0-9._-]{1,20}$, a URL-safe subset of GS1 AI-21 CSET 82) PLUS authoritative GS1-engine conformance for EVERY unit — a GTIN-keyed passport's unit Digital Link must parse cleanly through the engine, and a non-GTIN passport's AI-21 serial VALUE is validated through the same engine (CSET-82 charset + length); status must be a valid unit status; manufacturedAt must be Date-parseable. Predecessor linkage is NOT checked here (a persistence-time concern). The verdict order matches the input order.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
passportId path string required

The SKU/type-level passport, addressed by its UUID or caller-supplied productId (GTIN-14 / GRAI / SKU), scoped to your tenant.

Request body required

Example request
{
  "units": [
    {
      "serialNumber": "BATT-2026-000451"
    },
    {
      "serialNumber": "bad serial!"
    }
  ]
}

Example

curl -X POST "https://opendpp-node.eu/api/v1/passports/09501101530003/units/validate" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "units": [
      {
        "serialNumber": "BATT-2026-000451"
      },
      {
        "serialNumber": "bad serial!"
      }
    ]
  }'

Responses

200

Per-item conformance verdicts in input order (nothing persisted).

application/json object
success boolean required
message string
count integer required

Number of units checked.

validCount integer required

How many are GS1-conformant.

results array<object> required
serialNumber string | null required
ok boolean required
error string

Present only when ok=false.

Example 200 response
{
  "success": true,
  "message": "1 of 2 unit(s) GS1-conformant",
  "count": 2,
  "validCount": 1,
  "results": [
    {
      "serialNumber": "BATT-2026-000451",
      "ok": true
    },
    {
      "serialNumber": "bad serial!",
      "ok": false,
      "error": "Invalid serialNumber \"bad serial!\": must be 1-20 URL-safe characters ([A-Za-z0-9._-]) — GS1 AI-21's maximum length is 20"
    }
  ]
}
400

Invalid body envelope (not a JSON object, empty units, or more than 200 items).

application/json Error
Example 400 response
{
  "success": true,
  "error": "Bad Request",
  "message": "string",
  "requestId": "string",
  "code": "OPERATOR_NOT_BOUND"
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

Passport not found under your tenant workspace.

application/json Error
Example 404 response
{
  "success": true,
  "error": "Bad Request",
  "message": "string",
  "requestId": "string",
  "code": "OPERATOR_NOT_BOUND"
}
GET /api/v1/passports/{passportId}/units API key

List serialised battery units under a passport

Lists all serialised units of the passport, newest first (createdAt DESC). Paginated with ?page (default 1) and ?limit (default 100, max 200) — a SKU may carry many physical units; count is this page's size, total/totalPages describe the full set.

Permission: battery:read. Operator-scoped credentials may only read passports of their own Economic Operator (403). Units are raw persisted rows (no Fastify response schema, nothing stripped).

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
passportId path string required

The SKU/type-level passport, addressed either by its UUID or by its caller-supplied productId (GTIN-14 / GRAI / SKU). The UUID lookup is tried first, then productId — both scoped to your tenant.

page query integer optional

1-based page number (digits only; non-numeric falls back to 1).

default 1 · min 1
limit query integer optional

Page size. Clamped to 1–200; non-numeric falls back to the default 100.

default 100 · min 1 · max 200

Example

curl -X GET "https://opendpp-node.eu/api/v1/passports/09501101530003/units" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

The passport's units. productId echoes the passport's caller-supplied identifier; count equals units.length.

application/json BatteryUnitListResponse
Example 200 response
{
  "success": true,
  "count": 1,
  "productId": "09501101530003",
  "units": [
    {
      "id": "9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a",
      "serialNumber": "BATT-2026-000451",
      "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003/21/BATT-2026-000451",
      "passportId": "4c8e1d2a-6b3f-4a9e-8d57-0f1e2a3b4c5d",
      "tenantId": "tenant-demo-opendpp",
      "manufacturedAt": "2026-05-02T08:00:00.000Z",
      "status": "IN_SERVICE",
      "ceasedAt": null,
      "predecessorUnitId": null,
      "createdAt": "2026-06-12T09:41:00.000Z",
      "updatedAt": "2026-06-12T09:41:00.000Z"
    }
  ],
  "page": 1,
  "limit": 100,
  "total": 1,
  "totalPages": 1
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
POST /api/v1/passports/{passportId}/units API key

Serialise individual battery units under a passport (bulk, up to 200)

Creates one or many individual physical battery units (Battery Reg. (EU) 2023/1542 Art. 77(2)) under a SKU/type-level passport. Send either a single unit object or {"units": [...]} with at most 200 items (if units is present and an array it is used; otherwise the whole body is treated as one unit).

Permission: battery:write. Bearer API key (op_dpp_token_…) or session JWT; cookie-session clients must send X-CSRF-Token. Operator-scoped credentials may only serialise under passports of their own Economic Operator (403). Write operations pass subscription gating (402) and optional tenant MFA enforcement (403).

Per-item validation (collected as plain-string errors, not a rejection of the whole batch): serialNumber is trimmed then must match ^[A-Za-z0-9._-]{1,20}$ (a URL-safe subset of GS1 AI-21 CSET 82, ≤ 20 chars) AND is validated to full AI-21 conformance by GS1's authoritative engine — a GTIN-keyed unit through its full Digital Link, a non-GTIN unit through its AI-21 serial value; status must be a valid unit status; manufacturedAt must be Date-parseable; duplicate (passport, serialNumber) pairs are skipped with *"A unit with this serial already exists for this passport"*. Each created unit gets a per-unit GS1 Digital Link URI /{01|8003}/{productId}/21/{serialNumber} carrying the real physical serial in AI-21.

Predecessor linkage (Art. 77(7) repurpose/remanufacture): predecessorUnitId must reference an existing unit in your tenant (any passport). A recycled predecessor (ceasedAt set) is refused — its passport has ceased to exist (Art. 77(8)). (The check keys on ceasedAt, which only the events-route RECYCLED transition stamps — a unit merely *created* with status RECYCLED has no ceasedAt and is still accepted as a predecessor.) In one transaction the new unit is created, an append-only STATUS_CHANGE event ({status, successorUnitId, successorSerial} payload) is written to the predecessor, and the predecessor's status is set to predecessorStatus (default REPURPOSED; only REPURPOSED|REMANUFACTURED|REUSED allowed).

Partial success: the response is 201 when at least one unit was created; skipped items are listed in errors. If *every* item failed you get 400 Serialisation Failed with the same string array. A batteryunit.created audit event and a tenant notification are emitted on success.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
passportId path string required

The SKU/type-level passport, addressed either by its UUID or by its caller-supplied productId (GTIN-14 / GRAI / SKU). The UUID lookup is tried first, then productId — both scoped to your tenant.

Request body required

Example request
{
  "units": [
    {
      "serialNumber": "BATT-2026-000451",
      "manufacturedAt": "2026-05-02T08:00:00.000Z"
    },
    {
      "serialNumber": "BATT-2026-000452",
      "status": "IN_SERVICE",
      "predecessorUnitId": "5a1c9e7d-3b2f-4c8a-9e6d-7f0b1a2c3d4e",
      "predecessorStatus": "REMANUFACTURED"
    }
  ]
}

Example

curl -X POST "https://opendpp-node.eu/api/v1/passports/09501101530003/units" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "units": [
      {
        "serialNumber": "BATT-2026-000451",
        "manufacturedAt": "2026-05-02T08:00:00.000Z"
      },
      {
        "serialNumber": "BATT-2026-000452",
        "status": "IN_SERVICE",
        "predecessorUnitId": "5a1c9e7d-3b2f-4c8a-9e6d-7f0b1a2c3d4e",
        "predecessorStatus": "REMANUFACTURED"
      }
    ]
  }'

Responses

201

At least one unit was serialised. count is the number actually created; when some items were skipped, errors lists one plain-English string per skipped item and message notes the skip count.

Example 201 response
{
  "success": true,
  "message": "Serialised 2 individual unit(s)",
  "count": 2,
  "units": [
    {
      "id": "9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a",
      "serialNumber": "BATT-2026-000451",
      "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003/21/BATT-2026-000451",
      "passportId": "4c8e1d2a-6b3f-4a9e-8d57-0f1e2a3b4c5d",
      "tenantId": "tenant-demo-opendpp",
      "manufacturedAt": "2026-05-02T08:00:00.000Z",
      "status": "IN_SERVICE",
      "ceasedAt": null,
      "predecessorUnitId": null,
      "createdAt": "2026-06-12T09:41:00.000Z",
      "updatedAt": "2026-06-12T09:41:00.000Z"
    },
    {
      "id": "2f6b0c1d-8e4a-4b7c-a93d-5e2f1a0b9c8d",
      "serialNumber": "BATT-2026-000452",
      "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003/21/BATT-2026-000452",
      "passportId": "4c8e1d2a-6b3f-4a9e-8d57-0f1e2a3b4c5d",
      "tenantId": "tenant-demo-opendpp",
      "manufacturedAt": null,
      "status": "IN_SERVICE",
      "ceasedAt": null,
      "predecessorUnitId": "5a1c9e7d-3b2f-4c8a-9e6d-7f0b1a2c3d4e",
      "createdAt": "2026-06-12T09:41:00.000Z",
      "updatedAt": "2026-06-12T09:41:00.000Z"
    }
  ]
}
400

Three shapes: (1) standard Bad Request triple when the body is not a JSON object, the units array is empty, or more than 200 units are sent; (2) Serialisation Failed ({success:false, error:"Serialisation Failed", errors: string[]}no message field) when *every* item in the batch failed per-item validation/creation; (3) a syntactically malformed JSON body is rejected by the framework before the handler runs, with Fastify's default {statusCode:400, error:"Bad Request", message} body.

Example 400 response
{
  "success": false,
  "error": "Bad Request",
  "message": "A single request may serialise at most 200 units"
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
GET /api/v1/units/{id} API key

Get one battery unit as JSON-LD with its dynamic-data history

Returns the unit as a JSON-LD document (Content-Type: application/ld+json) in the privileged tenant view: currentState (the latest telemetry snapshot) and dynamicData (the 500 most recent events, newest first by recordedAt) are included; the public restrictedData marker is absent. The embedded ofModel is the SKU/type passport document rendered in the owner (unredacted) variant — legitimate-interest-tier metadata and owner-only keys are NOT masked, unlike the anonymous public document.

Caveat: this authenticated endpoint does not load lineage relations, so repurposedFrom is always null and successorUnits is always [] here even when Art. 77(7) lineage exists; the public resolver view (GET /unit/{id}) does resolve them.

Permission: battery:read. Operator-scoped credentials may only read units whose passport belongs to their Economic Operator (403).

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

Battery unit UUID (tenant-scoped).

Example

curl -X GET "https://opendpp-node.eu/api/v1/units/9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

The unit's JSON-LD document (privileged view, telemetry included).

application/ld+json BatteryUnitJsonLd
Example 200 response
{
  "@context": [
    "https://opendpp-node.eu/contexts/dpp/v1",
    {
      "BatteryUnit": "https://opendpp-node.eu/ns/dpp#BatteryUnit",
      "serialNumber": "https://opendpp-node.eu/ns/dpp#serialNumber",
      "ofModel": "https://opendpp-node.eu/ns/dpp#ofModel",
      "currentState": "https://opendpp-node.eu/ns/dpp#currentState",
      "dynamicData": "https://opendpp-node.eu/ns/dpp#dynamicData",
      "stateOfHealth": "https://opendpp-node.eu/ns/dpp#stateOfHealth",
      "cycleCount": "https://opendpp-node.eu/ns/dpp#cycleCount",
      "restrictedData": "https://opendpp-node.eu/ns/dpp#restrictedData",
      "repurposedFrom": "https://opendpp-node.eu/ns/dpp#repurposedFrom",
      "successorUnits": "https://opendpp-node.eu/ns/dpp#successorUnits"
    }
  ],
  "@type": "BatteryUnit",
  "@id": "https://opendpp-node.eu/01/09501101530003/21/BATT-2026-000451",
  "id": "9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a",
  "serialNumber": "BATT-2026-000451",
  "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003/21/BATT-2026-000451",
  "status": "IN_SERVICE",
  "manufacturedAt": "2026-05-02T08:00:00.000Z",
  "repurposedFrom": null,
  "successorUnits": [],
  "ofModel": {
    "@context": [
      "https://opendpp-node.eu/contexts/dpp/v1",
      {
        "DigitalProductPassport": "https://opendpp-node.eu/ns/dpp#DigitalProductPassport",
        "economicOperator": "https://opendpp-node.eu/ns/dpp#economicOperator",
        "manufacturingFacility": "https://opendpp-node.eu/ns/dpp#manufacturingFacility",
        "metadata": "https://opendpp-node.eu/ns/dpp#metadata",
        "digitalSeal": "https://opendpp-node.eu/ns/dpp#digitalSeal",
        "signingPublicKey": "https://opendpp-node.eu/ns/dpp#signingPublicKey",
        "status": "https://opendpp-node.eu/ns/dpp#status",
        "archivedAt": "https://opendpp-node.eu/ns/dpp#archivedAt",
        "retentionUntil": "https://opendpp-node.eu/ns/dpp#retentionUntil",
        "category": "https://opendpp-node.eu/contexts/dpp/v1#category",
        "originCountry": "https://opendpp-node.eu/contexts/dpp/v1#originCountry"
      }
    ],
    "@type": "DigitalProductPassport",
    "@id": "https://opendpp-node.eu/01/09501101530003",
    "id": "4c8e1d2a-6b3f-4a9e-8d57-0f1e2a3b4c5d",
    "productId": "09501101530003",
    "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003",
    "digitalSeal": null,
    "signingPublicKey": null,
    "status": "ACTIVE",
    "archivedAt": null,
    "retentionUntil": null,
    "proof": null,
    "createdAt": "2026-05-01T10:00:00.000Z",
    "updatedAt": "2026-06-01T10:00:00.000Z",
    "economicOperator": {
      "@type": "EconomicOperator",
      "id": "operator-demo-opendpp",
      "name": "OpenDPP Demo Eco Industries (SAMPLE)",
      "regId": "EU-DEFAULT-001",
      "role": "Manufacturer"
    },
    "manufacturingFacility": null,
    "metadata": {
      "category": "batteries",
      "originCountry": "PT"
    },
    "category": "batteries",
    "originCountry": "PT"
  },
  "currentState": {
    "stateOfHealth": 97.4,
    "cycleCount": 132,
    "remainingCapacityAh": 48.7,
    "temperatureC": 23.1,
    "recordedAt": "2026-06-11T16:20:00.000Z"
  },
  "dynamicData": [
    {
      "@type": "BatteryUnitEvent",
      "eventType": "SOH_MEASUREMENT",
      "stateOfHealth": 97.4,
      "cycleCount": 132,
      "remainingCapacityAh": 48.7,
      "temperatureC": 23.1,
      "payload": null,
      "recordedAt": "2026-06-11T16:20:00.000Z"
    }
  ],
  "createdAt": "2026-06-12T09:41:00.000Z",
  "updatedAt": "2026-06-12T09:41:00.000Z"
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
DELETE /api/v1/units/{id} API key

Permanently delete a battery unit and its telemetry

HARD delete — permanently removes the unit row and cascades all of its BatteryUnitEvent telemetry. This is *not* a lifecycle/status transition: to record end-of-life semantics (decommissioned, waste, recycled — incl. the Art. 77(8) public 410 tombstone) append a telemetry event with a status instead (POST /api/v1/units/{id}/events). Deletion is intended for erroneous serialisations. A batteryunit.deleted audit event is written.

Permission: battery:write. Cookie-session clients must send X-CSRF-Token. Operator-scoped credentials may only delete units whose passport belongs to their Economic Operator (403). Write operations pass subscription gating (402) and optional tenant MFA enforcement (403).

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

Battery unit UUID (tenant-scoped).

Example

curl -X DELETE "https://opendpp-node.eu/api/v1/units/9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

The unit and its events were permanently deleted.

application/json BatteryUnitDeleteResponse
Example 200 response
{
  "success": true,
  "message": "Battery unit deleted."
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
GET /api/v1/units/{id}/events API key

List a battery unit's telemetry history (newest first, max 500)

Returns the unit's append-only dynamic-data history ordered by recordedAt DESC, capped at the 500 most recent events. There is no pagination — older events beyond the cap are not retrievable via this endpoint.

Permission: battery:read. Operator-scoped credentials may only read units whose passport belongs to their Economic Operator (403). Events are raw persisted rows (no Fastify response schema, nothing stripped).

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

Battery unit UUID (tenant-scoped).

Example

curl -X GET "https://opendpp-node.eu/api/v1/units/9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a/events" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

The unit's telemetry history. count equals events.length (≤ 500); serialNumber echoes the unit's physical serial.

Example 200 response
{
  "success": true,
  "count": 2,
  "serialNumber": "BATT-2026-000451",
  "events": [
    {
      "id": "7e5d3c1b-9a8f-4e6d-b2c4-1a0e9f8d7c6b",
      "batteryUnitId": "9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a",
      "tenantId": "tenant-demo-opendpp",
      "eventType": "SOH_MEASUREMENT",
      "stateOfHealth": 96.8,
      "cycleCount": 140,
      "remainingCapacityAh": 48.2,
      "temperatureC": 24.5,
      "payload": {
        "measuredBy": "BMS firmware 4.2.1"
      },
      "recordedAt": "2026-06-12T09:41:00.000Z",
      "createdAt": "2026-06-12T09:41:05.000Z"
    },
    {
      "id": "3a9c7e5d-1b2f-4c6a-8e0d-9f7b5a3c1d2e",
      "batteryUnitId": "9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a",
      "tenantId": "tenant-demo-opendpp",
      "eventType": "CHARGE_CYCLE",
      "stateOfHealth": null,
      "cycleCount": 139,
      "remainingCapacityAh": null,
      "temperatureC": null,
      "payload": null,
      "recordedAt": "2026-06-11T16:20:00.000Z",
      "createdAt": "2026-06-11T16:20:02.000Z"
    }
  ]
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
POST /api/v1/units/{id}/events API key

Append an immutable telemetry event to a battery unit

Appends one append-only per-unit dynamic-data record (Annex XIII / Art. 77: SoH, cycle count, remaining capacity, temperature, negative events). History is immutable — there is no update or delete path for events.

Permission: battery:write. Cookie-session clients must send X-CSRF-Token. Operator-scoped credentials may only write to units whose passport belongs to their Economic Operator (403). Write operations pass subscription gating (402) and optional tenant MFA enforcement (403).

Validation (400 with the standard error triple): eventType is required and must be one of SOH_MEASUREMENT|CHARGE_CYCLE|STATUS_CHANGE|NEGATIVE_EVENT|OTHER; stateOfHealth 0–100; cycleCount and remainingCapacityAh 0–9007199254740991; temperatureC −273.15–10000 (each may also be null/omitted); status, if present, must be a valid unit status; recordedAt must be Date-parseable (defaults to server time when omitted). cycleCount is truncated to an integer before persisting; a payload that is not an object or array is silently dropped (stored as null) — JSON arrays pass the server's typeof check and are persisted verbatim.

Status transition: when status is present and differs from the unit's current status, the unit is updated in the same transaction as the event — this works with *any* eventType, though STATUS_CHANGE is the conventional carrier. Transitioning to RECYCLED (Art. 77(8)) additionally stamps ceasedAt (if not already set; never cleared), after which the public unit view becomes a 410 tombstone and the unit can no longer gain successor units. status itself is not locked afterwards — a later event may still set a different value — but ceasedAt persists, so the public 410 and the predecessor refusal are permanent.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

Battery unit UUID (tenant-scoped).

Request body required

Example request
{
  "eventType": "SOH_MEASUREMENT",
  "stateOfHealth": 96.8,
  "cycleCount": 140,
  "remainingCapacityAh": 48.2,
  "temperatureC": 24.5,
  "payload": {
    "measuredBy": "BMS firmware 4.2.1"
  },
  "recordedAt": "2026-06-12T09:41:00.000Z"
}

Example

curl -X POST "https://opendpp-node.eu/api/v1/units/9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a/events" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "eventType": "SOH_MEASUREMENT",
    "stateOfHealth": 96.8,
    "cycleCount": 140,
    "remainingCapacityAh": 48.2,
    "temperatureC": 24.5,
    "payload": {
      "measuredBy": "BMS firmware 4.2.1"
    },
    "recordedAt": "2026-06-12T09:41:00.000Z"
  }'

Responses

201

Event appended (and, when status was supplied and differed, the unit's status transitioned in the same transaction).

Example 201 response
{
  "success": true,
  "message": "Dynamic data recorded",
  "event": {
    "id": "7e5d3c1b-9a8f-4e6d-b2c4-1a0e9f8d7c6b",
    "batteryUnitId": "9b2fa884-1f0c-4d6e-9a3b-2c7d85e41f6a",
    "tenantId": "tenant-demo-opendpp",
    "eventType": "SOH_MEASUREMENT",
    "stateOfHealth": 96.8,
    "cycleCount": 140,
    "remainingCapacityAh": 48.2,
    "temperatureC": 24.5,
    "payload": {
      "measuredBy": "BMS firmware 4.2.1"
    },
    "recordedAt": "2026-06-12T09:41:00.000Z",
    "createdAt": "2026-06-12T09:41:05.000Z"
  }
}
400

Two shapes: (1) the standard error triple from handler validation — messages: Request body must be a valid JSON object; eventType must be one of: SOH_MEASUREMENT, CHARGE_CYCLE, STATUS_CHANGE, NEGATIVE_EVENT, OTHER; <field> must be a number between <lo> and <hi> (stateOfHealth/cycleCount/remainingCapacityAh/temperatureC); status must be one of: IN_SERVICE, DECOMMISSIONED, RECALLED, REPURPOSED, REMANUFACTURED, REUSED, WASTE, RECYCLED; recordedAt is not a valid date. (2) A syntactically malformed JSON body is rejected by the framework before the handler runs, with Fastify's default {statusCode:400, error:"Bad Request", message} body.

application/json Error | FastifyDefaultBadRequest
Example 400 response
{
  "success": false,
  "error": "Bad Request",
  "message": "stateOfHealth must be a number between 0 and 100"
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

The transaction failed. Handler-built body: {success:false, error:"Internal Server Error", message:"Failed to record dynamic data"} (error logged server-side, never echoed).

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "Failed to record dynamic data"
}

Facilities

Manufacturing facility master data, identified by GS1 GLN-13 (the Unique Facility Identifier). GLN, name, activity and country are public in passport documents; street addresses are never published.

GET /api/v1/facilities API key

List facilities in the tenant workspace

Lists all facilities registered under your tenant workspace, sorted by createdAt descending. Paginated with ?page (default 1) and ?limit (default 100, max 200); count is this page's size, total/totalPages describe the full set. A non-numeric page/limit falls back to its default.

Permission: facility:read (Bearer API key or session JWT/cookie).

Operator-scoped keys: when authenticated with an API key scoped to an Economic Operator, the list contains only facilities whose operatorId equals the key's operator — facilities with no operator (operatorId: null) are excluded from the list (they remain readable individually via GET /api/v1/facilities/{id}).

The full row is returned to the owner, including the privileged address fields (streetAddress, city, postalCode) that public passport documents never expose (owner-only in JSON-LD; never emitted in AAS).

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
page query integer optional

1-based page number (digits only; non-numeric falls back to 1).

default 1 · min 1
limit query integer optional

Page size. Clamped to 1–200; non-numeric falls back to the default 100.

default 100 · min 1 · max 200

Example

curl -X GET "https://opendpp-node.eu/api/v1/facilities" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

Facility list.

application/json FacilityListEnvelope
Example 200 response
{
  "success": true,
  "count": 2,
  "facilities": [
    {
      "id": "9b2fa884-5b6e-4c0a-9f3d-2e7c1a8d4b61",
      "gln": "0950110153014",
      "name": "Munich Cell Assembly Plant",
      "activity": "Cell assembly",
      "streetAddress": "Werkstrasse 12",
      "city": "Munich",
      "postalCode": "80331",
      "country": "DE",
      "operatorId": "4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b",
      "tenantId": "7c3e9a12-4b5d-4f6e-8a9b-1c2d3e4f5a6b",
      "createdAt": "2026-06-12T09:41:00.000Z",
      "updatedAt": "2026-06-12T09:41:00.000Z"
    },
    {
      "id": "3d8c1f27-6a4b-4e9d-a2c8-5b7e9f0a1c3d",
      "gln": "0950110153021",
      "name": "Rotterdam Recycling Hub",
      "activity": "Recycling",
      "streetAddress": null,
      "city": null,
      "postalCode": null,
      "country": "NL",
      "operatorId": null,
      "tenantId": "7c3e9a12-4b5d-4f6e-8a9b-1c2d3e4f5a6b",
      "createdAt": "2026-06-10T14:05:12.000Z",
      "updatedAt": "2026-06-10T14:05:12.000Z"
    }
  ],
  "page": 1,
  "limit": 100,
  "total": 2,
  "totalPages": 1
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
POST /api/v1/facilities API key

Register a facility (GS1 GLN)

Registers a manufacturing/processing facility as tenant-scoped master data, backing the Unique Facility Identifier (UFI, EN 18219). Passports reference facilities via facilityId.

Permission: facility:write. Authenticate with a Bearer API key (op_dpp_token_…) or a session JWT; cookie-authenticated sessions must also send the X-CSRF-Token header (double-submit against the opendpp_csrf cookie) — Bearer clients are exempt. Write permissions are subscription-gated: a lapsed workspace subscription returns 402.

GLN validation: gln is trimmed, then must be exactly 13 digits with a valid GS1 modulo-10 check digit (same weighting algorithm as GTIN). The GLN is unique platform-wide (database unique constraint), so a duplicate returns 409 even if the existing facility belongs to another tenant.

Country: country must match ^[A-Za-z]{2}$ after trimming and is stored uppercased.

Operator binding: if operatorId is supplied (non-empty), that Economic Operator must be bound to your tenant workspace, otherwise 403. An empty/whitespace operatorId is stored as null. Requests authenticated with an operator-scoped API key may only attach facilities to their own operator: a mismatched operatorId returns 403, and when omitted the key's operator id is applied automatically.

activity, streetAddress, city and postalCode are trimmed; empty/whitespace values are stored as null.

Public/privileged field split: the public JSON-LD passport document exposes id, gln, name, activity and country of a linked facility; the public AAS export emits only the GLN, name and country (manufacturingFacilityGln / manufacturingFacilityName / manufacturingFacilityCountry, plus the GLN as a urn:gs1:gln: global asset reference) — the facility id and activity are never emitted in AAS. streetAddress, city and postalCode are owner-only in both formats. This endpoint returns the full row to you as the owner.

Emits a facility.created audit event and an in-app notification.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Request body required

application/json FacilityCreateRequest
Example request
{
  "gln": "0950110153014",
  "name": "Munich Cell Assembly Plant",
  "activity": "Cell assembly",
  "streetAddress": "Werkstrasse 12",
  "city": "Munich",
  "postalCode": "80331",
  "country": "DE",
  "operatorId": "4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b"
}

Example

curl -X POST "https://opendpp-node.eu/api/v1/facilities" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "gln": "0950110153014",
    "name": "Munich Cell Assembly Plant",
    "activity": "Cell assembly",
    "streetAddress": "Werkstrasse 12",
    "city": "Munich",
    "postalCode": "80331",
    "country": "DE",
    "operatorId": "4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b"
  }'

Responses

201

Facility registered.

application/json FacilityCreatedEnvelope
Example 201 response
{
  "success": true,
  "message": "Facility registered successfully",
  "facility": {
    "id": "9b2fa884-5b6e-4c0a-9f3d-2e7c1a8d4b61",
    "gln": "0950110153014",
    "name": "Munich Cell Assembly Plant",
    "activity": "Cell assembly",
    "streetAddress": "Werkstrasse 12",
    "city": "Munich",
    "postalCode": "80331",
    "country": "DE",
    "operatorId": "4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b",
    "tenantId": "7c3e9a12-4b5d-4f6e-8a9b-1c2d3e4f5a6b",
    "createdAt": "2026-06-12T09:41:00.000Z",
    "updatedAt": "2026-06-12T09:41:00.000Z"
  }
}
400

Invalid request body. Checked in order: body must be a JSON object; gln must be a 13-digit GS1 GLN with a valid mod-10 check digit; name must be a non-empty string; country must be a 2-letter ISO code. Note: a syntactically malformed JSON body is rejected earlier by the JSON parser with the default error shape ({"statusCode": 400, "error": "Bad Request", "message": <parse error>} — no code key), not this envelope; an empty body parses to {} and fails the gln check with this envelope.

application/json Error
Example 400 response
{
  "success": false,
  "error": "Bad Request",
  "message": "Request body must be a valid JSON object"
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Forbidden. Route-specific causes: an operator-scoped API key supplied an operatorId other than its own (Your access is restricted to Economic Operator: <id>); or the supplied operatorId is not bound to your tenant workspace. Auth-layer causes also land here: insufficient permission (facility:write), missing/invalid CSRF token on a cookie session, cross-tenant subdomain mismatch, or MFA required when enforced.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Your access is restricted to Economic Operator: 4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b"
}
409

A facility with this GLN is already registered. The GLN unique constraint is platform-wide, so the conflict can be caused by a facility owned by another tenant.

application/json Error
Example 409 response
{
  "success": false,
  "error": "Conflict",
  "message": "A facility with GLN 0950110153014 is already registered."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Internal error (standard envelope). This route's catch block returns message Failed to register facility for unexpected database errors; a failure inside the auth layer returns message Authentication verification failed.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "Failed to register facility"
}
GET /api/v1/facilities/{id} API key

Get a single facility

Fetches one facility by id, scoped to your tenant workspace.

Permission: facility:read.

Operator-scoped keys: if the facility belongs to a *different* Economic Operator than the key's scope, the response is 403. Facilities with no operator (operatorId: null) are readable by operator-scoped keys here, even though they are excluded from the list endpoint.

Returns the full row including the privileged address fields (streetAddress, city, postalCode) that public passport documents never expose. (Public exposure of a linked facility: the JSON-LD document shows id/gln/name/activity/country; the AAS export only the GLN, name and country.)

404 body: standard envelope with message Facility <id> not found under your Tenant workspace.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

Facility id (UUID, as returned at registration). Non-existent or other-tenant ids return 404.

Example

curl -X GET "https://opendpp-node.eu/api/v1/facilities/string" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

Facility found.

application/json FacilityEnvelope
Example 200 response
{
  "success": true,
  "facility": {
    "id": "9b2fa884-5b6e-4c0a-9f3d-2e7c1a8d4b61",
    "gln": "0950110153014",
    "name": "Munich Cell Assembly Plant",
    "activity": "Cell assembly",
    "streetAddress": "Werkstrasse 12",
    "city": "Munich",
    "postalCode": "80331",
    "country": "DE",
    "operatorId": "4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b",
    "tenantId": "7c3e9a12-4b5d-4f6e-8a9b-1c2d3e4f5a6b",
    "createdAt": "2026-06-12T09:41:00.000Z",
    "updatedAt": "2026-06-12T09:41:00.000Z"
  }
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
403

Forbidden. Route-specific cause: the request used an operator-scoped API key and the facility belongs to a different Economic Operator. Auth-layer causes (insufficient facility:read permission, cross-tenant subdomain mismatch) also land here.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Your access is restricted to Economic Operator: 4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b"
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
PUT /api/v1/facilities/{id} API key

Update facility master data (GLN is immutable)

Partially updates a facility's master data. The GLN itself is immutable — it is the resolvable UFI identifier; a gln key in the body is silently ignored (as is operatorId — the operator binding cannot be changed here).

Permission: facility:write. Cookie sessions must send X-CSRF-Token; write permissions are subscription-gated (402 when lapsed).

Field semantics (all optional): - name — applied only when a non-empty string; an empty/whitespace or non-string value is silently ignored (name can never be cleared). - activity, streetAddress, city, postalCode — applied whenever the key is *present* in the body: the value is stringified and trimmed; anything that trims to empty (null, "", or a whitespace-only string) clears the field to null — the same normalization as POST. - country — when present as a string it must match ^[A-Za-z]{2}$ (else 400) and is stored uppercased; a non-string value is silently ignored.

An empty body (or one with no recognized fields) is accepted: the response is 200 with the otherwise-unchanged row, though updatedAt is still bumped.

Operator-scoped keys: updating a facility that belongs to a different Economic Operator returns 403; facilities with operatorId: null are updatable.

Emits a facility.updated audit event recording the changed fields.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

Facility id (UUID, as returned at registration). Non-existent or other-tenant ids return 404.

Request body

application/json FacilityUpdateRequest
Example request
{
  "name": "Munich Cell Assembly Plant — Hall B",
  "activity": "Final manufacturing",
  "streetAddress": "Werkstrasse 14",
  "country": "DE"
}

Example

curl -X PUT "https://opendpp-node.eu/api/v1/facilities/string" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Munich Cell Assembly Plant — Hall B",
    "activity": "Final manufacturing",
    "streetAddress": "Werkstrasse 14",
    "country": "DE"
  }'

Responses

200

Updated facility (full row).

application/json FacilityEnvelope
Example 200 response
{
  "success": true,
  "facility": {
    "id": "9b2fa884-5b6e-4c0a-9f3d-2e7c1a8d4b61",
    "gln": "0950110153014",
    "name": "Munich Cell Assembly Plant — Hall B",
    "activity": "Final manufacturing",
    "streetAddress": "Werkstrasse 14",
    "city": "Munich",
    "postalCode": "80331",
    "country": "DE",
    "operatorId": "4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b",
    "tenantId": "7c3e9a12-4b5d-4f6e-8a9b-1c2d3e4f5a6b",
    "createdAt": "2026-06-12T09:41:00.000Z",
    "updatedAt": "2026-06-12T10:15:30.000Z"
  }
}
400

country was present as a string but is not a 2-letter ISO code. (This is the only body validation that errors; other invalid fields are silently ignored.) Note: a syntactically malformed JSON body is rejected earlier by Fastify's content-type parser with its default error shape ({"statusCode": 400, "code": "FST_ERR_CTP_…", "error": "Bad Request", "message": …}), not this envelope.

application/json Error
Example 400 response
{
  "success": false,
  "error": "Bad Request",
  "message": "country must be a 2-letter ISO country code"
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Forbidden. Route-specific cause: the request used an operator-scoped API key and the facility belongs to a different Economic Operator. Auth-layer causes (insufficient facility:write permission, missing/invalid CSRF token on a cookie session, cross-tenant mismatch, MFA when enforced) also land here.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Your access is restricted to Economic Operator: 4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b"
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
DELETE /api/v1/facilities/{id} API key

Delete a facility (passports are unlinked, never deleted)

Removes the facility master-data row. Passports are never deleted by this operation: Passport.facilityId is a SET NULL foreign key, so any passports referencing the facility simply lose their UFI link (facilityId becomes null) and remain fully intact and publicly resolvable.

Permission: facility:write. Cookie sessions must send X-CSRF-Token; write permissions are subscription-gated (402 when lapsed).

Operator-scoped keys: deleting a facility that belongs to a different Economic Operator returns 403; facilities with operatorId: null are deletable.

Emits a facility.deleted audit event and an in-app notification. 404 body: standard envelope with message Facility <id> not found under your Tenant workspace.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

Facility id (UUID, as returned at registration). Non-existent or other-tenant ids return 404.

Example

curl -X DELETE "https://opendpp-node.eu/api/v1/facilities/string" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

Facility deleted. Minimal envelope — no message, no facility.

application/json FacilityDeletedEnvelope
Example 200 response
{
  "success": true
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Forbidden. Route-specific cause: the request used an operator-scoped API key and the facility belongs to a different Economic Operator. Auth-layer causes (insufficient facility:write permission, missing/invalid CSRF token on a cookie session, cross-tenant mismatch, MFA when enforced) also land here.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Your access is restricted to Economic Operator: 4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b"
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}

Access Grants

Capability tokens implementing tiered access (Battery Regulation Art. 77(9) legitimate-interest access): issue, approve, deny and revoke dpp_li_… / dpp_auth_… tokens. Third parties request access via the hosted request page; granted tokens unlock restricted fields on the public resolution endpoints (Bearer or ?grant=).

GET /api/v1/grants API key

List access grants and pending access requests

Lists the workspace's access grants — capability-token grants for the Battery Regulation's restricted data tiers (Reg. (EU) 2023/1542 Art. 77(9), Annex XIII(2)–(4)) — including undecided third-party access requests (status: PENDING, issuerType: REQUEST) submitted via the hosted request-access page.

Permission: grant:read. Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Paginated with ?page (default 1) and ?limit (default 100, max 200), grouped by status ascending (alphabetical: ACTIVE, DENIED, PENDING, REVOKED) and newest-first within each group. AUTHORITY grants (platform-issued market-surveillance access) are listed for transparency but are not tenant-revocable (revocable: false). Raw capability tokens are never included — only issuance/approval responses contain them, once.

Pagination: results are paged with ?page (default 1) and ?limit (default 100, max 200). The response now also carries success, count, total and totalPages alongside grants.

Parameters

NameInTypeDescription
page query integer optional

1-based page number (digits only; non-numeric falls back to 1).

default 1 · min 1
limit query integer optional

Page size. Clamped to 1–200; non-numeric falls back to the default 100.

default 100 · min 1 · max 200

Example

curl -X GET "https://opendpp-node.eu/api/v1/grants" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

The workspace's grants and requests, paginated newest-first — a { success, count, page, limit, total, totalPages, grants } envelope. limit is clamped to its maximum and a non-numeric limit falls back to the default; there is no fixed 500-row cap.

application/json GrantListResponse
Example 200 response
{
  "grants": [
    {
      "id": "9b2fa884-3c1d-4e8a-9f27-5b06c8d41a72",
      "status": "ACTIVE",
      "kind": "LEGITIMATE_INTEREST",
      "granteeName": "Dr. Elena Varga",
      "granteeEmail": "e.varga@inspection-example.eu",
      "organization": "EU Battery Inspection Services",
      "purpose": "State-of-health verification for second-life suitability assessment under Art. 77(9).",
      "scopeType": "UNIT",
      "passportId": "4f1f7d2e-9a3b-4c5d-8e6f-7a8b9c0d1e2f",
      "batteryUnitId": "7c3a91d5-2e4f-4b6a-8c0d-1e2f3a4b5c6d",
      "issuerType": "TENANT",
      "issuerEmail": "admin@example.opendpp-node.eu",
      "decidedAt": null,
      "decidedBy": null,
      "expiresAt": "2026-09-12T09:41:00.000Z",
      "revokedAt": null,
      "lastUsedAt": "2026-06-12T10:02:14.000Z",
      "useCount": 3,
      "createdAt": "2026-06-12T09:41:00.000Z",
      "revocable": true
    },
    {
      "id": "c5d8e1f4-7a2b-4c9d-8e3f-6a1b2c3d4e5f",
      "status": "PENDING",
      "kind": "LEGITIMATE_INTEREST",
      "granteeName": "Marta Keller",
      "granteeEmail": "m.keller@recycler-example.eu",
      "organization": "Circular Battery Recycling GmbH",
      "purpose": "Assessing remaining capacity before acquisition for repurposing (Art. 77(9)).",
      "scopeType": "UNIT",
      "passportId": "4f1f7d2e-9a3b-4c5d-8e6f-7a8b9c0d1e2f",
      "batteryUnitId": "7c3a91d5-2e4f-4b6a-8c0d-1e2f3a4b5c6d",
      "issuerType": "REQUEST",
      "issuerEmail": null,
      "decidedAt": null,
      "decidedBy": null,
      "expiresAt": "2026-09-10T08:15:30.000Z",
      "revokedAt": null,
      "lastUsedAt": null,
      "useCount": 0,
      "createdAt": "2026-06-12T08:15:30.000Z",
      "revocable": true
    }
  ],
  "count": 2,
  "page": 1,
  "limit": 100,
  "total": 2,
  "totalPages": 1,
  "success": true
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
POST /api/v1/grants API key

Issue a legitimate-interest access grant directly

Directly issues an ACTIVE legitimate-interest access grant (no pending request involved) and mints its capability token. The raw token (dpp_li_ + 32 hex characters) is returned once in this response; only its SHA-256 hash is stored. The grantee presents it to the public resolution endpoints as Authorization: Bearer dpp_li_… or ?grant=dpp_li_… to unlock the restricted (tier-2 / per-unit) data of the granted scope.

Permission: grant:write (write operations are subject to subscription gating, so 402 is possible). Cookie-session clients must send the X-CSRF-Token header; Bearer clients are exempt. On workspaces that enforce multi-factor authentication, user sessions that did not authenticate with a second factor receive 403 on writes (API-key clients are exempt). Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Scope semantics: - UNITbatteryUnitId is required; the unit must belong to this workspace. The unit's parent passportId is recorded on the grant. - PASSPORTpassportId is required; the passport must belong to this workspace and must not be a DRAFT (drafts return 404). - TENANT — workspace-wide; no target id needed.

expiresAt is required, must be in the future, and at most 366 days out. This endpoint always mints kind: LEGITIMATE_INTERESTAUTHORITY (dpp_auth_…) grants are platform-issued only and cannot be created here. The issuance is audited as grant.issued.

String fields longer than their documented maximum are silently truncated, not rejected; unknown fields are ignored.

Request body required

application/json CreateGrantRequest
Example request
{
  "granteeName": "Dr. Elena Varga",
  "granteeEmail": "e.varga@inspection-example.eu",
  "organization": "EU Battery Inspection Services",
  "purpose": "State-of-health verification for second-life suitability assessment under Art. 77(9).",
  "scopeType": "UNIT",
  "batteryUnitId": "7c3a91d5-2e4f-4b6a-8c0d-1e2f3a4b5c6d",
  "expiresAt": "2026-09-12T09:41:00.000Z"
}

Example

curl -X POST "https://opendpp-node.eu/api/v1/grants" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "granteeName": "Dr. Elena Varga",
    "granteeEmail": "e.varga@inspection-example.eu",
    "organization": "EU Battery Inspection Services",
    "purpose": "State-of-health verification for second-life suitability assessment under Art. 77(9).",
    "scopeType": "UNIT",
    "batteryUnitId": "7c3a91d5-2e4f-4b6a-8c0d-1e2f3a4b5c6d",
    "expiresAt": "2026-09-12T09:41:00.000Z"
  }'

Responses

201

Grant issued. token is the raw capability token — shown only here, store it now.

application/json GrantIssuedResponse
Example 201 response
{
  "success": true,
  "grant": {
    "id": "9b2fa884-3c1d-4e8a-9f27-5b06c8d41a72",
    "status": "ACTIVE",
    "kind": "LEGITIMATE_INTEREST",
    "granteeName": "Dr. Elena Varga",
    "granteeEmail": "e.varga@inspection-example.eu",
    "organization": "EU Battery Inspection Services",
    "purpose": "State-of-health verification for second-life suitability assessment under Art. 77(9).",
    "scopeType": "UNIT",
    "passportId": "4f1f7d2e-9a3b-4c5d-8e6f-7a8b9c0d1e2f",
    "batteryUnitId": "7c3a91d5-2e4f-4b6a-8c0d-1e2f3a4b5c6d",
    "issuerType": "TENANT",
    "issuerEmail": "admin@example.opendpp-node.eu",
    "decidedAt": null,
    "decidedBy": null,
    "expiresAt": "2026-09-12T09:41:00.000Z",
    "revokedAt": null,
    "lastUsedAt": null,
    "useCount": 0,
    "createdAt": "2026-06-12T09:41:00.000Z",
    "revocable": true
  },
  "token": "dpp_li_00000000000000000000000000000001"
}
400

Validation failure. Body omits the success field. message is one of: granteeName is required, granteeEmail is invalid, scopeType must be UNIT, PASSPORT or TENANT, expiresAt is required, expiresAt must be an ISO-8601 date, expiresAt must be in the future, expiresAt must be within 366 days.

application/json GrantRouteError
Example 400 response
{
  "error": "Bad Request",
  "message": "expiresAt must be within 366 days"
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

For scopeType UNIT/PASSPORT: the target id does not exist in this workspace (cross-tenant targets and DRAFT passports are indistinguishable from missing ones). Omitting the target id required by the scopeType also returns this 404 — there is no separate 400 for a missing target id. Body omits the success field.

application/json GrantRouteError
Example 404 response
{
  "error": "Not Found",
  "message": "Scope target not found in this workspace"
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
POST /api/v1/grants/{id}/approve API key

Approve a pending access request and mint its token

Approves a PENDING third-party access request (submitted via the hosted request-access page). Approval mints the legitimate-interest capability token at this moment — pending requests carry no token — sets status: ACTIVE, records decidedAt/decidedBy, and replaces the request's provisional 90-day expiry with the expiresAt you supply (required; future; max 366 days out).

The raw token is returned once in this response. If the request has a granteeEmail, the grantee is additionally e-mailed an inspection link containing the token (…/unit/{batteryUnitId}?grant=dpp_li_… or …/passport/{passportId}?grant=dpp_li_…) — the only other place the raw token ever exists. The decision is audited as grant.approved.

Permission: grant:write (subscription gating ⇒ 402 possible; cookie sessions need X-CSRF-Token; on workspaces enforcing multi-factor authentication, user sessions without a second factor get 403 — API-key clients exempt). Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

The access-request (AccessGrant) id.

Request body required

application/json ApproveGrantRequest
Example request
{
  "expiresAt": "2026-09-12T09:41:00.000Z"
}

Example

curl -X POST "https://opendpp-node.eu/api/v1/grants/string/approve" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "expiresAt": "2026-09-12T09:41:00.000Z"
  }'

Responses

200

Request approved; the capability token is shown only here (and in the grantee e-mail).

application/json GrantIssuedResponse
Example 200 response
{
  "success": true,
  "grant": {
    "id": "c5d8e1f4-7a2b-4c9d-8e3f-6a1b2c3d4e5f",
    "status": "ACTIVE",
    "kind": "LEGITIMATE_INTEREST",
    "granteeName": "Marta Keller",
    "granteeEmail": "m.keller@recycler-example.eu",
    "organization": "Circular Battery Recycling GmbH",
    "purpose": "Assessing remaining capacity before acquisition for repurposing (Art. 77(9)).",
    "scopeType": "UNIT",
    "passportId": "4f1f7d2e-9a3b-4c5d-8e6f-7a8b9c0d1e2f",
    "batteryUnitId": "7c3a91d5-2e4f-4b6a-8c0d-1e2f3a4b5c6d",
    "issuerType": "REQUEST",
    "issuerEmail": null,
    "decidedAt": "2026-06-12T09:41:00.000Z",
    "decidedBy": "admin@example.opendpp-node.eu",
    "expiresAt": "2026-09-12T09:41:00.000Z",
    "revokedAt": null,
    "lastUsedAt": null,
    "useCount": 0,
    "createdAt": "2026-06-12T08:15:30.000Z",
    "revocable": true
  },
  "token": "dpp_li_00000000000000000000000000000002"
}
400

Invalid expiresAt. Body omits the success field. message is one of: expiresAt is required, expiresAt must be an ISO-8601 date, expiresAt must be in the future, expiresAt must be within 366 days.

application/json GrantRouteError
Example 400 response
{
  "error": "Bad Request",
  "message": "expiresAt is required"
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

No grant with this id exists in this workspace. Body omits the success field.

application/json GrantRouteError
Example 404 response
{
  "error": "Not Found",
  "message": "Grant not found"
}
409

The grant is not PENDING (already decided, active, or revoked). Body omits the success field; the current status is interpolated into the message.

application/json GrantRouteError
Example 409 response
{
  "error": "Conflict",
  "message": "Only PENDING requests can be approved (status: ACTIVE)"
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
POST /api/v1/grants/{id}/deny API key

Deny a pending access request

Denies a PENDING third-party access request: sets status: DENIED and records decidedAt/decidedBy. No token is ever minted for a denied request, and no e-mail is sent to the requester. The decision is audited as grant.denied. The request body, if any, is ignored.

Permission: grant:write (subscription gating ⇒ 402 possible; cookie sessions need X-CSRF-Token; on workspaces enforcing multi-factor authentication, user sessions without a second factor get 403 — API-key clients exempt). Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

The access-request (AccessGrant) id.

Example

curl -X POST "https://opendpp-node.eu/api/v1/grants/string/deny" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

Request denied.

application/json GrantDecisionResponse
Example 200 response
{
  "success": true,
  "grant": {
    "id": "c5d8e1f4-7a2b-4c9d-8e3f-6a1b2c3d4e5f",
    "status": "DENIED",
    "kind": "LEGITIMATE_INTEREST",
    "granteeName": "Marta Keller",
    "granteeEmail": "m.keller@recycler-example.eu",
    "organization": "Circular Battery Recycling GmbH",
    "purpose": "Assessing remaining capacity before acquisition for repurposing (Art. 77(9)).",
    "scopeType": "UNIT",
    "passportId": "4f1f7d2e-9a3b-4c5d-8e6f-7a8b9c0d1e2f",
    "batteryUnitId": "7c3a91d5-2e4f-4b6a-8c0d-1e2f3a4b5c6d",
    "issuerType": "REQUEST",
    "issuerEmail": null,
    "decidedAt": "2026-06-12T09:41:00.000Z",
    "decidedBy": "admin@example.opendpp-node.eu",
    "expiresAt": "2026-09-10T08:15:30.000Z",
    "revokedAt": null,
    "lastUsedAt": null,
    "useCount": 0,
    "createdAt": "2026-06-12T08:15:30.000Z",
    "revocable": true
  }
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

No grant with this id exists in this workspace. Body omits the success field.

application/json GrantRouteError
Example 404 response
{
  "error": "Not Found",
  "message": "Grant not found"
}
409

The grant is not PENDING (already decided, active, or revoked). Body omits the success field; the current status is interpolated into the message.

application/json GrantRouteError
Example 409 response
{
  "error": "Conflict",
  "message": "Only PENDING requests can be denied (status: DENIED)"
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
DELETE /api/v1/grants/{id} API key

Revoke an access grant (soft revocation)

Soft-revokes a grant: sets status: REVOKED and revokedAt (the row is retained for audit; the public resolvers reject the token from then on). Audited as grant.revoked.

Behavioral caveats (no status precondition — only the kind is checked): - Works on a grant in any status: revoking a PENDING request withdraws it; revoking a DENIED grant flips it to REVOKED. - Re-revoking an already-REVOKED grant returns 200 again and preserves the original revokedAt. - AUTHORITY grants (kind: AUTHORITY, platform-issued market-surveillance access) are not tenant-revocable — 403. Battery Reg. Art. 77 market-surveillance access must not depend on manufacturer consent; platform admins manage those.

Permission: grant:write (subscription gating ⇒ 402 possible; cookie sessions need X-CSRF-Token; on workspaces enforcing multi-factor authentication, user sessions without a second factor get 403 — API-key clients exempt). Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

The grant (AccessGrant) id.

Example

curl -X DELETE "https://opendpp-node.eu/api/v1/grants/string" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

Grant revoked (idempotent: re-revoking keeps the original revokedAt).

application/json GrantDecisionResponse
Example 200 response
{
  "success": true,
  "grant": {
    "id": "9b2fa884-3c1d-4e8a-9f27-5b06c8d41a72",
    "status": "REVOKED",
    "kind": "LEGITIMATE_INTEREST",
    "granteeName": "Dr. Elena Varga",
    "granteeEmail": "e.varga@inspection-example.eu",
    "organization": "EU Battery Inspection Services",
    "purpose": "State-of-health verification for second-life suitability assessment under Art. 77(9).",
    "scopeType": "UNIT",
    "passportId": "4f1f7d2e-9a3b-4c5d-8e6f-7a8b9c0d1e2f",
    "batteryUnitId": "7c3a91d5-2e4f-4b6a-8c0d-1e2f3a4b5c6d",
    "issuerType": "TENANT",
    "issuerEmail": "admin@example.opendpp-node.eu",
    "decidedAt": null,
    "decidedBy": null,
    "expiresAt": "2026-09-12T09:41:00.000Z",
    "revokedAt": "2026-06-12T11:30:00.000Z",
    "lastUsedAt": "2026-06-12T10:02:14.000Z",
    "useCount": 3,
    "createdAt": "2026-06-12T09:41:00.000Z",
    "revocable": true
  }
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Two distinct bodies share this status: (1) route-level — the grant is an AUTHORITY grant and cannot be revoked by the workspace; body is {error, message} without a success field (see example); (2) middleware — insufficient permission, missing/invalid CSRF token, cross-tenant access, or MFA required; standard envelope {success: false, error, message}.

application/json GrantRouteError | Error
Example 403 response
{
  "error": "Forbidden",
  "message": "Authority grants are platform-managed: market-surveillance access cannot be revoked by the workspace."
}
404

No grant with this id exists in this workspace. Body omits the success field.

application/json GrantRouteError
Example 404 response
{
  "error": "Not Found",
  "message": "Grant not found"
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}

Webhooks

Subscribe HTTPS endpoints to passport lifecycle events. Deliveries are HMAC-SHA256-signed POSTs with retry/backoff — see the webhooks section of this document for the signature scheme and payloads.

GET /api/v1/webhooks/subscriptions API key

List webhook subscriptions (signing secrets stripped)

Lists all webhook subscriptions of the calling workspace. Unpaginated (the per-workspace cap is 25).

Permission: webhook:read (read permissions are not subscription-gated, so no 402).

The HMAC signing secret is stripped from every row — it is returned once by the create endpoint and once by rotate-secret. isActive reflects whether the subscription receives deliveries; toggle it with PATCH /api/v1/webhooks/subscriptions/{id}. Global rate limit 100 requests/min/IP.

Example

curl -X GET "https://opendpp-node.eu/api/v1/webhooks/subscriptions" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

All subscriptions of the workspace, secrets removed.

Example 200 response
{
  "success": true,
  "subscriptions": [
    {
      "id": "5e8d2c47-9a1b-4f63-8c0d-7b4e2f9a6d35",
      "tenantId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
      "url": "https://erp.example.com/hooks/opendpp",
      "events": [
        "passport.ingested",
        "passport.sealed"
      ],
      "isActive": true,
      "createdAt": "2026-06-12T09:41:00.000Z",
      "updatedAt": "2026-06-12T09:41:00.000Z"
    },
    {
      "id": "0c7b1e92-4d5a-4b38-a6f1-83e9d27c50b4",
      "tenantId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
      "url": "https://plm.example.com/integrations/opendpp/events",
      "events": [
        "*"
      ],
      "isActive": true,
      "createdAt": "2026-05-30T14:02:11.000Z",
      "updatedAt": "2026-05-30T14:02:11.000Z"
    }
  ]
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Auth-layer failures return the standard {success: false, error, message} envelope (message "Authentication verification failed"); an unhandled database error in the handler itself instead returns a framework-default {statusCode, error, message} body without success.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "Authentication verification failed"
}
POST /api/v1/webhooks/subscriptions API key

Register a webhook subscription (signing secret returned once)

Registers an endpoint to receive passport lifecycle webhooks for the calling workspace.

Permission: webhook:write. Cookie-session callers must send the X-CSRF-Token header (double-submit); Bearer API-key/JWT clients are exempt. Write permissions are additionally gated on an active workspace subscription (402).

URL validation (SSRF guard): url must be an absolute http(s) URL. At registration the hostname is DNS-resolved and the request is rejected with 400 if any resolved A/AAAA record is loopback, RFC 1918/CGNAT private, link-local / cloud-metadata (169.254.0.0/16), multicast, or an equivalent IPv6 range. At delivery time the socket is pinned to the validated IP and redirects are never followed.

Event filters: events must be a non-empty array drawn from passport.ingested, passport.updated, passport.sealed, passport.recalled, passport.status_updated, *. The * wildcard matches every emitted event.

Signing secret — shown once: the 201 response contains the full subscription row including the HMAC-SHA256 signing secret (whsec_ + 32 lowercase hex chars, server-generated, never client-supplied). This is the only time the secret is ever returned: the list endpoint strips it and there is no rotation or update endpoint — delete and re-create to rotate.

Limits: maximum 25 subscriptions per workspace (409 Conflict). Global rate limit 100 requests/min/IP (429 with x-ratelimit-* headers). Unknown request-body fields are ignored.

Request body required

Example request
{
  "url": "https://erp.example.com/hooks/opendpp",
  "events": [
    "passport.ingested",
    "passport.sealed"
  ]
}

Example

curl -X POST "https://opendpp-node.eu/api/v1/webhooks/subscriptions" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://erp.example.com/hooks/opendpp",
    "events": [
      "passport.ingested",
      "passport.sealed"
    ]
  }'

Responses

201

Subscription registered. subscription is the full row including secret — store it now; it is never returned again.

Example 201 response
{
  "success": true,
  "message": "Webhook subscription registered successfully",
  "subscription": {
    "id": "5e8d2c47-9a1b-4f63-8c0d-7b4e2f9a6d35",
    "tenantId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "url": "https://erp.example.com/hooks/opendpp",
    "secret": "whsec_00000000000000000000000000000000",
    "events": [
      "passport.ingested",
      "passport.sealed"
    ],
    "isActive": true,
    "createdAt": "2026-06-12T09:41:00.000Z",
    "updatedAt": "2026-06-12T09:41:00.000Z"
  }
}
400

Validation failure. Exact messages: "url must be a valid string URL" (missing, empty, or non-string url); "Outbound webhook URL rejected: <reason>" (SSRF guard — covers a malformed URL string (reason Invalid URL format), a non-http(s) scheme, an unresolvable host, or a private/loopback/metadata address); "events must be a non-empty array of strings"; "event '<event>' is invalid. Allowed events: passport.ingested, passport.updated, passport.sealed, passport.recalled, passport.status_updated, *". (A malformed JSON body instead yields a framework-default {statusCode, error, message} 400 body.)

application/json Error
Example 400 response
{
  "success": false,
  "error": "Bad Request",
  "message": "event 'passport.deleted' is invalid. Allowed events: passport.ingested, passport.updated, passport.sealed, passport.recalled, passport.status_updated, *"
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
409

The workspace already has 25 webhook subscriptions (the per-tenant cap). Delete an existing subscription first.

application/json Error
Example 409 response
{
  "success": false,
  "error": "Conflict",
  "message": "Maximum of 25 webhook subscriptions per workspace reached."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Persistence failed (details logged server-side) — message "Failed to register webhook subscription.". Auth-layer failures use the message "Authentication verification failed". (A failure of the pre-create subscription-count query instead returns a framework-default {statusCode, error, message} body without success.)

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "Failed to register webhook subscription."
}
PATCH /api/v1/webhooks/subscriptions/{id} API key

Update a webhook subscription (url / events / active)

Partially updates a subscription: any of url, events, isActive (key present = set, omitted = unchanged). A new url is re-validated by the DNS-resolving SSRF guard (same rules as creation); events must be a non-empty subset of the allowed filters. The signing secret is not editable here — use rotate-secret. An empty body returns the current subscription unchanged. The response strips the secret.

Permission: webhook:write. Cookie sessions must send X-CSRF-Token; write permissions are subscription-gated (402 when lapsed).

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

Webhook subscription UUID (as returned at creation / by the list endpoint).

Request body

Example request
{
  "events": [
    "passport.sealed",
    "passport.recalled"
  ],
  "isActive": false
}

Example

curl -X PATCH "https://opendpp-node.eu/api/v1/webhooks/subscriptions/string" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      "passport.sealed",
      "passport.recalled"
    ],
    "isActive": false
  }'

Responses

200

Updated subscription (secret stripped).

Example 200 response
{
  "success": true,
  "message": "Webhook subscription updated",
  "subscription": {
    "id": "9b2fa884-5b6e-4c0a-9f3d-2e7c1a8d4b61",
    "tenantId": "7c3e9a12-4b5d-4f6e-8a9b-1c2d3e4f5a6b",
    "url": "https://erp.example.com/hooks/opendpp",
    "events": [
      "passport.sealed",
      "passport.recalled"
    ],
    "isActive": false,
    "createdAt": "2026-06-12T09:41:00.000Z",
    "updatedAt": "2026-06-12T10:05:00.000Z"
  }
}
400

Invalid url (not a string / SSRF-rejected), empty/invalid events, or non-boolean isActive.

application/json Error
Example 400 response
{
  "success": false,
  "error": "Bad Request",
  "message": "events must be a non-empty array of strings"
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
DELETE /api/v1/webhooks/subscriptions/{id} API key

Delete a webhook subscription

Deletes a webhook subscription, stopping future deliveries to its endpoint.

Permission: webhook:write (cookie sessions must send X-CSRF-Token; write permissions are subscription-gated, 402).

The lookup is tenant-scoped: an id that exists but belongs to another workspace returns the same 404 with message "Webhook subscription not found under your tenant". Deleting and re-creating is the only way to rotate a signing secret. Global rate limit 100 requests/min/IP.

Parameters

NameInTypeDescription
id path string required

Webhook subscription UUID (as returned at creation / by the list endpoint).

Example

curl -X DELETE "https://opendpp-node.eu/api/v1/webhooks/subscriptions/string" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

Subscription deleted.

Example 200 response
{
  "success": true,
  "message": "Webhook subscription successfully deleted"
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Auth-layer failures return the standard {success: false, error, message} envelope (message "Authentication verification failed"); an unhandled database error in the handler itself instead returns a framework-default {statusCode, error, message} body without success.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "Authentication verification failed"
}
POST /api/v1/webhooks/subscriptions/{id}/rotate-secret API key

Rotate a webhook subscription's signing secret

Mints a fresh HMAC-SHA256 signing secret for the subscription and returns it once (the old secret stops validating immediately). Use this after a suspected secret leak, or on a rotation schedule. There is no request body.

Update your receiver to verify signatures with the new secret as soon as you rotate — deliveries in flight use whichever secret was current when signed.

Permission: webhook:write. Cookie sessions must send X-CSRF-Token; write permissions are subscription-gated (402 when lapsed).

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

Webhook subscription UUID (as returned at creation / by the list endpoint).

Example

curl -X POST "https://opendpp-node.eu/api/v1/webhooks/subscriptions/string/rotate-secret" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

Rotated. The full row INCLUDING the new secret is returned — the only time it is shown after rotation.

Example 200 response
{
  "success": true,
  "message": "Webhook signing secret rotated. Store the new secret now — it is shown only once.",
  "subscription": {
    "id": "9b2fa884-5b6e-4c0a-9f3d-2e7c1a8d4b61",
    "tenantId": "7c3e9a12-4b5d-4f6e-8a9b-1c2d3e4f5a6b",
    "url": "https://erp.example.com/hooks/opendpp",
    "events": [
      "passport.sealed"
    ],
    "secret": "whsec_00000000000000000000000000000000",
    "isActive": true,
    "createdAt": "2026-06-12T09:41:00.000Z",
    "updatedAt": "2026-06-12T10:06:00.000Z"
  }
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
GET /api/v1/webhooks/deliveries API key

List recent webhook delivery attempts (the outbox)

Returns recent delivery records (the outbox), newest first, for debugging endpoint failures. Records are event-level (one per emitted event, fanned out to all matching subscriptions), not per-subscription — status reflects the event's overall delivery state and errorMessage joins per-endpoint errors. Payloads are not included.

Filter with ?status=PENDING|DELIVERED|FAILED and cap with ?limit= (1–200, default 50; a non-numeric value falls back to the default).

Permission: webhook:read.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
status query enum optional

Filter by delivery state. Unrecognized values are ignored (no filter applied).

one of: PENDING, DELIVERED, FAILED
limit query integer optional

Max records to return.

default 50 · min 1 · max 200

Example

curl -X GET "https://opendpp-node.eu/api/v1/webhooks/deliveries" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

Recent delivery records, newest first.

application/json WebhookDeliveriesResponse
Example 200 response
{
  "success": true,
  "count": 2,
  "deliveries": [
    {
      "id": "1a2b3c4d-0001-4a3c-9d6a-1e0a4f3a7b21",
      "event": "passport.sealed",
      "status": "DELIVERED",
      "retryCount": 0,
      "lastAttempt": "2026-06-12T09:42:00.000Z",
      "nextRetryAt": null,
      "errorMessage": null,
      "createdAt": "2026-06-12T09:41:59.000Z"
    },
    {
      "id": "1a2b3c4d-0002-4a3c-9d6a-1e0a4f3a7b21",
      "event": "passport.ingested",
      "status": "FAILED",
      "retryCount": 2,
      "lastAttempt": "2026-06-12T09:50:00.000Z",
      "nextRetryAt": "2026-06-12T10:20:00.000Z",
      "errorMessage": "https://erp.example.com/hooks/opendpp: HTTP 503",
      "createdAt": "2026-06-12T09:41:00.000Z"
    }
  ]
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
POST /api/v1/webhooks/subscriptions/{id}/test API key

Send a signed test event to a subscription

Delivers a single signed sample event to the subscription's URL right now and reports the outcome — use it to confirm your endpoint is reachable and that your signature verification works, without waiting for a real passport event. The payload is a representative public JSON-LD passport document marked _test: true; it is signed exactly like a production delivery (HMAC-SHA256 over ${timestamp}.${body}). The event type is a concrete value from the subscription's filter (the * wildcard is skipped; defaults to passport.sealed).

Permission: webhook:write. Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

Webhook subscription UUID (as returned at creation / by the list endpoint).

Example

curl -X POST "https://opendpp-node.eu/api/v1/webhooks/subscriptions/string/test" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

The test delivery was attempted (check delivered/statusCode for the receiver's response).

application/json WebhookTestResult
Example 200 response
{
  "success": true,
  "event": "passport.sealed",
  "url": "https://erp.example.com/hooks/opendpp",
  "delivered": true,
  "statusCode": 200,
  "error": null
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}

Traceability & Audit

UNTP/EPCIS supply-chain traceability events, lineage queries, and the public seal verifier. The verifier checks the cryptographic seal AND that the signing workspace is bound to the economic operator declared in the payload.

POST /api/v1/events API key

Register a UNTP/EPCIS 2.0 traceability event (VC-shaped)

Registers a supply-chain traceability event carried as a VC-shaped UNTP credential and persists it as an EPCIS 2.0 event row scoped to your tenant.

Permission: passport:update (write operation — subscription gating applies, see 402). When the node operator enforces MFA, writes from user-backed sessions (cookie or Bearer JWT) whose MFA policy requires a second factor (user policy REQUIRED, or DEFAULT with the workspace's MFA-by-default setting, which is on by default) receive 403 without one; API-key clients are exempt. Cookie-session clients must send the X-CSRF-Token header (double-submit with the opendpp_csrf cookie); Bearer JWT / API-key clients are exempt.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Validation pipeline (in order): 1. *Structural* — the body must be an object containing credentialSubject, otherwise 400 Bad Request. 2. *EPCIS rule* — action is strictly forbidden on TransformationEvent (any non-null value → 400 Schema Validation Error). 3. *Cryptographic* — the credential's proof MUST be a conformant W3C DataIntegrityProof with cryptosuite: "ecdsa-jcs-2019" and a multibase base58btc (z…) proofValue; any other proof shape (e.g. the legacy key-sorted MerkleTreeAttestationProof) is rejected. The ECDSA P-256 signature is verified per that cryptosuite over sha256(JCS(proof options)) ‖ sha256(JCS(credential without proof)) — RFC 8785 JCS canonicalization, IEEE-P1363 raw r‖s — a conformant, interoperable Data Integrity suite, which is what makes the persisted isUntpCompliant: true honest. The verification key is resolved in trust order: (a) an embedded proof.verificationMethod.x5c chain, accepted ONLY when the node has eIDAS trust anchors configured, the chain validates against them, every certificate is currently valid, and the leaf attests the issuer; (b) ALL of the authoritative vault keys (current + retired, so a pre-rotation credential still verifies) of the tenant whose UNIQUE subdomain EXACTLY equals the trailing :-segment of the issuer DID. If no key resolves or the signature does not verify → 400 Cryptographic Verification Failed. 4. *Operator scoping* — if your API key is scoped to an Economic Operator, the credential's declared operator DID — the issuer DID, or credentialSubject.responsibleOperatorDid only when issuer is absent — must contain the bound operator's registration id (e.g. EU-DEFAULT-001), otherwise 403 with message: "Your access is restricted to Economic Operator: <operatorId> (<regId>)".

Persistence: the stored event id is ALWAYS server-generated (UUID) — the credential's own id is never used as the primary key (prevents cross-tenant id squatting); the issuer DID is retained as issuerDid. Defaults applied on write: bizStepurn:epcglobal:cbv:bizstep:receiving; dispositionurn:epcglobal:cbv:disp:in_progress; readPointgeo:<latitude>,<longitude> derived from credentialSubject.originLocation when present; bizLocationresponsibleOperatorDid; eventTimeissuanceDate, else the server clock; epcList[credentialSubject.id] when not supplied as an array (or []). The row is stored with isUntpCompliant: true and the proof.proofValue retained.

Caveats: credentialSubject.eventType must be one of the documented event-type values and action (when present) one of ADD/OBSERVE/DELETE — both map to server-side enums, and a missing or unknown value is only rejected at the persistence layer and surfaces as the 500 Database Persistence Failed body, not as a 400. Note the 201 envelope is {status: "success", ...}, NOT the usual {success: true, ...} shape. This endpoint does not create lineage edges between events; the lineage DAG read by GET /api/v1/events/{id}/lineage is built from lineage relations maintained separately on the node.

Request body required

application/json UntpEventCredential
Example request
{
  "@context": [
    "https://www.w3.org/ns/credentials/v2",
    "https://vocabulary.uncefact.org/untp/dpp/"
  ],
  "id": "urn:uuid:0e7a2c1c-6f4e-4a08-9d2e-3b1f5a7c9d10",
  "type": [
    "VerifiableCredential",
    "DigitalTraceabilityEvent"
  ],
  "issuer": "did:web:opendpp-node.eu:EU-DEFAULT-001:demo",
  "issuanceDate": "2026-06-12T09:41:00.000Z",
  "credentialSubject": {
    "id": "urn:epc:id:sgtin:0950110153.0003.SN-2026-000123",
    "eventType": "ObjectEvent",
    "action": "OBSERVE",
    "bizStep": "urn:epcglobal:cbv:bizstep:shipping",
    "disposition": "urn:epcglobal:cbv:disp:in_transit",
    "readPoint": "geo:41.1496,-8.6109",
    "bizLocation": "urn:epc:id:sgln:0950110153000..0",
    "eventTime": "2026-06-12T08:30:00.000Z",
    "epcList": [
      "urn:epc:id:sgtin:0950110153.0003.SN-2026-000123"
    ],
    "responsibleOperatorDid": "did:web:opendpp-node.eu:EU-DEFAULT-001:demo"
  },
  "proof": {
    "type": "DataIntegrityProof",
    "cryptosuite": "ecdsa-jcs-2019",
    "created": "2026-06-12T09:41:00.000Z",
    "proofPurpose": "assertionMethod",
    "verificationMethod": "did:web:opendpp-node.eu:EU-DEFAULT-001:demo#key-1",
    "proofValue": "z4oey5q2M3XKaxup3tmzN4DRFTLVqpLMweBrSxMY2xEQLExampleEcdsaJcs2019MultibaseBase58btcProofValue"
  }
}

Example

curl -X POST "https://opendpp-node.eu/api/v1/events" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "@context": [
      "https://www.w3.org/ns/credentials/v2",
      "https://vocabulary.uncefact.org/untp/dpp/"
    ],
    "id": "urn:uuid:0e7a2c1c-6f4e-4a08-9d2e-3b1f5a7c9d10",
    "type": [
      "VerifiableCredential",
      "DigitalTraceabilityEvent"
    ],
    "issuer": "did:web:opendpp-node.eu:EU-DEFAULT-001:demo",
    "issuanceDate": "2026-06-12T09:41:00.000Z",
    "credentialSubject": {
      "id": "urn:epc:id:sgtin:0950110153.0003.SN-2026-000123",
      "eventType": "ObjectEvent",
      "action": "OBSERVE",
      "bizStep": "urn:epcglobal:cbv:bizstep:shipping",
      "disposition": "urn:epcglobal:cbv:disp:in_transit",
      "readPoint": "geo:41.1496,-8.6109",
      "bizLocation": "urn:epc:id:sgln:0950110153000..0",
      "eventTime": "2026-06-12T08:30:00.000Z",
      "epcList": [
        "urn:epc:id:sgtin:0950110153.0003.SN-2026-000123"
      ],
      "responsibleOperatorDid": "did:web:opendpp-node.eu:EU-DEFAULT-001:demo"
    },
    "proof": {
      "type": "DataIntegrityProof",
      "cryptosuite": "ecdsa-jcs-2019",
      "created": "2026-06-12T09:41:00.000Z",
      "proofPurpose": "assertionMethod",
      "verificationMethod": "did:web:opendpp-node.eu:EU-DEFAULT-001:demo#key-1",
      "proofValue": "z4oey5q2M3XKaxup3tmzN4DRFTLVqpLMweBrSxMY2xEQLExampleEcdsaJcs2019MultibaseBase58btcProofValue"
    }
  }'

Responses

201

Event registered. Note the non-standard envelope: status: "success" (string), not success: true.

application/json TraceEventRegistered
Example 201 response
{
  "status": "success",
  "eventId": "9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b",
  "untpVerified": true
}
400

Route-specific validation failure, always {success: false, error, message} with one of three error values: Bad Request (body missing or no credentialSubject), Schema Validation Error (action present on a TransformationEvent), or Cryptographic Verification Failed (no trusted key resolved for the issuer, or the proof signature does not verify).

application/json Error
Example 400 response
{
  "success": false,
  "error": "Bad Request",
  "message": "Invalid credential payload structure."
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Persistence failure — also returned when credentialSubject.eventType/action is missing or not a valid enum value (the server-side enum rejects the row). The authentication layer can additionally emit {success: false, error: "Internal Server Error", message: "Authentication verification failed"}.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Database Persistence Failed",
  "message": "Failed to persist the event."
}
POST /api/v1/events/epcis API key

Capture a native GS1 EPCIS 2.0 document (JSON/JSON-LD)

Captures a native GS1 EPCIS 2.0 document — the standard's own JSON/JSON-LD interchange format — and persists each supported event as an EPCIS event row scoped to your tenant, alongside the VC-shaped POST /api/v1/events path. Send the document exactly as your EPCIS infrastructure produces it.

Permission: passport:update (write operation — subscription gating applies, see 402). When the node operator enforces MFA, writes from user-backed sessions (cookie or Bearer JWT) whose MFA policy requires a second factor (user policy REQUIRED, or DEFAULT with the workspace's MFA-by-default setting, which is on by default) receive 403 without one; API-key clients are exempt. Cookie-session clients must send the X-CSRF-Token header (double-submit with the opendpp_csrf cookie); Bearer JWT / API-key clients are exempt.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Validation: the WHOLE document is validated against the official GS1 EPCIS 2.0.1 JSON Schema (vendored and pinned on the node) before any event is stored — a non-conformant document is rejected 400 with the first few schema violations under errors[]. Notable rules the OFFICIAL schema enforces: @context and creationDate are required; bizStep/disposition must use the CBV short names (e.g. commissioning, in_transit) or a custom (non-CBV) URI — the legacy urn:epcglobal:cbv:* URN form is REJECTED by the standard's schema; action is forbidden on TransformationEvent; readPoint/bizLocation carry {id: <uri>}. Only type: "EPCISDocument" is accepted (no EPCISQueryDocument, no bare events), and epcisBody.eventList must be non-empty.

Per-event capture (partial success): events are processed independently and the 201 response reports results[] (captured) and errors[] (rejected) by index. An event is rejected — never silently dropped — when its type is outside this node's traceability model (ObjectEvent, AggregationEvent, TransformationEvent, AssociationEvent are supported; TransactionEvent is not) or when it identifies stock ONLY by quantity lists (no epcList/parentID/childEPCs/inputEPCList/outputEPCList — nothing EPC-identified would remain to trace). If EVERY event is rejected the response is 400 No Events Captured with the same errors[].

Fidelity disclosure: recognized EPCIS fields the node does not persist (eventID, quantity lists, sensorElementList, bizTransactionList, sourceList/destinationList, persistentDisposition, errorDeclaration, ilmd, custom extension fields, …) are listed per event under results[].ignoredFields instead of being silently discarded.

Persistence: row ids are ALWAYS server-generated (UUID) — a client-supplied eventID is never adopted as the primary key (it is disclosed under ignoredFields); CBV short names are normalized to the node's stored URN form (urn:epcglobal:cbv:bizstep:* / urn:epcglobal:cbv:disp:*, the same form the VC-shaped path stores and the lineage projection reads); defaults when absent: bizStepreceiving, dispositionin_progress. Rows captured on this path carry no per-event credential and are stored with isUntpCompliant: false (API-key provenance only) — they are never presented as UNTP-verified. This endpoint does not create lineage edges between events.

Request body required

application/json EpcisDocument
Example request
{
  "@context": [
    "https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld"
  ],
  "type": "EPCISDocument",
  "schemaVersion": "2.0",
  "creationDate": "2026-07-02T10:00:00.000Z",
  "epcisBody": {
    "eventList": [
      {
        "type": "ObjectEvent",
        "action": "ADD",
        "bizStep": "commissioning",
        "disposition": "active",
        "eventTime": "2026-01-15T08:00:00.000Z",
        "eventTimeZoneOffset": "+00:00",
        "epcList": [
          "urn:epc:id:sgtin:0950110.154904.1"
        ],
        "readPoint": {
          "id": "geo:41.1579,-8.6291"
        },
        "bizLocation": {
          "id": "urn:opendpp:location:PT-SAMPLE-SITE-3"
        }
      },
      {
        "type": "TransformationEvent",
        "bizStep": "commissioning",
        "disposition": "in_progress",
        "eventTime": "2026-01-16T08:00:00.000Z",
        "eventTimeZoneOffset": "+00:00",
        "inputEPCList": [
          "urn:epc:id:sgtin:0950110.154904.1"
        ],
        "outputEPCList": [
          "urn:epc:id:sgtin:0950110.154100.1"
        ],
        "readPoint": {
          "id": "geo:41.1579,-8.6291"
        }
      }
    ]
  }
}

Example

curl -X POST "https://opendpp-node.eu/api/v1/events/epcis" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "@context": [
      "https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld"
    ],
    "type": "EPCISDocument",
    "schemaVersion": "2.0",
    "creationDate": "2026-07-02T10:00:00.000Z",
    "epcisBody": {
      "eventList": [
        {
          "type": "ObjectEvent",
          "action": "ADD",
          "bizStep": "commissioning",
          "disposition": "active",
          "eventTime": "2026-01-15T08:00:00.000Z",
          "eventTimeZoneOffset": "+00:00",
          "epcList": [
            "urn:epc:id:sgtin:0950110.154904.1"
          ],
          "readPoint": {
            "id": "geo:41.1579,-8.6291"
          },
          "bizLocation": {
            "id": "urn:opendpp:location:PT-SAMPLE-SITE-3"
          }
        },
        {
          "type": "TransformationEvent",
          "bizStep": "commissioning",
          "disposition": "in_progress",
          "eventTime": "2026-01-16T08:00:00.000Z",
          "eventTimeZoneOffset": "+00:00",
          "inputEPCList": [
            "urn:epc:id:sgtin:0950110.154904.1"
          ],
          "outputEPCList": [
            "urn:epc:id:sgtin:0950110.154100.1"
          ],
          "readPoint": {
            "id": "geo:41.1579,-8.6291"
          }
        }
      ]
    }
  }'

Responses

201

At least one event captured. Note the non-standard envelope: status: "success" (string). errors[] lists per-event rejections when the capture was partial.

application/json EpcisCaptureResponse
Example 201 response
{
  "status": "success",
  "captured": 2,
  "results": [
    {
      "index": 0,
      "eventId": "9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b",
      "eventType": "ObjectEvent"
    },
    {
      "index": 1,
      "eventId": "1c3d4e5f-8a6b-4c8d-9e0f-1a2b9b2fa884",
      "eventType": "TransformationEvent"
    }
  ],
  "errors": []
}
400

Always {success: false, error, message} (plus errors[] detail where noted) with one of: EPCIS Schema Validation Failed (the document does not conform to the official GS1 EPCIS 2.0 JSON Schema; errors[] carries the first few violations), Unsupported Document Type (type is not EPCISDocument), Empty Document (epcisBody.eventList is empty), or No Events Captured (every event was rejected; errors[] carries the per-event reasons).

application/json Error
Example 400 response
{
  "success": false,
  "error": "EPCIS Schema Validation Failed",
  "message": "The document does not conform to the GS1 EPCIS 2.0 JSON Schema.",
  "errors": [
    "/epcisBody/eventList/0 must have required property 'eventTime'"
  ]
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
GET /api/v1/events/{id}/lineage API key

Retrieve the upstream pedigree of an event as a recursive lineage DAG

Returns the full upstream pedigree of a traceability event as a recursive Directed Acyclic Graph: the root event plus, in parents, every event linked upstream through lineage relations registered on the node, walked transitively (parents of parents). A shared ancestor reached through multiple downstream paths is repeated under EACH path — the DAG is expanded into a tree in the response, not deduplicated; only a true cycle aborts the walk (400).

Permission: passport:read. Every node in the walk — the root AND each upstream parent — is scoped to the caller's tenant; an event belonging to another tenant is invisible and the request fails with 404 (no cross-tenant pedigree reads). Sessions with the SUPER_ADMIN role are exempt from tenant scoping.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Caveats: if the lineage graph contains a circular reference the walk aborts with 400. Any other failure (unknown id, other-tenant id, missing parent) is reported as the same deliberately generic 404 body. eventTime is serialized as ISO 8601 UTC; epcs is parsed from the stored EPC list (a non-array value degrades to []); location mirrors the stored bizLocation.

Content negotiation (EPCIS projection): Accept: application/ld+json returns the SAME tenant-scoped lineage as a native GS1 EPCIS 2.0 document (EPCISDocument with the walk's events under epcisBody.eventList, ordered by eventTime, bounded to 500 events) instead of the recursive JSON tree — another projection of the same canonical rows, mirroring the AAS/VC Accept-header pattern on the passport resolution routes. Emitted documents use the official CBV short names (stored urn:epcglobal:cbv:* values are mapped back), expose row ids as eventID URNs (urn:uuid:*, or urn:opendpp:event:* for non-UUID ids), and wrap non-URI stored locations as urn:opendpp:location:*.

Parameters

NameInTypeDescription
id path string required

EPCIS event id — the server-generated UUID returned as eventId by POST /api/v1/events.

Example

curl -X GET "https://opendpp-node.eu/api/v1/events/string/lineage" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

The lineage DAG rooted at the requested event.

application/json TraceLineageResponse
Example 200 response
{
  "success": true,
  "lineage": {
    "eventId": "9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b",
    "eventType": "TransformationEvent",
    "bizStep": "urn:epcglobal:cbv:bizstep:commissioning",
    "disposition": "urn:epcglobal:cbv:disp:active",
    "eventTime": "2026-06-12T08:30:00.000Z",
    "epcs": [
      "urn:epc:id:sgtin:0950110153.0003.SN-2026-000123"
    ],
    "location": "urn:epc:id:sgln:0950110153000..0",
    "readPoint": "geo:41.1496,-8.6109",
    "isUntpCompliant": true,
    "issuerDid": "did:web:opendpp-node.eu:EU-DEFAULT-001:demo",
    "parents": [
      {
        "eventId": "4c81d2e6-9f0a-4b3c-8d5e-1a2b3c4d5e6f",
        "eventType": "ObjectEvent",
        "bizStep": "urn:epcglobal:cbv:bizstep:receiving",
        "disposition": "urn:epcglobal:cbv:disp:in_progress",
        "eventTime": "2026-06-10T14:05:00.000Z",
        "epcs": [
          "urn:epc:id:sgtin:0950110153.0001.RAW-COTTON-LOT-77"
        ],
        "location": "did:web:opendpp-node.eu:supplier-pt",
        "readPoint": "geo:38.7223,-9.1393",
        "isUntpCompliant": true,
        "issuerDid": "did:web:opendpp-node.eu:supplier-pt",
        "parents": []
      }
    ]
  }
}
application/ld+json EpcisDocument
Example 200 response
{
  "@context": [
    "https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld"
  ],
  "type": "EPCISDocument",
  "schemaVersion": "2.0",
  "creationDate": "2026-07-02T10:00:00.000Z",
  "epcisBody": {
    "eventList": [
      {
        "eventID": "urn:uuid:9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b",
        "type": "ObjectEvent",
        "action": "ADD",
        "bizStep": "commissioning",
        "disposition": "active",
        "eventTime": "2026-01-15T08:00:00.000Z",
        "eventTimeZoneOffset": "+00:00",
        "epcList": [
          "urn:epc:id:sgtin:0950110.154904.1"
        ],
        "readPoint": {
          "id": "geo:41.1579,-8.6291"
        }
      }
    ]
  }
}
400

Circular reference detected while walking the lineage graph.

application/json Error
Example 400 response
{
  "success": false,
  "error": "Lineage Retrieval Failed",
  "message": "Circular reference detected in lineage graph."
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The event does not exist, belongs to another tenant, or an upstream node could not be retrieved. The error value is Lineage Retrieval Failed (not the standard Not Found).

application/json Error
Example 404 response
{
  "success": false,
  "error": "Lineage Retrieval Failed",
  "message": "Lineage could not be retrieved."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
POST /api/v1/events/{id}/audit API key

Run heuristic UFLPA/EUDR compliance screening over an event's lineage

Walks the same upstream lineage DAG as GET /api/v1/events/{id}/lineage and screens every node's location data against two heuristic rules:

  • UFLPA — flags any node whose bizLocation starts with CN-65 (ISO 3166-2 Xinjiang), contains the keyword XINJIANG (case-insensitive), or whose readPoint contains the coordinate pair 43.8256,87.6168.
  • EUDR — flags any node whose readPoint parses as geo:<lat>,<lng> (or bare <lat>,<lng>) coordinates inside the sample deforestation polygon lat −5.0…−3.0, lng −65.0…−60.0.

These are geographic screening heuristics evaluated against the data registered on this node — not a legal compliance determination.

Permission: passport:read (a read permission despite the POST verb — no subscription gating). Cookie-session clients must send the X-CSRF-Token header; Bearer clients are exempt. Tenant scoping and the SUPER_ADMIN bypass are identical to the lineage endpoint. No request body is read — send an empty body (an empty or absent JSON body is accepted).

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

When zero violations are found, the response embeds a TraceabilityComplianceCertificate object (status SCREENED_NO_MATCHES, screens OpenDPP-EUDR-heuristic / OpenDPP-UFLPA-screen); otherwise certificate is null and errors lists each violation as a human-readable string. ANY failure — unknown event id, other-tenant id, or even a circular lineage graph — is reported as the same generic 404 body.

Parameters

NameInTypeDescription
id path string required

EPCIS event id — the server-generated UUID returned as eventId by POST /api/v1/events. Used as the root of the audited lineage DAG.

Example

curl -X POST "https://opendpp-node.eu/api/v1/events/string/audit" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

Audit completed (compliant or not — violations are reported in-band, not as HTTP errors).

Example 200 response
{
  "success": true,
  "eventId": "9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b",
  "compliant": true,
  "errors": [],
  "auditedAt": "2026-06-12T09:41:00.000Z",
  "certificate": {
    "type": "TraceabilityComplianceCertificate",
    "rootEventId": "9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b",
    "status": "SCREENED_NO_MATCHES",
    "regulatoryStandards": [
      "OpenDPP-EUDR-heuristic",
      "OpenDPP-UFLPA-screen"
    ]
  }
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The audit could not be completed: unknown event id, an event belonging to another tenant, or a circular lineage graph. The error value is Compliance Audit Failed (not the standard Not Found).

application/json Error
Example 404 response
{
  "success": false,
  "error": "Compliance Audit Failed",
  "message": "Lineage compliance audit could not be completed."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
POST /api/v1/audit/verify Public

Publicly verify a passport's eIDAS seal, certificate chain and timestamp

Public seal-verification API — cryptographically verifies that a Digital Product Passport document was sealed by an economic-operator tenant registered on this node and has not been tampered with. No authentication required.

Rate limit: custom in-memory token bucket, 30 requests/min per IP (per app instance). This bucket emits no rate-limit headers of its own — any x-ratelimit-* headers on responses (including this 429) come from the global 100 req/min limiter and describe that budget, not the 30/min one. The 429 body is the two-field {"error": "Too Many Requests", "message": "Rate limit exceeded."}.

Input resolution. payload is required. signature and publicKey may be supplied top-level, or are extracted from the document's embedded proof block: signaturepayload.proof.proofValue (else payload.proof.signatureValue); publicKeypayload.proof.publicKeyPem, else the leaf certificate's SPKI when payload.proof.x5c is present. If, after extraction, any of the three is still missing → 400. The public key is CRLF-normalized and trimmed before matching.

Verification pipeline (in order): 1. Certificate-chain report (optional). If payload.proof.x5c is a non-empty array of base64-DER certificates (leaf first), the chain is parsed and a certificate report is built: the leaf's subject / issuer / validFrom / validTo (X.509 textual dates such as Jan 10 00:00:00 2026 GMT — NOT ISO 8601), chainValid (every link signature-verifies against the next certificate, every certificate is inside its validity window, and the top of the chain is anchored to this node's seal CA — SHA-256 fingerprint match or signature under the CA key; the CA is published at GET /.well-known/opendpp-seal-ca.pem), and keyMatchesProof (the leaf SPKI equals the supplied publicKey, whitespace-insensitive; always true when no explicit key was supplied). An unparseable chain yields {"chainValid": false, "error": "Unparseable x5c certificate chain"} and does NOT fail the request. This reports the CERTIFIED identity of the seal creator (eIDAS Art. 36(1)(b)). SECURITY: the report is attached ONLY on a verified: true outcome whose chain is TRUSTED (chainValid AND keyMatchesProof both true) — an untrusted/self-signed chain, one outside its validity window or not anchored to this node's seal CA, or one whose leaf key does not match the verifying key, is never surfaced as a certificate block (it must not present an unverified identity as authoritative). The two policy-failure responses omit it too. 2. Key-registration gate. The publicKey must exactly match the registered eIDAS public key of a tenant on this node (trailing-newline tolerant) — otherwise HTTP 200 with verified: false and an explanatory message. Verification-policy failures are reported in-band, never as HTTP errors. 3. Operator-binding gate (fail-closed). If the payload declares an operator registration id (payload.operator.regId, else payload.economicOperator.regId), that id MUST resolve to an Economic Operator registered on this node AND that operator MUST be bound to the signing tenant (a workspace–operator binding registered on this node). A declared operator that is unregistered, or registered but not bound to the key-owning tenant, → 200 verified: false with an explanatory message. Payloads that declare no operator id skip this gate. 4. Signature verification (two phases). *Phase 1 — Merkle seal:* when payload.metadata is an object (or, when the metadata key is entirely absent, the whole payload is treated as the metadata), the SHA-256 Merkle tree over the metadata's top-level properties is rebuilt and the base64 ECDSA (P-256 / SHA-256) signature is verified against the recomputed root. Every leaf is recomputed from the actual values — caller-supplied redacted-leaf hashes are NOT accepted (they would let a tampered field be smuggled past verification), so a publicly redacted document will not pass the Merkle phase: verify the unredacted, privileged document. *Phase 2 — fallback:* if the Merkle phase does not verify, the signature is verified over the deterministic key-sorted canonicalization of the entire payload. 5. RFC 3161 timestamp report (optional). When payload.proof.rfc3161.token is a non-empty base64-DER TimeStampToken, the response includes timestamp with the TSA-asserted genTime parsed from the token's TSTInfo (or genTime: null plus a note when the token cannot be parsed). When the node has a TSA CA configured (TSA_CA_PEM), the report also carries timeAuthenticated — the node's own verification of the token's CMS SignedData signature over its TSTInfo PLUS full RFC 3161 trust-path validation of the signer to that anchor (a critical id-kp-timeStamping EKU, validity at the asserted genTime, CA-constrained intermediates) (false, and the asserted time unauthenticated, when no CA is configured, the signature fails, or the path is not policy-valid); a verifier may still run its own openssl ts -verify. Like certificate, it appears only on the final verification outcome.

Outcome. A processed verification ALWAYS returns HTTP 200 with verified: true|false; 400 is reserved for missing parameters or an exception thrown while verifying (e.g. an undecodable public key). timestamp is attached only when verification proceeds past the key-registration and operator-binding gates; certificate is attached only on a verified: true outcome whose chain is trusted (chainValid AND keyMatchesProof) — the two policy verified: false responses (and any untrusted-chain outcome) omit certificate, even when an x5c chain and/or an RFC 3161 token were supplied. The 400 bodies on this public endpoint are {"success": false, "message": "..."} — they include success but OMIT the error field. (A syntactically malformed JSON body is rejected earlier by the framework with its default {statusCode, error, message} body; a POST with no body at all — no Content-Type — fails before processing with a framework-default 500, so send at least {}. An empty application/json body is treated as {} and yields the documented 400.)

Request body required

application/json SealVerifyRequest
Example request
{
  "payload": {
    "passportId": "9b2fa884-5b1d-4c0e-9a3f-2d7c8e1f6a45",
    "productId": "09501101530003",
    "operator": {
      "name": "OpenDPP Demo Eco Industries",
      "regId": "EU-DEFAULT-001"
    },
    "metadata": {
      "category": "textiles",
      "originCountry": "PT",
      "materialComposition": [
        {
          "material": "Organic Cotton",
          "percentage": 80
        },
        {
          "material": "Recycled Polyester",
          "percentage": 20
        }
      ]
    },
    "proof": {
      "type": "DataIntegrityProof",
      "proofValue": "MEQCIB3pZ8sVxampleMerkleRootSealSignatureFirstIntegerAiAW6kQexampleSecondDerIntegerValue0123456789abcd==",
      "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEexampleDemoTenantSealPublicKey\n0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNO=\n-----END PUBLIC KEY-----",
      "x5c": [
        "MIIB2zCCAYGgAwIBAgIUExampleLeafSealCertificateBase64Der",
        "MIIB4TCCAYagAwIBAgIUExampleNodeSealCaCertificateBase64Der"
      ],
      "rfc3161": {
        "token": "MIIKlAYJKoZIhvcNAQcCoIIKhTCCCoECAQMxDzANBglghkgBZQMEAgEFADCBExampleTimeStampTokenDer"
      }
    }
  }
}

Example

curl -X POST "https://opendpp-node.eu/api/v1/audit/verify" \
  -H "Content-Type: application/json" \
  -d '{
    "payload": {
      "passportId": "9b2fa884-5b1d-4c0e-9a3f-2d7c8e1f6a45",
      "productId": "09501101530003",
      "operator": {
        "name": "OpenDPP Demo Eco Industries",
        "regId": "EU-DEFAULT-001"
      },
      "metadata": {
        "category": "textiles",
        "originCountry": "PT",
        "materialComposition": [
          {
            "material": "Organic Cotton",
            "percentage": 80
          },
          {
            "material": "Recycled Polyester",
            "percentage": 20
          }
        ]
      },
      "proof": {
        "type": "DataIntegrityProof",
        "proofValue": "MEQCIB3pZ8sVxampleMerkleRootSealSignatureFirstIntegerAiAW6kQexampleSecondDerIntegerValue0123456789abcd==",
        "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEexampleDemoTenantSealPublicKey\n0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNO=\n-----END PUBLIC KEY-----",
        "x5c": [
          "MIIB2zCCAYGgAwIBAgIUExampleLeafSealCertificateBase64Der",
          "MIIB4TCCAYagAwIBAgIUExampleNodeSealCaCertificateBase64Der"
        ],
        "rfc3161": {
          "token": "MIIKlAYJKoZIhvcNAQcCoIIKhTCCCoECAQMxDzANBglghkgBZQMEAgEFADCBExampleTimeStampTokenDer"
        }
      }
    }
  }'

Responses

200

Verification processed. verified reports the cryptographic outcome; policy failures (unregistered key, unbound operator) come back as verified: false with a message — still HTTP 200, and WITHOUT certificate/timestamp even when an x5c chain or RFC 3161 token was supplied. Once verification proceeds past the policy gates, certificate is present for x5c-carrying proofs ONLY when the outcome is verified: true with a trusted chain (chainValid AND keyMatchesProof), and timestamp when an RFC 3161 token was embedded.

application/json SealVerifyResponse
Example 200 response
{
  "success": true,
  "verified": true,
  "certificate": {
    "subject": "CN=OpenDPP Demo Eco Industries Seal",
    "issuer": "CN=OpenDPP Node Seal CA",
    "validFrom": "Jan 10 00:00:00 2026 GMT",
    "validTo": "Jan 10 00:00:00 2028 GMT",
    "chainValid": true,
    "keyMatchesProof": true
  },
  "timestamp": {
    "present": true,
    "genTime": "2026-06-12T09:41:03.000Z"
  }
}
400

Missing cryptographic parameters (after proof-block extraction), or an exception during verification (e.g. undecodable key material). Body is {success: false, message} — NO error field on this public endpoint.

application/json object
success boolean required
always false
message enum required
one of: Missing cryptographic parameter: payload, signature, and publicKey are required, Signature verification failed.
Example 400 response
{
  "success": false,
  "message": "Missing cryptographic parameter: payload, signature, and publicKey are required"
}
429

Public-resolution rate limit exceeded (30 requests/min per IP; no rate-limit headers). With Accept: text/html an HTML page is returned instead.

application/json Error
Example 429 response
{
  "error": "Too Many Requests",
  "message": "Rate limit exceeded. Public passport resolutions are limited to 30 requests per minute."
}

Public Resolution

Unauthenticated, content-negotiated passport resolution: GS1 Digital Link paths, passport and unit pages. One URL serves JSON-LD (default), an AAS environment (Accept: application/aas+json), a signed UNTP Verifiable Credential (Accept: application/vc+jwt enveloping, application/vc+ld+json embedded ecdsa-jcs-2019 Data Integrity, or application/dc+sd-jwt SD-JWT-VC selective disclosure; the first two also item-level on /unit/:id), or HTML (Accept: text/html). Tiered by optional credentials or grant tokens. Rate limit: 30 requests/min per IP.

GET /passport/{id} Public · tiered by credentials

Resolve a passport by UUID (JSON-LD / AAS / HTML)

Public, content-negotiated resolution of a Digital Product Passport by its server-assigned UUID. Lookup is by primary key only — GTIN/GRAI/serial lookups go through the GS1 Digital Link gateway (GET /01/{gtin14}, GET /8003/{grai}).

Content negotiation — the representation is chosen by RFC 7231 §5.3.2 Accept q-value negotiation (highest q wins; ties broken by media-range specificity, then the client's stated order): application/aas+json (or bare aas+json) → role-filtered Asset Administration Shell environment; application/vc+jwt (or bare vc+jwt) → a signed UNTP DigitalProductPassport credential (public tier; 406 Not Acceptable when the passport has no manufacturing facility with a country of production); application/vc+ld+json (or bare vc+ld+json) → the same credential with an embedded W3C Data Integrity proof (ecdsa-jcs-2019), same 406 condition; application/dc+sd-jwt (or the legacy vc+sd-jwt) → the same credential as an SD-JWT-VC for cryptographic selective disclosure (a holder presents a subset of credentialSubject claims), same 406 condition; text/html → server-rendered passport page. An absent Accept, or one matching only */*, yields the canonical default JSON-LD (application/ld+json); an unsupported type is ignored. Because q-values and client order are honoured, Accept: text/html, application/vc+jwt selects HTML (the client's first preference), NOT vc+jwt. Vary: Accept is always set on the 200.

Access tiers — no permission string (public endpoint). Credentials are *optional* and never produce 401/402/403 here; an invalid or foreign credential silently degrades to the public tier: - Public (anonymous): restricted metadata keys (for category batteries: detailedPerformance, lifecycleAndInUse, circularityAndDisassembly — masked only when present) and the owner-only key facilityDetails (present-as-placeholder in every non-owner response, even when the underlying metadata never contained it) carry the literal placeholder [REDACTED - Privileged Access Required]. Each masked key that exists in the sealed metadata keeps its true Merkle leaf hash in proof.redactedLeaves, so the eIDAS seal stays offline-verifiable after redaction; a placeholder-valued key with no redactedLeaves entry was never in the sealed metadata and must be excluded when rebuilding the root. - Legitimate interest / authority: a capability grant token — dpp_li_… (tenant-issued) or dpp_auth_… (platform-issued, not tenant-revocable) — sent as Authorization: Bearer <token> or ?grant=<token>, with TENANT or PASSPORT scope covering this passport, unlocks the restricted tier-2 keys. facilityDetails, the facility street address and DRAFT passports stay hidden. Grant-unlocked responses add Cache-Control: private, no-store and Referrer-Policy: no-referrer. - Owner: a tenant API key (op_dpp_token_…, shown once at creation) belonging to the owning tenant or to a tenant bound to the passport's economic operator — sent as Bearer or, legacy, as the literal value of the opendpp_session cookie. Only API keys are matched on the public resolvers: a Console JWT login session in that cookie does not unlock owner tier (it silently resolves as public). Owners see everything, including DRAFT passports, owner-only metadata keys and the facility street address (manufacturingFacility.streetAddress/city/postalCode). In the AAS representation the owner credential's API-key role drives element filtering; a grant maps to the legitimate_interest filter tier, anonymous to public.

DRAFT passports are hidden from everyone but the owner (404 with a body identical to a true miss). Every resolution is recorded in the passport's access audit log with an anonymized IP.

Rate limit: 30 requests/min/IP via a per-process in-memory limiter; its 429 body is the two-field public error shape (no success field). This limiter adds no headers of its own — the x-ratelimit-* headers still present on responses (including these 429s) belong to the global platform limit (100 req/min/IP, 600/min for known crawler user agents), which applies on top.

Parameters

NameInTypeDescription
id path string required

The passport's server-assigned UUID (returned as id on creation and embedded as AI-21 in the SKU-level Digital Link URI).

grant query string optional

Capability grant token (dpp_li_… legitimate-interest, dpp_auth_… authority) — the inspection-link path for QR-scanning inspectors who cannot set headers. Equivalent to sending the token as Authorization: Bearer. Tokens minted by the platform are the prefix followed by 32 hex characters, but the server matches any prefixed token against its stored hashes (the demo workspace's sample tokens use a different suffix), so the pattern here is deliberately loose. Treat as a secret: responses unlocked this way carry Cache-Control: private, no-store + Referrer-Policy: no-referrer, and the server log redacts the parameter.

pattern ^dpp_(li|auth)_[A-Za-z0-9]{20,64}$

Example

curl -X GET "https://opendpp-node.eu/passport/string"

Responses

200

The passport in the negotiated representation. Vary: Accept always set; Cache-Control: private, no-store and Referrer-Policy: no-referrer added only when a grant token unlocked the response.

Headers: Vary, Cache-Control, Referrer-Policy
application/ld+json PublicPassportJsonLd
Example 200 response
{
  "@context": [
    "https://opendpp-node.eu/contexts/dpp/v1",
    {
      "DigitalProductPassport": "https://opendpp-node.eu/ns/dpp#DigitalProductPassport",
      "economicOperator": "https://opendpp-node.eu/ns/dpp#economicOperator",
      "manufacturingFacility": "https://opendpp-node.eu/ns/dpp#manufacturingFacility",
      "metadata": "https://opendpp-node.eu/ns/dpp#metadata",
      "digitalSeal": "https://opendpp-node.eu/ns/dpp#digitalSeal",
      "signingPublicKey": "https://opendpp-node.eu/ns/dpp#signingPublicKey",
      "status": "https://opendpp-node.eu/ns/dpp#status",
      "archivedAt": "https://opendpp-node.eu/ns/dpp#archivedAt",
      "retentionUntil": "https://opendpp-node.eu/ns/dpp#retentionUntil",
      "category": "https://opendpp-node.eu/contexts/dpp/v1#category",
      "gtin": "https://opendpp-node.eu/contexts/dpp/v1#gtin",
      "batteryCategory": "https://opendpp-node.eu/contexts/dpp/v1#batteryCategory",
      "chemistry": "https://opendpp-node.eu/contexts/dpp/v1#chemistry",
      "originCountry": "https://opendpp-node.eu/contexts/dpp/v1#originCountry",
      "detailedPerformance": "https://opendpp-node.eu/contexts/dpp/v1#detailedPerformance",
      "facilityDetails": "https://opendpp-node.eu/contexts/dpp/v1#facilityDetails"
    }
  ],
  "@type": "DigitalProductPassport",
  "@id": "https://opendpp-node.eu/01/09501101530003",
  "id": "9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b",
  "productId": "09501101530003",
  "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003",
  "digitalSeal": "MEUCIQDl0n7K0wG7B1k9p2v4S0cQ9X4j8M5n6P7q8R9s0T1uAIgK2L3m4N5o6P7q8R9s0T1u2V3w4X5y6Z7a8B9c0D1e2F4=",
  "signingPublicKey": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7sB3kFq2nXp9wLm4Rv8tYc1dZh6j\nKe0aGu5iSx3oNl2bPw9rT7vCmD4fHg8qWy1zEjU6kA0sIxLpO5tMnBvR3Q==\n-----END PUBLIC KEY-----\n",
  "status": "ACTIVE",
  "archivedAt": null,
  "retentionUntil": null,
  "proof": {
    "@type": [
      "MerkleTreeAttestationProof"
    ],
    "type": "MerkleTreeAttestationProof",
    "signatureAlgorithm": "ECDSA-P256-SHA256-over-MerkleRoot",
    "created": "2026-06-12T09:41:00.000Z",
    "proofPurpose": "assertionMethod",
    "verificationMethod": "https://opendpp-node.eu/passport/9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b#key-1",
    "signatureValue": "MEUCIQDl0n7K0wG7B1k9p2v4S0cQ9X4j8M5n6P7q8R9s0T1uAIgK2L3m4N5o6P7q8R9s0T1u2V3w4X5y6Z7a8B9c0D1e2F4=",
    "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7sB3kFq2nXp9wLm4Rv8tYc1dZh6j\nKe0aGu5iSx3oNl2bPw9rT7vCmD4fHg8qWy1zEjU6kA0sIxLpO5tMnBvR3Q==\n-----END PUBLIC KEY-----\n",
    "x5c": [
      "MIIB2TCCAX6gAwIBAgIUTGVhZkNlcnRFeGFtcGxlRGF0YTAKBggqhkjOPQQDAjAgMR4wHAYDVQQDDBVPcGVuRFBQIFNlYWwgQ0EgKERlbW8p",
      "MIIBszCCAVmgAwIBAgIUQ2FDZXJ0RXhhbXBsZURhdGFCYXNlNjQwCgYIKoZIzj0EAwIwIDEeMBwGA1UEAwwVT3BlbkRQUCBTZWFsIENBIChEZW1vKQ"
    ],
    "rfc3161": {
      "genTime": "2026-06-12T09:41:02.000Z",
      "token": "MIIKExampleBase64DerEncodedRfc3161TimeStampRespToken"
    },
    "merkleRoot": "8c4f9d2e6a1b7c3f5e0d8a4b2c6f1e9d7a3b5c8f0e2d4a6b9c1f3e5d7a0b2c4f",
    "redactedLeaves": {
      "detailedPerformance": "f3a19c7e5b2d8f4a6c0e1d9b7a3f5c2e8d4b6a0c9f1e3d5b7a2c4f6e8d0b1a3c",
      "facilityDetails": "0b7c4f2e9d1a6b3c8f5e0d7a4b1c6f3e9d2a5b8c0f7e4d1a6b3c9f5e2d8a0b4c"
    }
  },
  "createdAt": "2026-06-01T08:00:00.000Z",
  "updatedAt": "2026-06-12T09:41:00.000Z",
  "economicOperator": {
    "@type": "EconomicOperator",
    "id": "4f6f0b9c-2d71-4f3a-8e5b-1c9d7a2e6b40",
    "name": "Voltaic Cells Manufacturing GmbH",
    "regId": "EU-DEFAULT-001",
    "role": "MANUFACTURER"
  },
  "manufacturingFacility": {
    "@type": "Facility",
    "id": "7c1e5a2d-9b4f-4c8e-a3d6-2f8b0e4c9a71",
    "gln": "0950110153007",
    "name": "Voltaic Gigafactory Brandenburg",
    "activity": "cell assembly",
    "country": "DE"
  },
  "metadata": {
    "category": "batteries",
    "gtin": "09501101530003",
    "batteryCategory": "ev",
    "chemistry": "NMC 811",
    "originCountry": "DE",
    "detailedPerformance": "[REDACTED - Privileged Access Required]",
    "facilityDetails": "[REDACTED - Privileged Access Required]"
  },
  "category": "batteries",
  "gtin": "09501101530003",
  "batteryCategory": "ev",
  "chemistry": "NMC 811",
  "originCountry": "DE",
  "detailedPerformance": "[REDACTED - Privileged Access Required]",
  "facilityDetails": "[REDACTED - Privileged Access Required]"
}
application/aas+json AasEnvironment
Example 200 response
{
  "assetAdministrationShells": [
    {
      "id": "urn:opendpp:aas:9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b",
      "idShort": "AAS_09501101530003",
      "assetInformation": {
        "assetKind": "Instance",
        "globalAssetId": "urn:opendpp:asset:4f6f0b9c-2d71-4f3a-8e5b-1c9d7a2e6b40:09501101530003"
      }
    }
  ],
  "submodels": [
    {
      "id": "urn:opendpp:submodel:general:9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b",
      "idShort": "GeneralProductInformation"
    },
    {
      "id": "urn:opendpp:submodel:compliance",
      "idShort": "ComplianceMetadata"
    },
    {
      "id": "urn:opendpp:submodel:security-seal:9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b",
      "idShort": "eidasVerificationSeal"
    }
  ],
  "conceptDescriptions": []
}
application/vc+jwt string

A UNTP DigitalProductPassport credential as an enveloping JOSE proof — a compact JWS with protected header {alg:ES256, typ:vc+jwt, kid:<issuerDid>#key-0}. PUBLIC tier only (privileged metadata never enters the VC). The issuer is the tenant's did:web; resolve GET /tenants/{tenantId}/did.json for the verification key. Revocation is the W3C Bitstring Status List referenced by the credential's credentialStatus (served at GET /tenants/{tenantId}/status/revocation). Returned only when the passport has a manufacturing facility with a country of production — otherwise 406 Not Acceptable.

Example 200 response
"eyJhbGciOiJFUzI1NiIsInR5cCI6InZjK2p3dCIsImtpZCI6ImRpZDp3ZWI6..."
application/vc+ld+json object

The same UNTP DigitalProductPassport credential with an EMBEDDED W3C Data Integrity proof (proof.type DataIntegrityProof, proof.cryptosuite ecdsa-jcs-2019) — RFC 8785 JCS canonicalization, multibase base58btc proof.proofValue, signed by the tenant's did:web key (proof.verificationMethod = <issuerDid>#key-<n>; resolve GET /tenants/{tenantId}/did.json). PUBLIC tier; revocation via credentialStatus (Bitstring Status List). Same 406 Not Acceptable when the passport has no manufacturing facility with a country of production. The enveloping application/vc+jwt is the primary, UNTP-mandated representation; this is the optional embedded-proof alternative for Data-Integrity verifiers (#117).

Example 200 response
{}
application/dc+sd-jwt string

The same UNTP DigitalProductPassport credential as an SD-JWT-VC (IETF SD-JWT-VC) — cryptographic selective disclosure. The issuer serves the FULL SD-JWT, <JWS>~<disclosure>~… with protected header {alg:ES256, typ:dc+sd-jwt, kid:<issuerDid>#key-<n>} plus the SD-JWT-VC claims iss (the issuer did:web) and vct (https://opendpp-node.eu/vct/digital-product-passport). A HOLDER may then present any SUBSET of the disclosable credentialSubject claims (materialProvenance / performanceClaim / characteristics / producedAtFacility / countryOfProduction) by dropping disclosures, and it still verifies against the issuer signature — withheld claims survive only as opaque _sd digests. Structural identity (id/type/productCategory/vct/iss) is always in clear; revocation (credentialStatus, Bitstring Status List) is never disclosable. PUBLIC tier; the issuer is the tenant's did:web (resolve GET /tenants/{tenantId}/did.json). Same 406 Not Acceptable when the passport has no manufacturing facility with a country of production. The legacy application/vc+sd-jwt media type is also accepted on the request Accept header (#118).

Example 200 response
"eyJhbGciOiJFUzI1NiIsInR5cCI6ImRjK3NkLWp3dCJ9.eyJpc3MiOiJkaWQ6d2ViOi4uLiIsInZjdCI6Li4ufQ.sig~WyJzYWx0MCIsImNvdW50cnlPZlByb2R1Y3Rpb24iLHsiY291bnRyeUNvZGUiOiJERSJ9XQ~"
text/html string

Server-rendered passport page (owner credentials see the full owner view).

Example 200 response
"string"
400

Passport identifier missing. (Defensive guard — not reachable through normal routing, since the path parameter is required.) Body omits the success field.

application/json Error
Example 400 response
{
  "error": "Bad Request",
  "message": "Passport identifier must be provided"
}
404

No passport with that UUID — or the passport is a DRAFT and the caller is not owner-tier (identical body, deliberate). Accept: text/html gets an SSR not-found page instead of JSON. Body omits the success field. (This route does not set Vary on the 404.) Separately, a request on an unknown tenant workspace host receives a platform-level JSON 404 (No tenant company found for subdomain: …) before resolution runs — that host check applies to every documented path except /health.

application/json Error
Example 404 response
{
  "error": "Not Found",
  "message": "No Digital Product Passport found matching identifier: 9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b"
}
text/html string

SSR not-found page.

Example 404 response
"string"
406

The requested representation cannot be produced for this resource. Returned when a Verifiable Credential media type (application/vc+jwt, application/vc+ld+json, or application/dc+sd-jwt) is requested for a passport that has no manufacturing facility with a country of production — a conformant UNTP credential cannot be issued without it. Request the default JSON-LD (or another supported representation) instead.

application/json Error
Example 406 response
{
  "error": "Not Acceptable",
  "message": "This passport cannot be represented as a UNTP Verifiable Credential: it has no manufacturing facility with a country of production."
}
429

Public-resolution rate limit exceeded (30 requests/min per IP; no rate-limit headers). With Accept: text/html an HTML page is returned instead.

application/json Error
Example 429 response
{
  "error": "Too Many Requests",
  "message": "Rate limit exceeded. Public passport resolutions are limited to 30 requests per minute."
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
GET /01/{gtin14} Public · tiered by credentials

GS1 Digital Link resolution by GTIN-14 (AI 01)

Unified GS1 Digital Link gateway, GTIN branch. The GTIN-14 is matched against metadata.gtin, metadata.grai, or the passport's productId. On tenant workspaces (https://{tenant}.opendpp-node.eu) the lookup is scoped to that tenant — an unknown subdomain returns 404. Without a tenant scope, a GTIN matching more than one passport is rejected with 400 (ambiguous); disambiguate via a brand subdomain (the ?subdomain= query override is honoured in non-production environments only).

Content negotiation (RFC 7231 §5.3.2 Accept q-value negotiation; JSON-LD default / application/aas+json / application/vc+jwt / application/vc+ld+json / application/dc+sd-jwt / text/html, Vary: Accept always set), access tiers (public / dpp_li_…·dpp_auth_… grant via Bearer or ?grant= / owner = a tenant API key sent as Bearer or, legacy, as the literal opendpp_session cookie value — Console JWT login sessions do not unlock owner tier), DRAFT hiding, access-audit logging (anonymized IP), and grant response headers (Cache-Control: private, no-store, Referrer-Policy: no-referrer) are identical to GET /passport/{id} — see that operation for the full tier semantics. No permission string (public endpoint); invalid credentials silently degrade to the public tier, never 401/403.

The gateway also accepts additional GS1 AI key/value path pairs after the GTIN; the only one acted on is AI 21 (serial) — documented separately as GET /01/{gtin14}/21/{serial}. (The underlying route is GET /{ai}/*; AI prefixes other than 01 and 8003 get a 400.) This resolver handles only the UNCOMPRESSED GS1 Digital Link grammar; a compressed Digital Link (its AI data encoded as a base64url blob) is detected and rejected with a clear 400 that points to the uncompressed form (#261).

Rate limit: 30 requests/min/IP, per-process in-memory limiter; two-field 429 body without success. The limiter adds no headers of its own — x-ratelimit-* headers on responses come from the global platform limit (100 req/min/IP), which applies on top.

Parameters

NameInTypeDescription
gtin14 path string required

GTIN-14: exactly 14 digits with a valid GS1 modulo-10 check digit (the check digit is validated server-side — the pattern alone is not sufficient).

pattern ^[0-9]{14}$
grant query string optional

Capability grant token (dpp_li_… / dpp_auth_…); equivalent to Authorization: Bearer. Minted tokens are the prefix + 32 hex characters, but the server matches any prefixed token against stored hashes, so the pattern is deliberately loose. Treat as a secret — grant-unlocked responses are private, no-store and the parameter is redacted from logs.

pattern ^dpp_(li|auth)_[A-Za-z0-9]{20,64}$

Example

curl -X GET "https://opendpp-node.eu/01/string"

Responses

200

The matched passport in the negotiated representation (same envelope as GET /passport/{id}).

Headers: Vary, Cache-Control, Referrer-Policy
application/ld+json PublicPassportJsonLd
Example 200 response
{
  "@context": [
    "https://opendpp-node.eu/contexts/dpp/v1",
    {
      "DigitalProductPassport": "https://opendpp-node.eu/ns/dpp#DigitalProductPassport",
      "economicOperator": "https://opendpp-node.eu/ns/dpp#economicOperator",
      "manufacturingFacility": "https://opendpp-node.eu/ns/dpp#manufacturingFacility",
      "metadata": "https://opendpp-node.eu/ns/dpp#metadata",
      "digitalSeal": "https://opendpp-node.eu/ns/dpp#digitalSeal",
      "signingPublicKey": "https://opendpp-node.eu/ns/dpp#signingPublicKey",
      "status": "https://opendpp-node.eu/ns/dpp#status",
      "archivedAt": "https://opendpp-node.eu/ns/dpp#archivedAt",
      "retentionUntil": "https://opendpp-node.eu/ns/dpp#retentionUntil",
      "category": "https://opendpp-node.eu/contexts/dpp/v1#category",
      "gtin": "https://opendpp-node.eu/contexts/dpp/v1#gtin",
      "chemistry": "https://opendpp-node.eu/contexts/dpp/v1#chemistry",
      "originCountry": "https://opendpp-node.eu/contexts/dpp/v1#originCountry",
      "detailedPerformance": "https://opendpp-node.eu/contexts/dpp/v1#detailedPerformance",
      "facilityDetails": "https://opendpp-node.eu/contexts/dpp/v1#facilityDetails"
    }
  ],
  "@type": "DigitalProductPassport",
  "@id": "https://opendpp-node.eu/01/09501101530003",
  "id": "9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b",
  "productId": "09501101530003",
  "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003",
  "digitalSeal": null,
  "signingPublicKey": null,
  "status": "ACTIVE",
  "archivedAt": null,
  "retentionUntil": null,
  "proof": null,
  "createdAt": "2026-06-01T08:00:00.000Z",
  "updatedAt": "2026-06-12T09:41:00.000Z",
  "economicOperator": {
    "@type": "EconomicOperator",
    "id": "4f6f0b9c-2d71-4f3a-8e5b-1c9d7a2e6b40",
    "name": "Voltaic Cells Manufacturing GmbH",
    "regId": "EU-DEFAULT-001",
    "role": "MANUFACTURER"
  },
  "manufacturingFacility": {
    "@type": "Facility",
    "id": "7c1e5a2d-9b4f-4c8e-a3d6-2f8b0e4c9a71",
    "gln": "0950110153007",
    "name": "Voltaic Gigafactory Brandenburg",
    "activity": "cell assembly",
    "country": "DE"
  },
  "metadata": {
    "category": "batteries",
    "gtin": "09501101530003",
    "chemistry": "NMC 811",
    "originCountry": "DE",
    "detailedPerformance": "[REDACTED - Privileged Access Required]",
    "facilityDetails": "[REDACTED - Privileged Access Required]"
  },
  "category": "batteries",
  "gtin": "09501101530003",
  "chemistry": "NMC 811",
  "originCountry": "DE",
  "detailedPerformance": "[REDACTED - Privileged Access Required]",
  "facilityDetails": "[REDACTED - Privileged Access Required]"
}
application/aas+json AasEnvironment
Example 200 response
{
  "assetAdministrationShells": [
    {
      "id": "urn:opendpp:aas:9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b",
      "idShort": "AAS_09501101530003",
      "assetInformation": {
        "assetKind": "Instance",
        "globalAssetId": "urn:opendpp:asset:4f6f0b9c-2d71-4f3a-8e5b-1c9d7a2e6b40:09501101530003"
      }
    }
  ],
  "submodels": [
    {
      "id": "urn:opendpp:submodel:general:9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b",
      "idShort": "GeneralProductInformation"
    },
    {
      "id": "urn:opendpp:submodel:compliance",
      "idShort": "ComplianceMetadata"
    },
    {
      "id": "urn:opendpp:submodel:security-seal:9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b",
      "idShort": "eidasVerificationSeal"
    }
  ],
  "conceptDescriptions": []
}
application/vc+jwt string

A UNTP DigitalProductPassport credential as an enveloping JOSE proof — a compact JWS with protected header {alg:ES256, typ:vc+jwt, kid:<issuerDid>#key-0}. PUBLIC tier only (privileged metadata never enters the VC). The issuer is the tenant's did:web; resolve GET /tenants/{tenantId}/did.json for the verification key. Revocation is the W3C Bitstring Status List referenced by the credential's credentialStatus (served at GET /tenants/{tenantId}/status/revocation). Returned only when the passport has a manufacturing facility with a country of production — otherwise 406 Not Acceptable.

Example 200 response
"eyJhbGciOiJFUzI1NiIsInR5cCI6InZjK2p3dCIsImtpZCI6ImRpZDp3ZWI6..."
application/vc+ld+json object

The same UNTP DigitalProductPassport credential with an EMBEDDED W3C Data Integrity proof (proof.type DataIntegrityProof, proof.cryptosuite ecdsa-jcs-2019) — RFC 8785 JCS canonicalization, multibase base58btc proof.proofValue, signed by the tenant's did:web key (proof.verificationMethod = <issuerDid>#key-<n>; resolve GET /tenants/{tenantId}/did.json). PUBLIC tier; revocation via credentialStatus (Bitstring Status List). Same 406 Not Acceptable when the passport has no manufacturing facility with a country of production. The enveloping application/vc+jwt is the primary, UNTP-mandated representation; this is the optional embedded-proof alternative for Data-Integrity verifiers (#117).

Example 200 response
{}
application/dc+sd-jwt string

The same UNTP DigitalProductPassport credential as an SD-JWT-VC (IETF SD-JWT-VC) — cryptographic selective disclosure. The issuer serves the FULL SD-JWT, <JWS>~<disclosure>~… with protected header {alg:ES256, typ:dc+sd-jwt, kid:<issuerDid>#key-<n>} plus the SD-JWT-VC claims iss (the issuer did:web) and vct (https://opendpp-node.eu/vct/digital-product-passport). A HOLDER may then present any SUBSET of the disclosable credentialSubject claims (materialProvenance / performanceClaim / characteristics / producedAtFacility / countryOfProduction) by dropping disclosures, and it still verifies against the issuer signature — withheld claims survive only as opaque _sd digests. Structural identity (id/type/productCategory/vct/iss) is always in clear; revocation (credentialStatus, Bitstring Status List) is never disclosable. PUBLIC tier; the issuer is the tenant's did:web (resolve GET /tenants/{tenantId}/did.json). Same 406 Not Acceptable when the passport has no manufacturing facility with a country of production. The legacy application/vc+sd-jwt media type is also accepted on the request Accept header (#118).

Example 200 response
"eyJhbGciOiJFUzI1NiIsInR5cCI6ImRjK3NkLWp3dCJ9.eyJpc3MiOiJkaWQ6d2ViOi4uLiIsInZjdCI6Li4ufQ.sig~WyJzYWx0MCIsImNvdW50cnlPZlByb2R1Y3Rpb24iLHsiY291bnRyeUNvZGUiOiJERSJ9XQ~"
text/html string

Server-rendered passport page.

Example 200 response
"string"
400

Invalid GTIN-14 (must be 14 digits with a valid modulo-10 check digit) or — when no tenant scope is in play and no AI-21 serial was given — an ambiguous lookup matching multiple passports. Bodies omit the success field.

application/json Error
Example 400 response
{
  "error": "Bad Request",
  "message": "Invalid GS1 GTIN format. Standard GS1 Digital Link resolution under AI 01 requires a numeric-only GTIN of exactly 14 digits with a valid modulo-10 check digit. Got: \"09501101530009\""
}
404

No passport matches the identifier (content-negotiated: HTML page for Accept: text/html, JSON otherwise; Vary: Accept set), a DRAFT match was hidden from a non-owner caller, or the request arrived on an unknown tenant subdomain (JSON only). Bodies omit the success field.

Headers: Vary
application/json Error
Example 404 response
{
  "error": "Not Found",
  "message": "No Digital Product Passport found matching identifier: 09501101530003"
}
text/html string

SSR not-found page.

Example 404 response
"string"
406

The requested representation cannot be produced for this resource. Returned when a Verifiable Credential media type (application/vc+jwt, application/vc+ld+json, or application/dc+sd-jwt) is requested for a passport that has no manufacturing facility with a country of production — a conformant UNTP credential cannot be issued without it. Request the default JSON-LD (or another supported representation) instead.

application/json Error
Example 406 response
{
  "error": "Not Acceptable",
  "message": "This passport cannot be represented as a UNTP Verifiable Credential: it has no manufacturing facility with a country of production."
}
429

Public-resolution rate limit exceeded (30 requests/min per IP; no rate-limit headers). With Accept: text/html an HTML page is returned instead.

application/json Error
Example 429 response
{
  "error": "Too Many Requests",
  "message": "Rate limit exceeded. Public passport resolutions are limited to 30 requests per minute."
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
GET /01/{gtin14}/21/{serial} Public

GS1 Digital Link serialised-item redirect (AI 01 + AI 21)

GS1 Digital Link resolution of an *individual serialised item*. This path never returns a document directly — on success it issues a 302 redirect (the query string, including ?grant=, is preserved on the Location URL):

1. If the GTIN resolves to a SKU/type passport that has a serialised battery unit whose serialNumber equals the AI-21 value → 302 to /unit/{unitId} (Battery Reg. Art. 77(2) per-unit view). 2. Otherwise (legacy fallback) the AI-21 value is matched against the passport UUID, metadata.serialNumber, or metadata["21"]; if a passport matches → 302 to /passport/{passportId}. 3. Otherwise → 404 (content-negotiated).

The ambiguity check of the bare-GTIN branch is skipped when an AI-21 serial is present. The redirect handler itself never evaluates credentials — access tiers (owner / grant / public) apply at the redirect target; carry the grant in ?grant= (preserved across the redirect) or re-send the Authorization header to the target. On tenant subdomains the lookup is scoped to that tenant (unknown subdomain → 404, JSON only).

No permission string (public endpoint). Rate limit: 30 requests/min/IP (in-memory public limiter; two-field 429 body without success). The limiter adds no headers of its own — x-ratelimit-* headers come from the global platform limit, which applies on top — and the redirect target counts as a second request against both.

Parameters

NameInTypeDescription
gtin14 path string required

GTIN-14: exactly 14 digits with a valid GS1 modulo-10 check digit (validated server-side).

pattern ^[0-9]{14}$
serial path string required

GS1 AI-21 serial. For serialised battery units this is the unit's physical serial (units are created matching ^[A-Za-z0-9._-]{1,20}$); the legacy fallback also matches a passport UUID or the passport's metadata.serialNumber / metadata["21"] value. Percent-encode reserved characters; the segment is URL-decoded before matching.

grant query string optional

Capability grant token. Not evaluated by this redirect handler — it is preserved on the Location URL and takes effect at the redirect target.

pattern ^dpp_(li|auth)_[A-Za-z0-9]{20,64}$

Example

curl -X GET "https://opendpp-node.eu/01/string/21/string"

Responses

302

Redirect to the resolved resource. Location is /unit/{unitId} (serialised unit found) or /passport/{passportId} (legacy serial fallback), with the original query string appended. No body.

Headers: Location
400

Invalid GTIN-14 (format / modulo-10 check digit). Body omits the success field.

application/json Error
Example 400 response
{
  "error": "Bad Request",
  "message": "Invalid GS1 GTIN format. Standard GS1 Digital Link resolution under AI 01 requires a numeric-only GTIN of exactly 14 digits with a valid modulo-10 check digit. Got: \"09501101530009\""
}
404

Neither a serialised unit nor a fallback passport matches (content-negotiated HTML/JSON, Vary: Accept), or unknown tenant subdomain (JSON only). Bodies omit the success field.

Headers: Vary
application/json Error
Example 404 response
{
  "error": "Not Found",
  "message": "No Digital Product Passport found matching identifier: 09501101530003"
}
text/html string

SSR not-found page.

Example 404 response
"string"
429

Public-resolution rate limit exceeded (30 requests/min per IP; no rate-limit headers). With Accept: text/html an HTML page is returned instead.

application/json Error
Example 429 response
{
  "error": "Too Many Requests",
  "message": "Rate limit exceeded. Public passport resolutions are limited to 30 requests per minute."
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
GET /8003/{grai} Public · tiered by credentials

GS1 Digital Link resolution by GRAI (AI 8003)

Unified GS1 Digital Link gateway, GRAI branch (Global Returnable Asset Identifier). The GRAI is matched against metadata.gtin, metadata.grai, or the passport's productId. Everything else — content negotiation (JSON-LD default / application/aas+json / application/vc+jwt / application/vc+ld+json / application/dc+sd-jwt / text/html, Vary: Accept), access tiers (public / grant dpp_li_…·dpp_auth_… via Bearer or ?grant= / owner = tenant API key as Bearer or legacy opendpp_session cookie value, never a Console JWT session), DRAFT hiding, tenant-subdomain scoping, the no-tenant-scope ambiguity 400, access-audit logging, grant response headers, and the 30 req/min/IP in-memory rate limit (two-field 429 body without success; the limiter adds no headers of its own — x-ratelimit-* headers come from the global 100 req/min/IP limit, which applies on top) — is identical to GET /01/{gtin14}; see that operation and GET /passport/{id} for full semantics.

An additional /21/{serial} AI pair after the GRAI behaves exactly like GET /01/{gtin14}/21/{serial} (302 redirect to /unit/{id} or /passport/{id}). No permission string (public endpoint).

Parameters

NameInTypeDescription
grai path string required

GRAI: a 14-digit numeric asset identifier with a valid GS1 modulo-10 check digit (validated server-side), followed by an optional alphanumeric serial component of up to 16 characters (total length 14-30).

pattern ^[0-9]{14}[A-Za-z0-9]{0,16}$ · min length 14 · max length 30
grant query string optional

Capability grant token (dpp_li_… / dpp_auth_…); equivalent to Authorization: Bearer. Minted tokens are the prefix + 32 hex characters; the server matches any prefixed token against stored hashes. Treat as a secret.

pattern ^dpp_(li|auth)_[A-Za-z0-9]{20,64}$

Example

curl -X GET "https://opendpp-node.eu/8003/string"

Responses

200

The matched passport in the negotiated representation (same envelope as GET /passport/{id}).

Headers: Vary, Cache-Control, Referrer-Policy
application/ld+json PublicPassportJsonLd
Example 200 response
{
  "@context": [
    "https://opendpp-node.eu/contexts/dpp/v1",
    {
      "DigitalProductPassport": "https://opendpp-node.eu/ns/dpp#DigitalProductPassport",
      "economicOperator": "https://opendpp-node.eu/ns/dpp#economicOperator",
      "manufacturingFacility": "https://opendpp-node.eu/ns/dpp#manufacturingFacility",
      "metadata": "https://opendpp-node.eu/ns/dpp#metadata",
      "digitalSeal": "https://opendpp-node.eu/ns/dpp#digitalSeal",
      "signingPublicKey": "https://opendpp-node.eu/ns/dpp#signingPublicKey",
      "status": "https://opendpp-node.eu/ns/dpp#status",
      "archivedAt": "https://opendpp-node.eu/ns/dpp#archivedAt",
      "retentionUntil": "https://opendpp-node.eu/ns/dpp#retentionUntil",
      "category": "https://opendpp-node.eu/contexts/dpp/v1#category",
      "grai": "https://opendpp-node.eu/contexts/dpp/v1#grai",
      "originCountry": "https://opendpp-node.eu/contexts/dpp/v1#originCountry",
      "facilityDetails": "https://opendpp-node.eu/contexts/dpp/v1#facilityDetails"
    }
  ],
  "@type": "DigitalProductPassport",
  "@id": "https://opendpp-node.eu/8003/09501101530003CRATE001/21/3c7e1f52-8d4a-4b9e-a6c0-5f2d8e1b7a93",
  "id": "3c7e1f52-8d4a-4b9e-a6c0-5f2d8e1b7a93",
  "productId": "09501101530003CRATE001",
  "digitalLinkUri": "https://opendpp-node.eu/8003/09501101530003CRATE001/21/3c7e1f52-8d4a-4b9e-a6c0-5f2d8e1b7a93",
  "digitalSeal": null,
  "signingPublicKey": null,
  "status": "ACTIVE",
  "archivedAt": null,
  "retentionUntil": null,
  "proof": null,
  "createdAt": "2026-06-01T08:00:00.000Z",
  "updatedAt": "2026-06-12T09:41:00.000Z",
  "economicOperator": {
    "@type": "EconomicOperator",
    "id": "4f6f0b9c-2d71-4f3a-8e5b-1c9d7a2e6b40",
    "name": "Voltaic Cells Manufacturing GmbH",
    "regId": "EU-DEFAULT-001",
    "role": "MANUFACTURER"
  },
  "manufacturingFacility": null,
  "metadata": {
    "category": "iron-steel",
    "grai": "09501101530003CRATE001",
    "originCountry": "DE",
    "facilityDetails": "[REDACTED - Privileged Access Required]"
  },
  "category": "iron-steel",
  "grai": "09501101530003CRATE001",
  "originCountry": "DE",
  "facilityDetails": "[REDACTED - Privileged Access Required]"
}
application/aas+json AasEnvironment
Example 200 response
{
  "assetAdministrationShells": [
    {
      "id": "urn:opendpp:aas:3c7e1f52-8d4a-4b9e-a6c0-5f2d8e1b7a93",
      "idShort": "AAS_09501101530003CRATE001",
      "assetInformation": {
        "assetKind": "Instance",
        "globalAssetId": "urn:opendpp:asset:4f6f0b9c-2d71-4f3a-8e5b-1c9d7a2e6b40:09501101530003CRATE001"
      }
    }
  ],
  "submodels": [
    {
      "id": "urn:opendpp:submodel:general:3c7e1f52-8d4a-4b9e-a6c0-5f2d8e1b7a93",
      "idShort": "GeneralProductInformation"
    },
    {
      "id": "urn:opendpp:submodel:compliance",
      "idShort": "ComplianceMetadata"
    }
  ],
  "conceptDescriptions": []
}
application/vc+jwt string

A UNTP DigitalProductPassport credential as an enveloping JOSE proof — a compact JWS with protected header {alg:ES256, typ:vc+jwt, kid:<issuerDid>#key-0}. PUBLIC tier only (privileged metadata never enters the VC). The issuer is the tenant's did:web; resolve GET /tenants/{tenantId}/did.json for the verification key. Revocation is the W3C Bitstring Status List referenced by the credential's credentialStatus (served at GET /tenants/{tenantId}/status/revocation). Returned only when the passport has a manufacturing facility with a country of production — otherwise 406 Not Acceptable.

Example 200 response
"eyJhbGciOiJFUzI1NiIsInR5cCI6InZjK2p3dCIsImtpZCI6ImRpZDp3ZWI6..."
application/vc+ld+json object

The same UNTP DigitalProductPassport credential with an EMBEDDED W3C Data Integrity proof (proof.type DataIntegrityProof, proof.cryptosuite ecdsa-jcs-2019) — RFC 8785 JCS canonicalization, multibase base58btc proof.proofValue, signed by the tenant's did:web key (proof.verificationMethod = <issuerDid>#key-<n>; resolve GET /tenants/{tenantId}/did.json). PUBLIC tier; revocation via credentialStatus (Bitstring Status List). Same 406 Not Acceptable when the passport has no manufacturing facility with a country of production. The enveloping application/vc+jwt is the primary, UNTP-mandated representation; this is the optional embedded-proof alternative for Data-Integrity verifiers (#117).

Example 200 response
{}
application/dc+sd-jwt string

The same UNTP DigitalProductPassport credential as an SD-JWT-VC (IETF SD-JWT-VC) — cryptographic selective disclosure. The issuer serves the FULL SD-JWT, <JWS>~<disclosure>~… with protected header {alg:ES256, typ:dc+sd-jwt, kid:<issuerDid>#key-<n>} plus the SD-JWT-VC claims iss (the issuer did:web) and vct (https://opendpp-node.eu/vct/digital-product-passport). A HOLDER may then present any SUBSET of the disclosable credentialSubject claims (materialProvenance / performanceClaim / characteristics / producedAtFacility / countryOfProduction) by dropping disclosures, and it still verifies against the issuer signature — withheld claims survive only as opaque _sd digests. Structural identity (id/type/productCategory/vct/iss) is always in clear; revocation (credentialStatus, Bitstring Status List) is never disclosable. PUBLIC tier; the issuer is the tenant's did:web (resolve GET /tenants/{tenantId}/did.json). Same 406 Not Acceptable when the passport has no manufacturing facility with a country of production. The legacy application/vc+sd-jwt media type is also accepted on the request Accept header (#118).

Example 200 response
"eyJhbGciOiJFUzI1NiIsInR5cCI6ImRjK3NkLWp3dCJ9.eyJpc3MiOiJkaWQ6d2ViOi4uLiIsInZjdCI6Li4ufQ.sig~WyJzYWx0MCIsImNvdW50cnlPZlByb2R1Y3Rpb24iLHsiY291bnRyeUNvZGUiOiJERSJ9XQ~"
text/html string

Server-rendered passport page.

Example 200 response
"string"
400

Invalid GRAI (format / check digit) or — without tenant scope and AI-21 serial — an ambiguous lookup. Bodies omit the success field.

application/json Error
Example 400 response
{
  "error": "Bad Request",
  "message": "Invalid GS1 GRAI format. Standard GS1 Digital Link resolution under AI 8003 requires a 14-digit asset identifier (with a valid modulo-10 check digit) followed by an optional alphanumeric serial component of up to 16 characters. Got: \"0950110153000\""
}
404

No passport matches (content-negotiated HTML/JSON, Vary: Accept), DRAFT hidden from non-owner, or unknown tenant subdomain (JSON only). Bodies omit the success field.

Headers: Vary
application/json Error
Example 404 response
{
  "error": "Not Found",
  "message": "No Digital Product Passport found matching identifier: 09501101530003CRATE001"
}
text/html string

SSR not-found page.

Example 404 response
"string"
406

The requested representation cannot be produced for this resource. Returned when a Verifiable Credential media type (application/vc+jwt, application/vc+ld+json, or application/dc+sd-jwt) is requested for a passport that has no manufacturing facility with a country of production — a conformant UNTP credential cannot be issued without it. Request the default JSON-LD (or another supported representation) instead.

application/json Error
Example 406 response
{
  "error": "Not Acceptable",
  "message": "This passport cannot be represented as a UNTP Verifiable Credential: it has no manufacturing facility with a country of production."
}
429

Public-resolution rate limit exceeded (30 requests/min per IP; no rate-limit headers). With Accept: text/html an HTML page is returned instead.

application/json Error
Example 429 response
{
  "error": "Too Many Requests",
  "message": "Rate limit exceeded. Public passport resolutions are limited to 30 requests per minute."
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
GET /unit/{id} Public · tiered by credentials

Resolve an individual serialised battery unit

Public, content-negotiated view of one individual serialised unit (battery; Reg. (EU) 2023/1542 Art. 77(2)) by its unit UUID, including the embedded SKU/type passport (ofModel, masked by the same tier rules as GET /passport/{id}).

Content negotiation: Accept containing application/vc+jwt (or bare vc+jwt) → a signed PER-UNIT (item-granularity) UNTP DigitalProductPassport credential (public tier; 406 Not Acceptable when the unit's type passport has no manufacturing facility with a country of production); application/vc+ld+json (or bare vc+ld+json) → the same per-unit credential with an embedded ecdsa-jcs-2019 Data Integrity proof, same 406; text/html → server-rendered unit page; everything else → JSON-LD (application/ld+json). No AAS representation on this route. Vary: Accept always set on the 200. The 410 tombstone check (below) precedes content negotiation, so a recycled/ceased unit never yields a vc+jwt or vc+ld+json.

Per-unit telemetry is never public (Annex XIII(2)-(4)): anonymous responses omit currentState/dynamicData and instead carry a restrictedData notice with a /request-access pointer. An owner credential — a tenant API key (op_dpp_token_…) of the owning or operator-bound tenant, sent as Bearer or, legacy, as the literal opendpp_session cookie value (a Console JWT login session does not unlock owner tier) — or a valid grant token (dpp_li_…/dpp_auth_… as Bearer or ?grant=; TENANT, PASSPORT or UNIT scope) unlocks currentState and dynamicData — up to the 500 most recent events, newest first. Invalid credentials silently degrade to the public tier (never 401/402/403). Grant-unlocked responses add Cache-Control: private, no-store + Referrer-Policy: no-referrer. No permission string (public endpoint).

Art. 77(8) tombstone: once the unit's status is RECYCLED (or ceasedAt is set) this URL answers 410 Gone with a minimal tombstone for everyone — grants and owner credentials do NOT override it (the owning tenant retains internal access via GET /api/v1/units/{id}).

Every resolution is access-audit-logged with an anonymized IP. Rate limit: 30 requests/min/IP (in-memory public limiter; two-field 429 body without success). The limiter adds no headers of its own — x-ratelimit-* headers come from the global platform limit, which applies on top.

Parameters

NameInTypeDescription
id path string required

The battery unit's server-assigned UUID (AI-21 serial resolution via GET /01/{gtin14}/21/{serial} redirects here).

grant query string optional

Capability grant token (dpp_li_… / dpp_auth_…); equivalent to Authorization: Bearer. Minted tokens are the prefix + 32 hex characters; the server matches any prefixed token against stored hashes. Treat as a secret.

pattern ^dpp_(li|auth)_[A-Za-z0-9]{20,64}$

Example

curl -X GET "https://opendpp-node.eu/unit/string"

Responses

200

The unit document in the negotiated representation. Telemetry keys (currentState, dynamicData) only for owner/grant tiers; restrictedData notice for the anonymous public.

Headers: Vary, Cache-Control, Referrer-Policy
application/ld+json PublicBatteryUnitJsonLd
Example 200 response
{
  "@context": [
    "https://opendpp-node.eu/contexts/dpp/v1",
    {
      "BatteryUnit": "https://opendpp-node.eu/ns/dpp#BatteryUnit",
      "serialNumber": "https://opendpp-node.eu/ns/dpp#serialNumber",
      "ofModel": "https://opendpp-node.eu/ns/dpp#ofModel",
      "currentState": "https://opendpp-node.eu/ns/dpp#currentState",
      "dynamicData": "https://opendpp-node.eu/ns/dpp#dynamicData",
      "stateOfHealth": "https://opendpp-node.eu/ns/dpp#stateOfHealth",
      "cycleCount": "https://opendpp-node.eu/ns/dpp#cycleCount",
      "restrictedData": "https://opendpp-node.eu/ns/dpp#restrictedData",
      "repurposedFrom": "https://opendpp-node.eu/ns/dpp#repurposedFrom",
      "successorUnits": "https://opendpp-node.eu/ns/dpp#successorUnits"
    }
  ],
  "@type": "BatteryUnit",
  "@id": "https://opendpp-node.eu/01/09501101530003/21/BATT-2026-000451",
  "id": "5d8e2c41-7b9a-4e3f-8c2d-6a1f0b9e4d72",
  "serialNumber": "BATT-2026-000451",
  "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003/21/BATT-2026-000451",
  "status": "IN_SERVICE",
  "manufacturedAt": "2026-01-15T08:00:00.000Z",
  "repurposedFrom": null,
  "successorUnits": [],
  "ofModel": {
    "@context": [
      "https://opendpp-node.eu/contexts/dpp/v1",
      {
        "DigitalProductPassport": "https://opendpp-node.eu/ns/dpp#DigitalProductPassport",
        "economicOperator": "https://opendpp-node.eu/ns/dpp#economicOperator",
        "manufacturingFacility": "https://opendpp-node.eu/ns/dpp#manufacturingFacility",
        "metadata": "https://opendpp-node.eu/ns/dpp#metadata",
        "digitalSeal": "https://opendpp-node.eu/ns/dpp#digitalSeal",
        "signingPublicKey": "https://opendpp-node.eu/ns/dpp#signingPublicKey",
        "status": "https://opendpp-node.eu/ns/dpp#status",
        "archivedAt": "https://opendpp-node.eu/ns/dpp#archivedAt",
        "retentionUntil": "https://opendpp-node.eu/ns/dpp#retentionUntil",
        "category": "https://opendpp-node.eu/contexts/dpp/v1#category",
        "gtin": "https://opendpp-node.eu/contexts/dpp/v1#gtin",
        "originCountry": "https://opendpp-node.eu/contexts/dpp/v1#originCountry",
        "detailedPerformance": "https://opendpp-node.eu/contexts/dpp/v1#detailedPerformance",
        "facilityDetails": "https://opendpp-node.eu/contexts/dpp/v1#facilityDetails"
      }
    ],
    "@type": "DigitalProductPassport",
    "@id": "https://opendpp-node.eu/01/09501101530003",
    "id": "9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b",
    "productId": "09501101530003",
    "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003",
    "digitalSeal": null,
    "signingPublicKey": null,
    "status": "ACTIVE",
    "archivedAt": null,
    "retentionUntil": null,
    "proof": null,
    "createdAt": "2026-06-01T08:00:00.000Z",
    "updatedAt": "2026-06-12T09:41:00.000Z",
    "economicOperator": {
      "@type": "EconomicOperator",
      "id": "4f6f0b9c-2d71-4f3a-8e5b-1c9d7a2e6b40",
      "name": "Voltaic Cells Manufacturing GmbH",
      "regId": "EU-DEFAULT-001",
      "role": "MANUFACTURER"
    },
    "manufacturingFacility": {
      "@type": "Facility",
      "id": "7c1e5a2d-9b4f-4c8e-a3d6-2f8b0e4c9a71",
      "gln": "0950110153007",
      "name": "Voltaic Gigafactory Brandenburg",
      "activity": "cell assembly",
      "country": "DE"
    },
    "metadata": {
      "category": "batteries",
      "gtin": "09501101530003",
      "originCountry": "DE",
      "detailedPerformance": "[REDACTED - Privileged Access Required]",
      "facilityDetails": "[REDACTED - Privileged Access Required]"
    },
    "category": "batteries",
    "gtin": "09501101530003",
    "originCountry": "DE",
    "detailedPerformance": "[REDACTED - Privileged Access Required]",
    "facilityDetails": "[REDACTED - Privileged Access Required]"
  },
  "restrictedData": {
    "reason": "LEGITIMATE_INTEREST_REQUIRED",
    "reference": "Regulation (EU) 2023/1542, Annex XIII(2)-(4)",
    "description": "Per-unit dynamic data (state of health, cycle counts, negative events, temperature) is accessible only to persons with a legitimate interest and to authorities.",
    "howToRequest": "/request-access?unit=5d8e2c41-7b9a-4e3f-8c2d-6a1f0b9e4d72"
  },
  "createdAt": "2026-01-15T09:00:00.000Z",
  "updatedAt": "2026-06-12T09:41:00.000Z"
}
text/html string

Server-rendered unit page (telemetry sections only for owner/grant tiers).

Example 200 response
"string"
application/vc+jwt string

A per-UNIT (item-granularity) UNTP DigitalProductPassport credential as an enveloping JOSE proof — a compact JWS with protected header {alg:ES256, typ:vc+jwt, kid:<issuerDid>#key-<n>}. credentialSubject.idGranularity is "item", with the real GS1 AI-21 serial as itemNumber and a link back to the SKU/type passport credential. PUBLIC tier only (per-unit telemetry never enters the VC). The issuer is the tenant's did:web (resolve GET /tenants/{tenantId}/did.json); revocation is the W3C Bitstring Status List referenced by credentialStatus (GET /tenants/{tenantId}/status/revocation) — the unit has its own bit, distinct from the type passport's. Returned only when the unit's type passport has a manufacturing facility with a country of production — otherwise 406 Not Acceptable. A RECYCLED/ceased unit answers 410 Gone (the tombstone precedes this branch), never a credential.

Example 200 response
"string"
application/vc+ld+json object

The per-UNIT credential (item granularity) with an EMBEDDED W3C Data Integrity proof (proof.cryptosuite ecdsa-jcs-2019, RFC 8785 JCS, multibase base58btc proofValue) instead of the enveloping JWS — did:web-signed, with the same idGranularity:"item" / GS1 AI-21 itemNumber / type-passport link / per-unit Bitstring Status List bit, and the same 406 (no facility/country) and 410 (recycled) conditions as the vc+jwt form (#117).

Example 200 response
{}
application/dc+sd-jwt string

The per-UNIT credential (item granularity) as an SD-JWT-VC for cryptographic selective disclosure (<JWS>~<disclosure>~…, typ: dc+sd-jwt; legacy vc+sd-jwt accepted) — a holder may present a subset of the disclosable credentialSubject claims while the issuer signature stays intact. Same idGranularity:"item" / GS1 AI-21 itemNumber / type-passport link / per-unit Bitstring Status List bit, and the same 406 (no facility/country) and 410 (recycled) conditions as the vc+jwt form (#251 RE-Q — parity with GET /passport/{id}).

Example 200 response
"string"
404

No unit with that id (a malformed UUID also resolves to this 404). Content-negotiated: HTML page for Accept: text/html, JSON otherwise; Vary: Accept set. Body omits the success field. A request on an unknown tenant workspace host receives the platform-level JSON 404 (No tenant company found for subdomain: …) before this handler runs.

Headers: Vary
application/json Error
Example 404 response
{
  "error": "Not Found",
  "message": "No serialised unit found matching identifier: 5d8e2c41-7b9a-4e3f-8c2d-6a1f0b9e4d72"
}
text/html string

SSR not-found page.

Example 404 response
"string"
406

The requested representation cannot be produced for this resource. Returned when a Verifiable Credential media type (application/vc+jwt, application/vc+ld+json, or application/dc+sd-jwt) is requested for a passport that has no manufacturing facility with a country of production — a conformant UNTP credential cannot be issued without it. Request the default JSON-LD (or another supported representation) instead.

application/json Error
Example 406 response
{
  "error": "Not Acceptable",
  "message": "This passport cannot be represented as a UNTP Verifiable Credential: it has no manufacturing facility with a country of production."
}
410

Gone — the unit was RECYCLED (or ceasedAt is set): the battery passport has ceased to exist (Art. 77(8)). A minimal tombstone is returned to everyone; grants and owner credentials do not override it. Content-negotiated: HTML tombstone for Accept: text/html, JSON-LD otherwise.

application/ld+json BatteryUnitTombstoneJsonLd
Example 410 response
{
  "@context": [
    "https://opendpp-node.eu/contexts/dpp/v1",
    {
      "BatteryUnit": "https://opendpp-node.eu/ns/dpp#BatteryUnit",
      "serialNumber": "https://opendpp-node.eu/ns/dpp#serialNumber",
      "ceasedAt": "https://opendpp-node.eu/ns/dpp#ceasedAt"
    }
  ],
  "@type": "BatteryUnit",
  "@id": "https://opendpp-node.eu/01/09501101530003/21/BATT-2024-000118",
  "id": "1f4c8a92-6e3d-4b7a-9d5c-8e2a0f6b3c14",
  "serialNumber": "BATT-2024-000118",
  "status": "RECYCLED",
  "ceasedAt": "2026-03-01T10:00:00.000Z",
  "notice": "This battery has been recycled. Its battery passport has ceased to exist (Regulation (EU) 2023/1542, Art. 77(8)).",
  "ofModelUrl": "/passport/9b2fa884-1c3d-4e5f-8a6b-7c8d9e0f1a2b"
}
text/html string

SSR tombstone page.

Example 410 response
"string"
429

Public-resolution rate limit exceeded (30 requests/min per IP; no rate-limit headers). With Accept: text/html an HTML page is returned instead.

application/json Error
Example 429 response
{
  "error": "Too Many Requests",
  "message": "Rate limit exceeded. Public passport resolutions are limited to 30 requests per minute."
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
POST /api/v1/gs1/gtin Public

Compute a GTIN check digit from a company prefix + item reference

The actionable counterpart to the non-GS1 ingest advisory (#255): given the GS1 company prefix your organisation legally owns plus an item reference, OpenDPP computes the GS1 mod-10 check digit and returns the resulting 14-digit GTIN + a Digital Link preview. Set the GTIN as a passport productId to get a scannable GS1 Digital Link.

It ONLY completes the check digit — it never allocates a GS1 company prefix or asserts ownership (a real GTIN requires a prefix licensed to you by GS1). gs1CompanyPrefix is REQUIRED; a request with none is refused (400). gs1CompanyPrefix + itemRef must be exactly 13 digits (the check digit forms the 14th) and both must be digit strings, else 400.

Public + stateless (pure arithmetic; no tenant data). No authentication required.

Rate limit (changed in 1.12.0): an anonymous caller gets 2 requests/min per IP — a per-route cap that replaces the global ceiling. Send an Authorization header (any valid API key) and the normal ladder applies instead: the global authenticated ceiling, with your per-key tier bucket underneath it. Standard x-ratelimit-* headers; 429 with retry-after when exceeded.

Request body required

application/json object
gs1CompanyPrefix string required

The GS1 company prefix your organisation legally owns (digits only, typically 7-10 digits).

itemRef string

The item reference (digits only) that, appended to the prefix, makes exactly 13 digits.

Example request
{
  "gs1CompanyPrefix": "0950110153",
  "itemRef": "000"
}

Example

curl -X POST "https://opendpp-node.eu/api/v1/gs1/gtin" \
  -H "Content-Type: application/json" \
  -d '{
    "gs1CompanyPrefix": "0950110153",
    "itemRef": "000"
  }'

Responses

200

The computed GTIN + Digital Link preview.

application/json object
success boolean required
gtin string required

The 14-digit GTIN (prefix + itemRef + check digit).

gs1CompanyPrefix string required
itemRef string required
checkDigit string required

The computed GS1 mod-10 check digit (the 14th digit).

digitalLink string (uri) required

A Digital Link preview https://opendpp-node.eu/01/{gtin} — resolvable once a passport uses this GTIN as its productId.

note string required

The ownership caveat: OpenDPP computes only the check digit and never allocates a prefix or asserts ownership.

Example 200 response
{
  "success": true,
  "gtin": "09501101530003",
  "gs1CompanyPrefix": "0950110153",
  "itemRef": "000",
  "checkDigit": "3",
  "digitalLink": "https://opendpp-node.eu/01/09501101530003",
  "note": "OpenDPP computed the GS1 mod-10 check digit for the 13-digit body you supplied. Use this GTIN ONLY if your organisation legally owns this GS1 company prefix — OpenDPP does not allocate prefixes or assert ownership. Set it as the passport productId to get a scannable GS1 Digital Link."
}
400

gs1CompanyPrefix missing (we never fabricate a GTIN), non-digit input, or prefix + itemRef not exactly 13 digits. Body: {success:false, error:"Bad Request", message}.

application/json object
success boolean
error string
message string
Example 400 response
{
  "success": true,
  "error": "string",
  "message": "string"
}
POST /api/v1/gs1/decode Public

Decode GS1 scan data / element string / Digital Link into structured AIs + HRI

Decodes raw scanner output — AIM-symbology-prefixed scan data (e.g. ]Q1https://id.gs1.org/01/09501101532007/21/VM-1, ]C1010950…), a bracketed GS1 element string ((01)09501101532007(21)VM-1), or a Digital Link URI — into its structured Application Identifiers, the Human-Readable Interpretation (HRI), and a Digital Link that resolves on this node. Parsing is performed by GS1's authoritative Barcode Syntax Engine (vendored WASM), so check digits and the AI grammar are validated, not approximated.

Public + stateless — no permission and no tenant data is touched; it complements the public resolver. Supply exactly one of scanData, elementString, digitalLink; zero or more than one returns 400. After decoding, GET the returned digitalLinkUri (the canonical path rehosted on this node) to resolve the passport/unit.

Errors: missing/multiple/over-long input, or a value GS1's grammar rejects, returns 400 (Provide exactly one of: scanData, elementString, digitalLink or Not a valid GS1 <kind>: <engine message>); 503 if the engine is unavailable.

Rate limit (changed in 1.12.0): an anonymous caller gets 2 requests/min per IP — a per-route cap that replaces the global ceiling, so this endpoint stays a convenience for integrators rather than a free scripted service. Send an Authorization header (any valid API key) and the normal ladder applies instead: the global authenticated ceiling, with your per-key tier bucket underneath it. Standard x-ratelimit-* headers on both paths; 429 with retry-after when exceeded.

Request body required

application/json object

Provide exactly one of the three input forms.

scanData string

Raw scanner output including its AIM symbology identifier prefix (e.g. ]Q1… QR, ]d2… DataMatrix, ]C1… GS1-128).

max length 4096
elementString string

Bracketed GS1 AI element string, e.g. (01)09501101532007(21)VM-1.

max length 4096
digitalLink string

A GS1 Digital Link URI (http/https).

max length 4096
Example request
{
  "scanData": "]Q1https://id.gs1.org/01/09501101532007/21/VM-LFP100-26-1"
}

Example

curl -X POST "https://opendpp-node.eu/api/v1/gs1/decode" \
  -H "Content-Type: application/json" \
  -d '{
    "scanData": "]Q1https://id.gs1.org/01/09501101532007/21/VM-LFP100-26-1"
  }'

Responses

200

Decoded GS1 data. canonicalUpi is the host-independent identity (https://id.gs1.org/…); digitalLinkUri is that same path rehosted on this node's resolver host (GET it to resolve). elementString/canonicalUpi/digitalLinkUri are null and ai is empty when the input is syntactically valid GS1 but carries no AI data.

application/json object
success boolean required
input string required

Which input form was decoded: scanData, elementString or digitalLink.

elementString string | null required

Bracketed AI element string, e.g. (01)09501101532007(21)VM-1.

hri array<string> required

Human-Readable Interpretation lines, e.g. ["(01) 09501101532007", "(21) VM-1"].

canonicalUpi string | null required

Host-independent canonical Digital Link (https://id.gs1.org/…).

digitalLinkUri string | null required

The canonical path rehosted on this node's resolver host — GET it to resolve.

ai object required

Application Identifier → value map, e.g. {"01": "09501101532007", "21": "VM-1"}.

additional properties allowed
Example 200 response
{
  "success": true,
  "input": "scanData",
  "elementString": "(01)09501101532007(21)VM-LFP100-26-1",
  "hri": [
    "(01) 09501101532007",
    "(21) VM-LFP100-26-1"
  ],
  "canonicalUpi": "https://id.gs1.org/01/09501101532007/21/VM-LFP100-26-1",
  "digitalLinkUri": "https://opendpp-node.eu/01/09501101532007/21/VM-LFP100-26-1",
  "ai": {
    "21": "VM-LFP100-26-1",
    "01": "09501101532007"
  }
}
400

Missing/multiple inputs, over-long input, or a value GS1's grammar rejects. Messages: Provide exactly one of: scanData, elementString, digitalLink, Input exceeds 4096 characters, Not a valid GS1 <kind>: <engine message>.

application/json Error
Example 400 response
{
  "success": false,
  "error": "Bad Request",
  "message": "Not a valid GS1 digitalLink: AI (01) value is too short"
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
503

The GS1 Syntax Engine (WASM) could not be loaded.

application/json Error
Example 503 response
{
  "success": false,
  "error": "Service Unavailable",
  "message": "GS1 Syntax Engine is unavailable"
}
POST /api/v1/gs1/decode/batch Public

Batch-decode many GS1 scans / element strings / Digital Links in one request

Batch form of POST /api/v1/gs1/decode for line-side / warehouse stations capturing many scans per second. Send { "items": [ … ] } (≤200), each item exactly one of scanData/elementString/digitalLink, and receive a results array aligned to input order — each entry either a decoded scan (ok: true, the same fields as the single-scan 200 minus success) or an error (ok: false + error). Partial-success: one bad item never fails the batch — the request returns 200 and per-item failures are reported in place. Parsing uses GS1's authoritative Barcode Syntax Engine (vendored WASM). Public + stateless (no permission, no tenant data).

Errors: a missing/empty/non-array items, or more than 200 items, returns 400; a body over the 256 KiB route cap returns 413; 503 if the engine is unavailable.

Rate limit (changed in 1.12.0): an anonymous caller gets 2 requests/min per IP — a per-route cap that replaces the global ceiling, so this endpoint stays a convenience for integrators rather than a free scripted service. Send an Authorization header (any valid API key) and the normal ladder applies instead: the global authenticated ceiling, with your per-key tier bucket underneath it. Standard x-ratelimit-* headers on both paths; 429 with retry-after when exceeded.

Request body required

application/json object
items array<object> required

Up to 200 scans, each an object with exactly one of scanData/elementString/digitalLink.

min items 1 · max items 200
scanData string
max length 4096
elementString string
max length 4096
digitalLink string
max length 4096
Example request
{
  "items": [
    {
      "scanData": "]Q1https://id.gs1.org/01/09501101532007/21/VM-LFP100-26-1"
    },
    {
      "elementString": "(01)09501101532007"
    },
    {
      "digitalLink": "not-a-valid-link"
    }
  ]
}

Example

curl -X POST "https://opendpp-node.eu/api/v1/gs1/decode/batch" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      {
        "scanData": "]Q1https://id.gs1.org/01/09501101532007/21/VM-LFP100-26-1"
      },
      {
        "elementString": "(01)09501101532007"
      },
      {
        "digitalLink": "not-a-valid-link"
      }
    ]
  }'

Responses

200

Per-item decode results aligned to input order (partial-success — one bad item never fails the batch).

application/json object
success boolean required
count integer required

Number of items decoded (== items.length).

results array<object | object> required

One entry per input item, in order.

ok enum required
one of: true
input string required
elementString string | null required
hri array<string> required
canonicalUpi string | null required
digitalLinkUri string | null required
ai object required
additional properties allowed
ok enum required
one of: false
error string required
Example 200 response
{
  "success": true,
  "count": 3,
  "results": [
    {
      "ok": true,
      "input": "scanData",
      "elementString": "(01)09501101532007(21)VM-LFP100-26-1",
      "hri": [
        "(01) 09501101532007",
        "(21) VM-LFP100-26-1"
      ],
      "canonicalUpi": "https://id.gs1.org/01/09501101532007/21/VM-LFP100-26-1",
      "digitalLinkUri": "https://opendpp-node.eu/01/09501101532007/21/VM-LFP100-26-1",
      "ai": {
        "21": "VM-LFP100-26-1",
        "01": "09501101532007"
      }
    },
    {
      "ok": true,
      "input": "elementString",
      "elementString": "(01)09501101532007",
      "hri": [
        "(01) 09501101532007"
      ],
      "canonicalUpi": "https://id.gs1.org/01/09501101532007",
      "digitalLinkUri": "https://opendpp-node.eu/01/09501101532007",
      "ai": {
        "01": "09501101532007"
      }
    },
    {
      "ok": false,
      "error": "Not a valid GS1 digitalLink: the URI is not a valid GS1 Digital Link"
    }
  ]
}
400

Missing/empty/non-array items, or more than 200 items. Messages: Provide an 'items' array of scans to decode, 'items' must be a non-empty array, 'items' exceeds the 200-item batch limit.

application/json Error
Example 400 response
{
  "success": false,
  "error": "Bad Request",
  "message": "'items' exceeds the 200-item batch limit"
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
503

The GS1 Syntax Engine (WASM) could not be loaded.

application/json Error
Example 503 response
{
  "success": false,
  "error": "Service Unavailable",
  "message": "GS1 Syntax Engine is unavailable"
}

Verifiable Credentials

Issuer trust endpoints that back the UNTP Verifiable Credential representations: the workspace's did:web DID document (public keys only) and its W3C Bitstring Status List for revocation. Unauthenticated; resolve these to verify and revocation-check any OpenDPP-issued credential.

GET /tenants/{tenantId}/did.json Public

Resolve a tenant's did:web DID document

Resolves the issuing workspace's did:web DID document (application/did+json). The DID is did:web:opendpp-node.eu:tenants:{tenantId}, which per the did:web method dereferences here. The document exposes only public key material — the workspace's eIDAS public key(s) as JsonWebKey2020 verification methods, each with a stable #key-<index> id matching the kid of the credentials it signs.

Use it to verify any OpenDPP-issued Verifiable Credential (Accept: application/vc+jwt, application/vc+ld+json, or application/dc+sd-jwt on the public resolution endpoints) without out-of-band key exchange. Both current and retired keys are published and listed in assertionMethod/authentication, so credentials issued before a key rotation still verify; new credentials always use the current key. The optional name is the issuer's authoritative legal name (the same value used in every credential's issuer.name).

No authentication, no permission (public endpoint). Subject only to the global platform rate limit (100 req/min/IP). A workspace that has never provisioned a signing key returns 404. Never returns private key material.

Parameters

NameInTypeDescription
tenantId path string required

The issuing workspace (tenant) id — the {tenantId} of its did:web:opendpp-node.eu:tenants:{tenantId} DID.

Example

curl -X GET "https://opendpp-node.eu/tenants/9b2fa884-3c1d-4e0a-9f6b-2d7c5a1e8b40/did.json"

Responses

200

The tenant's did:web DID document (public keys only).

application/did+json DidWebDocument
Example 200 response
{
  "@context": [
    "https://www.w3.org/ns/did/v1",
    "https://w3id.org/security/suites/jws-2020/v1"
  ],
  "id": "did:web:opendpp-node.eu:tenants:9b2fa884-3c1d-4e0a-9f6b-2d7c5a1e8b40",
  "name": "ACME Manufacturing UAB",
  "verificationMethod": [
    {
      "id": "did:web:opendpp-node.eu:tenants:9b2fa884-3c1d-4e0a-9f6b-2d7c5a1e8b40#key-0",
      "type": "JsonWebKey2020",
      "controller": "did:web:opendpp-node.eu:tenants:9b2fa884-3c1d-4e0a-9f6b-2d7c5a1e8b40",
      "publicKeyJwk": {
        "kty": "EC",
        "crv": "P-256",
        "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
        "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
        "alg": "ES256",
        "use": "sig"
      }
    }
  ],
  "assertionMethod": [
    "did:web:opendpp-node.eu:tenants:9b2fa884-3c1d-4e0a-9f6b-2d7c5a1e8b40#key-0"
  ],
  "authentication": [
    "did:web:opendpp-node.eu:tenants:9b2fa884-3c1d-4e0a-9f6b-2d7c5a1e8b40#key-0"
  ]
}
404

No DID document for this tenant (the workspace has not provisioned a signing key).

application/json Error
Example 404 response
{
  "error": "Not Found",
  "message": "No DID document for this tenant."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
GET /tenants/{tenantId}/status/revocation Public

Tenant revocation status list (W3C Bitstring Status List)

Serves the workspace's W3C Bitstring Status List as a signed enveloping vc+jwt (application/vc+jwt) — a BitstringStatusListCredential whose GZIP+base64 encoded bitstring sets the bit of every passport whose status is RECALLED or DECOMMISSIONED. An OpenDPP-issued DPP credential's credentialStatus.statusListCredential points here; a verifier fetches this list and checks the bit at the credential's statusListIndex to determine whether it has been revoked. Signed by the workspace's current key (stable kid), verifiable via the DID document at /tenants/{tenantId}/did.json.

No authentication, no permission (public endpoint). Subject only to the global platform rate limit (100 req/min/IP). A workspace with no signing key returns 404.

Parameters

NameInTypeDescription
tenantId path string required

The issuing workspace (tenant) id.

Example

curl -X GET "https://opendpp-node.eu/tenants/9b2fa884-3c1d-4e0a-9f6b-2d7c5a1e8b40/status/revocation"

Responses

200

The signed BitstringStatusListCredential as a compact vc+jwt (JWS string). Decode and verify with the issuer's DID key, then read the encoded bitstring.

application/vc+jwt string

Compact JWS (vc+jwt) enveloping the W3C BitstringStatusListCredential.

Example 200 response
"string"
404

No status list for this tenant (unknown workspace or no signing key).

application/json Error
Example 404 response
{
  "error": "Not Found",
  "message": "No status list for this tenant."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}

Schemas & Vocabulary

Machine-readable contracts: per-category ESPR JSON Schemas, the W3C JSON-LD context, and the curated materials vocabulary.

GET /api/v1/schemas/{category} Public

Get the ESPR metadata schema for a product category

Returns the machine-readable ESPR metadata schema for a product category.

Default representation (any Accept NOT containing application/ld+json): the category's JSON Schema draft-07 document, served as application/schema+json, with each known field annotated server-side with a plain-English description (the annotations are AJV-ignored — validation behavior is unchanged). With Accept: application/ld+json: a small JSON-LD @context for the category vocabulary instead. Note: the route does not set Vary: Accept.

The category path segment is lower-cased before lookup. Machine-readable schemas exist for 5 of the 9 ESPR categories: textiles, batteries, electronics, chemicals, construction. The remaining 4 categories accepted by passport metadata validation (cosmetics, toys, iron-steel, aluminium) are validated by built-in server rules and currently return 404 from this endpoint.

No authentication, no permission (public endpoint). No custom rate limiter — only the global platform limit applies (100 req/min/IP, standard x-ratelimit-* headers). Like every documented path except /health, a request on an unknown tenant workspace host receives a platform-level JSON 404 (No tenant company found for subdomain: …, no success field) before this handler runs.

Parameters

NameInTypeDescription
category path enum required

ESPR product category (case-insensitive). Only textiles, batteries, electronics, chemicals and construction have published JSON Schemas; cosmetics, toys, iron-steel and aluminium return 404 here (their validation rules are built into the server).

one of: textiles, batteries, electronics, chemicals, construction, cosmetics, toys, iron-steel, aluminium

Example

curl -X GET "https://opendpp-node.eu/api/v1/schemas/textiles"

Responses

200

The category schema (default) or its JSON-LD vocabulary context (Accept: application/ld+json).

application/schema+json SectorJsonSchemaDocument
Example 200 response
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Chemicals compliance schema",
  "type": "object",
  "required": [
    "category",
    "materialComposition",
    "originCountry",
    "facilityDetails",
    "regulatoryCompliance",
    "hazardClassification",
    "safetyDatasheetUrl",
    "presenceOfSVHC"
  ],
  "properties": {
    "category": {
      "type": "string",
      "enum": [
        "chemicals"
      ],
      "description": "Product sector for this passport: one of textiles, batteries, electronics, chemicals or construction. Must match the chosen schema."
    },
    "materialComposition": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "material",
          "percentage"
        ],
        "properties": {
          "material": {
            "type": "string",
            "minLength": 1,
            "description": "Name of one material in the product, e.g. 'cotton', 'aluminium', 'PET'. Free text, at least one character."
          },
          "percentage": {
            "type": "number",
            "minimum": 0.01,
            "maximum": 100,
            "description": "Share of this item as a percentage (0.01-100). Material/fibre percentages should add up to 100 for the whole product."
          }
        }
      },
      "description": "List the materials in the product, each with its name and weight percentage; required under ESPR for transparency and recycling."
    },
    "originCountry": {
      "type": "string",
      "pattern": "^[A-Z]{2}$",
      "description": "Country where the product was made or assembled. Two-letter ISO 3166 code in capitals, e.g. DE, FR, CN."
    },
    "facilityDetails": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "facilityName",
          "location",
          "activity"
        ],
        "properties": {
          "facilityName": {
            "type": "string",
            "minLength": 1,
            "description": "Name of the supply-chain facility or company at this site, e.g. 'Acme Weaving Mill'."
          },
          "location": {
            "type": "string",
            "minLength": 1,
            "description": "Where the facility is, e.g. city and country or full address; used for supply-chain traceability."
          },
          "activity": {
            "type": "string",
            "minLength": 1,
            "description": "What this facility does in the chain, e.g. 'spinning', 'assembly', 'final manufacturing'."
          },
          "eori": {
            "type": "string",
            "description": "Optional EU EORI number identifying the operator for customs, e.g. DE123456789012345. Leave blank if none."
          },
          "eudrPlots": {
            "type": "array",
            "description": "Land plots where deforestation-risk commodities were produced, with geolocation; required by EUDR to prove deforestation-free sourcing."
          }
        }
      },
      "description": "List of sites in the supply chain (factory, processor, etc.), each with name, location and activity; supports traceability duties."
    },
    "regulatoryCompliance": {
      "type": "object",
      "required": [
        "ceMarking",
        "certificates"
      ],
      "properties": {
        "ceMarking": {
          "type": "boolean",
          "description": "True/false: does the product carry CE marking declaring conformity with applicable EU product law?"
        },
        "certificates": {
          "type": "array",
          "items": {
            "type": "object",
            "required": [
              "name",
              "referenceNumber",
              "issuer"
            ],
            "properties": {
              "name": {
                "type": "string",
                "minLength": 1,
                "description": "Name of the certificate or standard, e.g. 'OEKO-TEX Standard 100' or 'ISO 9001'."
              },
              "referenceNumber": {
                "type": "string",
                "minLength": 1,
                "description": "The certificate's official number or ID so it can be looked up with the issuer."
              },
              "issuer": {
                "type": "string",
                "minLength": 1,
                "description": "Organisation that issued the certificate, e.g. the notified body or testing lab."
              },
              "validUntil": {
                "type": "string",
                "description": "Expiry date of the certificate (e.g. 2026-12-31). Leave blank if it does not expire."
              }
            }
          },
          "description": "List of certificates or approvals held for the product, each with a name, reference number and issuer."
        }
      },
      "description": "Block confirming the product meets EU rules: CE marking status and any certificates held."
    },
    "hazardClassification": {
      "type": "array",
      "items": {
        "type": "string",
        "minLength": 1
      },
      "description": "List of CLP/GHS hazard classes or H-statements for the chemical, e.g. 'Flammable liquid', 'H315'."
    },
    "safetyDatasheetUrl": {
      "type": "string",
      "format": "uri",
      "description": "Web link (https) to the chemical's Safety Data Sheet (SDS), required under REACH/CLP."
    },
    "presenceOfSVHC": {
      "type": "boolean",
      "description": "True/false: does the product contain Substances of Very High Concern above the REACH notification threshold?"
    }
  }
}
application/ld+json SectorVocabularyContext
Example 200 response
{
  "@context": {
    "@vocab": "https://w3id.org/opendpp/schemas/chemicals#",
    "id": "@id",
    "type": "@type",
    "category": "https://w3id.org/opendpp#category",
    "materialComposition": "https://w3id.org/opendpp#materialComposition",
    "originCountry": "https://w3id.org/opendpp#originCountry",
    "facilityDetails": "https://w3id.org/opendpp#facilityDetails",
    "regulatoryCompliance": "https://opendpp.org/compliance#regulatoryCompliance"
  }
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
GET /contexts/dpp/v1 Public

Canonical resolvable JSON-LD context for passport & unit documents

Serves the stable, resolvable W3C JSON-LD @context (application/ld+json) that every public passport and battery-unit JSON-LD document references in its @context array — this is the context to dereference when expanding OpenDPP JSON-LD. It declares @vocab: https://opendpp-node.eu/ns/dpp# (so even dynamic metadata keys expand under the OpenDPP namespace and are never silently dropped by a strict JSON-LD processor) plus explicit term mappings for the core DPP/unit vocabulary (DigitalProductPassport, BatteryUnit, economicOperator, manufacturingFacility, metadata, digitalSeal, signingPublicKey, proof, status, MerkleTreeAttestationProof). Cacheable (Cache-Control: public, max-age=86400).

(The separate /context/v1 serves an older, fixed term-only list and is not the context emitted documents point to.)

No authentication, no permission (public endpoint). No custom rate limiter — only the global platform limit applies (100 req/min/IP, standard x-ratelimit-* headers).

Example

curl -X GET "https://opendpp-node.eu/contexts/dpp/v1"

Responses

200

The canonical @vocab-based JSON-LD context document (fixed content).

application/ld+json DppVocabContextDocument
Example 200 response
{
  "@context": {
    "@version": 1.1,
    "@vocab": "https://opendpp-node.eu/ns/dpp#",
    "dpp": "https://opendpp-node.eu/ns/dpp#",
    "DigitalProductPassport": "dpp:DigitalProductPassport",
    "BatteryUnit": "dpp:BatteryUnit",
    "MerkleTreeAttestationProof": "dpp:MerkleTreeAttestationProof",
    "economicOperator": "dpp:economicOperator",
    "manufacturingFacility": "dpp:manufacturingFacility",
    "metadata": "dpp:metadata",
    "digitalSeal": "dpp:digitalSeal",
    "signingPublicKey": "dpp:signingPublicKey",
    "proof": "dpp:proof",
    "status": "dpp:status"
  }
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
GET /context/v1 Public

W3C JSON-LD context document for passport terms (secondary, fixed term list)

Serves a static W3C JSON-LD @context document (application/ld+json) for the core Digital Product Passport term vocabulary: maps the DPP terms to https://opendpp-node.eu/ns/dpp#… IRIs and createdAt/updatedAt to schema.org dateCreated/dateModified. This is a secondary fixed term list — the context that public passport/unit JSON-LD documents actually reference is the @vocab-based one at GET /contexts/dpp/v1; dereference that when expanding OpenDPP JSON-LD.

No authentication, no permission (public endpoint). No custom rate limiter — only the global platform limit applies (100 req/min/IP, standard x-ratelimit-* headers). Like every documented path except /health, a request on an unknown tenant workspace host receives a platform-level JSON 404 before this handler runs.

Example

curl -X GET "https://opendpp-node.eu/context/v1"

Responses

200

The JSON-LD context document (fixed content).

application/ld+json DppJsonLdContextDocument
Example 200 response
{
  "@context": {
    "DigitalProductPassport": "https://opendpp-node.eu/ns/dpp#DigitalProductPassport",
    "economicOperator": "https://opendpp-node.eu/ns/dpp#economicOperator",
    "metadata": "https://opendpp-node.eu/ns/dpp#metadata",
    "digitalSeal": "https://opendpp-node.eu/ns/dpp#digitalSeal",
    "signingPublicKey": "https://opendpp-node.eu/ns/dpp#signingPublicKey",
    "proof": "https://opendpp-node.eu/ns/dpp#proof",
    "createdAt": "http://schema.org/dateCreated",
    "updatedAt": "http://schema.org/dateModified"
  }
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
GET /api/v1/materials API key

List the platform-curated material vocabulary

Lists active entries from the platform-global material vocabulary that powers the searchable material/fiber/chemistry pickers in the passport form. This is shared reference data, deliberately not tenant-scoped, so DPP data stays comparable across tenants.

Auth: any authenticated session — Bearer API key, Bearer JWT, or opendpp_session cookie. No specific permission string is required and subscription status is not checked, so this endpoint never returns 402. On a tenant-subdomain host, credentials belonging to a different tenant receive 403 with message Cross-tenant access blocked..

Filtering & ordering: kind filters by vocabulary kind — an unrecognized value is silently ignored (the filter simply isn't applied; no 400). search is a trimmed, case-insensitive substring match on name (blank values ignored). Only active entries are returned, ordered by kind ascending then name ascending. limit is clamped to 1–1000 (default 1000); there is no pagination.

Envelope caveat: the 200 body is { "materials": [...] } — there is no success field on this endpoint.

Curation: this vocabulary is curated by the platform operator — the API is read-only for tenant credentials. Free-text material values in passport metadata remain allowed but are never auto-added to this vocabulary.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
kind query enum optional

Filter by vocabulary kind. Unrecognized values are silently ignored (no error; the filter is not applied).

one of: material, fiber, chemistry, substance, hazard, crm
search query string optional

Case-insensitive substring match on the entry name (value is trimmed; blank values ignored).

limit query integer optional

Maximum number of entries to return. Clamped to 1–1000 (out-of-range numeric values are clamped, not rejected); a non-numeric value falls back to the default 1000.

default 1000 · min 1 · max 1000

Example

curl -X GET "https://opendpp-node.eu/api/v1/materials" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

Active vocabulary entries matching the filters, ordered by kind then name ascending. Note: no success field in this envelope.

Example 200 response
{
  "materials": [
    {
      "id": "9b2fa884-3c1d-4e0a-9f6b-2d7c5a1e8b40",
      "name": "Lithium Iron Phosphate (LFP)",
      "kind": "chemistry",
      "casNumber": "15365-14-7",
      "description": "Cathode chemistry for stationary storage and EV cells"
    },
    {
      "id": "9b2fa884-77aa-4b2e-8c3d-0f1e2a3b4c5d",
      "name": "Organic Cotton",
      "kind": "fiber",
      "casNumber": null,
      "description": null
    }
  ]
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}

QR Codes

Export GS1-Digital-Link QR codes (PNG/SVG, 128–2048 px, GS1 quiet zone) for passports and battery units.

GET /api/v1/passports/{id}/qr API key

Export a print-grade GS1 Digital Link QR code for a passport

Renders the passport's GS1 Digital Link URI (its digitalLinkUri, e.g. https://opendpp-node.eu/01/09501101530003) as a print-grade QR code and returns it as a binary file download. The printed carrier resolves through the public GS1 gateway.

Permission: passport:read (read-only — subscription status is not checked on :read permissions, so this endpoint never returns 402). Works with a Bearer API key, a Bearer JWT, or the opendpp_session cookie — plain same-origin <a href> downloads are supported for browser sessions.

Identifier resolution: {id} is matched first against the passport UUID, then against the caller-supplied productId (GTIN-14/GRAI/SKU), always scoped to your tenant. Credentials scoped to an Economic Operator receive 403 (Your access is restricted to Economic Operator: <operatorId>) when the passport belongs to a different operator.

QR rendering: 4-module quiet zone (GS1 guidance); error-correction level per ecl (default Q, the GS1 recommendation for product labels); size is clamped to 128–2048 px — out-of-range values are clamped to the nearest bound, not rejected, and fractional values are truncated. The response carries Content-Disposition: attachment; filename="qr-<productId>.png" (or .svg); the filename base is the passport's productId with characters outside [A-Za-z0-9._-] replaced by _, truncated to 80 characters.

Errors: an invalid query option returns 400 with one of these exact messages: format must be png or svg, size must be a number, ecl must be M, Q or H. An unknown passport returns 404 with message Passport <id> not found under your Tenant workspace.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

Passport UUID, or the caller-supplied productId (GTIN-14/GRAI/SKU) as a fallback. Resolution is tenant-scoped.

format query enum optional

Output image format. Case-insensitive; any other value returns 400 (format must be png or svg).

one of: png, svg · default "png"
size query integer optional

Rendered width in pixels (PNG) / SVG width attribute. Clamped to 128–2048 — out-of-range values are silently clamped, fractions truncated. A non-numeric value returns 400 (size must be a number).

default 1024 · min 128 · max 2048
ecl query enum optional

QR error-correction level: M (~15% recovery), Q (~25%, GS1 product-label guidance, default) or H (~30%). Case-insensitive; any other value returns 400 (ecl must be M, Q or H). L is intentionally not offered.

one of: M, Q, H · default "Q"
hri query boolean optional

When 1/true, renders the GS1 Human-Readable Interpretation (the bracketed AI string, e.g. (01) 09501101530003 (21) BAT-2026-000123) as vector text beneath the QR symbol — the print-grade GS1 label form (machine-readable QR + the human-readable AI string). Requires format=svg; combining it with format=png returns 400 (hri (Human-Readable Interpretation) labels require format=svg).

default false

Example

curl -X GET "https://opendpp-node.eu/api/v1/passports/string/qr" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  --output qr.png

Responses

200

QR code image encoding the passport's digitalLinkUri. The returned media type follows the format query parameter (no Accept-header negotiation): image/png is a raw PNG bitmap, size px wide; image/svg+xml is a vector SVG document. Delivered as an attachment download.

Headers: Content-Disposition
image/svg+xml string

Vector SVG document (returned when format=svg).

400

Invalid query option. Exact messages: format must be png or svg, size must be a number, ecl must be M, Q or H, hri (Human-Readable Interpretation) labels require format=svg.

application/json Error
Example 400 response
{
  "success": false,
  "error": "Bad Request",
  "message": "ecl must be M, Q or H"
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
POST /api/v1/passports/labels API key

Bulk-export print-grade QR labels for many passports as a ZIP

Renders a GS1 Digital Link QR code for each of the supplied passports and returns them as a single application/zip download (Content-Disposition: attachment; filename="labels.zip") — the export counterpart to the bulk import. One image entry per resolved passport, named <productId>.<png|svg> (characters outside [A-Za-z0-9._-] replaced by _, truncated to 80 chars; duplicate names get a -2, -3, … suffix), plus a manifest.json listing what was included and skipped.

Permission: passport:read (read-only — no subscription/402 gate, and NOT subject to the programmatic API-write entitlement).

Partial success: an id that is unknown, not owned by your tenant, or outside an operator-scoped key's bound operator is skipped and reported in manifest.json ({ id, reason }) — it never fails the whole batch. Only the caller's own passports resolve, so this cannot enumerate another tenant's catalog.

Limits: at most 200 ids per call (MAX_BULK_LABELS, mirrors the bulk-import cap); more returns 400 pointing at the async export. hri: true requires format: "svg" (same constraint as the single QR). size is clamped to 128–2048.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Request body required

application/json object
ids array<string> required

Passport UUIDs and/or productIds (GTIN-14/GRAI/SKU), 1–200 items. Each is resolved tenant-scoped; unresolvable / not-owned ids are skipped and listed in manifest.json.

min items 1 · max items 200
format enum

Image format for every label in the ZIP.

one of: png, svg · default "png"
size integer

Rendered width in px; clamped to 128–2048.

default 1024 · min 128 · max 2048
ecl enum

QR error-correction level (GS1 product-label guidance: Q).

one of: M, Q, H · default "Q"
hri boolean

Overlay the GS1 Human-Readable Interpretation beneath each symbol. Requires format: "svg".

default false
Example request
{
  "ids": [
    "09501101530003",
    "9b2fa884-3c1d-4e0a-9f6b-2d7c5a1e8b40"
  ],
  "format": "svg",
  "hri": true
}

Example

curl -X POST "https://opendpp-node.eu/api/v1/passports/labels" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "ids": [
      "09501101530003",
      "9b2fa884-3c1d-4e0a-9f6b-2d7c5a1e8b40"
    ],
    "format": "svg",
    "hri": true
  }'

Responses

200

A ZIP archive of QR images (one per resolved passport) plus a manifest.json reporting included/skipped ids.

application/zip string (binary)
400

Empty/oversize ids (> 200), an invalid format/size/ecl, or hri: true without format: "svg".

application/json Error
Example 400 response
{
  "success": true,
  "error": "Bad Request",
  "message": "string",
  "requestId": "string",
  "code": "OPERATOR_NOT_BOUND"
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}
GET /api/v1/units/{id}/qr API key

Export a print-grade QR code for an individual battery unit

Renders the battery unit's GS1 Digital Link URI as a print-grade QR code — the AI-21 path segment carries the unit's real physical serial number (e.g. https://opendpp-node.eu/01/09501101530003/21/BAT-2026-000123). This is the carrier each individual battery must wear (per-unit passports, Battery Regulation Art. 77(2)).

Permission: battery:read (read-only — subscription status is not checked on :read permissions, so this endpoint never returns 402). Works with a Bearer API key, a Bearer JWT, or the opendpp_session cookie.

Identifier resolution: {id} is the BatteryUnit UUID only — unlike the passport QR route there is no serial-number fallback. Lookup is tenant-scoped. Credentials scoped to an Economic Operator receive 403 (Your access is restricted to Economic Operator: <operatorId>) when the unit's parent passport belongs to a different operator.

QR rendering: identical pipeline to the passport QR export — 4-module quiet zone, ecl default Q, size clamped to 128–2048 px (clamped, not rejected; fractions truncated). The response carries Content-Disposition: attachment; filename="qr-<serialNumber>.png" (or .svg); the filename base is the unit's serialNumber with characters outside [A-Za-z0-9._-] replaced by _, truncated to 80 characters.

Errors: an invalid query option returns 400 with one of these exact messages: format must be png or svg, size must be a number, ecl must be M, Q or H. An unknown unit returns 404 with message Battery unit <id> not found under your Tenant workspace.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Parameters

NameInTypeDescription
id path string required

BatteryUnit UUID (primary key). Serial numbers are NOT accepted here.

format query enum optional

Output image format. Case-insensitive; any other value returns 400 (format must be png or svg).

one of: png, svg · default "png"
size query integer optional

Rendered width in pixels (PNG) / SVG width attribute. Clamped to 128–2048 — out-of-range values are silently clamped, fractions truncated. A non-numeric value returns 400 (size must be a number).

default 1024 · min 128 · max 2048
ecl query enum optional

QR error-correction level: M (~15% recovery), Q (~25%, GS1 product-label guidance, default) or H (~30%). Case-insensitive; any other value returns 400 (ecl must be M, Q or H).

one of: M, Q, H · default "Q"
hri query boolean optional

When 1/true, renders the GS1 Human-Readable Interpretation (the bracketed AI string, e.g. (01) 09501101530003 (21) BAT-2026-000123) as vector text beneath the QR symbol — the print-grade GS1 label form (machine-readable QR + the human-readable AI string). Requires format=svg; combining it with format=png returns 400 (hri (Human-Readable Interpretation) labels require format=svg).

default false

Example

curl -X GET "https://opendpp-node.eu/api/v1/units/9b2fa884-5e2b-4d1c-8a7f-3e9d0c4b6a21/qr" \
  -H "Authorization: Bearer $OPENDPP_API_KEY" \
  --output qr.png

Responses

200

QR code image encoding the unit's digitalLinkUri (AI-21 = real physical serial). The returned media type follows the format query parameter (no Accept-header negotiation): image/png is a raw PNG bitmap, size px wide; image/svg+xml is a vector SVG document. Delivered as an attachment download.

Headers: Content-Disposition
image/svg+xml string

Vector SVG document (returned when format=svg).

400

Invalid query option. Exact messages: format must be png or svg, size must be a number, ecl must be M, Q or H, hri (Human-Readable Interpretation) labels require format=svg.

application/json Error
Example 400 response
{
  "success": false,
  "error": "Bad Request",
  "message": "format must be png or svg"
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
404

The resource does not exist or is not visible to the calling workspace.

application/json Error
Example 404 response
{
  "success": false,
  "error": "Not Found",
  "message": "Resource not found."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}

eIDAS Keys

Tenant signing-key management. Keys are generated and held server-side in an encrypted vault; private key material is never returned by any endpoint.

POST /api/v1/tenants/rotate-keys API key

Rotate the tenant's eIDAS ECDSA signing key pair

Generates a brand-new ECDSA prime256v1 (P-256) key pair for your workspace's eIDAS advanced-seal signing and rotates it into the encrypted database vault, replacing the previous key. No request body is required; a valid JSON body, if sent, is ignored.

What happens: - The new private key (PKCS#8) is encrypted with AES-256-GCM (per-entry HKDF-derived key; the tenant id is bound as GCM additional authenticated data) and upserted into the vault — the previous private key is overwritten and unrecoverable. - The tenant's published eidasPublicKey is updated to the new public key (SPKI PEM), which is also returned in the response. - A best-effort X.509 identity certificate is minted from the platform seal CA, binding the new key to the tenant's legal name (eIDAS Art. 36(1)(b) creator identification); a certificate-minting failure does not fail the rotation (the certificate fields simply stay null until backfilled).

Operational impact: rotation does not invalidate existing seals. Each sealed passport embeds the signing public key and certificate chain at sealing time, so previously sealed passports keep verifying with their embedded key material. Passports sealed after rotation use the new key.

Permission: key:write. Cookie-session clients must send X-CSRF-Token; Bearer clients are exempt.

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Example

curl -X POST "https://opendpp-node.eu/api/v1/tenants/rotate-keys" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

Key pair rotated. publicKey is the new public key as an SPKI PEM string (the private key never leaves the encrypted vault).

application/json RotateTenantKeysResponse
Example 200 response
{
  "success": true,
  "message": "eIDAS Asymmetric Key Pair generated and rotated in secure DB custody successfully",
  "publicKey": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEb2zJ9bQ4mB1S0aD3kXq8YV5nW7Hc\nPdLmA2tFx0RkNqUjEe6ZsK1vGyTwoCh3M4i5O8rJlD9fXaB0nSgQp7Y2uw==\n-----END PUBLIC KEY-----\n"
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
402

The write is blocked by billing — the workspace subscription is lapsed / its grace period expired (reads are unaffected), OR (on passport-creating writes) the workspace has reached its plan's published-passport cap (code: "passport_quota_exceeded" + quota + upgradeUrl), OR a programmatic API-key write was attempted on a tier without API access (code: "api_access_required" + upgradeUrl; the dashboard/session path is unaffected). A lapsed-subscription block carries no code.

application/json PassportQuotaError
Example 402 response
{
  "success": false,
  "error": "Payment Required",
  "message": "Your workspace subscription status is 'past_due'. Write access is suspended."
}
403

Authenticated but not allowed: the key lacks the required permission, the request crosses workspaces, or an MFA-gated write was attempted without an MFA session.

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "Insufficient permissions. Required: \"passport:create\"."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Key generation, vault encryption, or database failure. The route handler emits the minimal envelope ({success: false, message}, no error key, message taken from the underlying error); a failure inside the authentication layer instead emits the standard three-field envelope.

application/json OperatorMinimalError | Error
Example 500 response
{
  "success": false,
  "message": "tenantId is required for key provisioning"
}
GET /.well-known/opendpp-seal-ca.pem Public

Download the platform seal-CA certificate (PEM)

Downloads the platform seal-CA certificate as PEM. Third parties pin this CA to validate the x5c certificate chains embedded in sealed-passport proof blocks — the chain's leaf certificate binds a tenant's signing key to its legal identity (eIDAS Art. 36(1)(b) creator identification; the seal is an eIDAS *advanced*, not qualified, electronic seal). The certificate is provisioned server-side on first use.

No authentication, no permission (public endpoint). Successful responses carry Cache-Control: public, max-age=3600. Like every documented path except /health, a request on an unknown tenant workspace host receives a platform-level JSON 404 before this handler runs.

Rate limit: 30 requests/min/IP via the in-memory public limiter — note that this route's 429 body carries ONLY {"error": "Too Many Requests"} (no message, no success), unlike the other public resolvers. The global platform limit (100 req/min/IP) applies on top: a global-limit 429 carries the platform's default {statusCode, error, message} body instead, and the global limiter's x-ratelimit-* headers appear on every response from this route. Returns 503 if the seal CA cannot be provisioned or loaded.

Example

curl -X GET "https://opendpp-node.eu/.well-known/opendpp-seal-ca.pem"

Responses

200

The CA certificate, PEM-encoded.

Headers: Cache-Control
application/x-pem-file string

X.509 CA certificate in PEM encoding.

Example 200 response
"-----BEGIN CERTIFICATE-----\nMIIBszCCAVmgAwIBAgIUQ2FDZXJ0RXhhbXBsZURhdGFCYXNlNjQwCgYIKoZIzj0E\nAwIwIDEeMBwGA1UEAwwVT3BlbkRQUCBTZWFsIENBIChEZW1vKQ==\n-----END CERTIFICATE-----\n"
429

Rate limited (30/min/IP in-memory public limiter). This route's 429 body has ONLY the error field — no message, no success. (Any x-ratelimit-* headers on the response belong to the global platform limiter.)

application/json object
error string required
always Too Many Requests
Example 429 response
{
  "error": "Too Many Requests"
}
503

Seal CA not available (provisioning/load failure). Body omits the success field.

application/json Error
Example 503 response
{
  "error": "Service Unavailable",
  "message": "Seal CA not available"
}

Account

Identity of the authenticated API key / session: workspace, role, permissions, operator scope, and passport usage against the tier quota — the integration-facing counterpart to the console's profile endpoints.

GET /api/v1/whoami API key

Identity of the authenticated key / session

Returns a compact, integration-focused view of the calling credential: the workspace, the principal's role and resolved permissions, whether the session is an API key, the operator the key is scoped to (null = workspace-wide), and active-passport usage against the tier quota. Use it to verify a key works, discover the effective permission set, and surface remaining quota.

This is the public counterpart to the console's GET /api/v1/me; profile, localization and billing details are intentionally not exposed here.

Permission: none beyond a valid tenant-scoped session — any API key can call it. Platform-admin sessions are rejected with 403 (they are not tenant-scoped).

Rate limit: your plan's per-key budget applies — Growth 120/min, Scale 600/min, Enterprise unlimited — with a ceiling of 3x that rate across all of the workspace's keys. The per-IP ceiling is raised for Authorization-bearing requests, so it is not the binding limit here. Standard x-ratelimit-* headers; 429 carries Retry-After.

Example

curl -X GET "https://opendpp-node.eu/api/v1/whoami" \
  -H "Authorization: Bearer $OPENDPP_API_KEY"

Responses

200

The authenticated identity.

application/json WhoamiResponse
Example 200 response
{
  "success": true,
  "tenant": {
    "id": "7c3e9a12-4b5d-4f6e-8a9b-1c2d3e4f5a6b",
    "name": "Demo Manufacturing UAB",
    "subdomain": "demo",
    "tier": "growth",
    "subscriptionStatus": "active"
  },
  "auth": {
    "role": "BRAND_OPERATOR",
    "permissions": [
      "passport:read",
      "passport:create",
      "passport:update",
      "passport:seal"
    ],
    "isApiKeySession": true,
    "operatorId": "4f6d2c1e-8a9b-4d3e-b7c5-0a1f2e3d4c5b"
  },
  "usage": {
    "activePassports": 12,
    "passportLimit": 250
  }
}
401

Missing, invalid, revoked or expired credentials. Send a valid Authorization: Bearer op_dpp_token_… header.

application/json Error
Example 401 response
{
  "success": false,
  "error": "Unauthorized",
  "message": "Missing or invalid authentication. Expected Bearer API key, JWT, or active session cookie."
}
403

The session is not scoped to a tenant workspace (e.g. a platform-admin session).

application/json Error
Example 403 response
{
  "success": false,
  "error": "Forbidden",
  "message": "This session is not scoped to a tenant workspace."
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
500

Unexpected server error. Unhandled errors are normalized by the global error handler to this envelope with a generic message; some routes catch their own failures and return the same envelope with a route-specific message. Details are logged server-side and never returned.

application/json Error
Example 500 response
{
  "success": false,
  "error": "Internal Server Error",
  "message": "An unexpected error occurred"
}

Service

Service metadata and liveness.

GET /health Public

Service health check

Liveness probe. Always returns 200 with the service identity, the current server time (ISO 8601 UTC with milliseconds), and the running build identity (apiVersion/commit/builtAt — the same fields as GET /api/v1/version). No authentication, no permission. Exempt from the platform rate limit (#810) so a monitor or uptime check can poll it freely — it carries no x-ratelimit-* headers. This is the one path exempt from tenant-subdomain resolution — it answers 200 on any host.

Example

curl -X GET "https://opendpp-node.eu/health"

Responses

200

Service is up.

application/json HealthStatus
Example 200 response
{
  "status": "OK",
  "service": "OpenDPP B2B Enterprise Engine",
  "timestamp": "2026-06-12T09:41:00.000Z",
  "apiVersion": "1.0.0",
  "commit": "a7a96d0",
  "builtAt": "2026-06-12T09:30:00Z"
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}
GET /api/v1/version Public

Running API contract version & build identity

Returns the SemVer of the public API contract currently served (apiVersion), plus the source build identity (commit, builtAt). The contract's MAJOR equals the /api/v1 URL major; a breaking change ships as a new /api/v1-style major (/api/v2), never as an edit to this contract — so a stable apiVersion major is a safe thing for an integration or a generated SDK to pin to. commit/builtAt read "unknown" when a build did not inject them. No authentication, no permission; subject only to the global platform rate limit (100 req/min/IP anonymous, higher for Authorization-bearing requests and known crawler user agents; standard x-ratelimit-* headers on responses).

Example

curl -X GET "https://opendpp-node.eu/api/v1/version"

Responses

200

The running API contract version and build identity.

application/json ServiceVersion
Example 200 response
{
  "apiVersion": "1.0.0",
  "commit": "a7a96d0",
  "builtAt": "2026-06-12T09:30:00Z"
}
429

Rate limit exceeded — either your key's per-minute plan budget (or the 3x workspace ceiling above it) or the per-IP ceiling, whichever bit first. Inspect the x-ratelimit-* headers and retry after Retry-After. A 429 is never a credential problem: an invalid or revoked key returns 401.

Headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after
application/json object

Fastify rate-limit plugin default body.

statusCode integer required
code string
error string required
message string required
Example 429 response
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 1 minute"
}

Webhook events

Deliveries are POSTs to your subscribed URL — see the Webhooks endpoints to subscribe, and the signature scheme described per event below.

EVENTpassport.ingested

Passport created or first published

Sent when a passport becomes active for the first time: a non-draft POST /api/v1/passports create, the first publish of a draft via PUT /api/v1/passports/{id}, or each successfully ingested row of POST /api/v1/passports/bulk. Draft creation does NOT emit it, non-publish updates and deletes emit nothing, and AAS ingestion (POST /api/v1/passports/aas/ingest) emits no webhook events at all. The payload is the freshly created passport: status: "ACTIVE" with digitalSeal and proof still null (unless a previously sealed draft is re-published). Emission is enqueued transactionally with the passport write (outbox pattern) and delivered asynchronously to every active subscription whose filter contains passport.ingested or *.

Delivery contract (sender User-Agent: OpenDPP-Webhook-Outbox/1.0): - The body is a JSON envelope { id, type, created, data }: data is the public (redacted) JSON-LD passport document, type is the event name (also in the X-OpenDPP-Event header), and id is the stable delivery id (also in the X-OpenDPP-Delivery header, constant across retries). Owner-tier fields (facility street address, restricted metadata values) are never included. The owner-only facilityDetails metadata key always appears with the masked value [REDACTED - Privileged Access Required] — in metadata, flattened at top level, and in the @context term map — even when the passport never set that key; restricted metadata keys likewise appear masked, with their true leaf hashes preserved in proof.redactedLeaves once sealed. - Success = any HTTP 2xx returned within the 5-second timeout. Redirects are never followed — a 3xx counts as failure. The response body is ignored. - Retries: up to 5 delivery attempts total. Failed attempts 1–4 schedule the next attempt with exponential backoff — ~1 min, 5 min, 30 min, then 2 h after the previous failure. The 5th failed attempt dead-letters the event (no further deliveries) and the workspace receives an in-app notification. - Per-subscription dedup: endpoints that already returned 2xx are not re-POSTed when the same event is retried for sibling subscriptions. Still treat delivery as at-least-once (a 2xx the sender fails to record can re-deliver), but the X-OpenDPP-Delivery id is STABLE across retries, so deduplicate on it for exactly-once. - Signature verification: compute HMAC-SHA256(secret, X-OpenDPP-Timestamp + "." + rawBody) keyed with the FULL whsec_… secret (including the prefix), hex-encode lowercase, constant-time-compare with X-OpenDPP-Signature (bare hex — no v1=/sha256= prefix). Verify over the raw body bytes before any JSON parsing. Reject timestamps older than ~5 minutes; the timestamp and signature are re-minted on every retry attempt, so retried deliveries always carry a fresh, valid pair.

Parameters

NameInTypeDescription
X-OpenDPP-Event header string required

Event type (also present as type in the body envelope).

always passport.ingested
Payload (application/json) WebhookEnvelope
Example payload
{
  "id": "evt_5e8d2c479a1b4f63",
  "type": "passport.ingested",
  "created": "2026-06-12T10:15:02.000Z",
  "data": {
    "@context": [
      "https://opendpp-node.eu/contexts/dpp/v1",
      {
        "DigitalProductPassport": "https://opendpp-node.eu/ns/dpp#DigitalProductPassport",
        "economicOperator": "https://opendpp-node.eu/ns/dpp#economicOperator",
        "manufacturingFacility": "https://opendpp-node.eu/ns/dpp#manufacturingFacility",
        "metadata": "https://opendpp-node.eu/ns/dpp#metadata",
        "digitalSeal": "https://opendpp-node.eu/ns/dpp#digitalSeal",
        "signingPublicKey": "https://opendpp-node.eu/ns/dpp#signingPublicKey",
        "status": "https://opendpp-node.eu/ns/dpp#status",
        "archivedAt": "https://opendpp-node.eu/ns/dpp#archivedAt",
        "retentionUntil": "https://opendpp-node.eu/ns/dpp#retentionUntil",
        "category": "https://opendpp-node.eu/contexts/dpp/v1#category",
        "originCountry": "https://opendpp-node.eu/contexts/dpp/v1#originCountry",
        "batteryChemistry": "https://opendpp-node.eu/contexts/dpp/v1#batteryChemistry",
        "facilityDetails": "https://opendpp-node.eu/contexts/dpp/v1#facilityDetails"
      }
    ],
    "@type": "DigitalProductPassport",
    "@id": "https://opendpp-node.eu/01/09501101530003",
    "id": "9b2fa884-5c1d-4e7a-9f3b-6d2c8e0a4b71",
    "productId": "09501101530003",
    "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003",
    "digitalSeal": null,
    "signingPublicKey": null,
    "status": "ACTIVE",
    "archivedAt": null,
    "retentionUntil": null,
    "proof": null,
    "createdAt": "2026-06-12T09:41:00.000Z",
    "updatedAt": "2026-06-12T09:41:00.000Z",
    "economicOperator": {
      "@type": "EconomicOperator",
      "id": "1d4f7a92-3b6e-4c08-8a5d-9e2b7c4f6a13",
      "name": "Volta Demo Industries GmbH",
      "regId": "EU-DEFAULT-001",
      "role": "Manufacturer"
    },
    "manufacturingFacility": {
      "@type": "Facility",
      "id": "7c3e9b15-8d2a-4f6c-b1e7-0a5d4c8f2e96",
      "gln": "0950110153007",
      "name": "Demo Gigafactory One",
      "activity": "Cell manufacturing",
      "country": "DE"
    },
    "metadata": {
      "category": "batteries",
      "originCountry": "DE",
      "batteryChemistry": "LFP",
      "facilityDetails": "[REDACTED - Privileged Access Required]"
    },
    "category": "batteries",
    "originCountry": "DE",
    "batteryChemistry": "LFP",
    "facilityDetails": "[REDACTED - Privileged Access Required]"
  }
}
EVENTpassport.sealed

Passport sealed with an eIDAS advanced seal

Sent when a passport is sealed via POST /api/v1/passports/{id}/seal, transactionally with the seal write. The payload carries the populated digitalSeal, signingPublicKey, and the proof block: merkleRoot always; an x5c certificate chain binding the signing key to the tenant's legal identity when the tenant's signing key has an issued chain; an optional rfc3161 trusted timestamp; and redactedLeaves hashes when the passport carries masked metadata keys. Delivered to every active subscription whose filter contains passport.sealed or *.

Delivery contract (sender User-Agent: OpenDPP-Webhook-Outbox/1.0): - The body is a JSON envelope { id, type, created, data }: data is the public (redacted) JSON-LD passport document, type is the event name (also in the X-OpenDPP-Event header), and id is the stable delivery id (also in the X-OpenDPP-Delivery header, constant across retries). The owner-only facilityDetails metadata key always appears with the masked value [REDACTED - Privileged Access Required] (in metadata, flattened at top level, and in the @context term map) even when the passport never set that key; restricted metadata keys likewise appear masked, with their true leaf hashes in proof.redactedLeaves. - Success = any HTTP 2xx within the 5-second timeout. Redirects are never followed (3xx = failure). Response body ignored. - Retries: up to 5 delivery attempts total. Failed attempts 1–4 schedule the next attempt ~1 min / 5 min / 30 min / 2 h after the previous failure; the 5th failed attempt dead-letters the event and the workspace is notified in-app. - Per-subscription dedup: endpoints that already returned 2xx are not re-POSTed on retries; still treat delivery as at-least-once, but the X-OpenDPP-Delivery id is STABLE across retries, so deduplicate on it for exactly-once. - Signature verification: HMAC-SHA256(secret, X-OpenDPP-Timestamp + "." + rawBody) keyed with the FULL whsec_… secret, lowercase hex, constant-time compare against X-OpenDPP-Signature (bare hex, no scheme prefix). Verify over the raw body bytes; reject >~5 min timestamp skew; timestamp+signature are re-minted per retry attempt.

Parameters

NameInTypeDescription
X-OpenDPP-Event header string required

Event type (also present as type in the body envelope).

always passport.sealed
Payload (application/json) WebhookEnvelope
Example payload
{
  "id": "evt_5e8d2c479a1b4f63",
  "type": "passport.sealed",
  "created": "2026-06-12T10:15:02.000Z",
  "data": {
    "@context": [
      "https://opendpp-node.eu/contexts/dpp/v1",
      {
        "DigitalProductPassport": "https://opendpp-node.eu/ns/dpp#DigitalProductPassport",
        "economicOperator": "https://opendpp-node.eu/ns/dpp#economicOperator",
        "manufacturingFacility": "https://opendpp-node.eu/ns/dpp#manufacturingFacility",
        "metadata": "https://opendpp-node.eu/ns/dpp#metadata",
        "digitalSeal": "https://opendpp-node.eu/ns/dpp#digitalSeal",
        "signingPublicKey": "https://opendpp-node.eu/ns/dpp#signingPublicKey",
        "status": "https://opendpp-node.eu/ns/dpp#status",
        "archivedAt": "https://opendpp-node.eu/ns/dpp#archivedAt",
        "retentionUntil": "https://opendpp-node.eu/ns/dpp#retentionUntil",
        "category": "https://opendpp-node.eu/contexts/dpp/v1#category",
        "originCountry": "https://opendpp-node.eu/contexts/dpp/v1#originCountry",
        "batteryChemistry": "https://opendpp-node.eu/contexts/dpp/v1#batteryChemistry",
        "facilityDetails": "https://opendpp-node.eu/contexts/dpp/v1#facilityDetails"
      }
    ],
    "@type": "DigitalProductPassport",
    "@id": "https://opendpp-node.eu/01/09501101530003",
    "id": "9b2fa884-5c1d-4e7a-9f3b-6d2c8e0a4b71",
    "productId": "09501101530003",
    "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003",
    "digitalSeal": "MEUCIQDx5n0L8q2vY7c3T1k9rWm4ZpB6aH8sJdQfN2eK0uXgRwIgVtY3mC1bL7pDqE5fS9hA2nO4xWzUjMK6cTiPeB8oG1s=",
    "signingPublicKey": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEx7Qb1k2mP4cV9sJh3nT6fL0wR8dZ\n5yA1uE7gXiK2oB4qNvC6tS9jM8hW0aFrD3pUzGmYLk5eHxOI1cR7vJbT2g==\n-----END PUBLIC KEY-----\n",
    "status": "ACTIVE",
    "archivedAt": null,
    "retentionUntil": null,
    "proof": {
      "@type": [
        "MerkleTreeAttestationProof"
      ],
      "type": "MerkleTreeAttestationProof",
      "signatureAlgorithm": "ECDSA-P256-SHA256-over-MerkleRoot",
      "created": "2026-06-12T10:15:02.000Z",
      "proofPurpose": "assertionMethod",
      "verificationMethod": "https://opendpp-node.eu/passport/9b2fa884-5c1d-4e7a-9f3b-6d2c8e0a4b71#key-1",
      "signatureValue": "MEUCIQDx5n0L8q2vY7c3T1k9rWm4ZpB6aH8sJdQfN2eK0uXgRwIgVtY3mC1bL7pDqE5fS9hA2nO4xWzUjMK6cTiPeB8oG1s=",
      "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEx7Qb1k2mP4cV9sJh3nT6fL0wR8dZ\n5yA1uE7gXiK2oB4qNvC6tS9jM8hW0aFrD3pUzGmYLk5eHxOI1cR7vJbT2g==\n-----END PUBLIC KEY-----\n",
      "x5c": [
        "MIIBxjCCAWygAwIBAgIUKp3c8f0AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AbMAoGCCqGSM49BAMCMCExHzAdBgNVBAMMFk9wZW5EUFAgU2VhbCBJc3N1aW5nIENB"
      ],
      "merkleRoot": "7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069"
    },
    "createdAt": "2026-06-12T09:41:00.000Z",
    "updatedAt": "2026-06-12T10:15:02.000Z",
    "economicOperator": {
      "@type": "EconomicOperator",
      "id": "1d4f7a92-3b6e-4c08-8a5d-9e2b7c4f6a13",
      "name": "Volta Demo Industries GmbH",
      "regId": "EU-DEFAULT-001",
      "role": "Manufacturer"
    },
    "manufacturingFacility": {
      "@type": "Facility",
      "id": "7c3e9b15-8d2a-4f6c-b1e7-0a5d4c8f2e96",
      "gln": "0950110153007",
      "name": "Demo Gigafactory One",
      "activity": "Cell manufacturing",
      "country": "DE"
    },
    "metadata": {
      "category": "batteries",
      "originCountry": "DE",
      "batteryChemistry": "LFP",
      "facilityDetails": "[REDACTED - Privileged Access Required]"
    },
    "category": "batteries",
    "originCountry": "DE",
    "batteryChemistry": "LFP",
    "facilityDetails": "[REDACTED - Privileged Access Required]"
  }
}
EVENTpassport.recalled

Passport recalled

Sent when PUT /api/v1/passports/{id}/status transitions a passport to RECALLED, transactionally with the status write. The payload is the public JSON-LD passport with status: "RECALLED" (seal/proof fields reflect whatever state the passport was in when recalled). Delivered to every active subscription whose filter contains passport.recalled or *.

Delivery contract (sender User-Agent: OpenDPP-Webhook-Outbox/1.0): - The body is a JSON envelope { id, type, created, data }: data is the public (redacted) JSON-LD passport document, type is the event name (also in the X-OpenDPP-Event header), and id is the stable delivery id (also in the X-OpenDPP-Delivery header, constant across retries). The owner-only facilityDetails metadata key always appears with the masked value [REDACTED - Privileged Access Required] (in metadata, flattened at top level, and in the @context term map) even when the passport never set that key; restricted metadata keys likewise appear masked. - Success = any HTTP 2xx within the 5-second timeout. Redirects are never followed (3xx = failure). Response body ignored. - Retries: up to 5 delivery attempts total. Failed attempts 1–4 schedule the next attempt ~1 min / 5 min / 30 min / 2 h after the previous failure; the 5th failed attempt dead-letters the event and the workspace is notified in-app. - Per-subscription dedup: endpoints that already returned 2xx are not re-POSTed on retries; still treat delivery as at-least-once, but the X-OpenDPP-Delivery id is STABLE across retries, so deduplicate on it for exactly-once. - Signature verification: HMAC-SHA256(secret, X-OpenDPP-Timestamp + "." + rawBody) keyed with the FULL whsec_… secret, lowercase hex, constant-time compare against X-OpenDPP-Signature (bare hex, no scheme prefix). Verify over the raw body bytes; reject >~5 min timestamp skew; timestamp+signature are re-minted per retry attempt.

Parameters

NameInTypeDescription
X-OpenDPP-Event header string required

Event type (also present as type in the body envelope).

always passport.recalled
Payload (application/json) WebhookEnvelope
Example payload
{
  "id": "evt_5e8d2c479a1b4f63",
  "type": "passport.recalled",
  "created": "2026-06-12T10:15:02.000Z",
  "data": {
    "@context": [
      "https://opendpp-node.eu/contexts/dpp/v1",
      {
        "DigitalProductPassport": "https://opendpp-node.eu/ns/dpp#DigitalProductPassport",
        "economicOperator": "https://opendpp-node.eu/ns/dpp#economicOperator",
        "manufacturingFacility": "https://opendpp-node.eu/ns/dpp#manufacturingFacility",
        "metadata": "https://opendpp-node.eu/ns/dpp#metadata",
        "digitalSeal": "https://opendpp-node.eu/ns/dpp#digitalSeal",
        "signingPublicKey": "https://opendpp-node.eu/ns/dpp#signingPublicKey",
        "status": "https://opendpp-node.eu/ns/dpp#status",
        "archivedAt": "https://opendpp-node.eu/ns/dpp#archivedAt",
        "retentionUntil": "https://opendpp-node.eu/ns/dpp#retentionUntil",
        "category": "https://opendpp-node.eu/contexts/dpp/v1#category",
        "originCountry": "https://opendpp-node.eu/contexts/dpp/v1#originCountry",
        "batteryChemistry": "https://opendpp-node.eu/contexts/dpp/v1#batteryChemistry",
        "facilityDetails": "https://opendpp-node.eu/contexts/dpp/v1#facilityDetails"
      }
    ],
    "@type": "DigitalProductPassport",
    "@id": "https://opendpp-node.eu/01/09501101530003",
    "id": "9b2fa884-5c1d-4e7a-9f3b-6d2c8e0a4b71",
    "productId": "09501101530003",
    "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003",
    "digitalSeal": null,
    "signingPublicKey": null,
    "status": "RECALLED",
    "archivedAt": null,
    "retentionUntil": null,
    "proof": null,
    "createdAt": "2026-06-12T09:41:00.000Z",
    "updatedAt": "2026-06-12T11:02:45.000Z",
    "economicOperator": {
      "@type": "EconomicOperator",
      "id": "1d4f7a92-3b6e-4c08-8a5d-9e2b7c4f6a13",
      "name": "Volta Demo Industries GmbH",
      "regId": "EU-DEFAULT-001",
      "role": "Manufacturer"
    },
    "manufacturingFacility": {
      "@type": "Facility",
      "id": "7c3e9b15-8d2a-4f6c-b1e7-0a5d4c8f2e96",
      "gln": "0950110153007",
      "name": "Demo Gigafactory One",
      "activity": "Cell manufacturing",
      "country": "DE"
    },
    "metadata": {
      "category": "batteries",
      "originCountry": "DE",
      "batteryChemistry": "LFP",
      "facilityDetails": "[REDACTED - Privileged Access Required]"
    },
    "category": "batteries",
    "originCountry": "DE",
    "batteryChemistry": "LFP",
    "facilityDetails": "[REDACTED - Privileged Access Required]"
  }
}
EVENTpassport.status_updated

Passport status changed (decommission or reactivation)

Sent when PUT /api/v1/passports/{id}/status performs a non-recall transition: decommissioning (DECOMMISSIONED, which also sets retentionUntil and starts the retention clock) or reactivation back to ACTIVE. Subscribe to it directly with the passport.status_updated filter (or *).

Delivery contract (sender User-Agent: OpenDPP-Webhook-Outbox/1.0): - The body is a JSON envelope { id, type, created, data }: data is the public (redacted) JSON-LD passport document, type is the event name (also in the X-OpenDPP-Event header), and id is the stable delivery id (also in the X-OpenDPP-Delivery header, constant across retries). The owner-only facilityDetails metadata key always appears with the masked value [REDACTED - Privileged Access Required] (in metadata, flattened at top level, and in the @context term map) even when the passport never set that key; restricted metadata keys likewise appear masked. - Success = any HTTP 2xx within the 5-second timeout. Redirects are never followed (3xx = failure). Response body ignored. - Retries: up to 5 delivery attempts total. Failed attempts 1–4 schedule the next attempt ~1 min / 5 min / 30 min / 2 h after the previous failure; the 5th failed attempt dead-letters the event and the workspace is notified in-app. - Per-subscription dedup: endpoints that already returned 2xx are not re-POSTed on retries; still treat delivery as at-least-once, but the X-OpenDPP-Delivery id is STABLE across retries, so deduplicate on it for exactly-once. - Signature verification: HMAC-SHA256(secret, X-OpenDPP-Timestamp + "." + rawBody) keyed with the FULL whsec_… secret, lowercase hex, constant-time compare against X-OpenDPP-Signature (bare hex, no scheme prefix). Verify over the raw body bytes; reject >~5 min timestamp skew; timestamp+signature are re-minted per retry attempt.

Parameters

NameInTypeDescription
X-OpenDPP-Event header string required

Event type (also present as type in the body envelope).

always passport.status_updated
Payload (application/json) WebhookEnvelope
Example payload
{
  "id": "evt_5e8d2c479a1b4f63",
  "type": "passport.status_updated",
  "created": "2026-06-12T10:15:02.000Z",
  "data": {
    "@context": [
      "https://opendpp-node.eu/contexts/dpp/v1",
      {
        "DigitalProductPassport": "https://opendpp-node.eu/ns/dpp#DigitalProductPassport",
        "economicOperator": "https://opendpp-node.eu/ns/dpp#economicOperator",
        "manufacturingFacility": "https://opendpp-node.eu/ns/dpp#manufacturingFacility",
        "metadata": "https://opendpp-node.eu/ns/dpp#metadata",
        "digitalSeal": "https://opendpp-node.eu/ns/dpp#digitalSeal",
        "signingPublicKey": "https://opendpp-node.eu/ns/dpp#signingPublicKey",
        "status": "https://opendpp-node.eu/ns/dpp#status",
        "archivedAt": "https://opendpp-node.eu/ns/dpp#archivedAt",
        "retentionUntil": "https://opendpp-node.eu/ns/dpp#retentionUntil",
        "category": "https://opendpp-node.eu/contexts/dpp/v1#category",
        "originCountry": "https://opendpp-node.eu/contexts/dpp/v1#originCountry",
        "batteryChemistry": "https://opendpp-node.eu/contexts/dpp/v1#batteryChemistry",
        "facilityDetails": "https://opendpp-node.eu/contexts/dpp/v1#facilityDetails"
      }
    ],
    "@type": "DigitalProductPassport",
    "@id": "https://opendpp-node.eu/01/09501101530003",
    "id": "9b2fa884-5c1d-4e7a-9f3b-6d2c8e0a4b71",
    "productId": "09501101530003",
    "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003",
    "digitalSeal": null,
    "signingPublicKey": null,
    "status": "DECOMMISSIONED",
    "archivedAt": null,
    "retentionUntil": "2041-06-12T10:30:00.000Z",
    "proof": null,
    "createdAt": "2026-06-12T09:41:00.000Z",
    "updatedAt": "2026-06-12T10:30:00.000Z",
    "economicOperator": {
      "@type": "EconomicOperator",
      "id": "1d4f7a92-3b6e-4c08-8a5d-9e2b7c4f6a13",
      "name": "Volta Demo Industries GmbH",
      "regId": "EU-DEFAULT-001",
      "role": "Manufacturer"
    },
    "manufacturingFacility": {
      "@type": "Facility",
      "id": "7c3e9b15-8d2a-4f6c-b1e7-0a5d4c8f2e96",
      "gln": "0950110153007",
      "name": "Demo Gigafactory One",
      "activity": "Cell manufacturing",
      "country": "DE"
    },
    "metadata": {
      "category": "batteries",
      "originCountry": "DE",
      "batteryChemistry": "LFP",
      "facilityDetails": "[REDACTED - Privileged Access Required]"
    },
    "category": "batteries",
    "originCountry": "DE",
    "batteryChemistry": "LFP",
    "facilityDetails": "[REDACTED - Privileged Access Required]"
  }
}
EVENTpassport.updated

Passport edited in place

Sent when an already-published (non-draft) passport's content is edited in place via PUT /api/v1/passports/{id}, transactionally with the update write. This is distinct from first publish (that emits passport.ingested, not this event) and is never emitted for sealed passports — in-place edits of a sealed passport are rejected with 403. The payload is the updated public JSON-LD passport with status: "ACTIVE". Delivered to every active subscription whose filter contains passport.updated or *.

Delivery contract (sender User-Agent: OpenDPP-Webhook-Outbox/1.0): - The body is a JSON envelope { id, type, created, data }: data is the public (redacted) JSON-LD passport document, type is the event name (also in the X-OpenDPP-Event header), and id is the stable delivery id (also in the X-OpenDPP-Delivery header, constant across retries). The owner-only facilityDetails metadata key always appears with the masked value [REDACTED - Privileged Access Required] (in metadata, flattened at top level, and in the @context term map) even when the passport never set that key; restricted metadata keys likewise appear masked. - Success = any HTTP 2xx within the 5-second timeout. Redirects are never followed (3xx = failure). Response body ignored. - Retries: up to 5 delivery attempts total. Failed attempts 1–4 schedule the next attempt ~1 min / 5 min / 30 min / 2 h after the previous failure; the 5th failed attempt dead-letters the event and the workspace is notified in-app. - Per-subscription dedup: endpoints that already returned 2xx are not re-POSTed on retries; still treat delivery as at-least-once, but the X-OpenDPP-Delivery id is STABLE across retries, so deduplicate on it for exactly-once. - Signature verification: HMAC-SHA256(secret, X-OpenDPP-Timestamp + "." + rawBody) keyed with the FULL whsec_… secret, lowercase hex, constant-time compare against X-OpenDPP-Signature (bare hex, no scheme prefix). Verify over the raw body bytes; reject >~5 min timestamp skew; timestamp+signature are re-minted per retry attempt.

Parameters

NameInTypeDescription
X-OpenDPP-Event header string required

Event type (also present as type in the body envelope).

always passport.updated
Payload (application/json) WebhookEnvelope
Example payload
{
  "id": "evt_5e8d2c479a1b4f63",
  "type": "passport.updated",
  "created": "2026-06-12T10:15:02.000Z",
  "data": {
    "@context": [
      "https://opendpp-node.eu/contexts/dpp/v1",
      {
        "DigitalProductPassport": "https://opendpp-node.eu/ns/dpp#DigitalProductPassport",
        "economicOperator": "https://opendpp-node.eu/ns/dpp#economicOperator",
        "manufacturingFacility": "https://opendpp-node.eu/ns/dpp#manufacturingFacility",
        "metadata": "https://opendpp-node.eu/ns/dpp#metadata",
        "digitalSeal": "https://opendpp-node.eu/ns/dpp#digitalSeal",
        "signingPublicKey": "https://opendpp-node.eu/ns/dpp#signingPublicKey",
        "status": "https://opendpp-node.eu/ns/dpp#status",
        "archivedAt": "https://opendpp-node.eu/ns/dpp#archivedAt",
        "retentionUntil": "https://opendpp-node.eu/ns/dpp#retentionUntil",
        "category": "https://opendpp-node.eu/contexts/dpp/v1#category",
        "originCountry": "https://opendpp-node.eu/contexts/dpp/v1#originCountry",
        "batteryChemistry": "https://opendpp-node.eu/contexts/dpp/v1#batteryChemistry",
        "facilityDetails": "https://opendpp-node.eu/contexts/dpp/v1#facilityDetails"
      }
    ],
    "@type": "DigitalProductPassport",
    "@id": "https://opendpp-node.eu/01/09501101530003",
    "id": "9b2fa884-5c1d-4e7a-9f3b-6d2c8e0a4b71",
    "productId": "09501101530003",
    "digitalLinkUri": "https://opendpp-node.eu/01/09501101530003",
    "digitalSeal": null,
    "signingPublicKey": null,
    "status": "ACTIVE",
    "archivedAt": null,
    "retentionUntil": null,
    "proof": null,
    "createdAt": "2026-06-12T09:41:00.000Z",
    "updatedAt": "2026-06-12T10:30:00.000Z",
    "economicOperator": {
      "@type": "EconomicOperator",
      "id": "1d4f7a92-3b6e-4c08-8a5d-9e2b7c4f6a13",
      "name": "Volta Demo Industries GmbH",
      "regId": "EU-DEFAULT-001",
      "role": "Manufacturer"
    },
    "manufacturingFacility": {
      "@type": "Facility",
      "id": "7c3e9b15-8d2a-4f6c-b1e7-0a5d4c8f2e96",
      "gln": "0950110153007",
      "name": "Demo Gigafactory One",
      "activity": "Cell manufacturing",
      "country": "DE"
    },
    "metadata": {
      "category": "batteries",
      "originCountry": "DE",
      "batteryChemistry": "LFP",
      "facilityDetails": "[REDACTED - Privileged Access Required]"
    },
    "category": "batteries",
    "originCountry": "DE",
    "batteryChemistry": "LFP",
    "facilityDetails": "[REDACTED - Privileged Access Required]"
  }
}

Error & advisory codes

Branch on these machine-stable codes instead of parsing messages. Error.code appears on 4xx bodies; AdvisoryItem.code appears in the non-blocking warnings[] / notices[] channels on success responses. Each carries a developer message and an end-user friendlyMessage (localized via ?lang= / Accept-Language).

Error codes Error.code

CodeStatusWhen
OPERATOR_NOT_BOUND 400 No economic operator is bound to your workspace — register one (POST /api/v1/operators) or pass operatorId.
OPERATOR_AMBIGUOUS 400 Multiple operators are bound to your workspace; pass operatorId to choose which one to attribute to.
OPERATOR_SCOPE_FORBIDDEN 403 Your operator-scoped API key may only act for its own economic operator.
GTIN_CHECK_DIGIT_INVALID 400 A 14-digit productId failed the GS1 mod-10 check digit (a typo'd GTIN is never silently downgraded to a SKU).
GLN_CHECK_DIGIT_INVALID 400 A facility gln failed the GS1 GLN-13 mod-10 check digit.
PASSPORT_DUPLICATE 409 A passport already exists for this (productId, operator) pair — re-import with upsert to update.
PASSPORT_SEALED_IMMUTABLE 403 The passport is sealed and cannot be edited in place — re-seal explicitly after any change.
CATEGORY_IMMUTABLE 400 The ESPR category of a passport is immutable — create a new passport to re-classify a product.
FACILITY_NOT_FOUND 404 The facility does not exist in your workspace (as a resource 404, or a passport's facilityId that resolves to nothing).
FACILITY_DUPLICATE 409 A facility with this GLN is already registered in your workspace.
WEBHOOK_NOT_FOUND 404 The webhook subscription does not exist under your workspace.
WEBHOOK_LIMIT_REACHED 409 The per-workspace webhook-subscription limit has been reached; delete one before adding another.
WEBHOOK_URL_REJECTED 400 The outbound webhook URL was rejected (not a valid public https URL, or blocked by the SSRF guard).

Advisory codes AdvisoryItem.code

CodeChannelWhen
NON_GS1_PRODUCT_ID warning The productId is not a GS1 GTIN-14 / GRAI, so the passport (and any units) have no scannable GS1 Digital Link — it resolves via /passport/{id}.
PII_SHAPE_DETECTED warning The submitted metadata looks like it contains personal data (PII). A Digital Product Passport should carry product data only (ESPR FAQ Q16 / GDPR).
DRAFT_DEMOTED warning Saving with draft:true demoted an already-published passport to DRAFT — it is no longer publicly resolvable.
EORI_NOT_FOUND warning A declared EORI was not found in the EU Commission EOS register (opt-in existence check). The operator was still registered — advisory only.
OPERATOR_AUTO_ATTRIBUTED notice operatorId was omitted, so the passport was attributed to the workspace's first (or only) bound economic operator.
GTIN_AUTO_COPIED notice A valid GTIN-14 (or GRAI) productId was auto-copied into metadata.gtin (or metadata.grai) before storage.

Schemas

Error object

Standard error body. Authenticated-API errors include success: false; some endpoints (and all public resolution errors) omit success and return only error + message.

success boolean

Always false when present. Omitted by public endpoints and some self-service endpoints.

error string required

Short error title (usually the HTTP reason phrase).

message string required

Human-readable explanation.

requestId string

Correlation id for this request, also returned as the X-Request-Id response header on EVERY response. Present in generic (server-error / framework) bodies; quote it to support to correlate with server logs. Adopts a well-formed inbound X-Request-Id if you send one.

code enum

Optional MACHINE-STABLE error code for the developer-facing write/ingest surface (passport / operator / unit / resolver / facility / events / webhooks) — branch on this instead of parsing message. Present on the errors it covers (see src/constants/api-error-codes.ts), omitted otherwise.

one of: OPERATOR_NOT_BOUND, OPERATOR_AMBIGUOUS, OPERATOR_SCOPE_FORBIDDEN, GTIN_CHECK_DIGIT_INVALID, GLN_CHECK_DIGIT_INVALID, COMPRESSED_DIGITAL_LINK, PASSPORT_DUPLICATE, PASSPORT_SEALED_IMMUTABLE, CATEGORY_IMMUTABLE, FACILITY_NOT_FOUND, FACILITY_DUPLICATE, WEBHOOK_NOT_FOUND, WEBHOOK_LIMIT_REACHED, WEBHOOK_URL_REJECTED

ValidationErrorItem object

One field-level finding from ESPR category validation. path uses dot/bracket notation into the metadata object (e.g. materialComposition[0].percentage).

path string required

Dot/bracket path of the offending metadata field.

message string required

Technical validation message.

friendlyMessage string

Localized, human-friendly explanation (language from ?lang= or Accept-Language; 28 languages, default en).

AdvisoryItem object

One non-blocking advisory on a response's warnings[] (a heads-up — the request still succeeded) or notices[] (informational — something helpful the API did). The code is a MACHINE-STABLE handle an interface can switch on, map to its own localized string, or link to docs; the human message (developer-facing) and friendlyMessage (end-user, localizable) wording may change, but the code will not.

code enum required

Stable advisory code. WARNINGS: NON_GS1_PRODUCT_ID (the productId is not a GS1 GTIN/GRAI → no scannable GS1 link), PII_SHAPE_DETECTED (metadata looks like personal data), UNIT_NO_SCANNABLE_LINK (units under a non-GTIN passport have no scannable unit link), DRAFT_DEMOTED (draft:true took an already-published passport offline), EORI_NOT_FOUND (a declared EORI was not in the EU EOS register). NOTICES: OPERATOR_AUTO_ATTRIBUTED (operatorId omitted → the workspace's first bound operator was used), GTIN_AUTO_COPIED (a valid GTIN-14/GRAI productId was copied into metadata.gtin/metadata.grai).

one of: NON_GS1_PRODUCT_ID, PII_SHAPE_DETECTED, UNIT_NO_SCANNABLE_LINK, DRAFT_DEMOTED, EORI_NOT_FOUND, OPERATOR_AUTO_ATTRIBUTED, GTIN_AUTO_COPIED
path string

The field the advisory is about (e.g. productId, draft, regId), when applicable.

message string required

Developer-facing detail (English).

friendlyMessage string required

End-user-facing, localizable summary.

PassportQuotaError object

402 body for a write blocked by billing. Always carries error + message. A block caused by the subscription tier's published-passport CAP additionally sets code: "passport_quota_exceeded" plus quota and upgradeUrl. A programmatic (API-key) write on a tier without API access sets code: "api_access_required" plus upgradeUrl instead. Clients distinguish each from a lapsed-subscription 402 (no code) and can prompt an upgrade.

success boolean

Always false when present.

error string required

Short error title.

message string required

Human-readable explanation.

code string

Machine-readable discriminator. passport_quota_exceeded = the tier's passport cap is reached; api_access_required = a programmatic (API-key) write was attempted on a tier without the API-access entitlement (use the dashboard session, or upgrade to a Growth+ plan); omitted for a lapsed-subscription 402.

quota object

Present only on a passport_quota_exceeded block: current usage vs the tier cap.

tier string

The workspace subscription tier.

activePassports integer

Active (non-draft, non-archived) passports, counted tenant-wide.

passportLimit integer

The tier's passport cap.

upgradeUrl string

Where the workspace owner can upgrade the plan.

WhoamiResponse object

The calling credential's identity: its workspace, the resolved auth principal and permissions, and active-passport usage against the plan quota.

success boolean required
always true
tenant object required
id string required

Workspace (tenant) id.

name string required

Workspace company name.

subdomain string | null required

Workspace subdomain (<subdomain>.opendpp-node.eu), or null if none is assigned.

tier string required

Subscription tier (e.g. pilot, micro, starter, growth, scale, enterprise).

subscriptionStatus string | null required

Billing status string (e.g. active, past_due). When not active, write operations may return 402. No amounts or processor identifiers are exposed.

auth object required
role string required

The principal's role.

permissions array<string> required

Effective permission strings, re-derived server-side from the role (never trusted from the token). May include wildcards like operator:*.

isApiKeySession boolean required

true when authenticated with an API key, false for a session JWT.

operatorId string | null required

The economic operator this credential is scoped to, or null for a workspace-wide key. A scoped key's writes and reads are restricted to this operator.

usage object required
activePassports integer required

Active (non-draft, non-archived) passports counted against the quota — operator-scoped for a scoped key.

passportLimit integer | null required

Tier passport quota, or null for an unlimited tier.

BatteryUnitStatus string

Annex XIII battery-status vocabulary (Battery Reg. (EU) 2023/1542). RECYCLED means the passport has ceased to exist (Art. 77(8)): the public unit view answers 410 Gone whenever status is RECYCLED or ceasedAt is set. Terminality is enforced via ceasedAt, which only the events-route transition stamps (and never clears): the predecessor-refusal check keys on ceasedAt alone, so a unit *created* with initial status RECYCLED (no ceasedAt) is already a public 410 tombstone yet can still be referenced as a predecessor; conversely, once ceasedAt is set the events endpoint will still accept later status values, but the 410 tombstone and the predecessor refusal persist.

BatteryUnitEventType string

Per-unit dynamic-data event category (Annex XIII / Art. 77 telemetry).

FastifyDefaultBadRequest object

Fastify's default 400 error body, returned when a syntactically malformed JSON request body is rejected by the framework before the handler runs (so none of the handler-built {success:false, ...} shapes apply).

statusCode integer required
always 400
code string

Fastify error code, e.g. FST_ERR_CTP_INVALID_JSON_BODY. May be absent.

error string required
always Bad Request
message string required

BatteryUnitRow object

One physical serialised battery (raw persisted row — these routes declare no Fastify response schema, so all model fields are returned as-is). A BatteryUnit is an individual instance of a SKU/type-level passport, carrying its real serial in GS1 AI-21.

id string required
serialNumber string required

The battery's real physical serial number (GS1 AI-21 value). 1–20 URL-safe characters; GS1 recommends ≤ 20.

pattern ^[A-Za-z0-9._-]{1,20}$
digitalLinkUri string (uri) required

Per-unit GS1 Digital Link: {BASE_URL}/{01|8003}/{productId}/21/{serialNumber} — AI 01 for GTIN (and non-GS1 SKUs), 8003 for GRAI. Unique platform-wide.

passportId string required

The SKU/type-level passport this unit is an instance of.

tenantId string required

Owning tenant id (the demo tenant uses the fixed id tenant-demo-opendpp; regular tenants use UUIDs).

manufacturedAt string | null (date-time) required
status BatteryUnitStatus required
ceasedAt string | null (date-time) required

Stamped when the events endpoint transitions status to RECYCLED (Art. 77(8) cease-to-exist); never cleared afterwards, even if a later event changes status again. Non-null means the public unit view is a 410 tombstone and the unit is refused as a predecessorUnitId. Note: a unit *created* with initial status RECYCLED does NOT get ceasedAt stamped (the public view still tombstones on the status alone, but the predecessor refusal does not apply).

predecessorUnitId string | null required

Art. 77(7) lineage: the original unit this battery was repurposed/remanufactured from (null for first-life units).

createdAt string (date-time) required
updatedAt string (date-time) required

BatteryUnitEventRow object

One immutable per-unit telemetry record (raw persisted row). Append-only: no update or delete path exists.

id string required
batteryUnitId string required
tenantId string required
eventType BatteryUnitEventType required
stateOfHealth number | null required

State of health, percent.

min 0 · max 100
cycleCount integer | null required

Cumulative full-equivalent cycles (truncated to an integer on write).

min 0
remainingCapacityAh number | null required

Measured remaining capacity, ampere-hours.

min 0
temperatureC number | null required

Observed temperature, °C.

min -273.15 · max 10000
payload object | array | null required

Free-form additional telemetry/context as persisted: a JSON object or array (both pass the server's typeof check and are stored verbatim); null when omitted or when the submitted value was dropped (any non-object, non-array value).

recordedAt string (date-time) required

When the measurement was taken (client-supplied; server time when omitted).

createdAt string (date-time) required

Immutable append timestamp (server-assigned).

BatteryUnitCreateItem object

One unit to serialise. Validation is per-item: an invalid item is skipped (its error string collected) without failing the rest of the batch.

serialNumber string required

Required. The battery's real physical serial (GS1 AI-21). Trimmed, then must match ^[A-Za-z0-9._-]{1,20}$ (GS1 AI-21's 20-character maximum) — a longer serial is rejected at ingest with a per-item error. Must be unique within the passport (duplicates are skipped with an item error).

pattern ^[A-Za-z0-9._-]{1,20}$
manufacturedAt string | number

Optional. Any value accepted by JavaScript Date parsing (ISO 8601 recommended; epoch milliseconds also work). Invalid dates skip the item.

predecessorUnitId string

Optional Art. 77(7) linkage: id of an existing unit in your tenant (any passport) that this battery was repurposed/remanufactured from. A recycled predecessor (ceasedAt set) is refused — the check keys on ceasedAt, which only the events-route RECYCLED transition stamps. Atomically with creation, a STATUS_CHANGE event ({status, successorUnitId, successorSerial}) is appended to the predecessor and its status set to predecessorStatus.

predecessorStatus enum

Optional; only meaningful with predecessorUnitId. The status the predecessor transitions to. Defaults to REPURPOSED.

one of: REPURPOSED, REMANUFACTURED, REUSED · default "REPURPOSED"

SerializeBatteryUnitsRequest object

Either a single unit object, or a batch wrapper {units: [...]}. Precedence: when units is present and is an array it is used; otherwise the whole body is treated as one unit. (anyOf, not oneOf: a body carrying both a top-level serialNumber and a units array matches both shapes and is accepted by the server — units wins.)

units array<BatteryUnitCreateItem> required

1–200 units per request. An empty array is rejected with 400 Bad Request; more than 200 likewise.

min items 1 · max items 200

SerializeBatteryUnitsResponse object

Returned (201) when at least one unit was created. Partial success is possible: skipped items are reported in errors while units holds the created rows.

success boolean required
always true
message string required

E.g. Serialised 2 individual unit(s) or Serialised 1 individual unit(s), skipped 1.

count integer required

Number of units actually created (equals units.length).

min 1
units array<BatteryUnitRow> required
errors array<string>

Present only when some items were skipped — one plain-English string per skipped item, generally prefixed [<serialNumber>].

warnings array<AdvisoryItem> required

Non-blocking advisories. Carries a single note when the passport's productId is NOT a GS1 GTIN — the created units then have no scannable GS1 unit Digital Link (/01/{gtin}/21/{serial}) and resolve only via /unit/{id}. Empty [] for a GTIN-keyed passport.

BatteryUnitSerialisationFailedError object

400 body when every item in the serialisation batch failed. Note: errors is an array of plain strings and there is no message field (unlike the standard error triple).

success boolean required
always false
error string required
always Serialisation Failed
errors array<string> required
min items 1

BatteryUnitListResponse object

A page of the serialised battery units recorded under one passport, with the paging envelope.

success boolean required
always true
count integer required

Number of items returned in THIS page (≤ limit).

productId string required

The passport's caller-supplied product identifier (GTIN-14 / GRAI / SKU).

units array<BatteryUnitRow> required

All units of the passport, createdAt DESC.

page integer required

1-based page number returned.

limit integer required

Effective page size (default 100, max 200).

total integer required

Total items matching across all pages.

totalPages integer required

Total number of pages (≥ 1).

BatteryUnitDynamicDataEvent object

One telemetry event in the JSON-LD dynamicData history (privileged view only).

@type string required
always BatteryUnitEvent
eventType BatteryUnitEventType required
stateOfHealth number | null required
cycleCount integer | null required
remainingCapacityAh number | null required
temperatureC number | null required
payload object | array | null required

The persisted free-form payload — a JSON object or array (arrays pass the write-path typeof check), null when absent.

recordedAt string (date-time) required

BatteryUnitJsonLd object

JSON-LD document for one serialised battery unit, privileged tenant view (isPrivileged=true): includes currentState + dynamicData telemetry (restricted to legitimate-interest holders/authorities on the public view, where a restrictedData marker appears instead — never on this endpoint).

@context array<string | object> required

JSON-LD context: the shared https://opendpp-node.eu/contexts/dpp/v1 IRI plus an inline term map for the battery-unit vocabulary.

@type string required
always BatteryUnit
@id string (uri) required

The unit's GS1 Digital Link URI (same value as digitalLinkUri).

id string required
serialNumber string required
pattern ^[A-Za-z0-9._-]{1,20}$
digitalLinkUri string (uri) required
status BatteryUnitStatus required
manufacturedAt string | null (date-time) required
repurposedFrom BatteryUnitLineageRef | null required

Art. 77(7) predecessor link. Always null on GET /api/v1/units/{id} — the authenticated handler does not load the lineage relation; use the public resolver GET /unit/{id} to see resolved lineage.

successorUnits array<BatteryUnitLineageRef> required

Units repurposed/remanufactured from this one. Always [] on GET /api/v1/units/{id} (relation not loaded; see repurposedFrom).

ofModel PublicPassportJsonLd required
currentState BatteryUnitCurrentState | null required
dynamicData array<BatteryUnitDynamicDataEvent> required

Full telemetry history, newest first by recordedAt, capped at the 500 most recent events.

max items 500
createdAt string (date-time) required
updatedAt string (date-time) required

BatteryUnitDeleteResponse object

Confirmation that a battery unit was deleted.

success boolean required
always true
message string required
always Battery unit deleted.

RecordBatteryUnitEventRequest object

One telemetry record. All measurements are optional and independently nullable; numeric ranges are enforced with 400 on violation.

eventType BatteryUnitEventType required
stateOfHealth number | null

State of health, percent (0–100).

min 0 · max 100
cycleCount number | null

Cumulative full-equivalent cycles. Fractional values are accepted but truncated to an integer before persisting.

min 0 · max 9007199254740991
remainingCapacityAh number | null

Remaining capacity, ampere-hours.

min 0 · max 9007199254740991
temperatureC number | null

Observed temperature, °C.

min -273.15 · max 10000
payload object | array | null

Free-form JSON with additional telemetry/context. Objects and arrays pass the server's typeof check and are persisted verbatim; any other value (string, number, boolean) — and an explicit null — is silently dropped and stored as null.

recordedAt string | number

When the measurement was taken. Any value accepted by JavaScript Date parsing (ISO 8601 recommended; epoch milliseconds also work); 400 if unparseable. Defaults to the server's current time.

RecordBatteryUnitEventResponse object

Confirmation that a dynamic-data record was appended to a battery unit, echoing the stored event.

success boolean required
always true
message string required
always Dynamic data recorded
event BatteryUnitEventRow required

BatteryUnitEventListResponse object

A battery unit's append-only dynamic-data history, newest first.

success boolean required
always true
count integer required

Equals events.length; never exceeds 500.

min 0 · max 500
serialNumber string required

The unit's physical serial (GS1 AI-21 value).

events array<BatteryUnitEventRow> required

Newest first by recordedAt, capped at the 500 most recent.

max items 500

FacilityRow object

A facility (GS1 GLN) master-data row, exactly as stored. Returned in full to the owning tenant. Public exposure in passport documents differs by format: the *JSON-LD* document exposes id, gln, name, activity and country of a linked facility; the *AAS* export emits only the GLN, name and country (manufacturingFacilityGln/Name/Country). streetAddress, city and postalCode are emitted only to the owning/bound tenant in both formats.

id string required

Facility id (UUID).

gln string required

GS1 Global Location Number — 13 digits with a valid GS1 modulo-10 check digit. Unique platform-wide. Immutable after registration (it is the resolvable UFI).

pattern ^[0-9]{13}$
name string required

Facility display name (trimmed, non-empty).

activity string | null required

Free-text activity, e.g. "Cell assembly", "Final manufacturing", "Recycling". Public in JSON-LD; not emitted in the AAS export.

streetAddress string | null required

Street address. Owner-only: redacted from public JSON-LD and never emitted in AAS.

city string | null required

City. Owner-only: redacted from public JSON-LD and never emitted in AAS.

postalCode string | null required

Postal code. Owner-only: redacted from public JSON-LD and never emitted in AAS.

country string required

2-letter ISO 3166-1 alpha-2 country code, stored uppercase. Public in both JSON-LD and AAS.

pattern ^[A-Z]{2}$
operatorId string | null required

Id of the owning Economic Operator, or null for a tenant-level facility. Set at creation; not updatable via PUT.

tenantId string required

Owning tenant workspace id.

createdAt string (date-time) required
updatedAt string (date-time) required

FacilityCreateRequest object

A facility to register: its GS1 GLN-13, name and country, plus optional activity and street address.

gln string required

GS1 GLN-13. Trimmed, then validated: exactly 13 digits with a valid GS1 modulo-10 check digit. Unique platform-wide (409 on duplicate). Immutable after registration.

pattern ^[0-9]{13}$
name string required

Facility name. Must be a non-empty string; stored trimmed.

min length 1
country string required

2-letter ISO country code (case-insensitive on input; stored uppercase).

pattern ^[A-Za-z]{2}$
activity string

Optional activity, e.g. "Cell assembly". Trimmed; empty/whitespace is stored as null.

streetAddress string

Optional street address (owner-only in public views). Trimmed; empty is stored as null.

city string

Optional city (owner-only in public views). Trimmed; empty is stored as null.

postalCode string

Optional postal code (owner-only in public views). Trimmed; empty is stored as null.

operatorId string

Optional owning Economic Operator id. Must be bound to your tenant workspace (403 otherwise). Empty/whitespace is treated as absent. Operator-scoped API keys may only use their own operator id (it is applied automatically when omitted).

FacilityUpdateRequest object

Partial update. gln and operatorId are immutable — if present they are silently ignored, as is any unknown key. For activity/streetAddress/city/postalCode the *presence* of the key matters: the value is stringified and trimmed, and anything that trims to empty (null, "", or a whitespace-only string) clears the field to null — the same normalization as POST.

name string

New name. Applied only when a non-empty string; empty/whitespace or non-string values are silently ignored (the name can never be cleared).

min length 1
activity string | null

New activity, or null/"" to clear. A whitespace-only string is stored as "" (see schema description).

streetAddress string | null

New street address, or null/"" to clear. A whitespace-only string is stored as "" (see schema description).

city string | null

New city, or null/"" to clear. A whitespace-only string is stored as "" (see schema description).

postalCode string | null

New postal code, or null/"" to clear. A whitespace-only string is stored as "" (see schema description).

country string

New 2-letter ISO country code (400 if a string that does not match; stored uppercase; non-string values are silently ignored).

pattern ^[A-Za-z]{2}$

FacilityCreatedEnvelope object

Confirmation that a facility was registered, carrying the stored record.

success boolean required
always true
message string required
always Facility registered successfully
facility FacilityRow required

FacilityListEnvelope object

A page of the workspace's facilities, with the paging envelope.

success boolean required
always true
count integer required

Number of items returned in THIS page (≤ limit).

facilities array<FacilityRow> required

All facilities in the workspace, sorted by createdAt descending. Operator-scoped keys see only their operator's facilities.

page integer required

1-based page number returned.

limit integer required

Effective page size (default 100, max 200).

total integer required

Total items matching across all pages.

totalPages integer required

Total number of pages (≥ 1).

FacilityEnvelope object

A single facility record.

success boolean required
always true
facility FacilityRow required

FacilityDeletedEnvelope object

Confirmation that a facility was deleted.

success boolean required
always true

GrantRow object

Tenant-facing projection of an access grant. The token hash, issuer user id, revoking actor and request IP are never exposed; raw capability tokens appear only in the one-time issuance/approval responses. All fields are always present (nullable ones serialize as null).

id string required
status enum required

PENDING = undecided third-party request (no token exists yet); ACTIVE = usable token; DENIED = rejected request; REVOKED = soft-revoked.

one of: PENDING, ACTIVE, DENIED, REVOKED
kind enum required

LEGITIMATE_INTEREST (dpp_li_… tokens, tenant-issued or approved from a request) or AUTHORITY (dpp_auth_… tokens, platform-issued for market surveillance; not tenant-revocable).

one of: LEGITIMATE_INTEREST, AUTHORITY
granteeName string required
max length 160
granteeEmail string | null required
max length 254
organization string | null required
max length 200
purpose string | null required

The stated legitimate interest.

max length 2000
scopeType enum required

What the token unlocks on the public resolvers: a single battery unit, a single passport, or the whole workspace.

one of: UNIT, PASSPORT, TENANT
passportId string | null required

Set for PASSPORT scope, and also for UNIT scope (the unit's parent passport). null for TENANT scope.

batteryUnitId string | null required

Set only for UNIT scope.

issuerType enum required

TENANT = issued directly via this API; REQUEST = submitted by a third party through the hosted request-access page; PLATFORM = platform-admin-issued (AUTHORITY grants).

one of: TENANT, PLATFORM, REQUEST
issuerEmail string | null required

E-mail of the issuing user; null when issued by an API key or created from a public request.

decidedAt string | null (date-time) required

When a PENDING request was approved/denied; null for direct issuances.

decidedBy string | null required

The deciding actor: a user e-mail, API_KEY_<keyId> when decided via an API key, or the literal unknown in degenerate authentication states.

expiresAt string (date-time) required

Hard expiry; the public resolvers reject the token after this instant. PENDING requests carry a provisional 90-day expiry that is replaced on approval.

revokedAt string | null (date-time) required
lastUsedAt string | null (date-time) required

Last successful use on a public resolver (book-kept best-effort).

useCount integer required

Successful public-resolver uses (incremented best-effort).

createdAt string (date-time) required
revocable boolean required

Computed: false for AUTHORITY grants (platform-managed), true otherwise.

GrantRouteError object

Error body used by the grants endpoints' route-level errors (400/403/404/409). Unlike the standard error envelope, it has NO success field.

error string required

HTTP reason phrase, e.g. Bad Request, Not Found, Conflict, Forbidden.

message string required

Human-readable explanation.

GrantListResponse object

List envelope for GET /api/v1/grants (paginated).

success boolean required
always true
count integer required

Number of items returned in THIS page (≤ limit).

page integer required

1-based page number returned.

limit integer required

Effective page size (default 100, max 200).

total integer required

Total items matching across all pages.

totalPages integer required

Total number of pages (≥ 1).

grants array<GrantRow> required

Grants for this page (≤ limit), ordered by status ascending then createdAt descending.

max items 200

GrantIssuedResponse object

Returned by direct issuance (201) and request approval (200). token is the raw capability token — shown ONCE here (and, on approval, in the grantee's inspection-link e-mail); only its SHA-256 hash is persisted.

success boolean required
always true
grant GrantRow required
token string required

Legitimate-interest capability token (dpp_li_ + 32 lowercase hex). Present it to public resolution endpoints as Authorization: Bearer <token> or ?grant=<token>. Treat like a password — it cannot be retrieved again.

pattern ^dpp_li_[0-9a-f]{32}$

GrantDecisionResponse object

Returned by deny and revoke: the updated grant, no token.

success boolean required
always true
grant GrantRow required

CreateGrantRequest object

Direct-issuance body. Over-length strings are silently truncated to the documented maximum, not rejected; unknown fields are ignored. The grant kind is always LEGITIMATE_INTEREST — there is no kind/type field.

granteeName string required

Required (whitespace-only is rejected as missing). Truncated to 160 characters.

min length 1 · max length 160
granteeEmail string (email)

Optional. Truncated to 254 characters, then must match a basic e-mail pattern (x@y.z) or the request fails with 400 granteeEmail is invalid. Stored as given (not lowercased).

max length 254
organization string

Optional. Truncated to 200 characters.

max length 200
purpose string

Optional stated legitimate interest. Truncated to 2000 characters.

max length 2000
scopeType enum required

Required. UNIT needs batteryUnitId; PASSPORT needs passportId; TENANT is workspace-wide. Any other value ⇒ 400.

one of: UNIT, PASSPORT, TENANT
passportId string

Required when scopeType is PASSPORT; ignored otherwise. Must be a non-DRAFT passport in this workspace — a missing, cross-tenant, or DRAFT id (or omitting the field entirely) returns 404.

max length 64
batteryUnitId string

Required when scopeType is UNIT; ignored otherwise. Must be a battery unit in this workspace — a missing or cross-tenant id (or omitting the field entirely) returns 404.

max length 64
expiresAt string (date-time) required

Required. Date parsing is lenient (some non-ISO values may be accepted) — ISO 8601 is strongly recommended. Must be in the future and at most 366 days from now.

ApproveGrantRequest object

Approval body — only the final expiry is supplied; everything else comes from the original request.

expiresAt string (date-time) required

Required. Date parsing is lenient (some non-ISO values may be accepted) — ISO 8601 is strongly recommended. Must be in the future and at most 366 days from now. Replaces the request's provisional 90-day expiry.

OperatorRow object

An economic-operator record (EconomicOperator). Operators are scoped to your workspace (each workspace keeps its own row for a given regId). Returned verbatim from the database (no field stripping); nullable fields are serialized as null.

id string required

Operator UUID.

name string required

Legal/display name of the operator.

regId string required

Official registration id (EORI number, VAT id, DUNS, or national business-registry id). Unique within your workspace and immutable after registration.

regIdScheme enum required

Which kind of registration id regId is. null = unspecified national/business id. When EORI, regId is guaranteed to satisfy the EORI syntax ^[A-Z]{2}[A-Za-z0-9]{1,15}$.

one of: EORI, VAT, DUNS, NATIONAL, OTHER, null
role string required

Supply-chain role, free text — e.g. "MANUFACTURER", "IMPORTER", "RETAILER". Defaults to "MANUFACTURER" at registration.

archivedAt string | null (date-time) required

Soft-delete / cessation-of-trading marker. Non-null = the operator is archived (its passports are retained and still publicly resolvable).

createdAt string (date-time) required

RegisterOperatorRequest object

An economic operator to register: its legal name and registration identifier, with an optional identifier scheme and supply-chain role.

name string required

Legal/display name. Ignored if your workspace already has an operator with this regId (the existing record is returned instead).

regId string required

Official registration id (EORI, VAT, DUNS, or national registry id). Unique within your workspace; immutable after registration. Fabricated EORI-MOCK… ids are rejected. When regIdScheme is EORI, must match ^[A-Z]{2}[A-Za-z0-9]{1,15}$ (e.g. DE1234567890).

regIdScheme string | null

Optional declaration of what kind of id regId is. Allowed values: EORI, VAT, DUNS, NATIONAL, OTHER — matched case-insensitively (uppercased server-side). Any other value is rejected with 400. Omit or send null for an unspecified national/business id. Ignored when binding to an existing operator.

role string

Supply-chain role, free text — e.g. MANUFACTURER, IMPORTER, RETAILER. Defaults to MANUFACTURER. Ignored when binding to an existing operator.

default "MANUFACTURER"

RegisterOperatorResponse object

Confirmation that an economic operator was registered, carrying the stored record and any non-blocking advisories.

success boolean required
always true
message string required

Always "Economic Operator supplier registered successfully" (also when an existing operator was bound rather than created).

operator OperatorRow required
warnings array<AdvisoryItem> required

Non-blocking advisories (#404). Carries a single EORI-not-found note when the OPT-IN EORI_EXISTENCE_CHECK is enabled and a declared EORI is not found in the EU EOS register. Empty [] otherwise. Never blocks registration.

UpdateOperatorRequest object

Both fields are optional. Values must be non-empty strings after trimming; anything else (missing, non-string, whitespace-only) is silently ignored. regId and regIdScheme cannot be changed. An omitted body or an empty object {} is accepted and returns the unchanged row.

name string

New display name (trimmed).

role string

New supply-chain role, free text (trimmed) — e.g. MANUFACTURER, IMPORTER, RETAILER.

UpdateOperatorResponse object

The economic operator as stored after the update.

success boolean required
always true
operator OperatorRow required

DeleteOperatorResponse object

Outcome of removing an economic operator: whether it was archived rather than deleted, and how many of its passports were archived with it.

success boolean required
always true
archived boolean required

true = the operator was archived (soft-deleted; passports retained, restorable). false = the operator was hard-deleted (it had no passports).

archivedPassports integer

Number of active passports archived alongside the operator. Present only on the primary archive path (operator had passports); absent on hard deletes and on the foreign-key fallback archive.

min 0

RestoreOperatorResponse object

Outcome of restoring an archived economic operator, including how many of its passports were restored.

success boolean required
always true
restoredPassports integer required

Number of archived passports returned to the active catalogue (archivedAt and retentionUntil cleared). Passports independently DECOMMISSIONED are not restored and not counted.

min 0

RotateTenantKeysResponse object

Confirmation that the workspace's signing key was rotated, returning the new public key.

success boolean required
always true
message string required

Always "eIDAS Asymmetric Key Pair generated and rotated in secure DB custody successfully".

publicKey string required

The new ECDSA prime256v1 (P-256) public key, PEM-encoded (SPKI, -----BEGIN PUBLIC KEY----- block, trailing newline). The matching private key is held only AES-256-GCM-encrypted in the platform vault and is never returned.

OperatorMinimalError object

Minimal error envelope used by the operator/key self-service handlers — note the standard error key is ABSENT (unlike the shared Error schema).

success boolean required
always false
message string required

OperatorListResponse object

The economic operators bound to the calling workspace.

success boolean required
always true
count integer required

Number of operators returned (the list is not paginated).

operators array<OperatorRow> required

OperatorGetResponse object

A single economic operator record.

success boolean required
always true
operator OperatorRow required

PassportMetadataInput object

The ESPR product metadata payload. For non-draft ingestion and for the validate-only endpoints, category is mandatory and must be one of the 9 ESPR categories; each category then mandates its own field set (e.g. textiles require fiberComposition, careInstructions, size; batteries require batteryCategory, chemistry, electrochemicalCapacity, durability, recycledContentShare, carbonFootprint). For five categories — textiles, batteries, electronics, chemicals, construction — the authoritative per-category JSON Schema (required fields, value constraints, field help) is served live at GET /api/v1/schemas/{category}; the other four (cosmetics, toys, iron-steel, aluminium) are validated by built-in server-side rules and GET /api/v1/schemas/{category} returns 404 for them. Cross-field rules are enforced on top: materialComposition (and textile fiberComposition) percentages must sum to 100 ±0.1, originCountry must be a real ISO 3166-1 alpha-2 code, textile hazardous-substance concentrations are checked against REACH ppm limits. A documented set of supplementary objects (e.g. technicalProperties, environmentalFootprint, circularityAttributes, esgDueDiligence, detailedPerformance) produce non-blocking warnings instead of errors when malformed. With draft: true (single ingestion only) validation is skipped entirely and any object is accepted.

category enum

ESPR product category; selects the validation rules. Required whenever validation runs (i.e. always, except draft: true single ingestion).

one of: textiles, batteries, electronics, cosmetics, toys, iron-steel, aluminium, chemicals, construction
originCountry string

ISO 3166-1 alpha-2 country code (validated against the full 249-code set).

pattern ^[A-Z]{2}$
commodityCode string

Optional HS / TARIC commodity code (4–10 digits). Validated for every category when present (a malformed code is a 400). Not category-mandated, but required to project the passport into the EU DPP registry pointer (ESPR Art. 13).

pattern ^[0-9]{4,10}$
additional properties allowed

PassportEnrichmentInput object

Optional presentational (non-regulatory) marketing enrichment, stored OUTSIDE the ESPR-validated metadata and the Merkle seal; it never appears in the JSON-LD passport document. Server-side it is whitelist-sanitized rather than rejected: unknown keys are dropped; tagline is trimmed and capped at 200 chars, description at 4000, image caption at 200, link label at 120; at most 24 images and 24 links are kept; URLs must be http, https, or mailto (any other scheme, e.g. javascript: or data:, is silently dropped). An enrichment that sanitizes down to nothing is stored as null.

tagline string

Short marketing tagline (server-capped at 200 chars).

description string

Marketing description (server-capped at 4000 chars).

images array<object>

Up to 24 kept. Items without a valid http/https/mailto url are dropped.

url string (uri)

http, https, or mailto only.

caption string

Server-capped at 200 chars.

links array<object>

Up to 24 kept. Items without a valid http/https/mailto url are dropped; a missing label defaults to the URL.

label string

Server-capped at 120 chars.

url string (uri)

http, https, or mailto only.

additional properties allowed

PassportCreateRequest object

A passport to create: its product identifier and ESPR category metadata, with optional operator and facility binding, a draft flag, and enrichment held outside the sealed metadata.

productId string required

Product identifier: a GTIN-14 (exactly 14 digits with a valid GS1 mod-10 check digit — auto-copied to metadata.gtin), a GRAI (14-digit numeric asset id with valid check digit + optional up to 16 alphanumeric serial chars, total 14–30 — auto-copied to metadata.grai), or a free-form SKU. Determines the GS1 Application Identifier (01 vs 8003) in the generated Digital Link URI. Whitespace-only values are rejected 400. Unique per economic operator (409 on duplicate).

min length 1
operatorId string

UUID of an EconomicOperator bound to your tenant workspace (403 if not bound). Defaults to your workspace's first bound operator. Operator-scoped API keys force their own operator (403 on mismatch).

facilityId string

Optional UUID of a Facility (GLN-backed Unique Facility Identifier) in your workspace; 400 if not found.

metadata PassportMetadataInput required
draft boolean

When true: skips ALL ESPR/traceability validation, stores the passport with status: "DRAFT" (not publicly resolvable), and emits no webhook. Publish later via a validated edit.

default false

PassportIngestCreated object

201 envelope of POST /api/v1/passports. passport is the public redacted JSON-LD; warnings/notices are always present (possibly empty); vcReady/vcReadyReason report UNTP Verifiable-Credential readiness.

success boolean required
always true
message string required

"Digital Product Passport successfully validated and ingested", or "Draft passport saved" when draft: true.

passport PublicPassportJsonLd required
warnings array<ValidationErrorItem> required

Non-blocking findings — a MIX of ESPR validation warnings (no code) and machine-coded advisories (a code per src/constants/api-advisories.ts, e.g. NON_GS1_PRODUCT_ID, PII_SHAPE_DETECTED). Always present; empty for drafts. See AdvisoryItem for the coded shape.

notices array<AdvisoryItem> required

Informational advisories about helpful things the API did (always coded): OPERATOR_AUTO_ATTRIBUTED (operatorId omitted → the workspace's sole bound operator used), GTIN_AUTO_COPIED (a valid GTIN/GRAI copied into metadata.gtin/grai). Always present; empty when nothing to note.

vcReady boolean

#247: whether this passport can emit a UNTP Verifiable Credential — true only when a manufacturing facility with a country of production is linked (producedAtFacility + countryOfProduction are required by the UNTP DPP schema; a GLN is optional). The passport still publishes and resolves as AAS / JSON-LD / HTML regardless.

vcReadyReason string | null

Null when vcReady is true; otherwise a short, actionable reason (link a facility with a country of production).

PassportValidateOnlyRequest object

A metadata payload to validate against its ESPR category rules without persisting anything.

productId string required

Product identifier (GTIN-14 / GRAI / SKU). Required and checked non-empty (whitespace-only → 400), but not otherwise used by the dry-run.

min length 1
operatorId string

Accepted by the body schema but IGNORED by the validate-only handlers.

metadata PassportMetadataInput required

PassportValidateOnlyResult object

200 envelope of the validate-only endpoints (only the declared keys are emitted).

success boolean required
always true
message string required
category string required

Echo of metadata.category (or "unknown" if absent).

errors array<ValidationErrorItem> required

Always an empty array on 200.

max items 0
warnings array<ValidationErrorItem>

Non-blocking findings (e.g. malformed supplementary objects like circularityAttributes). The key is OMITTED entirely when there are no warnings — it is never an empty array.

PassportValidateOnlyError object

400 envelope of the validate-only endpoints. Three variants: ESPR validation failure (error: "Validation Failed", with success, category, errors[], optional warnings[] — omitted when none; category-validity error items carry no friendlyMessage); whitespace-only productId (error: "Bad Request", category: "unknown", errors: [], no warnings); and structural request rejections (body-schema violations, malformed JSON), which return only error + message.

success boolean
always false
error enum required
one of: Bad Request, Validation Failed
message string required
category string

metadata.category echo, or "unknown" for structural failures.

errors array<ValidationErrorItem>
warnings array<ValidationErrorItem>

Omitted when there are no warnings.

PassportBulkRow object

One bulk-ingestion row. The HTTP layer only requires each row to be an object; rows missing productId or metadata, failing ESPR validation, referencing unbound operators/unknown facilities, or duplicating an existing (productId, operatorId) pair are SKIPPED and reported as strings in the response errors[] — they never fail the whole request (unless every row fails). Bulk rows do not support draft or enrichment, are always created with status: "ACTIVE", skip the EPCIS traceability audit, and do NOT get metadata.gtin/metadata.grai auto-injected.

productId string

GTIN-14 / GRAI / free-form SKU (required in practice; rows without it are skipped with an error string).

operatorId string

Optional EconomicOperator UUID bound to your workspace; defaults to the workspace's first bound operator. Operator-scoped API keys force their operator.

facilityId string

Optional Facility UUID in your workspace; unknown ids skip the row.

additional properties allowed

PassportBulkRequest object

A batch of passports to ingest, with optional dry-run preview and upsert-on-conflict behaviour.

passports array<PassportBulkRow> required

1–200 rows. The bounds are enforced before any row is processed; violations return the default {statusCode, code, error, message} error body.

min items 1 · max items 200
dryRun boolean

When true, every row is validated and the duplicate/operator/facility checks run, but nothing is written — the response is 200 and reports which rows are OK (results) vs failed (errors[]). Powers a pre-import preview.

upsert boolean

When true, a row whose (productId, operator) already exists updates the existing passport instead of being reported as a duplicate (a sealed passport is never overwritten). Enables idempotent re-import of a corrected catalog.

PassportBulkResult object

201 partial-success envelope of POST /api/v1/passports/bulk. Returned whenever at least one row was inserted, even if other rows failed. Each result row carries a vcReady UNTP Verifiable-Credential readiness signal (#247) and a per-row non-blocking warnings[] (#249 non-GS1 advisory + #400 PII-shape privacy advisory; empty when the row is clean).

success boolean required
always true
message string required

Template: "Bulk CSV ingestion finished. Registered <n> passports, skipped <m> rows with errors."

insertedCount integer required

Number of rows actually inserted (= results.length).

min 0
results array<object> required
productId string required
digitalLinkUri string (uri) required

Generated GS1 Digital Link URI https://opendpp-node.eu/{01|8003}/{productId}.

vcReady boolean required

#247: whether this row's passport can emit a UNTP Verifiable Credential — true only when a manufacturing facility with a country of production is linked. On a dryRun preview this reflects the EFFECTIVE facility after import (the row's facility, else the existing passport's preserved one).

vcReadyReason string | null

Null when vcReady is true; otherwise a short, actionable reason (link a facility with a country of production).

warnings array<AdvisoryItem>

Per-row non-blocking advisories — the #249 non-GS1 "no scannable QR" note and the #400 PII-shape privacy advisory. Empty [] when the row is clean; never blocks the row.

errors array<string>

Human-readable per-row failure strings, prefixed [SKU: <productId>] (or "Missing or invalid productId in spreadsheet row"). Present ONLY when at least one row failed — omitted otherwise.

PassportBulkFailure object

400 body of POST /api/v1/passports/bulk when EVERY row failed. Note: errors is an array of STRINGS (not objects) and there is NO message field.

success boolean required
always false
error string required
always Bulk Ingestion Failed
errors array<string> required

One human-readable string per failed row, prefixed [SKU: <productId>] where the productId was readable.

AasEnvironmentInput object

An Asset Administration Shell (AAS) JSON Environment — the format produced by OpenDPP's AAS export of a passport. MUST contain a submodel with idShort: "ComplianceMetadata" whose submodelElements (AAS Property elements and SubmodelElementCollections) are parsed back into the passport metadata object; absence fails 400 Ingestion Failed. MAY contain an eidasVerificationSeal submodel (elements digitalSealHash, cryptographicSignature, pemPublicKey) — when present, the seal is verified against the tenant's SERVER-HELD eIDAS public key (the embedded pemPublicKey is never trusted as the verification key). Body limit 256 KiB.

assetAdministrationShells array<object>

AAS shells. The first shell's assetInformation.specificAssetIds entry with name: "productId" is the last-resort productId source (after metadata.gtin/metadata.grai/metadata.productId).

additional properties allowed
submodels array<object> required

Must include the ComplianceMetadata submodel; may include eidasVerificationSeal.

additional properties allowed
conceptDescriptions array<object>
additional properties allowed
additional properties allowed

AasIngestCreated object

201 envelope of POST /api/v1/passports/aas/ingest. Returned for both newly created passports and in-place updates of existing UNSEALED passports. No webhook event is emitted by this endpoint. vcReady/vcReadyReason report UNTP Verifiable-Credential readiness (#247) and warnings carries the non-GS1 advisory (#249), for parity with POST /api/v1/passports.

success boolean required
always true
message string required
always Digital Product Passport successfully ingested from AAS
passportId string required
productId string required
isSealed boolean required

True when the environment embedded an eidasVerificationSeal submodel (the seal is then stored on the passport).

signatureVerified boolean required

True when the embedded seal verified against the tenant's server-held eIDAS public key. Always false for unsealed documents. (A sealed-but-unverified document never reaches 201 — it fails 400.)

vcReady boolean required

#247: whether the ingested passport can emit a UNTP Verifiable Credential — true only when a manufacturing facility with a country of production is linked. AAS ingestion does not set a facility, so a newly created passport is false; an in-place update preserves whatever facility the existing passport had.

vcReadyReason string | null

Null when vcReady is true; otherwise a short, actionable reason (link a facility with a country of production).

warnings array<ValidationErrorItem> required

#249: non-blocking advisories. Always present (empty array when none); carries the non-GS1 advisory when the resolved productId is not a GS1 GTIN-14/GRAI.

PassportListResponse object

Envelope of GET /api/v1/passports. The response is serialized against a declared response schema: top-level keys other than these four are stripped. There is NO total count.

success boolean required

Always true on 200.

page integer required

Effective page after server-side clamping (min 1).

min 1
limit integer required

Effective page size after server-side clamping (default 10, max 100).

min 1 · max 100
passports array<PassportListItem> required

PassportAasEnvironment object

IDTA Asset Administration Shell environment (returned when Accept contains application/aas+json), role-filtered for the caller's access tier. Identifiers use urn:opendpp:* forms: the shell id is urn:opendpp:aas:<passportUuid> with idShort AAS_<productId> and globalAssetId urn:opendpp:asset:<operatorId>:<productId> (plus specificAssetIds carrying the productId and, when a facility is assigned, its GLN). The environment contains, at minimum, a GeneralProductInformation submodel (urn:opendpp:submodel:general:<passportUuid>) and a ComplianceMetadata submodel (urn:opendpp:submodel:compliance); for a manufacturer/product-identified passport it also carries an IDTA Digital Nameplate submodel (idShort Nameplate) and one or more additive per-category submodel views (ESPR-category views such as CarbonFootprint / TechnicalData, id prefix urn:opendpp:submodel:category:). An eidasVerificationSeal submodel is appended when the tenant has signing keys configured. Loose schema — the full AAS document structure is documented with the public resolution endpoints.

assetAdministrationShells array<object> required
additional properties allowed
submodels array<object> required
additional properties allowed
conceptDescriptions array<object> required
additional properties allowed
additional properties allowed

PassportUpdateRequest object

Body of PUT /api/v1/passports/{id}. Unknown keys are ignored.

metadata object required

Full replacement ESPR metadata object. metadata.category must be one of: textiles, batteries, electronics, cosmetics, toys, iron-steel, aluminium, chemicals, construction. Validated against the category's compliance rules unless draft: true (400 with errors[] on failure — no warnings on this route). A machine-readable JSON Schema is published at GET /api/v1/schemas/{category} for textiles, batteries, electronics, chemicals and construction only — cosmetics, toys, iron-steel and aluminium are validated by built-in rules and return 404 from that endpoint.

draft boolean

true = save as draft: skips ESPR validation and forces status DRAFT (this also demotes an already-published passport back to DRAFT). false/absent = validated save; a DRAFT passport is published (status ACTIVE, emits the passport.ingested webhook).

default false
changeReason string

Free-text reason recorded on the version-history snapshot. Defaults to "API Update".

facilityId string | null

GLN-backed facility assignment. Omit to leave unchanged; null or "" detaches; a UUID attaches a facility that must be owned by your tenant (otherwise 400).

enrichment PassportEnrichmentInput | null

Presentational marketing block stored OUTSIDE the ESPR-validated metadata and the Merkle seal. Include the key (even null/{}) to overwrite; omit to leave unchanged. An empty result after sanitation clears the block.

additional properties allowed

PassportUpdateResponse object

200 envelope of PUT /api/v1/passports/{id}. The passport document is serialized at the PUBLIC redaction tier (owner-only/restricted metadata keys masked) even for the owner. Also carries the vcReady/vcReadyReason UNTP readiness signal (#247) and a non-blocking warnings[].

success boolean required

Always true on 200.

message enum required

"Draft published" when a validated save promoted a DRAFT to ACTIVE; the longer message otherwise.

one of: Draft published, Digital Product Passport successfully updated and history versioned
passport PublicPassportJsonLd required
warnings array<AdvisoryItem> required

Non-blocking advisories. Carries a single note when saving with "draft": true DEMOTED an already-published (ACTIVE/RECALLED/DECOMMISSIONED) passport to DRAFT — it is then no longer publicly resolvable. Empty [] otherwise.

PassportUpdateValidationError object

400 ESPR validation failure body of PUT /api/v1/passports/{id}. DIVERGENCE from POST /api/v1/passports: there is never a warnings array on this route.

success boolean required
always false
error string required
always Validation Failed
message string required
errors array<ValidationErrorItem> required

Blocking validation errors. friendlyMessage is localized via the lang query parameter / Accept-Language (default en).

PassportSealResponse object

200 envelope of POST /api/v1/passports/{id}/seal. digitalSeal is duplicated inside passport.digitalSeal and passport.proof.signatureValue. The passport document is serialized at the PUBLIC redaction tier; masked keys keep their true leaf hashes in proof.redactedLeaves. Note: despite the message wording, this endpoint does not change the passport's status.

success boolean required

Always true on 200.

message string required
always Passport sealed with the tenant's eIDAS advanced electronic seal and published.
digitalSeal string required

Base64 ECDSA P-256 (prime256v1) SHA-256 signature over the metadata Merkle root (eIDAS ADVANCED electronic seal — not a qualified seal, not a W3C DataIntegrityProof).

signingPublicKey string required

PEM-encoded public key of the tenant's signing key pair; verify the seal offline against proof.merkleRoot.

passport PublicPassportJsonLd required
warnings array<AdvisoryItem> required

#255 publish-time re-warning: a single non-GS1 advisory when the sealed passport's productId is not a GS1 GTIN/GRAI (it has no scannable Digital Link — mint a GTIN you own via POST /api/v1/gs1/gtin). Empty [] when the productId is GS1-keyed. Non-blocking.

PassportStatusUpdateRequest object

Body of PUT /api/v1/passports/{id}/status. Only status is read; any other keys are ignored (there is no reason field).

status enum required

Target lifecycle state. DECOMMISSIONED starts the retention clock (retentionUntil = now + the configured retention period, default 15 years); ACTIVE reactivates (clears retentionUntil and archivedAt); RECALLED marks the product recalled. DRAFT is not a valid target — drafts are published via PUT /api/v1/passports/{id}.

one of: ACTIVE, RECALLED, DECOMMISSIONED
additional properties allowed

PassportStatusUpdateResponse object

200 envelope of PUT /api/v1/passports/{id}/status. The passport document is serialized at the PUBLIC redaction tier.

success boolean required

Always true on 200.

message string required

Passport status successfully updated to <STATUS>.

status enum required

The passport's new lifecycle state.

one of: ACTIVE, RECALLED, DECOMMISSIONED
passport PublicPassportJsonLd required

PassportListItem object

One JSON-LD passport document as it appears in GET /api/v1/passports list responses. Same shape as PublicPassportJsonLd with list-specific divergences imposed by the route's declared response serialization: economicOperator never carries role; manufacturingFacility is always null; the @context term map (second array element) is emptied to {}; and proof is emptied to {} on sealed items (null on unsealed) — fetch the single passport for the verifiable proof block.

@context array<string | object> required

Exactly two entries: the context URL https://opendpp-node.eu/contexts/dpp/v1 and an inline term map covering the 9 fixed DPP terms (DigitalProductPassport, economicOperator, manufacturingFacility, metadata, digitalSeal, signingPublicKey, status, archivedAt, retentionUntil) plus one generated term per metadata key (https://opendpp-node.eu/contexts/dpp/v1#<key>).

min items 2 · max items 2
additional properties allowed
@type string required
always DigitalProductPassport
@id string (uri) required

The passport's canonical GS1 Digital Link URI (same value as digitalLinkUri).

id string required

Server-assigned passport UUID.

productId string required

Caller-supplied product identifier: a GTIN-14 (^[0-9]{14}$ with valid GS1 modulo-10 check digit), a GRAI (^[0-9]{14}[A-Za-z0-9]{0,16}$), or a free-form SKU.

digitalLinkUri string (uri) required

SKU/type-level GS1 Digital Link URI: {BASE_URL}/{01|8003}/{productId} (AI-21 carries the passport UUID at SKU level; individual units carry their physical serial instead).

digitalSeal string | null required

eIDAS ADVANCED electronic seal: base64 ECDSA prime256v1 (P-256) signature over the Merkle root of the key-sorted metadata. null when the passport has not been sealed.

signingPublicKey string | null required

PEM public key that verifies digitalSeal. null when unsealed.

status enum required

Passport lifecycle status (serialized as ACTIVE when unset). DRAFT is only ever visible to owner-tier callers — public/grant resolution of a draft returns 404.

one of: DRAFT, ACTIVE, RECALLED, DECOMMISSIONED
archivedAt string | null (date-time) required

Soft-delete marker (owner off-boarded / decommissioned). Archived passports remain publicly resolvable (ESPR persistence duty).

retentionUntil string | null (date-time) required

Minimum-availability deadline; the passport is never purged before this instant.

proof object | null required

{} when sealed, null when unsealed. The full MerkleTreeAttestationProof is only available on single-passport reads.

createdAt string (date-time) required
updatedAt string (date-time) required
economicOperator EconomicOperatorNode | null required

The economic operator (manufacturer/importer/retailer) responsible for the product. Public in all tiers.

manufacturingFacility null required

Always null in list responses (facility nodes are only embedded on single-passport reads).

metadata object required

The ESPR category metadata, tier-masked: keys above the caller's tier hold the literal string [REDACTED - Privileged Access Required] instead of their value.

additional properties allowed
additional properties allowed

PublicPassportJsonLd object

The public, redacted JSON-LD Digital Product Passport document (application/ld+json). All listed top-level keys are ALWAYS present (null where not applicable). Additionally, every key of the (masked) metadata object — except the reserved document keys (@context, @type, @id, id, productId, digitalLinkUri, digitalSeal, signingPublicKey, status, archivedAt, retentionUntil, proof, createdAt, updatedAt, economicOperator, manufacturingFacility, metadata) — is ALSO flattened onto the document root for direct semantic-graph querying (hence additionalProperties: true); flattened values are identical to the corresponding metadata values, including redaction placeholders. Tier-masked metadata keys are replaced (in both places) with the literal string [REDACTED - Privileged Access Required]. Masking by tier: anonymous public callers lose the per-category restricted keys (category batteries: detailedPerformance, lifecycleAndInUse, circularityAndDisassembly — masked only when actually present) AND the owner-only key facilityDetails; legitimate-interest/authority grant holders lose only facilityDetails; owner-tier responses are unmasked and additionally include the facility street address fields. Note: facilityDetails is placeholder-masked in EVERY non-owner response, even when the underlying metadata never contained the key — in that case it has no entry in proof.redactedLeaves. Each masked key that exists in the sealed metadata keeps its true Merkle leaf hash in proof.redactedLeaves, so the eIDAS seal stays verifiable offline after redaction (see MerkleTreeAttestationProof for the reconstruction rule).

@context array<string | object> required

Exactly two entries: the context URL https://opendpp-node.eu/contexts/dpp/v1 and an inline term map covering the 9 fixed DPP terms (DigitalProductPassport, economicOperator, manufacturingFacility, metadata, digitalSeal, signingPublicKey, status, archivedAt, retentionUntil) plus one generated term per metadata key (https://opendpp-node.eu/contexts/dpp/v1#<key>).

min items 2 · max items 2
additional properties allowed
@type string required
always DigitalProductPassport
@id string (uri) required

The passport's canonical GS1 Digital Link URI (same value as digitalLinkUri).

id string required

Server-assigned passport UUID.

productId string required

Caller-supplied product identifier: a GTIN-14 (^[0-9]{14}$ with valid GS1 modulo-10 check digit), a GRAI (^[0-9]{14}[A-Za-z0-9]{0,16}$), or a free-form SKU.

digitalLinkUri string (uri) required

SKU/type-level GS1 Digital Link URI: {BASE_URL}/{01|8003}/{productId} (AI-21 carries the passport UUID at SKU level; individual units carry their physical serial instead).

digitalSeal string | null required

eIDAS ADVANCED electronic seal: base64 ECDSA prime256v1 (P-256) signature over the Merkle root of the key-sorted metadata. null when the passport has not been sealed.

signingPublicKey string | null required

PEM public key that verifies digitalSeal. null when unsealed.

status enum required

Passport lifecycle status (serialized as ACTIVE when unset). DRAFT is only ever visible to owner-tier callers — public/grant resolution of a draft returns 404.

one of: DRAFT, ACTIVE, RECALLED, DECOMMISSIONED
archivedAt string | null (date-time) required

Soft-delete marker (owner off-boarded / decommissioned). Archived passports remain publicly resolvable (ESPR persistence duty).

retentionUntil string | null (date-time) required

Minimum-availability deadline; the passport is never purged before this instant.

proof MerkleTreeAttestationProof | null required

Present (non-null) only when the passport is sealed (digitalSeal set).

createdAt string (date-time) required
updatedAt string (date-time) required
economicOperator EconomicOperatorNode | null required

The economic operator (manufacturer/importer/retailer) responsible for the product. Public in all tiers.

manufacturingFacility PublicFacilityNode | null required

GLN-backed Unique Facility Identifier node (EN 18219), or null when no facility is linked. GLN, name, activity and country are public; street-address fields appear only in owner-tier responses.

metadata object required

The ESPR category metadata, tier-masked: keys above the caller's tier hold the literal string [REDACTED - Privileged Access Required] instead of their value.

additional properties allowed
additional properties allowed

MerkleTreeAttestationProof object

OpenDPP's own proof type — an eIDAS ADVANCED electronic seal: an ECDSA prime256v1 signature over a SHA-256 Merkle root of the key-sorted metadata (one leaf per top-level metadata key). Deliberately NOT a W3C DataIntegrityProof / ecdsa-jcs-2019 Verifiable Credential (no RFC 8785 JCS canonicalization). Verifiable offline: rebuild the Merkle root from metadata — substituting each redactedLeaves hash for its placeholder-masked key, and EXCLUDING any placeholder-masked key that has no redactedLeaves entry (such a key was never present in the sealed metadata; the serializer injects the owner-only placeholder unconditionally) — then verify signatureValue with publicKeyPem; the x5c chain validates against the platform seal CA (GET /.well-known/opendpp-seal-ca.pem) and the rfc3161 token via openssl ts -verify.

@type array<string> required

Always ["MerkleTreeAttestationProof"].

type string required
always MerkleTreeAttestationProof
signatureAlgorithm string required
always ECDSA-P256-SHA256-over-MerkleRoot
created string (date-time) required

Mirrors the passport's updatedAt.

proofPurpose string required
always assertionMethod
verificationMethod string (uri) required

https://opendpp-node.eu/passport/{passportId}#key-1.

signatureValue string required

Base64 ECDSA P-256/SHA-256 signature over the hex Merkle root string (same value as the document's digitalSeal).

publicKeyPem string required

PEM public key for verification (same value as the document's signingPublicKey).

x5c array<string>

OPTIONAL (omitted when no chain was recorded at seal time). X.509 chain as base64 DER (no PEM armor), leaf first, binding the signing key to the tenant's legal identity; issued by the platform seal CA. Denormalised at seal time, so later key/cert rotations never retroactively change a proof.

rfc3161 object

OPTIONAL (omitted when timestamping was off/unavailable at seal time). RFC 3161 trusted timestamp over SHA-256(merkleRoot) — an independent existed-at anchor from the configured TSA.

genTime string | null (date-time) required

TSA generation time.

token string required

Base64 DER RFC 3161 TimeStampToken; verifies offline via openssl ts -verify.

merkleRoot string required

Hex SHA-256 Merkle root over the key-sorted metadata leaves.

pattern ^[0-9a-f]{64}$
redactedLeaves object

OPTIONAL — present only when at least one masked key actually exists in the underlying sealed metadata. Maps each such metadata key to its TRUE hex leaf hash, so the Merkle root can be reconstructed from the redacted document. A masked key that was never present in the metadata (the owner-only key is placeholder-injected unconditionally for non-owner tiers) yields NO entry here — verifiers must exclude placeholder-valued keys without an entry when rebuilding the tree.

additional properties allowed

EconomicOperatorNode object

Embedded economic-operator JSON-LD node (public in all tiers).

@type string required
always EconomicOperator
id string required
name string required
regId string required

EORI number or official business-registry identifier (unique platform-wide), e.g. EU-DEFAULT-001.

role string

Operator role in the supply chain, e.g. MANUFACTURER, IMPORTER, RETAILER. Always present in detail/resolution responses; absent from GET /api/v1/passports list items.

PublicFacilityNode object

Embedded manufacturing-facility JSON-LD node — the GS1 GLN-backed Unique Facility Identifier (UFI, EN 18219). The five listed fields are public; streetAddress/city/postalCode appear ONLY in owner-tier responses (never via legitimate-interest grants).

@type string required
always Facility
id string required
gln string required

GS1 GLN-13 with a valid modulo-10 check digit.

pattern ^[0-9]{13}$
name string required
activity string | null required

What the facility does in the chain, e.g. cell assembly.

country string required

ISO 3166-1 alpha-2 country code.

streetAddress string | null

Owner tier only — omitted from public and grant-tier responses.

city string | null

Owner tier only.

postalCode string | null

Owner tier only.

AasEnvironment object

An Asset Administration Shell (AAS) v3.0 environment export of the passport, served as application/aas+json. Three top-level keys: assetAdministrationShells (asset identity — urn:opendpp:aas:{passportId} / urn:opendpp:asset:{operatorId}:{productId}, GS1 GLN-qualified specific asset ids), submodels, and conceptDescriptions (semantic concept records from the admin-curated registry, urn:opendpp:concept:…; empty array when the registry is empty). Submodels: a GeneralProductInformation submodel (urn:opendpp:submodel:general:{passportId}), a ComplianceMetadata submodel (urn:opendpp:submodel:compliance) mapping the passport metadata through the concept registry, an IDTA Digital Nameplate submodel (idShort Nameplate) whenever manufacturer/product identity is available — carrying ManufacturerName / ManufacturerProductDesignation for EU-index (CIRPASS-2) discoverability — one or more additive per-category submodel views (ESPR-category views such as CarbonFootprint / TechnicalData, id prefix urn:opendpp:submodel:category:), and — whenever the issuing tenant's eIDAS signing key is provisioned (the normal case) — an eidasVerificationSeal submodel (urn:opendpp:submodel:security-seal:{passportId}) carrying digitalSealHash, cryptographicSignature, pemPublicKey and an optional x509CertificateChain. The seal submodel is present for EVERY access tier. Role filtering strips restricted and commercial owner-only elements from the ComplianceMetadata submodel before sending: owner credentials are filtered by their API-key role, grant holders by the legitimate_interest tier, anonymous callers by the public tier. Submodel internals are intentionally not enumerated in this specification.

additional properties allowed

PublicBatteryUnitJsonLd object

Public JSON-LD document for one individual serialised battery unit (Reg. (EU) 2023/1542 Art. 77(2)). The listed required keys are always present. EXACTLY ONE of two tier-dependent groups is added: anonymous (public) responses carry restrictedData (Annex XIII(2)-(4) notice) and OMIT currentState/dynamicData entirely; owner/grant (privileged) responses carry currentState (latest measurement or null) and dynamicData (up to 500 events, newest first) and omit restrictedData. The embedded ofModel passport is masked by the caller's tier like GET /passport/{id}.

@context array<string | object> required

The context URL https://opendpp-node.eu/contexts/dpp/v1 plus a fixed inline term map for the battery-unit terms.

min items 2 · max items 2
additional properties allowed
@type string required
always BatteryUnit
@id string (uri) required

The unit's GS1 Digital Link URI (AI-21 = the real physical serial).

id string required
serialNumber string required

The physical battery serial (the real GS1 AI-21 value; unique within its SKU/type passport).

pattern ^[A-Za-z0-9._-]{1,20}$
digitalLinkUri string (uri) required
status enum required

Annex XIII battery-status vocabulary. A RECYCLED (or ceased) unit is never served as a 200 — its URL answers 410 with the tombstone document instead.

one of: IN_SERVICE, DECOMMISSIONED, RECALLED, REPURPOSED, REMANUFACTURED, REUSED, WASTE, RECYCLED
manufacturedAt string | null (date-time) required
repurposedFrom BatteryUnitLineageRef | null required

Art. 77(7) lineage: the original unit this repurposed/remanufactured battery came from. The link itself is public.

successorUnits array<BatteryUnitLineageRef> required

Units re-placed on the market under a new passport derived from this one (empty array when none).

ofModel PublicPassportJsonLd required
currentState BatteryUnitCurrentState | null

Present ONLY in owner/grant-tier responses: the most recent recorded measurement, or null when the unit has no events.

dynamicData array<BatteryUnitEventNode>

Present ONLY in owner/grant-tier responses: append-only telemetry history, newest first, capped at the 500 most recent events.

max items 500
createdAt string (date-time) required
updatedAt string (date-time) required

BatteryUnitLineageRef object

Public lineage pointer between battery units (Art. 77(7)).

unitId string required
serialNumber string required
digitalLinkUri string (uri) required
unitUrl string required

Relative public unit URL: /unit/{unitId}.

BatteryUnitCurrentState object

Latest recorded measurement of the unit (owner/grant tiers only). All measurement fields are null when the latest event did not carry them.

stateOfHealth number | null required

State of health, percent (0-100).

cycleCount integer | null required

Cumulative full-equivalent charge cycles.

remainingCapacityAh number | null required

Measured remaining capacity in ampere-hours.

temperatureC number | null required

Observed temperature in degrees Celsius.

recordedAt string (date-time) required

When the measurement was taken (client-supplied).

BatteryUnitEventNode object

One append-only telemetry event (owner/grant tiers only).

@type string required
always BatteryUnitEvent
eventType enum required
one of: SOH_MEASUREMENT, CHARGE_CYCLE, STATUS_CHANGE, NEGATIVE_EVENT, OTHER
stateOfHealth number | null required

Percent, 0-100.

cycleCount integer | null required
remainingCapacityAh number | null required
temperatureC number | null required
payload object | array | null required

Free-form additional telemetry/context supplied at ingestion. Ingestion accepts any JSON object OR array, so both shapes can appear here.

additional properties allowed
recordedAt string (date-time) required

When the measurement was taken (client-supplied at ingestion).

BatteryUnitRestrictedDataNotice object

Marker replacing per-unit telemetry in anonymous (public-tier) responses, with a pointer for requesting legitimate-interest access (Reg. (EU) 2023/1542, Annex XIII(2)-(4)).

reason string required
always LEGITIMATE_INTEREST_REQUIRED
reference string required
always Regulation (EU) 2023/1542, Annex XIII(2)-(4)
description string required
always Per-unit dynamic data (state of health, cycle counts, negative events, temperature) is accessible only to persons with a legitimate interest and to authorities.
howToRequest string required

Relative URL /request-access?unit={unitId} where a legitimate-interest grant can be requested.

BatteryUnitTombstoneJsonLd object

Art. 77(8) tombstone (HTTP 410): once a battery is recycled its passport has ceased to exist. This minimal record confirms the unit existed, that it was recycled and when, plus the (still living) model-passport link. Grants and owner credentials do not override the tombstone on the public URL; the underlying data is retained internally for the statutory retention window.

@context array<string | object> required
min items 2 · max items 2
additional properties allowed
@type string required
always BatteryUnit
@id string (uri) required
id string required
serialNumber string required
status string required
always RECYCLED
ceasedAt string | null (date-time) required

When the unit's passport ceased to exist (stamped when the status transitioned to RECYCLED).

notice string required
always This battery has been recycled. Its battery passport has ceased to exist (Regulation (EU) 2023/1542, Art. 77(8)).
ofModelUrl string | null required

Relative URL of the still-living SKU/type passport: /passport/{passportId}.

SectorJsonSchemaDocument object

A JSON Schema draft-07 document describing the ESPR metadata payload for one product category, served as application/schema+json. Each known field is annotated server-side with a plain-English description from the platform's field-help registry (annotations are AJV-ignored; validation behavior is identical to the raw schema). The same schema (without annotations) validates metadata on POST /api/v1/passports and the validate-only endpoints.

$schema string required
always http://json-schema.org/draft-07/schema#
title string required
type string required
always object
required array<string> required
properties object required
additional properties allowed
additional properties allowed

SectorVocabularyContext object

Per-category JSON-LD vocabulary context, returned by GET /api/v1/schemas/{category} when Accept contains application/ld+json. Fixed shape: @vocab is https://w3id.org/opendpp/schemas/{category}# plus mappings for id, type, category, materialComposition, originCountry, facilityDetails and regulatoryCompliance.

@context object required
additional properties allowed

DppJsonLdContextDocument object

The fixed W3C JSON-LD context document served by GET /context/v1: maps DigitalProductPassport, economicOperator, metadata, digitalSeal, signingPublicKey and proof to https://opendpp-node.eu/ns/dpp#… IRIs, and createdAt/updatedAt to schema.org dateCreated/dateModified.

@context object required
additional properties allowed

DppVocabContextDocument object

The canonical resolvable JSON-LD context served by GET /contexts/dpp/v1 — the context every public passport and battery-unit document references in its @context. Declares @vocab (so unknown terms expand under the OpenDPP namespace) plus core term mappings; @version is the numeric JSON-LD 1.1 marker, so @context values are a mix of strings and that number.

@context object required

Term map: @vocab + @version (1.1) + the core DPP/unit term → dpp: CURIE mappings.

HealthStatus object

Health-check body of GET /health. Carries the running build identity (apiVersion/commit/builtAt) in addition to the liveness fields.

status string required
always OK
service string required
always OpenDPP B2B Enterprise Engine
timestamp string (date-time) required

Current server time, ISO 8601 UTC with milliseconds.

apiVersion string required

SemVer of the public API contract currently served (equals the OpenAPI document's info.version; its MAJOR equals the /api/v1 URL major).

commit string required

Short git commit SHA of the running build, or "unknown" when a build did not inject it.

builtAt string required

Build/deploy timestamp (ISO 8601 UTC), or "unknown" when a build did not inject it.

ServiceVersion object

Running API contract version and source build identity, returned by GET /api/v1/version. The apiVersion MAJOR is the safe thing to pin an integration or generated SDK to.

apiVersion string required

SemVer of the public API contract currently served (equals the OpenAPI document's info.version; its MAJOR equals the /api/v1 URL major).

commit string required

Short git commit SHA of the running build, or "unknown" when a build did not inject it.

builtAt string required

Build/deploy timestamp (ISO 8601 UTC), or "unknown" when a build did not inject it.

MaterialVocabularyRow object

One entry of the platform-curated material vocabulary. Entries are unique per (kind, name).

id string required
name string required

Canonical display name, e.g. "Organic Cotton" or "Lithium Iron Phosphate (LFP)".

kind enum required

Vocabulary kind. crm = critical raw material.

one of: material, fiber, chemistry, substance, hazard, crm
casNumber string | null required

Optional CAS registry number (chemicals/substances); null when not applicable.

description string | null required

Optional short note shown in the picker; null when unset.

MaterialVocabularyListResponse object

Envelope of GET /api/v1/materials. Caveat: unlike most authenticated endpoints there is NO success field.

materials array<MaterialVocabularyRow> required

Active vocabulary entries, ordered by kind ascending then name ascending, capped at limit (max 1000).

UntpEventCredential object

A UNTP/EPCIS 2.0 traceability event wrapped as a VC-shaped credential. The only hard structural requirement is credentialSubject; the proof MUST be a conformant W3C DataIntegrityProof (cryptosuite: "ecdsa-jcs-2019") and a missing, non-conformant, or unverifiable proof is rejected with the 400 Cryptographic Verification Failed body. Extra properties are permitted — the signature covers sha256(JCS(proof options)) ‖ sha256(JCS(credential without proof)) (RFC 8785 JCS canonicalization).

@context array<string>
id string

Credential id (e.g. urn:uuid:...). NOT used as the stored event id — the event primary key is always server-generated.

type array<string>
issuer string

Issuer DID. Unless a trusted x5c chain is embedded, the verification key is resolved by EXACT match of the DID's trailing :-segment against registered tenant subdomains/company names — e.g. did:web:opendpp-node.eu:demo resolves the workspace with subdomain demo. For operator-scoped API keys the issuer DID must ALSO contain the bound operator's registration id somewhere in the string (the issuer is checked in preference to credentialSubject.responsibleOperatorDid), e.g. did:web:opendpp-node.eu:EU-DEFAULT-001:demo. Stored verbatim as the event's issuerDid.

issuanceDate string (date-time)

Fallback for the stored eventTime when credentialSubject.eventTime is absent.

credentialSubject UntpEventCredentialSubject required
proof UntpEventProof required

UntpEventCredentialSubject object

The EPCIS event payload. eventType is effectively required: it is persisted into a server-side enum, and a missing or unknown value is rejected at the persistence layer (surfacing as the 500 Database Persistence Failed body, not a 400).

id string

EPC identifier of the subject (e.g. urn:epc:id:sgtin:0950110153.0003.SN-2026-000123). When epcList is not supplied as an array, the stored EPC list defaults to [id] (or [] if absent).

eventType enum required

EPCIS 2.0 event type (server-side enum).

one of: ObjectEvent, AggregationEvent, TransformationEvent, AssociationEvent
action enum

EPCIS action (server-side enum). Optional — but MUST be absent (or null) on TransformationEvent, otherwise 400 Schema Validation Error. An unknown value is rejected at the persistence layer (500).

one of: ADD, OBSERVE, DELETE
bizStep string

CBV business step URI. Defaults to urn:epcglobal:cbv:bizstep:receiving.

default "urn:epcglobal:cbv:bizstep:receiving"
disposition string

CBV disposition URI. Defaults to urn:epcglobal:cbv:disp:in_progress.

default "urn:epcglobal:cbv:disp:in_progress"
readPoint string

Where the event was observed (e.g. geo:41.1496,-8.6109). When absent and originLocation is present, defaults to geo:<latitude>,<longitude>.

bizLocation string

Business location (SGLN URI, DID, or free identifier). When absent, defaults to responsibleOperatorDid.

eventTime string (date-time)

When the event occurred (anything new Date() parses). Defaults to the credential issuanceDate, else the server clock.

epcList array<string>

EPC URIs observed by the event. A non-array value is replaced with [credentialSubject.id] (or []).

parentEpc string

Parent EPC for AggregationEvent.

childEpcs array<string>

Child EPCs for AggregationEvent (stored as JSON verbatim).

inputEpcList array<string>

Input EPCs for TransformationEvent (stored as JSON verbatim).

outputEpcList array<string>

Output EPCs for TransformationEvent (stored as JSON verbatim).

originLocation object

Geographic origin; used only to derive a default readPoint (geo:<lat>,<lng>) when readPoint is absent. latitude/longitude are required here for correct usage but NOT enforced server-side: an originLocation missing them is not rejected — the derived readPoint is then silently persisted as the malformed literal geo:undefined,undefined.

latitude number required
longitude number required
eudrPlotId string
responsibleOperatorDid string

DID of the responsible economic operator. Fallback for bizLocation, and (only when issuer is absent) for the operator-scope check on operator-scoped API keys.

UntpEventProof object

Credential proof. MUST be a conformant W3C DataIntegrityProof with cryptosuite: "ecdsa-jcs-2019" and a multibase base58btc (z…) proofValue. Verified (ECDSA P-256, IEEE-P1363 raw r‖s) over sha256(JCS(proof options)) ‖ sha256(JCS(credential without proof)) — RFC 8785 JCS canonicalization, a conformant W3C Data Integrity suite.

type string required

MUST be DataIntegrityProof.

always DataIntegrityProof
cryptosuite string required

MUST be ecdsa-jcs-2019 (RFC 8785 JCS).

always ecdsa-jcs-2019
created string (date-time)
proofPurpose string

e.g. assertionMethod.

verificationMethod string | UntpVerificationMethod

Either a key-identifier string or an embedded object carrying an x5c certificate chain.

proofValue string required

Multibase base58btc (z…) ecdsa-jcs-2019 signature. Stored verbatim with the event.

UntpVerificationMethod object

Embedded verification-method object. The x5c chain (base64 DER, leaf first) is honoured ONLY when the node has eIDAS trust anchors configured, every certificate is currently valid, each link verifies against the next, the top is anchored, and the leaf attests the credential issuer — otherwise it is ignored and the registered tenant key is used instead.

id string
type string
controller string
x5c array<string>

X.509 certificate chain, base64 DER, leaf first.

TraceEventRegistered object

201 envelope of POST /api/v1/events. Note: status: "success" (string), not the usual success: true boolean.

status string required
always success
eventId string required

Server-generated event id. Use it with GET /api/v1/events/{id}/lineage and POST /api/v1/events/{id}/audit.

untpVerified boolean required
always true

TraceLineageNode object

One node of the recursive upstream lineage DAG. All keys are always present; location, readPoint and issuerDid are null when unset. location mirrors the stored bizLocation. A shared ancestor reached through multiple downstream paths appears once under each path (the DAG is expanded into a tree).

eventId string required
eventType enum required
one of: ObjectEvent, AggregationEvent, TransformationEvent, AssociationEvent
bizStep string required
disposition string required
eventTime string (date-time) required
epcs array<string> required

EPC URIs parsed from the stored EPC list (degrades to [] when unparseable).

location string | null required

The event's stored bizLocation.

readPoint string | null required
isUntpCompliant boolean required
issuerDid string | null required
parents array<TraceLineageNode> required

Upstream parent events (recursive). Empty array at the origin of the chain.

TraceLineageResponse object

The upstream pedigree of a traceability event, as a recursive graph of the events it derives from.

success boolean required
always true
lineage TraceLineageNode required

TraceComplianceAuditResponse object

The verdict of auditing a traceability lineage: whether it is compliant, any errors found, and the resulting certificate.

success boolean required
always true
eventId string required

Echo of the audited root event id.

compliant boolean required
errors array<string> required

Human-readable violation strings, e.g. UFLPA Compliance Failure: Event [<uuid>] contains raw materials originating from prohibited Xinjiang region (<location>). or EUDR Compliance Failure: Farm coordinates [<lat>, <lng>] intersect with deforested area plots. Empty when compliant.

auditedAt string (date-time) required
certificate TraceComplianceCertificate | null required

Present only when compliant is true; null otherwise.

TraceComplianceCertificate object

A compliance certificate for an audited traceability lineage, naming the root event and the regulatory standards checked.

type string required
always TraceabilityComplianceCertificate
rootEventId string required
status string required

Screening outcome — informational (e.g. SCREENED_NO_MATCHES when no geographic screen matched); NOT a legal compliance verdict.

regulatoryStandards array<string> required

Vendor screening-heuristic identifiers applied (e.g. OpenDPP-EUDR-heuristic, OpenDPP-UFLPA-screen) — informational, NOT EU regulatory standards.

SealVerifyRequest object

Verification request. Only payload is strictly required: signature and publicKey are extracted from payload.proof (proofValue/signatureValue, publicKeyPem, or the x5c leaf SPKI) when omitted, and the request fails 400 only if either is still missing after extraction.

payload object required

The sealed passport document to verify — typically the JSON-LD passport document exactly as resolved from the public endpoints, or any {passportId, productId, metadata, operator} payload. Only the fields below are interpreted; all other properties are preserved and participate in the whole-payload signature fallback.

metadata object

The sealed metadata object — the SHA-256 Merkle tree is rebuilt over its top-level properties (every leaf recomputed from the actual values; caller-supplied redacted-leaf hashes are not accepted). When the metadata key is entirely ABSENT, the whole payload object is treated as the metadata for the Merkle phase; a present-but-non-object value skips the Merkle phase (only the whole-payload fallback runs).

operator object

Declared economic operator. A present regId triggers the fail-closed operator-binding gate.

name string
regId string

Operator registration id (e.g. EORI-style EU-DEFAULT-001). Must resolve to a registered Economic Operator bound to the signing tenant, or verification fails (verified: false).

economicOperator object

Alternative location for the declared operator id — economicOperator.regId is checked when operator.regId is absent.

regId string
proof object

Embedded W3C-style proof block. Sources for the signature, public key, certificate chain and RFC 3161 token when the top-level fields are omitted.

type string
proofValue string

Base64 ECDSA seal — used as signature when the top-level field is absent.

signatureValue string

Legacy alias for proofValue (checked second).

publicKeyPem string

PEM (SPKI) public key — used as publicKey when the top-level field is absent.

x5c array<string>

X.509 certificate chain, base64 DER, LEAF FIRST. Enables the certificate report; the leaf's SPKI also serves as the public key when none is otherwise supplied.

rfc3161 object
token string

RFC 3161 TimeStampToken (base64 DER CMS ContentInfo) — enables the timestamp report.

additional properties allowed
signature string

Base64 ECDSA (P-256 / SHA-256) seal signature. Optional when payload.proof.proofValue (or signatureValue) is present.

publicKey string

PEM (SPKI) public key of the sealing tenant. Optional when payload.proof.publicKeyPem or an x5c chain is present. CRLF line endings are normalized before matching.

SealVerifyResponse object

Always HTTP 200 once the request is well-formed. verified: false covers both cryptographic failure and the two registration/binding policy failures — the policy failures add a message and OMIT certificate/timestamp even when an x5c chain or RFC 3161 token was supplied.

success boolean required
always true
verified boolean required
message enum

Present only on the two policy failures: unregistered public key, or a declared operator not bound to the signing tenant.

one of: Cryptographic verification failed: The public key used to seal this passport is not registered to any authorized economic operator tenant on this node., Cryptographic verification failed: The economic operator declared in this passport is not a registered operator bound to the signing tenant.
certificate SealCertificateReport

SealCertificateReport object

Present only for x5c-carrying proofs on a verified: true outcome whose chain is TRUSTED — chainValid AND keyMatchesProof both true (the two policy verified: false responses AND any untrusted-chain outcome omit it): the certified legal identity of the seal creator (eIDAS Art. 36(1)(b)). An untrusted chain is never surfaced, so an emitted report always has chainValid: true.

subject string

Leaf-certificate subject (multi-line RDN string as produced by Node's X509Certificate, e.g. CN=OpenDPP Demo Eco Industries Seal).

issuer string

Leaf-certificate issuer RDN string.

validFrom string

X.509 textual date, e.g. Jan 10 00:00:00 2026 GMT — NOT ISO 8601.

validTo string

X.509 textual date — NOT ISO 8601.

chainValid boolean required

True only when every chain link signature-verifies, every certificate is within its validity window, AND the top of the chain is anchored to this node's seal CA (fingerprint match or signature under the CA key).

keyMatchesProof boolean

True when the leaf SPKI equals the supplied publicKey (whitespace-insensitive), or when no explicit public key was supplied (the leaf key was used).

error string

Present only when the chain could not be parsed.

always Unparseable x5c certificate chain

SealTimestampReport object

Present only when payload.proof.rfc3161.token was supplied AND verification proceeds past the key-registration and operator-binding gates (the two policy verified: false responses omit it). Reports presence, the TSA-asserted genTime from the token's TSTInfo, and — when the node has a TSA CA configured (TSA_CA_PEM) — timeAuthenticated: the result of verifying the token's CMS SignedData signature over its TSTInfo and chaining the signer certificate to that anchor.

present boolean required
always true
genTime string | null (date-time) required

TSA-asserted generation time (ISO 8601), or null when the token's TSTInfo could not be parsed.

timeAuthenticated boolean

True only when the token's RFC 3161 CMS SignedData signature verifies over its TSTInfo AND the signer passes full trust-path validation to the node's configured TSA CA anchor (TSA_CA_PEM): the signer is an end-entity timestamping certificate (a CRITICAL id-kp-timeStamping EKU, not a CA) that is VALID at the asserted genTime and chains through CA-constrained, genTime-valid intermediates to the anchor (itself a CA valid at genTime). False when the signature fails, when the path is not policy-valid, and when no TSA CA is configured (the asserted genTime is then unauthenticated). This is the node's own cryptographic check and does not replace a verifier's independent openssl ts -verify.

note string

Present only when genTime is null.

always token present but TSTInfo could not be parsed

EpcisDocument object

A GS1 EPCIS 2.0 document (JSON/JSON-LD). This shape is indicative — the OFFICIAL GS1 EPCIS 2.0.1 JSON Schema (vendored on the node, $id https://ref.gs1.org/standards/epcis/2.0.1/epcis-json-schema.json) is authoritative and is what capture validates against.

@context string | array<object> required

JSON-LD context — include https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld.

type enum required
one of: EPCISDocument
schemaVersion string required
creationDate string (date-time) required
epcisBody object required
eventList array<object> required
additional properties allowed
additional properties allowed

EpcisCaptureResponse object

Per-event outcome of capturing an EPCIS 2.0 document, with partial-success semantics: some events may be stored while others fail.

status enum required
one of: success
captured integer required

How many events were persisted.

results array<object> required
index integer required

Position of the event in the submitted eventList.

eventId string required

Server-generated row id (UUID) — the authoritative event identity on this node.

eventType string required
ignoredFields array<string>

Recognized EPCIS fields present on the event that this node does not persist — disclosed, never silently dropped.

errors array<object> required
index integer required
message string required

DidWebDocument object

A tenant's did:web DID document (public-key material only). Verification methods are JsonWebKey2020 entries with stable #key-<index> ids; current and retired keys are both listed so pre-rotation credentials still verify.

@context array<string> required

JSON-LD contexts: the DID core context plus the JWS-2020 suite.

id string required

The workspace DID, did:web:opendpp-node.eu:tenants:{tenantId}.

name string

The issuer's authoritative legal name (present when the workspace has a company name).

verificationMethod array<object> required

Public verification keys.

id string required

<did>#key-<index> — matches the credential kid.

type enum required
one of: JsonWebKey2020
controller string required
publicKeyJwk object required

The public key as a JWK (EC P-256, alg: ES256, use: sig). Public material only.

assertionMethod array<string> required

Verification-method ids authorized to assert credentials (current + retired keys).

authentication array<string> required

WebhookEventFilter string

Subscribable event filter values. * matches every emitted event. passport.status_updated (decommission/reactivate) and passport.updated (in-place edit of a published passport) are now first-class subscribable filters.

WebhookEnvelope object

The signed body of every webhook delivery. data is the public (redacted) JSON-LD passport document; type is the concrete event name (also in the X-OpenDPP-Event header); id is the stable delivery id (also in the X-OpenDPP-Delivery header) and is CONSTANT across all retries of the same event, so deduplicate on it for exactly-once processing; created is the event time (stable across retries, distinct from the per-attempt X-OpenDPP-Timestamp).

id string required

Stable delivery id; mirrors the X-OpenDPP-Delivery header; constant across retries.

type enum required

Concrete event type (also in the X-OpenDPP-Event header). Never the * filter value.

one of: passport.ingested, passport.updated, passport.sealed, passport.recalled, passport.status_updated
created string (date-time) required

Event creation/enqueue time (stable across retries).

data PublicPassportJsonLd required

WebhookSubscriptionCreateRequest object

An endpoint to receive webhook deliveries, and the event types it subscribes to.

url string (uri) required

Absolute http(s) endpoint URL of your receiver (e.g. a PLM/ERP integration endpoint). DNS-resolved and SSRF-guarded at registration: malformed URLs, loopback, private (RFC 1918/CGNAT), link-local/cloud-metadata, multicast, and equivalent IPv6 ranges are rejected with 400. Redirects are never followed at delivery time.

events array<WebhookEventFilter> required

Non-empty list of event filters. Any value outside the allowed set is rejected with 400.

min items 1

WebhookSubscriptionRow object

A webhook subscription row with the HMAC signing secret stripped (it is shown exactly once, in the 201 create response).

id string required

Subscription id.

tenantId string required

Owning workspace (tenant) id.

url string (uri) required

Receiver endpoint URL.

events array<WebhookEventFilter> required

Event filters this subscription matches (validated at creation).

isActive boolean required

true while the subscription receives deliveries; only active subscriptions are delivered to. Toggle with PATCH /api/v1/webhooks/subscriptions/{id} (isActive) to pause/resume without deleting.

createdAt string (date-time) required
updatedAt string (date-time) required

WebhookSubscriptionWithSecret object

The full subscription row as returned ONLY by the 201 create response — includes the HMAC-SHA256 signing secret.

secret string required

Server-generated HMAC-SHA256 signing key (whsec_ + 32 lowercase hex chars). Shown ONCE — here and in the rotate-secret response only; the list endpoint strips it. The FULL string, including the whsec_ prefix, is the HMAC key for delivery signatures. Rotate it with POST /api/v1/webhooks/subscriptions/{id}/rotate-secret.

pattern ^whsec_[0-9a-f]{32}$

WebhookSubscriptionCreateResponse object

Confirmation that a webhook subscription was created, carrying the stored subscription.

success boolean required
always true
message string required
always Webhook subscription registered successfully
subscription WebhookSubscriptionWithSecret required

WebhookSubscriptionListResponse object

The calling workspace's webhook subscriptions.

success boolean required
always true
subscriptions array<WebhookSubscriptionRow> required

WebhookSubscriptionDeleteResponse object

Confirmation that a webhook subscription was deleted.

success boolean required
always true
message string required
always Webhook subscription successfully deleted

WebhookSubscriptionUpdateRequest object

All fields optional; include only what you want to change. The secret is not editable here.

url string (uri)

New receiver URL. Re-validated by the SSRF guard.

events array<WebhookEventFilter>

Replacement (non-empty) event-filter set.

min items 1
isActive boolean

Pause (false) or resume (true) deliveries.

WebhookSubscriptionUpdateResponse object

The webhook subscription as stored after the update.

success boolean required
always true
message string

Present when a change was applied ("Webhook subscription updated"); absent when the body had no recognized fields.

subscription WebhookSubscriptionRow required

WebhookSecretRotateResponse object

Confirmation that a subscription's HMAC signing secret was rotated; the new secret is returned once and cannot be retrieved again.

success boolean required
always true
message string required
subscription WebhookSubscriptionWithSecret required

WebhookDeliveryRow object

One outbox delivery record (event-level). The payload is not included.

id string required

Outbox record id.

event string required

Event type, e.g. passport.sealed.

status enum required

Overall delivery state. FAILED after 5 exhausted attempts (dead-lettered).

one of: PENDING, DELIVERED, FAILED
retryCount integer required

Failed attempts so far (0–5).

lastAttempt string | null (date-time) required

Timestamp of the most recent attempt, or null if never attempted.

nextRetryAt string | null (date-time) required

When the next retry is eligible (null if delivered or dead-lettered).

errorMessage string | null required

Joined per-endpoint error text from the last failed attempt, or null.

createdAt string (date-time) required

WebhookDeliveriesResponse object

Recent webhook delivery attempts for a subscription, newest first, for debugging endpoint failures.

success boolean required
always true
count integer required
deliveries array<WebhookDeliveryRow> required

WebhookTestResult object

The outcome of delivering a signed sample event to a subscription's endpoint right now.

success boolean required

The request was processed (NOT whether the receiver accepted it — see delivered).

always true
event string required

The event type sent.

url string (uri) required

The receiver URL the test was sent to.

delivered boolean required

true if the receiver returned a 2xx within the 5s timeout.

statusCode integer | null required

The receiver's HTTP status, or null on a transport/SSRF error.

error string | null required

Transport/SSRF error message when delivery failed, else null.