Reference
Webhooks.
onpack pushes signed POST requests to every endpoint you
register, for every event you subscribe to. Timestamps in the signature
block replay attacks; a quick HMAC check rejects anything that wasn't
sent by us.
Setting up an endpoint
From the dashboard, Settings → Webhooks → Add Webhook Endpoint.
Provide a public HTTPS URL — private and link-local addresses are refused,
and the resolved IP is pinned at delivery time — and pick the events you
care about. Or do the same over the API:
POST /brands/:slug/webhook_endpoints.
Every endpoint has its own signing secret, whsec_…. Copy it
the moment it's shown — the plaintext is never retrievable again. Rotate it
any time with Rotate Secret in the dashboard or
POST …/rotate_secret over the API; the new secret applies to
the next delivery.
Both products emit. A Pack Identity company is offered
passport.published and document.expiring; a
Pack Experience company the scan, promotion, reward and batch
events. An endpoint can only subscribe to events of products the company owns.
Request shape
Every delivery is a JSON POST with these headers:
| Header | Description |
|---|---|
X-Onpack-Event | Event name, e.g. scan.processed. |
X-Onpack-Delivery | Per-delivery id — use it to dedupe on retries. |
X-Onpack-Signature | t=<unix>,v1=<hex-hmac>. See verification below. |
User-Agent | onpack-webhooks/1.0 |
Body is a JSON envelope:
{
"id": "4b0…c912",
"event": "scan.processed",
"occurred_at": "2026-04-23T15:04:12Z",
"brand": { "id": 1, "slug": "northfield", "name": "Northfield", "prefix": "NFD" },
"data": { "...": "event-specific payload" }
}
Verifying the signature
Compute HMAC-SHA256 over timestamp + "." + raw_body
using your signing secret, then compare against the v1 value.
Reject any timestamp older than five minutes to block replays.
require "openssl"
def verify_onpack!(body, signature_header, secret, tolerance: 300)
parts = signature_header.to_s.split(",").map { |p| p.split("=", 2) }.to_h
timestamp = parts["t"].to_i
signature = parts["v1"].to_s
raise "stale webhook" if (Time.now.to_i - timestamp).abs > tolerance
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{body}")
raise "bad signature" unless Rack::Utils.secure_compare(expected, signature)
end
import crypto from "node:crypto";
export function verifyOnpack(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const ts = Number(parts.t);
if (Math.abs(Date.now()/1000 - ts) > toleranceSec) throw new Error("stale");
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw new Error("bad sig");
}
import hmac, hashlib, time
def verify_onpack(raw_body: bytes, header: str, secret: str, tolerance: int = 300):
parts = dict(p.split("=", 1) for p in header.split(","))
ts = int(parts["t"])
if abs(time.time() - ts) > tolerance:
raise ValueError("stale webhook")
mac = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(mac, parts["v1"]):
raise ValueError("bad signature")
Retries and idempotency
We consider any 2xx response a success. Anything else — a
non-2xx status, a connection refused, a read past ten seconds — is retried
up to five times, thirty seconds apart, after which the delivery is marked
failed and stays visible under the endpoint with the response we got. A
failed delivery can be re-sent with Replay. The
X-Onpack-Delivery header is stable across retries and replays
— use it as an idempotency key.
Deliveries stop when a company closes its account, and an event stops being delivered when the company no longer owns the product it belongs to.
Sending a test delivery
Each endpoint has a Send Test button in the dashboard that
posts a synthetic webhook.test event. Use it to confirm your
signature verification before flipping real events on.