Skip to content
Home

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 2xx quickly (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.

typeMeaning
grant.activatedA source the end user connected is now active and ingesting.
grant.revokedA grant was revoked (by the end user, by you, or by an admin).
sync.initial_completedA source’s initial sync finished — data is now queryable.
sync.initial_failedA 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"
}
FieldMeaning
event_idStable UUID for this event. Use it for deduplication (see below).
typeOne of the event types above.
occurred_atWhen the transition happened (RFC 3339).
customer_idYour account identifier.
end_user_idYour opaque identifier for the user — the value you passed to /authorize.
sourceThe 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:

HeaderValue
X-Signaturesha256=<hex> where <hex> is HMAC-SHA256(secret, rawBody ‖ X-Timestamp).
X-TimestampThe signing time, as decimal Unix seconds.
X-Unbound-Event-IdThe 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) and 408 (request timeout) are retried the same way.
  • Other 4xx — treated as permanent rejection. The delivery is not retried.
  • 3xx — redirects are not followed; a 3xx counts 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:

  1. The operator rotates and gives you the new secret.
  2. During the overlap window, accept a delivery if it verifies against either the old or the new secret.
  3. 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).

  • 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 source identifiers 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.