Reference
REST API.
Base URL https://onpack.io/api/v1. JSON in, JSON out. Every
path below /brands/:slug is scoped to that brand, and access
is gated by the bearer token
and its scopes. The one unauthenticated surface is the
public passport feed.
Endpoints at a glance
Catalogue · any scope
Pack Identity · dpp:*
Pack Experience · marketing:*
Tools · any plan, any scope
?kind=fixed)Public · no token
SKUs
The catalogue both products hang off — a SKU is what a passport describes
and what codes are printed for. Any product's grant at the needed access
level will do (dpp:read or marketing:read to list,
either :write to change).
List SKUs
| Param | Type | Description |
|---|---|---|
include_drafts | "true" | Include unpublished products. Default: live only. |
limit | int, ≤1000 | Default 1000. |
offset | int | Default 0. |
The body is a bare array. The page is described in headers:
X-Total-Count carries the total, and when there is more a
Link: <…>; rel="next" header points at the next page.
SKU fields
Accepted on POST/PATCH.
| Field | Type | Description |
|---|---|---|
name | string | Internal SKU label. Required. |
display_name | string | The name shown on the public page, when it differs from the internal one. |
article_number | string | Your own key — what a print vendor's MIS and your ERP join on. |
gtin | string | GS1 GTIN in 8-, 12-, 13- or 14-digit form; normalised and returned as 14 digits. Validated for the check digit, uniqueness, and — once your company declares its GS1 Company Prefix(es) — that it falls under one you own. Anchors the public /01/{GTIN} page. |
prefix | string | 2–8 uppercase alphanumerics, unique within the brand; the middle of every unit code. Returned in its full BRAND-PREFIX form. |
code_mode | enum | Which codes this product carries: both, public_only (the GTIN page, no unit codes) or private_only. |
page_mode | enum | full — the themed product page — or e_label: mandatory particulars only, no tracking, no marketing, the mode wine's e-label duty requires. |
product_kind | enum | food (food and drink) or non_food; decides which passport fields are in scope. |
description | string | Optional internal note. |
image_url | string | Optional product image, fetched and stored. |
model_3d_url | string | Optional 3D model for the reveal. |
rarity_enabled | bool | Whether units of this SKU draw a rarity tier on claim. |
Live example:
https://onpack.io/01/09524000123459
— a fully populated product passport, in English and German, with no
authentication. Append ?linkType=linkset for the GS1 linkset,
or send Accept: application/ld+json for the JSON-LD product
information payload.
curl -X POST "https://onpack.io/api/v1/brands/northfield/skus" \
-H "Authorization: Bearer pkd_••••••••••••••••" \
-H "Content-Type: application/json" \
-d '{ "name": "Northfield Wildflower Honey 340 g", "prefix": "HNY", "gtin": "9524000123459", "code_mode": "public_only" }'
{
"id": 31,
"name": "Northfield Wildflower Honey 340 g",
"prefix": "NFD-HNY",
"gtin": "09524000123459",
"article_number": null,
"description": null,
"page_mode": "full",
"slug": "wildflower-honey-340-g",
"public_code_url": "https://onpack.io/01/09524000123459",
"published": false,
"units_count": 0,
"scans_count": 0
}
public_code_url is what the printed code must resolve to. Build
it from this field, never from the GTIN: a brand on a verified custom domain
swaps the origin, and a product with no GTIN resolves at
/{brand}/p/{slug}. units_count and
scans_count are Pack Experience figures and read
0 on a brand that doesn't run it. GET …/skus/:id adds
a batches array.
A key the endpoint doesn't know is a 400 naming it, never a
silent drop — {"published": true} used to answer 200 and do
nothing. Publishing has its own verb:
Publish and unpublish
The same act as the editor's Publish button, through the same gate. A new
product goes live — its page resolves, a first revision is frozen and
passport.published fires. Refused with a reason the client can
act on: 422 empty_page when the page has nothing on it (no
photo, no description, not one passport field), 409 needs_approval
on a brand with four-eyes sign-off and nothing signed off, 402
plan_limit at the plan's SKU limit. Completeness is reported, never
enforced — the response carries passport_completeness for the
primary language, the number the editor's confirm dialog says, and going
live short is the brand's decision. Publishing an already-live product is a
no-op 200. Unpublishing is always allowed; printed codes keep
resolving to the withdrawn-product page, and the public feed answers
410.
{ "id": 31, "name": "Northfield Wildflower Honey 340 g", "published": true, "…": "…",
"passport_completeness": {
"locale": "de", "regime": "fic", "filled": 7, "total": 9, "percentage": 78,
"complete": false, "missing": ["storage", "usage"] } }
Edits
The keystroke-level history of one product — every save, published or not,
covering name, GTIN, image and code mode as well as the passport.
limit defaults to 50, at most 200; the body carries
total so a client can tell when it has the whole story.
{
"total": 12, "limit": 50,
"edits": [
{ "at": "2026-08-30T09:12:41Z", "event": "update", "actor": "[email protected]",
"changes": [ { "key": "passport", "from": { "…": "…" }, "to": { "…": "…" } } ] }
]
}
Passports
The product-page content Pack Identity sells. Its own resource
rather than keys on the SKU: its own permissions (dpp:read /
dpp:write), its own shape — a registry, not a column list — and
its own revisions.
Read the passport
The registry travels with the values, so a client never hardcodes the field
list. Each entry carries its type (text,
textarea, decimal, enum, list,
components), unit, regime (which law
names the field), options for lists and enums, and
translatable. A translatable field's value is the
whole locale map; display is the primary language rendered for
a human. completeness is reported per language — a page complete
in German and half-written in English is not 71 % complete — and against the
rulebook the product's product_kind falls under:
regime: "fic" for food and drink (the Reg. 1169/2011
particulars, nutrition counted as one block), "non_food" for
everything else (composition, durability and repair, the responsible
operator; no nutrition). Fields marked regime: "general" are
never counted, nor are those marked voluntary: true — a
"may contain" line, a note under the nutrition table — which the rulebook
knows but leaves to the brand.
{
"sku_id": 31,
"gtin": "09524000123459",
"locales": ["de", "en"],
"primary_locale": "de",
"url": "https://onpack.io/01/09524000123459",
"fields": [
{
"key": "legal_name", "label": "Legal name of the food",
"group": "identity", "type": "text", "regime": "fic",
"translatable": true,
"value": { "de": "Blütenhonig", "en": "Wildflower honey" },
"display": "Blütenhonig"
},
{
"key": "allergens_contains", "label": "Contains",
"group": "contents", "type": "list", "regime": "fic",
"options": { "milk": "Milk", "nuts": "Nuts", "…": "…" },
"translatable": false,
"value": ["none"],
"display": "None of the 14 Annex II allergens"
},
{
"key": "energy_kj", "label": "Energy",
"group": "nutrition", "type": "decimal", "unit": "kJ", "regime": "fic",
"translatable": false,
"value": 1380, "display": "1380 kJ"
}
],
"groups": { "identity": "What it is", "contents": "What's in it", "…": "…" },
"completeness": {
"de": { "regime": "fic", "filled": 9, "total": 9, "percentage": 100,
"complete": true, "missing": [], "untranslated": [],
"nutrition_complete": true, "nutrition_partial": false },
"en": { "regime": "fic", "filled": 7, "total": 9, "percentage": 78,
"complete": false, "missing": [], "untranslated": ["story", "storage"],
"nutrition_complete": true, "nutrition_partial": false }
},
"documents": [
{ "id": 12, "kind": "certificate", "title": "AMA-Gütesiegel 2026",
"issuer": "AMA-Marketing", "backs": "certifications",
"issued_on": "2026-01-10", "expires_on": "2027-01-09", "status": "valid",
"shown_on_page": true, "filename": "ama-2026.pdf", "byte_size": 184201 }
],
"updated_at": "2026-08-30T09:12:41Z"
}
Write to the passport
PATCH merges, it does not replace: a client updating one field
must not blank the other forty because they were not in the body. The merge
goes one level deeper on a translatable field, so sending only the German
leaves the English alone. An explicit empty string clears a field. A bare
string on a translatable field lands in the product's primary language.
A value that doesn't coerce — "ca. 1450" for an energy figure —
is a 422 naming the field, not a silent drop. A bare string on
fbo_name or fbo_address is the usual surprise: they
are translatable, so send { "de": …, "en": … } or the English
meter stays short.
allergens_contains takes keys from the registry's
options — or the one value it doesn't list, "none":
a food that contains none of the fourteen declares that explicitly, and it
is the only way an allergen-free product reaches a complete passport. Not
accepted on allergens_may_contain. Fields the registry marks
readonly are derived — energy_kcal is worked out
from the kilojoules — and a value sent for one is ignored.
Writing to a draft does not publish it — that is
POST …/skus/:id/publish, above. Writing to a live
product is a publication: the save freezes a new revision and emits
passport.published with the fields that changed, unless the
brand requires four-eyes sign-off, in which case the revision waits for an
approver and the public page keeps serving the last approved one.
curl -X PATCH "https://onpack.io/api/v1/brands/northfield/skus/31/passport" \
-H "Authorization: Bearer pkd_••••••••••••••••" \
-H "Content-Type: application/json" \
-d '{ "passport": {
"ingredients": { "de": "Blütenhonig", "en": "Wildflower honey" },
"allergens_contains": ["none"],
"energy_kj": 1380,
"storage": "Kühl und trocken lagern."
} }'
Revisions
Every publication is an immutable revision, addressed by its
number — "revision 4 of this product" is what a brand and a
regulator refer to, a global row id is not. source is one of
ui, api, import, restore
or system; actor is the person's email, or for an
API write the token — sap-sync (API token, issued by
[email protected]) — so a system's writes never read as an employee's. show adds
changes against the previous revision. restore
(dpp:write) publishes an old revision as a new one —
it creates, it never moves a pointer — and records both the token and the
person who issued it; an optional note is kept with it.
{
"number": 4,
"published_at": "2026-08-30T09:12:41Z",
"source": "ui",
"actor": "[email protected]",
"restored_from": null,
"note": null,
"passport": { "legal_name": { "de": "Blütenhonig", "en": "Wildflower honey" }, "…": "…" },
"product_fields": { "name": "Northfield Wildflower Honey 340 g", "gtin": "09524000123459", "…": "…" }
}
Public passport feed
The unauthenticated read surface for retailers, marketplaces and aggregators: the same content the product page serves, shaped for a machine that wants a stable contract rather than a document. It contains only what is already public — published values, presentable documents — and resolves through the same path the page does, so a machine and a shopper never get different answers from one identifier.
| Selecting a language | |
|---|---|
?lang=de | Explicit, wins. |
Accept-Language | Negotiated against the languages the product publishes; the response is Vary: Accept-Language. |
| neither | The product's primary language. |
Responses are cacheable for five minutes. Throttled at 60 requests a minute
per IP — the same ceiling as the page. A GTIN nobody publishes is a
404; a passport the brand has withdrawn is a 410
with error: "passport_withdrawn" and the withdrawal date, the
same tombstone the HTML page shows.
{
"gtin": "09524000123459",
"name": "Wildflower Honey",
"brand": { "name": "Northfield" },
"url": "https://onpack.io/01/09524000123459",
"language": "en",
"available_languages": ["de", "en"],
"fields": [
{ "key": "legal_name", "label": "Legal name of the food", "group": "identity",
"value": "Wildflower honey", "display": "Wildflower honey", "language": "en" },
{ "key": "storage", "label": "Storage", "group": "keeping",
"value": "Kühl und trocken lagern.", "display": "Kühl und trocken lagern.",
"language": "de", "fallback": true }
],
"documents": [
{ "kind": "certificate", "title": "AMA-Gütesiegel 2026", "issuer": "AMA-Marketing",
"backs": "certifications", "issued_on": "2026-01-10", "expires_on": "2027-01-09",
"url": "https://onpack.io/northfield/d/12" }
],
"updated_at": "2026-08-30T09:12:41Z"
}
A field filled in another language than the one served carries
"fallback": true and says which language it is in —
the page marks it the same way.
Batches
Generate and download unit codes for a SKU. Pack Experience
only (402 product_not_in_plan otherwise). Batches are async —
you get a batch.generated webhook when the codes exist and a
batch.export_ready webhook when the file is available.
| Field | Type | Description |
|---|---|---|
quantity | int | Units to mint. Required. |
purpose | enum | production or test. Test codes never count against the plan and never reach analytics. |
notes | string | Optional — the print job, the line, whatever you'd want to see beside the run later. |
confirm_overage | "true" | Required when the run would exceed the units included in your plan. See below. |
A run that would push the month past its included units is refused once,
with 402 overage_confirmation_required and a full
preview of the charge, so an integration can put the number in
front of a human. Re-submit with confirm_overage=true to
acknowledge it; the response then carries the overage block
and the Stripe invoice item it became.
curl -X POST "https://onpack.io/api/v1/brands/northfield/skus/31/batches" \
-H "Authorization: Bearer pkd_••••••••••••••••" \
-H "Content-Type: application/json" \
-d '{ "quantity": 100000, "purpose": "production", "notes": "Run 12, line B" }'
{ "id": 22, "number": 12, "sku": "NFD-HNY", "quantity": 100000, "purpose": "production",
"status": "generating", "overage": null }
{ "error": "overage_confirmation_required",
"message": "This batch will exceed your plan's included quota. Re-submit with confirm_overage=true to acknowledge the charge.",
"preview": {
"requested_units": 100000, "units_already_used": 180000,
"included_units": 200000, "units_consuming_quota": 20000,
"overage_units": 80000, "overage_rate_eur": 0.006, "overage_cost_eur": 480.0,
"upgrade_recommended": true, "next_tier_key": "scale",
"next_plan_monthly_delta_eur": 2250.0
} }
export_status walks generating_units →
preparing_export → ready. Until it is
ready, download answers 202 with the
same JSON and requests the export if nobody has; poll it. Once ready it
streams the CSV, and export_checksum_sha256 /
export_row_count let the print vendor verify what they got.
{
"id": 22, "number": 12, "quantity": 100000, "purpose": "production",
"units_generated": true, "generated_at": "2026-04-12T08:31:02Z", "units_count": 100000,
"export_status": "ready",
"export_checksum_sha256": "9f2c…e1a0", "export_row_count": 100000,
"export_url": "https://onpack.io/api/v1/brands/northfield/skus/31/batches/22/download"
}
Scans
Every touchpoint with your packaging. Production scans only — tester activity is excluded. marketing:read.
List scans
| Param | Type | Description |
|---|---|---|
since | ISO 8601 | Return scans created on or after this timestamp. |
until | ISO 8601 | Return scans created on or before this timestamp. |
limit | int, ≤1000 | Default 100. |
offset | int | Default 0. |
curl "https://onpack.io/api/v1/brands/northfield/scans?since=2026-04-01T00:00:00Z&limit=50" \
-H "Authorization: Bearer pkd_••••••••••••••••"
require "net/http"; require "json"
uri = URI("https://onpack.io/api/v1/brands/northfield/scans?since=2026-04-01T00:00:00Z&limit=50")
req = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{ENV['ONPACK_API_TOKEN']}")
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
scans = JSON.parse(res.body)["data"]
{
"total": 8614, "limit": 50, "offset": 0,
"data": [
{ "id": 184293, "unit_code": "NFD-HNY-260412-7HGQ2", "sku_name": "Northfield Wildflower Honey 340 g",
"identified": true, "collector_id": 55821, "country": "DE", "city": "Berlin",
"scanned_at": "2026-04-23T15:04:12Z" }
]
}
Retrieve one scan (with outcome)
Returns the same shape that the
scan.processed webhook delivers — so your ingestion code
can use one parser for both paths.
{
"scan": {
"id": 184293,
"unit": { "id": 9102, "code": "NFD-HNY-260412-7HGQ2" },
"sku": { "id": 31, "name": "Northfield Wildflower Honey 340 g", "prefix": "NFD-HNY" },
"batch_id": 22,
"country": "DE", "city": "Berlin",
"device_type": "mobile",
"scanned_at": "2026-04-23T15:04:12Z"
},
"collector": { "id": 55821, "identified": true },
"eligibility": [
{
"activation": { "id": 8, "name": "Spring Instant Win", "activation_type": "instant_win" },
"outcome": "won",
"entry_id": 71284,
"reward": {
"id": 44, "name": "5€ voucher",
"reward_type": "coupon", "tier": "standard",
"face_value": 5.0
}
}
]
}
Collectors
The audience who has scanned at least one product from your brand. Strictly
brand-scoped — scans on other brands are never surfaced here, even if the
same consumer exists platform-wide. marketing:read.
id. Pair it with your own CRM if you need
contact info — ask for consent first.
List collectors
limit (default 100, ≤1000) and offset; the body is the same total / limit / offset / data envelope as scans.
curl "https://onpack.io/api/v1/brands/northfield/collectors?limit=20" \
-H "Authorization: Bearer pkd_••••••••••••••••"
Retrieve one collector
Returns brand-scoped activity: point balance, scan count, items claimed, plus the last 20 scans with their full outcomes. 404s if the collector has never scanned your brand.
{
"id": 55821,
"points": 320,
"scans": 12,
"items_claimed": 9,
"joined_at": "2026-02-11T08:44:00Z",
"recent_scans": [
{ "scan": { "...": "..." }, "collector": { "id": 55821, "identified": true }, "eligibility": [] }
]
}
Redemptions
Rewards that customers have claimed and redeemed at point-of-fulfilment. marketing:read.
| Param | Type | Description |
|---|---|---|
status | enum | pending, fulfilled or expired. Default: all. |
since / until | ISO 8601 | As for scans. |
limit / offset | int | Default 100 (≤1000) / 0. |
{
"total": 412, "limit": 100, "offset": 0,
"data": [
{ "id": 9911, "voucher_code": "NFD-7K2Q-M4", "status": "fulfilled",
"reward_name": "5€ voucher", "reward_type": "coupon", "face_value": 5.0, "points_spent": 250,
"consumer_id": 55821, "redeemed_at": "2026-04-24T11:02:19Z" }
]
}
Link codes
A short link with a QR for anything that isn't a pack — a poster, a flyer,
a menu — pointing at an https page of the brand's own. The code is
printed once; where it opens can change afterwards, and every scan is
counted. Part of every plan: owning either product is enough, and any
product's grant at the needed access level will do. Addressed by
token, the three-to-five-character id in the printed address,
in either case.
Fields
Accepted on POST/PATCH.
| Field | Type | Description |
|---|---|---|
label | string, ≤120 | Required. Only the brand sees it. |
destination_url | https URL | Required. A full https:// address on the brand's own site — never onpack's host. Every printed code follows a change. |
schedule | [{from, url}] | Optional. From each from (Vienna wall-clock, 2026-10-12T18:00) the code opens that url instead. |
utm | bool | Append utm_source=onpack&utm_medium=qr&utm_campaign=<label> on the redirect. |
tracking_params | query string | The brand's own parameters, appended on the redirect (ref=poster&src=messe). |
notify_first_scan | bool | Mail on the first scan. Default true. |
notify_every | int ≥ 10 | Mail every N scans. Null for none. |
notify_unreachable | bool | Mail when the page stops answering (checked every three hours). Default true. |
notify_email | Who hears. Null for whoever made the code, else the company's operational contacts. |
The response adds what the brand needs to print and to read: token,
short_url (on the brand's verified domain when it has one),
qr_payload (the address in uppercase — what the QR encodes),
resolved_destination (where a scan goes right now, tracking
applied), scans_count, last_scanned_at,
reachability (ok, status such as
HTTP 404, failing_since) and access:
live, grace (plan ended, redirects for 30 more
days) or expired.
curl -X POST "https://onpack.io/api/v1/brands/northfield/link_codes" \
-H "Authorization: Bearer pkd_••••••••••••••••" \
-H "Content-Type: application/json" \
-d '{ "label": "Trade-fair poster", "destination_url": "https://northfield.example/summer", "utm": true }'
{ "token": "k7m", "label": "Trade-fair poster",
"short_url": "https://onpack.io/l/k7m", "qr_payload": "HTTPS://ONPACK.IO/L/K7M",
"destination_url": "https://northfield.example/summer",
"resolved_destination": "https://northfield.example/summer?utm_source=onpack&utm_medium=qr&utm_campaign=trade-fair-poster",
"schedule": [], "utm": true, "tracking_params": null,
"scans_count": 0, "last_scanned_at": null,
"reachability": { "ok": true, "status": null, "checked_at": null, "failing_since": null, "url": null },
"notify": { "first_scan": true, "every": null, "unreachable": true, "email": null },
"access": "live", "created_at": "2026-10-01T09:12:00Z", "updated_at": "2026-10-01T09:12:00Z" }
Scans of a link code
| Param | Type | Description |
|---|---|---|
since | ISO 8601 | Scans on or after this timestamp. |
until | ISO 8601 | Scans on or before this timestamp. |
limit | int, ≤1000 | Default 100. Paged with X-Total-Count and Link: rel="next". |
offset | int | Default 0. |
A bare array, newest first, of { country, platform, scanned_at }
— platform is ios, android,
desktop or unknown. People only: crawlers are
recorded but never listed or counted. No IP address was kept — a redirect
gives nobody a moment to consent to more. For every scan as it happens,
subscribe to link_code.scanned.
Artwork
The QR as the dashboard serves it: 25×25 modules at error-correction level
Q, SVG for print, 1200 px PNG for screens. ?kind=fixed encodes
the destination itself instead of the short link — nothing to change or
count afterwards, and nothing that depends on onpack.
Outreach
A mailing with a page behind it. The brand writes one page — headline, text, products, one button — and every recipient gets their own code into it, printed on their letter; the page greets them by name, and the first open and the first tap on the button tell the brand. Over the API a CRM runs the whole loop: create the mailing, push the list, pull the codes, read back who opened and who engaged. Part of every plan, any product's grant at the needed access level.
?archived=true for the archived onesarchived: true|falseThe page
Accepted on POST/PATCH. A change reaches every recipient's page at once — letters already sent show the new version.
| Field | Type | Description |
|---|---|---|
name | string, ≤120 | Required. Only the brand sees it. |
headline | string, ≤160 | Required. May carry {{company}}, {{name}}, {{salutation}}; the page fills them from the recipient. An unknown field is a 422. |
intro | text, ≤2000 | Same merge fields. A blank line starts a paragraph; a paragraph left with nothing but punctuation (a missing salutation) is dropped. |
sku_ids | [int], ≤12 | The products shown as cards, in this order. Must be the brand's. A recipient with products of their own shows those instead. |
cta_kind | none · email · phone · link | The one button at the end of the page. |
cta_label | string, ≤80 | What it says. Defaults per kind. |
cta_value | string | An email address, a phone number or a full https:// URL, as cta_kind says. |
notify_first_open | bool | Mail on each recipient's first open. Default true. |
notify_email | Who hears. Null for whoever made the mailing. |
The response adds products (id and name), cta.href
(the mailto:, tel: or URL the button opens),
counts (recipients, opened,
engaged, opens), last_opened_at,
archived and access as for link codes.
Recipients
POST takes one recipient as a flat object, or
recipients: [ … ] for the whole list — all or nothing: a row
that can't be saved fails the call with its index and nothing
is minted. A mailing holds at most 5,000.
No postal address is taken; the letter is merged from the list the brand
already has, and what comes back is the reference it gave.
| Field | Type | Description |
|---|---|---|
company | string, ≤160 | One of company and name is required. |
name | string, ≤160 | The person. |
salutation | string, ≤160 | "Sehr geehrte Frau Huber" — written as it should appear. |
reference | string, ≤80 | The brand's own id for them; names the code's file. |
note | text, ≤1000 | A line for this recipient alone, set apart on their page. |
products | string or [string] | Names, article numbers or barcodes, matched to the catalogue: barcode, article number, prefix, exact name, then a start of a name that means one product. Anything ambiguous lands in unmatched_products, never guessed. Empty means the mailing's products. |
| Param | Type | Description |
|---|---|---|
filter | opened · engaged · unopened | The call list is engaged; the reminder list is unopened. Default: everyone. |
limit | int, ≤1000 | Default 1000. Paged with X-Total-Count and Link: rel="next". |
offset | int | Default 0. |
curl -X POST "https://onpack.io/api/v1/brands/northfield/outreaches/12/recipients" \
-H "Authorization: Bearer pkd_••••••••••••••••" \
-H "Content-Type: application/json" \
-d '{ "recipients": [
{ "reference": "H-1001", "company": "Hotel Post", "name": "Maria Huber",
"salutation": "Sehr geehrte Frau Huber", "products": ["Wildflower Honey", "4006381333931"],
"note": "We saw your new breakfast room in September." } ] }'
[ { "token": "aqpepqtr3rfa", "reference": "H-1001", "company": "Hotel Post", "name": "Maria Huber",
"salutation": "Sehr geehrte Frau Huber", "note": "We saw your new breakfast room in September.",
"page_url": "https://onpack.io/o/aqpepqtr3rfa", "qr_payload": "HTTPS://ONPACK.IO/O/AQPEPQTR3RFA",
"test_url": "https://onpack.io/o/aqpepqtr3rfa?test=…",
"products": [ { "id": 31, "name": "Northfield Wildflower Honey 340 g" } ], "own_products": true, "unmatched_products": [],
"opened": false, "first_opened_at": null, "last_opened_at": null, "opens": 0,
"engaged": false, "first_engaged_at": null, "last_engaged_at": null, "engagements": 0,
"created_at": "2026-10-01T09:14:00Z" } ]
page_url is what the code encodes — twelve random characters;
the page is not guessable and not indexed. test_url opens the
same page without counting, for checking a letter before it is printed.
The ZIP at /codes holds the list as a semicolon CSV (with a
url column for a print house that makes its own codes) plus
svg/ and png/ named <reference>-<token>;
one recipient's code is at /recipients/:token/code.svg or
.png, 29×29 modules at level Q. To hear about opens and taps
as they happen, subscribe to
outreach.opened and
outreach.engaged.
Webhook endpoints
Everything Settings → Webhooks does, over the API — so an
integration can register itself. Owning either product is enough, and a
read scope on either lists, a write scope on either changes — which events
the endpoint may carry is decided per event, by what the company owns.
Fields: url (public HTTPS; private and link-local addresses
are refused), active, subscribed_events[] from
the catalog — only events of
products the company owns are accepted. The plaintext
signing_secret appears in exactly two responses: the
201 from create and the body of
rotate_secret. Every other read shows
signing_secret_last_four.
curl -X POST "https://onpack.io/api/v1/brands/northfield/webhook_endpoints" \
-H "Authorization: Bearer pkd_••••••••••••••••" \
-H "Content-Type: application/json" \
-d '{ "url": "https://erp.example.at/onpack", "subscribed_events": ["passport.published", "document.expiring"] }'
{ "id": 7, "url": "https://erp.example.at/onpack", "active": true,
"subscribed_events": ["passport.published", "document.expiring"],
"signing_secret_last_four": "k3Qx", "last_delivered_at": null, "created_at": "2026-09-04T10:12:00Z",
"signing_secret": "whsec_••••••••••••••••••••••••k3Qx" }
Errors
Errors are JSON with an error string — a short code for the
ones a program should branch on, a sentence for the rest — sometimes with
details or a message. The
status table lists what
each code means.
{ "error": "Validation failed", "details": { "gtin": ["check digit is wrong"] } }