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.
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
| Method | Path | What it does | Auth |
|---|---|---|---|
| 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:instructions | Redirect to one typed link. Unavailable types fall back to the default. | public |
| GET | /01/{gtin}?linkType=linkset | The RFC 9264 linkset: every link the code offers. Accept: application/linkset+json does the same. | public |
| GET | /01/{gtin}?linkType=all | Deprecated 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/gs1resolver | Resolver description file declaring supported keys and capabilities. | public |
| GET | /tools/label | Generate QR artwork for any GTIN. No account, stores nothing. | public |
/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}/image | Upload the product photo as raw JPEG, PNG or WebP bytes, 2 MB max. Hosted back at /img/{gtin}. | API key |
| GET | /api/products/{gtin}/analytics | Scan analytics: totals, a 30 day daily series and top countries. | API key |
| GET | /api/products/{gtin}/live | WebSocket 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&cursor | Page through the catalogue. Always follow the cursor. | API key |
| POST | /api/products/bulk | Import 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}/lots | Page 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/bulk | Import up to 1000 batches at once. | API key |
| POST | /api/products/{gtin}/lots/{lot}/recall | Recall one batch. | API key |
| DELETE | /api/products/{gtin}/lots/{lot}/recall | Lift a batch recall, leaving the batch's other data intact. | API key |
| POST | /api/products/{gtin}/recall | Recall every batch of a SKU. | API key |
| DELETE | /api/products/{gtin}/recall | Lift a SKU-wide recall. | API key |
| GET | /api/products/{gtin}/label | QR SVG, element string, print size and placement check. | API key |
| POST | /api/verify-artwork | Check the linear barcode against the 2D code before printing. | API key |
| POST | /api/preview-page | Render a product page from a posted config without saving it. Used by the console preview. | API key |
| POST | /api/products/{gtin}/keys | Generate a signing keypair. The private key is returned once and never stored. | API key |
| POST | /api/products/{gtin}/keys/import | Register a public key you generated offline. | API key |
| POST | /api/products/{gtin}/sign | Sign one serial. You supply the private key; we never keep it. | API key |
| POST | /api/products/{gtin}/auth | Require or stop requiring signed serials. | API key |
| POST | /api/products/{gtin}/revocations | Revoke a single serial number. | API key |
| GET | /api/products/{gtin}/revocations | List revoked serials for a product. | API key |
| DELETE | /api/products/{gtin}/revocations/{serial} | Restore a revoked serial. | API key |
| GET | /api/products/{gtin}/stats | Scan totals: human, machine, suspicious. | API key |
| GET | /api/products/{gtin}/audit | Who changed what, newest first. | API key |
| GET | /api/me | Who the caller is: signed-in user, brand account, and claim status. | API key |
| POST | /api/account/claim | Ask for a brand account by naming a domain. Returns the TXT record to publish. | API key |
| POST | /api/account/verify | Check for the TXT record. The account is created the moment it is visible. | API key |
| POST | /api/account/key | Issue a fresh API key for your account. The previous one stops working. | API key |
| GET | /api/claims | Claims on a prefix another account already holds. Everything else verifies itself. | root only |
| POST | /api/claims/{userId}/approve | Approve a claim and create the account. Prefixes and host can be corrected here. | root only |
| POST | /api/claims/{userId}/deny | Turn down a claim, with a reason the asker can see. | root only |
| POST | /api/tenants | Create a brand account scoped to GS1 company prefixes. | root only |
| GET | /api/tenants | List brand accounts. | root only |
| PUT | /api/tenants/{id}/host | Give a brand its own host, e.g. nike.onpack.link. | root only |
| POST | /api/hostnames | Bring your own domain, e.g. onpack.nike.com. Returns the DNS records to add. | API key |
| GET | /api/hostnames/wiring | Whether certificate issuance is wired to Cloudflare. Operational check. | root only |
| GET | /api/domainconnect?hostname | Ask the hostname's DNS provider for a one click Domain Connect setup link. Null when unsupported. | API key |
| GET | /api/hostnames | List 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-keys | Strip 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.