---
title: "Create a code"
method: POST
path: "/api/v1/codes"
tags: ["Public API v1"]
---

# Create a code

`POST /api/v1/codes`

## Request

- Base URL: `https://scanduro.com`
- URL: `https://scanduro.com/api/v1/codes`
- Auth: HTTP bearer

`Idempotency-Key` is REQUIRED, not optional. Creating a code consumes plan quota and mints a
permanent slug, so a client that times out and retries without one ends up with two codes and
no way to tell which it printed. Requiring the header turns that from a silent accounting
problem into a 400 on your very first call.

A retry with the same key replays the stored response verbatim, with `Idempotent-Replay: true`.
The same key with a different body is a 409 rather than a silent no-op.

Plan quota, destination screening and reserved-slug refusal are enforced here exactly as they
are in the dashboard. There is no path through this API that bypasses an abuse control.

## Headers

- `Idempotency-Key` string, required

## Request body

- CreateCodeRequest
  - `name` string, required
  - `type` 'static' | 'dynamic', required
  - `kind` 'qr' | 'link'
  - `contentType` 'url' | 'text' | 'vcard' | 'wifi' | 'email' | 'sms' | 'geo' | 'pdf' | 'gs1', required
  - `slug` string: Ignored for GS1: the server derives a deterministic canonical resolver key from payload.
  - `destination` string, required: For GS1, the screened http(s) redirect destination.
  - `payload` string, nullable: For contentType gs1, JSON text matching Gs1Payload. GS1 codes must be dynamic and use an auto-derived canonical key.
  - `design` string, required
  - `scanCap` integer, nullable
  - `customDomain` string, nullable
  - `subdomain` string, nullable: A claimed layer-2 LABEL ("sisar"), never a host. The server derives the host from the claim table, so a caller cannot name a host it does not own. 501 when the deployment cannot serve branded subdomains, 402 when the plan does not include one, 404 when this workspace has not claimed the label, and 400 on a sandbox key because a test row does not resolve on any host. Omit it and the code is created on the shared resolver.
  - `domain` string, nullable: A layer-3 host this workspace has already added AND verified (see POST /api/domains). Unlike subdomain this is a full hostname, because there is no apex to derive it from - but it is checked against this workspace's own verified list, so supplying a host still proves nothing. Takes precedence over subdomain when both are given. 402 when the plan does not include custom domains, 400 on a sandbox key, and a single 404 domain_unknown for not yours / never added / not verified / abandoned alike, so the endpoint cannot be used as an oracle for which hostnames another workspace has registered.

## Response `201`

Created, or replayed from a previous identical request.

- object
  - `data` PublicCode, required: An explicit allowlist of fields, not the stored row. A column added to the database must not silently appear here, because this surface is versioned and anything it returns becomes a compatibility obligation.
    - `shortUrl` string, uri, nullable: Server-derived canonical resolver URL for a live dynamic URL code, including its verified branded hostname where applicable. Null for sandbox, static and non-URL payloads. Integrations must encode this value, not the mutable destination, into dynamic QR artwork.
    - `id` string, required: The immutable public identity of the link. Assigned once, never changed, never reused. Store this, not the slug — a slug can be retired and a domain can be moved.
    - `name` string, required
    - `type` 'static' | 'dynamic', required
    - `kind` 'qr' | 'link', required
    - `contentType` 'url' | 'text' | 'vcard' | 'wifi' | 'email' | 'sms' | 'geo' | 'pdf' | 'gs1', required
    - `slug` string, required
    - `destination` string, required
    - `status` 'active' | 'paused' | 'blocked', required: The product state — whether the owner gets editing and analytics.
    - `hostname` string, nullable: The branded host this code resolves on, or `null` for the shared resolver. Reported rather than left to be inferred: the apex is deployment configuration, so a client that reconstructed the host from the label it sent would get it wrong the moment the apex changed — and this is the URL that gets printed.
    - `mode` 'live' | 'test': Which namespace this row belongs to, decided by the key that created it and never patchable. A `test` row does NOT resolve — that is what makes a free sandbox safe to offer, because a sandbox code cannot be printed and quietly work for a while.
    - `resolverState` 'active' | 'frozen' | 'suspended', required: The infrastructure state — whether the link resolves at all. Read-only here; only an operator changes it. A lapsed subscription never appears in this field.
    - `scanCap` integer, nullable
    - `createdAt` string, date-time, required

## Other responses

- `400`: Validation failure, a missing Idempotency-Key, or an unusable cursor.
- `401`: No key, a malformed key, an unknown key, a revoked key or an expired key. Deliberately one response for all five: telling the caller which applies would make the endpoint an oracle for enumerating valid keys.
- `402`: Plan quota for dynamic codes is exhausted.
- `403`: Either the plan does not include API access (`plan_required`) or the key does not carry the required scope (`missing_scope`). The credential itself is valid in both cases.
- `409`: The slug is taken or reserved, or the Idempotency-Key was reused with a different body.
- `422`: The destination was refused by reputation screening.
- `429`: Per-key limit reached. Retry-After gives the seconds remaining in the window.

---

[API](https://skmtc.dev/scanduro/apis/scanduro-public-api.md) · [All operations](https://skmtc.dev/scanduro/apis/scanduro-public-api/llms.txt) · [OpenAPI document](https://skmtc.dev/scanduro/apis/scanduro-public-api/revisions/cdb36d96cad7?raw)
