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

# GS1 Linkset

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




## OpenAPI

````yaml /openapi.yaml get /qr-codes/{id}/linkset
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://scan.useqrkit.com/{short_code}`)
      that redirects to your destination — editable later, with scan analytics.
      Types: `url` (default), `vcard`, and `wifi`/`text`/`email`/`sms`/`event`
      when created with `"type": "dynamic"` (the scan serves a hosted page:
      Wi-Fi credentials card, text page, pre-filled email compose card,
      message card, or event card).
    - **Static** codes encode their payload directly — nothing to host, but
      the content is fixed once printed and there are no analytics.
      Types: `wifi`/`text`/`email`/`sms`/`event` (the default for those
      types), and `url` when created with `"type": "static"`.

    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 |

      | `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}/linkset:
    get:
      tags:
        - QR Codes
      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
      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
                  - 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'
components:
  schemas:
    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
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
                - invalid_request
                - unauthorized
                - forbidden
                - not_found
                - conflict
                - target_not_editable
                - unprocessable_entity
                - payload_too_large
                - rate_limit_exceeded
                - internal_error
            message:
              type: string
            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 GS1 code allowance (`-1` =
                unlimited).
            current_count:
              type: integer
              description: PLAN_LIMIT only — dynamic GS1 codes the workspace already holds.
            requested:
              type: integer
              description: >-
                PLAN_LIMIT on a batch only — 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
  responses:
    Unauthorized:
      description: Missing or invalid credentials
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: Missing scope or plan does not include this feature
      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'
    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.

````