API reference

Two surfaces: an open resolver that anything can scan, and an authenticated admin API for configuring what a scan returns. Base URL is your resolver origin.

Try it live

These buttons call the production resolver from your browser. No account and no API key, because looking up a code is public: this is the same endpoint a checkout scanner or a shopper's phone would reach.

GET /01/09506000134352 ready
Pick a request above to run it against production.

Authentication

Admin requests carry a bearer token. A root key administers the whole deployment; a tenant key is scoped to the GS1 company prefixes that brand holds, and is rejected on any key outside them.

curl https://onpack.link/api/products \
  -H "Authorization: Bearer $ONPACK_KEY"

Tenant keys are shown once at creation and stored only as a hash. If one is lost, revoke it and issue another.

Resolving a scan

The resolver decides what to return from the Accept header, the Accept-Language header and the country the scan came from. Nothing about the printed code changes.

A checkout scanner or inventory system

curl -H "Accept: application/ld+json" \
  https://onpack.link/01/09506000134352/10/CHIH01

{ "@type": "Product", "gtin14": "09506000134352",
  "hasBatchLotNumber": "CHIH01", "inLanguage": "en",
  "digitalProductPassport": { "origin": "Chihuahua, Mexico" },
  "recalled": true,
  "recall": { "status": "recalled", "reason": "Possible Salmonella contamination",
              "advice": "Do not eat these onions. Return them for a refund.",
              "reference": "FDA-2026-1234" } }

A recall is reported as recalled: true on a 200, never as an error status. An error gets logged and dropped; a recall has to be read.

Every link a code carries

curl "https://onpack.link/01/09506000134352?linkType=linkset"

{ "linkset": [ { "anchor": "https://onpack.link/01/09506000134352",
    "https://ref.gs1.org/voc/pip": [ { "href": "https://brand.example.com/water" } ],
    "https://ref.gs1.org/voc/instructions": [ { "href": "...", "title": "..." } ] } ] }

Ask for one link with ?linkType=gs1:instructions, in compact or full-IRI form. If the product has no link of that type the resolver returns 404 with the linkset in the body, so the client can pick something else. That is required by the standard and changed in Digital Link 1.1: it no longer falls back to the default. When several links share the requested type and nothing in the request chooses between them, the answer is 300 Multiple Choices with the candidates listed.

Configuring a product

curl -X PUT https://onpack.link/api/products/09506000134352 \
  -H "Authorization: Bearer $ONPACK_KEY" -H "Content-Type: application/json" \
  -d '{
    "name": "Sparkling Water",
    "targetUrl": "https://brand.example.com/water",
    "links": [
      { "linkType": "gs1:pip",          "href": "https://brand.example.com/water" },
      { "linkType": "gs1:instructions", "href": "https://brand.example.com/how-to" },
      { "linkType": "gs1:pip",          "href": "https://brand.example.com/mx", "countries": ["MX"] }
    ],
    "markets":      { "MX": { "targetUrl": "https://brand.example.com/mx" } },
    "translations": { "fr": { "name": "Eau pétillante" } },
    "compliance":   { "euDpp": { "origin": "Bottled in Belgium", "recycling": "Glass" } },
    "security":     { "maxScansPerSerial": 25 }
  }'

Overlays apply least to most specific: base config, then the scanning market, then the scanned batch, then the reader's language. Anything a narrower layer omits falls through.

Batches and recalls

Origin, production dates, expiry and recalls belong to a batch. A batch record can be as small as a single recall.

# the season's harvest, in one request
curl -X POST https://onpack.link/api/products/09506000134352/lots/bulk \
  -H "Authorization: Bearer $ONPACK_KEY" -H "Content-Type: application/json" \
  -d '{ "lots": [
        { "lot": "CHIH01", "origin": "Chihuahua, Mexico", "producedAt": "2026-05-01" },
        { "lot": "CHIH02", "origin": "Chihuahua, Mexico", "producedAt": "2026-05-02" } ] }'

# withdraw one batch
curl -X POST https://onpack.link/api/products/09506000134352/lots/CHIH01/recall \
  -H "Authorization: Bearer $ONPACK_KEY" -H "Content-Type: application/json" \
  -d '{ "status": "recalled", "reason": "Possible Salmonella contamination",
        "advice": "Return to the store for a refund.", "reference": "FDA-2026-1234" }'

Every other batch of the same product keeps resolving normally. DELETE the same path lifts the recall without touching the batch's other data.

Print artwork

