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:

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": "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.

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 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.