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 delivery and signing flow behind this reference.

The envelope#

Every delivery is a single JSON object:

JSON
{  "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 */  ]}
FieldTypeNotes
idstringUnique per delivery. Your idempotency key. Stable across retries and replays. Matches the webhook-id header.
typestringThe event type (see below).
apiVersionstringDate-pinned payload contract version. Currently 2026-06-01. New fields may be added without bumping it; a breaking change bumps it.
createdAtstringISO 8601 timestamp the event was built.
workspaceIdstringYour BankSync workspace id.
feedobject{ id, name, dataType } of the feed that produced the event. Absent on endpoint.test.
jobobject{ id, trigger }. trigger is scheduled | manual | api | test | redelivery.
batchobject{ sequence }, the 1-based ordinal of this batch within the job, per feed. Present on data events.
dataarrayThe rows. Present on data + test events.

Event types#

TypeWhenSemantics
transactions.deltatransactions feed, each runNew or updated transactions since the last run. Upsert by data[].id.
balances.snapshotbalances feed, each runCurrent balance per account. Replace state keyed by accountId.
holdings.snapshotholdings feed, each runCurrent holdings. Replace state keyed by accountId + ticker.
trades.snapshottrades feed, each runCurrent trades. Replace state keyed by id.
orders.snapshotorders feed, each runOpen/pending brokerage orders. Replace the set keyed by id.
loans.snapshotloans feed, each runCurrent loan state. Replace keyed by id/accountId.
sync.completedend of a jobTerminal summary: { status, rowsDelivered, batchesDelivered }. The signal that a snapshot is complete; commit your swap here.
sync.faileda job failedSame 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

Transactions are deltas: each event carries only new or changed rows, which you upsert by id. Every other type is a snapshot: each run sends the full current state, batched across one or more events, ending with sync.completed.

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

To prevent replay, reject any request whose webhook-timestamp is more than 5 minutes from now before processing it.

Verify with the Standard Webhooks library for your language:

JavaScript
// 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
# 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:

ResponseBehavior
2xxSuccess.
408, 425, 429, 5xxRetried with backoff. 429 honors Retry-After (capped at 10 min).
network error / timeout (30s)Retried with backoff.
410 GoneThe endpoint is treated as unsubscribed and disabled.
other 4xx, and any 3xxPermanent 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#

FieldTypeNotes
idstringStable per provider. Upsert key.
datestringPosted date/time.
authorizedDatestring?Authorization date. Absent for SaltEdge.
descriptionstringMerchant/description.
merchantNamestring?Cleaned merchant name (provider enrichment).
originalDescriptionstring?Raw statement text. Plaid only.
amountnumberSigned; positive = money in.
creditAmount / debitAmountnumberUnsigned split (one is 0).
currencystringISO 4217.
categorystring?
typestring?debit / credit / transfer / fee.
referencestring?
pendingboolean
pendingTransactionIdstring?Plaid posted rows reference the pending row they replace.
accountId, accountName, accountNumber, accountTypestring(?)Account identity.
bankId, bankstring(?)Bank identity.
customobject?Enrichment-added fields.

balances.snapshot#

FieldTypeNotes
datestringSnapshot time (not a bank timestamp).
amountnumberLedger balance.
availableBalancenumber?Spendable now; available credit for cards. Omitted for SnapTrade.
pendingBalancenumber?max(0, amount - availableBalance).
creditLimitnumber?Credit products only.
currencystring
accountId, accountName, accountNumber, accountType, bankId, bankstring(?)

holdings.snapshot#

FieldTypeNotes
idstring?Synthesized per account + security.
security, tickerstring?
quantitynumber
price, priceDate, currentValuenumber?/string?
costBasisnumber? | nullnull = brokerage didn't report it.
isCashEquivalentboolean?Cash arrives as a holding with ticker: "CUR:USD", price: 1. Don't double-count against a balances feed.
currencystring
account/bank identity fieldsstring(?)

trades.snapshot#

FieldTypeNotes
id, datestring
typestringbuy / sell / dividend / fee.
amountnumberSigned; positive = cash out (a buy). Note this is the opposite of transactions.
quantity, price, feesnumber?
security, tickerstring?
currencystring
account/bank identity fieldsstring(?)

orders.snapshot#

FieldTypeNotes
idstringNamespaced order:<brokerageOrderId> so it never collides with a trade id.
brokerageOrderId, date, status, action, orderTypestring
timeInForcestring?
totalQuantity, filledQuantity, openQuantity, canceledQuantitynumber?
limitPrice, stopPrice, executionPricenumber?
executedAt, expiresAtstring?
security, ticker, currencystring(?)
account/bank identity fieldsstring(?)

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 by data[].id.
  • For snapshots, replace state by the documented identity key when sync.completed arrives.
  • Reject webhook-timestamp older 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.