{
  "openapi": "3.1.0",
  "info": {
    "title": "TracePass API",
    "version": "1.0.0",
    "summary": "Digital Product Passport REST API for the TracePass platform.",
    "description": "REST API for managing Digital Product Passports on TracePass —\nproducts, passports, parties (economic operators), bulk batches,\nand the JSON-LD tenant export.\n\nMost operations require an API key in the standard\n`Authorization: Bearer <key>` header. v1 API access is available\non every plan including Free. Daily call caps apply only to\nFree (100/day) and Basic (200/day) as free-tier abuse prevention;\nStarter and above are unlimited.\n\nWorked examples in curl / TypeScript / Python live alongside\neach endpoint at https://www.tracepass.eu/docs.\n",
    "contact": {
      "name": "TracePass support",
      "email": "support@tracepass.eu",
      "url": "https://www.tracepass.eu"
    },
    "license": {
      "name": "TracePass Terms of Service",
      "url": "https://www.tracepass.eu/terms"
    },
    "termsOfService": "https://www.tracepass.eu/terms"
  },
  "servers": [
    {
      "url": "https://app.tracepass.eu",
      "description": "Production"
    }
  ],
  "externalDocs": {
    "description": "Prose documentation with worked examples",
    "url": "https://www.tracepass.eu/docs"
  },
  "tags": [
    {
      "name": "Passports",
      "description": "Create, read, update, suspend, archive, and bulk-import Digital\nProduct Passports. Includes the parties block for economic-\noperator chains.\n",
      "externalDocs": {
        "url": "https://www.tracepass.eu/docs/passports"
      }
    },
    {
      "name": "Products",
      "description": "The catalogue layer — products, images, defaults. One product\ncan have many passports (one per serialised unit).\n",
      "externalDocs": {
        "url": "https://www.tracepass.eu/docs/products"
      }
    },
    {
      "name": "Exports",
      "description": "Bulk JSON-LD tenant export. Dashboard-cookie auth, not API\nkey — see the operation's security block.\n",
      "externalDocs": {
        "url": "https://www.tracepass.eu/docs/exports"
      }
    },
    {
      "name": "EPCIS",
      "description": "GS1 EPCIS 2.0 supply-chain events — export a passport's event\nhistory, capture events from partners and ERP systems, and\nquery the event store. Export is included on Starter plans and\nup; capture and query are a paid add-on.\n",
      "externalDocs": {
        "url": "https://www.tracepass.eu/docs/epcis"
      }
    },
    {
      "name": "Templates",
      "description": "The regulatory DPP schemas — what a compliant passport must\ncontain per category, and the EU regulation behind each\nrequired field. Read-only reference data; use it to discover\nrequirements before creating products and passports.\n",
      "externalDocs": {
        "url": "https://www.tracepass.eu/docs"
      }
    },
    {
      "name": "OAuth",
      "description": "OAuth 2.0 authorization-server endpoints (authorization-code\nflow with PKCE). These power the user-authorized auth method —\na third-party app or AI assistant a user \"Connects\". Not v1\nresource endpoints; documented here so client libraries can\ndrive the flow. Discovery metadata at\n`/.well-known/oauth-authorization-server` (RFC 8414).\n",
      "externalDocs": {
        "url": "https://www.tracepass.eu/docs/authentication"
      }
    }
  ],
  "security": [
    {
      "BearerAuth": []
    },
    {
      "OAuth2": [
        "passports:read"
      ]
    }
  ],
  "paths": {
    "/api/v1/passports": {
      "post": {
        "operationId": "createPassport",
        "tags": [
          "Passports"
        ],
        "security": [
          {
            "BearerAuth": []
          },
          {
            "OAuth2": [
              "passports:write"
            ]
          }
        ],
        "summary": "Create a single passport.",
        "description": "Create a single Digital Product Passport. The passport is\nbound to a product (`productId`) and identified by a GS1\nGTIN + serial unique within that GTIN. New passports start\nin `draft` status — fields are populated via subsequent\nPATCH calls and the passport is published from the\ndashboard once review is complete.\n\nCounts as one v1 write AND consumes one DPP slot from the\nplan's `maxDpps` quota — when that quota is exhausted and\nthe plan supports overage, returns 402 with an\n`overage_required` body. Retry with\n`confirmOverage: true` to accept the per-passport charge.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreatePassportInput"
              },
              "example": {
                "productId": "6650a1b2c3d4e5f6a7b8c9d0",
                "gs1": {
                  "gtin": "04012345000015",
                  "serialNumber": "BP-48V-100-000001"
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Passport created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Passport"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/OverageRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      },
      "get": {
        "operationId": "listPassports",
        "tags": [
          "Passports"
        ],
        "summary": "List passports.",
        "description": "Paginated list of passports the workspace owns. Filter by\nproduct, status, or a free-text search across GTIN and\nserial.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/Page"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "name": "productId",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/ObjectId"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/PassportStatus"
            }
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated passport list.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/PaginatedPassports"
                    }
                  },
                  "required": [
                    "data"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/v1/passports/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/PassportId"
        }
      ],
      "get": {
        "operationId": "getPassport",
        "tags": [
          "Passports"
        ],
        "summary": "Read one passport.",
        "description": "Default response includes the full passport — translatable\nfields carry their `sourceLocale` and a per-locale\n`translations` map. `?lang=<locale>` resolves every field\nthrough the public-viewer chain and returns one resolved\nvalue per field; `?format=full` adds template-derived\nlabels, units, and access levels alongside each value.\n\nAn alternate addressing form exists at\n`GET /api/v1/passports/by-serial/{serial}` with the same\nbody and query-parameter behaviour.\n",
        "parameters": [
          {
            "name": "format",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "full"
              ]
            },
            "description": "Set to `full` to include template field labels, units,\nand access levels alongside each field value.\n"
          },
          {
            "name": "lang",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "ISO 639-1 locale to resolve every field value through\nthe public-viewer chain (viewer-locale translation →\nsource-locale value → English → first-applied →\nuniversal source). One of the 24 EU locales. When set,\nthe `translations` map is dropped from each field.\n"
          }
        ],
        "responses": {
          "200": {
            "description": "Passport.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Passport"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      },
      "delete": {
        "operationId": "deletePassport",
        "tags": [
          "Passports"
        ],
        "security": [
          {
            "BearerAuth": []
          },
          {
            "OAuth2": [
              "passports:write"
            ]
          }
        ],
        "summary": "Delete a passport permanently.",
        "description": "**Permanent and irreversible.** Removes the passport row\nplus every cascaded dependent (extractions, agent-session\nstate, supplier requests linked to this passport alone,\nscan + service events, EPCIS event refs, and any uploaded\ndocuments whose only reference was this passport — shared\ndocuments stay with the link removed). R2 storage under\nthe passport's folder is swept.\n\n**Only never-published passports are eligible.** Status\nmust be `draft` or `in_review` AND `publishedAt` must be\n`null`. Anything else returns 409 with a `reason` naming\nthe policy. The compliance moat protecting printed QRs in\nthe wild is preserved — a published passport can only be\narchived, never deleted.\n\n**Paid-plan feature.** Returns 403 on Free plans with a\n`reason` field pointing at the archive endpoint as the\nno-cost alternative.\n\nHonours `Idempotency-Key`. No webhook (the `billingEvents`\naudit entry is the trail).\n",
        "responses": {
          "200": {
            "description": "Permanently deleted; summary includes cascade counts.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string"
                    },
                    "summary": {
                      "type": "object",
                      "properties": {
                        "passportId": {
                          "type": "string"
                        },
                        "serialNumber": {
                          "type": "string"
                        },
                        "gtin": {
                          "type": "string"
                        },
                        "deletedCounts": {
                          "type": "object",
                          "description": "Per-collection cascade counts plus R2 object sweep."
                        },
                        "dppsActiveDelta": {
                          "type": "integer",
                          "enum": [
                            -1,
                            0
                          ],
                          "description": "How the live-active counter moved (always -1 or 0)."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Plan gate — hard-delete is paid-only.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Passport not eligible (already published / approved / suspended / etc).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/v1/passports/{id}/compliance": {
      "parameters": [
        {
          "$ref": "#/components/parameters/PassportId"
        }
      ],
      "get": {
        "operationId": "getPassportCompliance",
        "tags": [
          "Passports"
        ],
        "summary": "Compliance verdict for one passport.",
        "description": "Returns a three-tier compliance verdict\n(`compliant` / `compliant_with_warnings` / `incomplete`) with\nregulation-cited findings, so an integration can gap-check a\npassport and re-check after fixing — the read → fix → verify\nloop.\n\nFindings come from three tiers:\n- **static** — required template fields present + approved,\n  required economic-operator parties present (critical), and\n  well-formed field values (warning);\n- **conditional** — per-category rules in force today (battery\n  Reg (EU) 2023/1542 Art. 77 + Annex XIII field-applicability,\n  detergents + paints-coatings REACH Art. 33 / SCIP,\n  construction Reg (EU) 2024/3110),\n  plus the cross-cutting EU-economic-operator rule\n  (Reg (EU) 2019/1020 Art. 4). For batteries, applicability\n  findings (`ruleId: \"BAT-APP\"`, warning) flag fields filled but\n  not applicable to the battery's type — e.g. carbon footprint on\n  a non-rechargeable cell. These reuse the standard\n  `ComplianceFinding` shape (no new fields);\n- **coverage** — `conditionalCoverage` reports whether\n  conditional rules were evaluated for this category or it is\n  `static-only` (no binding conditional in force yet), so the\n  absence of conditional findings is never read as compliance.\n\nRead-only; counts as one v1 passport read against the daily cap.\n",
        "responses": {
          "200": {
            "description": "Compliance verdict.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ComplianceVerdict"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/v1/passports/{id}/registry-readiness": {
      "parameters": [
        {
          "$ref": "#/components/parameters/PassportId"
        }
      ],
      "get": {
        "operationId": "getPassportRegistryReadiness",
        "tags": [
          "Passports"
        ],
        "summary": "Registry-readiness preflight for one passport.",
        "description": "Returns whether a passport would pass the **EU DPP Registry's\nformal submission gate** — `{ ready, findings[] }`. This is the\nregistry's *mechanical* pre-submission check, distinct from and\ncomplementary to the substantive `/compliance` verdict: a\npassport can be registry-ready yet not substantively compliant,\nand vice-versa.\n\nFive formal checks, each carried on the standard\n`ComplianceFinding` shape with a `REG-READY-*` `ruleId`:\n- **`REG-READY-MANDATORY`** — every mandatory field that applies\n  to this battery type is present and approved (applicability-aware:\n  a field that is not applicable to the battery's category is not\n  counted as missing; an `unknown`-applicability field surfaces as\n  a warning, not a block);\n- **`REG-READY-FORMAT`** — filled fields are well-formed\n  (types, enums, patterns, ranges);\n- **`REG-READY-LINK`** — the passport's public link resolves\n  (a live probe; on an unpublished passport this is an advisory\n  warning rather than a block);\n- **`REG-READY-GRANULARITY`** — the passport carries a serial\n  number, since batteries register at item-level granularity\n  (a missing serial is a block);\n- **`REG-READY-COMMODITY`** — the product's commodity code\n  (CN/TARIC) is well-formed where the category carries one; for\n  batteries, which carry no commodity code, this is an advisory\n  warning only, never a block.\n\n`ready` is `true` only when there are zero critical findings.\n**Battery passports only** in this version; a non-battery passport\nreturns `ready: true` with a single `REG-READY-SCOPE` warning.\n\nRead-only; counts as one v1 passport read against the daily cap.\n",
        "responses": {
          "200": {
            "description": "Registry-readiness result.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RegistryReadinessResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/v1/passports/{id}/fields/{key}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/PassportId"
        },
        {
          "$ref": "#/components/parameters/FieldKey"
        },
        {
          "$ref": "#/components/parameters/IdempotencyKey"
        }
      ],
      "patch": {
        "operationId": "updatePassportField",
        "tags": [
          "Passports"
        ],
        "security": [
          {
            "BearerAuth": []
          },
          {
            "OAuth2": [
              "passports:write"
            ]
          }
        ],
        "summary": "Update one field on a passport.",
        "description": "Patch a single field on a passport. The value is validated\nagainst the field key (must exist on the passport's\ntemplate) and persisted with an audit-trail entry tagged\n`via API key <prefix>`. Writes default to `status:\n\"approved\"` — API-key-driven integrations are trusted by\nconvention. Override with `source: \"ai_suggested\"` or\n`source: \"supplier\"` to land in the review queue instead.\n\nAn alternate addressing form exists at PATCH\n`/api/v1/passports/by-serial/{serial}/fields/{key}` with\nthe same body shape.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateFieldInput"
              },
              "example": {
                "value": 5.24
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Field updated.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "field",
                    "version"
                  ],
                  "properties": {
                    "field": {
                      "$ref": "#/components/schemas/PassportField"
                    },
                    "version": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/v1/passports/{id}/parties/{role}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/PassportId"
        },
        {
          "$ref": "#/components/parameters/PartyRole"
        },
        {
          "$ref": "#/components/parameters/IdempotencyKey"
        }
      ],
      "patch": {
        "operationId": "upsertParty",
        "tags": [
          "Passports"
        ],
        "security": [
          {
            "BearerAuth": []
          },
          {
            "OAuth2": [
              "passports:write"
            ]
          }
        ],
        "summary": "Upsert an economic-operator party.",
        "description": "Create or replace the Party for one role on a passport.\nSending the same role twice is an upsert: the existing\nblock is replaced atomically. At least one of `gln` or\n`legacyOperatorId` must be set in the body — without an\nidentifier the Party doesn't actually identify anyone.\n\nWhen two roles share the same `gln`, the JSON-LD emission\ncollapses them into a single party with multiple roles\nattached.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Party"
              },
              "example": {
                "legalName": "EuroBat Recyclers GmbH",
                "gln": "4012345678901",
                "country": "DE",
                "url": "https://eurobat-recyclers.example"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Party upserted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "role",
                    "party",
                    "version"
                  ],
                  "properties": {
                    "role": {
                      "$ref": "#/components/schemas/PartyRoleEnum"
                    },
                    "party": {
                      "$ref": "#/components/schemas/Party"
                    },
                    "version": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      },
      "delete": {
        "operationId": "deleteParty",
        "tags": [
          "Passports"
        ],
        "security": [
          {
            "BearerAuth": []
          },
          {
            "OAuth2": [
              "passports:write"
            ]
          }
        ],
        "summary": "Remove a party from a passport.",
        "description": "Remove the Party for one role on a passport. Idempotent —\nremoving an absent role returns `{ removed: false }`\nwithout bumping the passport version.\n",
        "responses": {
          "200": {
            "description": "Party removed (or already absent).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "role",
                    "removed"
                  ],
                  "properties": {
                    "role": {
                      "$ref": "#/components/schemas/PartyRoleEnum"
                    },
                    "removed": {
                      "type": "boolean"
                    },
                    "version": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/v1/passports/{id}/suspend": {
      "parameters": [
        {
          "$ref": "#/components/parameters/PassportId"
        },
        {
          "$ref": "#/components/parameters/IdempotencyKey"
        }
      ],
      "post": {
        "operationId": "suspendPassport",
        "tags": [
          "Passports"
        ],
        "security": [
          {
            "BearerAuth": []
          },
          {
            "OAuth2": [
              "passports:write"
            ]
          }
        ],
        "summary": "Suspend a passport (reversible).",
        "description": "Reversible suspend. The public viewer flips to the\nsuspended state page (HTTP 423 with structured body); QR\nscans effectively die without the URL going to 404.\n\nOptional `reason` body surfaces in the\n`passport.suspended` webhook payload and the dashboard\naudit trail (truncated at 500 chars). Empty body is fine.\n",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "reason": {
                    "type": "string",
                    "maxLength": 500
                  }
                }
              },
              "example": {
                "reason": "Quality investigation pending — batch BB-2026-04-12."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Suspended.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Passport"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "description": "Wrong status to suspend from.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/v1/passports/{id}/archive": {
      "parameters": [
        {
          "$ref": "#/components/parameters/PassportId"
        },
        {
          "$ref": "#/components/parameters/IdempotencyKey"
        }
      ],
      "post": {
        "operationId": "archivePassport",
        "tags": [
          "Passports"
        ],
        "security": [
          {
            "BearerAuth": []
          },
          {
            "OAuth2": [
              "passports:write"
            ]
          }
        ],
        "summary": "Archive a passport (irreversible).",
        "description": "**Irreversible.** Public viewer returns 404, the GS1\nDigital Link URL stops resolving, the QR code dies for\ngood. Use ONLY for products that never shipped — archiving\na passport for a product already in customers' hands\nbreaks every QR scan they'll ever make.\n\nThe HTTP method is POST and the path includes a literal\n`archive` segment, both intentional friction. Fires the\n`passport.archived` webhook.\n",
        "responses": {
          "200": {
            "description": "Archived.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Passport"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "description": "Already archived.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/v1/passports/batch": {
      "post": {
        "operationId": "batchCreatePassports",
        "tags": [
          "Passports"
        ],
        "security": [
          {
            "BearerAuth": []
          },
          {
            "OAuth2": [
              "passports:write"
            ]
          }
        ],
        "summary": "Batch-create passports (up to 100 per call).",
        "description": "Create up to 100 passports in one call. Each item carries\nthe same body shape as the single-create endpoint;\npartial-success per item — each gets its own status in the\nresponse array.\n\nThe whole batch consumes N writes upfront against the\ndaily-write budget; if that would overflow, the entire\nbatch returns 429 (no partial billing). Same applies to\nDPP overage at the batch level — when the DPP quota would\nbe exceeded the batch returns 402 with `overage_required`,\nand `confirmOverage: true` accepts the overage charge for\nthe whole batch.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchCreatePassportsInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Partial-success batch result.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BatchPassportsResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/OverageRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/v1/passports/{id}/qr": {
      "parameters": [
        {
          "$ref": "#/components/parameters/PassportId"
        }
      ],
      "get": {
        "operationId": "getPassportQr",
        "tags": [
          "Passports"
        ],
        "summary": "Render the passport's QR code.",
        "description": "Returns the QR code for a passport. Default response is a\nraw SVG image so `<img src=\".../qr\">` works directly. Use\n`?format=png` for a raster PNG, or `?format=json` for an\nenvelope containing both the SVG markup and a base64\nPNG data URI plus the encoded URL + status.\n\nCounts as one v1 passport-read against the daily budget\n(same counter as the data endpoint).\n",
        "parameters": [
          {
            "name": "format",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "svg",
                "png",
                "json"
              ],
              "default": "svg"
            }
          },
          {
            "name": "useCompanyBranding",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "true"
              ]
            },
            "description": "When set to `true`, foreground colour comes from the\ncompany's `branding.primaryColor`. Ignored if `color`\nis also set.\n"
          },
          {
            "name": "color",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "pattern": "^[0-9A-Fa-f]{6}$"
            },
            "description": "6-char hex without `#`. Wins over useCompanyBranding."
          },
          {
            "name": "backgroundColor",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "pattern": "^[0-9A-Fa-f]{6,8}$"
            },
            "description": "6 or 8 char hex without `#` (8 = RGBA)."
          }
        ],
        "responses": {
          "200": {
            "description": "QR rendering.",
            "content": {
              "image/svg+xml": {
                "schema": {
                  "type": "string"
                }
              },
              "image/png": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QrJsonEnvelope"
                }
              }
            }
          },
          "400": {
            "description": "Invalid colour parameter.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/v1/passports/by-serial/{serial}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Serial"
        },
        {
          "$ref": "#/components/parameters/GtinQuery"
        }
      ],
      "get": {
        "operationId": "getPassportBySerial",
        "tags": [
          "Passports"
        ],
        "summary": "Read one passport by serial.",
        "description": "By-serial addressing for the read endpoint — same response\nshape and same query parameters (`?lang=`, `?format=full`)\nas `GET /api/v1/passports/{id}`.\n",
        "parameters": [
          {
            "name": "format",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "full"
              ]
            }
          },
          {
            "name": "lang",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Passport.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Passport"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/AmbiguousSerial"
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/v1/passports/by-serial/{serial}/qr": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Serial"
        },
        {
          "$ref": "#/components/parameters/GtinQuery"
        }
      ],
      "get": {
        "operationId": "getPassportQrBySerial",
        "tags": [
          "Passports"
        ],
        "summary": "Render the passport's QR code (by serial).",
        "description": "Same shape as `GET /api/v1/passports/{id}/qr`.",
        "parameters": [
          {
            "name": "format",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "svg",
                "png",
                "json"
              ],
              "default": "svg"
            }
          },
          {
            "name": "useCompanyBranding",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "true"
              ]
            }
          },
          {
            "name": "color",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "pattern": "^[0-9A-Fa-f]{6}$"
            }
          },
          {
            "name": "backgroundColor",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "pattern": "^[0-9A-Fa-f]{6,8}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "QR rendering.",
            "content": {
              "image/svg+xml": {
                "schema": {
                  "type": "string"
                }
              },
              "image/png": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QrJsonEnvelope"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/AmbiguousSerial"
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/v1/passports/by-serial/{serial}/fields/{key}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Serial"
        },
        {
          "$ref": "#/components/parameters/GtinQuery"
        },
        {
          "$ref": "#/components/parameters/FieldKey"
        },
        {
          "$ref": "#/components/parameters/IdempotencyKey"
        }
      ],
      "patch": {
        "operationId": "updatePassportFieldBySerial",
        "tags": [
          "Passports"
        ],
        "security": [
          {
            "BearerAuth": []
          },
          {
            "OAuth2": [
              "passports:write"
            ]
          }
        ],
        "summary": "Update one field on a passport (by serial).",
        "description": "Same body, same response, and same semantics as PATCH\n`/api/v1/passports/{id}/fields/{key}`.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateFieldInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Field updated.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "field",
                    "version"
                  ],
                  "properties": {
                    "field": {
                      "$ref": "#/components/schemas/PassportField"
                    },
                    "version": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/AmbiguousSerial"
          },
          "422": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/v1/passports/by-serial/{serial}/suspend": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Serial"
        },
        {
          "$ref": "#/components/parameters/GtinQuery"
        },
        {
          "$ref": "#/components/parameters/IdempotencyKey"
        }
      ],
      "post": {
        "operationId": "suspendPassportBySerial",
        "tags": [
          "Passports"
        ],
        "security": [
          {
            "BearerAuth": []
          },
          {
            "OAuth2": [
              "passports:write"
            ]
          }
        ],
        "summary": "Suspend a passport (by serial).",
        "description": "Same shape as POST `/api/v1/passports/{id}/suspend`.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "reason": {
                    "type": "string",
                    "maxLength": 500
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Suspended.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Passport"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/AmbiguousSerial"
          },
          "422": {
            "description": "Wrong status to suspend from.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/v1/passports/by-serial/{serial}/archive": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Serial"
        },
        {
          "$ref": "#/components/parameters/GtinQuery"
        },
        {
          "$ref": "#/components/parameters/IdempotencyKey"
        }
      ],
      "post": {
        "operationId": "archivePassportBySerial",
        "tags": [
          "Passports"
        ],
        "security": [
          {
            "BearerAuth": []
          },
          {
            "OAuth2": [
              "passports:write"
            ]
          }
        ],
        "summary": "Archive a passport (by serial, irreversible).",
        "description": "Same shape as POST `/api/v1/passports/{id}/archive`. **Irreversible.**",
        "responses": {
          "200": {
            "description": "Archived.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Passport"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/AmbiguousSerial"
          },
          "422": {
            "description": "Already archived.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/v1/passports/by-serial/{serial}/epcis": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Serial"
        },
        {
          "$ref": "#/components/parameters/GtinQuery"
        }
      ],
      "get": {
        "operationId": "passportEpcisExportBySerial",
        "tags": [
          "EPCIS"
        ],
        "summary": "Export a passport's events as an EPCIS 2.0 document (by serial).",
        "description": "Same EPCIS 2.0 export as\n`GET /api/v1/passports/{id}/epcis`, looked up by your own\nserial instead of our internal id and scoped to the API\nkey's company. Returns a standards-valid EPCIS 2.0 JSON-LD\n`EPCISDocument`.\n\nEPCIS export is included on Starter plans and up; a tenant\nwithout it gets `403 epcis_export_not_available`. Counts as\none passport read.\n",
        "responses": {
          "200": {
            "description": "EPCIS 2.0 document.",
            "content": {
              "application/ld+json": {
                "schema": {
                  "$ref": "#/components/schemas/EpcisDocument"
                }
              }
            }
          },
          "400": {
            "description": "Invalid serial.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "EPCIS export not available on this plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/AmbiguousSerial"
          }
        }
      }
    },
    "/api/v1/products": {
      "post": {
        "operationId": "createProduct",
        "tags": [
          "Products"
        ],
        "security": [
          {
            "BearerAuth": []
          },
          {
            "OAuth2": [
              "passports:write"
            ]
          }
        ],
        "summary": "Create a product.",
        "description": "Create a product. The category template is resolved\nautomatically from the `category` slug — must match a\nseeded category. `model` strings are unique within the\nworkspace.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateProductInput"
              },
              "example": {
                "name": "Li-Ion 48V Battery Pack",
                "model": "BP-48V-100",
                "category": "batteries",
                "defaultFieldValues": {
                  "batteryChemistry": "NMC",
                  "nominalVoltage": 48
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Product created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Product"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      },
      "get": {
        "operationId": "listProducts",
        "tags": [
          "Products"
        ],
        "summary": "List products.",
        "description": "Paginated list of products in the workspace.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Page"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "name": "category",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "active",
                "archived"
              ]
            }
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated product list.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/PaginatedProducts"
                    }
                  },
                  "required": [
                    "data"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/v1/products/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProductId"
        }
      ],
      "get": {
        "operationId": "getProduct",
        "tags": [
          "Products"
        ],
        "summary": "Read one product.",
        "description": "Read one product by ID. Returns the full document\nincluding default field values, image URLs, template\nreference, and the running `passportCount`.\n",
        "responses": {
          "200": {
            "description": "Product.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Product"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      },
      "patch": {
        "operationId": "updateProduct",
        "tags": [
          "Products"
        ],
        "security": [
          {
            "BearerAuth": []
          },
          {
            "OAuth2": [
              "passports:write"
            ]
          }
        ],
        "summary": "Update a product.",
        "description": "Patch one or more product fields. Send only the keys you\nwant to change — omitted fields stay untouched.\n\n`imageUrls` REPLACES the existing array (deliberate — your\nCMS stays canonical). To append a single image without\nrewriting the list, use the multipart upload endpoint.\n`description: null` clears the description (distinct from\nomitting the key).\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateProductInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Product updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Product"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      },
      "delete": {
        "operationId": "deleteProduct",
        "tags": [
          "Products"
        ],
        "security": [
          {
            "BearerAuth": []
          },
          {
            "OAuth2": [
              "passports:write"
            ]
          }
        ],
        "summary": "Delete a product permanently.",
        "description": "**Permanent and irreversible.** Removes the product row +\nany uploaded documents whose only reference was this\nproduct (documents linked to other products/passports\nstay, just with `productId` unset). R2 storage under\n`<companyId>/products/<productId>/` is swept.\n\n**Only products with ZERO passports of any status are\neligible** — including archived. The rule preserves audit\nhistory: an archived passport carries the regulatory story\n(\"we published this, then withdrew it\") that would be\norphaned if the parent product disappeared.\n\n**Paid-plan feature.** Returns 403 on Free plans; use the\narchive endpoint instead.\n\nHonours `Idempotency-Key`. No webhook.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "Permanently deleted; summary includes cascade counts.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string"
                    },
                    "summary": {
                      "type": "object",
                      "properties": {
                        "productId": {
                          "type": "string"
                        },
                        "name": {
                          "type": "string"
                        },
                        "deletedCounts": {
                          "type": "object",
                          "properties": {
                            "documentRefsUnlinked": {
                              "type": "integer"
                            },
                            "documentsDeleted": {
                              "type": "integer"
                            },
                            "r2ObjectsDeleted": {
                              "type": "integer"
                            },
                            "documentR2ObjectsDeleted": {
                              "type": "integer"
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Plan gate — hard-delete is paid-only.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Product not eligible (any passports still reference it).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/v1/products/{id}/archive": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProductId"
        },
        {
          "$ref": "#/components/parameters/IdempotencyKey"
        }
      ],
      "post": {
        "operationId": "archiveProduct",
        "tags": [
          "Products"
        ],
        "security": [
          {
            "BearerAuth": []
          },
          {
            "OAuth2": [
              "passports:write"
            ]
          }
        ],
        "summary": "Archive a product.",
        "description": "Soft-archive. `Product.status` flips to `archived`; the\nproduct disappears from default listings (still visible\nwith the `?showArchived=true` filter). Existing passports\nkeep resolving — archive blocks NEW passport creation\nagainst this product going forward; it doesn't break any\nQR already in customers' hands.\n\nReturns 409 when any non-archived passport still references\nthe product. Honours `Idempotency-Key`.\n",
        "responses": {
          "200": {
            "description": "Archived.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Product"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Product still has non-archived passports.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/v1/products/batch": {
      "post": {
        "operationId": "batchCreateProducts",
        "tags": [
          "Products"
        ],
        "security": [
          {
            "BearerAuth": []
          },
          {
            "OAuth2": [
              "passports:write"
            ]
          }
        ],
        "summary": "Batch-create products (up to 100 per call).",
        "description": "Create up to 100 products in one call. Each item carries\nthe same body shape as the single-create endpoint.\nPartial-success per item — each gets its own status in\nthe response array.\n\nThe whole batch consumes N writes upfront against the\ndaily-write budget; if that would overflow, the entire\nbatch returns 429 (no partial billing).\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "products"
                ],
                "properties": {
                  "products": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 100,
                    "items": {
                      "$ref": "#/components/schemas/CreateProductInput"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Partial-success batch result.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BatchProductsResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/v1/products/{id}/images": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProductId"
        }
      ],
      "post": {
        "operationId": "uploadProductImage",
        "tags": [
          "Products"
        ],
        "security": [
          {
            "BearerAuth": []
          },
          {
            "OAuth2": [
              "passports:write"
            ]
          }
        ],
        "summary": "Upload a product image.",
        "description": "Upload a single image file via `multipart/form-data` (field\nname `file`) and append it to the product's `imageUrls`\narray. Returns the resulting public R2 URL plus the full\nupdated array.\n\nPNG / JPG / WebP only, max 5 MB per file, max 20 images\nper product. **No Idempotency-Key support** — multipart\nbodies aren't safely hashable; the client should check\nexistence and skip if retrying.\n",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "Image bytes (PNG, JPG, or WebP, ≤ 5 MB)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Image uploaded.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UploadImageResponse"
                }
              }
            }
          },
          "400": {
            "description": "No file provided.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "413": {
            "description": "Image larger than 5 MB.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "415": {
            "description": "Unsupported MIME type.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "422": {
            "description": "Product already has 20 images (max).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/v1/products/{id}/images/{index}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProductId"
        },
        {
          "name": "index",
          "in": "path",
          "required": true,
          "schema": {
            "type": "integer",
            "minimum": 0
          },
          "description": "Zero-based image index in the product's `imageUrls` array."
        }
      ],
      "delete": {
        "operationId": "deleteProductImage",
        "tags": [
          "Products"
        ],
        "security": [
          {
            "BearerAuth": []
          },
          {
            "OAuth2": [
              "passports:write"
            ]
          }
        ],
        "summary": "Remove a product image by index.",
        "description": "Remove a single image from `imageUrls` by its zero-based\nindex. The underlying R2 object is not deleted; the URL\njust stops being referenced.\n",
        "responses": {
          "200": {
            "description": "Image removed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "removed",
                    "imageUrls"
                  ],
                  "properties": {
                    "removed": {
                      "type": "string",
                      "format": "uri"
                    },
                    "imageUrls": {
                      "type": "array",
                      "items": {
                        "type": "string",
                        "format": "uri"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid image index.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Index out of range, or product not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimit"
          }
        }
      }
    },
    "/api/exports/tenant": {
      "get": {
        "operationId": "tenantExport",
        "tags": [
          "Exports"
        ],
        "summary": "Bulk JSON-LD tenant export.",
        "description": "Returns every product, every passport (regardless of\nstatus), and every referenced category template the\nworkspace owns, as one JSON-LD document.\n\n**Not API-key reachable.** Auth is dashboard JWT (admin\nrole) + paying-customer gate — same trust level as\ndeleting the company. The pragmatic flow is: open Settings\n→ Data export in the dashboard and click \"Export tenant\".\nProgrammatic access requires a session cookie issued by\n`/api/auth/login`.\n\nSynchronous response with `Content-Disposition:\nattachment; filename=\"tracepass-tenant-export-<companyId>-<YYYY-MM-DD>.jsonld\"`.\nNo pagination — all-or-nothing.\n",
        "security": [
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "name": "Accept",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "application/ld+json",
                "application/json"
              ],
              "default": "application/ld+json"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Tenant export.",
            "headers": {
              "Content-Disposition": {
                "schema": {
                  "type": "string"
                },
                "description": "attachment; filename=\"tracepass-tenant-export-<companyId>-<YYYY-MM-DD>.jsonld\"\n"
              }
            },
            "content": {
              "application/ld+json": {
                "schema": {
                  "$ref": "#/components/schemas/TenantExport"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TenantExport"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "Active paid subscription required.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "403": {
            "description": "Admin role required.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/exports/tenant/epcis": {
      "get": {
        "operationId": "tenantEpcisExport",
        "tags": [
          "Exports"
        ],
        "summary": "Bulk EPCIS 2.0 tenant export.",
        "description": "Returns every **published** passport the workspace owns,\nmerged into a single EPCIS 2.0 `EPCISDocument` (supply-chain,\nservice, ownership, and captured events). The EPCIS sibling of\nthe JSON-LD tenant export at `GET /api/exports/tenant`.\n\nOnly published passports are included — draft / suspended /\narchived / expired passports are not part of a traceability\nfeed. The tenant exporting their own data sees the full event\nset (authority access level).\n\n**Not API-key reachable.** Auth is dashboard JWT (admin role) +\npaying-customer gate — same privileged-action trust level as\nthe JSON-LD tenant export. Programmatic access requires a\nsession cookie from `/api/auth/login`; the per-passport v1\nEPCIS routes cover API-key access.\n\nEPCIS export is included on Starter plans and up; a tenant\nwithout it gets `403 epcis_export_not_available`.\n\nSynchronous response with `Content-Disposition: attachment`.\nNo pagination — a tenant large enough to need streaming is the\ntrigger to add an NDJSON / background-job path (not built today).\n",
        "security": [
          {
            "SessionCookie": []
          }
        ],
        "parameters": [
          {
            "name": "Accept",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "application/ld+json",
                "application/json"
              ],
              "default": "application/ld+json"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "EPCIS 2.0 document covering every published passport.",
            "headers": {
              "Content-Disposition": {
                "schema": {
                  "type": "string"
                },
                "description": "attachment; filename=\"tracepass-tenant-epcis-<companyId>-<YYYY-MM-DD>.jsonld\"\n"
              }
            },
            "content": {
              "application/ld+json": {
                "schema": {
                  "$ref": "#/components/schemas/EpcisDocument"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EpcisDocument"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "Active paid subscription required.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "403": {
            "description": "Admin role required, or EPCIS export not available on the\ntenant's plan (`epcis_export_not_available`, Starter+).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/exports/tenant/registry-batch": {
      "get": {
        "operationId": "tenantRegistryBatchExport",
        "tags": [
          "Exports"
        ],
        "summary": "Batch registration file for the EU DPP Registry.",
        "description": "Builds the batch file an economic operator uploads to the EU\nDPP Registry, as XML. Sibling of the JSON-LD and EPCIS tenant\nexports.\n\n**Publishing a passport does not register it.** The duty to\nregister sits with the economic operator placing the product on\nthe market (Reg (EU) 2023/1542), and TracePass has no registry\nclient. This endpoint hands over the payload already in the\nshape the registry accepts, so the step the operator owes is an\nupload rather than a data-assembly exercise.\n\n**Batteries only** — BATTERIES is the only product group the\nregistry offers, so passports in other categories are excluded\nrather than exported into a group the registry would reject.\nThe response reports how many were left out, so the caller is\nnot guessing.\n\n**Published passports only:** an unpublished passport's link\ndoes not resolve, and the registry probes it mechanically.\n\nAt most 100 items per file.\n\n**Not API-key reachable.** Auth is dashboard JWT with the admin\nrole — the same privileged-action gate as the other tenant\nexports. There is deliberately **no plan gate**: this is the\ncustomer's own regulatory obligation, and putting a paywall\nbetween them and a duty they cannot otherwise discharge is not\na trade worth making.\n\nSynchronous response with `Content-Disposition: attachment`.\n",
        "security": [
          {
            "SessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "The batch registration file.",
            "content": {
              "application/xml": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Admin role required."
          },
          "404": {
            "description": "Workspace not found."
          }
        }
      }
    },
    "/api/v1/passports/{id}/epcis": {
      "get": {
        "operationId": "passportEpcisExport",
        "tags": [
          "EPCIS"
        ],
        "summary": "Export a passport's events as an EPCIS 2.0 document.",
        "description": "Returns the passport's supply-chain, service, ownership, and\ncaptured events as a standards-valid EPCIS 2.0 JSON-LD\n`EPCISDocument`.\n\nEPCIS export is included on Starter plans and up; a tenant\nwithout it gets `403 epcis_export_not_available`.\n\nA by-serial sibling exists at\n`GET /api/v1/passports/by-serial/{serial}/epcis` — identical\nresponse, looked up by your own serial. Counts as one\npassport read.\n",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The passport's TracePass id."
          }
        ],
        "responses": {
          "200": {
            "description": "EPCIS 2.0 document.",
            "content": {
              "application/ld+json": {
                "schema": {
                  "$ref": "#/components/schemas/EpcisDocument"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "EPCIS export not available on this plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/epcis/capture": {
      "post": {
        "operationId": "epcisCapture",
        "tags": [
          "EPCIS"
        ],
        "security": [
          {
            "BearerAuth": []
          },
          {
            "OAuth2": [
              "passports:write"
            ]
          }
        ],
        "summary": "Capture EPCIS 2.0 events.",
        "description": "The EPCIS 2.0 Capture interface. Accepts an `EPCISDocument`,\nan `EPCISQueryDocument`, a bare event, or a bare array of\nevents as JSON-LD. Each event is validated, its EPCs resolved\nto passports, and stored.\n\nResponds `202 Accepted` with a `captureJobId` — poll\n`GET /api/v1/epcis/capture/{id}` for the per-event outcome.\n\nEPCIS capture is a paid add-on; a tenant without it gets\n`403 epcis_capture_not_available`. Counts as one v1 write.\nIdempotent: send an `Idempotency-Key` header, and each event's\nown EPCIS `eventID` is also deduped.\n",
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Stripe-style idempotency key. A replay with the same key\nand body returns the cached 202 without re-capturing.\n"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/ld+json": {
              "schema": {
                "$ref": "#/components/schemas/EpcisDocument"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Capture job accepted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EpcisCaptureResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "Active paid subscription required.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "403": {
            "description": "EPCIS capture add-on not enabled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/epcis/capture/{id}": {
      "get": {
        "operationId": "epcisCaptureJob",
        "tags": [
          "EPCIS"
        ],
        "summary": "Poll an EPCIS capture job.",
        "description": "The read side of the EPCIS 2.0 async capture model. Returns\nthe job's status, how many events were captured, and any\nper-event errors. Counts as one read.\n",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The capture job id from the capture response."
          }
        ],
        "responses": {
          "200": {
            "description": "Capture job status.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EpcisCaptureJob"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "EPCIS capture add-on not enabled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/epcis/events": {
      "get": {
        "operationId": "epcisQuery",
        "tags": [
          "EPCIS"
        ],
        "summary": "Query the EPCIS 2.0 event store.",
        "description": "Search your captured supply-chain events using EPCIS 2.0\nquery parameters, returned as an `EPCISQueryDocument`.\n\nEight parameters are supported: `EQ_bizStep`,\n`EQ_disposition`, `EQ_eventType`, `MATCH_epc`,\n`EQ_bizLocation`, `EQ_readPoint`, `GE_eventTime` and\n`LT_eventTime`. Values may be `|`-separated to match any of\nseveral. Any other parameter returns `400\nepcis_query_invalid` rather than being silently ignored, so a\ntypo fails loudly instead of widening your result set.\n\nResults are newest-first and only ever include events your own\nworkspace captured and approved. A response caps at 1000\nevents; if there were more, `X-TracePass-Result-Truncated:\ntrue` is set — narrow the window with `GE_eventTime` /\n`LT_eventTime` to page through.\n\nA workspace with the `epcisCaptureEnabled` flag turned off\ngets `403 epcis_query_not_available`. Where query is not\nprovisioned for the deployment the response is `503\nepcis_query_node_unavailable`. Counts as one read.\n",
        "parameters": [
          {
            "name": "EQ_bizStep",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Match events with this bizStep. `|`-separate to match any\nof several, e.g. `shipping|receiving`.\n"
          },
          {
            "name": "GE_eventTime",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Match events at or after this time."
          },
          {
            "name": "MATCH_epc",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Match events referencing this EPC."
          }
        ],
        "responses": {
          "200": {
            "description": "EPCIS query result.",
            "content": {
              "application/ld+json": {
                "schema": {
                  "$ref": "#/components/schemas/EpcisDocument"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "Active paid subscription required.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "403": {
            "description": "EPCIS query add-on not enabled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "503": {
            "description": "The EPCIS query node is not provisioned for this\ndeployment. Export and capture are unaffected.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/templates": {
      "get": {
        "operationId": "listTemplates",
        "tags": [
          "Templates"
        ],
        "summary": "List the DPP category templates (regulatory schemas).",
        "description": "Lists all DPP category templates — the regulatory field\nschemas (battery, textile, electronics, …). Reference data:\neach entry gives the field count, required-field count, and\nthe governing EU regulation, so an integrator or AI assistant\ncan discover what a compliant passport in a category needs\nbefore creating products or passports. Not company-scoped —\ntemplates are global.\n\n**No authentication required.** This is regulatory reference\ndata — the identical field definitions ship as the Apache-2.0\n`@tracepass/dpp-schemas` npm package — so it is readable\nwithout a key. Bounded by an IP rate limit (60 requests/min).\n",
        "security": [],
        "responses": {
          "200": {
            "description": "The available category templates.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "templates": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "category": {
                            "type": "string",
                            "example": "battery"
                          },
                          "categoryLabel": {
                            "type": "object",
                            "additionalProperties": {
                              "type": "string"
                            },
                            "description": "Localized category label, keyed by locale."
                          },
                          "fieldCount": {
                            "type": "integer",
                            "example": 119
                          },
                          "requiredFieldCount": {
                            "type": "integer",
                            "description": "Category-agnostic required count. Where a template carries `requiredFieldCountByCategory`, prefer that map — this flat number is right for only one of the categories (for battery, the industrial one).",
                            "example": 59
                          },
                          "requiredFieldCountByCategory": {
                            "type": "object",
                            "nullable": true,
                            "description": "Required-field totals per product category, present only where required-ness varies (the battery template today). Keyed by the categories that owe a passport under Art. 77(1) of Reg (EU) 2023/1542 — `EV`, `LMT`, `industrial_gt_2kwh`. `portable`, `SLI` and `industrial_lte_2kwh` are deliberately ABSENT rather than zero: those batteries owe no passport at all, which is a different statement from \"zero fields required\".",
                            "additionalProperties": {
                              "type": "integer"
                            },
                            "example": {
                              "EV": 74,
                              "LMT": 77,
                              "industrial_gt_2kwh": 59
                            }
                          },
                          "version": {
                            "type": "integer",
                            "example": 1
                          },
                          "regulation": {
                            "type": "object",
                            "properties": {
                              "name": {
                                "type": "string",
                                "example": "EU Battery Regulation"
                              },
                              "number": {
                                "type": "string",
                                "example": "(EU) 2023/1542"
                              },
                              "effectiveDate": {
                                "type": "string",
                                "format": "date-time"
                              },
                              "mandatoryDate": {
                                "type": "string",
                                "format": "date-time"
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (60 requests/min per IP).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/templates/{category}": {
      "get": {
        "operationId": "getTemplate",
        "tags": [
          "Templates"
        ],
        "summary": "Get the full regulatory schema for one DPP category.",
        "description": "Returns the full field schema for one DPP category — every\nfield with its key, label, data type, whether it is required,\naccess level (public / restricted / authority), enum options,\nvalidation bounds, and (where known) the regulation article /\nannex that mandates it. This is the \"what does a compliant\nbattery / textile / … passport actually need\" lookup that\npowers compliance-aware integrations and the MCP server's\ncompliance-copilot prompts.\n\n**No authentication required** — see the list endpoint above.\nBounded by an IP rate limit (60 requests/min).\n",
        "security": [],
        "parameters": [
          {
            "name": "category",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "battery",
                "textile",
                "electronics",
                "construction",
                "steel",
                "detergents",
                "paints-coatings",
                "packaging",
                "furniture",
                "tyres",
                "jewelry",
                "toys",
                "fmcg"
              ]
            },
            "description": "The DPP category key."
          }
        ],
        "responses": {
          "200": {
            "description": "The category's full field schema.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "category": {
                      "type": "string",
                      "example": "battery"
                    },
                    "categoryLabel": {
                      "type": "string",
                      "nullable": true
                    },
                    "version": {
                      "type": "integer"
                    },
                    "regulation": {
                      "type": "object",
                      "properties": {
                        "name": {
                          "type": "string"
                        },
                        "number": {
                          "type": "string"
                        },
                        "effectiveDate": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "mandatoryDate": {
                          "type": "string",
                          "format": "date-time"
                        }
                      }
                    },
                    "fieldCount": {
                      "type": "integer"
                    },
                    "requiredFieldCount": {
                      "type": "integer",
                      "description": "Count of fields whose category-agnostic `required` is true. Where a template uses per-category applicability, see `requiredFieldCountByCategory` for the per-category totals."
                    },
                    "requiredFieldCountByCategory": {
                      "type": "object",
                      "nullable": true,
                      "description": "Per-category required-field totals, present only for templates that use `requiredBy` (today: battery). Keys are the in-scope category values.",
                      "additionalProperties": {
                        "type": "integer"
                      }
                    },
                    "fields": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "key": {
                            "type": "string",
                            "example": "batteryUniqueIdentifier"
                          },
                          "label": {
                            "type": "string",
                            "nullable": true
                          },
                          "description": {
                            "type": "string",
                            "nullable": true
                          },
                          "dataType": {
                            "type": "string",
                            "example": "string"
                          },
                          "unit": {
                            "type": "string",
                            "nullable": true
                          },
                          "required": {
                            "type": "boolean",
                            "description": "Category-agnostic default. For templates that carry `requiredBy`, prefer that map — this boolean is the fallback used when the passport's category is unset or absent from the map. Note the battery exception below: for an out-of-scope battery category this boolean does NOT apply, because no field is mandatory at all."
                          },
                          "requiredBy": {
                            "type": "object",
                            "nullable": true,
                            "description": "Per-category required-ness, present only where a field's applicability varies by product category (today: the battery template, keyed by `batteryCategory`). Absent means the field is category-agnostic — read `required` instead. IMPORTANT for battery: the map is keyed ONLY by the three categories that owe a battery passport under Art. 77(1) of Reg (EU) 2023/1542 — `EV`, `LMT`, `industrial_gt_2kwh`. A `portable`, `SLI` or `industrial_lte_2kwh` battery owes no passport, so NO field is required for it. Check scope first; falling through to `required` for those three values asserts an obligation the Regulation does not impose.",
                            "additionalProperties": {
                              "type": "string",
                              "enum": [
                                "required",
                                "conditional",
                                "notApplicable"
                              ]
                            },
                            "example": {
                              "EV": "required",
                              "LMT": "notApplicable",
                              "industrial_gt_2kwh": "conditional"
                            }
                          },
                          "accessLevel": {
                            "type": "string",
                            "enum": [
                              "public",
                              "restricted",
                              "authority"
                            ]
                          },
                          "category": {
                            "type": "string",
                            "description": "Localized sub-section header this field belongs to."
                          },
                          "enumOptions": {
                            "type": "array",
                            "nullable": true,
                            "items": {
                              "type": "object",
                              "properties": {
                                "value": {
                                  "type": "string"
                                },
                                "label": {
                                  "type": "string",
                                  "nullable": true
                                }
                              }
                            }
                          },
                          "validation": {
                            "type": "object",
                            "properties": {
                              "minLength": {
                                "type": "integer",
                                "nullable": true
                              },
                              "maxLength": {
                                "type": "integer",
                                "nullable": true
                              },
                              "min": {
                                "type": "number",
                                "nullable": true
                              },
                              "max": {
                                "type": "number",
                                "nullable": true
                              },
                              "pattern": {
                                "type": "string",
                                "nullable": true
                              }
                            }
                          },
                          "regulationRef": {
                            "type": "object",
                            "nullable": true,
                            "description": "The regulation article/annex that mandates\nthis field, when known. null for platform-\nderived fields.\n",
                            "properties": {
                              "article": {
                                "type": "string",
                                "example": "Art. 77"
                              },
                              "annex": {
                                "type": "string",
                                "example": "Annex VI"
                              },
                              "description": {
                                "type": "string",
                                "nullable": true
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "No template for that category.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (60 requests/min per IP).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/oauth/register": {
      "post": {
        "operationId": "oauthRegister",
        "tags": [
          "OAuth"
        ],
        "summary": "Register an OAuth client (Dynamic Client Registration).",
        "description": "RFC 7591 Dynamic Client Registration. Public, unauthenticated —\na hosted client (e.g. an MCP host) self-registers before any\nuser is involved. Public clients (PKCE-only) omit\n`token_endpoint_auth_method` or set it to `none` and receive no\nsecret; confidential clients set `client_secret_post` and\nreceive a `client_secret` once. Rate-limited per IP.\n(Developers can also register apps interactively at Developer →\nOAuth Apps in the dashboard.)\n",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "client_name",
                  "redirect_uris"
                ],
                "properties": {
                  "client_name": {
                    "type": "string",
                    "example": "Acme ERP Connector"
                  },
                  "redirect_uris": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "format": "uri"
                    },
                    "example": [
                      "https://acme.example/oauth/callback"
                    ]
                  },
                  "scope": {
                    "type": "string",
                    "example": "passports:read passports:write offline_access"
                  },
                  "token_endpoint_auth_method": {
                    "type": "string",
                    "enum": [
                      "none",
                      "client_secret_post"
                    ],
                    "default": "none"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Client registered. `client_secret` present only for confidential clients.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "client_id": {
                      "type": "string",
                      "example": "oac_3f9a..."
                    },
                    "client_secret": {
                      "type": "string",
                      "description": "Returned ONCE; confidential clients only."
                    },
                    "client_name": {
                      "type": "string"
                    },
                    "redirect_uris": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "scope": {
                      "type": "string"
                    },
                    "token_endpoint_auth_method": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid client metadata or redirect_uri."
          }
        }
      }
    },
    "/api/oauth/authorize": {
      "get": {
        "operationId": "oauthAuthorize",
        "tags": [
          "OAuth"
        ],
        "summary": "Authorization endpoint (start the auth-code flow).",
        "description": "RFC 6749 §4.1.1. Validates the request, then redirects the\nlogged-in user to the consent screen; on approval the browser\nis redirected back to `redirect_uri` with a single-use `code`.\nPKCE is mandatory (`code_challenge_method=S256`). An unknown\nclient or unregistered `redirect_uri` returns 400 (no redirect);\nother parameter errors redirect back with `?error=`.\n",
        "security": [],
        "parameters": [
          {
            "name": "response_type",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "code"
              ]
            }
          },
          {
            "name": "client_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "redirect_uri",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uri"
            }
          },
          {
            "name": "scope",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Space-delimited scopes (narrowed to the client's ceiling)."
          },
          {
            "name": "state",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "code_challenge",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "base64url SHA-256 of the code_verifier."
          },
          {
            "name": "code_challenge_method",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "S256"
              ]
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Redirect to the consent screen, or back to the client with code / error."
          },
          "400": {
            "description": "Unknown client or unregistered redirect_uri."
          }
        }
      }
    },
    "/api/oauth/token": {
      "post": {
        "operationId": "oauthToken",
        "tags": [
          "OAuth"
        ],
        "summary": "Token endpoint (exchange code or refresh token).",
        "description": "RFC 6749 §3.2. Form-encoded. `grant_type=authorization_code`\nexchanges a PKCE-bound code (send `code_verifier`) for a 15-min\naccess token (+ refresh token when `offline_access` was\ngranted). `grant_type=refresh_token` rotates the refresh token.\nConfidential clients also send `client_secret`. Responses are\n`Cache-Control: no-store`.\n",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "properties": {
                  "grant_type": {
                    "type": "string",
                    "enum": [
                      "authorization_code",
                      "refresh_token"
                    ]
                  },
                  "code": {
                    "type": "string"
                  },
                  "redirect_uri": {
                    "type": "string",
                    "format": "uri"
                  },
                  "code_verifier": {
                    "type": "string"
                  },
                  "refresh_token": {
                    "type": "string"
                  },
                  "client_id": {
                    "type": "string"
                  },
                  "client_secret": {
                    "type": "string",
                    "description": "Confidential clients only."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Access token issued.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "access_token": {
                      "type": "string"
                    },
                    "token_type": {
                      "type": "string",
                      "enum": [
                        "Bearer"
                      ]
                    },
                    "expires_in": {
                      "type": "integer",
                      "example": 900
                    },
                    "scope": {
                      "type": "string"
                    },
                    "refresh_token": {
                      "type": "string",
                      "description": "Present only when offline_access was granted."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "invalid_grant / invalid_request / unsupported_grant_type."
          },
          "401": {
            "description": "invalid_client (bad client authentication)."
          }
        }
      }
    },
    "/api/oauth/revoke": {
      "post": {
        "operationId": "oauthRevoke",
        "tags": [
          "OAuth"
        ],
        "summary": "Revoke a token (RFC 7009).",
        "description": "Revoke a refresh token. Always returns 200 even for an unknown\ntoken (per RFC 7009, don't leak token validity). Confidential\nclients authenticate with `client_secret`.\n",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "required": [
                  "token",
                  "client_id"
                ],
                "properties": {
                  "token": {
                    "type": "string"
                  },
                  "client_id": {
                    "type": "string"
                  },
                  "client_secret": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Acknowledged (regardless of whether the token existed)."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "BearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "opaque",
        "description": "API-key Bearer token — the service-account method, best for\nserver-to-server and ERP integrations. Mint at Developer →\nAPI keys in the dashboard. Keys carry the `tp_` prefix\nfollowed by an opaque random suffix, and are company-scoped\n(all-or-nothing — they bypass OAuth scope checks).\n"
      },
      "OAuth2": {
        "type": "oauth2",
        "description": "OAuth 2.0 with PKCE — the user-authorized method, best for\nthird-party apps and AI assistants that act on a user's\nbehalf. A user grants specific scopes on a consent screen;\nthe resulting access token is limited to the intersection of\nthe user's dashboard role and the granted scopes. Register an\napp at Developer → OAuth Apps, or via Dynamic Client\nRegistration (RFC 7591) at `/api/oauth/register`. Discovery\nmetadata is at `/.well-known/oauth-authorization-server`.\n",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://app.tracepass.eu/api/oauth/authorize",
            "tokenUrl": "https://app.tracepass.eu/api/oauth/token",
            "refreshUrl": "https://app.tracepass.eu/api/oauth/token",
            "scopes": {
              "passports:read": "View products and digital product passports",
              "passports:write": "Create, update, and publish passports",
              "documents:read": "Read uploaded documents and extractions",
              "documents:write": "Upload documents and run AI extractions",
              "suppliers:read": "View supplier requests",
              "suppliers:write": "Create and manage supplier requests",
              "offline_access": "Issue a refresh token (stay connected)"
            }
          }
        }
      },
      "SessionCookie": {
        "type": "apiKey",
        "in": "cookie",
        "name": "tp_session",
        "description": "Dashboard session cookie issued by `/api/auth/login`.\nUsed only for the bulk JSON-LD tenant export, which is\ndeliberately not exposed via API key.\n"
      }
    },
    "parameters": {
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "schema": {
          "type": "string",
          "maxLength": 255
        },
        "description": "UUID v4 (or any opaque string ≤ 255 chars) per logical\noperation. The platform stores the first response for 24\nhours and replays it on the same key + same workspace.\nReusing the same key with a different request body returns\n422.\n"
      },
      "PassportId": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "$ref": "#/components/schemas/ObjectId"
        }
      },
      "ProductId": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "$ref": "#/components/schemas/ObjectId"
        }
      },
      "PartyRole": {
        "name": "role",
        "in": "path",
        "required": true,
        "schema": {
          "$ref": "#/components/schemas/PartyRoleEnum"
        }
      },
      "FieldKey": {
        "name": "key",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        },
        "description": "Field key (camelCase) as defined on the passport's\ncategory template — e.g. `nominalVoltage`,\n`batteryChemistry`, `recycledContentCobalt`.\n"
      },
      "Serial": {
        "name": "serial",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "minLength": 1,
          "maxLength": 100
        },
        "description": "Passport serial number, as supplied by the customer at\npassport-create time. Used by the by-serial alternate\naddressing forms.\n"
      },
      "GtinQuery": {
        "name": "gtin",
        "in": "query",
        "required": false,
        "schema": {
          "type": "string"
        },
        "description": "Optional disambiguator for the by-serial routes. A serial is\nunique only WITHIN a GTIN, so if the same serial exists on\npassports under two different GTINs in your account, a\nserial-only lookup is ambiguous and returns 409. Pass the\n`gtin` to resolve precisely — `(account, gtin, serial)` is\nunique. Omit it when your serials are unique account-wide.\n"
      },
      "Page": {
        "name": "page",
        "in": "query",
        "required": false,
        "schema": {
          "type": "integer",
          "minimum": 1,
          "default": 1
        }
      },
      "Limit": {
        "name": "limit",
        "in": "query",
        "required": false,
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 100,
          "default": 20
        }
      }
    },
    "schemas": {
      "ObjectId": {
        "type": "string",
        "pattern": "^[a-f0-9]{24}$",
        "example": "6650a1b2c3d4e5f6a7b8c9d0",
        "description": "MongoDB ObjectId hex string (24 chars)."
      },
      "GTIN": {
        "type": "string",
        "pattern": "^[0-9]{14}$",
        "example": "04012345000015",
        "description": "GS1 GTIN-14 with valid check digit."
      },
      "GLN": {
        "type": "string",
        "pattern": "^[0-9]{13}$",
        "example": "4012345678901",
        "description": "GS1 Global Location Number (13 digits, mod-10)."
      },
      "PartyRoleEnum": {
        "type": "string",
        "enum": [
          "manufacturer",
          "importer",
          "authorisedRepresentative",
          "distributor",
          "recycler",
          "producerResponsibilityOrg"
        ]
      },
      "Party": {
        "type": "object",
        "required": [
          "legalName"
        ],
        "description": "Economic-operator party. At least one of `gln` or\n`legacyOperatorId` must be set — without an identifier the\nParty doesn't actually identify anyone.\n",
        "properties": {
          "gln": {
            "$ref": "#/components/schemas/GLN"
          },
          "legalName": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "example": "EuroBat Recyclers GmbH"
          },
          "country": {
            "type": "string",
            "pattern": "^[A-Z]{2}$",
            "description": "ISO 3166-1 alpha-2 country code.",
            "example": "DE"
          },
          "legacyOperatorId": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120,
            "description": "Free-text fallback identifier (VAT, EORI, supplier\ncode) for parties without a GLN.\n",
            "example": "DE123456789"
          },
          "url": {
            "type": "string",
            "format": "uri",
            "maxLength": 500
          },
          "status": {
            "type": "string",
            "readOnly": true,
            "description": "Server-side status (active, etc.). Returned but ignored on write."
          }
        }
      },
      "PartyMap": {
        "type": "object",
        "additionalProperties": {
          "$ref": "#/components/schemas/Party"
        },
        "description": "Map of role → Party. Each role appears at most once.\n"
      },
      "GS1Block": {
        "type": "object",
        "required": [
          "gtin",
          "serialNumber"
        ],
        "properties": {
          "gtin": {
            "$ref": "#/components/schemas/GTIN"
          },
          "serialNumber": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100,
            "example": "BP-48V-100-000001"
          },
          "digitalLinkUri": {
            "type": "string",
            "format": "uri",
            "readOnly": true,
            "example": "https://id.tracepass.eu/p/01/04012345000015/21/BP-48V-100-000001"
          }
        }
      },
      "PassportStatus": {
        "type": "string",
        "enum": [
          "draft",
          "in_review",
          "approved",
          "published",
          "suspended",
          "expired",
          "archived"
        ]
      },
      "PassportField": {
        "type": "object",
        "properties": {
          "value": {},
          "source": {
            "type": "string",
            "enum": [
              "manual",
              "ai_suggested",
              "ai_approved",
              "reference_db",
              "supplier",
              "system"
            ]
          },
          "sourceProvider": {
            "type": "string",
            "description": "The specific data provider behind an AI-extracted value, where `source` only says it came from a reference database. One of `eprel`, `open-food-facts`, `pubchem`, `vies`, `echa`, `climatiq`, `gs1`, `manufacturers-cache`, or `research` for a web-researched value. Absent when the origin has no provider identity (a value read from your uploaded document, or one a supplier submitted), and absent when the extraction pipeline altered the provider's value before storing it — an unlabelled value is never a provider's word for it.",
            "example": "eprel"
          },
          "sourceUrl": {
            "type": "string",
            "format": "uri",
            "description": "The provider's canonical URL for this value, when it published one. Machine-readable companion to `evidence`.",
            "example": "https://eprel.ec.europa.eu/qr/450201"
          },
          "status": {
            "type": "string"
          },
          "accessLevel": {
            "type": "string"
          },
          "sourceLocale": {
            "type": "string"
          },
          "translations": {
            "type": "object",
            "additionalProperties": {
              "type": "object",
              "properties": {
                "value": {},
                "source": {
                  "type": "string"
                },
                "generatedAt": {
                  "type": "string",
                  "format": "date-time"
                }
              }
            }
          },
          "lastUpdatedAt": {
            "type": "string",
            "format": "date-time"
          },
          "lastUpdatedBy": {
            "type": "string"
          }
        }
      },
      "ComplianceFinding": {
        "type": "object",
        "description": "One compliance gap or warning, with regulation provenance.",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "missing_field",
              "unapproved_field",
              "missing_party",
              "invalid_format",
              "conditional_missing",
              "unverifiable_conditional"
            ]
          },
          "severity": {
            "type": "string",
            "enum": [
              "critical",
              "warning"
            ]
          },
          "target": {
            "type": "string",
            "description": "Field key or party role this finding concerns."
          },
          "regulation": {
            "type": "string",
            "description": "Regulation number, e.g. \"(EU) 2023/1542\"."
          },
          "article": {
            "type": "string",
            "description": "Article / annex citation, e.g. \"Art. 77\"."
          },
          "ruleId": {
            "type": "string",
            "description": "Rule id from the verified spec (e.g. \"BAT-1\", \"CC-1\")."
          },
          "why": {
            "type": "string",
            "description": "One-line explanation of why this matters."
          },
          "fix": {
            "type": "string",
            "description": "One-line actionable next step."
          }
        },
        "required": [
          "type",
          "severity",
          "why"
        ]
      },
      "RegistryReadinessResult": {
        "type": "object",
        "description": "Whether a passport would pass the EU DPP Registry's formal submission gate. Distinct from the substantive ComplianceVerdict.\n",
        "properties": {
          "ready": {
            "type": "boolean",
            "description": "True only when there are zero critical findings — i.e. the passport would pass the registry's formal gate.\n"
          },
          "findings": {
            "type": "array",
            "description": "Formal-gate findings, each with a REG-READY-* ruleId (REG-READY-MANDATORY / REG-READY-FORMAT / REG-READY-LINK / REG-READY-GRANULARITY / REG-READY-COMMODITY / REG-READY-SCOPE). Critical findings block readiness; warnings do not.\n",
            "items": {
              "$ref": "#/components/schemas/ComplianceFinding"
            }
          }
        },
        "required": [
          "ready",
          "findings"
        ]
      },
      "ComplianceVerdict": {
        "type": "object",
        "description": "Three-tier compliance verdict for a passport.",
        "properties": {
          "verdict": {
            "type": "string",
            "enum": [
              "compliant",
              "compliant_with_warnings",
              "incomplete"
            ]
          },
          "category": {
            "type": "string"
          },
          "conditionalCoverage": {
            "type": "string",
            "enum": [
              "evaluated",
              "static-only"
            ],
            "description": "Whether per-category conditional rules ran (`evaluated`) or\nno binding conditional exists yet for this category\n(`static-only`). Absence of conditional findings is only\nmeaningful alongside this flag.\n"
          },
          "critical": {
            "type": "array",
            "description": "Findings that force the `incomplete` verdict.",
            "items": {
              "$ref": "#/components/schemas/ComplianceFinding"
            }
          },
          "warnings": {
            "type": "array",
            "description": "Findings that downgrade to `compliant_with_warnings`.",
            "items": {
              "$ref": "#/components/schemas/ComplianceFinding"
            }
          },
          "checkedRules": {
            "type": "array",
            "description": "Rule ids that actually ran (static checks + each conditional rule).",
            "items": {
              "type": "string"
            }
          },
          "completionPercentage": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100
          }
        },
        "required": [
          "verdict",
          "category",
          "conditionalCoverage",
          "critical",
          "warnings",
          "checkedRules",
          "completionPercentage"
        ]
      },
      "Passport": {
        "type": "object",
        "properties": {
          "_id": {
            "$ref": "#/components/schemas/ObjectId"
          },
          "companyId": {
            "$ref": "#/components/schemas/ObjectId"
          },
          "productId": {
            "$ref": "#/components/schemas/ObjectId"
          },
          "templateId": {
            "$ref": "#/components/schemas/ObjectId"
          },
          "gs1": {
            "$ref": "#/components/schemas/GS1Block"
          },
          "status": {
            "$ref": "#/components/schemas/PassportStatus"
          },
          "completionPercentage": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100
          },
          "fieldCounts": {
            "type": "object",
            "properties": {
              "total": {
                "type": "integer"
              },
              "empty": {
                "type": "integer"
              },
              "approved": {
                "type": "integer"
              },
              "pendingReview": {
                "type": "integer"
              },
              "flagged": {
                "type": "integer"
              }
            }
          },
          "fields": {
            "type": "object",
            "additionalProperties": {
              "$ref": "#/components/schemas/PassportField"
            }
          },
          "parties": {
            "$ref": "#/components/schemas/PartyMap"
          },
          "publishedAt": {
            "type": "string",
            "format": "date-time"
          },
          "suspendedAt": {
            "type": "string",
            "format": "date-time"
          },
          "suspensionReason": {
            "type": "string"
          },
          "archivedAt": {
            "type": "string",
            "format": "date-time"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PassportSummary": {
        "type": "object",
        "description": "Trimmed passport shape returned in list responses.",
        "properties": {
          "_id": {
            "$ref": "#/components/schemas/ObjectId"
          },
          "productId": {
            "$ref": "#/components/schemas/ObjectId"
          },
          "gs1": {
            "$ref": "#/components/schemas/GS1Block"
          },
          "status": {
            "$ref": "#/components/schemas/PassportStatus"
          },
          "completionPercentage": {
            "type": "integer"
          },
          "publishedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Product": {
        "type": "object",
        "properties": {
          "_id": {
            "$ref": "#/components/schemas/ObjectId"
          },
          "name": {
            "type": "string"
          },
          "model": {
            "type": "string"
          },
          "category": {
            "type": "string"
          },
          "templateId": {
            "$ref": "#/components/schemas/ObjectId"
          },
          "defaultFieldValues": {
            "type": "object",
            "additionalProperties": {}
          },
          "imageUrls": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uri",
              "maxLength": 2048
            },
            "maxItems": 20
          },
          "passportCount": {
            "type": "integer"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "archived"
            ]
          },
          "sourceLocale": {
            "type": "string"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "CreatePassportInput": {
        "type": "object",
        "required": [
          "productId",
          "gs1"
        ],
        "properties": {
          "productId": {
            "$ref": "#/components/schemas/ObjectId"
          },
          "gs1": {
            "type": "object",
            "required": [
              "gtin",
              "serialNumber"
            ],
            "properties": {
              "gtin": {
                "$ref": "#/components/schemas/GTIN"
              },
              "serialNumber": {
                "type": "string",
                "minLength": 1,
                "maxLength": 100
              }
            }
          },
          "parties": {
            "$ref": "#/components/schemas/PartyMap"
          },
          "confirmOverage": {
            "type": "boolean",
            "description": "Accept the per-passport overage charge when the plan's\n`maxDpps` quota is already exhausted. Required only\nafter a 402 response on a previous attempt.\n"
          }
        }
      },
      "UpdateFieldInput": {
        "type": "object",
        "required": [
          "value"
        ],
        "properties": {
          "value": {
            "description": "New value, of the type the field's template entry\nexpects. Number fields take a raw number, not a string.\n"
          },
          "source": {
            "type": "string",
            "enum": [
              "manual",
              "ai_suggested",
              "ai_approved",
              "reference_db",
              "supplier",
              "system"
            ],
            "description": "Tag the origin of the value. Default `manual`.\n`ai_suggested` and `supplier` land in the review queue;\neverything else writes as approved.\n"
          },
          "sourceLocale": {
            "type": "string",
            "description": "ISO 639-1 locale of the value. One of the 24 EU\nlocales. Defaults to the passport's `sourceLocale`.\n"
          }
        }
      },
      "CreateProductInput": {
        "type": "object",
        "required": [
          "name",
          "model",
          "category"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200
          },
          "model": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100,
            "description": "Unique within the workspace."
          },
          "category": {
            "type": "string",
            "description": "Seeded category slug — `batteries`, `textiles`,\n`jewelry`, `electronics`, `furniture`, `detergents`,\n`paints-coatings`, `packaging`, `toys`, `fmcg`, `tyres`,\n`iron-steel`, `construction`.\n"
          },
          "description": {
            "type": "string",
            "maxLength": 2000
          },
          "defaultFieldValues": {
            "type": "object",
            "additionalProperties": {}
          },
          "imageUrls": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uri",
              "maxLength": 2048
            },
            "maxItems": 20
          },
          "sourceLocale": {
            "type": "string"
          }
        }
      },
      "UpdateProductInput": {
        "type": "object",
        "description": "Send only the keys you want to change. `imageUrls`\nREPLACES the existing array. `description: null` clears\nthe description.\n",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200
          },
          "model": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100
          },
          "description": {
            "oneOf": [
              {
                "type": "string",
                "maxLength": 2000
              },
              {
                "type": "null"
              }
            ]
          },
          "defaultFieldValues": {
            "type": "object",
            "additionalProperties": {}
          },
          "imageUrls": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uri",
              "maxLength": 2048
            },
            "maxItems": 20
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "archived"
            ]
          },
          "sourceLocale": {
            "type": "string"
          }
        }
      },
      "UploadImageResponse": {
        "type": "object",
        "required": [
          "imageUrl",
          "imageUrls"
        ],
        "properties": {
          "imageUrl": {
            "type": "string",
            "format": "uri"
          },
          "imageUrls": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uri"
            }
          }
        }
      },
      "PaginationEnvelope": {
        "type": "object",
        "required": [
          "items",
          "total",
          "page",
          "limit",
          "totalPages"
        ],
        "properties": {
          "total": {
            "type": "integer"
          },
          "page": {
            "type": "integer"
          },
          "limit": {
            "type": "integer"
          },
          "totalPages": {
            "type": "integer"
          }
        }
      },
      "PaginatedPassports": {
        "allOf": [
          {
            "$ref": "#/components/schemas/PaginationEnvelope"
          },
          {
            "type": "object",
            "required": [
              "items"
            ],
            "properties": {
              "items": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/PassportSummary"
                }
              }
            }
          }
        ]
      },
      "PaginatedProducts": {
        "allOf": [
          {
            "$ref": "#/components/schemas/PaginationEnvelope"
          },
          {
            "type": "object",
            "required": [
              "items"
            ],
            "properties": {
              "items": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/Product"
                }
              }
            }
          }
        ]
      },
      "BatchResultItem": {
        "oneOf": [
          {
            "type": "object",
            "required": [
              "index",
              "status",
              "data"
            ],
            "properties": {
              "index": {
                "type": "integer"
              },
              "status": {
                "type": "string",
                "enum": [
                  "created"
                ]
              },
              "data": {
                "$ref": "#/components/schemas/Passport"
              }
            }
          },
          {
            "type": "object",
            "required": [
              "index",
              "status",
              "error"
            ],
            "properties": {
              "index": {
                "type": "integer"
              },
              "status": {
                "type": "string",
                "enum": [
                  "error"
                ]
              },
              "error": {
                "type": "string"
              }
            }
          }
        ]
      },
      "BatchSummary": {
        "type": "object",
        "required": [
          "created",
          "errors",
          "total"
        ],
        "properties": {
          "created": {
            "type": "integer"
          },
          "errors": {
            "type": "integer"
          },
          "total": {
            "type": "integer"
          }
        }
      },
      "BatchPassportsResponse": {
        "type": "object",
        "required": [
          "results",
          "summary"
        ],
        "properties": {
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BatchResultItem"
            }
          },
          "summary": {
            "$ref": "#/components/schemas/BatchSummary"
          }
        }
      },
      "BatchCreatePassportsInput": {
        "type": "object",
        "required": [
          "passports"
        ],
        "description": "Top-level `confirmOverage` is the BATCH-LEVEL flag — set\nit to accept the per-passport overage charge if the entire\nbatch would exceed the workspace's `maxDpps` quota. Wins\nover any per-item `confirmOverage` (the single-create\nflag, which is also accepted on each item but only relevant\nif a single item would exhaust the budget on its own).\n",
        "properties": {
          "passports": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "items": {
              "$ref": "#/components/schemas/CreatePassportInput"
            }
          },
          "confirmOverage": {
            "type": "boolean"
          }
        }
      },
      "BatchProductResultItem": {
        "oneOf": [
          {
            "type": "object",
            "required": [
              "index",
              "status",
              "data"
            ],
            "properties": {
              "index": {
                "type": "integer"
              },
              "status": {
                "type": "string",
                "enum": [
                  "created"
                ]
              },
              "data": {
                "$ref": "#/components/schemas/Product"
              }
            }
          },
          {
            "type": "object",
            "required": [
              "index",
              "status",
              "error"
            ],
            "properties": {
              "index": {
                "type": "integer"
              },
              "status": {
                "type": "string",
                "enum": [
                  "error"
                ]
              },
              "error": {
                "type": "string"
              }
            }
          }
        ]
      },
      "BatchProductsResponse": {
        "type": "object",
        "required": [
          "results",
          "summary"
        ],
        "properties": {
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BatchProductResultItem"
            }
          },
          "summary": {
            "$ref": "#/components/schemas/BatchSummary"
          }
        }
      },
      "QrJsonEnvelope": {
        "type": "object",
        "required": [
          "passportId",
          "gtin",
          "serial",
          "publicUrl",
          "status",
          "qrSvg",
          "qrPngDataUri",
          "colors"
        ],
        "description": "`format=json` response from the QR endpoints. Carries the\nSVG markup, a base64-encoded PNG data URI, and the\nencoded URL + passport status — useful for clients that\nwant both renderings in one round-trip plus the metadata\nthe QR encodes.\n",
        "properties": {
          "passportId": {
            "$ref": "#/components/schemas/ObjectId"
          },
          "gtin": {
            "$ref": "#/components/schemas/GTIN"
          },
          "serial": {
            "type": "string"
          },
          "publicUrl": {
            "type": "string",
            "format": "uri"
          },
          "status": {
            "$ref": "#/components/schemas/PassportStatus"
          },
          "qrSvg": {
            "type": "string",
            "description": "Inline `<svg>` markup, ready to embed."
          },
          "qrPngDataUri": {
            "type": "string",
            "description": "data:image/png;base64,..."
          },
          "colors": {
            "type": "object",
            "properties": {
              "foreground": {
                "type": "string"
              },
              "background": {
                "type": "string"
              }
            }
          }
        }
      },
      "ErrorEnvelope": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string"
          },
          "details": {
            "type": "object"
          }
        }
      },
      "OverageRequiredBody": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "enum": [
              "overage_required"
            ]
          },
          "planLimit": {
            "type": "integer"
          },
          "currentUsage": {
            "type": "integer"
          },
          "requested": {
            "type": "integer"
          },
          "extraPriceCents": {
            "type": "integer"
          },
          "message": {
            "type": "string"
          }
        }
      },
      "TenantExport": {
        "type": "object",
        "description": "Bulk JSON-LD export envelope.",
        "properties": {
          "@context": {
            "type": "string",
            "format": "uri"
          },
          "@type": {
            "type": "string",
            "enum": [
              "TenantExport"
            ]
          },
          "exportedAt": {
            "type": "string",
            "format": "date-time"
          },
          "company": {
            "type": "object",
            "properties": {
              "_id": {
                "$ref": "#/components/schemas/ObjectId"
              },
              "name": {
                "type": "string"
              },
              "country": {
                "type": "string"
              }
            }
          },
          "counts": {
            "type": "object",
            "properties": {
              "products": {
                "type": "integer"
              },
              "passports": {
                "type": "integer"
              },
              "templates": {
                "type": "integer"
              }
            }
          },
          "products": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Product"
            }
          },
          "passports": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Passport"
            }
          },
          "templates": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "$ref": "#/components/schemas/ObjectId"
                },
                "category": {
                  "type": "string"
                },
                "version": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "EpcisDocument": {
        "type": "object",
        "description": "A GS1 EPCIS 2.0 JSON-LD document. The shape is defined by the\nGS1 EPCIS 2.0 standard, not by TracePass — only the top-level\nenvelope is sketched here.\n",
        "properties": {
          "@context": {
            "description": "EPCIS 2.0 JSON-LD context (string or array)."
          },
          "type": {
            "type": "string",
            "enum": [
              "EPCISDocument",
              "EPCISQueryDocument"
            ]
          },
          "schemaVersion": {
            "type": "string",
            "example": "2.0"
          },
          "creationDate": {
            "type": "string",
            "format": "date-time"
          },
          "epcisBody": {
            "type": "object",
            "description": "Carries `eventList` (an array of EPCIS events) for an\nEPCISDocument, or `queryResults` for an EPCISQueryDocument.\n"
          }
        }
      },
      "EpcisCaptureResult": {
        "type": "object",
        "description": "The 202 response from the EPCIS Capture interface.",
        "properties": {
          "captureJobId": {
            "type": "string",
            "description": "Poll GET /api/v1/epcis/capture/{id} with this id."
          },
          "status": {
            "type": "string",
            "enum": [
              "success",
              "failed"
            ]
          },
          "eventCount": {
            "type": "integer",
            "description": "Events found in the submitted payload."
          },
          "capturedCount": {
            "type": "integer",
            "description": "Events actually stored (after idempotency dedup)."
          },
          "errors": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "index": {
                  "type": "integer"
                },
                "message": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "EpcisCaptureJob": {
        "type": "object",
        "description": "An EPCIS capture-job record, returned by the poll endpoint.",
        "properties": {
          "captureJobId": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "running",
              "success",
              "failed"
            ]
          },
          "eventCount": {
            "type": "integer"
          },
          "capturedCount": {
            "type": "integer"
          },
          "errors": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "index": {
                  "type": "integer"
                },
                "message": {
                  "type": "string"
                }
              }
            }
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "finishedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Validation error or malformed request.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing or invalid API key.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "Forbidden": {
        "description": "Plan-gate or workspace permission denied.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "NotFound": {
        "description": "Resource doesn't exist or is in a different workspace.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "Conflict": {
        "description": "Conflict — duplicate model on a product, GTIN registered\nto a different tenant, serial collision within a GTIN.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "AmbiguousSerial": {
        "description": "The serial maps to more than one passport in your account\n(serials are unique per GTIN, not per account). The request\nis rejected rather than acting on the wrong passport. Pass\n`?gtin=<gtin>` to disambiguate, or address the passport by\nits id instead. `count` is how many passports the serial\nmatched.\n",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "error": {
                  "type": "string",
                  "enum": [
                    "ambiguous_serial"
                  ]
                },
                "message": {
                  "type": "string"
                },
                "count": {
                  "type": "integer"
                }
              },
              "required": [
                "error",
                "message",
                "count"
              ]
            }
          }
        }
      },
      "IdempotencyConflict": {
        "description": "Idempotency-Key reused with a different request body. Use\na new key for the new request, or reuse the original body.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "OverageRequired": {
        "description": "DPP quota exhausted; retry with `confirmOverage: true` to\naccept the per-passport charge.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/OverageRequiredBody"
            }
          }
        }
      },
      "RateLimit": {
        "description": "Daily v1 write or passport-read budget exhausted. Resets\nat 00:00 UTC.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      }
    }
  }
}
