openapi: 3.0.3
tags:
  - name: Webhooks
    description: |
      Webhook endpoints receive HTTP POST notifications whenever events occur in your workspace.
      Each endpoint subscribes to one or more event types (or `*` for all). Requires API key
      scopes `webhooks:read` and/or `webhooks:write`.

      Endpoint caps are counted per `source`: up to 10 endpoints created directly
      (`source: "api"`, the default) plus up to 50 for each integration platform
      (`zapier`, `make`, `n8n`) — integrations register one endpoint per active
      Zap/scenario and manage their own.

      ## Event catalog

      | Type | Fires when |
      |------|-----------|
      | `qr.created` | A QR code is created via API or dashboard |
      | `qr.updated` | A QR code's name, target, design, tags or active state changes |
      | `qr.deleted` | A QR code is deleted (moved to the Trash) |
      | `qr.restored` | A QR code is restored from the Trash: via `POST /v1/qr-codes/{id}/restore`, the MCP `restore_qr_code` tool, or the dashboard's Trash. `data` is the full restored QR code. Dashboard restores also send `qr.updated`, as they did before this event existed |
      | `qr.scanned` | A dynamic QR code is scanned (see privacy guarantee below) |
      | `batch.completed` | All items in a batch job have finished processing |
      | `batch.failed` | A batch job failed globally (not item-level errors) |
      | `lead.captured` | A visitor submits a QRKit lead-capture form (carries consented contact data — see note below) |
      | `ping` | Emitted only via `POST /webhooks/{id}/test` to verify connectivity |

      ## Event envelope

      Every delivery sends a JSON body with the shape:

      ```json
      {
        "id": "evt_…",
        "object": "event",
        "type": "qr.scanned",
        "created_at": "2026-06-11T12:34:56.789Z",
        "data": { … }
      }
      ```

      Events are addressable via `GET /v1/events/{id}` for 30 days after creation.
      Re-fetch authoritative object state via the REST API if you need fields beyond what
      the event payload includes — event ordering is not guaranteed.

      ## Signature verification

      Every delivery includes the header:

      ```
      QRKit-Signature: t=1718099696,v1=a3b2c1…
      ```

      The signed string is `"{t}.{raw_request_body}"`. Compute `HMAC-SHA256` over that
      string using your endpoint secret (`whsec_…`). Compare the result against the `v1=`
      value using a **constant-time** equality function. Reject deliveries where the
      timestamp `t` differs from your server clock by more than **5 minutes** to prevent
      replay attacks.

      During the 24-hour grace period after a `rotate-secret` call the header will contain
      two `v1=` entries (one for each secret). Accept a delivery if **either** value matches.

      ## Retry schedule

      Failed deliveries (non-2xx response, timeout, or connection error) are retried up to
      7 attempts on an exponential back-off schedule:

      | Attempt | Delay after previous |
      |---------|---------------------|
      | 1 | immediate |
      | 2 | 1 minute |
      | 3 | 5 minutes |
      | 4 | 30 minutes |
      | 5 | 2 hours |
      | 6 | 6 hours |
      | 7 | 24 hours |

      After the 7th failed attempt the delivery moves to terminal `failed` status (~33 hours
      total window).

      ## Auto-disable

      After sustained delivery failures the endpoint is automatically set to `auto_disabled`
      and an email is sent to the workspace owner. Re-enable it by calling
      `PATCH /v1/webhooks/{id}` with `{ "status": "enabled" }` once your server is healthy
      (consecutive failure count resets to zero on re-enable).

      ## Delivery semantics

      - **At-least-once** — the same event may be delivered more than once; deduplicate by
        the event `id`.
      - **30-day retention** — events and delivery records are kept for 30 days.
      - **Ordering not guaranteed** — process events idempotently and re-fetch authoritative
        state via REST or `GET /v1/events` when you need a consistent view.

      ## qr.scanned privacy guarantee

      Scan events delivered to external endpoints contain **no IP address**, **no precise
      location** (no latitude, longitude, or postal code), and **no raw user-agent string**.
      The `data` payload is limited to: `country`, `city`, `device`, `os`, `browser`,
      `referrer`, and `language`.

      ## lead.captured contents

      Unlike `qr.scanned`, a `lead.captured` event carries the contact data the
      visitor **voluntarily submitted with consent** through a QRKit lead-capture
      form — typically `email`, `name`, and `phone`, plus any custom fields. Any
      scan context attached (`scan_context`) still follows the `qr.scanned`
      allowlist (country/city/device/os/browser — never IP, precise location, or
      raw user-agent). Treat the contact fields as PII per your privacy policy.

  - name: Events
    description: |
      Read-only log of dispatched events. An event is recorded when at least one enabled
      webhook endpoint is subscribed to its type at the time it occurs, then retained for
      30 days. Use this log to replay missed deliveries, audit delivered activity, or inspect
      the exact payload QRKit sent. Events that match no enabled endpoint are not recorded, so
      this is a log of what was dispatched, not a complete archive of every event. Requires the
      `webhooks:read` scope.
  - name: Account
    description: |
      Identify the workspace behind an API key. `GET /me` requires no scope — any valid
      key works — making it the connection-test endpoint for integrations (Zapier, Make,
      n8n) and a quick way to check which plan, scopes and environment a key carries.
info:
  title: QRKit API
  description: |
    Programmatic access to QRKit — create, manage and track QR codes.

    All requests are scoped to the **workspace** the API key belongs to:
    QR codes and folders created here appear in the QRKit dashboard and
    vice-versa.

    ## Dynamic vs static

    - **Dynamic** codes encode a short scan URL (`https://qrkit.to/{short_code}`,
      or your primary custom domain on Plus and up — see `domain_id`) that
      redirects to your destination or serves a hosted page — editable later,
      with scan analytics.
    - **Static** codes encode their payload directly — nothing to host, but
      the content is fixed once printed and there are no analytics.

    Which types are static, dynamic or either is listed on the `Target`
    schema.

    Dynamic codes have ids like `qr_123`; static codes `sqr_123`.
  version: '2026-02-08'
  contact:
    email: support@useqrkit.com
servers:
  - url: https://api.useqrkit.com/v1
    description: Production
  - url: http://localhost:3001/v1
    description: Local development
security:
  - bearerAuth: []

