Appearance
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: Webhooks v2 reference.
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):
- Compute the HMAC over the raw body bytes, exactly as received. Any re-serialization (JSON parse → stringify) breaks the signature.
- Compare in constant time.
- Try every active secret — during rotation the portal shows two (the outgoing secret stays valid for the overlap window).
- Reject when
|now − t| > 300seconds (replay protection). - 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
UnknownEventand never throw.) - Non-2xx responses are retried for terminal events at
1h, 3h, 7h, 12h, 24hafter 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": "…" }
}reasonappears on*Failed/Consent*events; branch onreason.code, never the message.- Additional top-level fields may appear on any event (e.g.
remainingDaysonConnectionConsentRevoked) — ignore what you don't know.
Event types (19): AuthenticationStarted/Success/Failed/Canceled, AccountsFetchingStarted/Success/Failed, TransactionsFetchingStarted/Success/Failed, ConnectionCreateSuccess/Failed, ConnectionReconnectSuccess/Failed, ConnectionRefreshSuccess/Failed, ConnectionDeleted, ConnectionConsentRevoked, ConnectionConsentExpired.
The terminal event tells you which operation finished: a create ends in ConnectionCreateSuccess/Failed, a reconnect (and the provider migration a reconnect can turn into) in ConnectionReconnectSuccess/Failed, and every refresh in ConnectionRefreshSuccess/Failed. Waiting for ConnectionCreateSuccess after a reconnect waits forever.
Testing
The portal's Send test ping button sends a signed test delivery to your configured URL. It carries the normal envelope and signature, but its type is not one of the events in the catalogue, so the SDKs hand it back as an unknown event. Verify it with the same code path as real events and respond 2xx - that also exercises your handling of unknown event types.