{"openapi":"3.1.0","info":{"title":"qr3.app API","version":"1.0.0","description":"Developer-first QR-Code-Plattform — Dynamische QR-Codes, Analytics, Webhooks und EU Digital Product Passport Support.\n\n## Workspace scope\n\nEvery API key is bound to one workspace and all requests are scoped to it.\nThe optional `X-Workspace-Id` request header re-anchors a request to a\ndifferent workspace, but only for callers entitled to it: platform\nsuperadmins, and the qr3 dashboard acting for a signed-in user (verified\nserver-side against that user's memberships). That is what lets an invited\nteam member reach the inviting organisation's workspace — their personal\nkey is bound to their own auto-provisioned workspace.\n\n**For an ordinary API key the header has no effect.** A workspace that does\nnot exist, or that the caller may not access, is ignored rather than\nrejected: the request stays in the key's own workspace. This keeps the API\nfrom revealing which workspace ids exist, and lets a stale dashboard\nselection degrade into acting as yourself instead of failing.\n\n## Rate limit\n\nEvery API key may send 60 requests per minute, on every plan.\nThe window is the calendar minute in UTC. Authenticated responses carry\n`X-RateLimit-Limit`. A `429` additionally carries `X-RateLimit-Remaining: 0`,\n`X-RateLimit-Reset` (Unix time of the next minute) and `Retry-After: 60`.","contact":{"email":"api@qr3.app","url":"https://docs.qr3.app"},"license":{"name":"Proprietary"},"x-logo":{"url":"https://qr3.app/logo.svg"}},"servers":[{"url":"https://qr3.app/v1","description":"Production"}],"externalDocs":{"description":"qr3.app API reference","url":"https://docs.qr3.app/en/api/reference/"},"security":[{"BearerAuth":[]}],"components":{"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"qr3_sk_xxx or qr3_pk_xxx","description":"API key from https://app.qr3.app/api-keys"}},"schemas":{"ProblemDetails":{"type":"object","required":["type","title","status","detail"],"properties":{"type":{"type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"},"errors":{"type":"array","items":{"type":"object"}}}},"QRCodeAppearance":{"type":"object","required":["foreground_color","background_color"],"additionalProperties":false,"description":"Stored print colors of the QR image (#570). Both fields are always present; null means the standard (black modules on white). Set via PATCH /codes/{id}. The four image routes draw it by default — a ?fg=/?bg= query value (including the explicit standard, e.g. ?fg=000000) takes precedence per field. SVG and PNG draw the colors as stored; PDF and EPS draw them as print colors (grey as DeviceGray, other colors as DeviceCMYK) with a white background unless background_color says otherwise, like the SVG. Error correction is never stored here; it is a download parameter (?ecc=).","properties":{"foreground_color":{"type":["string","null"],"pattern":"^#[0-9A-F]{6}$","description":"Module color, canonical upper-case #RRGGBB; null = black.","example":"#1F4E79"},"background_color":{"type":["string","null"],"pattern":"^(#[0-9A-F]{6}|transparent)$","description":"Background color, canonical upper-case #RRGGBB or `transparent`; null = white.","example":null}}},"QRCodeColorIssue":{"type":"object","required":["code","severity","field","message"],"description":"One finding of the color guard (same shape as DppEuComplianceIssue). Tiers: `blocked` below 1.5:1 contrast (422, never for transparent); `critical` below 2:1 or a luminance difference under 0.30, a warning together with a logo, and every transparent background; `warning` below 4:1 or a luminance difference under 0.50, or light modules on a dark background.","properties":{"code":{"type":"string","enum":["QR_COLOR_CONTRAST_TOO_LOW","QR_COLOR_CONTRAST_CRITICAL","QR_COLOR_CONTRAST_LOW","QR_COLOR_INVERTED","QR_COLOR_WITH_LOGO","QR_COLOR_TRANSPARENT_BACKGROUND"]},"severity":{"type":"string","enum":["warning","critical","blocked"]},"field":{"type":"string","enum":["appearance","appearance.background_color"]},"message":{"type":"string"},"hints":{"type":"object","additionalProperties":true,"description":"Optional numbers behind the finding: `contrast_ratio`, `luminance_difference`, the effective `foreground_color`/`background_color`, `quiet_zone_modules` for transparency."}}},"QRCode":{"type":"object","required":["id","workspace_id","short_code","redirect_url","image_svg_url","image_png_url","image_pdf_url","image_eps_url","title","type","target_url","type_display","is_dynamic","is_landing_page","description","accent_color","appearance","links","status","tags","notes","total_scans","url_scan_status","links_scan_status","logo_url","logo_file_id","ab_enabled","ab_target_url_b","ab_weight_a","expires_at","created_at","updated_at"],"properties":{"id":{"type":"string","example":"qr_r7f3kx"},"workspace_id":{"type":"string","example":"ws_abc123"},"short_code":{"type":"string","example":"r7f3Kx"},"redirect_url":{"type":"string","format":"uri","example":"https://qr3.app/r7f3Kx"},"image_svg_url":{"type":"string","format":"uri"},"image_png_url":{"type":"string","format":"uri"},"image_pdf_url":{"type":"string","format":"uri"},"image_eps_url":{"type":"string","format":"uri"},"title":{"type":["string","null"]},"type":{"type":"string","enum":["url","vcard","wifi","email","sms","location"]},"target_url":{"type":["string","null"]},"type_display":{"type":"string"},"is_dynamic":{"type":"boolean"},"is_landing_page":{"type":"boolean"},"description":{"type":["string","null"],"maxLength":500,"description":"Optional plaintext context rendered on a dynamic URL code's landingpage; null when absent."},"accent_color":{"type":["string","null"],"pattern":"^#[0-9A-F]{6}$","description":"Optional canonical landingpage accent for a dynamic URL code; null when absent."},"appearance":{"$ref":"#/components/schemas/QRCodeAppearance"},"links":{"type":"array","items":{"type":"object","required":["label","url"],"properties":{"label":{"type":"string"},"url":{"type":"string","format":"uri"}}}},"status":{"type":"string","enum":["live","paused","flagged","draft"]},"total_scans":{"type":"integer","minimum":0},"tags":{"type":"array","items":{"type":"string"}},"notes":{"type":["string","null"]},"url_scan_status":{"type":"string"},"links_scan_status":{"type":"string"},"logo_url":{"type":["string","null"],"deprecated":true,"description":"Retired (#571 part 2): read-only legacy value, kept only until cleared. Never read when rendering a QR image — upload a logo via POST /codes/{id}/logo instead (see logo_file_id). POST/PATCH reject any new value with 400; PATCH still accepts null to clear it."},"logo_file_id":{"type":["string","null"],"description":"Id of a logo uploaded via POST /codes/{id}/logo, null when none is set. When set, all four image formats (qr.svg, qr.png, qr.pdf, qr.eps) embed its actual pixels and upgrade error correction to H (#571 part 2, #567, #574). Adding or removing a logo changes the module pattern of all four outputs (error correction M ↔ H); replacing an existing logo with another one does not — error correction stays H, only the embedded pixels change. A code already printed keeps working either way, since the encoded content does not depend on the logo (see resolveQrContent); reprint it only if you want the logo change to show on the physical material, and never mix old and new printouts.","example":"file_r7f3kx9a"},"ab_enabled":{"type":"boolean","description":"A/B destinations: when true, scans are split between `target_url` (variant A) and `ab_target_url_b` (variant B). Dynamic url-type codes only."},"ab_target_url_b":{"type":["string","null"],"format":"uri"},"ab_weight_a":{"type":"integer","minimum":0,"maximum":100,"description":"Variant A's share of traffic as a percent; variant B receives the remainder. Defaults to 50.","example":50},"expires_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"BatchQRCode":{"type":"object","required":["id","workspace_id","short_code","redirect_url","image_svg_url","image_png_url","image_pdf_url","image_eps_url","title","type","target_url","is_dynamic","status","tags","notes","expires_at","url_scan_status","created_at","updated_at"],"properties":{"id":{"type":"string"},"workspace_id":{"type":"string"},"short_code":{"type":"string"},"redirect_url":{"type":"string","format":"uri"},"image_svg_url":{"type":"string","format":"uri"},"image_png_url":{"type":"string","format":"uri"},"image_pdf_url":{"type":"string","format":"uri"},"image_eps_url":{"type":"string","format":"uri"},"title":{"type":["string","null"],"description":"Trimmed item title; null when omitted or blank."},"type":{"type":"string","enum":["url","vcard","wifi"]},"target_url":{"type":"string"},"is_dynamic":{"type":"boolean"},"status":{"type":"string","enum":["live"]},"tags":{"type":"array","items":{"type":"string"}},"notes":{"type":["string","null"]},"expires_at":{"type":["string","null"],"format":"date-time"},"url_scan_status":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"Webhook":{"type":"object","properties":{"id":{"type":"string","example":"wh_abc123"},"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"type":"string","enum":["*","qr.created","qr.updated","qr.deleted","qr.scanned","qr.flagged"]},"example":["qr.scanned"]},"is_active":{"type":"boolean"},"consecutive_failures":{"type":"integer"},"last_triggered_at":{"type":["string","null"],"format":"date-time"},"last_status_code":{"type":["integer","null"]},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"UsageLimit":{"type":"object","properties":{"used":{"type":"integer"},"limit":{"type":"integer"}},"required":["used","limit"]},"AccountUsage":{"type":"object","properties":{"plan":{"type":"string"},"dynamic_codes":{"$ref":"#/components/schemas/UsageLimit"},"scans_this_month":{"$ref":"#/components/schemas/UsageLimit"},"dpp":{"$ref":"#/components/schemas/UsageLimit"},"workspaces":{"$ref":"#/components/schemas/UsageLimit"},"members":{"allOf":[{"$ref":"#/components/schemas/UsageLimit"}],"description":"Seats, not rows: a user who belongs to three workspaces counts once (COUNT DISTINCT user_id)."},"billing_period_end":{"type":"string","format":"date-time","description":"End of the period scans_this_month counts — the running calendar month in UTC."}},"required":["plan","dynamic_codes","scans_this_month","dpp","workspaces","members","billing_period_end"]},"ScanStats":{"type":"object","properties":{"total_scans":{"type":"integer"},"unique_scanners":{"type":"integer","description":"Compatible field name for distinct pseudonymous UTC scanner-days (COUNT DISTINCT ip_hash), not a people count."},"scans_today":{"type":"integer"},"by_day":{"type":"array","items":{"type":"object","properties":{"date":{"type":"string","format":"date"},"scans":{"type":"integer"},"unique":{"type":"integer"}}}},"by_country":{"type":"array","items":{"type":"object","properties":{"country":{"type":"string","description":"ISO 3166-1 alpha-2"},"scans":{"type":"integer"}}}},"by_device":{"type":"array","items":{"type":"object","properties":{"device_type":{"type":"string","enum":["mobile","desktop","tablet","unknown"]},"scans":{"type":"integer"}}}},"by_os":{"type":"array","items":{"type":"object","properties":{"os":{"type":"string"},"scans":{"type":"integer"}}}},"by_browser":{"type":"array","items":{"type":"object","properties":{"browser":{"type":"string"},"scans":{"type":"integer"}}}}}},"DppValidationError":{"type":"object","required":["field","message"],"properties":{"field":{"type":"string","example":"gtin"},"message":{"type":"string","example":"GTIN must be 14 digits"}}},"DppScanStats":{"type":"object","properties":{"data":{"type":"object","required":["dpp_id","period_days","total_scans","period_scans","unique_scanners","scans_by_day","top_countries","top_devices","top_representations","top_languages"],"properties":{"dpp_id":{"type":"string"},"period_days":{"type":"integer"},"total_scans":{"type":"integer","description":"All stored scans of this DPP, independent of `days`. Scan records are deleted after 90 days, so this covers at most the last 90 days."},"period_scans":{"type":"integer"},"unique_scanners":{"type":"integer","description":"Compatible field name for distinct pseudonymous scanner-days within the window (COUNT DISTINCT ip_hash). These are pseudonymised personal-data metrics, not people counts. Always ≤ period_scans."},"scans_by_day":{"type":"array","items":{"type":"object","properties":{"date":{"type":"string","format":"date"},"count":{"type":"integer"}}}},"top_countries":{"type":"array","items":{"type":"object","properties":{"value":{"type":["string","null"]},"count":{"type":"integer"}}}},"top_devices":{"type":"array","items":{"type":"object","properties":{"value":{"type":"string","enum":["mobile","tablet","desktop","bot","Unknown"]},"count":{"type":"integer"}}}},"top_representations":{"type":"array","items":{"type":"object","properties":{"value":{"type":"string","enum":["html","json","jsonld","linkset","dcat-ap","unknown"]},"count":{"type":"integer"}}}},"top_languages":{"type":"array","description":"Primary Accept-Language tag of consumers (e.g. \"de\", \"en\"); a `null` bucket counts requests without the header.","items":{"type":"object","properties":{"value":{"type":["string","null"]},"count":{"type":"integer"}}}}}},"meta":{"type":"object","properties":{"request_id":{"type":"string"}}}}},"WorkspaceDppScanStats":{"type":"object","properties":{"data":{"type":"object","required":["period_days","total_scans","period_scans","active_dpps","unique_scanners","scans_by_day","top_dpps"],"properties":{"period_days":{"type":"integer"},"total_scans":{"type":"integer","description":"All stored DPP scans of this workspace, independent of `days`. Scan records are deleted after 90 days, so this covers at most the last 90 days."},"period_scans":{"type":"integer"},"active_dpps":{"type":"integer"},"unique_scanners":{"type":"integer","description":"Compatible field name for distinct pseudonymous scanner-days across every DPP in this workspace within the window; not a people count."},"scans_by_day":{"type":"array","items":{"type":"object","properties":{"date":{"type":"string","format":"date"},"count":{"type":"integer"}}}},"top_dpps":{"type":"array","items":{"type":"object","properties":{"dpp_id":{"type":"string"},"product_name":{"type":"string"},"category":{"type":"string"},"count":{"type":"integer"}}}}}},"meta":{"type":"object","properties":{"request_id":{"type":"string"}}}}},"CodeCreatorStats":{"type":"object","properties":{"data":{"type":"object","required":["total_codes","attributed_codes","unassigned_codes","external_codes","creators"],"properties":{"total_codes":{"type":"integer","minimum":0},"attributed_codes":{"type":"integer","minimum":0},"unassigned_codes":{"type":"integer","minimum":0},"external_codes":{"type":"integer","minimum":0},"creators":{"type":"array","items":{"type":"object","required":["creator_type","user_id","name","email","avatar_url","total_codes","dynamic_codes","last_created_at"],"properties":{"creator_type":{"type":"string","enum":["user","unassigned","external"]},"user_id":{"type":["string","null"]},"name":{"type":["string","null"]},"email":{"type":["string","null"],"format":"email"},"avatar_url":{"type":["string","null"],"format":"uri"},"total_codes":{"type":"integer","minimum":1},"dynamic_codes":{"type":"integer","minimum":0},"last_created_at":{"type":"string","format":"date-time"}}}}}},"meta":{"type":"object","properties":{"request_id":{"type":"string"}}}}},"DppBrandingConfig":{"type":"object","properties":{"logo_url":{"type":"string","format":"uri"},"primary_color":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$"},"secondary_color":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$"},"font_family":{"type":"string","description":"CSS font stack for consumer landing HTML"},"hide_qr3_badge":{"type":"boolean","description":"Hide the Powered-by qr3.app footer on DPP HTML"}}},"TextileFiberShare":{"type":"object","required":["material","percentage"],"properties":{"material":{"type":"string","example":"organic cotton"},"percentage":{"type":"number","minimum":0,"maximum":100}}},"TextileDppData":{"type":"object","required":["fiber_composition","contains_microplastics","recycled_content_pct"],"properties":{"fiber_composition":{"type":"array","items":{"$ref":"#/components/schemas/TextileFiberShare"},"minItems":1,"maxItems":32},"contains_microplastics":{"type":"boolean"},"recycled_content_pct":{"type":"number","minimum":0,"maximum":100},"water_consumption_liters_per_kg":{"type":"number","minimum":0},"durability_class":{"type":"string","enum":["A","B","C","D"]},"repairability_score":{"type":"number","minimum":0,"maximum":10},"certifications":{"type":"array","items":{"type":"string"}}}},"GeneralProductDppData":{"type":"object","required":["carbon_footprint_kg","recyclability_pct"],"properties":{"carbon_footprint_kg":{"type":"number","minimum":0},"recyclability_pct":{"type":"number","minimum":0,"maximum":100},"material_description":{"type":"string","maxLength":500},"certifications":{"type":"array","items":{"type":"string"}}}},"BatteryDppData":{"type":"object","required":["capacity_kwh","carbon_footprint_kg","recycled_content_pct","recyclability_pct","manufacturer_warranty_years"],"properties":{"capacity_kwh":{"type":"number","minimum":0},"carbon_footprint_kg":{"type":"number","minimum":0},"recycled_content_pct":{"type":"number","minimum":0,"maximum":100},"recyclability_pct":{"type":"number","minimum":0,"maximum":100},"manufacturer_warranty_years":{"type":"integer","minimum":0},"carbon_footprint_class":{"type":"string"},"cobalt_content_pct":{"type":"number","minimum":0,"maximum":100},"lithium_content_pct":{"type":"number","minimum":0,"maximum":100},"lead_content_pct":{"type":"number","minimum":0,"maximum":100},"expected_lifetime_cycles":{"type":"integer","minimum":0},"expected_lifetime_years":{"type":"integer","minimum":0},"repair_score":{"type":"number","minimum":0,"maximum":10},"spare_parts_available_until":{"type":"string","format":"date"},"certifications":{"type":"array","items":{"type":"string"}},"substances_of_concern":{"type":"array","items":{"type":"string"}},"collection_info":{"type":"string"},"state_of_health_url":{"type":"string","format":"uri"}}},"DppPassport":{"type":"object","required":["id","workspace_id","organization_id","gtin","gs1_uri","product_name","category","manufacturer","origin_country","landing_locale","status","branding_config","validation_errors","is_valid","qr","created_at","updated_at"],"properties":{"id":{"type":"string","example":"dpp_abc123"},"workspace_id":{"type":"string","example":"ws_abc123"},"organization_id":{"type":"string","example":"org_abc123"},"code_id":{"type":["string","null"]},"created_by":{"type":["string","null"]},"gtin":{"type":"string","example":"04012345678901"},"serial":{"type":["string","null"],"example":"ABC123","pattern":"^[A-Za-z0-9._~-]{1,20}$","description":"Optional GS1 serial, limited to safe URL segment characters."},"lot":{"type":["string","null"],"example":"LOT42","pattern":"^[A-Za-z0-9._~-]{1,20}$","description":"Optional GS1 lot, limited to safe URL segment characters."},"gs1_uri":{"type":"string","format":"uri","example":"https://qr3.app/01/04012345678901/21/ABC123"},"product_name":{"type":"string","example":"EcoMax Battery 5000"},"category":{"type":"string","enum":["battery","textile","general"]},"manufacturer":{"type":"string","example":"GreenPower GmbH"},"origin_country":{"type":"string","example":"DE"},"battery_data":{"$ref":"#/components/schemas/BatteryDppData"},"textile_data":{"$ref":"#/components/schemas/TextileDppData"},"general_data":{"$ref":"#/components/schemas/GeneralProductDppData"},"landing_page_url":{"type":["string","null"],"format":"uri"},"landing_locale":{"type":"string","enum":["de","en","fr"]},"status":{"type":"string","enum":["draft","live","archived"]},"branding_config":{"$ref":"#/components/schemas/DppBrandingConfig"},"access_roles":{"type":"array","items":{"type":"string"},"description":"Resolver roles; must include \"public\" for anonymous GS1 resolution.","example":["public"]},"jsonld_url":{"type":["string","null"],"format":"uri"},"validation_errors":{"type":"array","items":{"$ref":"#/components/schemas/DppValidationError"}},"is_valid":{"type":"boolean"},"eu_registry_status":{"type":"string","enum":["not_registered","pending","registered","rejected"]},"eu_registry_id":{"type":["string","null"]},"eu_registered_at":{"type":["string","null"],"format":"date-time"},"qr":{"type":"object","required":["svg","png","pdf","eps"],"properties":{"svg":{"type":"string","format":"uri"},"png":{"type":"string","format":"uri"},"pdf":{"type":"string","format":"uri"},"eps":{"type":"string","format":"uri"}}},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"deleted_at":{"type":["string","null"],"format":"date-time"}}},"CreateDppPayload":{"type":"object","required":["gtin","product_name","manufacturer","origin_country"],"description":"Single DPP create payload (used by POST /dpp and /dpp/batch items).","properties":{"gtin":{"type":"string","pattern":"^\\d{14}$"},"serial":{"type":"string","pattern":"^[A-Za-z0-9._~-]{1,20}$"},"lot":{"type":"string","pattern":"^[A-Za-z0-9._~-]{1,20}$"},"product_name":{"type":"string"},"manufacturer":{"type":"string"},"origin_country":{"type":"string","pattern":"^[A-Z]{2}$"},"category":{"type":"string","enum":["battery","textile","general"],"default":"battery"},"landing_locale":{"type":"string"},"status":{"type":"string","enum":["draft","live","archived"],"default":"live"},"branding_config":{"$ref":"#/components/schemas/DppBrandingConfig"},"battery_data":{"$ref":"#/components/schemas/BatteryDppData"},"textile_data":{"$ref":"#/components/schemas/TextileDppData"},"general_data":{"$ref":"#/components/schemas/GeneralProductDppData"}}},"DppValidationResult":{"type":"object","required":["valid","errors","gs1_uri","jsonld_preview"],"description":"Validation response payload. The API wraps this schema in the standard `{ data, meta }` envelope.","properties":{"valid":{"type":"boolean"},"errors":{"type":"array","items":{"$ref":"#/components/schemas/DppValidationError"}},"gs1_uri":{"type":"string","format":"uri"},"jsonld_preview":{"type":"object","additionalProperties":true},"eu_compliance":{"anyOf":[{"$ref":"#/components/schemas/DppEuComplianceResult"},{"type":"null"}],"description":"Stateless EU-compliance preview (Q3.3.6) — same shape as the `DppEuComplianceResult` returned by `/dpp/:id/eu-compliance`. `null` when the Zod payload does not parse yet."}}},"DppEuComplianceIssue":{"type":"object","required":["code","severity","field","message"],"properties":{"code":{"type":"string","example":"GTIN_FORMAT"},"severity":{"type":"string","enum":["error","warning","info"]},"field":{"type":"string"},"message":{"type":"string"},"hints":{"type":"object","additionalProperties":true,"description":"Optional structured hints for UI rendering (e.g. matched greenwashing term, AGEC regulation, computed fiber share)."}}},"DppEuComplianceResult":{"type":"object","required":["compliant","issues","summary"],"properties":{"compliant":{"type":"boolean"},"espr_ready":{"type":"boolean","description":"True when the textile passport declares all ESPR B7–B15 fields and therefore qualifies as ESPR-ready."},"issues":{"type":"array","items":{"$ref":"#/components/schemas/DppEuComplianceIssue"}},"summary":{"type":"object","required":["errors","warnings"],"properties":{"errors":{"type":"integer","minimum":0},"warnings":{"type":"integer","minimum":0},"info":{"type":"integer","minimum":0}}}}},"DppImportRowResult":{"type":"object","required":["id","gtin","gs1_uri","row"],"properties":{"id":{"type":"string"},"gtin":{"type":"string"},"gs1_uri":{"type":"string","format":"uri"},"row":{"type":"integer","description":"1-based CSV row index including header"}}},"DppImportError":{"type":"object","required":["row","detail"],"properties":{"row":{"type":"integer"},"detail":{"type":"string"}}},"DppImportJob":{"type":"object","required":["job_id","status","total_rows","processed_rows","imported","skipped","errors","passports","created_at","updated_at"],"properties":{"job_id":{"type":"string","example":"imp_a1b2c3d4"},"status":{"type":"string","enum":["queued","running","completed","failed"]},"total_rows":{"type":"integer"},"processed_rows":{"type":"integer"},"imported":{"type":"integer"},"skipped":{"type":"integer"},"errors":{"type":"array","items":{"$ref":"#/components/schemas/DppImportError"}},"passports":{"type":"array","description":"First 100 imported passports for preview purposes.","items":{"$ref":"#/components/schemas/DppImportRowResult"}},"errors_csv":{"type":"string"},"failure_detail":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"started_at":{"type":"string","format":"date-time"},"completed_at":{"type":"string","format":"date-time"}}},"GS1Identifier":{"type":"object","required":["id","workspace_id","organization_id","identifier_type","value","status","created_at"],"properties":{"id":{"type":"string","example":"gid_a1b2c3d4"},"workspace_id":{"type":"string"},"organization_id":{"type":"string"},"identifier_type":{"type":"string","enum":["gtin","gln","company_prefix"]},"value":{"type":"string","example":"04012345678901"},"label":{"type":["string","null"]},"verified_at":{"type":["string","null"],"format":"date-time"},"verified_by":{"type":["string","null"],"enum":["self","gs1_registry","admin",null]},"status":{"type":"string","enum":["active","revoked","expired"]},"created_by":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"CreateGS1IdentifierRequest":{"type":"object","required":["identifier_type","value"],"properties":{"identifier_type":{"type":"string","enum":["gtin","gln","company_prefix"]},"value":{"type":"string","example":"04012345678901"},"label":{"type":"string","example":"EcoMax Battery 5000"}}},"ValidateGS1Request":{"type":"object","required":["identifier_type","value"],"properties":{"identifier_type":{"type":"string","enum":["gtin","gln","company_prefix"]},"value":{"type":"string"}}},"ValidateGS1Response":{"type":"object","required":["valid","identifier_type","value","detail"],"properties":{"valid":{"type":"boolean"},"identifier_type":{"type":"string"},"value":{"type":"string"},"detail":{"type":"string"}}},"File":{"type":"object","required":["id","code_id","filename","mime_type","size_bytes","hash_sha256","visibility","status","created_at","download_url"],"properties":{"id":{"type":"string","example":"file_a1b2c3d4"},"code_id":{"type":["string","null"],"description":"QR code this file is attached to, or null for a standalone file","example":"qr_r7f3kx"},"filename":{"type":"string","example":"datasheet.pdf"},"mime_type":{"type":"string","description":"Detected from the file's magic bytes, not from the client-sent type.","enum":["application/pdf","image/jpeg","image/png","image/webp","video/mp4","model/gltf-binary","model/gltf+json","application/json"],"example":"application/pdf"},"size_bytes":{"type":"integer","minimum":0,"example":24831},"hash_sha256":{"type":"string","description":"SHA-256 of the stored bytes","example":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"},"visibility":{"type":"string","enum":["public","private"],"example":"public"},"status":{"type":"string","enum":["active","deleted"],"example":"active"},"created_at":{"type":"string","format":"date-time"},"download_url":{"type":"string","format":"uri","description":"Authenticated download. Served with Cache-Control: no-store.","example":"https://qr3.app/v1/files/file_a1b2c3d4/download"},"public_url":{"type":"string","format":"uri","description":"Unauthenticated URL served by the redirect worker. Present ONLY when visibility=public and status=active — private files never expose one. Served inline with Cache-Control: public, max-age=300, so a replaced file may take up to 5 minutes to propagate.","example":"https://qr3.app/f/file_a1b2c3d4"}}},"ResponseMeta":{"type":"object","required":["request_id"],"properties":{"request_id":{"type":"string","description":"Per-response correlation id. Quote it in support requests.","example":"req_a1b2c3d4"}}},"CursorPaginationMeta":{"type":"object","required":["request_id","pagination"],"properties":{"request_id":{"type":"string","example":"req_a1b2c3d4"},"pagination":{"type":"object","required":["has_more","next_cursor"],"properties":{"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"],"description":"Opaque cursor for the next page — pass it back verbatim as `?cursor=`. Do not parse or construct it (it is a base64url-encoded keyset over the sort key, not a bare timestamp or id). null when has_more is false."}}}}},"ZodValidationError":{"type":"object","description":"Raw Zod validation failure as returned by the request validator. The body is NOT an RFC 7807 problem document — the schema check answers before any error shaping. It is nevertheless served with `Content-Type: application/problem+json`: an API-wide middleware stamps every JSON error response with the RFC 7807 media type, regardless of body shape. Parse it by this schema, not as a problem document.","required":["success","error"],"properties":{"success":{"type":"boolean","const":false},"error":{"type":"object","required":["name","issues"],"properties":{"name":{"type":"string","const":"ZodError"},"issues":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string","example":"invalid_type"},"path":{"type":"array","items":{"type":["string","integer"]},"example":["limit"]},"message":{"type":"string","example":"Expected number, received nan"}}}}}}}},"OrganizationLimits":{"type":"object","description":"Effective limits: the organization's stored overrides, floored at the plan's documented baseline — a stale row can never constrain a paid plan below its plan matrix.","required":["max_workspaces","max_members","max_dynamic_codes","max_scans_per_month"],"properties":{"max_workspaces":{"type":"integer","example":1},"max_members":{"type":"integer","example":3},"max_dynamic_codes":{"type":"integer","example":500},"max_scans_per_month":{"type":"integer","example":100000}}},"Organization":{"type":"object","required":["id","name","slug","owner_user_id","plan","billing_email","notification_email","limits","stripe_customer_id","has_subscription","created_at","updated_at"],"properties":{"id":{"type":"string","example":"org_a1b2c3d4"},"name":{"type":"string","example":"Acme GmbH"},"slug":{"type":"string","pattern":"^[a-z0-9-]+$","example":"acme"},"owner_user_id":{"type":"string","description":"Internal user id of the organization owner. Organizations created via POST /organizations carry the literal value \"api\"."},"plan":{"type":"string","enum":["free","pro","business","agency","enterprise"]},"billing_email":{"type":["string","null"],"format":"email","description":"Invoice recipient only — the initial customer_email for Stripe Checkout. Once the first checkout completes, Stripe owns the billing address."},"notification_email":{"type":["string","null"],"format":"email","description":"Recipient of operational notifications (scan-quota warnings). Falls back to the organization owner's email when null. Deliberately separate from billing_email."},"limits":{"$ref":"#/components/schemas/OrganizationLimits"},"stripe_customer_id":{"type":["string","null"],"description":"Set once the organization has completed a Stripe checkout."},"has_subscription":{"type":"boolean","description":"A Stripe subscription is linked. POST /billing/checkout refuses with 409 while one is active — Stripe's subscription mode only creates, never switches — so use GET /billing/portal instead. Reflects the stored link; a missed webhook can leave it stale, and checkout re-checks against Stripe and clears the row when the subscription no longer exists."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"OrganizationUsage":{"type":"object","required":["scans","dynamic_codes"],"properties":{"scans":{"type":"object","description":"Computed live from recorded scans for the current calendar month (UTC), summed across all of the organization's workspaces.","required":["used","limit","usage_pct","remaining","period","period_start"],"properties":{"used":{"type":"integer","minimum":0},"limit":{"type":"integer"},"usage_pct":{"type":"integer","description":"Rounded percentage. Not capped — overage reads as more than 100."},"remaining":{"type":"integer","minimum":0},"period":{"type":"string","description":"Billing period the count covers, `YYYY-MM` (UTC).","example":"2026-08"},"period_start":{"type":"string","format":"date-time"}}},"dynamic_codes":{"type":"object","description":"Dynamic codes currently counting against the organization-wide cap, vs the effective plan limit.","required":["used","limit","usage_pct","remaining"],"properties":{"used":{"type":"integer","minimum":0},"limit":{"type":"integer"},"usage_pct":{"type":"integer"},"remaining":{"type":"integer","minimum":0}}}}},"Workspace":{"type":"object","required":["id","organization_id","name","slug","logo_url","primary_color","custom_domain","approval_required","default_locale","stats","created_at","updated_at"],"properties":{"id":{"type":"string","example":"ws_a1b2c3d4"},"organization_id":{"type":"string","example":"org_a1b2c3d4"},"name":{"type":"string","example":"Marketing"},"slug":{"type":"string","pattern":"^[a-z0-9-]+$","example":"marketing"},"logo_url":{"type":["string","null"],"format":"uri"},"primary_color":{"type":["string","null"],"example":"#7c3aed"},"custom_domain":{"type":["string","null"]},"approval_required":{"type":"boolean"},"default_locale":{"type":"string","example":"de"},"stats":{"type":"object","description":"Legacy per-workspace counters. They are not maintained by the current redirect worker and read 0 in practice — use GET /organizations/usage for authoritative scan and code usage.","required":["dynamic_codes_count","scans_this_month"],"properties":{"dynamic_codes_count":{"type":"integer"},"scans_this_month":{"type":"integer"}}},"plan":{"type":"string","enum":["free","pro","business","agency","enterprise"],"description":"The owning organization's plan."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"Member":{"type":"object","required":["id","user_id","email","name","avatar_url","role","is_owner","is_self","invited_by","joined_at","created_at"],"properties":{"id":{"type":"string","example":"mem_a1b2c3d4"},"user_id":{"type":"string"},"email":{"type":"string","format":"email"},"name":{"type":["string","null"]},"avatar_url":{"type":["string","null"],"format":"uri"},"role":{"type":"string","enum":["org_admin","ws_admin","editor","contributor","viewer","approver"]},"is_owner":{"type":"boolean","description":"True for the organization owner. The owner's role cannot be changed and the owner cannot be removed."},"is_self":{"type":"boolean","description":"True when the row is the requesting user. Only ever true for requests proxied by the qr3 dashboard (verified acting user) — for direct API-key calls it is always false."},"invited_by":{"type":["string","null"],"description":"Internal user id of the inviter, when known."},"joined_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"}}},"AuditLog":{"type":"object","required":["id","workspace_id","actor_id","actor_type","action","resource_type","resource_id","changes","created_at"],"properties":{"id":{"type":"string","example":"aud_a1b2c3d4"},"workspace_id":{"type":"string","example":"ws_a1b2c3d4"},"actor_id":{"type":["string","null"],"description":"Internal user id, or null for anonymous or system activity."},"actor_type":{"type":"string","enum":["user","api","system"]},"action":{"type":"string","example":"code.updated"},"resource_type":{"type":"string","example":"codes"},"resource_id":{"type":"string","example":"qr_r7f3kx"},"changes":{"type":"object","description":"Free-form before/after payload recorded with the action; {} when none was captured."},"created_at":{"type":"string","format":"date-time"}}},"BillingPlan":{"type":"object","required":["name","price_eur_monthly","dynamic_codes","scans_per_month","features"],"properties":{"name":{"type":"string","enum":["free","pro","business","agency"]},"price_eur_monthly":{"type":"integer","example":29},"dynamic_codes":{"type":"integer","description":"Dynamic-code quota included in the plan."},"scans_per_month":{"type":"integer"},"features":{"type":"array","items":{"type":"string"},"example":["Dynamic QR Codes","Scan Analytics"]},"limits":{"type":"object","description":"The complete, enforced limits of the plan, from the plan matrix in @qr3/shared (#592). Byte values are bytes.","properties":{"dynamic_codes":{"type":"integer","example":500},"static_codes":{"type":"string","enum":["unlimited"]},"scans_per_month":{"type":"integer","description":"Soft limit: scans are always recorded, never blocked."},"workspaces":{"type":"integer"},"team_members":{"type":"integer"},"dpp_passports":{"type":"integer"},"dpp_import_rows_per_file":{"type":"integer"},"dpp_batch_items_per_request":{"type":"integer"},"codes_batch_items_per_request":{"type":"integer"},"codes_import_rows_per_file":{"type":"integer"},"codes_created_per_day":{"type":"object","properties":{"single":{"type":"integer"},"batch":{"type":"integer"}}},"api_requests_per_minute_per_key":{"type":"integer","example":60},"scan_retention_days":{"type":"integer","example":90},"file_storage":{"type":"object","properties":{"max_file_bytes":{"type":"integer"},"max_files_per_code":{"type":"integer"},"max_total_bytes_per_workspace":{"type":"integer"}}},"dpp_documents":{"type":"object","properties":{"max_docs_per_dpp":{"type":"integer"},"max_file_bytes":{"type":"integer"},"max_total_bytes_per_dpp":{"type":"integer"}}}}},"capabilities":{"type":"object","description":"Plan-gated features as enforced by the API (#592).","properties":{"webhooks":{"type":"boolean"},"ab_destinations":{"type":"boolean"},"audit_log":{"type":"boolean","description":"Reading GET /v1/audit-logs."},"scan_interstitial":{"type":"boolean","description":"Scans show a qr3 page before redirecting."},"eu_registry_submission":{"type":"boolean","description":"A test path until the EU registry API is live."},"export_formats":{"type":"array","items":{"type":"string","enum":["png","svg","pdf","eps"]}},"support":{"type":"string","enum":["email"]}}}}},"ScanBucket":{"type":"object","required":["value","count"],"properties":{"value":{"type":"string","example":"DE"},"count":{"type":"integer"}}},"CodeScanAnalytics":{"type":"object","required":["code_id","short_code","total_scans","period_days","period_scans","scans_by_day","top_countries","top_devices","top_os","ab"],"properties":{"code_id":{"type":"string","example":"qr_r7f3kx"},"short_code":{"type":"string","example":"r7f3Kx"},"total_scans":{"type":"integer","description":"All-time scans of the code, independent of the requested period."},"period_days":{"type":"integer","example":30},"period_scans":{"type":"integer","description":"Scans recorded within the requested period."},"scans_by_day":{"type":"array","items":{"type":"object","required":["date","count"],"properties":{"date":{"type":"string","format":"date"},"count":{"type":"integer"}}}},"top_countries":{"type":"array","description":"Top 8 countries by scan count. Unresolvable origins are grouped under `Unbekannt`.","items":{"$ref":"#/components/schemas/ScanBucket"}},"top_devices":{"type":"array","description":"Top 5 device types. Unresolvable values are grouped under `Unbekannt`.","items":{"$ref":"#/components/schemas/ScanBucket"}},"top_os":{"type":"array","description":"Top 5 operating systems. Unresolvable values are grouped under `Unbekannt`.","items":{"$ref":"#/components/schemas/ScanBucket"}},"ab":{"type":["object","null"],"description":"A/B attribution for the period, or `null` for codes that never had a split. Present even after A/B is switched off, so past results stay visible. `scans_a + scans_b` can be lower than `period_scans` — scans recorded before the split was enabled carry no variant.","required":["enabled","weight_a","target_a","target_b","scans_a","scans_b"],"properties":{"enabled":{"type":"boolean"},"weight_a":{"type":"integer","minimum":0,"maximum":100,"description":"Configured share of variant A in percent — compare against actuals."},"target_a":{"type":["string","null"],"format":"uri"},"target_b":{"type":["string","null"],"format":"uri"},"scans_a":{"type":"integer","minimum":0},"scans_b":{"type":"integer","minimum":0}}}}}}},"paths":{"/health":{"get":{"operationId":"getHealth","summary":"Health Check","tags":["System"],"security":[],"responses":{"200":{"description":"API is healthy","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","example":"ok"}}}}}}}}},"/workspace/stats/dpp":{"get":{"operationId":"getWorkspaceDppStats","summary":"Workspace-wide DPP scan statistics (Q3.4.2)","description":"Aggregates all `dpp_scans` for the API key’s workspace. Use for the dashboard home overview. Scans are recorded by the public DPP resolver; IPs are never stored in raw form.","tags":["Analytics"],"parameters":[{"name":"days","in":"query","schema":{"type":"integer","default":30,"minimum":1,"maximum":365}}],"responses":{"200":{"description":"Workspace DPP scan statistics","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceDppScanStats"}}}},"400":{"description":"Invalid `days` query","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/workspace/stats/code-creators":{"get":{"operationId":"getWorkspaceCodeCreatorStats","summary":"QR code counts grouped by creator","description":"Counts active QR codes in the authenticated workspace. Active workspace members are returned by name; unassigned and non-member identities are grouped into anonymous buckets.","tags":["Analytics"],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":200}}],"responses":{"200":{"description":"Workspace QR code creator statistics","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CodeCreatorStats"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Workspace not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Invalid `limit` query","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/codes":{"get":{"operationId":"listCodes","summary":"List QR Codes","tags":["QR Codes"],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":20,"maximum":100}},{"name":"cursor","in":"query","description":"Opaque `next_cursor` from the previous page, passed back verbatim. Malformed values are rejected with 400.","schema":{"type":"string"}},{"name":"status","in":"query","schema":{"type":"string","enum":["live","paused","flagged","draft"]}}],"responses":{"200":{"description":"List of QR codes","content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/QRCode"}},"meta":{"type":"object"}}}}}},"400":{"description":"Query failed schema validation (`limit` not an integer in 1–100, `status` not one of live/paused/flagged/draft, or a malformed `cursor`). The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"post":{"operationId":"createCode","summary":"Create QR Code","tags":["QR Codes"],"parameters":[{"name":"Idempotency-Key","in":"header","schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","allOf":[{"if":{"required":["type"],"properties":{"type":{"enum":["vcard","wifi","email","sms","location"]}}},"then":{"not":{"required":["is_dynamic"]}}},{"if":{"not":{"required":["type"]}},"then":{"required":["url"]}},{"if":{"required":["type"],"properties":{"type":{"const":"url"}}},"then":{"required":["url"]}},{"if":{"required":["type"],"properties":{"type":{"const":"vcard"}}},"then":{"anyOf":[{"required":["vcard_first_name"]},{"required":["vcard_last_name"]}]}},{"if":{"required":["type"],"properties":{"type":{"const":"wifi"}}},"then":{"required":["wifi_ssid"]}},{"if":{"required":["type"],"properties":{"type":{"const":"email"}}},"then":{"required":["email_to"]}},{"if":{"required":["type"],"properties":{"type":{"const":"sms"}}},"then":{"required":["sms_phone"]}},{"if":{"required":["type"],"properties":{"type":{"const":"location"}}},"then":{"required":["location_lat","location_lng"]}}],"properties":{"type":{"type":"string","enum":["url","vcard","wifi","email","sms","location"],"default":"url"},"title":{"type":"string","maxLength":255},"is_dynamic":{"type":"boolean","default":true,"description":"Only permitted when type=url; URL codes default to dynamic when omitted."},"tags":{"type":"array","items":{"type":"string"},"maxItems":20},"notes":{"type":"string","maxLength":2000},"expires_at":{"type":"string","format":"date-time"},"url":{"type":"string","format":"uri","pattern":"^https?://","description":"Required when type=url; only HTTP(S) URLs are accepted."},"vcard_first_name":{"type":"string","maxLength":100,"description":"One of vcard_first_name or vcard_last_name is required when type=vcard."},"vcard_last_name":{"type":"string","maxLength":100,"description":"One of vcard_first_name or vcard_last_name is required when type=vcard."},"vcard_organization":{"type":"string","maxLength":255},"vcard_phone":{"type":"string","maxLength":50},"vcard_email":{"type":"string","format":"email"},"vcard_url":{"type":"string","format":"uri"},"vcard_address":{"type":"string","maxLength":500},"wifi_ssid":{"type":"string","maxLength":32,"description":"Required when type=wifi."},"wifi_password":{"type":"string","maxLength":64},"wifi_encryption":{"type":"string","enum":["WPA","WEP","nopass"]},"wifi_hidden":{"type":"boolean"},"email_to":{"type":"string","format":"email","description":"Required when type=email."},"email_subject":{"type":"string","maxLength":200},"email_body":{"type":"string","maxLength":500},"sms_phone":{"type":"string","maxLength":30,"description":"Required when type=sms."},"sms_message":{"type":"string","maxLength":160},"location_lat":{"type":"number","minimum":-90,"maximum":90},"location_lng":{"type":"number","minimum":-180,"maximum":180},"location_query":{"type":"string","maxLength":255},"logo_url":{"type":"string","deprecated":true,"description":"Retired (#571 part 2): any value is rejected with 400 — upload a logo via POST /codes/{id}/logo instead, which the image routes embed directly."},"is_landing_page":{"type":"boolean"},"ab_enabled":{"type":"boolean","description":"Split scans between `url` (variant A) and `ab_target_url_b` (variant B). Requires a dynamic type=url code and an `ab_target_url_b`."},"ab_target_url_b":{"type":"string","format":"uri","pattern":"^https?://","maxLength":2048,"description":"Variant B destination. Dynamic type=url codes only."},"ab_weight_a":{"type":"integer","minimum":0,"maximum":100,"default":50,"description":"Variant A's share of traffic as a percent; variant B receives the remainder."},"links":{"type":"array","maxItems":20,"items":{"type":"object","required":["label","url"],"properties":{"label":{"type":"string","minLength":1,"maxLength":100},"url":{"type":"string","format":"uri","pattern":"^https?://","maxLength":2048}}}}}}}}},"responses":{"201":{"description":"QR code created","content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"properties":{"data":{"$ref":"#/components/schemas/QRCode"},"meta":{"type":"object"}}}}}},"400":{"description":"Body failed schema validation — unknown `type`, a missing type-specific required field (`url` for `type=url`, `wifi_ssid` for `type=wifi`, `email_to`, `sms_phone`, `location_lat`/`location_lng`), malformed `url` or `expires_at`, or oversized fields. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Plan limit reached for dynamic codes (extension members `resource: \"dynamic_codes\"`, `limit`, `used`), the target URL / an external link was flagged unsafe by the Web Risk scan, or the body carries an `appearance` (set colors via PATCH /codes/{id} instead; `null` is accepted as a no-op). Schema-level input errors answer 400, not 422.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/codes/batch":{"post":{"operationId":"createCodeBatch","summary":"Batch-create QR Codes","tags":["QR Codes"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["items"],"properties":{"items":{"type":"array","minItems":1,"maxItems":1000,"description":"Effective maximum by organization plan: Free 10, Pro 500, Business/Agency/Enterprise 1000 (operational route cap).","items":{"type":"object","allOf":[{"if":{"required":["type"],"properties":{"type":{"enum":["vcard","wifi"]}}},"then":{"not":{"required":["is_dynamic"]}}},{"if":{"not":{"required":["type"]}},"then":{"required":["url"]}},{"if":{"required":["type"],"properties":{"type":{"const":"url"}}},"then":{"required":["url"]}},{"if":{"required":["type"],"properties":{"type":{"const":"vcard"}}},"then":{"anyOf":[{"required":["vcard_first_name"]},{"required":["vcard_last_name"]}]}},{"if":{"required":["type"],"properties":{"type":{"const":"wifi"}}},"then":{"required":["wifi_ssid"]}}],"properties":{"type":{"type":"string","enum":["url","vcard","wifi"],"default":"url"},"title":{"type":"string","maxLength":255},"url":{"type":"string","format":"uri","pattern":"^https?://","description":"Required when type=url; only HTTP(S) URLs are accepted."},"is_dynamic":{"type":"boolean","default":true,"description":"Only permitted when type=url; URL items default to dynamic when omitted."},"tags":{"type":"array","items":{"type":"string","maxLength":50},"maxItems":20},"notes":{"type":"string","maxLength":2000},"expires_at":{"type":"string","format":"date-time"},"vcard_first_name":{"type":"string","maxLength":100,"description":"One of vcard_first_name or vcard_last_name is required when type=vcard."},"vcard_last_name":{"type":"string","maxLength":100,"description":"One of vcard_first_name or vcard_last_name is required when type=vcard."},"vcard_organization":{"type":"string","maxLength":255},"vcard_phone":{"type":"string","maxLength":50},"vcard_email":{"type":"string","format":"email"},"wifi_ssid":{"type":"string","maxLength":32,"description":"Required when type=wifi."},"wifi_password":{"type":"string","maxLength":64},"wifi_encryption":{"type":"string","enum":["WPA","WEP","nopass"]}}}},"skip_url_scan":{"type":"boolean","default":false,"description":"Skip the synchronous URL safety scan, which supports at most 50 URL items; skipped URL items are stored with pending scan status for deferred cron processing."}}}}}},"responses":{"201":{"description":"Batch created","content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"properties":{"data":{"type":"object","required":["created","total","failed"],"properties":{"created":{"type":"array","items":{"$ref":"#/components/schemas/BatchQRCode"}},"total":{"type":"integer","minimum":1},"failed":{"type":"integer","minimum":0}}},"meta":{"type":"object"}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Insufficient permissions","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Payload validation (`errors[].field`, e.g. `items.0.appearance` — colors are set per code via PATCH /codes/{id}), unsafe URL, organization-plan batch-size limit, or synchronous URL-scan limit","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key or daily code-creation rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/codes/{id}":{"get":{"operationId":"getCode","summary":"Get QR Code","tags":["QR Codes"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"QR code","content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"properties":{"data":{"$ref":"#/components/schemas/QRCode"},"meta":{"type":"object"}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"patch":{"operationId":"updateCode","summary":"Update QR Code","tags":["QR Codes"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri","pattern":"^https?://","description":"Only HTTP(S) URLs are accepted; URL updates apply only to type=url codes."},"title":{"type":"string","maxLength":255},"status":{"type":"string","enum":["live","paused"]},"tags":{"type":"array","items":{"type":"string"},"maxItems":20},"notes":{"type":"string","maxLength":2000},"logo_url":{"type":["string","null"],"deprecated":true,"description":"Retired (#571 part 2): a value is rejected with 400 — upload a logo via POST /codes/{id}/logo instead, which the image routes embed directly. `null` remains accepted to clear a legacy value."},"expires_at":{"type":["string","null"],"format":"date-time"},"is_landing_page":{"type":"boolean"},"description":{"type":"string","maxLength":500,"description":"Trimmed plaintext context for a dynamic type=url code in landingpage mode. Empty text removes the stored description."},"accent_color":{"type":"string","pattern":"^(|#[0-9a-fA-F]{6})$","description":"Optional landingpage accent for a dynamic type=url code in landingpage mode. Empty text removes the stored accent."},"ab_enabled":{"type":"boolean","description":"Split scans between `target_url` (variant A) and `ab_target_url_b` (variant B). Dynamic type=url codes only; enabling it requires a variant B (supplied here or already stored)."},"ab_target_url_b":{"type":["string","null"],"format":"uri","pattern":"^https?://","maxLength":2048,"description":"Variant B destination; `null` clears it and switches A/B off."},"ab_weight_a":{"type":"integer","minimum":0,"maximum":100,"description":"Variant A's share of traffic as a percent; variant B receives the remainder."},"links":{"type":"array","maxItems":20,"items":{"type":"object","required":["label","url"],"properties":{"label":{"type":"string","minLength":1,"maxLength":100},"url":{"type":"string","format":"uri","pattern":"^https?://","maxLength":2048}}}},"appearance":{"type":["object","null"],"additionalProperties":false,"description":"Stored print colors (#570). Merged with the stored value: a field left out keeps its value, `null` per field resets it, `null` for the whole object resets both. A value equal to the standard (#000000 / #FFFFFF) is not stored. The color guard checks the effective pair (new plus stored): below 1.5:1 answers 422, weaker pairs answer 200 with `meta.issues`. Only via PATCH — POST, batch and import reject it with 422.","properties":{"foreground_color":{"type":["string","null"],"pattern":"^#[0-9a-fA-F]{6}$"},"background_color":{"type":["string","null"],"pattern":"^(#[0-9a-fA-F]{6}|[Tt][Rr][Aa][Nn][Ss][Pp][Aa][Rr][Ee][Nn][Tt])$","description":"#RRGGBB or `transparent` (any case)."}}}}}}}},"responses":{"200":{"description":"Updated QR code. `meta.issues` lists the color guard's findings for a stored appearance (warning/critical) — always an array, empty when the request did not touch `appearance` or the colors are fine.","content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"properties":{"data":{"$ref":"#/components/schemas/QRCode"},"meta":{"type":"object","required":["request_id","issues"],"properties":{"request_id":{"type":"string"},"issues":{"type":"array","items":{"$ref":"#/components/schemas/QRCodeColorIssue"}}}}}}}}},"400":{"description":"Body failed schema validation (`status` not one of live/paused, malformed `url` or `expires_at`, oversized fields). The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"Conflict (errors/conflict): design_config was changed by concurrent requests on every one of three write attempts. The request's changes to title, description, accent_color, links, appearance and the URL's display label are written only if the stored design_config still matches what was read; otherwise they are re-applied to a fresh read. Reload the code and retry.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Validation error, URL update requested for a non-URL code, or an `appearance` whose effective color pair is below 1.5:1 contrast (`errors[].field: \"appearance\"`, extension member `issues` with the blocking QRCodeColorIssue). Nothing is stored.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"operationId":"deleteCode","summary":"Delete QR Code","tags":["QR Codes"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"properties":{"data":{"type":"object","required":["id","deleted"],"properties":{"id":{"type":"string"},"deleted":{"type":"boolean"}}},"meta":{"type":"object"}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Not found in this workspace, or already deleted — also for the second of two concurrent DELETEs on the same code, which changes nothing and sends no `qr.deleted` webhook","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/codes/{id}/logo":{"post":{"operationId":"uploadCodeLogo","summary":"Upload Code Logo","description":"Uploads a raster logo (multipart field `file`) for a code and normalizes it to a 512×512 transparent PNG, aspect ratio preserved. Replaces any existing logo for the code. Input is capped at 1 MB and checked by magic bytes — only PNG, JPEG and WebP are accepted (SVG is rejected). Once stored, all four image formats (`qr.svg`, `qr.png`, `qr.pdf`, `qr.eps`) embed the logo's actual pixels and upgrade error correction to `H` (#571 part 2, #567, #574). Adding a logo where none was set before CHANGES THE MODULE PATTERN of all four outputs (error correction M → H); replacing an existing logo with another one does NOT — error correction stays H, only the embedded pixels change, so the matrix is identical. A code already printed keeps working in every case, since the encoded content does not depend on the logo; reprint it only if you want the (new) logo to show on the physical material, and never mix old and new printouts. See `logo_file_id` on the returned code.","tags":["QR Codes"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"PNG, JPEG or WebP, at most 1 MB"}},"required":["file"]}}}},"responses":{"201":{"description":"Logo stored; the code's updated representation is returned","content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"properties":{"data":{"$ref":"#/components/schemas/QRCode"},"meta":{"type":"object","required":["request_id","issues"],"properties":{"request_id":{"type":"string"},"issues":{"type":"array","description":"Color guard on the code's stored appearance, judged with the logo state after this request (#570): a color warning becomes critical with a logo (QR_COLOR_WITH_LOGO) and returns to a warning once the logo is removed. Empty when no colors are stored.","items":{"$ref":"#/components/schemas/QRCodeColorIssue"}}}}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Role is read-only","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Code not found in this workspace","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"Conflict (errors/conflict): another request changed this code's logo between this upload's read and its write (a concurrent upload or removal). Nothing was changed and the uploaded image was discarded — reload the code and retry.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"413":{"description":"Logo exceeds the 1 MB limit","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Missing file field, unrecognised or disallowed file signature (e.g. SVG/GIF), or the image could not be normalized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"operationId":"deleteCodeLogo","summary":"Delete Code Logo","description":"Removes a code's uploaded logo and deletes the stored object. This CHANGES THE MODULE PATTERN of all four image outputs back (error correction H → M), byte-identical to a code that never had a logo (#574). A code already printed with the logo keeps working, since the encoded content does not depend on the logo; reprint it only if the physical material needs to match, and never mix old and new printouts. Idempotent: calling it on a code with no logo set still returns 200 and makes no change.","tags":["QR Codes"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Logo removed (or already absent); the code's updated representation is returned","content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"properties":{"data":{"$ref":"#/components/schemas/QRCode"},"meta":{"type":"object","required":["request_id","issues"],"properties":{"request_id":{"type":"string"},"issues":{"type":"array","description":"Color guard on the code's stored appearance, judged with the logo state after this request (#570): a color warning becomes critical with a logo (QR_COLOR_WITH_LOGO) and returns to a warning once the logo is removed. Empty when no colors are stored.","items":{"$ref":"#/components/schemas/QRCodeColorIssue"}}}}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Role is read-only","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Code not found in this workspace","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"Conflict (errors/conflict): a concurrent upload replaced the logo between this request's read and its write. Nothing was removed — reload the code and retry. A concurrent removal is not a conflict: both requests answer 200.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/codes/{id}/stats":{"get":{"operationId":"getCodeStats","summary":"Get Scan Statistics","tags":["Analytics"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Scan statistics","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScanStats"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/codes/{id}/scans":{"get":{"operationId":"listCodeScans","summary":"Code Scan Analytics","description":"Aggregated scan metrics for one QR code: a per-day time series plus the top countries, device types and operating systems within the requested period. `total_scans` is the code's all-time counter and independent of `days`.","tags":["Analytics"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"days","in":"query","schema":{"type":"integer","default":30,"minimum":1,"maximum":365}}],"responses":{"200":{"description":"Aggregated scan analytics","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/CodeScanAnalytics"},"meta":{"$ref":"#/components/schemas/ResponseMeta"}}}}}},"400":{"description":"`days` failed schema validation (not an integer, or outside 1–365). The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Code not found in this workspace","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/codes/{id}/scans.csv":{"get":{"operationId":"exportCodeScansCsv","summary":"Export Scans (CSV)","description":"Raw scan events for one QR code, one row per scan, newest first — columns: scanned_at, country, region, city, device_type, os, browser, language, referer, ab_variant, redirected_to. The hashed IP is deliberately never exported (GDPR). Capped at 50,000 rows. Sent as an attachment (`qr3-scans-{short_code}.csv`) with `Cache-Control: private, no-store`; cell values that could execute as spreadsheet formulas are neutralised with a leading apostrophe.","tags":["Analytics"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"days","in":"query","schema":{"type":"integer","default":90,"minimum":1,"maximum":365}}],"responses":{"200":{"description":"CSV export of raw scan events","content":{"text/csv":{"schema":{"type":"string"}}}},"400":{"description":"`days` failed schema validation (not an integer, or outside 1–365). The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Code not found in this workspace","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/codes/{id}/scans.xlsx":{"get":{"operationId":"exportCodeScansXlsx","summary":"Export Scans (XLSX)","description":"The same raw scan export as /codes/{id}/scans.csv, as an Excel workbook with a single `Scans` sheet. Same columns, same 50,000-row cap, same GDPR exclusion of the hashed IP. Sent as an attachment (`qr3-scans-{short_code}.xlsx`) with `Cache-Control: private, no-store`.","tags":["Analytics"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"days","in":"query","schema":{"type":"integer","default":90,"minimum":1,"maximum":365}}],"responses":{"200":{"description":"XLSX export of raw scan events","content":{"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":{"schema":{"type":"string","format":"binary"}}}},"400":{"description":"`days` failed schema validation (not an integer, or outside 1–365). The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Code not found in this workspace","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/codes/{shortCode}/qr.svg":{"get":{"operationId":"getCodeQrSvg","security":[],"summary":"Download QR as SVG","tags":["QR Codes"],"parameters":[{"name":"shortCode","in":"path","required":true,"schema":{"type":"string"}},{"name":"size","in":"query","schema":{"type":"integer","minimum":2,"maximum":20,"default":4}},{"name":"download","in":"query","description":"When present, responds with Content-Disposition: attachment to force a file download instead of inline display.","schema":{"type":"boolean"}},{"name":"fg","in":"query","description":"Foreground (module) color as hex `RRGGBB` or `RGB`, case-insensitive. The leading `#` is optional; if you send it, encode it as `%23`. `000000` is the standard. Keep a strong contrast to the background (at least 4:1) and dark modules on a light background — light-on-dark codes are not read by every scanner. Invalid values are ignored: the image is rendered exactly as without the parameter, never a 4xx. An effective (non-standard) value is served with `Cache-Control: public, max-age=300` instead of `immutable`.","schema":{"type":"string","pattern":"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$","examples":["1F4E79"]}},{"name":"bg","in":"query","description":"Background color, same format as `fg`, or `transparent` (SVG without a background rectangle, PNG with alpha 0). `ffffff` is the standard. An embedded logo keeps its opaque white plate. With `transparent`, the surface the code is placed on must be light and leave the 4-module quiet zone clear. Invalid values are ignored: the image is rendered exactly as without the parameter, never a 4xx. An effective (non-standard) value is served with `Cache-Control: public, max-age=300` instead of `immutable`.","schema":{"anyOf":[{"type":"string","pattern":"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"},{"type":"string","enum":["transparent"]}],"examples":["F5F0E6","transparent"]}},{"name":"ecc","in":"query","description":"Error-correction level. `M` is the standard; `Q` or `H` make a printed code more robust (e.g. on corrugated board), `L` makes it sparser but less robust. Changes the module pattern, not the encoded content: a code already printed keeps working, but never mix printouts of different levels. A logo always forces `H`. If the content does not fit at the requested level, the standard level is used. Not stored on the code — a download option like `size`. Case-insensitive. Invalid values are ignored: the image is rendered exactly as without the parameter, never a 4xx. An effective (non-standard) value is served with `Cache-Control: public, max-age=300` instead of `immutable`.","schema":{"type":"string","enum":["L","M","Q","H"],"default":"M"}}],"responses":{"200":{"description":"SVG QR asset (public, no auth required)"},"404":{"description":"Not found"}}}},"/codes/{shortCode}/qr.png":{"get":{"operationId":"getCodeQrPng","security":[],"summary":"Download QR as PNG","tags":["QR Codes"],"parameters":[{"name":"shortCode","in":"path","required":true,"schema":{"type":"string"}},{"name":"size","in":"query","schema":{"type":"integer","minimum":2,"maximum":20,"default":4}},{"name":"download","in":"query","description":"When present, responds with Content-Disposition: attachment to force a file download instead of inline display.","schema":{"type":"boolean"}},{"name":"fg","in":"query","description":"Foreground (module) color as hex `RRGGBB` or `RGB`, case-insensitive. The leading `#` is optional; if you send it, encode it as `%23`. `000000` is the standard. Keep a strong contrast to the background (at least 4:1) and dark modules on a light background — light-on-dark codes are not read by every scanner. Invalid values are ignored: the image is rendered exactly as without the parameter, never a 4xx. An effective (non-standard) value is served with `Cache-Control: public, max-age=300` instead of `immutable`.","schema":{"type":"string","pattern":"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$","examples":["1F4E79"]}},{"name":"bg","in":"query","description":"Background color, same format as `fg`, or `transparent` (SVG without a background rectangle, PNG with alpha 0). `ffffff` is the standard. An embedded logo keeps its opaque white plate. With `transparent`, the surface the code is placed on must be light and leave the 4-module quiet zone clear. Invalid values are ignored: the image is rendered exactly as without the parameter, never a 4xx. An effective (non-standard) value is served with `Cache-Control: public, max-age=300` instead of `immutable`.","schema":{"anyOf":[{"type":"string","pattern":"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"},{"type":"string","enum":["transparent"]}],"examples":["F5F0E6","transparent"]}},{"name":"ecc","in":"query","description":"Error-correction level. `M` is the standard; `Q` or `H` make a printed code more robust (e.g. on corrugated board), `L` makes it sparser but less robust. Changes the module pattern, not the encoded content: a code already printed keeps working, but never mix printouts of different levels. A logo always forces `H`. If the content does not fit at the requested level, the standard level is used. Not stored on the code — a download option like `size`. Case-insensitive. Invalid values are ignored: the image is rendered exactly as without the parameter, never a 4xx. An effective (non-standard) value is served with `Cache-Control: public, max-age=300` instead of `immutable`.","schema":{"type":"string","enum":["L","M","Q","H"],"default":"M"}}],"responses":{"200":{"description":"PNG QR asset (public, no auth required)"},"404":{"description":"Not found"}}}},"/codes/{shortCode}/qr.pdf":{"get":{"operationId":"getCodeQrPdf","security":[],"summary":"Download QR as PDF","tags":["QR Codes"],"parameters":[{"name":"shortCode","in":"path","required":true,"schema":{"type":"string"}},{"name":"format","in":"query","description":"'square' (default) sizes the page tightly to the code with no surrounding whitespace; 'a4' centers it on a full A4 sheet for printing.","schema":{"type":"string","enum":["a4","square"],"default":"square"}},{"name":"title","in":"query","description":"Optional caption above the code; truncated to 120 characters.","schema":{"type":"string","maxLength":120}},{"name":"fg","in":"query","description":"Module color, same format as on `qr.svg`/`qr.png`. Written as a print color: black and every grey (R = G = B) as DeviceGray, which prints on the black plate only; any other color as DeviceCMYK in whole percent by the simple formula K = 1 − max/255, C = (max − R)/max, M and Y likewise (e.g. `1f4e79` → C74 M36 Y0 K53). No ICC profile and not PDF/X, so viewers show hued colors slightly differently from the SVG; give the print shop your brand colors as CMYK if they must match exactly. Any chosen color also draws an opaque white background behind the code and its quiet zone, like the SVG — unless `bg` says otherwise. Without colors the file has no background. Invalid values are ignored: the image is rendered exactly as without the parameter, never a 4xx. An effective (non-standard) value is served with `Cache-Control: public, max-age=300` instead of `immutable`.","schema":{"type":"string","pattern":"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$","examples":["1F4E79"]}},{"name":"bg","in":"query","description":"Background color or `transparent`, same format as on `qr.svg`/`qr.png`, converted like `fg` (DeviceGray or DeviceCMYK). A color draws an opaque fill over the code and its 4-module quiet zone only — the title band and the rest of an A4 page stay unpainted; `ffffff` draws a white fill. `transparent` draws no fill, so place the code on a light surface that leaves the quiet zone clear. The PDF title stays black and a logo keeps its white plate; the logo image itself stays RGB. Invalid values are ignored: the image is rendered exactly as without the parameter, never a 4xx. Every valid value, including `ffffff` and `transparent`, makes the response `max-age=300` instead of `immutable`.","schema":{"anyOf":[{"type":"string","pattern":"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"},{"type":"string","enum":["transparent"]}],"examples":["F5F0E6","transparent"]}},{"name":"ecc","in":"query","description":"Error-correction level. `M` is the standard; `Q` or `H` make a printed code more robust (e.g. on corrugated board), `L` makes it sparser but less robust. Changes the module pattern, not the encoded content: a code already printed keeps working, but never mix printouts of different levels. A logo always forces `H`. If the content does not fit at the requested level, the standard level is used. Not stored on the code — a download option like `size`. Case-insensitive. Invalid values are ignored: the image is rendered exactly as without the parameter, never a 4xx. An effective (non-standard) value is served with `Cache-Control: public, max-age=300` instead of `immutable`.","schema":{"type":"string","enum":["L","M","Q","H"],"default":"M"}}],"responses":{"200":{"description":"Print-ready vector PDF QR asset (public, no auth required)"},"404":{"description":"Not found"}}}},"/codes/{shortCode}/qr.eps":{"get":{"operationId":"getCodeQrEps","security":[],"summary":"Download QR as EPS","tags":["QR Codes"],"parameters":[{"name":"shortCode","in":"path","required":true,"schema":{"type":"string"}},{"name":"size","in":"query","schema":{"type":"integer","minimum":2,"maximum":20,"default":4}},{"name":"fg","in":"query","description":"Module color, same format as on `qr.svg`/`qr.png`. Written as a print color: black and every grey (R = G = B) as DeviceGray, which prints on the black plate only; any other color as DeviceCMYK in whole percent by the simple formula K = 1 − max/255, C = (max − R)/max, M and Y likewise (e.g. `1f4e79` → C74 M36 Y0 K53). No ICC profile and not PDF/X, so viewers show hued colors slightly differently from the SVG; give the print shop your brand colors as CMYK if they must match exactly. Any chosen color also draws an opaque white background behind the code and its quiet zone, like the SVG — unless `bg` says otherwise. Without colors the file has no background. Invalid values are ignored: the image is rendered exactly as without the parameter, never a 4xx. An effective (non-standard) value is served with `Cache-Control: public, max-age=300` instead of `immutable`.","schema":{"type":"string","pattern":"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$","examples":["1F4E79"]}},{"name":"bg","in":"query","description":"Background color or `transparent`, same format as on `qr.svg`/`qr.png`, converted like `fg` (DeviceGray or DeviceCMYK). A color draws an opaque fill over the code and its 4-module quiet zone only — the title band and the rest of an A4 page stay unpainted; `ffffff` draws a white fill. `transparent` draws no fill, so place the code on a light surface that leaves the quiet zone clear. The PDF title stays black and a logo keeps its white plate; the logo image itself stays RGB. Invalid values are ignored: the image is rendered exactly as without the parameter, never a 4xx. Every valid value, including `ffffff` and `transparent`, makes the response `max-age=300` instead of `immutable`.","schema":{"anyOf":[{"type":"string","pattern":"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"},{"type":"string","enum":["transparent"]}],"examples":["F5F0E6","transparent"]}},{"name":"ecc","in":"query","description":"Error-correction level. `M` is the standard; `Q` or `H` make a printed code more robust (e.g. on corrugated board), `L` makes it sparser but less robust. Changes the module pattern, not the encoded content: a code already printed keeps working, but never mix printouts of different levels. A logo always forces `H`. If the content does not fit at the requested level, the standard level is used. Not stored on the code — a download option like `size`. Case-insensitive. Invalid values are ignored: the image is rendered exactly as without the parameter, never a 4xx. An effective (non-standard) value is served with `Cache-Control: public, max-age=300` instead of `immutable`.","schema":{"type":"string","enum":["L","M","Q","H"],"default":"M"}}],"responses":{"200":{"description":"EPS (PostScript) vector QR asset for prepress (public, no auth required)"},"404":{"description":"Not found"}}}},"/dpp":{"get":{"operationId":"listDpps","summary":"List DPP Passports","tags":["DPP"],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":20,"maximum":100}},{"name":"cursor","in":"query","description":"Opaque `next_cursor` from the previous page, passed back verbatim. Malformed values are rejected with 400.","schema":{"type":"string"}},{"name":"status","in":"query","schema":{"type":"string","enum":["draft","live","archived"]}}],"responses":{"200":{"description":"List of DPP passports","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/DppPassport"}},"meta":{"type":"object"}}}}}},"400":{"description":"Query failed schema validation (`limit` not an integer in 1–100, `status` not one of draft/live/archived, or a malformed `cursor`). The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"post":{"operationId":"createDpp","summary":"Create DPP Passport","tags":["DPP"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["gtin","product_name","manufacturer","origin_country"],"description":"Provide the matching *_data field for the chosen category.","properties":{"gtin":{"type":"string","pattern":"^\\d{14}$"},"serial":{"type":"string","maxLength":20,"pattern":"^[A-Za-z0-9._~-]{1,20}$","description":"Optional GS1 serial. Must use safe URL segment characters only."},"lot":{"type":"string","maxLength":20,"pattern":"^[A-Za-z0-9._~-]{1,20}$","description":"Optional GS1 lot. Must use safe URL segment characters only."},"product_name":{"type":"string"},"manufacturer":{"type":"string"},"origin_country":{"type":"string","pattern":"^[A-Z]{2}$"},"category":{"type":"string","enum":["battery","textile","general"],"default":"battery"},"landing_locale":{"type":"string","enum":["de","en","fr"],"default":"de"},"status":{"type":"string","enum":["draft","live","archived"],"default":"live"},"branding_config":{"$ref":"#/components/schemas/DppBrandingConfig"},"battery_data":{"$ref":"#/components/schemas/BatteryDppData"},"textile_data":{"$ref":"#/components/schemas/TextileDppData"},"general_data":{"$ref":"#/components/schemas/GeneralProductDppData"}}}}}},"responses":{"201":{"description":"DPP passport created","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/DppPassport"},"meta":{"type":"object"}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Validation or plan limit error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/dpp/validate":{"post":{"operationId":"validateDpp","summary":"Validate DPP Payload","tags":["DPP"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["gtin","product_name","manufacturer","origin_country","battery_data"],"properties":{"gtin":{"type":"string","pattern":"^\\d{14}$"},"serial":{"type":"string","pattern":"^[A-Za-z0-9._~-]{1,20}$","description":"Optional GS1 serial. Must use safe URL segment characters only."},"lot":{"type":"string","pattern":"^[A-Za-z0-9._~-]{1,20}$","description":"Optional GS1 lot. Must use safe URL segment characters only."},"product_name":{"type":"string"},"manufacturer":{"type":"string"},"origin_country":{"type":"string","pattern":"^[A-Z]{2}$"},"category":{"type":"string","enum":["battery"],"default":"battery"},"landing_locale":{"type":"string","enum":["de","en","fr"]},"status":{"type":"string","enum":["draft","live","archived"]},"branding_config":{"$ref":"#/components/schemas/DppBrandingConfig"},"battery_data":{"$ref":"#/components/schemas/BatteryDppData"}}}}}},"responses":{"200":{"description":"Validation result","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/DppValidationResult"},"meta":{"type":"object"}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/dpp/batch":{"post":{"operationId":"createDppBatch","summary":"Batch-create DPP Passports (up to 100)","tags":["DPP"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["items"],"properties":{"items":{"type":"array","minItems":1,"maxItems":100,"items":{"$ref":"#/components/schemas/CreateDppPayload"}}}}}}},"responses":{"201":{"description":"Batch created"},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Validation or plan-limit error"},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/dpp/import":{"post":{"operationId":"importDpps","summary":"Import DPP passports from CSV or XLSX","description":"Upload a CSV or XLSX file (multipart field `file`) or send raw `text/csv` body. Max 5 MB. Row limit is plan-based: Free 100, Pro 500, Business 1000, Agency 5000, Enterprise 10000. Imports up to 200 rows finish synchronously; larger imports return 202 with an import job id and are processed via Cloudflare Queues. For XLSX only the first sheet is read. Required columns: gtin, product_name, manufacturer, origin_country. Optional: serial, lot, category, status, landing_locale (one of de, en, fr; defaults to de — a row with any other value fails with a row-level error, same as an invalid origin_country).","tags":["DPP"],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"CSV or XLSX file (`.csv` or `.xlsx`)"}},"required":["file"]}},"text/csv":{"schema":{"type":"string","format":"binary"}},"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":{"schema":{"type":"string","format":"binary"}}}},"responses":{"201":{"description":"Import finished (partial success: check `errors` and `skipped`)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","required":["imported","skipped","passports","errors"],"properties":{"imported":{"type":"integer"},"skipped":{"type":"integer"},"passports":{"type":"array","items":{"$ref":"#/components/schemas/DppImportRowResult"}},"errors":{"type":"array","items":{"$ref":"#/components/schemas/DppImportError"}},"errors_csv":{"type":"string","description":"UTF-8 CSV string with failed rows (row number, error, original values). Only present when `skipped > 0`. Clients can offer this as a file download."}}},"meta":{"type":"object"}}}}}},"202":{"description":"Import accepted for asynchronous processing","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","required":["job_id","status","total_rows","status_url"],"properties":{"job_id":{"type":"string","example":"imp_a1b2c3d4"},"status":{"type":"string","enum":["queued"]},"total_rows":{"type":"integer"},"status_url":{"type":"string","example":"/v1/dpp/imports/imp_a1b2c3d4"}}},"meta":{"type":"object"}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Invalid CSV or over row limit","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/dpp/imports/{job_id}":{"get":{"operationId":"getDppImportJob","summary":"Get DPP import job status","description":"Returns the status and latest counters for an asynchronous DPP import job. The lookup is scoped to the authenticated workspace.","tags":["DPP"],"parameters":[{"name":"job_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Import job status","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/DppImportJob"},"meta":{"type":"object"}}}}}},"401":{"description":"Authentication required"},"404":{"description":"Unknown job or different workspace"},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/dpp/import/templates/{category}":{"get":{"operationId":"getDppImportTemplate","security":[],"summary":"Download import template (CSV or XLSX)","description":"Returns a pre-filled CSV or XLSX template for the given DPP category. No authentication required. Use `?format=xlsx` for Excel format (default: CSV). Categories: battery, textile, general.","tags":["DPP"],"parameters":[{"name":"category","in":"path","required":true,"schema":{"type":"string","enum":["battery","textile","general"]}},{"name":"format","in":"query","required":false,"schema":{"type":"string","enum":["csv","xlsx"],"default":"csv"}}],"responses":{"200":{"description":"Template file download","content":{"text/csv":{"schema":{"type":"string","format":"binary"}},"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":{"schema":{"type":"string","format":"binary"}}}},"422":{"description":"Invalid category","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/dpp/{id}":{"get":{"operationId":"getDpp","summary":"Get DPP Passport","description":"Returns JSON by default; send `Accept: application/ld+json` for JSON-LD.","tags":["DPP"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"DPP passport (JSON or JSON-LD)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/DppPassport"},"meta":{"type":"object"}}}},"application/ld+json":{"schema":{"type":"object","description":"schema.org Product JSON-LD"}}}},"400":{"description":"`id` failed schema validation — DPP ids start with `dpp_`. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"put":{"operationId":"updateDpp","summary":"Update a DPP Passport","description":"Partial update. GTIN/serial/lot are immutable after creation.","tags":["DPP"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"product_name":{"type":"string"},"manufacturer":{"type":"string"},"origin_country":{"type":"string"},"landing_locale":{"type":"string"},"status":{"type":"string","enum":["draft","live","archived"]},"branding_config":{"$ref":"#/components/schemas/DppBrandingConfig"},"battery_data":{"$ref":"#/components/schemas/BatteryDppData"},"textile_data":{"$ref":"#/components/schemas/TextileDppData"},"general_data":{"$ref":"#/components/schemas/GeneralProductDppData"}}}}}},"responses":{"200":{"description":"Updated passport"},"400":{"description":"`id` failed schema validation — DPP ids start with `dpp_`. Body failures answer 422 instead. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Not found"},"422":{"description":"Validation error"},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/dpp/{id}/stats":{"get":{"operationId":"getDppStats","summary":"DPP scan statistics (Q3.4.1)","description":"Aggregated scan metrics for a single DPP passport. Scans are recorded asynchronously by the public resolver at `/01/{gtin}/21/{serial}`. IP addresses are immediately pseudonymised with HMAC-SHA-256 using a long-lived secret, UTC day, and purpose; raw IPs are never persisted.","tags":["Analytics"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"days","in":"query","schema":{"type":"integer","default":30,"minimum":1,"maximum":365}}],"responses":{"200":{"description":"DPP scan statistics","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DppScanStats"}}}},"400":{"description":"`id` (must start with `dpp_`) or `days` (integer 1–365) failed schema validation. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/dpp/{id}/validate-update":{"post":{"operationId":"previewDppUpdateCompliance","summary":"Stateless EU-compliance preview for a partial update (Q3.3.7)","description":"Runs the same EU-compliance validator as `/dpp/{id}/eu-compliance`, but against a hypothetical merge of the existing DPP and the supplied partial update body. Does NOT persist changes — pair with `PUT /dpp/{id}` to actually save. Empty body returns the verdict for the current row.","tags":["DPP"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","description":"Same shape as `PUT /dpp/{id}` body but every field is optional (including an empty object).","properties":{"product_name":{"type":"string"},"manufacturer":{"type":"string"},"origin_country":{"type":"string"},"landing_locale":{"type":"string"},"status":{"type":"string","enum":["draft","live","archived"]},"branding_config":{"$ref":"#/components/schemas/DppBrandingConfig"},"market_countries":{"type":"array","items":{"type":"string"}},"battery_data":{"$ref":"#/components/schemas/BatteryDppData"},"textile_data":{"$ref":"#/components/schemas/TextileDppData"},"general_data":{"$ref":"#/components/schemas/GeneralProductDppData"}}}}}},"responses":{"200":{"description":"Compliance verdict + echo of merged fields","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"allOf":[{"$ref":"#/components/schemas/DppEuComplianceResult"},{"type":"object","properties":{"preview":{"type":"object","properties":{"status":{"type":"string"},"landing_locale":{"type":"string"},"market_countries":{"type":"array","items":{"type":"string"}},"changed_fields":{"type":"array","items":{"type":"string"},"description":"Names of fields present in the request body (changed or unchanged)."}}}}}]},"meta":{"type":"object"}}}}}},"400":{"description":"An `id` that does not start with `dpp_` answers the raw Zod result; a syntactically invalid JSON body answers a problem-shaped object. Schema failures of the body itself answer 422.","content":{"application/problem+json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ZodValidationError"},{"$ref":"#/components/schemas/ProblemDetails"}]}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Validation error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/dpp/{id}/eu-compliance":{"get":{"operationId":"getDppEuCompliance","summary":"EU ESPR compliance check","description":"Returns structured issues for the passport (same logic as POST /dpp/{id}/eu-validate).","tags":["DPP"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Compliance result","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/DppEuComplianceResult"},"meta":{"type":"object"}}}}}},"400":{"description":"`id` failed schema validation — DPP ids start with `dpp_`. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/dpp/{id}/eu-validate":{"post":{"operationId":"validateDppEuCompliance","summary":"EU ESPR compliance check (POST alias)","tags":["DPP"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Compliance result","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/DppEuComplianceResult"},"meta":{"type":"object"}}}}}},"400":{"description":"`id` failed schema validation — DPP ids start with `dpp_`. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/dpp/{id}/register":{"post":{"operationId":"registerDppWithEuRegistry","summary":"Submit DPP to EU central registry (async, mock until API published)","description":"Returns 202 and writes `eu_registry_status = 'pending'`; a callback to `POST /v1/webhooks/eu-registry` finalises the status. Until the EU registry API ships (≤ 19 Jul 2026) the mock adapter is used. **QA-only env switch** `EU_REGISTRY_MOCK_MODE` controls timing/outcome: `async` (default, ~5 s → `registered`), `instant_registered`, `instant_rejected` (`rejected_reason: \"qa_test\"`), `never` (no callback). `EU_REGISTRY_MODE = \"live\"` selects the real adapter (not yet implemented, throws — see DPP-SPEC.md §1, §7).","tags":["DPP"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"properties":{"client_reference":{"type":"string","maxLength":256}}}}}},"responses":{"202":{"description":"Registration accepted for asynchronous processing","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"eu_registry_status":{"type":"string","enum":["pending"]},"registry_request_id":{"type":"string"},"estimated_processing_seconds":{"type":"integer"}}},"meta":{"type":"object"}}}}}},"400":{"description":"`id` failed schema validation — DPP ids start with `dpp_`. Body failures answer 422 instead. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Not found"},"409":{"description":"Registry flow already started"},"422":{"description":"Plan or passport preconditions not met"},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/dpp/{id}/qr.svg":{"get":{"operationId":"getDppQrSvg","summary":"Download DPP QR as SVG","tags":["DPP"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"SVG QR asset. Public for live DPPs; draft and archived assets require auth plus workspace access."},"400":{"description":"`id` failed schema validation — DPP ids start with `dpp_`. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Not found"},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/dpp/{id}/qr.png":{"get":{"operationId":"getDppQrPng","summary":"Download DPP QR as PNG","tags":["DPP"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"PNG QR asset. Public for live DPPs; draft and archived assets require auth plus workspace access."},"400":{"description":"`id` failed schema validation — DPP ids start with `dpp_`. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Not found"},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/dpp/{id}/qr.pdf":{"get":{"operationId":"getDppQrPdf","summary":"Download DPP QR as PDF","tags":["DPP"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"format","in":"query","description":"'square' (default) sizes the page tightly to the code with no surrounding whitespace; 'a4' centers it on a full A4 sheet for printing.","schema":{"type":"string","enum":["a4","square"],"default":"square"}}],"responses":{"200":{"description":"PDF QR asset. Public for live DPPs; draft and archived assets require auth plus workspace access."},"400":{"description":"`id` failed schema validation — DPP ids start with `dpp_`. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Not found"},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/dpp/{id}/qr.eps":{"get":{"operationId":"getDppQrEps","summary":"Download DPP QR as EPS","tags":["DPP"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"EPS QR asset. Public for live DPPs; draft and archived assets require auth plus workspace access."},"400":{"description":"`id` failed schema validation — DPP ids start with `dpp_`. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Not found"},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/webhooks":{"get":{"operationId":"listWebhooks","summary":"List Webhooks","tags":["Webhooks"],"responses":{"200":{"description":"List of webhooks","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Webhook"}}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"post":{"operationId":"createWebhook","summary":"Create Webhook","description":"Included from the Pro plan (#592). qr3 POSTs every subscribed event as JSON\n`{ id, type, created, data }` to the URL:\n\n- `qr.created`, `qr.updated`: `data` is the code as `GET /v1/codes/{id}` returns it.\n- `qr.deleted`: `data` is `{ id, short_code, type, deleted_at }`.\n- `qr.flagged`: the code after the Web Risk re-scan paused it.\n- `qr.scanned`: a code scan (`code_id`, `short_code`, `scanned_at`, `country`,\n  `device_type`, `os`, `browser`, `language`, `ab_variant`, `redirected_to`) or a DPP resolver\n  scan (`dpp_id`, `gtin`, `serial`, `lot`, …, `representation`). No IP address.\n\nHeaders: `X-QR3-Signature: sha256=<HMAC-SHA256 of the raw body with your secret>`,\n`X-QR3-Event`, `X-QR3-Delivery`, `X-QR3-Attempt`, `User-Agent: qr3-webhooks/1.0`.\nUp to 3 attempts (immediately, after 60 s, after 300 s); a webhook is switched\noff after 10 failed deliveries in a row.","tags":["Webhooks"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url","secret"],"properties":{"url":{"type":"string","format":"uri","description":"Must be HTTPS"},"events":{"type":"array","items":{"type":"string","enum":["*","qr.created","qr.updated","qr.deleted","qr.scanned","qr.flagged"]},"default":["*"]},"secret":{"type":"string","minLength":16,"description":"HMAC secret (store securely)"}}}}}},"responses":{"201":{"description":"Webhook created (contains secret — only shown once)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Webhook"}}}},"400":{"description":"Body failed schema validation (`url` must be HTTPS and ≤ 2048 chars, `events` a non-empty list of known event names, `secret` 16–256 chars). The body is the raw Zod result, not an RFC 7807 problem document. A plan without webhooks answers 422 `plan-limit` instead.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"`plan-limit`: the organization's plan does not include webhooks (from Pro, #592). The body carries `feature` and `required_plan`.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/webhooks/{id}":{"get":{"operationId":"getWebhook","summary":"Get Webhook","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Webhook","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Webhook"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"patch":{"operationId":"updateWebhook","summary":"Update Webhook","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string"},"events":{"type":"array","items":{"type":"string"}},"is_active":{"type":"boolean"},"secret":{"type":"string","description":"Rotate the HMAC secret"}}}}}},"responses":{"200":{"description":"Updated webhook"},"400":{"description":"Body failed schema validation (`url` must be HTTPS and ≤ 2048 chars, `events` a non-empty list of known event names, `secret` 16–256 chars). The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"operationId":"deleteWebhook","summary":"Delete Webhook","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/webhooks/{id}/deliveries":{"get":{"operationId":"listWebhookDeliveries","summary":"List Webhook Deliveries","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"maximum":100}},{"name":"cursor","in":"query","description":"Opaque `next_cursor` from the previous page, passed back verbatim. An unknown cursor falls back to the first page.","schema":{"type":"string"}}],"responses":{"200":{"description":"Delivery log"},"400":{"description":"`limit` failed schema validation (integer 1–100). The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/webhooks/{id}/ping":{"post":{"operationId":"pingWebhook","summary":"Ping Webhook","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Ping result with status code and response time"},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api-keys":{"get":{"operationId":"listApiKeys","summary":"List API Keys","tags":["API Keys"],"responses":{"200":{"description":"List of API keys"},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"post":{"operationId":"createApiKey","summary":"Create API Key","tags":["API Keys"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","minLength":1,"maxLength":100},"expires_at":{"type":"string","format":"date-time","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}(:\\d{2}(\\.\\d+)?)?Z$","description":"UTC instant with a trailing `Z` (seconds optional). The validator rejects RFC 3339 numeric offsets such as `+02:00` — narrower than bare `format: date-time` suggests. Omit for a non-expiring key."}}}}}},"responses":{"201":{"description":"Created API key (secret shown once)"},"400":{"description":"Body failed schema validation (`name` missing, empty or over 100 chars; `expires_at` not a UTC datetime ending in `Z` — numeric offsets are rejected). The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api-keys/{id}":{"delete":{"operationId":"revokeApiKey","summary":"Revoke API Key","tags":["API Keys"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"ID des zu widerrufenden Schluessels."}],"responses":{"200":{"description":"Der Schluessel ist widerrufen. Kein 204: die Antwort traegt einen Body. Der Widerruf ist ein Soft-Delete (`is_active = 0`, `deleted_at` gesetzt) — die ID bleibt bestehen, der Schluessel authentifiziert nicht mehr.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"revoked":{"type":"boolean","enum":[true]}}},"meta":{"type":"object","properties":{"request_id":{"type":"string"}}}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Drei verschiedene Gruende: (a) die Rolle darf nicht loeschen; (b) es ist der Schluessel, mit dem DIESE Anfrage authentifiziert wurde — ein Client kann sich nicht selbst aussperren; (c) es ist ein systemverwalteter Schluessel. `detail` unterscheidet die Faelle.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Kein Schluessel dieser ID im Workspace — auch dann, wenn es ihn in einem anderen Workspace gibt oder er bereits widerrufen wurde.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/account/usage":{"get":{"operationId":"getAccountUsage","summary":"Get Account Usage","description":"Current usage against plan limits. Every figure comes from the same source the enforcement uses, so the numbers here never disagree with a 422 the API would return. `billing_period_end` is the end of the period `scans_this_month` counts — the running calendar month in UTC — and not a Stripe billing anniversary.","tags":["Account"],"responses":{"200":{"description":"Usage against plan limits","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountUsage"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Organization not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/account/me":{"get":{"operationId":"getAccount","summary":"Get Account","tags":["Account"],"responses":{"200":{"description":"Account information"},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/gs1/identifiers":{"get":{"operationId":"listGs1Identifiers","summary":"List GS1 identifiers","tags":["GS1"],"parameters":[{"name":"identifier_type","in":"query","schema":{"type":"string","enum":["gtin","gln","company_prefix"]}},{"name":"cursor","in":"query","description":"Opaque `next_cursor` from the previous page, passed back verbatim. Malformed values are rejected with 400.","schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":100}}],"responses":{"200":{"description":"Registered GS1 identifiers","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/GS1Identifier"}}}}}}},"400":{"description":"Query failed schema validation (`limit` not an integer in 1–100, `identifier_type` not one of gtin/gln/company_prefix, or a malformed `cursor`). The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"post":{"operationId":"registerGs1Identifier","summary":"Register a GS1 identifier (GTIN, GLN, or Company Prefix)","tags":["GS1"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGS1IdentifierRequest"}}}},"responses":{"201":{"description":"Identifier registered","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/GS1Identifier"}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"Identifier already registered for this organization"},"422":{"description":"Invalid identifier value"},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/gs1/identifiers/{id}":{"get":{"operationId":"getGs1Identifier","summary":"Get a specific GS1 identifier","tags":["GS1"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"GS1 identifier details","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/GS1Identifier"}}}}}},"400":{"description":"`id` failed schema validation — GS1 identifier ids start with `gid_`. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Identifier not found"},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"operationId":"deleteGs1Identifier","summary":"Remove a GS1 identifier (soft delete)","tags":["GS1"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Identifier removed"},"400":{"description":"`id` failed schema validation — GS1 identifier ids start with `gid_`. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Identifier not found"},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/gs1/validate":{"post":{"operationId":"validateGs1Identifier","summary":"Validate a GTIN, GLN or Company Prefix (no auth required)","tags":["GS1"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateGS1Request"}}}},"responses":{"200":{"description":"Validation result","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ValidateGS1Response"}}}}}}}}},"/files/upload":{"post":{"operationId":"uploadFile","summary":"Upload File","description":"Uploads a file (multipart field `file`) and optionally attaches it to a QR code. The declared content type is verified against the file's magic bytes; supported types are PDF, PNG, JPEG, WebP, MP4, glTF/GLB and JSON. Files are private unless `visibility=public` is sent. Per-file size limits are plan-based (Free 5 MB, Pro 25 MB, Business 100 MB, Agency 100 MB, Enterprise 250 MB); the per-code file count and the workspace storage total are capped per plan as well. Upload rate limit: 100 per hour per workspace.","tags":["Files"],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"The file to store"},"code_id":{"type":"string","description":"Attach to this QR code. Must belong to the same workspace."},"visibility":{"type":"string","enum":["public","private"],"default":"private","description":"`public` also serves the file at /f/{id} without authentication and lists it on the code's landing page."}},"required":["file"]}}}},"responses":{"201":{"description":"File stored","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/File"},"meta":{"type":"object"}}}}}},"400":{"description":"Malformed multipart body, empty file, or unrecognised file signature","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Role is read-only","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"code_id not found in this workspace","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"413":{"description":"File exceeds the plan's per-file size limit","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Missing file field, invalid visibility, per-code file limit reached, or workspace storage limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Upload rate limit for this workspace (100/hour), or the API key rate limit","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/files":{"get":{"operationId":"listFiles","summary":"List Files","description":"Lists the workspace's active files, newest first. Not paginated — every matching file is returned. `meta.file_count` and `meta.total_size_bytes` describe the filtered result set, not the workspace total.","tags":["Files"],"parameters":[{"name":"code_id","in":"query","schema":{"type":"string"},"description":"Only files attached to this QR code"},{"name":"visibility","in":"query","schema":{"type":"string","enum":["public","private"]}}],"responses":{"200":{"description":"File list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/File"}},"meta":{"type":"object"}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Invalid query parameters","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/files/{id}/download":{"get":{"operationId":"downloadFile","summary":"Download File","description":"Streams the stored bytes. Sent as an attachment with Cache-Control: no-store, so the response always reflects the current content — unlike the cached public /f/{id} URL.","tags":["Files"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"File contents. Content-Type mirrors the stored file's type; the body is sent as an attachment.","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"File not found in this workspace, or its stored object is missing","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/files/{id}":{"get":{"operationId":"getFile","summary":"Get File","description":"Returns the file's metadata. Readable by every role, including viewers.","tags":["Files"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"File metadata","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/File"},"meta":{"type":"object"}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"put":{"operationId":"replaceFile","summary":"Replace File","description":"Swaps the stored bytes while keeping the same `id` — and with it the download and public URLs, so a printed QR code stays valid. `visibility`, `code_id` and `created_at` are preserved; `filename`, `mime_type`, `size_bytes` and `hash_sha256` are updated. The workspace storage cap is checked against the size delta, and the per-code file limit does not apply (no file is added). A replaced public file may take up to 5 minutes to propagate through caches.","tags":["Files"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"The replacement file"}},"required":["file"]}}}},"responses":{"200":{"description":"File replaced","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/File"},"meta":{"type":"object"}}}}}},"400":{"description":"Malformed multipart body, empty file, or unrecognised file signature","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Role is read-only","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"413":{"description":"File exceeds the plan's per-file size limit","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Missing file field, or workspace storage limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Upload rate limit for this workspace (100/hour), or the API key rate limit","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"operationId":"deleteFile","summary":"Delete File","description":"Soft-deletes the file: it stops being listed and downloadable at once. A public file also stops being served at /f/{id}, but that response is cached for up to 5 minutes — so deleting is not an immediate takedown for anyone who already holds the link. Requires a role that may delete; contributors cannot.","tags":["Files"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"File deleted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"deleted":{"type":"boolean"}}},"meta":{"type":"object"}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Role may not delete","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/organizations":{"get":{"operationId":"getOwnOrganization","summary":"Get Own Organization","description":"Returns the organization the request is scoped to. For historic reasons the payload is a single-element array, not an object.","tags":["Organizations"],"responses":{"200":{"description":"The caller's organization","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Organization"}},"meta":{"$ref":"#/components/schemas/ResponseMeta"}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"The key's organization no longer exists (soft-deleted)","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"post":{"operationId":"createOrganization","summary":"Create Organization","description":"Creates a new, empty organization on the free plan. The new organization is NOT connected to the calling API key — the key stays bound to its original workspace and organization, and `owner_user_id` is recorded as the literal value \"api\".","tags":["Organizations"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","slug"],"properties":{"name":{"type":"string","minLength":1,"maxLength":255},"slug":{"type":"string","minLength":2,"maxLength":63,"pattern":"^[a-z0-9-]+$","description":"Globally unique, lowercase alphanumeric with hyphens."},"billing_email":{"type":"string","format":"email"}}}}}},"responses":{"201":{"description":"Organization created","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Organization"},"meta":{"$ref":"#/components/schemas/ResponseMeta"}}}}}},"400":{"description":"Body failed schema validation. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Role is read-only","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"Slug is already taken","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/organizations/usage":{"get":{"operationId":"getOrganizationUsage","summary":"Get Organization Usage","description":"Current-month scan consumption and active dynamic-code consumption for the organization, against its effective plan limits. Both counts are computed live and are organization-wide (summed across all the organization's workspaces).","tags":["Organizations"],"responses":{"200":{"description":"Scan and dynamic-code usage vs plan limits","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/OrganizationUsage"},"meta":{"$ref":"#/components/schemas/ResponseMeta"}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"The key's organization no longer exists (soft-deleted)","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/organizations/{id}":{"get":{"operationId":"getOrganization","summary":"Get Organization","description":"Returns the organization — but only the key's own. Any other id answers 404 (deliberately not 403, so foreign organization ids are not confirmed to exist).","tags":["Organizations"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The organization","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Organization"},"meta":{"$ref":"#/components/schemas/ResponseMeta"}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Not found — including every organization other than the key's own","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"patch":{"operationId":"updateOrganization","summary":"Update Organization","description":"Updates name, billing email (invoice recipient) and/or notification email (operational notifications such as scan-quota warnings) of the key's own organization. An empty body is a no-op that returns the current state.","tags":["Organizations"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":255},"billing_email":{"type":["string","null"],"format":"email"},"notification_email":{"type":["string","null"],"format":"email"}}}}}},"responses":{"200":{"description":"Updated organization","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Organization"},"meta":{"$ref":"#/components/schemas/ResponseMeta"}}}}}},"400":{"description":"Body failed schema validation. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Role is read-only, or the id is not the key's own organization","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"operationId":"deleteOrganization","summary":"Delete Organization","description":"Soft-deletes the key's own organization. Irreversible via the API — the row is retained but every subsequent organization-scoped request answers 404.","tags":["Organizations"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Organization soft-deleted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","required":["id","deleted"],"properties":{"id":{"type":"string"},"deleted":{"type":"boolean"}}},"meta":{"$ref":"#/components/schemas/ResponseMeta"}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Role may not delete, or the id is not the key's own organization","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Organization not found or already deleted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/workspaces":{"get":{"operationId":"listWorkspaces","summary":"List Workspaces","description":"Lists the workspaces of the key's organization, newest first, cursor-paginated. For requests proxied by the qr3 dashboard (verified acting user), workspaces of other organizations where that user holds an active membership are included as well — that is how an invited team member sees the inviting team's workspace.","tags":["Workspaces"],"parameters":[{"name":"cursor","in":"query","description":"Opaque `next_cursor` from the previous page, passed back verbatim. Malformed values are rejected with 400.","schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":100}}],"responses":{"200":{"description":"Workspace list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Workspace"}},"meta":{"$ref":"#/components/schemas/CursorPaginationMeta"}}}}}},"400":{"description":"Query failed schema validation. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"post":{"operationId":"createWorkspace","summary":"Create Workspace","description":"Creates a workspace in the key's organization. Fails with 422 once the plan's workspace limit is reached.","tags":["Workspaces"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","slug"],"properties":{"name":{"type":"string","minLength":1,"maxLength":255},"slug":{"type":"string","minLength":2,"maxLength":63,"pattern":"^[a-z0-9-]+$","description":"Unique within the organization."},"default_locale":{"type":"string","minLength":2,"maxLength":10,"default":"de"},"approval_required":{"type":"boolean","default":false},"primary_color":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$","example":"#7c3aed"}}}}}},"responses":{"201":{"description":"Workspace created","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Workspace"},"meta":{"$ref":"#/components/schemas/ResponseMeta"}}}}}},"400":{"description":"Body failed schema validation. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Role is read-only","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"Slug already exists in this organization","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Plan workspace limit reached. The problem document carries the extension members `resource` and `limit`.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/workspaces/{id}":{"get":{"operationId":"getWorkspace","summary":"Get Workspace","tags":["Workspaces"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The workspace","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Workspace"},"meta":{"$ref":"#/components/schemas/ResponseMeta"}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Workspace not found in the key's organization","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"patch":{"operationId":"updateWorkspace","summary":"Update Workspace","tags":["Workspaces"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":255},"default_locale":{"type":"string","minLength":2,"maxLength":10},"approval_required":{"type":"boolean"},"primary_color":{"type":["string","null"],"pattern":"^#[0-9a-fA-F]{6}$"},"logo_url":{"type":["string","null"],"format":"uri"},"custom_domain":{"type":["string","null"],"maxLength":253}}}}}},"responses":{"200":{"description":"Updated workspace","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Workspace"},"meta":{"$ref":"#/components/schemas/ResponseMeta"}}}}}},"400":{"description":"Body failed schema validation. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Role is read-only","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Workspace not found in the key's organization","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"operationId":"archiveWorkspace","summary":"Archive Workspace","description":"Archives (soft-deletes) the workspace; its codes remain stored but become inaccessible. The workspace the calling API key belongs to cannot be archived — use a different workspace's key.","tags":["Workspaces"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Workspace archived","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","required":["id","archived"],"properties":{"id":{"type":"string"},"archived":{"type":"boolean"}}},"meta":{"$ref":"#/components/schemas/ResponseMeta"}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Role may not delete, or the id is the calling key's own workspace","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Workspace not found in the key's organization","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/members":{"get":{"operationId":"listMembers","summary":"List Members","description":"Lists the members of the key's organization, newest first, cursor-paginated. Readable by every role.","tags":["Members"],"parameters":[{"name":"cursor","in":"query","description":"Opaque `next_cursor` from the previous page, passed back verbatim. Malformed values are rejected with 400.","schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":100}}],"responses":{"200":{"description":"Member list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Member"}},"meta":{"$ref":"#/components/schemas/CursorPaginationMeta"}}}}}},"400":{"description":"Query failed schema validation. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"post":{"operationId":"addMember","summary":"Add Member","description":"Adds an existing qr3 user to the organization by email (case-insensitive). The API cannot create accounts — an invitee without a qr3 account answers 422; ask them to sign up first. Requires the org_admin role.","tags":["Members"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string","format":"email","maxLength":320},"role":{"type":"string","enum":["org_admin","ws_admin","editor","contributor","viewer","approver"],"default":"editor"}}}}}},"responses":{"201":{"description":"Member added (or a previously removed membership re-activated)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Member"},"meta":{"$ref":"#/components/schemas/ResponseMeta"}}}}}},"400":{"description":"Body failed schema validation. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Only organization admins can manage members","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"That user is already a member of the organization","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"No qr3 account exists for that email, or the plan's seat limit is reached (the plan-limit problem carries the extension members `resource` and `limit`)","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/members/{id}":{"patch":{"operationId":"updateMemberRole","summary":"Change Member Role","description":"Changes a member's role. The organization owner's role cannot be changed, and the last remaining org_admin cannot be demoted. Requires the org_admin role.","tags":["Members"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["role"],"properties":{"role":{"type":"string","enum":["org_admin","ws_admin","editor","contributor","viewer","approver"]}}}}}},"responses":{"200":{"description":"Updated member","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Member"},"meta":{"$ref":"#/components/schemas/ResponseMeta"}}}}}},"400":{"description":"Body failed schema validation. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Only organization admins can manage members, or the target is the organization owner","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Member not found in the key's organization","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Demoting the last remaining organization admin","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"operationId":"removeMember","summary":"Remove Member","description":"Removes a member (soft-delete). The organization owner cannot be removed, and the last remaining org_admin cannot be removed. Requires the org_admin role.","tags":["Members"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Member removed","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","required":["id","removed"],"properties":{"id":{"type":"string"},"removed":{"type":"boolean"}}},"meta":{"$ref":"#/components/schemas/ResponseMeta"}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Only organization admins can manage members, or the target is the organization owner","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Member not found in the key's organization","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Removing the last remaining organization admin","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/billing/plans":{"get":{"operationId":"listBillingPlans","summary":"List Plans","description":"All self-serve plans with monthly EUR prices, the complete enforced limits (`limits`), plan-gated features (`capabilities`) and short feature bullets. `meta` says what happens at each limit. Public — no authentication.","tags":["Billing"],"security":[],"responses":{"200":{"description":"Available plans","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/BillingPlan"}},"meta":{"type":"object","properties":{"limit_behavior":{"type":"object","description":"Per limit: `blocked` with the HTTP status of the refused request, or `counted` (never refused).","additionalProperties":{"type":"object","properties":{"effect":{"type":"string","enum":["blocked","counted"]},"status":{"type":["integer","null"]},"type":{"type":["string","null"],"description":"Problem type slug under https://docs.qr3.app/errors/."}}}},"scan_quota_warning_percents":{"type":"array","items":{"type":"integer"},"example":[80,90,100]},"support":{"type":"object","properties":{"channel":{"type":"string","enum":["email"]},"address":{"type":"string","format":"email"}}},"enterprise":{"type":"object","properties":{"price_from_eur_monthly":{"type":"integer","example":800}}}}}}}}}}}}},"/billing/checkout":{"post":{"operationId":"createBillingCheckoutSession","summary":"Start Stripe Checkout","description":"Creates a Stripe Checkout session for a paid plan and returns the redirect URL. Checkout changes the ORGANIZATION's subscription, so only org admins may call it.","tags":["Billing"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["plan"],"properties":{"plan":{"type":"string","enum":["pro","business","agency"]},"success_url":{"type":"string","format":"uri","default":"https://app.qr3.app/dashboard?upgraded=1"},"cancel_url":{"type":"string","format":"uri","default":"https://app.qr3.app/dashboard"}}}}}},"responses":{"200":{"description":"Checkout session created","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","required":["checkout_url"],"properties":{"checkout_url":{"type":"string","format":"uri","description":"Redirect the user here to complete payment."}}},"meta":{"$ref":"#/components/schemas/ResponseMeta"}}}}}},"400":{"description":"Body failed schema validation. The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Only organization admins can change the subscription","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"The key's organization no longer exists","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"The organization already has an active subscription. Stripe's subscription mode only creates, never switches, so a second checkout would bill twice — use GET /billing/portal to manage or cancel the existing one instead. A stored subscription that Stripe no longer knows is cleared and does not block.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"503":{"description":"Billing is not configured on this deployment (Stripe key or plan price missing)","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/billing/portal":{"get":{"operationId":"getBillingPortalSession","summary":"Open Stripe Customer Portal","description":"Creates a Stripe Customer Portal session (manage subscription, invoices, payment methods) and returns its URL. Requires an existing Stripe customer — an organization that never completed a checkout answers 404. Only org admins may call it.","tags":["Billing"],"responses":{"200":{"description":"Portal session created","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","required":["portal_url"],"properties":{"portal_url":{"type":"string","format":"uri"}}},"meta":{"$ref":"#/components/schemas/ResponseMeta"}}}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Only organization admins can manage the subscription","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No Stripe customer yet — the organization has never completed a checkout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"503":{"description":"Billing is not configured on this deployment","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/audit-logs":{"get":{"operationId":"listAuditLogs","summary":"List Audit Logs","description":"Audit trail of the authenticated WORKSPACE (not the whole organization), newest first, cursor-paginated. Entries are written by the API for mutating actions; `changes` carries the recorded before/after payload.","tags":["Audit Logs"],"parameters":[{"name":"cursor","in":"query","description":"Opaque `next_cursor` from the previous page, passed back verbatim. Malformed values are rejected with 400.","schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":100}},{"name":"resource_type","in":"query","schema":{"type":"string","enum":["codes","api_keys","webhooks","workspaces","organizations","billing"]}},{"name":"resource_id","in":"query","schema":{"type":"string"}},{"name":"action","in":"query","schema":{"type":"string"},"description":"Exact action match, e.g. `code.updated`."}],"responses":{"200":{"description":"Audit log entries","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AuditLog"}},"meta":{"$ref":"#/components/schemas/CursorPaginationMeta"}}}}}},"400":{"description":"Query failed schema validation (e.g. an unknown resource_type). The body is the raw Zod result, not an RFC 7807 problem document.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ZodValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"API key rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}}},"tags":[{"name":"QR Codes","description":"Create, manage and configure QR codes"},{"name":"Analytics","description":"Scan statistics and analytics"},{"name":"DPP","description":"EU Digital Product Passport creation, validation and assets"},{"name":"Webhooks","description":"Real-time event notifications"},{"name":"API Keys","description":"Manage API keys for your workspace"},{"name":"Account","description":"Account and workspace information"},{"name":"GS1","description":"GS1 identifier management (BYO-GTIN/GLN)"},{"name":"Files","description":"Host files and attach them to QR codes"},{"name":"Organizations","description":"Organization profile, plan limits and usage"},{"name":"Workspaces","description":"Create and manage the organization's workspaces"},{"name":"Members","description":"Team members and their roles"},{"name":"Billing","description":"Plans, Stripe checkout and customer portal"},{"name":"Audit Logs","description":"Audit trail of workspace activity"},{"name":"System","description":"Health checks and system endpoints"}]}