---
title: "Webhooks developer reference"
description: "The webhook payload contract: the signed envelope, event types, per-data-type field schemas, signature verification, retries, and idempotency."
section: "API"
canonical: "https://banksync.io/docs/api/webhooks-reference"
---

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](/docs/integrations/webhooks).

[![The webhook destination this reference describes, set up in the app: entering the endpoint URL, revealing the whsec\_ signing secret shown once, a test delivery returning 200 in 142ms, and the recent deliveries table with status codes and attempts.](https://cdn.banksync.io/videos/webhook-destination-feed.poster.9f085924736e4b13.png)](https://cdn.banksync.io/videos/webhook-destination-feed.b93e77b77369be96.mp4)

[Watch: The webhook destination this reference describes, set up in the app: entering the endpoint URL, revealing the whsec\_ signing secret shown once, a test delivery returning 200 in 142ms, and the recent deliveries table with status codes and attempts.](https://cdn.banksync.io/videos/webhook-destination-feed.b93e77b77369be96.mp4)

## 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 */
  ]
}
```

| 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:** '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](https://www.standardwebhooks.com) format, so existing verifier libraries work out of the box. Each request carries:

```
webhook-id:        evt_job-abc_0_1_2
webhook-timestamp: 1718270000
webhook-signature: v1,g0Q1...base64...==
content-type:      application/json
user-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:

```js
// 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:

| 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 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.
