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 → Developers → Webhooks → New endpoint.
Provide a public HTTPS URL and pick the events you care about.
Every endpoint has its own signing secret. Copy it the moment it's shown — onpack stores only a hash, so you can never retrieve the plaintext again. Rotate it anytime from the dashboard.
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": "milka", "name": "Milka", "prefix": "MK" },
"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 triggers
a backoff retry (the schedule is visible on each delivery in the dashboard).
The X-Onpack-Delivery header is stable across retries — use it
as an idempotency key.
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.