API documentation

This is the raw integration guide, rendered as-is — also available unstyled at this same URL for scripts/agents (we content-negotiate on Accept). See also /llms.txt.

# qr42-app public API — integration guide

This is the third-party-facing companion to the code. If you're a brand
integrating qr42-app into your own systems (a PIM, an ERP, a CI pipeline
that publishes product data), this is what you can actually call today.

**Honest scope**: this is a narrow slice of the full management API, not
all of it. Most of qr42-app (orgs, billing, team management, brand-host
onboarding, disputes, bulk import, analytics, admin) is dashboard-only —
session-cookie authenticated, no API key path. **Exactly 4 endpoints**
accept an API key today: claim a GTIN, create/update a product, and
list/add links on a product. If what you need isn't below, it isn't
possible via API yet — ask, don't guess from the dashboard's own
capabilities.

## Getting a key

There's currently no way to create a key without a dashboard session —
API keys are minted from inside the app, not self-service via API:

1. Sign in at https://qrcode42.com/dashboard (magic link or passkey).
2. Create or select your org.
3. **API Keys** tab -> give it a name, pick scopes, create.
4. Copy the raw key immediately — it's shown exactly once
   (qr42_live_...). Only its SHA-256 hash is ever stored; if you lose
   it, revoke it and mint a new one.

Every key is scoped to **one org** and to specific **scopes**:

| Scope | Grants |
|---|---|
| gtins:write | POST /api/v1/gtins, POST /api/v1/products |
| links:read | GET /api/v1/products/:id/links |
| links:write | POST /api/v1/products/:id/links |
| analytics:read | Defined, but **nothing currently checks it** — there is no API-key-authenticated analytics endpoint. Scan analytics is dashboard-only today. |

A key can hold multiple scopes at once. Request only what you need.

## Authenticating

Every request below takes the raw key as a bearer token:

Authorization: Bearer qr42_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

A missing/invalid/revoked key gets a 401:
{ "error": "Invalid or revoked API key" }

A key missing the required scope for the endpoint you called gets a 403:
{ "error": "API key missing gtins:write scope" }

There is deliberately no way to tell "key doesn't exist" apart from "key
was revoked" from the error alone — same reasoning as this app's magic
links and passkey challenges: don't leak which failure mode occurred.

## Endpoints

### POST /api/v1/gtins — claim a GTIN

Claims a GTIN-14 on a host for your org. On the shared qr-42.com host
this starts "pending" and needs admin review before it resolves publicly
— claiming isn't the same as going live. On a brand host you already own
(Custom Hostname onboarded), it auto-verifies immediately.

Requires scope: gtins:write

curl -X POST https://qrcode42.com/api/v1/gtins \
  -H "Authorization: Bearer qr42_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "gtin14": "00000012345007",
    "hostname": "qr-42.com",
    "evidenceUrl": "https://yourbrand.com/products/trail-mix"
  }'

| Field | Required | Notes |
|---|---|---|
| gtin14 | yes | Exactly 14 digits, real GS1 mod-10 check digit — see GTIN-14 validation below. Rejected at claim time, not later. |
| hostname | yes | A QR Code 42 shared domain (qr-42.com or qr42.link), or a brand host your org already owns. Any other hostname is rejected with 403. |
| kind | no | "shared" (default) or "brand". Must match reality. |
| evidenceUrl | no | A URL an admin can check to confirm ownership (a listing page, a packaging photo). |

Success (201):
{
  "id": "claim_...", "org_id": "org_...", "gtin14": "00000012345007",
  "host_id": "host_...", "status": "pending",
  "evidence_ref": "https://yourbrand.com/products/trail-mix",
  "verified_by": null, "verified_at": null, "created_at": "2026-09-30 12:00:00"
}

Common errors (400): a bad checksum, a GTIN already claimed (pending or
verified) on that host by anyone, or a kind:"brand" claim against a
hostname that isn't an onboarded brand host for your org.

### POST /api/v1/products — create or update a product

