openapi: 3.1.0 info: title: CyfroPass Public API version: "1.0.0" description: | Public REST API (v1) for the CyfroPass Digital Product Passport platform. ## Availability The API is available on **every plan** (Free included). Tiers differ by the daily quota, not by a boolean. Every request still re-checks the tenant's live entitlement, so a fully suspended account answers `403 api_not_in_plan`. ## Authentication Authenticate every request with an API key, either as a bearer token or in the `X-API-Key` header: ``` Authorization: Bearer cp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx X-API-Key: cp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` New keys carry the `cp_live_` prefix (CyfroPass). Keys issued before the rebrand (`pf_live_`) keep working — validation is by hash, not by prefix. Keys are created by a workspace **owner** in the app under Settings → API keys (or `POST /api/tenant/apikeys`). The raw key is shown **once** at creation and stored only as a salted hash — keep it secret. A key authenticates as its tenant with **editor** rights: it can read and write product data, publish passports and run imports, but never touches billing, users or tenant settings (those endpoints are not part of v1). ## Rate limits Enforcement is a **per-UTC-day quota** scoped to the workspace, split into **writes/day** (POST/PATCH/PUT/DELETE) and **passport reads/day** (GET on the passport endpoints). Limits come from the plan (Free 100 writes + 100 reads/day; Starter 1,000 writes/day + unlimited reads; Business & Scale unlimited). `0` means unlimited. On the ceiling the API returns **429** with a flat body, e.g.: ``` {"error":"API rate limit: 100 writes/day via /api/v1. Currently 100 today; requested 1. Retry tomorrow (UTC) or upgrade your plan."} ``` The window is non-rolling and resets at 00:00 UTC. A small per-minute burst guard also applies (`429 rate_limited`). ## Idempotency Send an `Idempotency-Key` header (a UUID or opaque string ≤ 255 chars) on any write (POST/PATCH/DELETE). The first response is stored and **replayed** for 24h when the same key is retried with the same body. Reusing the key with a **different** body returns `422`. Keys over 255 chars return `400`. ## Errors Every error uses a uniform envelope with a stable, machine-readable `code` (see the `Error` schema). Some endpoints add a `fields` or `details` array with per-field validation reasons. ## Pagination List endpoints that paginate accept `limit` (1–200, default 50) and `offset` (>= 0, default 0), and return `total`, `limit` and `offset` alongside the items. contact: name: CyfroPass url: https://passflow.example servers: - url: https://{host}/v1 description: Origin host; replace {host} with your CyfroPass domain. variables: host: default: app.passflow.example security: - bearerAuth: [] - apiKeyHeader: [] tags: - name: Products description: Product catalogue (SKUs) and attached documents. - name: Passports description: Passport lifecycle — publish, re-publish, archive, version history. - name: Templates description: Read-only product-group templates and validation. - name: Import description: CSV / e-commerce store import wizard. - name: Batches & Serials description: >- Model → batch → serial data-inheritance hierarchy and per-level passport publishing (reachable via the v1 API-key surface, same as Products). - name: Compliance description: Per-product "what's missing" report and delegated-act template-update advisories. - name: Suppliers description: >- Supplier data-request forms (Feature B). NOT part of the v1 API-key surface — session-authenticated origin-root endpoints (tenant routes) and token-gated public endpoints. Documented here for completeness. - name: Billing description: >- Company / tax invoice details. Session + owner role, origin root — NOT part of the v1 API-key surface. - name: Auth description: >- Account self-service. Session-authenticated, origin root — NOT part of the v1 API-key surface. paths: /products: get: tags: [Products] summary: List products description: Lists products in the authenticated tenant. Paginated. parameters: - { name: status, in: query, required: false, schema: { type: string, enum: [draft, published, archived] } } - { name: template_id, in: query, required: false, schema: { type: string } } - { name: q, in: query, required: false, description: Substring match on name or GTIN., schema: { type: string } } - { name: sort, in: query, required: false, schema: { type: string, enum: [name, created_at, updated_at, completeness, status], default: created_at } } - { name: order, in: query, required: false, schema: { type: string, enum: [asc, desc], default: asc } } - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } } - { name: offset, in: query, required: false, schema: { type: integer, minimum: 0, default: 0 } } responses: "200": description: A page of products. content: application/json: schema: type: object properties: products: { type: array, items: { $ref: "#/components/schemas/Product" } } total: { type: integer } limit: { type: integer } offset: { type: integer } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/NotInPlan" } "429": { $ref: "#/components/responses/RateLimited" } post: tags: [Products] summary: Create a product description: | Creates a product from a template version. Fails with `sku_limit_reached` (402) once the plan's SKU ceiling is reached. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ProductCreate" } responses: "201": description: The created product plus this request's validation result. content: application/json: schema: { $ref: "#/components/schemas/ProductWithValidation" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "402": description: SKU limit reached for the plan. content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "403": { $ref: "#/components/responses/NotInPlan" } "422": description: Unknown template version (`template_not_found`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": { $ref: "#/components/responses/RateLimited" } /products/{id}: parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } get: tags: [Products] summary: Get a product responses: "200": description: The product. content: { application/json: { schema: { $ref: "#/components/schemas/Product" } } } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } patch: tags: [Products] summary: Update a product description: | Partially updates mutable fields. `data` is **merged** at the top level (per-key overwrite; omit a key to leave it untouched). `status` is read-only here — it changes only through publish/archive. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ProductUpdate" } responses: "200": description: The updated product plus its validation result. content: { application/json: { schema: { $ref: "#/components/schemas/ProductWithValidation" } } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } delete: tags: [Products] summary: Delete a draft product description: Only draft products can be deleted; published/archived → 409 not_draft. responses: "204": { description: Deleted. } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "409": description: Product is not a draft (`not_draft`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /products/{id}/documents: parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } get: tags: [Products] summary: List a product's documents responses: "200": description: Documents attached to the product. content: application/json: schema: type: object properties: documents: { type: array, items: { $ref: "#/components/schemas/Document" } } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } post: tags: [Products] summary: Upload a document description: Multipart upload. Max 25 MiB. requestBody: required: true content: multipart/form-data: schema: type: object properties: file: { type: string, format: binary } kind: { type: string, description: "Free-text label (e.g. certificate, image, manual). Defaults to `document`." } required: [file] responses: "201": description: The stored document metadata. content: { application/json: { schema: { $ref: "#/components/schemas/Document" } } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "413": description: File too large (> 25 MiB) (`file_too_large`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /products/{id}/documents/{docID}: parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } - { name: docID, in: path, required: true, schema: { type: string, format: uuid } } get: tags: [Products] summary: Download a document description: Streams the document bytes (Content-Disposition attachment). responses: "200": description: The document bytes. content: application/octet-stream: schema: { type: string, format: binary } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } delete: tags: [Products] summary: Delete a document responses: "204": { description: Deleted. } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /products/template-updates: get: tags: [Compliance] summary: Tenant template-update summary description: >- Outdated products grouped by template (spec §2.3): each group carries the latest version, migration hint, deadline and the affected products. Products already on the latest version are omitted. Advisory only. Mounted BEFORE `/{id}`. `501 not_implemented` when the detector is unwired. responses: "200": description: Outdated products grouped by template. content: application/json: schema: type: object properties: updates: { type: array, items: { $ref: "#/components/schemas/TemplateUpdateGroup" } } "401": { $ref: "#/components/responses/Unauthorized" } "501": { $ref: "#/components/responses/NotImplemented" } /products/{id}/compliance: parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } get: tags: [Compliance] summary: Product "what's missing" report description: >- The product's working data validated against its template version: the required-field counts and the required-but-unfilled fields (a field hidden by `visible_when` never counts as missing). `501 not_implemented` when no compliance reporter is wired. responses: "200": description: The compliance report. content: { application/json: { schema: { $ref: "#/components/schemas/ComplianceReport" } } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "501": { $ref: "#/components/responses/NotImplemented" } /products/{id}/template-update: parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } get: tags: [Compliance] summary: Per-product template-update notice description: >- Whether a newer version of the product's template exists and, if so, the migration hint, latest version and compliance deadline. Advisory only — the stored `template_version` is never changed. `501 not_implemented` when the detector is unwired. responses: "200": description: The template-update notice. content: { application/json: { schema: { $ref: "#/components/schemas/TemplateUpdate" } } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "501": { $ref: "#/components/responses/NotImplemented" } /products/{id}/batches: parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } get: tags: [Batches & Serials] summary: List a product's batches responses: "200": description: The product's batches (with effective-data completeness). content: application/json: schema: type: object properties: batches: { type: array, items: { $ref: "#/components/schemas/BatchListItem" } } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } post: tags: [Batches & Serials] summary: Create a batch description: >- A batch stores a sparse override map (only the keys it overrides); absent keys inherit the product's data. The override is pruned to the template's declared field keys. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/BatchCreate" } responses: "201": description: The created batch (detail DTO). content: { application/json: { schema: { $ref: "#/components/schemas/BatchDetail" } } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /products/{id}/batches/{batchId}: parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } - { name: batchId, in: path, required: true, schema: { type: string, format: uuid } } get: tags: [Batches & Serials] summary: Get a batch responses: "200": description: The batch detail DTO. content: { application/json: { schema: { $ref: "#/components/schemas/BatchDetail" } } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } patch: tags: [Batches & Serials] summary: Update a batch description: >- `batch_code` and `data` are both optional. When `data` is present it **replaces** the sparse override map wholesale (pruned to known fields), not merged; when absent it is left unchanged. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/BatchUpdate" } responses: "200": description: The updated batch detail DTO. content: { application/json: { schema: { $ref: "#/components/schemas/BatchDetail" } } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } delete: tags: [Batches & Serials] summary: Delete a batch responses: "204": { description: Deleted. } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /products/{id}/batches/{batchId}/publish: parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } - { name: batchId, in: path, required: true, schema: { type: string, format: uuid } } post: tags: [Batches & Serials] summary: Publish or re-publish a batch passport description: >- Freezes the batch's effective (product ⊕ batch) data into its own passport snapshot, mints/reuses a uid + GS1/QR carrier. A batch passport is an instance of the model and does NOT consume the SKU quota. responses: "201": description: The published batch passport state. content: { application/json: { schema: { $ref: "#/components/schemas/PublishResult" } } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": description: Account is read-only (`read_only`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": { $ref: "#/components/responses/NotFound" } "501": { $ref: "#/components/responses/NotImplemented" } delete: tags: [Batches & Serials] summary: Unpublish a batch passport description: Archives the batch passport (410 Gone publicly after the §4.3 grace window). responses: "200": description: Archived. content: application/json: schema: type: object properties: { state: { type: string, example: archived } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "501": { $ref: "#/components/responses/NotImplemented" } /products/{id}/batches/{batchId}/serials: parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } - { name: batchId, in: path, required: true, schema: { type: string, format: uuid } } get: tags: [Batches & Serials] summary: List a batch's serials responses: "200": description: The batch's serials. content: application/json: schema: type: object properties: serials: { type: array, items: { $ref: "#/components/schemas/SerialListItem" } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } post: tags: [Batches & Serials] summary: Create a serial description: >- A serial belongs to one batch and stores its own sparse override map; absent keys inherit the batch's effective data (product ⊕ batch ⊕ serial). requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/SerialCreate" } responses: "201": description: The created serial (detail DTO). content: { application/json: { schema: { $ref: "#/components/schemas/SerialDetail" } } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /products/{id}/batches/{batchId}/serials/{serialId}: parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } - { name: batchId, in: path, required: true, schema: { type: string, format: uuid } } - { name: serialId, in: path, required: true, schema: { type: string, format: uuid } } get: tags: [Batches & Serials] summary: Get a serial responses: "200": description: The serial detail DTO. content: { application/json: { schema: { $ref: "#/components/schemas/SerialDetail" } } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } patch: tags: [Batches & Serials] summary: Update a serial description: >- `serial_number` and `data` are optional. When `data` is present it replaces the serial's sparse override map wholesale (pruned to known fields); when absent it is left unchanged. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/SerialUpdate" } responses: "200": description: The updated serial detail DTO. content: { application/json: { schema: { $ref: "#/components/schemas/SerialDetail" } } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } delete: tags: [Batches & Serials] summary: Delete a serial responses: "204": { description: Deleted. } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /products/{id}/serials/{serialId}/publish: parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } - { name: serialId, in: path, required: true, schema: { type: string, format: uuid } } post: tags: [Batches & Serials] summary: Publish or re-publish a serial passport description: >- The serial is addressed by id alone (its batch is resolved server-side). Freezes the serial's effective (product ⊕ batch ⊕ serial) data into its own passport. Does NOT consume the SKU quota. responses: "201": description: The published serial passport state. content: { application/json: { schema: { $ref: "#/components/schemas/PublishResult" } } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": description: Account is read-only (`read_only`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": { $ref: "#/components/responses/NotFound" } "501": { $ref: "#/components/responses/NotImplemented" } delete: tags: [Batches & Serials] summary: Unpublish a serial passport responses: "200": description: Archived. content: application/json: schema: type: object properties: { state: { type: string, example: archived } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "501": { $ref: "#/components/responses/NotImplemented" } /passports/products/{productID}: parameters: - { name: productID, in: path, required: true, schema: { type: string, format: uuid } } get: tags: [Passports] summary: Get a product's current passport responses: "200": description: The current passport with its immutable snapshot. content: { application/json: { schema: { $ref: "#/components/schemas/Passport" } } } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /passports/products/{productID}/publish: parameters: - { name: productID, in: path, required: true, schema: { type: string, format: uuid } } post: tags: [Passports] summary: Publish or re-publish a passport description: | Freezes the product's working data into an immutable snapshot. First publish is version 1; re-publishing increments the version while keeping the same `uid` and `public_slug`. Enforces required-field validation and the plan's published-SKU limit. responses: "201": description: The published passport (with snapshot). content: { application/json: { schema: { $ref: "#/components/schemas/Passport" } } } "401": { $ref: "#/components/responses/Unauthorized" } "403": description: Account is read-only (`read_only`) or SKU limit reached (`sku_limit`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": { $ref: "#/components/responses/NotFound" } "422": description: Product has invalid or missing required fields (`validation_failed`). content: { application/json: { schema: { $ref: "#/components/schemas/ValidationError" } } } /passports/products/{productID}/archive: parameters: - { name: productID, in: path, required: true, schema: { type: string, format: uuid } } post: tags: [Passports] summary: Archive the current passport responses: "200": description: The archived passport (no snapshot). content: { application/json: { schema: { $ref: "#/components/schemas/Passport" } } } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "409": description: Already archived (`already_archived`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /passports/products/{productID}/versions: parameters: - { name: productID, in: path, required: true, schema: { type: string, format: uuid } } get: tags: [Passports] summary: List passport version history description: Version metadata, newest first; snapshots omitted. responses: "200": description: Version history. content: application/json: schema: type: object properties: versions: { type: array, items: { $ref: "#/components/schemas/Passport" } } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /passports/products/{productID}/versions/{version}: parameters: - { name: productID, in: path, required: true, schema: { type: string, format: uuid } } - { name: version, in: path, required: true, schema: { type: integer, minimum: 1 } } get: tags: [Passports] summary: Get one passport version responses: "200": description: That version's passport with its immutable snapshot. content: { application/json: { schema: { $ref: "#/components/schemas/Passport" } } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /templates: get: tags: [Templates] summary: List templates description: Every product-group template with its versions (compact summaries). responses: "200": description: Templates and versions. content: application/json: schema: type: object properties: templates: type: array items: type: object properties: id: { type: string } versions: { type: array, items: { $ref: "#/components/schemas/TemplateVersionSummary" } } "401": { $ref: "#/components/responses/Unauthorized" } /templates/field-types: get: tags: [Templates] summary: List generic field types responses: "200": description: The field-type registry. content: application/json: schema: type: object properties: field_types: type: array items: type: object properties: type: { type: string } widget: { type: string } json_kind: { type: string } label_key: { type: string } "401": { $ref: "#/components/responses/Unauthorized" } /templates/{id}: parameters: - { name: id, in: path, required: true, schema: { type: string } } get: tags: [Templates] summary: List versions of one template responses: "200": description: The template's versions. content: application/json: schema: type: object properties: id: { type: string } versions: { type: array, items: { $ref: "#/components/schemas/TemplateVersionSummary" } } "401": { $ref: "#/components/responses/Unauthorized" } "404": description: Unknown template (`template_not_found`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /templates/{id}/{version}: parameters: - { name: id, in: path, required: true, schema: { type: string } } - { name: version, in: path, required: true, schema: { type: string } } get: tags: [Templates] summary: Get the full template document description: The complete template JSON (sections and fields) for a version. responses: "200": description: The full template document. content: { application/json: { schema: { $ref: "#/components/schemas/Template" } } } "401": { $ref: "#/components/responses/Unauthorized" } "404": description: Unknown version (`version_not_found`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /templates/{id}/{version}/validate: parameters: - { name: id, in: path, required: true, schema: { type: string } } - { name: version, in: path, required: true, schema: { type: string } } post: tags: [Templates] summary: Validate product data against a template version requestBody: required: true content: application/json: schema: type: object properties: data: { type: object, additionalProperties: true } required: [data] responses: "200": description: A validation result. content: { application/json: { schema: { $ref: "#/components/schemas/ValidationResult" } } } "401": { $ref: "#/components/responses/Unauthorized" } "404": description: Unknown version (`version_not_found`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /templates/{id}/diff: parameters: - { name: id, in: path, required: true, schema: { type: string } } - { name: from, in: query, required: true, schema: { type: string } } - { name: to, in: query, required: true, schema: { type: string } } get: tags: [Templates] summary: Diff two template versions description: Machine-readable migration hints (added / removed / changed fields). responses: "200": description: The diff. content: { application/json: { schema: { type: object, additionalProperties: true } } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": description: Unknown version (`version_not_found`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /import/upload: post: tags: [Import] summary: Upload a CSV to start an import job description: Multipart CSV upload (max 10 MB). Delimiter and encoding are auto-detected. requestBody: required: true content: multipart/form-data: schema: type: object properties: file: { type: string, format: binary } required: [file] responses: "201": description: The created import job with a header/sample preview. content: { application/json: { schema: { $ref: "#/components/schemas/ImportJobPreview" } } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "413": description: File too large (> 10 MB) (`file_too_large`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /import/sources/test: post: tags: [Import] summary: Test a store connector description: Verify connectivity/credentials to a Shopify/WooCommerce/PrestaShop store without creating a job. requestBody: required: true content: application/json: { schema: { $ref: "#/components/schemas/StoreSource" } } responses: "200": description: Connection ok. content: application/json: schema: type: object properties: ok: { type: boolean } store_name: { type: string } product_count: { type: integer } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "502": description: Upstream store could not be reached (`connect_failed`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /import/sources/fetch: post: tags: [Import] summary: Fetch a store catalogue into an import job requestBody: required: true content: application/json: schema: allOf: - { $ref: "#/components/schemas/StoreSource" } - type: object properties: limit: { type: integer, minimum: 1, maximum: 5000, default: 1000 } responses: "201": description: The created import job with a header/sample preview. content: { application/json: { schema: { $ref: "#/components/schemas/ImportJobPreview" } } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "502": description: Upstream store could not be reached (`connect_failed`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /import/{job}/mapping-suggest: parameters: - { name: job, in: path, required: true, schema: { type: string, format: uuid } } post: tags: [Import] summary: Suggest a column-to-field mapping requestBody: required: true content: application/json: schema: type: object properties: template_id: { type: string } version: { type: string } required: [template_id, version] responses: "200": description: Suggested mapping. content: application/json: schema: type: object properties: job_id: { type: string, format: uuid } template_id: { type: string } version: { type: string } mapping: type: array items: type: object properties: header: { type: string } field_key: { type: string } confidence: { type: number } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /import/{job}/validate: parameters: - { name: job, in: path, required: true, schema: { type: string, format: uuid } } post: tags: [Import] summary: Validate mapped rows (no products created) requestBody: required: true content: application/json: schema: type: object properties: mapping: { type: object, additionalProperties: { type: string } } required: [mapping] responses: "200": description: Validation stats and a per-row error report. content: { application/json: { schema: { $ref: "#/components/schemas/ImportJobStatus" } } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "409": description: Mapping-suggest was never called (`template_not_selected`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /import/{job}/execute: parameters: - { name: job, in: path, required: true, schema: { type: string, format: uuid } } post: tags: [Import] summary: Create products from the validated rows description: Idempotent — a job already executing/executed returns 409 already_executed. responses: "200": description: Final job stats. content: { application/json: { schema: { $ref: "#/components/schemas/ImportJobStatus" } } } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "409": description: Not validated (`not_validated`) or already executed (`already_executed`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "422": description: Creating the rows would exceed the plan SKU limit (`sku_limit_exceeded`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /import/{job}: parameters: - { name: job, in: path, required: true, schema: { type: string, format: uuid } } get: tags: [Import] summary: Get import job status responses: "200": description: The job's current status. content: { application/json: { schema: { $ref: "#/components/schemas/ImportJobStatus" } } } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } # --------------------------------------------------------------------------- # Session-authenticated origin-root endpoints. These are NOT part of the v1 # API-key surface: they live at the origin root (not under /v1) and use the # session cookie. The per-path `servers` override reflects that; security is # the session cookie, not the API key. Documented here for completeness. # --------------------------------------------------------------------------- /api/products/{id}/supplier-requests: servers: - url: https://{host} variables: { host: { default: app.passflow.example } } parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } get: tags: [Suppliers] summary: List a product's supplier requests security: [{ cookieAuth: [] }] responses: "200": description: The product's supplier requests (all statuses, newest first). content: application/json: schema: type: object properties: items: { type: array, items: { $ref: "#/components/schemas/SupplierRequest" } } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } post: tags: [Suppliers] summary: Create a supplier request (owner|editor) description: >- Mints an unguessable token and queues a supplier e-mail. Every requested key must be a real field of the product's template. security: [{ cookieAuth: [] }] requestBody: required: true content: application/json: schema: type: object properties: field_keys: { type: array, items: { type: string } } supplier_email: { type: string, format: email } message: { type: string } required: [field_keys, supplier_email] responses: "201": description: The created request (with token + form link). content: { application/json: { schema: { $ref: "#/components/schemas/SupplierRequest" } } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /api/products/{id}/supplier-requests/{rid}: servers: - url: https://{host} variables: { host: { default: app.passflow.example } } parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } - { name: rid, in: path, required: true, schema: { type: string, format: uuid } } delete: tags: [Suppliers] summary: Cancel a supplier request (owner|editor) description: Cancels the request; the token stops working. security: [{ cookieAuth: [] }] responses: "204": { description: Cancelled. } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /api/products/{id}/supplier-requests/{rid}/resend: servers: - url: https://{host} variables: { host: { default: app.passflow.example } } parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } - { name: rid, in: path, required: true, schema: { type: string, format: uuid } } post: tags: [Suppliers] summary: Resend the supplier e-mail (owner|editor) security: [{ cookieAuth: [] }] responses: "200": description: The request (e-mail re-queued). content: { application/json: { schema: { $ref: "#/components/schemas/SupplierRequest" } } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "409": description: The request was cancelled (`request_cancelled`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /api/products/{id}/supplier-requests/{rid}/apply: servers: - url: https://{host} variables: { host: { default: app.passflow.example } } parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } - { name: rid, in: path, required: true, schema: { type: string, format: uuid } } post: tags: [Suppliers] summary: Apply staged answers to the product (owner|editor) description: >- Apply all staged answers, or the given subset (per-field approve). Only actually-staged keys are applied; applied keys leave staging. The request becomes `applied` once nothing remains. security: [{ cookieAuth: [] }] requestBody: required: false content: application/json: schema: type: object properties: field_keys: { type: array, items: { type: string } } responses: "200": description: The request after applying. content: { application/json: { schema: { $ref: "#/components/schemas/SupplierRequest" } } } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "409": description: Request not answered (`not_answered`) or nothing staged (`no_answers`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /api/supplier/{token}: servers: - url: https://{host} variables: { host: { default: app.passflow.example } } parameters: - { name: token, in: path, required: true, schema: { type: string }, description: The unguessable capability token (no session). } get: tags: [Suppliers] summary: Fetch the token-gated supplier form (public) description: >- No session — the token is the capability. Returns ONLY the requested fields of one product, each with its current value. An unknown/cancelled token → 404. security: [] responses: "200": description: The public form. content: { application/json: { schema: { $ref: "#/components/schemas/SupplierPublicForm" } } } "404": { $ref: "#/components/responses/NotFound" } post: tags: [Suppliers] summary: Submit answers to the form (public) description: >- Values are restricted to the requested fields and format-validated. The tenant's submission mode then applies (auto = apply now; review/approve = stage). A per-field validation failure returns 400 with a `fields` map. security: [] requestBody: required: true content: application/json: schema: type: object properties: values: { type: object, additionalProperties: true } required: [values] responses: "200": description: Submission accepted. content: application/json: schema: type: object properties: status: { type: string, enum: [answered, applied] } applied: { type: boolean } "400": { $ref: "#/components/responses/BadRequest" } "404": { $ref: "#/components/responses/NotFound" } "409": description: Already completed (`already_applied`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /api/supplier/{token}/documents: servers: - url: https://{host} variables: { host: { default: app.passflow.example } } parameters: - { name: token, in: path, required: true, schema: { type: string } } post: tags: [Suppliers] summary: Upload a document to the form (public) description: Multipart upload (field `file`). Max 15 MiB; PDF or image only. security: [] requestBody: required: true content: multipart/form-data: schema: type: object properties: file: { type: string, format: binary } required: [file] responses: "201": description: The stored document reference. content: application/json: schema: type: object properties: document_id: { type: string } filename: { type: string } size: { type: integer } "400": { $ref: "#/components/responses/BadRequest" } "404": { $ref: "#/components/responses/NotFound" } "409": description: Already completed (`already_applied`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "413": description: File too large (> 15 MiB) (`file_too_large`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "415": description: Unsupported content type (`unsupported_type`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "503": description: Document storage unavailable (`storage_unavailable`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /api/billing/details: servers: - url: https://{host} variables: { host: { default: app.passflow.example } } get: tags: [Billing] summary: Get company / tax invoice details (owner) security: [{ cookieAuth: [] }] responses: "200": description: The tenant's billing details plus derived flags. content: { application/json: { schema: { $ref: "#/components/schemas/BillingDetailsResponse" } } } "401": { $ref: "#/components/responses/Unauthorized" } "403": description: Not the owner role. content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } put: tags: [Billing] summary: Save company / tax invoice details (owner) description: >- Validates + persists the details, then best-effort syncs them to the Stripe customer (`eu_vat` tax id). A bare national NIP is normalized to its EU-VAT form (e.g. PL + 1234567890 → PL1234567890). The DB is the source of truth: a Stripe hiccup returns 200 with `stripe_synced:false`. These details gate checkout / AI-credit top-up (`billing_details_required`). security: [{ cookieAuth: [] }] requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/BillingDetails" } responses: "200": description: The normalized details plus flags. content: { application/json: { schema: { $ref: "#/components/schemas/BillingDetailsResponse" } } } "400": description: Invalid/missing field (`billing_details_invalid`) or malformed body (`invalid_body`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": { $ref: "#/components/responses/Unauthorized" } "403": description: Not the owner role. content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /api/auth/password: servers: - url: https://{host} variables: { host: { default: app.passflow.example } } post: tags: [Auth] summary: Change your password description: >- Changes the signed-in user's own password. The current password re-authenticates the request; the new one must meet the minimum length. SSO accounts have no local password. The password value is never logged (audit records only `user.password_changed`). Rate-limited (10/min/IP). security: [{ cookieAuth: [] }] requestBody: required: true content: application/json: schema: type: object properties: current_password: { type: string, format: password } new_password: { type: string, format: password } required: [current_password, new_password] responses: "200": description: Password changed. content: application/json: schema: type: object properties: { ok: { type: boolean, example: true } } "400": description: New password too short (`password_too_short`) or malformed body (`invalid_body`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": description: Current password incorrect (`invalid_credentials`), or no session. content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: SSO-managed account (`sso_managed`) — change the password at the IdP. content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } components: securitySchemes: bearerAuth: type: http scheme: bearer description: "API key as a bearer token: `Authorization: Bearer cp_live_...` (legacy `pf_live_` keys still accepted)." apiKeyHeader: type: apiKey in: header name: X-API-Key description: "API key in the `X-API-Key` header." cookieAuth: type: apiKey in: cookie name: dpp_session description: >- Session cookie set by the browser login flow. Used by the origin-root `/api/*` endpoints (NOT the v1 API-key surface). responses: Unauthorized: description: Missing or invalid API key (`invalid_api_key`). headers: WWW-Authenticate: { schema: { type: string } } content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } NotInPlan: description: The tenant's plan does not include API access (`api_not_in_plan`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } RateLimited: description: Per-key rate limit exceeded (`rate_limited`). headers: Retry-After: { schema: { type: integer }, description: Seconds to wait before retrying. } content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } NotFound: description: Resource not found in the caller's tenant (`not_found`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } BadRequest: description: Malformed request (`invalid_body`, `invalid_format`, `invalid_input`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } NotImplemented: description: The delegated feature is not configured on this deployment (`not_implemented`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } schemas: Error: type: object description: Uniform error envelope. `code` is a stable snake_case identifier. properties: error: type: object properties: code: { type: string, example: not_found } message: { type: string } required: [code, message] required: [error] ValidationError: type: object description: Error envelope with per-field publish-validation reasons. properties: error: type: object properties: code: { type: string, example: validation_failed } message: { type: string } fields: type: array items: type: object properties: field: { type: string } code: { type: string } message: { type: string } required: [code, message] required: [error] Product: type: object properties: id: { type: string, format: uuid } tenant_id: { type: string, format: uuid } template_id: { type: string } template_version: { type: string } name: { type: string } gtin: { type: [string, "null"] } status: { type: string, enum: [draft, published, archived] } completeness: { type: integer, minimum: 0, maximum: 100 } has_unpublished_changes: { type: boolean } data: { type: object, additionalProperties: true } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } ProductCreate: type: object properties: template_id: { type: string } template_version: { type: string } name: { type: string } gtin: { type: string } data: { type: object, additionalProperties: true } required: [template_id, template_version, name] ProductUpdate: type: object properties: name: { type: string } gtin: { type: string } data: { type: object, additionalProperties: true } ProductWithValidation: allOf: - { $ref: "#/components/schemas/Product" } - type: object properties: validation: { $ref: "#/components/schemas/ValidationResult" } Document: type: object properties: id: { type: string, format: uuid } product_id: { type: string, format: uuid } kind: { type: string } sha256: { type: string } size: { type: integer } created_at: { type: string, format: date-time } Passport: type: object properties: id: { type: string, format: uuid } product_id: { type: string, format: uuid } uid: { type: string } version: { type: integer } state: { type: string, enum: [published, updated, archived] } public_slug: { type: string } template_version: { type: string } is_current: { type: boolean } published_at: { type: string, format: date-time } archived_at: { type: [string, "null"], format: date-time } created_at: { type: string, format: date-time } snapshot: type: object description: Immutable frozen state (present on single reads and publish). additionalProperties: true ValidationResult: type: object properties: template_id: { type: string } version: { type: string } valid: { type: boolean } completeness: { type: number, description: "0..1; validated required / all required." } required_total: { type: integer } required_valid: { type: integer } errors: type: array items: type: object properties: section: { type: string } field: { type: string } type: { type: string } code: { type: string } TemplateVersionSummary: type: object properties: id: { type: string } version: { type: string } status: { type: string } granularity: { type: string } compliance_deadline: { type: [string, "null"] } section_count: { type: integer } field_count: { type: integer } required_count: { type: integer } Template: type: object properties: id: { type: string } version: { type: string } status: { type: string } compliance_deadline: { type: [string, "null"] } granularity: { type: string } sections: type: array items: type: object properties: id: { type: string } label_key: { type: string } fields: type: array items: type: object additionalProperties: true StoreSource: type: object properties: platform: { type: string, enum: [shopify, woocommerce, prestashop] } store_url: { type: string } credentials: type: object additionalProperties: { type: string } required: [platform, store_url, credentials] ImportJobPreview: type: object properties: job_id: { type: string, format: uuid } headers: { type: array, items: { type: string } } sample_rows: { type: array, items: { type: array, items: { type: string } } } row_count: { type: integer } delimiter: { type: string } encoding: { type: string } ImportJobStatus: type: object properties: job_id: { type: string, format: uuid } status: { type: string, enum: [uploaded, validated, executing, executed, failed] } filename: { type: string } headers: { type: array, items: { type: string } } row_count: { type: integer } template_id: { type: string } template_version: { type: string } mapping: { type: object, additionalProperties: { type: string } } stats: { type: object, additionalProperties: { type: integer } } report: type: array items: type: object properties: row: { type: integer } field: { type: string } message: { type: string } error: { type: [string, "null"] } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } BatchListItem: type: object properties: id: { type: string, format: uuid } product_id: { type: string, format: uuid } batch_code: { type: string } completeness: { type: integer, minimum: 0, maximum: 100 } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } BatchCreate: type: object properties: batch_code: { type: string } data: { type: object, additionalProperties: true, description: Sparse override map (pruned to template fields). } required: [batch_code] BatchUpdate: type: object description: "`data`, when present, REPLACES the sparse override map wholesale." properties: batch_code: { type: string } data: { type: object, additionalProperties: true } BatchDetail: type: object properties: id: { type: string, format: uuid } product_id: { type: string, format: uuid } batch_code: { type: string } data: { type: object, additionalProperties: true, description: The batch's own sparse override map. } effective_data: { type: object, additionalProperties: true, description: product.data overlaid with the batch override. } inherited_data: { type: object, additionalProperties: true, description: The parent product's effective data. } completeness: { type: integer, minimum: 0, maximum: 100 } published: { $ref: "#/components/schemas/PublishedState" } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } SerialListItem: type: object properties: id: { type: string, format: uuid } product_id: { type: string, format: uuid } batch_id: { type: string, format: uuid } serial_number: { type: string } completeness: { type: integer, minimum: 0, maximum: 100 } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } SerialCreate: type: object properties: serial_number: { type: string } data: { type: object, additionalProperties: true } required: [serial_number] SerialUpdate: type: object properties: serial_number: { type: string } data: { type: object, additionalProperties: true } SerialDetail: type: object properties: id: { type: string, format: uuid } product_id: { type: string, format: uuid } batch_id: { type: string, format: uuid } serial_number: { type: string } data: { type: object, additionalProperties: true, description: The serial's own sparse override map. } effective_data: { type: object, additionalProperties: true, description: Batch effective data overlaid with the serial override. } inherited_data: { type: object, additionalProperties: true, description: The batch's effective data. } completeness: { type: integer, minimum: 0, maximum: 100 } published: { $ref: "#/components/schemas/PublishedState" } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } PublishResult: type: object description: The published state returned by a batch/serial publish. properties: uid: { type: string } version: { type: integer } state: { type: string, enum: [published, updated, archived] } public_url: { type: string } gs1_link: { type: string } qr_svg_url: { type: string } qr_pdf_url: { type: string } passport_id: { type: string } PublishedState: type: object description: Published-passport state on a batch/serial detail DTO (omitted until first publish). properties: uid: { type: string } version: { type: integer } state: { type: string, enum: [published, updated, archived] } public_url: { type: string } gs1_link: { type: string } qr_svg_url: { type: string } qr_pdf_url: { type: string } ComplianceReport: type: object properties: completeness: { type: integer, minimum: 0, maximum: 100 } required_total: { type: integer } required_filled: { type: integer } missing: type: array items: type: object properties: section: { type: string } field_key: { type: string } label: { type: string } type: { type: string } access: { type: string, enum: [public, restricted, professional] } TemplateUpdate: type: object properties: outdated: { type: boolean } current_version: { type: string } latest_version: { type: string } latest_status: { type: string } hint: { type: string } added_required: { type: integer } deadline: { type: [string, "null"] } TemplateUpdateGroup: type: object properties: template_id: { type: string } latest_version: { type: string } latest_status: { type: string } hint: { type: string } added_required: { type: integer } deadline: { type: [string, "null"] } affected_count: { type: integer } products: type: array items: type: object properties: id: { type: string, format: uuid } name: { type: string } current_version: { type: string } SupplierRequest: type: object description: Tenant-facing supplier request (token + form link exposed to the tenant only). properties: id: { type: string, format: uuid } product_id: { type: string, format: uuid } supplier_email: { type: string, format: email } message: { type: string } fields: { type: array, items: { type: string } } status: { type: string, enum: [open, answered, applied, cancelled] } answers: { type: object, additionalProperties: true, description: Staged answers (only while status is answered). } token: { type: string } form_link: { type: string } answered_at: { type: [string, "null"], format: date-time } created_at: { type: string, format: date-time } SupplierPublicForm: type: object description: The token-gated public form — only the requested fields of one product. properties: product_name: { type: string } tenant_name: { type: string } status: { type: string, enum: [open, answered, applied, cancelled] } message: { type: string } fields: type: array items: type: object properties: key: { type: string } label: { type: string } type: { type: string } access: { type: string, enum: [public, restricted, professional] } required: { type: boolean } config: { type: [object, "null"], additionalProperties: true } current_value: {} BillingDetails: type: object description: Company / tax invoice details. `tax_id` is normalized to EU-VAT form. properties: company_name: { type: string } tax_id: { type: string, description: "EU-VAT or a bare national NIP (prefixed with country → eu_vat)." } line1: { type: string } line2: { type: string } city: { type: string } postal_code: { type: string } country: { type: string, description: 2-letter ISO country code. } required: [company_name, tax_id, line1, city, postal_code, country] BillingDetailsResponse: allOf: - { $ref: "#/components/schemas/BillingDetails" } - type: object properties: complete: { type: boolean, description: Every mandatory field present (the invoicing gate). } stripe_synced: { type: boolean, description: Whether the best-effort Stripe sync succeeded (PUT only). }