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

GET/brandsList your brands
GET/brands/:slug/skusList SKUs (paged)
POST/brands/:slug/skusCreate a SKU
GET/brands/:slug/skus/:idOne SKU with its batches
PATCH/brands/:slug/skus/:idUpdate a SKU
DELETE/brands/:slug/skus/:idDelete a SKU
POST/brands/:slug/skus/:id/publishTake the product live
POST/brands/:slug/skus/:id/unpublishWithdraw it
GET/brands/:slug/skus/:id/editsEvery save on the product, who and what

Pack Identity · dpp:*

GET/brands/:slug/skus/:id/passportThe passport: registry, values, completeness, documents
PATCH/brands/:slug/skus/:id/passportMerge fields into the passport
GET/brands/:slug/skus/:id/passport/revisionsPublished revisions, newest first
GET/brands/:slug/skus/:id/passport/revisions/:numberOne revision with its diff
POST/brands/:slug/skus/:id/passport/revisions/:number/restorePublish an old revision as a new one

Pack Experience · marketing:*

POST/brands/:slug/skus/:id/batchesGenerate a batch of unit codes
GET/brands/:slug/skus/:sku_id/batches/:idBatch status and export state
GET/brands/:slug/skus/:sku_id/batches/:id/downloadDownload batch CSV
GET/brands/:slug/scansRecent scans
GET/brands/:slug/scans/:idOne scan with full outcome
GET/brands/:slug/collectorsAudience that scanned this brand
GET/brands/:slug/collectors/:idOne collector with history
GET/brands/:slug/redemptionsReward redemptions
GET/brands/:slug/webhook_endpointsList, create, update, delete endpoints; rotate a secret

Tools · any plan, any scope

GET/brands/:slug/link_codesList, create, update, delete link codes by token
GET/brands/:slug/link_codes/:token/scansIts scans, newest first
GET/brands/:slug/link_codes/:token/code.svgThe artwork (.svg or .png; ?kind=fixed)
GET/brands/:slug/outreachesList, create, update, archive, delete mailings
GET/brands/:slug/outreaches/:id/recipientsThe list — filtered to opened / engaged / unopened — and bulk create
GET/brands/:slug/outreaches/:id/codesZIP: the list as CSV plus a code per recipient

Public · no token

GET/api/public/v1/01/:gtinA published passport, as a retailer reads it

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

GET/brands/:slug/skus
ParamTypeDescription
include_drafts"true"Include unpublished products. Default: live only.
limitint, ≤1000Default 1000.
offsetintDefault 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.

