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.