paths:
  /me:
    get:
      summary: Retrieve Account
      description: |
        Identifies the workspace behind the API key. Requires no scope — any valid key
        works. Integrations use this as the connection test and to build a
        human-readable connection label.
      operationId: getAccount
      tags: [Account]
      responses:
        '200':
          description: Account details
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Account' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /qr-codes:
    post:
      summary: Create QR Code
      description: |
        Creates a QR code, renders its image (SVG, stored on QRKit's CDN) and
        returns the full object. Requires the `qr:write` scope.

        Each target type takes structured fields (recommended) or a
        pre-encoded `destination` string. Dynamic codes count against your
        plan's QR limit; static codes do not.

        **GS1 Digital Link** codes use a different body, discriminated by
        `"type": "gs1"`: they carry an identifier (a GTIN and its qualifiers)
        instead of a `target`. See `CreateGs1QrCodeRequest`.
      operationId: createQrCode
      tags: [QR Codes]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/CreateQrCodeRequest'
                - $ref: '#/components/schemas/CreateGs1QrCodeRequest'
            examples:
              gs1-dynamic:
                summary: GS1 Digital Link on a QRKit scan host (editable after print)
                value:
                  type: gs1
                  mode: dynamic
                  name: Cocoa 200g
                  primary_key: { ai: '01', value: '09506000134352' }
                  qualifiers: [{ ai: '10', value: LOT1 }]
                  attributes: { '17': '270601' }
                  destination: https://brand.example/products/cocoa-200g
                  links:
                    - { linkType: 'gs1:pip', href: 'https://brand.example/products/cocoa-200g', title: Product information, hreflang: [en] }
                    - { linkType: 'gs1:instructions', href: 'https://brand.example/products/cocoa-200g/how-to', title: How to use }
                  overrides:
                    - { path: 10/LOT1, href: 'https://brand.example/recalls/2026-07' }
              gs1-static:
                summary: GS1 Digital Link on your OWN resolver (free, uncapped)
                value:
                  type: gs1
                  mode: static
                  domain: id.yourbrand.com
                  primary_key: { ai: '01', value: '09506000134352' }
              dynamic-url:
                summary: Dynamic URL code with UTM tracking
                value:
                  name: Summer campaign
                  target:
                    type: url
                    destination: https://example.com/landing
                    utm: { source: print, medium: qr, campaign: summer }
              static-wifi:
                summary: Static WiFi code
                value:
                  name: Office WiFi
                  target: { type: wifi, ssid: OfficeNet, password: hunter2, encryption: WPA }
              youtube:
                summary: YouTube channel (dynamic, shows as YouTube in the dashboard)
                value:
                  name: Channel
                  target: { type: youtube, destination: https://www.youtube.com/@yourchannel }
              whatsapp:
                summary: WhatsApp chat with a pre-filled message
                value:
                  name: Order on WhatsApp
                  target: { type: whatsapp, phone: '+4917012345678', message: Hi! I'd like to order }
              location:
                summary: Location pin that opens Waze
                value:
                  name: Store
                  target: { type: location, lat: 52.520008, lng: 13.404954, label: Main store, map_app: waze }
              appstores:
                summary: One code for both app stores
                value:
                  name: Get the app
                  target: { type: appstores, ios_url: 'https://apps.apple.com/app/id123456789', android_url: 'https://play.google.com/store/apps/details?id=com.example.app' }
              review:
                summary: Review page for Google and Tripadvisor
                value:
                  name: Table tent
                  target:
                    type: review
                    review_page:
                      title: Luigi's Pizzeria
                      links:
                        - { platform: google, url: 'https://g.page/r/CbXyz123/review' }
                        - { platform: tripadvisor, url: 'https://www.tripadvisor.com/UserReviewEdit-g1-d2' }
                      feedback_email: owner@luigis.example
              vcard:
                summary: Digital business card
                value:
                  name: Jane's card
                  target:
                    type: vcard
                    vcard: { your_name: Jane Doe, company_name: Acme, position: CEO, email: jane@acme.com, phone_mobile: "+40712345678" }
      responses:
        '201':
          description: QR code created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QrCode'
        '400': { $ref: '#/components/responses/BadRequest' }
        '409': { $ref: '#/components/responses/Conflict' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    get:
      summary: List QR Codes
      description: |
        Lists dynamic and static QR codes in the workspace (newest first by
        default), with cursor pagination. Requires the `qr:read` scope.

        Deleted codes are excluded unless you ask for them with `status`:
        `trashed` lists the Trash, `paused` lists dynamic codes a plan
        downgrade paused. Both keep their ids and carry `deleted_at`, and both
        can be brought back with `POST /v1/qr-codes/{id}/restore`. Use
        `sort=deleted_at` to page through them by deletion time.

        Each code's `target.editable` says whether it can be re-pointed through
        `PATCH /v1/qr-codes/{id}`; codes that hold a page rather than a link
        (link and review pages, forms, menus, app-store and smart-URL codes,
        uploaded files) report `false` and carry their public hosted page URL
        in `target.destination`.
      operationId: listQrCodes
      tags: [QR Codes]
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - { name: cursor, in: query, schema: { type: string }, description: Opaque cursor from a previous response's `next_cursor`. }
        - { name: kind, in: query, schema: { type: string, enum: [dynamic, static, all], default: all } }
        - { name: folder_id, in: query, schema: { type: string }, description: Only codes in this folder (e.g. `fld_12`). }
        - name: status
          in: query
          schema: { type: string, enum: [active, inactive, all, trashed, paused], default: all }
          description: |
            `all`, `active` and `inactive` only ever return live (not deleted)
            codes; `inactive` only matches deactivated dynamic codes (the
            dashboard's Inactive filter). Nothing was deleted: re-activate
            one with `PATCH` and `is_active: true` rather than restoring it.
            `trashed` returns the Trash (dynamic and static). `paused` returns
            dynamic codes paused by a plan downgrade — they are not in the
            Trash and are never permanently deleted by emptying it.
        - name: sort
          in: query
          schema: { type: string, enum: [created_at, updated_at, name, scan_count, deleted_at], default: created_at }
          description: '`deleted_at` requires `status=trashed` or `status=paused` (`400` otherwise).'
        - { name: order, in: query, schema: { type: string, enum: [asc, desc], default: desc } }
        - { name: search, in: query, schema: { type: string }, description: Case-insensitive name search. }
      responses:
        '200':
          description: Paginated list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/List'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/QrCode' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /qr-codes/{id}:
    get:
      summary: Retrieve QR Code
      description: |
        Retrieve one QR code by id.

        `target.editable` tells you whether the code can be re-pointed through
        `PATCH /v1/qr-codes/{id}`. Codes authored in the QRKit dashboard — link
        pages, forms, menus, app-store and smart-URL codes, and uploaded
        file/audio/video codes — report `editable: false`; their
        `target.destination` is the **public hosted page URL** (the same value
        as `short_url`) rather than the stored document or file key, and
        `linkpage`, `form` and `menupage` additionally carry a small
        `target.summary`.
      operationId: getQrCode
      tags: [QR Codes]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: '`qr_…` (dynamic) or `sqr_…` (static).' }
      responses:
        '200':
          description: The QR code
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QrCode' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      summary: Update QR Code
      description: |
        Updates a QR code. Requires the `qr:write` scope.

        Dynamic codes: `name`, `target.destination`, `target.utm`, `design`,
        `folder_id`, `tags`, `is_active`. Changing the design re-renders the
        stored image on the code's own host (its custom domain, if it has one);
        changing the destination does **not** require reprinting — the short
        URL stays the same. `domain_id` cannot change after creation (`422` for
        a different value; the current one is accepted): the host is printed
        in the QR.

        Dynamic `wifi`/`email`/`sms`/`event` codes are updated with their
        structured fields (`target.ssid`/`password`/`encryption`,
        `target.email`/`subject`/`message`/`cc`/`bcc`,
        `target.phone`/`message`,
        `target.title`/`location`/`start`/`end`) rather than
        `target.destination`; omitted fields keep their current value.
        Dynamic `text` codes take `target.text`.

        The link types (`youtube` … `googleform`) take `target.destination`
        like `url`. `whatsapp` takes `target.phone` and/or `target.message`
        (an omitted one keeps its value) or a WhatsApp link in
        `target.destination`; `telegram` takes `target.destination` (a
        `@username`, phone or t.me link). Either can also be re-pointed to any
        other http(s) page. `location` takes `target.lat` + `target.lng`
        and/or `map_app`, `label`, `address` (omitted ones keep their value);
        a plain `target.destination` turns it into an ordinary redirect.

        Static codes: only `name`, `folder_id` and `design` (the payload is
        baked into the printed code).

        Dynamic GS1 codes: `destination` (the default link), `links`,
        `overrides`, plus `name`, `design`, `folder_id`, `tags` and
        `is_active` — all at the TOP level, not under `target`. The identifier
        (`primary_key`, `qualifiers`, `attributes`, `domain`, `domain_id`,
        `mode`, `gs1_key`) is printed on packaging and immutable: naming any of
        them returns `422` with `gs1_code: GS1_IDENTIFIER_IMMUTABLE` rather
        than silently ignoring the field. A dynamic GS1 code therefore reports
        `target.editable: false`, with `target.destination` set to its Digital
        Link URI. A **static** GS1 code bakes its Digital Link into the image
        and, like every static code, cannot be re-pointed.

        Codes that hold a page rather than a link — link pages (including review
        pages created through this API), forms, menus, app-store and smart-URL
        codes, and uploaded file/audio/video codes — store a
        document or an uploaded file rather than a destination. They are
        reported with `target.editable: false` (and `target.destination` set to
        their public hosted page URL). Sending `target` for one of those is
        refused with `409 target_not_editable` and nothing in the request is
        applied; edit them in the dashboard. Their other fields (`name`,
        `folder_id`, `tags`, `design`, `is_active`) update normally.
      operationId: updateQrCode
      tags: [QR Codes]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 255 }
                target:
                  type: object
                  properties:
                    destination: { type: string, description: 'New destination (dynamic codes only). Rejected for dynamic wifi/email/sms/event — use their structured fields below.' }
                    text: { type: string, maxLength: 4000, description: New text (dynamic text codes). }
                    email: { type: string, format: email, description: New recipient (dynamic email codes). }
                    subject: { type: string, maxLength: 500, description: New subject (dynamic email codes). }
                    message: { type: string, maxLength: 2000, description: 'New body (dynamic email/sms codes) or pre-filled chat message (whatsapp, max 500).' }
                    cc: { type: string, maxLength: 500, description: New cc recipients (dynamic email codes). }
                    bcc: { type: string, maxLength: 500, description: New bcc recipients (dynamic email codes). }
                    phone: { type: string, maxLength: 50, description: 'New number in international format, e.g. +15551234567 (dynamic sms and whatsapp codes).' }
                    ssid: { type: string, maxLength: 100, description: New network name (dynamic wifi codes). }
                    password: { type: string, maxLength: 200, description: New password (dynamic wifi codes). }
                    encryption: { type: string, enum: [WPA, WEP, nopass], description: New security type (dynamic wifi codes). }
                    title: { type: string, maxLength: 300, description: New event title (dynamic event codes). }
                    location: { type: string, maxLength: 500, description: New venue (dynamic event codes). }
                    start: { type: string, maxLength: 50, description: 'New start datetime, ISO 8601 (dynamic event codes).' }
                    end: { type: string, maxLength: 50, description: 'New end datetime, ISO 8601 (dynamic event codes).' }
                    lat: { type: number, minimum: -90, maximum: 90, description: 'location codes: new latitude (send with lng).' }
                    lng: { type: number, minimum: -180, maximum: 180, description: 'location codes: new longitude (send with lat).' }
                    map_app: { type: string, enum: [auto, google, apple, waze], description: 'location codes: which map app a scan opens.' }
                    label: { type: string, maxLength: 80, description: 'location codes: pin name (used by Apple Maps); empty string clears it.' }
                    address: { type: string, maxLength: 200, description: 'location codes: street address; empty string clears it.' }
                    utm: { $ref: '#/components/schemas/Utm' }
                design: { $ref: '#/components/schemas/Design' }
                folder_id: { type: string, nullable: true, description: 'Folder id, or null to remove from its folder.' }
                tags: { type: array, items: { type: string }, maxItems: 10 }
                is_active:
                  type: boolean
                  description: |
                    Pause/resume scanning (dynamic codes only). Turning a
                    paused code back on (`true` on a code that is off) takes
                    one of the plan's QR code slots — one GS1 slot for a
                    dynamic GS1 code — so it is refused with `422` and
                    `reason: PLAN_LIMIT` when the plan is full, and nothing
                    else in the request is applied. A GS1 code whose custom
                    domain was removed cannot be turned back on (`409`,
                    `reason: DOMAIN_RETIRED`).
                destination: { type: string, format: uri, description: 'GS1 codes only — the new default link (`gs1:defaultLink`). Must be https.' }
                links: { type: array, items: { $ref: '#/components/schemas/Gs1Link' }, maxItems: 16, description: 'GS1 codes only — replaces the whole link list.' }
                overrides: { type: array, items: { $ref: '#/components/schemas/Gs1Override' }, maxItems: 500, description: 'GS1 codes only — replaces the whole override list.' }
      responses:
        '200':
          description: Updated QR code
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QrCode' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: '`target` is not editable for this QR code''s type (code `target_not_editable`), or `is_active: true` on a GS1 code whose custom domain was removed (`reason: DOMAIN_RETIRED`). The whole request was refused.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      summary: Delete QR Code
      description: |
        Moves the QR code to the Trash. It disappears from the dashboard's
        code list and from `GET /v1/qr-codes` immediately, and a dynamic
        code's redirect stops working. Nothing is erased: the code keeps its
        id, short URL, design and scan history, shows up in
        `GET /v1/qr-codes?status=trashed`, and can be brought back with
        `POST /v1/qr-codes/{id}/restore` (or from the dashboard's Trash).
        Permanent deletion is only possible from the dashboard. Emits
        `qr.deleted`. Requires the `qr:write` scope.
      operationId: deleteQrCode
      tags: [QR Codes]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: The code is in the Trash.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeleteResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /qr-codes/{id}/restore:
    post:
      summary: Restore QR Code
      description: |
        Restores a QR code from the Trash — or a dynamic code a plan
        downgrade paused — exactly as it was: same id, short URL, design,
        folder and scan history. A restored dynamic code is active again, so
        its redirect works for every copy already printed. No request body.
        Requires the `qr:write` scope. Emits `qr.restored` with the restored
        code as `data`.

        Find restorable codes with `GET /v1/qr-codes?status=trashed` (or
        `status=paused`).

        **Plan limits.** A dynamic code takes one of the plan's QR code
        slots when restored, so the restore is refused with `422` and
        `reason: PLAN_LIMIT` (plus `limit`, `current_count` and `plan_id`)
        when the plan is full. A dynamic GS1 Digital Link code counts against
        the plan's separate GS1 allowance instead (the error also carries
        `gs1_code: PLAN_LIMIT`), and needs a plan that includes the GS1
        resolver (`403`, `gs1_code: NOT_ENTITLED`). Its identifier needs no
        check: a code in the Trash keeps holding it, so nobody else can have
        registered it meanwhile. Static codes have no limit. Free-plan API
        keys manage static codes only, so restoring a dynamic code with one
        is `403`.

        **Concurrency.** Restores, creates and re-activations through the
        API, and restores and re-activations in the dashboard, are
        serialized per workspace, so parallel requests cannot go past the
        plan's limit — each one counts the slots the others already took.
        (A code created in the dashboard at the same moment is not part of
        that serialization.)

        **Not in the Trash.** Two `409 conflict` answers, which a client
        must tell apart by `reason`:
        - `NOT_IN_TRASH` — the code is **live** (`is_active: true`). There
          is nothing to restore; a client retrying a restore whose response
          it lost can treat this as success.
        - `DEACTIVATED` — the code was switched off (`is_active: false`) but
          never deleted. **Not** success: its redirect is still off.
          Re-activate it with `PATCH` and `is_active: true`.

        An id that does not exist, or belongs to another workspace, is
        `404` — and so is `GET /v1/qr-codes/{id}` for a code in the Trash;
        find those with `GET /v1/qr-codes?status=trashed`.

        **Removed custom domain.** A dynamic GS1 code whose custom domain
        has been removed (the hostname is gone for good) cannot be restored:
        `409` with `reason: DOMAIN_RETIRED`.
      operationId: restoreQrCode
      tags: [QR Codes]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: '`qr_…` or `sqr_…`, as returned while the code was live or by `GET /v1/qr-codes?status=trashed`.' }
      responses:
        '200':
          description: The restored QR code (`deleted_at` is `null`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QrCode' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: 'The code is not in the Trash (`reason: NOT_IN_TRASH`), or it is a GS1 code whose custom domain was removed (`reason: DOMAIN_RETIRED`).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: conflict
                  message: QR code is not in the Trash — only deleted codes can be restored.
                  reason: NOT_IN_TRASH
                  doc_url: https://docs.useqrkit.com/errors#conflict
                  request_id: req_abc123
        '422':
          description: 'The plan has no room for another dynamic code (`reason: PLAN_LIMIT`).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: unprocessable_entity
                  message: QR code limit reached for your starter plan (12 QR codes). Delete or deactivate a dynamic code, or upgrade, to restore this one.
                  reason: PLAN_LIMIT
                  limit: 12
                  current_count: 12
                  plan_id: starter
                  doc_url: https://docs.useqrkit.com/errors#unprocessable_entity
                  request_id: req_abc123
        '429': { $ref: '#/components/responses/RateLimited' }

  /qr-codes/{id}/download:
    get:
      summary: Download QR Image
      description: Regenerates the QR image at the requested size and format from the stored design. Requires the `qr:read` scope.
      operationId: downloadQrCode
      tags: [QR Codes]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: format, in: query, schema: { type: string, enum: [svg, png], default: svg } }
        - { name: size, in: query, schema: { type: integer, minimum: 100, maximum: 4000, default: 1000 }, description: Width/height in pixels. }
      responses:
        '200':
          description: Image binary
          content:
            image/svg+xml: { schema: { type: string, format: binary } }
            image/png: { schema: { type: string, format: binary } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /qr-codes/{id}/linkset:
    get:
      summary: GS1 Linkset
      description: |
        The RFC 9264 linkset for a dynamic GS1 Digital Link code: every link the
        resolver would serve for this identifier, with one anchor per lot/serial
        override. Requires the `qr:read` scope.

        Returned as `application/linkset+json` **without** this API's `{ data }`
        envelope, and with a `Link` header pointing at the GS1 JSON-LD context —
        the bytes are the same ones QRKit's resolver serves, so you can hand
        them straight to a validator or stand the record up on your own
        resolver. Link relations are full GS1 vocabulary IRIs.

        Every anchor carries the links registered ON it plus everything
        inherited from the level above (section 2.5.10): an override for
        `10/LOT1` publishes the GTIN's links as well as its own, most general
        first, with a repeated `href` listed once per relation. The default
        link is published twice — under `gs1:defaultLink`, and under the
        descriptive `gs1:pip` relation a client can ask for by name. Non-https
        destinations are dropped rather than served.

        Static GS1 codes resolve on your own host and QRKit holds no link record
        for them, so they return `422`; so does a non-GS1 code.
      operationId: getQrCodeLinkset
      tags: [QR Codes]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: '`qr_…` — a dynamic GS1 code.' }
      responses:
        '200':
          description: The linkset
          headers:
            Link:
              schema: { type: string }
              description: 'JSON-LD context: `<https://ref.gs1.org/standards/resolver/linkset-context>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"`'
          content:
            application/linkset+json:
              schema: { $ref: '#/components/schemas/Linkset' }
              example:
                linkset:
                  - anchor: 'https://qrkit.to/01/09506000134352'
                    'https://ref.gs1.org/voc/pip':
                      - { href: 'https://brand.example/products/cocoa-200g', title: Product information, hreflang: [en] }
                    'https://ref.gs1.org/voc/defaultLink':
                      - { href: 'https://brand.example/products/cocoa-200g', title: Cocoa 200g }
                  # The lot INHERITS the GTIN's links and adds its own; its
                  # default link is the recall notice, published under `pip`
                  # too. The inherited `pip` entry is listed once, ahead of
                  # the lot's own.
                  - anchor: 'https://qrkit.to/01/09506000134352/10/LOT1'
                    'https://ref.gs1.org/voc/pip':
                      - { href: 'https://brand.example/products/cocoa-200g', title: Product information, hreflang: [en] }
                      - { href: 'https://brand.example/recalls/2026-07', title: Cocoa 200g }
                    'https://ref.gs1.org/voc/defaultLink':
                      - { href: 'https://brand.example/recalls/2026-07', title: Cocoa 200g }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /qr-codes/{id}/analytics:
    get:
      summary: QR Code Analytics
      description: |
        Scan analytics for a dynamic QR code: totals, daily series and
        top breakdowns. Requires the `analytics:read` scope. Static codes
        have no scan tracking and return `422`.
      operationId: getQrCodeAnalytics
      tags: [QR Codes]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: from, in: query, schema: { type: string, format: date }, description: Include scans on/after this date. }
        - { name: to, in: query, schema: { type: string, format: date }, description: Include scans on/before this date. }
      responses:
        '200':
          description: Analytics summary
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Analytics' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /batches:
    post:
      summary: Create Batch
      description: |
        Creates up to 1,000 QR codes asynchronously. Returns `202` immediately;
        poll `GET /batches/{id}` for progress. Available on Plus, Pro, Ultra
        and Agency plans. Requires the `qr:write` scope.

        If processing is interrupted (for example by a server restart) and the
        batch makes no progress for 15 minutes, it is finished as it stands:
        items not yet created are marked `error` ("submit it again"), the
        batch gets its final status, and `batch.completed` / `batch.failed` is
        sent once (for batches under a week old) — so a batch never stays
        `processing` forever.

        Items use the same `target` rules as single creation. A shared
        `design` (or a saved dashboard template via `template_id`) is applied
        to every item. Dynamic items count against your plan's QR limit —
        capacity is checked before the batch is accepted.

        Items may be **GS1 Digital Link** records (`"type": "gs1"`, the same
        body as single creation) — the way to register a serialized run. As
        with every other item, a per-item `design` is ignored: the batch-level
        `design` (or `template_id`) styles the whole batch. Each item is
        validated and created independently, so a GS1 refusal (a taken or
        retired identifier) fails only that item: its reason appears as that
        item's `error` in `GET /batches/{id}`, and the rest of the batch still
        completes. GS1 items in `mode: "dynamic"` count against your plan's
        separate **GS1 code** limit, not the QR limit; `mode: "static"` ones are
        uncapped.
      operationId: createBatch
      tags: [Batches]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items:
                    oneOf:
                      - type: object
                        required: [target]
                        properties:
                          name: { type: string, maxLength: 255 }
                          type: { type: string, enum: [dynamic, static] }
                          target: { $ref: '#/components/schemas/Target' }
                          folder_id: { type: string }
                          tags: { type: array, items: { type: string }, maxItems: 10 }
                          domain_id: { type: string, nullable: true, description: 'As on single create. Every item''s domain is checked before the batch is accepted, and items without one all get the primary domain as it was when the batch was submitted.' }
                      # A GS1 item — the single-create body. Its `design`
                      # property, if sent, is ignored: `design` is shared
                      # across the whole batch (see the sibling `design`
                      # below). OpenAPI cannot express "$ref minus one
                      # property", so this is stated rather than schema'd.
                      - $ref: '#/components/schemas/CreateGs1QrCodeRequest'
                design: { $ref: '#/components/schemas/Design' }
                template_id: { type: string, description: Saved dashboard template id — overrides `design`. }
      responses:
        '202':
          description: Batch accepted for processing
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Batch' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    get:
      summary: List Batches
      operationId: listBatches
      tags: [Batches]
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        '200':
          description: Paginated list of batches
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/List'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Batch' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /batches/{id}:
    get:
      summary: Get Batch
      description: Batch status with item-level results. Poll until `status` is `completed`.
      operationId: getBatch
      tags: [Batches]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: Batch id (`batch_…`). }
      responses:
        '200':
          description: Batch with items
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Batch' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /batches/{id}/download:
    get:
      summary: Download Batch as ZIP
      description: ZIP archive of all completed QR images in the batch. Returns `422` while the batch is still processing.
      operationId: downloadBatch
      tags: [Batches]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: format, in: query, schema: { type: string, enum: [svg, png], default: svg } }
        - { name: size, in: query, schema: { type: integer, minimum: 100, maximum: 4000, default: 1000 }, description: PNG size (ignored for SVG). }
      responses:
        '200':
          description: ZIP archive
          content:
            application/zip: { schema: { type: string, format: binary } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /folders:
    post:
      summary: Create Folder
      operationId: createFolder
      tags: [Folders]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 255 }
      responses:
        '201':
          description: Folder created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Folder' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
    get:
      summary: List Folders
      operationId: listFolders
      tags: [Folders]
      responses:
        '200':
          description: All folders in the workspace
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/List'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Folder' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /folders/{id}:
    patch:
      summary: Update Folder
      operationId: updateFolder
      tags: [Folders]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: Folder id (`fld_…`). }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 255 }
      responses:
        '200':
          description: Updated folder
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Folder' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      summary: Delete Folder
      description: Deletes the folder. The QR codes inside are kept — they just leave the folder.
      operationId: deleteFolder
      tags: [Folders]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Deletion confirmed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeleteResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /tokens:
    post:
      summary: Create API Key
      description: |
        Creates an API key. Authenticated with a **Clerk session token**
        (dashboard sign-in), not an API key — most users should create keys
        from the [dashboard](https://useqrkit.com/dashboard/api-keys) instead.
        The full key is returned **once**; only its prefix is retrievable
        later. Available on every plan. A workspace can hold at most 10 active
        (non-revoked, non-expired) keys — 1 on the Free plan.
      operationId: createToken
      tags: [Tokens]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, scopes]
              properties:
                name: { type: string, maxLength: 255 }
                scopes:
                  type: array
                  minItems: 1
                  items: { type: string, enum: ['qr:read', 'qr:write', 'analytics:read', 'tokens:manage', 'webhooks:read', 'webhooks:write'] }
                environment: { type: string, enum: [live, test], default: live }
                expires_in_days: { type: integer, minimum: 1, maximum: 365 }
      responses:
        '201':
          description: API key created (full key shown once)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiKeyWithSecret' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422':
          description: Active-key cap reached (10 per workspace; 1 on the Free plan)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
    get:
      summary: List API Keys
      description: Lists the workspace's active API keys (prefixes only). Clerk session auth.
      operationId: listTokens
      tags: [Tokens]
      responses:
        '200':
          description: List of API keys
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/List'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/ApiKey' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /tokens/{id}:
    delete:
      summary: Revoke API Key
      description: Immediately and permanently invalidates the key. Clerk session auth.
      operationId: revokeToken
      tags: [Tokens]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: Key id (`key_…`). }
      responses:
        '200':
          description: Revocation confirmed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeleteResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Key already revoked
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /tokens/rotate:
    post:
      summary: Rotate API Key
      description: Revokes the old key and returns a new one with identical name, scopes, environment and expiry. Clerk session auth.
      operationId: rotateToken
      tags: [Tokens]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key_id]
              properties:
                key_id: { type: string }
      responses:
        '201':
          description: New API key (full key shown once)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiKeyWithSecret' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks:
    post:
      summary: Create Webhook Endpoint
      description: |
        Registers a new webhook endpoint. The signing secret (`secret`) is returned
        **only on this response** and on `rotate-secret` — store it securely.
        Requires the `webhooks:write` scope. Endpoint caps are counted per `source`:
        10 for `api` (the default), 50 for each integration source.
      operationId: createWebhook
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateWebhookRequest'
      responses:
        '201':
          description: Webhook endpoint created (full secret shown once)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpoint' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    get:
      summary: List Webhook Endpoints
      description: Returns all webhook endpoints for the workspace, across all sources. The `secret` field is masked. Requires the `webhooks:read` scope.
      operationId: listWebhooks
      tags: [Webhooks]
      responses:
        '200':
          description: List of webhook endpoints
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/List'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/WebhookEndpoint' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/{id}:
    get:
      summary: Retrieve Webhook Endpoint
      description: Returns a single webhook endpoint. The `secret` field is masked. Requires the `webhooks:read` scope.
      operationId: getWebhook
      tags: [Webhooks]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: 'Webhook endpoint id (`whe_…`).' }
      responses:
        '200':
          description: The webhook endpoint
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpoint' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      summary: Update Webhook Endpoint
      description: |
        Updates one or more fields of a webhook endpoint. All fields are optional —
        supply only the ones you want to change. Set `status` to `enabled` to
        re-enable an endpoint that was manually or automatically disabled (resets the
        consecutive failure count). Requires the `webhooks:write` scope.
      operationId: updateWebhook
      tags: [Webhooks]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateWebhookRequest' }
      responses:
        '200':
          description: Updated webhook endpoint
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpoint' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      summary: Delete Webhook Endpoint
      description: Permanently deletes the endpoint and stops all pending deliveries. Requires the `webhooks:write` scope.
      operationId: deleteWebhook
      tags: [Webhooks]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Deletion confirmed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeleteResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/{id}/rotate-secret:
    post:
      summary: Rotate Webhook Secret
      description: |
        Generates a new signing secret for the endpoint and returns it (full value,
        shown once). The previous secret remains valid as a second `v1=` signature
        entry for 24 hours to allow zero-downtime rotation.
        Requires the `webhooks:write` scope.
      operationId: rotateWebhookSecret
      tags: [Webhooks]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Endpoint with new secret (full value shown once)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpoint' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/{id}/test:
    post:
      summary: Send Test Webhook
      description: |
        Sends a `ping` event to this endpoint synchronously through the real signing and
        delivery pipeline, and returns the resulting `WebhookDelivery` plus a `succeeded`
        boolean. The endpoint must have `status: enabled`. Requires the `webhooks:write` scope.
      operationId: testWebhook
      tags: [Webhooks]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Delivery result
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/WebhookDelivery'
                  - type: object
                    properties:
                      succeeded:
                        type: boolean
                        description: '`true` if the endpoint responded with a 2xx status code.'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/{id}/deliveries:
    get:
      summary: List Webhook Deliveries
      description: Returns the delivery log for an endpoint (newest first). Requires the `webhooks:read` scope.
      operationId: listWebhookDeliveries
      tags: [Webhooks]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - { name: cursor, in: query, schema: { type: string }, description: Opaque cursor from a previous response's `next_cursor`. }
        - { name: status, in: query, schema: { type: string, enum: [pending, succeeded, failed] }, description: Filter by delivery status. }
      responses:
        '200':
          description: Paginated list of deliveries
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/List'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/WebhookDelivery' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /events:
    get:
      summary: List Events
      description: |
        Returns the workspace event archive (newest first), with cursor pagination.
        Events are retained for 30 days. Requires the `webhooks:read` scope.
      operationId: listEvents
      tags: [Events]
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - { name: cursor, in: query, schema: { type: string }, description: Opaque cursor from a previous response's `next_cursor`. }
        - { name: type, in: query, schema: { type: string, enum: [qr.created, qr.updated, qr.deleted, qr.restored, qr.scanned, batch.completed, batch.failed, lead.captured, ping] }, description: Filter by event type. }
      responses:
        '200':
          description: Paginated list of events
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/List'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Event' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /events/{id}:
    get:
      summary: Retrieve Event
      description: Returns a single event by id. Events are retained for 30 days. Requires the `webhooks:read` scope.
      operationId: getEvent
      tags: [Events]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: 'Event id (`evt_…`).' }
      responses:
        '200':
          description: The event
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Event' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: 'API key in the Authorization header: `Bearer qr_live_…` or `Bearer qr_test_…`. Token endpoints take a Clerk session JWT instead.'

  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing or invalid credentials — including a revoked or expired key, and a key whose workspace has been deleted.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: Missing scope, the plan does not include this feature, or the workspace's billing is paused (API access is off until billing restarts; the message gives the date).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unprocessable:
      description: Request understood but cannot be processed (e.g. plan limit reached)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Conflict:
      description: |
        The request collides with the current state of a resource. A restore
        of a code that is not in the Trash is `reason: NOT_IN_TRASH` (it is
        live) or `reason: DEACTIVATED` (switched off, not deleted), and of a
        GS1 code whose custom domain was removed `reason: DOMAIN_RETIRED`. The rest
        are GS1: the identifier is registered on this scan host
        (`gs1_code: GS1_KEY_TAKEN`) or was permanently deleted and tombstoned
        (`gs1_code: GS1_KEY_RETIRED`).

        A third case is your own doing and your own to fix: the identifier is
        held by one of *your* codes that is in the Trash (`gs1_code:
        GS1_KEY_IN_TRASH`). A trashed row still holds the key — restore that
        code, or delete it permanently, and the identifier is free again.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Rate limit or monthly quota exceeded — see Retry-After header
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Utm:
      type: object
      description: UTM parameters baked into the destination URL (dynamic url codes only; ignored for every other type).
      properties:
        source: { type: string }
        medium: { type: string }
        campaign: { type: string }
        term: { type: string }
        content: { type: string }

    Vcard:
      type: object
      required: [your_name]
      properties:
        your_name: { type: string }
        company_name: { type: string }
        position: { type: string }
        website: { type: string }
        phone_mobile: { type: string }
        phone_work: { type: string }
        phone: { type: string }
        fax: { type: string }
        email: { type: string }
        country: { type: string }
        state: { type: string }
        street: { type: string }
        city: { type: string }
        zipcode: { type: string }
        personal_desc: { type: string }

    Target:
      type: object
      required: [type]
      description: |
        What the QR code points to. Each type takes structured fields; the
        link types and most static types also accept a pre-encoded
        `destination` string if you prefer to encode yourself.

        | type | Fields | Static / dynamic |
        |---|---|---|
        | `url` | `destination`, optional `utm` | Your choice, dynamic by default |
        | `youtube`, `instagram`, `facebook`, `tiktok`, `x`, `pinterest`, `linktree`, `googleform` | `destination` (http/https) | Your choice, dynamic by default. Shown in the dashboard with the platform's label and icon |
        | `whatsapp` | `phone` (international format, no leading 0) + optional `message` (max 500), or a wa.me / chat.whatsapp.com link in `destination` | Your choice, dynamic by default |
        | `telegram` | `destination`: a `@username`, an international phone number, or a t.me link | Your choice, dynamic by default |
        | `location` | `lat`, `lng`, optional `label`, `address`, `map_app` | Your choice, dynamic by default. A static code is a fixed Google Maps link; a dynamic code opens the app set in `map_app` (`auto`: Apple Maps on iPhone/iPad, Google Maps elsewhere) |
        | `appstores` | one or more of `ios_url`, `android_url`, `harmony_url` | Always dynamic: a hosted page sends each phone to its own store. Edit its links in the dashboard |
        | `review` | `review_page` | Always dynamic: a hosted page with one button per review site. Returned as `type: "linkpage"`; edit its content in the dashboard's Link Page editor (`PATCH target` is refused) |
        | `text`, `email`, `sms`, `wifi`, `event` | see fields below | Static by default; pass `type: "dynamic"` for a hosted page |
        | `vcard` | `vcard` | Always dynamic |
      properties:
        type:
          type: string
          enum: [url, text, email, sms, wifi, event, vcard, youtube, instagram, facebook, tiktok, x, pinterest, linktree, googleform, whatsapp, telegram, location, appstores, review]
        destination:
          type: string
          description: 'url and the link types: the link. telegram: username, phone or link. whatsapp: a wa.me / chat.whatsapp.com link (instead of phone). Other types: optional pre-encoded payload (e.g. `WIFI:T:WPA;S:…;;`).'
        text: { type: string, description: 'text: the content to encode.' }
        email: { type: string, description: 'email: recipient address.' }
        subject: { type: string, description: 'email: subject line.' }
        message: { type: string, description: 'email/sms: body text. whatsapp: pre-filled chat message (max 500 characters).' }
        cc: { type: string, description: 'email: cc recipients — one or more addresses separated by , or ;.' }
        bcc: { type: string, description: 'email: bcc recipients — one or more addresses separated by , or ;.' }
        phone: { type: string, description: 'sms and whatsapp: phone number. For whatsapp, and for a dynamic sms code, it must be in international format (e.g. +15551234567) — WhatsApp refuses national numbers with a leading 0.' }
        ssid: { type: string, description: 'wifi: network name.' }
        password: { type: string, description: 'wifi: network password.' }
        encryption: { type: string, enum: [WPA, WEP, nopass], description: 'wifi: security type (default WPA).' }
        title: { type: string, description: 'event: event title.' }
        location: { type: string, description: 'event: venue.' }
        start: { type: string, description: 'event: start datetime (ISO 8601).' }
        end: { type: string, description: 'event: end datetime (ISO 8601).' }
        vcard: { $ref: '#/components/schemas/Vcard' }
        lat: { type: number, minimum: -90, maximum: 90, description: 'location: latitude of the pin.' }
        lng: { type: number, minimum: -180, maximum: 180, description: 'location: longitude of the pin.' }
        map_app: { type: string, enum: [auto, google, apple, waze], description: 'location, dynamic codes: which map app a scan opens. `auto` (default): Apple Maps on iPhone/iPad, Google Maps elsewhere.' }
        label: { type: string, maxLength: 80, description: 'location: pin name (used by Apple Maps).' }
        address: { type: string, maxLength: 200, description: 'location: street address, for reference.' }
        ios_url: { type: string, description: 'appstores: App Store link.' }
        android_url: { type: string, description: 'appstores: Google Play link.' }
        harmony_url: { type: string, description: 'appstores: Huawei AppGallery link.' }
        review_page: { $ref: '#/components/schemas/ReviewPage' }
        utm: { $ref: '#/components/schemas/Utm' }

    ReviewPage:
      type: object
      required: [title, links]
      description: A hosted "How did we do?" page with one button per review site.
      properties:
        title: { type: string, maxLength: 200, description: 'Page heading — the business name.' }
        description: { type: string, maxLength: 500 }
        card_title: { type: string, maxLength: 80, description: 'Heading of the review card. Defaults to "How did we do?".' }
        links:
          type: array
          minItems: 1
          maxItems: 4
          items:
            type: object
            required: [platform, url]
            properties:
              platform: { type: string, enum: [google, yelp, tripadvisor, trustpilot, custom] }
              url: { type: string, description: 'http(s). For Google, the "Ask for reviews" link from your Business Profile (g.page/r/…/review) or https://search.google.com/local/writereview?placeid=<Place ID>.' }
        feedback_email:
          type: string
          maxLength: 120
          description: Optional address for a "Send private feedback" row, shown to every visitor alongside the public review buttons.

    Design:
      type: object
      description: Visual customization. Stored with the code, so the dashboard shows exactly what the API created.
      properties:
        error_correction:
          type: string
          enum: [L, M, Q, H]
          description: |
            Error-correction level. Omit it for the automatic level, the same
            one the dashboard uses: **H** (30% recovery) when the code has a
            logo, **Q** (25%) otherwise. A logo code whose content is too long
            to fit at H stays at Q. A level you set explicitly is kept on every
            later re-render.

            **`L` is refused on a GS1 code** (`422`, `gs1_code:
            GS1_ERROR_CORRECTION_TOO_LOW`) — a Digital Link is printed on
            packaging and read by a fixed point-of-sale imager, where 7%
            recovery is not enough. Refused rather than quietly upgraded: a
            package is printed once, so an API that returned success for a
            design it did not honour would be found out on the print run.
        colors:
          type: object
          properties:
            foreground: { type: string, example: '#000000' }
            background: { type: string, example: '#ffffff' }
        gradient:
          type: object
          description: Foreground gradient (overrides colors.foreground).
          properties:
            start: { type: string, example: '#005ae0' }
            end: { type: string, example: '#00c2ff' }
            type:
              type: string
              enum: [linear-top-to-bottom, linear-left-to-right, linear-top-left-to-bottom-right, linear-top-right-to-bottom-left, radial]
              default: linear-top-to-bottom
        shapes:
          type: object
          properties:
            body: { type: string, enum: [square, dots, rounded, extra-rounded, classy, classy-rounded], default: square }
            eye_frame: { type: string, enum: [square, dot, extra-rounded], default: square }
            eye_ball: { type: string, enum: [square, dot, extra-rounded], default: square }
        eye_colors:
          type: object
          description: Custom colors for the corner eyes.
          properties:
            frame: { type: string, example: '#005ae0' }
            ball: { type: string, example: '#004bb8' }
        logo:
          type: object
          properties:
            url: { type: string, format: uri, description: 'http(s) image URL (PNG/JPG/SVG, max 2MB). Fetched once and embedded.' }
            clear_background: { type: boolean, default: false, description: Hide QR dots behind the logo. }
        transparent_background: { type: boolean, default: false }

    Gs1Link:
      type: object
      required: [linkType, href, title]
      description: |
        One entry a resolver can return for this item.
      properties:
        linkType:
          type: string
          description: |
            A GS1 Web Vocabulary link type — bare (`pip`) or prefixed
            (`gs1:pip`) — or an absolute https IRI for a custom vocabulary.
            An invented bare word is rejected: no scanning app could interpret it.
          example: gs1:pip
        href: { type: string, format: uri, description: 'Destination. Must be https — a Digital Link is scanned by consumer phones.' }
        title: { type: string, maxLength: 120, description: Shown verbatim in a resolver's HTML linkset page. }
        hreflang: { type: array, items: { type: string }, description: 'BCP-47 tags this link is for, e.g. ["en", "fr-CA"].' }
        type: { type: string, description: 'MIME type of the target, e.g. text/html.' }
        context: { type: array, items: { type: string }, description: GS1 context values (country/market) the link applies to. }

    Gs1Override:
      type: object
      required: [path, href]
      description: |
        A destination that replaces the row's default for ONE set of scanned
        qualifiers — the "recall this lot" primitive. A serialized run is one
        code with many overrides, not many codes.
      properties:
        path:
          type: string
          description: |
            The qualifier chain BELOW the code's own key — `10/LOT1`,
            `10/LOT1/21/SN9` — never the primary key again. Must be a legal
            qualifier chain for the primary key, in canonical order.
          example: 10/LOT1
        href: { type: string, format: uri, description: 'Destination for scans carrying this chain. Must be https.' }
        links: { type: array, items: { $ref: '#/components/schemas/Gs1Link' }, maxItems: 16 }

    CreateGs1QrCodeRequest:
      type: object
      required: [type, primary_key]
      description: |
        A GS1 Digital Link code. Discriminated by `type: "gs1"`, and carrying an
        IDENTIFIER instead of a `target`.

        **`mode: "static"`** bakes a Digital Link on *your own* resolver
        (`domain` is yours) into a static code. Free, uncapped, and QRKit never
        sees the scan. GTIN (AI 01) with qualifiers 22/10/21 and attribute 17.

        **`mode: "dynamic"`** (the default) registers the identifier on a QRKit
        scan host: the printed URI *is* the Digital Link, so a point-of-sale
        imager reads the GTIN straight out of the path offline, while the
        destination, link types and per-lot overrides stay editable after
        printing. Requires a plan with the GS1 resolver (Starter and above) and
        counts against your plan's separate **GS1 code** limit — a dynamic GS1
        row never consumes ordinary QR capacity, and deleting ordinary codes
        does not free one. Exceeding it is a `422` with `gs1_code: PLAN_LIMIT`.

        **Identifiers are immutable** (`primary_key`, `qualifiers`,
        `attributes`, the domain). They are printed on packaging; a later PATCH
        naming any of them returns `422 GS1_IDENTIFIER_IMMUTABLE`.

        **Plan note.** A single dynamic GS1 create is Starter and above, but
        `POST /v1/batches` is a **Plus and above** feature in its own right —
        so a Starter workspace registers identifiers one call at a time.
      properties:
        type: { type: string, enum: [gs1] }
        mode:
          type: string
          enum: [static, dynamic]
          default: dynamic
          description: See above.
        domain:
          type: string
          description: |
            **Required in `mode: "static"`** (omitting it is a `422` with
            `gs1_code: INVALID_KEY` naming `domain`), and meaningless in
            dynamic mode, where the host is QRKit's — use `domain_id`.

            The host your own resolver answers on, e.g.
            `id.yourbrand.com`. A hostname, optionally with a path prefix
            (`brand.example/products` prints
            `https://brand.example/products/01/<gtin>`), which the Digital Link
            URI syntax allows ahead of the AI segments. Normalised to https,
            lowercased, with any trailing slash and dot segments resolved.
          example: id.yourbrand.com
        domain_id:
          type: string
          nullable: true
          description: |
            dynamic mode only — one of your workspace's active custom domains
            (`dom_12` or `12`). **Omitted or `null`:** the default scan host —
            unlike ordinary codes, GS1 codes never follow the workspace's
            primary custom domain, because the host is part of the printed
            identifier. An unknown, inactive or foreign id, or any id on a plan
            without custom domains, silently falls back to the default scan
            host rather than failing: the response's `gs1.digital_link` always
            tells you which host was actually used.
            The host is also the identifier's uniqueness namespace, so the same
            GTIN may be registered once per host.
        primary_key:
          type: object
          required: [ai, value]
          description: 'The GS1 primary key. All 16 the standard defines are accepted in dynamic mode; static mode is GTIN (01) only.'
          properties:
            ai: { type: string, example: '01', description: 'Numeric application identifier: 01 (GTIN), 00 (SSCC), 414 (GLN), 8006, 8004, …' }
            value: { type: string, example: '09506000134352', description: 'The key value. A GTIN is validated (mod-10 check digit) and left-padded to 14 digits.' }
        qualifiers:
          type: array
          maxItems: 8
          description: |
            Key qualifiers for the primary key — for a GTIN: 22 (CPV), 10 (lot),
            21 (serial). Order does not matter in the request; they are stored
            and printed in the canonical order the standard requires. At most 8;
            no GS1 primary key defines more, and which ones are legal for a
            given key is validated against the standard's own table.
          items:
            type: object
            required: [ai, value]
            properties:
              ai: { type: string, example: '10' }
              value: { type: string, example: LOT1 }
        attributes:
          type: object
          additionalProperties: { type: string }
          description: |
            Data attributes baked into the PRINTED query string, e.g.
            `{"17": "270601"}` (expiry, YYMMDD). Attributes are not part of the
            identity — the resolver ignores them when matching — so two codes
            differing only here collide on the same identifier.
          example: { '17': '270601' }
        destination:
          type: string
          format: uri
          description: 'The default link (`gs1:defaultLink`) — where a scan goes when nothing more specific matches. Required in dynamic mode; must be https.'
        links: { type: array, items: { $ref: '#/components/schemas/Gs1Link' }, maxItems: 16, description: 'Everything except the default link. dynamic mode only.' }
        overrides: { type: array, items: { $ref: '#/components/schemas/Gs1Override' }, maxItems: 500, description: 'Per-lot / per-serial destinations. dynamic mode only.' }
        name: { type: string, maxLength: 255 }
        design: { $ref: '#/components/schemas/Design' }
        folder_id: { type: string }
        tags: { type: array, items: { type: string, maxLength: 50 }, maxItems: 10 }

    Gs1Record:
      type: object
      description: |
        Present on GS1 codes only. The identifier half is READ-ONLY (R4 — it is
        printed); `links` and `overrides` are the editable half.
      properties:
        mode: { type: string, enum: [static, dynamic] }
        key: { type: string, example: '01/09506000134352/10/LOT1', description: 'Canonical Digital Link path (dynamic only). Numeric AIs, no leading slash.' }
        digital_link: { type: string, format: uri, description: 'The URI the printed QR encodes — NOT `<host>/<short_code>`.' }
        primary_key:
          type: object
          properties:
            ai: { type: string }
            value: { type: string }
        qualifiers: { type: array, items: { type: object, properties: { ai: { type: string }, value: { type: string } } } }
        attributes: { type: object, additionalProperties: { type: string } }
        links: { type: array, items: { $ref: '#/components/schemas/Gs1Link' } }
        overrides: { type: array, items: { $ref: '#/components/schemas/Gs1Override' } }
        linkset_url:
          type: string
          format: uri
          description: 'Absolute URL of this code''s RFC 9264 linkset — `GET /v1/qr-codes/{id}/linkset`.'
          example: 'https://api.useqrkit.com/v1/qr-codes/qr_123/linkset'

    Linkset:
      type: object
      description: |
        An RFC 9264 linkset. Link relations are FULL GS1 vocabulary IRIs
        (`https://ref.gs1.org/voc/pip`), never `gs1:` CURIEs — the published
        linkset schema rejects a colon in a key. Each override gets its OWN
        anchor: a lot-specific recall belongs to that lot's URI, not to every
        unit ever made — and that anchor also carries everything registered on
        the level above it (section 2.5.10). These are the same bytes QRKit's
        resolver serves for the same identifier.
      properties:
        linkset:
          type: array
          items:
            type: object
            description: 'One anchor. Keys other than `anchor` are link-relation IRIs mapping to arrays of link entries.'
            properties:
              anchor: { type: string, format: uri, example: 'https://qrkit.to/01/09506000134352' }
            additionalProperties:
              type: array
              items:
                type: object
                properties:
                  href: { type: string, format: uri }
                  title: { type: string }
                  hreflang: { type: array, items: { type: string } }
                  type: { type: string }
                  context: { type: array, items: { type: string } }

    CreateQrCodeRequest:
      type: object
      required: [target]
      properties:
        name: { type: string, maxLength: 255, description: 'Defaults to "Campaign {code}" / "Static {code}".' }
        type:
          type: string
          enum: [dynamic, static]
          description: Chooses the mode where the type allows it. `url` defaults to dynamic; `wifi`/`text`/`email`/`sms`/`event` default to static but accept `dynamic` (hosted scan page, editable, scan analytics — requires structured target fields, not a pre-encoded `destination`); `vcard`, `appstores` and `review` always dynamic; `url`, the link types (`youtube` … `googleform`), `whatsapp`, `telegram` and `location` default to dynamic and accept `static`. Omit to use the type's default.
        target: { $ref: '#/components/schemas/Target' }
        design: { $ref: '#/components/schemas/Design' }
        folder_id: { type: string, description: Folder to place the code in (e.g. `fld_12`). }
        domain_id:
          type: string
          nullable: true
          description: |
            Dynamic codes only: the scan host printed in the QR. Omit it to use
            the workspace's **primary custom domain** when it has an active one
            and the plan includes custom domains (Plus and up) — the same
            default the dashboard uses — pass `null` for the default QRKit
            host, or a `dom_<n>` id from `GET /v1/me` → `custom_domains`.
            An id that is not an active custom domain of the workspace is
            refused (`422`), as is any non-null `domain_id` on a static code; naming a
            domain on a plan without custom domains is `403`. In a batch, every
            item's domain is checked before the batch is accepted. The host is
            fixed once the code exists: `PATCH` refuses a different `domain_id`
            (sending the current value is accepted, except on GS1 codes, which
            refuse any `domain_id`). GS1
            codes use their own `domain_id` (see `CreateGs1QrCodeRequest`).
          example: dom_3
        tags: { type: array, items: { type: string, maxLength: 50 }, maxItems: 10 }

    QrCode:
      type: object
      properties:
        id: { type: string, example: qr_123, description: '`qr_…` for dynamic, `sqr_…` for static.' }
        object: { type: string, example: qr_code }
        kind: { type: string, enum: [dynamic, static] }
        name: { type: string }
        type: { type: string, description: 'The stored type: any `target.type` the API creates (a `review` is stored as `linkpage`), `gs1`, or a dashboard-only type such as `file`, `menupage`, `form` or `smarturl`. Treat unknown values as opaque.', example: url }
        short_code: { type: string, example: AB3D5 }
        short_url: { type: string, nullable: true, example: 'https://qrkit.to/AB3D5', description: 'null for static codes. On the workspace custom domain when the code was created on one. For a dynamic GS1 code this is the Digital Link URI itself, not `<host>/<short_code>`.' }
        domain_id: { type: string, nullable: true, example: dom_3, description: 'The custom domain `short_url` is on, or null for the default host (and for static codes).' }
        gs1: { $ref: '#/components/schemas/Gs1Record' }
        target:
          type: object
          properties:
            type: { type: string }
            destination:
              type: string
              nullable: true
              description: |
                Always a value you can act on, but it means one of two things —
                read `editable` to know which.

                When `editable` is `true`, this is the stored payload (the URL
                for dynamic `url` codes, the encoded payload for statics), and
                `PATCH .../qr-codes/{id}` can re-point it through `target`.
                Note that dynamic `vcard` codes store the contact as JSON, so
                their `destination` is the full contact record (name, phone,
                email, address) — treat webhook payloads for them as personal
                data.

                When `editable` is `false` — codes that hold a page rather than
                a link, whether made in the dashboard or through this API (a
                review page is stored as `linkpage`) — the stored value is NOT a destination (`linkpage`,
                `form`, `menupage`, `appstores` and `smarturl` hold a JSON
                document; `file`, `audio`, `video` and `menu` hold an uploaded
                file), so this is the code's **public hosted page URL** instead:
                the same value as `short_url`, and the URL the QR image
                encodes. `null` only in the degenerate case of a dashboard-
                authored row with no hosted URL — no URL is ever invented.
            editable:
              type: boolean
              description: |
                Whether `target` can be changed through
                `PATCH /v1/qr-codes/{id}`. `false` for codes that hold a page
                rather than a link (link and review pages, forms, menus, app-store and
                smart-URL codes, and uploaded file/audio/video codes) — sending
                `target` for one of those is refused with
                `409 target_not_editable` and nothing in the request is
                applied. Edit them in the dashboard instead.
            summary:
              type: object
              nullable: true
              description: |
                Present only for `linkpage`, `form` and `menupage` (all of them
                `editable: false`). A short, safe description of the stored
                document, published in place of the document itself; `title` is
                null when the document has none.
              properties:
                title: { type: string, nullable: true }
                menus: { type: integer, description: 'menupage only: number of VISIBLE menus (hidden ones are the author''s unpublished drafts and are not counted).' }
                items: { type: integer, description: 'menupage only: total VISIBLE items across every visible section of every visible menu.' }
        design:
          type: object
          nullable: true
          properties:
            colors:
              type: object
              properties:
                foreground: { type: string }
                background: { type: string }
            gradient:
              type: object
              nullable: true
              properties:
                start: { type: string }
                end: { type: string }
                type: { type: string }
            shapes:
              type: object
              properties:
                body: { type: string }
                eye_frame: { type: string }
                eye_ball: { type: string }
            eye_colors:
              type: object
              nullable: true
              properties:
                frame: { type: string }
                ball: { type: string }
            has_logo: { type: boolean }
            transparent_background: { type: boolean }
        folder_id: { type: string, nullable: true, example: fld_12 }
        tags: { type: array, items: { type: string } }
        image_url: { type: string, example: 'https://img.useqrkit.com/qr-AB3D5-1717000000000.svg' }
        download_url: { type: string, example: /v1/qr-codes/qr_123/download }
        is_active: { type: boolean, description: 'Dynamic codes: whether the redirect is on. Static codes: `false` while the code is in the Trash (the printed payload itself keeps working).' }
        scan_count: { type: integer, nullable: true, description: null for static codes (no tracking). }
        utm:
          type: object
          nullable: true
          description: UTM parameters (null unless set on a dynamic url code).
          properties:
            source: { type: string }
            medium: { type: string }
            campaign: { type: string }
            term: { type: string }
            content: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        deleted_at:
          type: string
          format: date-time
          nullable: true
          description: '`null` for a live code. Set while the code is in the Trash (or paused by a plan downgrade); restore it with `POST /v1/qr-codes/{id}/restore`.'

    Analytics:
      type: object
      properties:
        object: { type: string, example: analytics }
        qr_code_id: { type: string, example: qr_123 }
        total_scans: { type: integer }
        first_scan_at: { type: string, format: date-time, nullable: true }
        last_scan_at: { type: string, format: date-time, nullable: true }
        scans_by_day:
          type: array
          items:
            type: object
            properties:
              date: { type: string, example: '2026-06-01' }
              count: { type: integer }
        countries: { $ref: '#/components/schemas/Breakdown' }
        cities: { $ref: '#/components/schemas/Breakdown' }
        devices: { $ref: '#/components/schemas/Breakdown' }
        operating_systems: { $ref: '#/components/schemas/Breakdown' }
        browsers: { $ref: '#/components/schemas/Breakdown' }
        referrers: { $ref: '#/components/schemas/Breakdown' }
        languages: { $ref: '#/components/schemas/Breakdown' }

    Breakdown:
      type: array
      description: Top 10 values with scan counts.
      items:
        type: object
        properties:
          name: { type: string }
          count: { type: integer }

    Batch:
      type: object
      properties:
        id: { type: string, example: batch_aB3dE5fG7hI9jK1m }
        object: { type: string, example: batch }
        total_count: { type: integer }
        completed_count: { type: integer }
        error_count: { type: integer }
        status: { type: string, enum: [processing, completed, failed] }
        items:
          type: array
          description: Present on `GET /batches/{id}`.
          items:
            type: object
            properties:
              name: { type: string }
              type: { type: string }
              status: { type: string, enum: [pending, completed, error] }
              qr_code_id: { type: string, nullable: true }
              error: { type: string, nullable: true }
              reason:
                type: string
                nullable: true
                description: 'Machine-readable reason when an item failed, same values as `error.reason` — e.g. `PLAN_LIMIT` when the plan ran out of slots while the batch ran (another batch or request took them). `null` otherwise.'
              gs1_code:
                type: string
                nullable: true
                description: |
                  Machine-readable reason when a GS1 item failed — the same
                  values the single-create endpoint returns in
                  `error.gs1_code`. `null` for an ordinary failure. A batch of
                  500 is otherwise only separable by string-matching English:
                  `GS1_KEY_TAKEN` is "pick a different identifier",
                  `PLAN_LIMIT` is "upgrade", `GS1_KEY_IN_TRASH` is "restore
                  your own code".
        created_at: { type: string, format: date-time }

    Folder:
      type: object
      properties:
        id: { type: string, example: fld_12 }
        object: { type: string, example: folder }
        name: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ApiKey:
      type: object
      properties:
        id: { type: string, example: key_aB3dE5fG7hI9jK1m }
        object: { type: string, example: api_key }
        name: { type: string }
        key_prefix: { type: string, example: qr_live_a1b2 }
        environment: { type: string, enum: [live, test] }
        scopes:
          type: array
          items: { type: string }
        last_used_at: { type: string, format: date-time, nullable: true }
        expires_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    ApiKeyWithSecret:
      allOf:
        - $ref: '#/components/schemas/ApiKey'
        - type: object
          properties:
            key: { type: string, description: The full API key — shown only once. }

    List:
      type: object
      properties:
        object: { type: string, example: list }
        has_more: { type: boolean }
        next_cursor: { type: string, nullable: true }

    DeleteResponse:
      type: object
      properties:
        id: { type: string }
        object: { type: string }
        deleted: { type: boolean, example: true }

    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum: [invalid_request, unauthorized, forbidden, not_found, conflict, target_not_editable, destination_unsafe, unprocessable_entity, payload_too_large, rate_limit_exceeded, internal_error]
            message: { type: string }
            reason:
              type: string
              description: |
                Machine-readable reason, where the HTTP code alone is
                ambiguous. `NOT_IN_TRASH` (409): a restore of a code that is
                live. `DEACTIVATED` (409): a restore of a code that is
                switched off but was never deleted — re-activate it with
                `PATCH is_active: true`. `DOMAIN_RETIRED` (409): a restore or
                re-activation of a GS1 code whose custom domain was removed.
                `PLAN_LIMIT` (422): a create, batch, restore, or `PATCH` with
                `is_active: true` the plan has no room for. Parallel API
                requests are serialized per workspace, so they cannot
                together go past the limit.
              enum: [NOT_IN_TRASH, DEACTIVATED, DOMAIN_RETIRED, PLAN_LIMIT]
            gs1_code:
              type: string
              description: |
                Machine-readable reason for a GS1 failure, where the HTTP code
                alone is ambiguous. Present only on GS1 requests.
              enum:
                - NOT_ENTITLED
                - PLAN_LIMIT
                - INVALID_KEY
                - INVALID_DESTINATION
                - INVALID_LINKS
                - INVALID_OVERRIDES
                - GS1_KEY_TAKEN
                - GS1_KEY_IN_TRASH
                - GS1_KEY_RETIRED
                - GS1_IDENTIFIER_IMMUTABLE
                - GS1_METADATA_MISSING
                - GS1_STATIC_HAS_NO_LINKSET
                - GS1_NOT_A_GS1_CODE
                - GS1_ERROR_CORRECTION_TOO_LOW
                - CREATE_FAILED
            field: { type: string, description: 'The input to blame, e.g. `primary_key.value`, `destination`, `links[2].href`.' }
            fields: { type: array, items: { type: string }, description: 'The immutable identifier fields a PATCH tried to change (GS1_IDENTIFIER_IMMUTABLE).' }
            limit: { type: integer, description: 'PLAN_LIMIT only — the plan''s allowance for the kind of code involved: GS1 codes when `gs1_code` is set, otherwise dynamic QR codes (`-1` = unlimited).' }
            current_count: { type: integer, description: 'PLAN_LIMIT only — codes of that kind the workspace already holds (active, not deleted).' }
            requested: { type: integer, description: 'PLAN_LIMIT on a batch only — the dynamic (or dynamic GS1) items the batch asked for.' }
            plan_id: { type: string, description: 'PLAN_LIMIT only — the plan the limit was read from.' }
            doc_url: { type: string }
            request_id: { type: string }

    Account:
      type: object
      properties:
        object: { type: string, example: account }
        organization_id: { type: string, example: org_2abcDEfGhIjKlMn }
        organization_name: { type: string, nullable: true, example: Acme Inc }
        plan: { type: string, example: pro }
        key_prefix: { type: string, example: qr_live_a1b2 }
        scopes:
          type: array
          items: { type: string }
          example: ['qr:read', 'qr:write', 'webhooks:read', 'webhooks:write']
        environment: { type: string, enum: [live, test] }
        custom_domains:
          type: array
          description: The workspace's active custom domains — the ids `domain_id` accepts. When the plan includes custom domains (Plus and up), new dynamic codes go on the one with `is_primary` unless `domain_id` says otherwise; on other plans (e.g. during a downgrade's grace period) they use the default host and naming a domain returns `403`.
          items:
            type: object
            properties:
              id: { type: string, example: dom_3 }
              hostname: { type: string, example: go.acme.com }
              is_primary: { type: boolean }

    WebhookEndpoint:
      type: object
      properties:
        id: { type: string, example: whe_aB3dE5fG7hI9jK1m }
        object: { type: string, example: webhook_endpoint }
        url: { type: string, format: uri, example: 'https://example.com/webhooks/qrkit' }
        description: { type: string, nullable: true }
        events:
          type: array
          items: { type: string }
          example: ['qr.created', 'qr.scanned']
        source:
          type: string
          enum: [api, zapier, make, n8n]
          description: 'Who manages this endpoint. Set at creation, immutable afterwards. Endpoint caps are counted per source.'
        status:
          type: string
          enum: [enabled, disabled, auto_disabled]
          description: '`auto_disabled` is set automatically after sustained delivery failures. Re-enable with `PATCH /webhooks/{id}` `{ "status": "enabled" }`.'
        secret:
          type: string
          description: 'Full `whsec_…` value returned only on create (`POST /webhooks`) and secret rotation (`POST /webhooks/{id}/rotate-secret`). All other reads return the masked form `whsec_…XXXX`.'
          example: 'whsec_…a1b2'
        last_success_at: { type: string, format: date-time, nullable: true }
        last_failure_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreateWebhookRequest:
      type: object
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          description: HTTPS URL to receive event payloads. Must resolve to a publicly routable host.
          example: 'https://example.com/webhooks/qrkit'
        events:
          type: array
          minItems: 1
          items:
            type: string
            enum: [qr.created, qr.updated, qr.deleted, qr.restored, qr.scanned, batch.completed, batch.failed, lead.captured, '*']
          description: 'Event types to subscribe to. Use `"*"` to subscribe to all current and future event types.'
        description:
          type: string
          maxLength: 500
          description: Optional human-readable label for this endpoint.
        source:
          type: string
          enum: [api, zapier, make, n8n]
          default: api
          description: >-
            Who manages this endpoint. Integration platforms (Zapier, Make, n8n) set
            their own value so their endpoints are counted and capped separately
            (10 per workspace for `api`, 50 for each integration source). Immutable
            after creation.

    UpdateWebhookRequest:
      type: object
      description: All fields are optional; supply only those you want to change. At least one field is required.
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          description: New destination URL. Must resolve to a publicly routable host.
        events:
          type: array
          minItems: 1
          items:
            type: string
            enum: [qr.created, qr.updated, qr.deleted, qr.restored, qr.scanned, batch.completed, batch.failed, lead.captured, '*']
        description:
          type: string
          maxLength: 500
          nullable: true
          description: Set to `null` to clear the description.
        status:
          type: string
          enum: [enabled, disabled]
          description: 'Set to `enabled` to re-enable a manually or automatically disabled endpoint (resets consecutive failure count to zero).'

    WebhookDelivery:
      type: object
      properties:
        id: { type: string, example: whd_aB3dE5fG7hI9jK1m }
        object: { type: string, example: webhook_delivery }
        event_id: { type: string, example: evt_aB3dE5fG7hI9jK1m }
        endpoint_id: { type: string, example: whe_aB3dE5fG7hI9jK1m }
        status:
          type: string
          enum: [pending, succeeded, failed]
        attempt_count: { type: integer, minimum: 0 }
        next_attempt_at:
          type: string
          format: date-time
          nullable: true
          description: Populated only while `status` is `pending`; `null` otherwise.
        last_attempt_at: { type: string, format: date-time, nullable: true }
        response_status:
          type: integer
          nullable: true
          description: HTTP status code returned by the endpoint on the last attempt.
        response_body:
          type: string
          nullable: true
          description: 'First 1,024 characters of the response body from the last attempt.'
        last_error:
          type: string
          nullable: true
          description: Error message from the last failed attempt (e.g. connection refused, timeout).
        created_at: { type: string, format: date-time }

    Event:
      type: object
      properties:
        id: { type: string, example: evt_aB3dE5fG7hI9jK1m }
        object: { type: string, example: event }
        type:
          type: string
          enum: [qr.created, qr.updated, qr.deleted, qr.restored, qr.scanned, batch.completed, batch.failed, lead.captured, ping]
          example: qr.scanned
        created_at: { type: string, format: date-time }
        data:
          type: object
          description: |
            Event-specific payload. For `qr.scanned` events delivered to external endpoints
            this object contains only: `country`, `city`, `device`, `os`, `browser`,
            `referrer`, and `language` — no IP address, no precise location, and no raw
            user-agent string.
