Webhooks
Status: Draft Last Updated: 2026-06-15 Audience: customer
This is the reference for the customer webhooks Unbound pushes to your backend when grant and sync state changes. Webhooks exist because some transitions complete asynchronously, after the end user has left the consent flow — most importantly, a device agent’s first sync can land minutes to hours later. Webhooks are the push complement to the pull channels: the grants query and the management API.
Use them to drive a “syncing → ready” state in your UI instead of polling.
All example values are synthetic. Replace them with your own.
Registering an endpoint
Endpoint registration is performed by your Unbound operator (self-serve registration is deferred). You provide an HTTPS URL; the operator registers it and returns a signing secret.
- The URL must be an absolute
https://URL. - Your endpoint must return a
2xxquickly (within a few seconds) — acknowledge fast and process asynchronously. - A signing secret (the HMAC key, 64 hex characters) is generated and shown to the operator exactly once. Store it securely alongside your
client_secret; it is sealed at rest and not recoverable. If it is lost, rotate (see Rotating the secret).
Event vocabulary
Every delivery is a JSON object with a stable envelope. The vocabulary only grows within a major version — treat unknown type values as ignorable.
type | Meaning |
|---|---|
grant.activated | A source the end user connected is now active and ingesting. |
grant.revoked | A grant was revoked (by the end user, by you, or by an admin). |
sync.initial_completed | A source’s initial sync finished — data is now queryable. |
sync.initial_failed | A source’s initial sync failed and will not retry automatically. |
These events fire for every source, whether the user connected via an in-browser OAuth handoff or via a device agent — server-side connectors and on-device agents both emit them.
The envelope
The payload is PII-free: it carries identifiers and status only — never message content, contact details, or any personal data. Resolve the identifiers against your own records.
{ "event_id": "018f0a2b-7c3d-7e4f-8a1b-2c3d4e5f6071", "type": "grant.activated", "occurred_at": "2026-06-13T17:04:05Z", "customer_id": "018f0000-0000-7000-8000-000000000001", "end_user_id": "user-42", "source": "imessage"}| Field | Meaning |
|---|---|
event_id | Stable UUID for this event. Use it for deduplication (see below). |
type | One of the event types above. |
occurred_at | When the transition happened (RFC 3339). |
customer_id | Your account identifier. |
end_user_id | Your opaque identifier for the user — the value you passed to /authorize. |
source | The source identifier — e.g. gmail, imessage, android_sms. See Data Sources. |
A sync.initial_failed event additionally carries a short, non-sensitive reason string.
Verifying the signature
Every request carries two signing headers plus a dedup header:
| Header | Value |
|---|---|
X-Signature | sha256=<hex> where <hex> is HMAC-SHA256(secret, rawBody ‖ X-Timestamp). |
X-Timestamp | The signing time, as decimal Unix seconds. |
X-Unbound-Event-Id | The same UUID as event_id in the body, so you can dedup before parsing. |
The MAC input is the raw request body bytes followed by the timestamp string. Sign over the raw bytes you received, before any JSON re-encoding — re-serialization changes the bytes and breaks the MAC. Always verify before processing, reject anything that fails with 401, and use a constant-time comparison. Reject deliveries outside a freshness window (5 minutes recommended) to bound replay.
func verify(secret, body []byte, sigHeader, tsHeader string, now time.Time) bool { // Freshness: reject stale or future-dated timestamps. ts, err := strconv.ParseInt(tsHeader, 10, 64) if err != nil { return false } if d := now.Sub(time.Unix(ts, 0)); d < -5*time.Minute || d > 5*time.Minute { return false }
// Recompute the MAC over body || timestamp and compare in constant time. mac := hmac.New(sha256.New, secret) mac.Write(body) mac.Write([]byte(tsHeader)) expected := mac.Sum(nil)
got, err := hex.DecodeString(strings.TrimPrefix(sigHeader, "sha256=")) if err != nil { return false } return hmac.Equal(got, expected)}import hashlib, hmac, time
def verify(secret: bytes, body: bytes, sig_header: str, ts_header: str) -> bool: try: ts = int(ts_header) except ValueError: return False if abs(time.time() - ts) > 300: # 5-minute freshness window return False expected = hmac.new(secret, body + ts_header.encode(), hashlib.sha256).hexdigest() got = sig_header.removeprefix("sha256=") return hmac.compare_digest(got, expected)const crypto = require("crypto");
function verify(secret, rawBody, sigHeader, tsHeader) { const ts = Number.parseInt(tsHeader, 10); if (!Number.isFinite(ts)) return false; if (Math.abs(Date.now() / 1000 - ts) > 300) return false; // 5-minute window
const mac = crypto.createHmac("sha256", secret); mac.update(rawBody); // raw bytes, before JSON parsing mac.update(tsHeader); const expected = mac.digest("hex"); const got = sigHeader.replace(/^sha256=/, ""); const a = Buffer.from(got, "hex"); const b = Buffer.from(expected, "hex"); return a.length === b.length && crypto.timingSafeEqual(a, b);}Capture the raw body. In Node/Express, register a raw-body parser for the webhook route before any JSON body parser, so you verify against the exact bytes received. The starter-kit webhook handler shows this ordering — see Reference handler.
Idempotency and deduplication
Delivery is at-least-once: a webhook may be retried, so the same event can arrive more than once. event_id is stable across retries — record processed event_ids and treat a repeat as a no-op. The X-Unbound-Event-Id header carries the same value, so you can dedup before parsing the body.
Do not rely on delivery order. When ordering matters, reconcile against durable grant state — re-read grants or the management API rather than inferring state from event sequence.
Delivery guarantees, retry, and backoff
What your endpoint returns determines whether a delivery is retried:
2xx— acknowledged. The delivery is settled and never re-sent.5xx, request timeout, or connection failure — treated as transient. The delivery is retried with exponential backoff over an extended redelivery window (on the order of a day) before being given up on.429(rate limited) and408(request timeout) are retried the same way.- Other
4xx— treated as permanent rejection. The delivery is not retried. 3xx— redirects are not followed; a3xxcounts as a failed attempt and is retried, never re-POSTed to the redirect target. The signed payload only ever goes to the registered URL.
Practical implication: return 2xx only once you have durably accepted the event (recording its event_id is enough — process the rest asynchronously). Return 5xx if you want the delivery retried later. Return a 4xx only when the event is genuinely unprocessable, since that stops redelivery. Because retries re-send the byte-identical payload, your signature verification continues to hold across attempts.
Import is independent of webhook delivery: a failed webhook never affects whether the user’s data is imported — it only affects whether you were notified. If you miss events, recover by re-reading current state via the management API.
Rotating the secret
To rotate, contact your Unbound operator. Rotation mints a new secret while leaving the existing endpoint active, so deliveries keep verifying during the cutover:
- The operator rotates and gives you the new secret.
- During the overlap window, accept a delivery if it verifies against either the old or the new secret.
- Once your backend has switched to the new secret and confirmed deliveries verify, the operator deactivates the old secret.
Update your backend to the new secret before the old one is deactivated.
Reference handler
The Connect starter kit includes a copyable webhook receiver (webhooks.js) that implements everything above: raw-body HMAC verification, the freshness window, X-Unbound-Event-Id dedup, and the raw-body-before-JSON middleware ordering. Use it as the basis for your own handler rather than building verification from scratch.
Your Unbound operator registers your webhook endpoint and rotates secrets on your behalf (see Registering an endpoint and Rotating the secret).
Related Documents
- Connect overview — How end users authorize data sources through your product; the flow these events complete.
- Connect JS loader — Embedding the in-page consent flow whose asynchronous completions these events report.
- Data API — The pull channel (
grants) these events complement; reconcile against it when ordering matters. - Management API — Account-wide grant and sync state; the recovery path when you miss events.
- Data Sources — The
sourceidentifiers carried in every event, and which sources sync asynchronously. - Authorization Flow — The consent flow whose asynchronous completions these events report.
- Quickstart — Where webhook registration fits in the onboarding walkthrough.