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: 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):
- 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 (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.