curl "https://onpack.link/api/products/09506000134352/label?serial=BOTTLE-001&labelWidthMm=45&linearWidthMm=37.29" \
  -H "Authorization: Bearer $ONPACK_KEY"

{ "url": "https://onpack.link/01/09506000134352/21/BOTTLE-001",
  "elementString": "(01)09506000134352(21)BOTTLE-001",
  "qr": { "svg": "<svg ...", "moduleCount": 33, "minWidthMm": 16.2 },
  "placement": { "ok": false, "maxDistanceFromLinearMm": 50,
                 "issues": ["both codes together need 53.5 mm, more than the label's 45 mm ..."] } }

Add ?format=svg for the artwork on its own, ?x= for a different X-dimension and ?ecc= for error correction. Before a print run, check the plate itself:

curl -X POST https://onpack.link/api/verify-artwork \
  -H "Authorization: Bearer $ONPACK_KEY" -H "Content-Type: application/json" \
  -d '{ "linear": "9506000134352",
        "digitalLink": "https://onpack.link/01/09506000134352",
        "expectedOrigin": "https://onpack.link",
        "placement": { "distanceFromLinearMm": 20, "labelWidthMm": 120 } }'

# 200 when it is safe to print, 422 with issues when it is not

Serial authentication

Serials are printed as {serial}.{signature} and verified at the edge against one public key per product, with no lookup per item. Onpack stores public keys only.

# keep the private key on your machine
node scripts/sign-serials.mjs keygen > keys.json
curl -X POST https://onpack.link/api/products/09506000134352/keys/import \
  -H "Authorization: Bearer $ONPACK_KEY" -H "Content-Type: application/json" \
  -d "{\"publicKey\": $(jq -c .publicKey keys.json)}"

# sign a run offline; the key never leaves the machine
printf 'BOTTLE-001\nBOTTLE-002\n' | node scripts/sign-serials.mjs sign \
  --key keys.json --gtin 09506000134352 --origin https://onpack.link

Revoke a compromised serial with POST /api/products/{gtin}/revocations; it is intercepted at the next scan without re-keying the rest of the run.

Large catalogues

Listings are paged. Follow the cursor until complete is true. The first page is not the whole catalogue.

curl "https://onpack.link/api/products?limit=1000&cursor=$CURSOR" \
  -H "Authorization: Bearer $ONPACK_KEY"

{ "products": [ { "key": "gtin:09506000134352", "gtin14": "...", "name": "..." } ],
  "cursor": "...", "complete": false }

Write in batches of up to 1000 with POST /api/products/bulk. Rows that fail are reported individually; the rest are still written.

All endpoints

