> ## Documentation Index
> Fetch the complete documentation index at: https://docs.useqrkit.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Restore QR Code

> 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`.




## OpenAPI

````yaml /openapi.yaml post /qr-codes/{id}/restore
openapi: 3.0.3
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: []
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.
paths:
  /qr-codes/{id}/restore:
    post:
      tags:
        - QR Codes
      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
      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'
components:
  schemas:
    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`.
    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
    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
    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
  responses:
    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'
    RateLimited:
      description: Rate limit or monthly quota exceeded — see Retry-After header
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  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.

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.