Webhook destinations and signature verification
How Postbag delivers to webhook destinations: the JSON payload, Postbag-Signature (t=…,v1=… HMAC-SHA256), Postbag-Delivery and Postbag-Event headers, retry and dead-letter behaviour, and verification code in Node.js, Python and Go.
Updated · Markdown
A webhook destination is a URL, an optional secret and optional extra headers. It is the universal extension point: CRMs, automation tools, your own services.
The request
POST https://crm.example.com/postbag
Content-Type: application/json
Postbag-Delivery: dl_a91x02
Postbag-Event: submission.received
Postbag-Signature: t=1724200000,v1=5f1c…e9a2
{ "id": "dl_a91x02", "type": "submission.received", "schema_version": 3,
"stream": { "id": "st_…", "slug": "vending-leads" } | null,
"form": { "id": "fm_…", "slug": "kontorsautomat-contact" },
"data": { …mapped payload… }, "extras": { … }, "meta": { … } }
Postbag-Event is submission.received for instant routes and digest.ready for digest routes (one payload per period containing the period’s submissions).
Response handling
| Your response | Postbag does |
|---|---|
2xx | Marks the delivery sent, stores status, latency and a body excerpt. |
410 | Treats the destination as having disabled itself; no retry. |
| anything else, or a timeout (10 s) | Marks failed, schedules a retry with backoff min(2^attempts × 30 s ± 20 %, 6 h), up to 10 attempts, then dead and raises delivery.dead. |
Dead deliveries keep their payload and can be retried from the dashboard or POST /v1/deliveries/{id}/retry.
Verifying the signature
The signature is HMAC-SHA256(secret, "{t}.{rawBody}") in hex. Always verify over the raw body bytes, compare in constant time, and reject stale timestamps.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto"
export function verifyPostbag(secret: string, header: string, rawBody: string, toleranceSec = 300): boolean {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=") as [string, string]))
const t = Number(parts.t)
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")
const given = parts.v1 ?? ""
return expected.length === given.length && timingSafeEqual(Buffer.from(expected), Buffer.from(given))
}
Python
import hmac, hashlib, time
def verify_postbag(secret: str, header: str, raw_body: bytes, tolerance=300) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
t = int(parts.get("t", "0"))
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Go
func verifyPostbag(secret, header string, rawBody []byte, tolerance time.Duration) bool {
parts := map[string]string{}
for _, kv := range strings.Split(header, ",") {
p := strings.SplitN(kv, "=", 2); if len(p) == 2 { parts[p[0]] = p[1] }
}
t, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil || time.Since(time.Unix(t, 0)).Abs() > tolerance { return false }
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(t, 10) + ".")); mac.Write(rawBody)
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(parts["v1"]))
}
Organization system webhooks
Separate from route destinations, POST /v1/webhooks { url, events[], secret? } subscribes to organization events (submission.received, delivery.dead, form.schema.changed, stream.schema.changed, drift.detected, destination.failing, …). Dispatch is triggered from Postgres; deliveries are listed at GET /v1/webhooks/{id}/deliveries and signed the same way.
Your first form is three minutes away.
Sign up, get a submit URL, point a form at it. The first submission lands in your inbox and your email. Everything else appears when you need it.