MethodPathWhat it doesAuth
GET/01/{gtin}Resolve a scan. Content-negotiated: JSON-LD for machines, page or redirect for people.public
GET/01/{gtin}/10/{lot}/21/{serial}Resolve with batch and serial qualifiers, in GS1 sequence.public
GET/gtin/{gtin}/lot/{lot}/ser/{serial}The alphanumeric spelling of the same link.public
GET/00/{sscc}Resolve a logistics unit.public
GET/414/{gln}Resolve a physical location.public
GET/01/{gtin}?linkType=gs1:instructionsRedirect to one typed link. Unavailable types fall back to the default.public
GET/01/{gtin}?linkType=linksetThe RFC 9264 linkset: every link the code offers. Accept: application/linkset+json does the same.public
GET/01/{gtin}?linkType=allDeprecated spelling of linkType=linkset, still accepted.public
GET/eh{hex} or /ex{base64}Resolve a compressed Digital Link URI carrying an EPC binary string.public
GET/p/{key}A product's hosted landing page. Scans redirect here rather than being served in place.public
GET/.well-known/gs1resolverResolver description file declaring supported keys and capabilities.public
GET/tools/labelGenerate QR artwork for any GTIN. No account, stores nothing.public
OPTIONS/01/{gtin}Declares GET, HEAD and OPTIONS. HEAD is answered on every resolver route.public
PUT/api/products/{gtin}Create or replace a product configuration.API key
PUT/api/products/{gtin}/imageUpload the product photo as raw JPEG, PNG or WebP bytes, 2 MB max. Hosted back at /img/{gtin}.API key
GET/api/products/{gtin}/analyticsScan analytics: totals, a 30 day daily series and top countries.API key
GET/api/products/{gtin}/liveWebSocket feed of scans as they happen. Pass the key as ?token= since browser sockets cannot send headers.API key
GET/api/products/{gtin}Read one configuration. Private keys are redacted.API key
DELETE/api/products/{gtin}Remove a configuration.API key
GET/api/products?limit&cursorPage through the catalogue. Always follow the cursor.API key
POST/api/products/bulkImport up to 1000 products, reporting per-row failures.API key
PUT/api/products/sscc/{sscc}Configure a logistics unit. Same shape for /gln/{gln}.API key
GET/api/products/sscc/{sscc}Read a logistics unit or location config. Same shape for /gln/{gln}.API key
DELETE/api/products/sscc/{sscc}Remove it. Same shape for /gln/{gln}.API key
PUT/api/products/{gtin}/lots/{lot}Set a batch's origin, dates, links and content.API key
GET/api/products/{gtin}/lotsPage through a product's batches.API key
GET/api/products/{gtin}/lots/{lot}Read one batch.API key
DELETE/api/products/{gtin}/lots/{lot}Remove one batch.API key
POST/api/products/{gtin}/lots/bulkImport up to 1000 batches at once.API key
POST/api/products/{gtin}/lots/{lot}/recallRecall one batch.API key
DELETE/api/products/{gtin}/lots/{lot}/recallLift a batch recall, leaving the batch's other data intact.API key
POST/api/products/{gtin}/recallRecall every batch of a SKU.API key
DELETE/api/products/{gtin}/recallLift a SKU-wide recall.API key
GET/api/products/{gtin}/labelQR SVG, element string, print size and placement check.API key
POST/api/verify-artworkCheck the linear barcode against the 2D code before printing.API key
POST/api/preview-pageRender a product page from a posted config without saving it. Used by the console preview.API key
POST/api/products/{gtin}/keysGenerate a signing keypair. The private key is returned once and never stored.API key
POST/api/products/{gtin}/keys/importRegister a public key you generated offline.API key
POST/api/products/{gtin}/signSign one serial. You supply the private key; we never keep it.API key
POST/api/products/{gtin}/authRequire or stop requiring signed serials.API key
POST/api/products/{gtin}/revocationsRevoke a single serial number.API key
GET/api/products/{gtin}/revocationsList revoked serials for a product.API key
DELETE/api/products/{gtin}/revocations/{serial}Restore a revoked serial.API key
GET/api/products/{gtin}/statsScan totals: human, machine, suspicious.API key
GET/api/products/{gtin}/auditWho changed what, newest first.API key
GET/api/meWho the caller is: signed-in user, brand account, and claim status.API key
POST/api/account/claimAsk for a brand account by naming a domain. Returns the TXT record to publish.API key
POST/api/account/verifyCheck for the TXT record. The account is created the moment it is visible.API key
POST/api/account/keyIssue a fresh API key for your account. The previous one stops working.API key
GET/api/claimsClaims on a prefix another account already holds. Everything else verifies itself.root only
POST/api/claims/{userId}/approveApprove a claim and create the account. Prefixes and host can be corrected here.root only
POST/api/claims/{userId}/denyTurn down a claim, with a reason the asker can see.root only
POST/api/tenantsCreate a brand account scoped to GS1 company prefixes.root only
GET/api/tenantsList brand accounts.root only
PUT/api/tenants/{id}/hostGive a brand its own host, e.g. nike.onpack.link.root only
POST/api/hostnamesBring your own domain, e.g. onpack.nike.com. Returns the DNS records to add.API key
GET/api/hostnames/wiringWhether certificate issuance is wired to Cloudflare. Operational check.root only
GET/api/domainconnect?hostnameAsk the hostname's DNS provider for a one click Domain Connect setup link. Null when unsupported.API key
GET/api/hostnamesList the domains registered to your account.API key
GET/api/hostnames/{hostname}Check a domain's certificate status.API key
DELETE/api/hostnames/{hostname}Stop serving one of your domains.API key
DELETE/api/tenants/{id}Revoke a brand account's key.root only
POST/api/maintenance/purge-private-keysStrip private keys written by older versions.root only

Machine clients get this list as JSON: curl -H "Accept: application/json" /docs.

Errors

Admin errors return { "error": "..." } with the status that fits: 400 for a malformed request, 401 for a missing or unknown key, 403 for a key outside its GS1 prefixes, 404 for something not configured, 409 for a conflicting state and 422 for artwork that fails its checks. Resolution adds 300 when several links match a requested type and nothing picks between them.

The resolver follows the GS1 standard exactly: a request URI that fails validation is 400, a valid one the resolver has no record of is 404, and neither is ever a 200. A person scanning a box still gets somewhere useful, because those responses carry an HTML page with a link onward to the brand, rather than a redirect that would hide the error from machines.