Authentication
Bearer tokens for the REST API.
Every call to api/v1 carries one header —
Authorization: Bearer pkd_…. No signing, no nonces, no clock
skew to worry about. HTTPS handles the transport. The public passport feed
under api/public/v1 carries nothing at all.
Token shape
A token is pkd_ followed by 48 hex characters
(24 random bytes). It is opaque — don't parse it. onpack stores only a
SHA-256 digest, so the plaintext is shown exactly once, when it is issued.
Tokens are named and a company can hold as many as it likes — one per integration, so revoking the one a contractor used doesn't take the ERP feed down with it. Each one carries:
| Attribute | Meaning |
|---|---|
| Scopes | One or more of dpp:read, dpp:write, marketing:read, marketing:write. dpp is Pack Identity — the passport, its revisions, the passport.published and document.expiring events. marketing is Pack Experience — units, batches, scans, collectors, redemptions, webhook endpoints. The catalogue (/brands, /skus, /edits) accepts either product's grant at the needed access level. |
| Brand | Optional. Left blank, the token reaches every brand the company owns; set, it reaches that brand only — anything else answers 404, so a narrowed token can't be used to enumerate the rest of the portfolio. |
| Issued by | The team member who created it. Every passport revision written through the API records both the token and that person. |
Issuing one
Sign in, open the brand dashboard, go to Settings → API, give
the token a name, tick its scopes, optionally pin it to one brand, and click
Issue token. Copy the value from the banner — it is not
shown again. Only an admin or an account manager
on that brand sees the page.
Sending the token
curl https://onpack.io/api/v1/brands \
-H "Authorization: Bearer pkd_••••••••••••••••"
require "net/http"
uri = URI("https://onpack.io/api/v1/brands")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{ENV['ONPACK_API_TOKEN']}"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
const res = await fetch("https://onpack.io/api/v1/brands", {
headers: { Authorization: `Bearer ${process.env.ONPACK_API_TOKEN}` }
});
Responses you should expect
| Status | Meaning |
|---|---|
200 / 201 | Request succeeded. Body is JSON; 204 on a delete. |
400 | A parameter could not be read — a since that isn't ISO 8601, for example. error says which. |
401 | Missing, unknown, revoked or expired token. Deliberately one message for all four. |
402 | Not a permission problem — a plan one. error is product_not_in_plan when the company doesn't own the product this endpoint belongs to, overage_confirmation_required when a batch would exceed the included units (re-send with confirm_overage=true), or the reason code generation is paused on the account. |
403 | insufficient_scope. The token exists but wasn't granted this permission; needed and granted in the body say which. |
404 | Resource not found or outside this token's brand. |
422 | Validation failed. Field-level messages in details. |
429 | rate_limited. Over the ceiling below; Retry-After (seconds) and retry_after in the body say when the window opens again. |
{
"error": "insufficient_scope",
"needed": "dpp:write",
"granted": ["dpp:read", "marketing:read"],
"message": "This token does not have the dpp:write permission."
}
Rate limits
60 requests a minute per token, across every endpoint,
reads and writes alike. Beyond that every request answers 429
until the minute turns over. A catalogue sync that writes one product a
second sits exactly on the line — space calls out, or batch what you can:
a passport PATCH carries every field in one call, and the
catalogue reads page at up to 1,000 products a call. The unauthenticated
passport feed has its own ceiling of 60 a minute per IP.
HTTP/1.1 429 Too Many Requests
Retry-After: 23
Content-Type: application/json
{ "error": "rate_limited", "retry_after": 23,
"message": "Rate limit exceeded: 60 requests a minute. Try again in 23 seconds." }
Rotating a token
Issue the new token first, deploy it, then Revoke the old
one from Settings → API. Revocation is immediate; because
tokens are individual there is no forced overlap window — you choose it.
A revoked token stays listed with who issued it and when it was last used.
marketing:write can mint production codes that go to a
printer; treat it like a payment credential.