Attaches a name/brand to a GTIN for your org. This is a real upsert:
call it again with the same gtin14 to update name/brandName on the
existing row (a field you omit is left untouched, not blanked). Returns
201 the first time, 200 on every update after.

Requires scope: gtins:write

curl -X POST https://qrcode42.com/api/v1/products \
  -H "Authorization: Bearer qr42_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "gtin14": "00000012345007", "name": "Trail Mix, 12oz", "brandName": "Your Brand" }'

Response:
{
  "id": "prod_...", "org_id": "org_...", "gtin14": "00000012345007",
  "name": "Trail Mix, 12oz", "brand_name": "Your Brand",
  "image_url": null, "default_lang": null
}

### GET /api/v1/products/:id/links — list a product's links

Requires scope: links:read

curl https://qrcode42.com/api/v1/products/prod_xxxxx/links \
  -H "Authorization: Bearer qr42_live_..."

:id must be a product your org owns — otherwise 403, not the data.

Response (this is the resolver's published shape, not a raw DB row — no
link id, href/title/etc. only, matching exactly what qr-42.com serves):
{
  "gtin14": "00000012345007",
  "links": [
    { "linkType": "gs1:pip", "href": "https://yourbrand.com/products/trail-mix", "title": "Trail Mix product page", "lang": "en", "country": "US", "isDefault": true }
  ]
}

### POST /api/v1/products/:id/links — add a link

Adds one link to a product on a specific host. **You need a verified
claim for this GTIN on that host first** — this call 403s otherwise. On
the shared host, "verified" means an admin has reviewed your claim; this
can't be skipped via the API.

Requires scope: links:write

curl -X POST https://qrcode42.com/api/v1/products/prod_xxxxx/links \
  -H "Authorization: Bearer qr42_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "hostname": "qr-42.com", "linkType": "gs1:pip",
    "href": "https://yourbrand.com/products/trail-mix",
    "title": "Trail Mix product page", "lang": "en", "country": "US", "isDefault": true
  }'

| Field | Required | Notes |
|---|---|---|
| hostname | yes | Must have a verified claim for this product's GTIN. |
| linkType | yes | A GS1 link type (gs1:pip, gs1:pdf, etc.) or your own custom string. |
| href | yes | Must be http:// or https:// — anything else is rejected server-side. |
| title | yes | Required by the resolver's linkset schema (RFC 9264). |
| lang, country | no | Scopes the link for language/country negotiation. |
| mediaType | no | e.g. text/html, application/pdf. |
| isDefault | no | Marks this the fallback link. |
| scope | no | { cpv, lot, serial } — Growth-tier+ qualifier scoping. |

Once added, live on the resolver within seconds — no separate "publish"
step. Response (201) is the product's full current link list, same
shape as GET .../links above.

## GTIN-14 validation

Every GTIN must be exactly 14 digits with a real GS1 mod-10 check digit.
A syntactically-plausible but checksum-invalid GTIN is rejected
immediately with "Invalid GTIN check digit (expected N, got M)".

Worked example for 00000012345007:
- First 13 digits: 0000001234500.
- Rightmost-first, alternating weights 3,1,3,1...:
  0·3 + 0·1 + 5·3 + 4·1 + 3·3 + 2·1 + 1·3 + 0·1 + 0·3 + 0·1 + 0·3 + 0·1 + 0·3 = 33.
- Check digit = (10 - (33 % 10)) % 10 = 7. Matches the trailing 7 — valid.

## What's explicitly NOT available via API key

- Creating an org, or an API key itself — session-only.
- Claim verification on the shared host — admin review only, no API path.
- Listing/deleting products or claims, revoking/rotating a key — dashboard only.
- Short links, landing pages, brand-host onboarding, bulk CSV import,
  billing, disputes, team management, analytics, anything under
  /api/v1/admin/* — all dashboard/session-only.

Cookie settings

Choose which cookies we may use. You can change this at any time from the footer. See the privacy policy.