FieldTypeDescription
namestringInternal SKU label. Required.
display_namestringThe name shown on the public page, when it differs from the internal one.
article_numberstringYour own key — what a print vendor's MIS and your ERP join on.
gtinstringGS1 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.
prefixstring2–8 uppercase alphanumerics, unique within the brand; the middle of every unit code. Returned in its full BRAND-PREFIX form.
code_modeenumWhich codes this product carries: both, public_only (the GTIN page, no unit codes) or private_only.
page_modeenumfull — the themed product page — or e_label: mandatory particulars only, no tracking, no marketing, the mode wine's e-label duty requires.
product_kindenumfood (food and drink) or non_food; decides which passport fields are in scope.
descriptionstringOptional internal note.
image_urlstringOptional product image, fetched and stored.
model_3d_urlstringOptional 3D model for the reveal.
rarity_enabledboolWhether 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
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" }'
JSON
{
  "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

POST/brands/:slug/skus/:id/publish
POST/brands/:slug/skus/:id/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.

JSON
{ "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

GET/brands/:slug/skus/:id/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.

JSON
{
  "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

GET/brands/:slug/skus/:id/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.

JSON
{
  "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/brands/:slug/skus/:id/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
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

GET/brands/:slug/skus/:id/passport/revisions
GET/brands/:slug/skus/:id/passport/revisions/:number
POST/brands/:slug/skus/:id/passport/revisions/:number/restore

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.

JSON
{
  "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

GEThttps://onpack.io/api/public/v1/01/:gtin

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=deExplicit, wins.
Accept-LanguageNegotiated against the languages the product publishes; the response is Vary: Accept-Language.
neitherThe 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.

JSON
{
  "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.

POST/brands/:slug/skus/:id/batches
FieldTypeDescription
quantityintUnits to mint. Required.
purposeenumproduction or test. Test codes never count against the plan and never reach analytics.
notesstringOptional — 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
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" }'
201
{ "id": 22, "number": 12, "sku": "NFD-HNY", "quantity": 100000, "purpose": "production",
  "status": "generating", "overage": null }
402
{ "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
  } }
GET/brands/:slug/skus/:sku_id/batches/:id
GET/brands/:slug/skus/:sku_id/batches/:id/download

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.

JSON
{
  "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

GET/brands/:slug/scans
ParamTypeDescription
sinceISO 8601Return scans created on or after this timestamp.
untilISO 8601Return scans created on or before this timestamp.
limitint, ≤1000Default 100.
offsetintDefault 0.
curl
curl "https://onpack.io/api/v1/brands/northfield/scans?since=2026-04-01T00:00:00Z&limit=50" \
  -H "Authorization: Bearer pkd_••••••••••••••••"
Ruby
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"]
JSON
{
  "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)

GET/brands/:slug/scans/:id

Returns the same shape that the scan.processed webhook delivers — so your ingestion code can use one parser for both paths.

JSON
{
  "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.

i
Email and other PII are intentionally omitted. Consumer identity surfaces as an opaque id. Pair it with your own CRM if you need contact info — ask for consent first.

List collectors

GET/brands/:slug/collectors

limit (default 100, ≤1000) and offset; the body is the same total / limit / offset / data envelope as scans.

curl
curl "https://onpack.io/api/v1/brands/northfield/collectors?limit=20" \
  -H "Authorization: Bearer pkd_••••••••••••••••"

Retrieve one collector

GET/brands/:slug/collectors/:id

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.

JSON
{
  "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.

GET/brands/:slug/redemptions
ParamTypeDescription
statusenumpending, fulfilled or expired. Default: all.
since / untilISO 8601As for scans.
limit / offsetintDefault 100 (≤1000) / 0.
JSON
{
  "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" }
  ]
}

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.

GET/brands/:slug/link_codes
POST/brands/:slug/link_codes
GET/brands/:slug/link_codes/:token
PATCH/brands/:slug/link_codes/:token
DELETE/brands/:slug/link_codes/:token
GET/brands/:slug/link_codes/:token/scans
GET/brands/:slug/link_codes/:token/code.svg

Fields

Accepted on POST/PATCH.

FieldTypeDescription
labelstring, ≤120Required. Only the brand sees it.
destination_urlhttps URLRequired. 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.
utmboolAppend utm_source=onpack&utm_medium=qr&utm_campaign=<label> on the redirect.
tracking_paramsquery stringThe brand's own parameters, appended on the redirect (ref=poster&src=messe).
notify_first_scanboolMail on the first scan. Default true.
notify_everyint ≥ 10Mail every N scans. Null for none.
notify_unreachableboolMail when the page stops answering (checked every three hours). Default true.
notify_emailemailWho 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
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 }'
201
{ "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

GET/brands/:slug/link_codes/:token/scans
ParamTypeDescription
sinceISO 8601Scans on or after this timestamp.
untilISO 8601Scans on or before this timestamp.
limitint, ≤1000Default 100. Paged with X-Total-Count and Link: rel="next".
offsetintDefault 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

GET/brands/:slug/link_codes/:token/code.svg
GET/brands/:slug/link_codes/:token/code.png

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.

GET/brands/:slug/outreaches?archived=true for the archived ones
POST/brands/:slug/outreaches
GET/brands/:slug/outreaches/:id
PATCH/brands/:slug/outreaches/:idalso archived: true|false
DELETE/brands/:slug/outreaches/:idevery code in it goes dark
GET/brands/:slug/outreaches/:id/codesZIP

The page

Accepted on POST/PATCH. A change reaches every recipient's page at once — letters already sent show the new version.

FieldTypeDescription
namestring, ≤120Required. Only the brand sees it.
headlinestring, ≤160Required. May carry {{company}}, {{name}}, {{salutation}}; the page fills them from the recipient. An unknown field is a 422.
introtext, ≤2000Same merge fields. A blank line starts a paragraph; a paragraph left with nothing but punctuation (a missing salutation) is dropped.
sku_ids[int], ≤12The products shown as cards, in this order. Must be the brand's. A recipient with products of their own shows those instead.
cta_kindnone · email · phone · linkThe one button at the end of the page.
cta_labelstring, ≤80What it says. Defaults per kind.
cta_valuestringAn email address, a phone number or a full https:// URL, as cta_kind says.
notify_first_openboolMail on each recipient's first open. Default true.
notify_emailemailWho 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

GET/brands/:slug/outreaches/:id/recipients
POST/brands/:slug/outreaches/:id/recipients
GET/brands/:slug/outreaches/:id/recipients/:token
DELETE/brands/:slug/outreaches/:id/recipients/:token
GET/brands/:slug/outreaches/:id/recipients/:token/code.svg

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.

FieldTypeDescription
companystring, ≤160One of company and name is required.
namestring, ≤160The person.
salutationstring, ≤160"Sehr geehrte Frau Huber" — written as it should appear.
referencestring, ≤80The brand's own id for them; names the code's file.
notetext, ≤1000A line for this recipient alone, set apart on their page.
productsstring 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.
ParamTypeDescription
filteropened · engaged · unopenedThe call list is engaged; the reminder list is unopened. Default: everyone.
limitint, ≤1000Default 1000. Paged with X-Total-Count and Link: rel="next".
offsetintDefault 0.
curl
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." } ] }'
201
[ { "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.

GET/brands/:slug/webhook_endpoints
POST/brands/:slug/webhook_endpoints
GET/brands/:slug/webhook_endpoints/:id
PATCH/brands/:slug/webhook_endpoints/:id
DELETE/brands/:slug/webhook_endpoints/:id
POST/brands/:slug/webhook_endpoints/:id/rotate_secret

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
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"] }'
201
{ "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.

JSON
{ "error": "Validation failed", "details": { "gtin": ["check digit is wrong"] } }