Skip to content

Webhooks — verification guide

Connection lifecycle events are delivered as HTTP POSTs to the webhook URL you configure in the portal (one URL per mode — sandbox and live are independent, with independent signing secrets). Full contract: spec/webhooks-v2.yaml.

The signature

Every delivery carries:

X-BB-Signature: t=<unix-ts>,v1=<hex>

where hex = HMAC_SHA256(secret, "{t}." + raw_request_body).

Verification rules (the SDK helpers implement all of this — use them):

  1. Compute the HMAC over the raw body bytes, exactly as received. Any re-serialization (JSON parse → stringify) breaks the signature.
  2. Compare in constant time.
  3. Try every active secret — during rotation the portal shows two (the outgoing secret stays valid for the overlap window).
  4. Reject when |now − t| > 300 seconds (replay protection).
  5. Ignore unknown scheme keys in the header (forward compatibility); a future v2= entry must not break your verifier.
ts
// Node — the raw body, NOT the parsed JSON:
import { verify, parseEvent, SIGNATURE_HEADER } from '@budgetbakers/partner-sdk';

const result = verify(activeSecrets, req.header(SIGNATURE_HEADER) ?? '', rawBody);
// 'valid' | 'invalid_signature' | 'timestamp_out_of_tolerance' | 'malformed_header'
python
# Python
from budgetbakers_partner_sdk import verify, parse_event

result = verify(active_secrets, request.headers.get("X-BB-Signature", ""), raw_body)

Secrets (whsec_…) are generated and rotated in the portal — Webhooks → Signing secrets, per mode. Store them like passwords.

Responding

  • Respond 2xx for every verified delivery you accepted — including event types you don't recognize. Unknown types are new features, not errors; rejecting them floods the retry queue. (The SDK parsers return them as UnknownEvent and never throw.)
  • Non-2xx responses are retried for terminal events at 1h, 3h, 7h, 12h, 24h after the initial attempt; progress events are delivered once, without retries.
  • Deliveries are at-least-once: deduplicate on eventId (stable across retries).

The event body

json
{
  "eventId": "7d2c4f7e-…",
  "type": "ConnectionCreateSuccess",
  "clientId": "538efad5-…",
  "connectionId": "38d0322a-…",
  "createdAt": "2026-07-15T08:10:30.000Z",
  "reason": { "code": "consent_revoked", "message": "…" }
}
  • reason appears on *Failed / Consent* events; branch on reason.code, never the message.
  • Additional top-level fields may appear on any event (e.g. remainingDays on ConnectionConsentRevoked) — ignore what you don't know.

Event types (17): AuthenticationStarted/Success/Failed/Canceled, AccountsFetchingStarted/Success/Failed, TransactionsFetchingStarted/Success/Failed, ConnectionCreateSuccess/Failed, ConnectionRefreshSuccess/Failed, ConnectionDeleted, ConnectionConsentRevoked, ConnectionConsentExpired.

Testing

The portal's Send test ping button delivers a signed TestPing to your configured URL — verify it with the same code path as real events.