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:

HeaderDescription
X-Onpack-EventEvent name, e.g. scan.processed.
X-Onpack-DeliveryPer-delivery id — use it to dedupe on retries.
X-Onpack-Signaturet=<unix>,v1=<hex-hmac>. See verification below.
User-Agentonpack-webhooks/1.0

Body is a JSON envelope:

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

Ruby
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
Node
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");
}
Python
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")
!
Verify against the raw request body, not a parsed + re-serialised copy — JSON round-trips can reorder keys and break the HMAC.

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.