Webhooks developer reference
The webhook payload contract: the signed envelope, event types, per-data-type field schemas, signature verification, retries, and idempotency.
7 min read
On this page
BankSync delivers synced bank data to your endpoint as signed JSON POST requests. This reference documents the wire format so you can build and verify a receiver. To connect an endpoint and create a feed, see the Webhooks integration guide.
The envelope#
Every delivery is a single JSON object:
{ "id": "evt_job-abc_0_1_2", "type": "transactions.delta", "apiVersion": "2026-06-01", "createdAt": "2026-06-01T09:30:00.000Z", "workspaceId": "ws_...", "feed": { "id": "feed_...", "name": "Chase to Webhook", "dataType": "transactions" }, "job": { "id": "job_abc", "trigger": "scheduled" }, "batch": { "sequence": 3 }, "data": [ /* rows: see the per-type schemas below */ ]}| Field | Type | Notes |
|---|---|---|
id | string | Unique per delivery. Your idempotency key. Stable across retries and replays. Matches the webhook-id header. |
type | string | The event type (see below). |
apiVersion | string | Date-pinned payload contract version. Currently 2026-06-01. New fields may be added without bumping it; a breaking change bumps it. |
createdAt | string | ISO 8601 timestamp the event was built. |
workspaceId | string | Your BankSync workspace id. |
feed | object | { id, name, dataType } of the feed that produced the event. Absent on endpoint.test. |
job | object | { id, trigger }. trigger is scheduled | manual | api | test | redelivery. |
batch | object | { sequence }, the 1-based ordinal of this batch within the job, per feed. Present on data events. |
data | array | The rows. Present on data + test events. |
Event types#
| Type | When | Semantics |
|---|---|---|
transactions.delta | transactions feed, each run | New or updated transactions since the last run. Upsert by data[].id. |
balances.snapshot | balances feed, each run | Current balance per account. Replace state keyed by accountId. |
holdings.snapshot | holdings feed, each run | Current holdings. Replace state keyed by accountId + ticker. |
trades.snapshot | trades feed, each run | Current trades. Replace state keyed by id. |
orders.snapshot | orders feed, each run | Open/pending brokerage orders. Replace the set keyed by id. |
loans.snapshot | loans feed, each run | Current loan state. Replace keyed by id/accountId. |
sync.completed | end of a job | Terminal summary: { status, rowsDelivered, batchesDelivered }. The signal that a snapshot is complete; commit your swap here. |
sync.failed | a job failed | Same summary shape with status: "failed" and an errorCategory. |
endpoint.test | "Send test event" | Sample rows for a chosen data type. Marked "test": true in the envelope. |
Delta vs snapshot
Signing & verification#
BankSync signs every delivery using the Standard Webhooks format, so existing verifier libraries work out of the box. Each request carries:
webhook-id: evt_job-abc_0_1_2webhook-timestamp: 1718270000webhook-signature: v1,g0Q1...base64...==content-type: application/jsonuser-agent: BankSync-Webhooks/1.0 (+https://banksync.io/docs/api/webhooks-reference)The signature is HMAC-SHA256 over the string {webhook-id}.{webhook-timestamp}.{body}, base64-encoded, keyed by the base64-decoded payload of your whsec_ secret. During a secret rotation the header carries two space-separated signatures (current + previous); a delivery is valid if either verifies.
Reject stale timestamps
Verify with the Standard Webhooks library for your language:
// Node (npm i standardwebhooks)import { Webhook } from 'standardwebhooks'
const wh = new Webhook(process.env.BANKSYNC_WEBHOOK_SECRET) // "whsec_..."app.post('/webhooks/banksync', express.raw({ type: 'application/json' }), (req, res) => { let event try { event = wh.verify(req.body, { 'webhook-id': req.header('webhook-id'), 'webhook-timestamp': req.header('webhook-timestamp'), 'webhook-signature': req.header('webhook-signature'), }) } catch { return res.sendStatus(400) // bad signature } // De-dupe on event.id, then process event.data … res.sendStatus(200)})# Python (pip install standardwebhooks)from standardwebhooks import Webhook
wh = Webhook(os.environ["BANKSYNC_WEBHOOK_SECRET"]) # "whsec_..."
@app.post("/webhooks/banksync")def banksync_webhook(): try: event = wh.verify(request.data, dict(request.headers)) except Exception: return ("", 400) # De-dupe on event["id"], then process event["data"] … return ("", 200)Respond with any 2xx to acknowledge. Non-2xx responses are retried (see below).
Retries & delivery#
Delivery is at-least-once. BankSync retries transient failures with exponential backoff:
| Response | Behavior |
|---|---|
2xx | Success. |
408, 425, 429, 5xx | Retried with backoff. 429 honors Retry-After (capped at 10 min). |
| network error / timeout (30s) | Retried with backoff. |
410 Gone | The endpoint is treated as unsubscribed and disabled. |
other 4xx, and any 3xx | Permanent rejection, not retried. Redirects are never followed. |
Because delivery is at-least-once and retries reuse the same id, deduplicate on id (or webhook-id). Within a job, batch.sequence lets you order batches. An endpoint that fails persistently is auto-disabled; re-enable it from the endpoint page after fixing your server.
Per-data-type payloads#
Rows are camelCase; money is JSON numbers (parse with a decimal type); dates are ISO 8601 (or YYYY-MM-DD for date-only fields); currency is ISO 4217. Optional fields are omitted when absent. Enrichment-added custom fields are nested under a custom object. Field availability varies by provider (Plaid / Fiskil / SaltEdge / SnapTrade).
transactions.delta#
| Field | Type | Notes |
|---|---|---|
id | string | Stable per provider. Upsert key. |
date | string | Posted date/time. |
authorizedDate | string? | Authorization date. Absent for SaltEdge. |
description | string | Merchant/description. |
merchantName | string? | Cleaned merchant name (provider enrichment). |
originalDescription | string? | Raw statement text. Plaid only. |
amount | number | Signed; positive = money in. |
creditAmount / debitAmount | number | Unsigned split (one is 0). |
currency | string | ISO 4217. |
category | string? | |
type | string? | debit / credit / transfer / fee. |
reference | string? | |
pending | boolean | |
pendingTransactionId | string? | Plaid posted rows reference the pending row they replace. |
accountId, accountName, accountNumber, accountType | string(?) | Account identity. |
bankId, bank | string(?) | Bank identity. |
custom | object? | Enrichment-added fields. |
balances.snapshot#
| Field | Type | Notes |
|---|---|---|
date | string | Snapshot time (not a bank timestamp). |
amount | number | Ledger balance. |
availableBalance | number? | Spendable now; available credit for cards. Omitted for SnapTrade. |
pendingBalance | number? | max(0, amount - availableBalance). |
creditLimit | number? | Credit products only. |
currency | string | |
accountId, accountName, accountNumber, accountType, bankId, bank | string(?) |
holdings.snapshot#
| Field | Type | Notes |
|---|---|---|
id | string? | Synthesized per account + security. |
security, ticker | string? | |
quantity | number | |
price, priceDate, currentValue | number?/string? | |
costBasis | number? | null | null = brokerage didn't report it. |
isCashEquivalent | boolean? | Cash arrives as a holding with ticker: "CUR:USD", price: 1. Don't double-count against a balances feed. |
currency | string | |
| account/bank identity fields | string(?) |
trades.snapshot#
| Field | Type | Notes |
|---|---|---|
id, date | string | |
type | string | buy / sell / dividend / fee. |
amount | number | Signed; positive = cash out (a buy). Note this is the opposite of transactions. |
quantity, price, fees | number? | |
security, ticker | string? | |
currency | string | |
| account/bank identity fields | string(?) |
orders.snapshot#
| Field | Type | Notes |
|---|---|---|
id | string | Namespaced order:<brokerageOrderId> so it never collides with a trade id. |
brokerageOrderId, date, status, action, orderType | string | |
timeInForce | string? | |
totalQuantity, filledQuantity, openQuantity, canceledQuantity | number? | |
limitPrice, stopPrice, executionPrice | number? | |
executedAt, expiresAt | string? | |
security, ticker, currency | string(?) | |
| account/bank identity fields | string(?) |
loans.snapshot#
The widest schema; almost every field is optional and provider/product-dependent. Core fields: id, accountId, loanType, loanName, currentBalance, originalAmount, interestRate, minimumPaymentAmount, nextPaymentDueDate, isOverdue, currency, bankId. Product-specific groups:
- Credit cards:
aprPurchase,aprCash,aprBalanceTransfer,lastStatementBalance,lastStatementDate. - Student loans:
disbursementDates(array),pslfStatus,repaymentPlan. - Mortgages:
escrowBalance,hasPMI,hasPrepaymentPenalty,propertyAddress(object),loanTerm,maturityDate.
Idempotency & ordering checklist#
- Deduplicate on
id. - For
transactions.delta, upsert bydata[].id. - For snapshots, replace state by the documented identity key when
sync.completedarrives. - Reject
webhook-timestampolder than 5 minutes. - Don't require auth on the receiving route; verify the signature instead.
Use this page with your AI assistant
Every BankSync doc is available as plain Markdown for agents and LLMs.