> ## Documentation Index
> Fetch the complete documentation index at: https://docs.redbark.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Payload

> Payload format, headers, and signature verification for webhook destinations

<Info>
  Webhooks are in **beta**. The payload format and signing scheme may change.
</Info>

When a sync runs for a webhook destination, Redbark sends one or more HTTP POSTs to your endpoint with a JSON payload. Transaction syncs produce `transactions.synced` events; brokerage syncs produce `trades.synced` events. Every request is signed with HMAC-SHA256.

## Event types

| `type`                | When sent                       | `data` contents                                                                                           |
| --------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `transactions.synced` | Bank transaction sync completes | [Transaction](#transaction-fields) objects in `new` and `updated`                                         |
| `trades.synced`       | Brokerage trade sync completes  | [Trade](#trade-fields) objects in `new` (`updated` is reserved for future use and currently always empty) |

The envelope shape is identical across event types — only the `type` field and the contents of `data.new` / `data.updated` change. Consumers that only care about one event type should filter on `event.type` and ignore the rest.

## Request headers

Each webhook request includes these headers:

| Header                  | Description                                                                                                                                                                         |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`          | `application/json`                                                                                                                                                                  |
| `User-Agent`            | `Redbark-Webhook/1.0`                                                                                                                                                               |
| `X-Redbark-Signature`   | HMAC-SHA256 signature in the format `sha256=<hex_digest>`                                                                                                                           |
| `X-Redbark-Timestamp`   | Unix timestamp (seconds) when the signature was created                                                                                                                             |
| `X-Redbark-Delivery-Id` | UUID identifying this delivery (matches `id` in the payload). Every retry attempt of the same chunk carries the same value, so you can use it as an idempotency key across retries. |

## Payload format

All field names use **snake\_case**. Monetary amounts on transactions are **integers in the smallest currency unit** (e.g. cents). Envelope timestamps (`created`) are **Unix epoch seconds**. The `local_date` field on transactions is a **YYYY-MM-DD** string; `transaction_date` and `post_date` are **full ISO 8601 timestamps** passed through from the banking provider. Missing optional fields are `null`, never empty strings.

```json theme={null}
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "object": "event",
  "type": "transactions.synced",
  "api_version": "2026-03-15",
  "created": 1741243200,
  "data": {
    "new": [
      {
        "id": "e4a7f91b2c3d4e5f6a7b8c9d",
        "object": "transaction",
        "amount": -4550,
        "currency": "aud",
        "status": "posted",
        "description": "Woolworths Sydney",
        "direction": "debit",
        "class": "payment",
        "account_id": "a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890",
        "account_name": "Everyday Account",
        "account": "Everyday Account",
        "local_date": "2026-03-05",
        "transaction_date": "2026-03-05T10:30:00+11:00",
        "post_date": "2026-03-05T10:30:00+11:00",
        "merchant_name": "Woolworths",
        "category": "FOOD_AND_DRINK",
        "merchant_category_code": null,
        "reference": null,
        "extended_description": null
      }
    ],
    "updated": []
  },
  "metadata": {
    "sync_run_id": "run_abc123",
    "new_count": 1,
    "updated_count": 0,
    "chunk": 1,
    "total_chunks": 1
  }
}
```

### Top-level fields

| Field         | Type      | Description                                                                                 |
| ------------- | --------- | ------------------------------------------------------------------------------------------- |
| `id`          | `string`  | Unique event identifier (UUID)                                                              |
| `object`      | `string`  | Always `"event"`                                                                            |
| `type`        | `string`  | Event type — `"transactions.synced"` or `"trades.synced"`. See [Event types](#event-types). |
| `api_version` | `string`  | API version used to generate this payload                                                   |
| `created`     | `integer` | Unix timestamp (seconds) of when the event was created                                      |
| `data`        | `object`  | Contains `new` and `updated` transaction arrays                                             |
| `metadata`    | `object`  | Sync run context and chunking information                                                   |

### Transaction fields

Each transaction in `data.new` contains:

| Field                    | Type             | Description                                                                                                                                                                                                                                                                                           |
| ------------------------ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                     | `string`         | Opaque transaction identifier from the banking provider                                                                                                                                                                                                                                               |
| `object`                 | `string`         | Always `"transaction"`                                                                                                                                                                                                                                                                                |
| `amount`                 | `integer`        | Amount in smallest currency unit (e.g. cents). Negative for debits.                                                                                                                                                                                                                                   |
| `currency`               | `string \| null` | Lowercase ISO 4217 currency code (e.g. `"aud"`, `"nzd"`)                                                                                                                                                                                                                                              |
| `status`                 | `string`         | `"posted"` by default. If the sync has `includePendingTransactions` enabled, pending transactions are also delivered with `status: "pending"`.                                                                                                                                                        |
| `description`            | `string`         | Transaction description                                                                                                                                                                                                                                                                               |
| `direction`              | `string`         | `"credit"` or `"debit"`                                                                                                                                                                                                                                                                               |
| `class`                  | `string`         | `"payment"`, `"transfer"`, `"bank-fee"`, `"interest"`, or `"other"`                                                                                                                                                                                                                                   |
| `account_id`             | `string`         | UUID of the account. Use this to match transactions to accounts from the [Accounts API](/api-reference/accounts).                                                                                                                                                                                     |
| `account_name`           | `string`         | Human-readable account name (e.g. `"Everyday Account"`)                                                                                                                                                                                                                                               |
| `account`                | `string`         | **Deprecated.** Same as `account_name`. Use `account_id` and `account_name` instead.                                                                                                                                                                                                                  |
| `local_date`             | `string`         | Transaction date as `YYYY-MM-DD`, localised using the transaction's provider-supplied source timezone when present, otherwise the timezone in the user's [settings](https://app.redbark.com/settings). Derived from `transaction_date`, falling back to `post_date` when `transaction_date` is empty. |
| `transaction_date`       | `string`         | Raw transaction timestamp from the bank (full ISO 8601). Use `local_date` for display.                                                                                                                                                                                                                |
| `post_date`              | `string \| null` | Full ISO 8601 timestamp from the bank (not a date-only string — passed through verbatim from the provider's `posting_date_time`)                                                                                                                                                                      |
| `merchant_name`          | `string \| null` | Merchant name                                                                                                                                                                                                                                                                                         |
| `category`               | `string \| null` | Raw CDR primary-category code (uppercase, e.g. `FOOD_AND_DRINK`). See [REST categories](/api-reference/transactions#categories) for the full list.                                                                                                                                                    |
| `subcategory`            | `string \| null` | Enriched sub-category. **Only present when the destination has enriched categories enabled** (omitted from the object entirely otherwise).                                                                                                                                                            |
| `confidence_level`       | `string \| null` | Confidence of the enriched categorisation: `"VERY_HIGH"`, `"HIGH"`, `"MEDIUM"`, `"LOW"`, or `null`. **Only present when the destination has enriched categories enabled.**                                                                                                                            |
| `custom_category`        | `string \| null` | User-defined category name from the user's mapping, or `null` to fall back to `category`. **Only present when the destination has enriched categories enabled.**                                                                                                                                      |
| `merchant_category_code` | `string \| null` | Merchant category code (MCC)                                                                                                                                                                                                                                                                          |
| `reference`              | `string \| null` | Bank-provided transaction reference (e.g. BPAY reference, NPP end-to-end ID)                                                                                                                                                                                                                          |
| `extended_description`   | `string \| null` | Provider-supplied extended description (NPP payload, payee/payer info) when available                                                                                                                                                                                                                 |

<Info>
  Amounts are integers in the **smallest currency unit**. For example, `$45.50 AUD` is `4550`. Zero-decimal currencies like JPY use the full amount (e.g. `¥500` is `500`).
</Info>

<Warning>
  The `subcategory`, `confidence_level`, and `custom_category` fields are only emitted when the destination has enriched categories enabled. They are omitted from the object entirely otherwise, rather than sent as `null`. Strict parsers that reject unknown keys must allow for these three fields appearing or not appearing depending on the destination's configuration.
</Warning>

### Updated transaction fields

Each entry in `data.updated` contains all the same fields above, plus:

| Field                 | Type     | Description                                                                       |
| --------------------- | -------- | --------------------------------------------------------------------------------- |
| `previous_attributes` | `object` | Reserved for future use. Always `{}` today — the diff itself is not yet surfaced. |

`data.updated` is populated when a sync detects mutations to previously-delivered transactions (for example, the banking provider's stored values for a posted transaction change between syncs). The full current attributes are sent on every entry; you can compare against your own stored copy to derive the diff while `previous_attributes` remains reserved.

### Trade fields

Trade events use the same envelope as transactions, but with `type: "trades.synced"` and Trade objects in `data.new`. Quantities, prices, and fees are **decimal strings** (not integers) to preserve precision for fractional shares. `data.updated` is reserved for future use and currently always empty.

```json theme={null}
{
  "id": "d8e7f6a5-b4c3-2109-8765-4321fedcba09",
  "object": "event",
  "type": "trades.synced",
  "api_version": "2026-03-15",
  "created": 1741243200,
  "data": {
    "new": [
      {
        "id": "trade_xyz789",
        "object": "trade",
        "symbol": "VAS",
        "name": "Vanguard Australian Shares Index ETF",
        "type": "buy",
        "quantity": "12",
        "price": "98.45",
        "currency": "AUD",
        "total_amount": "1181.40",
        "fees": "4.95",
        "trade_date": "2026-03-05T10:30:00+11:00",
        "settlement_date": "2026-03-07T00:00:00+11:00",
        "account": "Brokerage Cash",
        "description": "Market buy — limit 98.50"
      }
    ],
    "updated": []
  },
  "metadata": {
    "sync_run_id": "run_abc123",
    "new_count": 1,
    "updated_count": 0,
    "chunk": 1,
    "total_chunks": 1
  }
}
```

Each trade in `data.new` contains:

| Field             | Type             | Description                                                                                   |
| ----------------- | ---------------- | --------------------------------------------------------------------------------------------- |
| `id`              | `string`         | Unique trade identifier from the brokerage provider                                           |
| `object`          | `string`         | Always `"trade"`                                                                              |
| `symbol`          | `string`         | Ticker symbol (e.g. `"VAS"`, `"AAPL"`)                                                        |
| `name`            | `string \| null` | Security name where available                                                                 |
| `type`            | `string`         | `"buy"`, `"sell"`, `"dividend"`, `"split"`, `"transfer"`, `"fee"`, `"interest"`, or `"other"` |
| `quantity`        | `string`         | Number of units (decimal string, supports fractional shares)                                  |
| `price`           | `string`         | Price per unit in `currency` (decimal string)                                                 |
| `currency`        | `string`         | ISO 4217 currency code (e.g. `"AUD"`, `"USD"`)                                                |
| `total_amount`    | `string`         | Total trade value in `currency` (decimal string)                                              |
| `fees`            | `string \| null` | Fees charged on this trade, in `currency`                                                     |
| `trade_date`      | `string`         | ISO 8601 timestamp when the trade executed                                                    |
| `settlement_date` | `string \| null` | ISO 8601 timestamp when the trade settled                                                     |
| `account`         | `string`         | Human-readable account name                                                                   |
| `description`     | `string \| null` | Provider-supplied description or note                                                         |

<Info>
  Trade amounts are **decimal strings**, not integers. This is different from the transaction payload (which uses smallest-unit integers) because brokerage quantities can be fractional — parsing as a `Decimal`/`BigDecimal` on your side is recommended.
</Info>

### Metadata fields

| Field           | Type             | Description                                                                                                    |
| --------------- | ---------------- | -------------------------------------------------------------------------------------------------------------- |
| `sync_run_id`   | `string \| null` | UUID of the sync run that produced this delivery. `null` when the delivery was replayed outside of a sync run. |
| `new_count`     | `integer`        | Number of new transactions in this payload                                                                     |
| `updated_count` | `integer`        | Number of updated transactions in this payload                                                                 |
| `chunk`         | `integer`        | Current chunk number (1-indexed)                                                                               |
| `total_chunks`  | `integer`        | Total number of chunks for this sync                                                                           |

### Chunking

When a sync produces more than 500 transactions, the payload is split into multiple requests. Each chunk is delivered sequentially as a separate POST. Use `metadata.chunk` and `metadata.total_chunks` to reassemble if needed.

## Verifying signatures

Every webhook request is signed with HMAC-SHA256 using your signing secret. Verify the signature on every request to confirm it came from Redbark and has not been tampered with.

### Verification steps

1. Extract the `X-Redbark-Timestamp` and `X-Redbark-Signature` headers
2. Concatenate the timestamp and raw request body with a period: `<timestamp>.<body>`
3. Compute the HMAC-SHA256 digest of this string using your signing secret
4. Compare your computed signature with the one in the header (strip the `sha256=` prefix)
5. Check that the timestamp is within 5 minutes of the current time to prevent replay attacks

### Example: Node.js

```javascript theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyWebhook(req, signingSecret) {
  const signature = req.headers["x-redbark-signature"];
  const timestamp = req.headers["x-redbark-timestamp"];
  const body = req.rawBody; // raw request body as string

  // Check timestamp is within 5 minutes
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (age > 300) {
    throw new Error("Timestamp too old, possible replay attack");
  }

  // Compute expected signature
  const hmac = createHmac("sha256", signingSecret);
  hmac.update(`${timestamp}.${body}`);
  const expected = `sha256=${hmac.digest("hex")}`;

  // Constant-time comparison
  if (
    signature.length !== expected.length ||
    !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  ) {
    throw new Error("Invalid signature");
  }

  return JSON.parse(body);
}
```

### Example: Python

```python theme={null}
import hashlib
import hmac
import time

def verify_webhook(headers, body, signing_secret):
    signature = headers["X-Redbark-Signature"]
    timestamp = headers["X-Redbark-Timestamp"]

    # Check timestamp is within 5 minutes
    age = abs(time.time() - int(timestamp))
    if age > 300:
        raise ValueError("Timestamp too old, possible replay attack")

    # Compute expected signature
    signed_content = f"{timestamp}.{body}".encode()
    expected = "sha256=" + hmac.new(
        signing_secret.encode(), signed_content, hashlib.sha256
    ).hexdigest()

    # Constant-time comparison
    if not hmac.compare_digest(signature, expected):
        raise ValueError("Invalid signature")

    return json.loads(body)
```

## Expected response

Your endpoint must return a **2xx status code** (e.g. `200 OK`) to acknowledge receipt. The response body is ignored.

| Status code                      | Behaviour                                                                                                                                                     |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2xx                              | Success. Delivery is recorded, consecutive-failure counter resets to zero.                                                                                    |
| 429                              | Rate limited. Retried with exponential backoff.                                                                                                               |
| 5xx                              | Server error. Retried with exponential backoff.                                                                                                               |
| Other 4xx                        | Client error. **Not retried** — counted as a single failed delivery.                                                                                          |
| Timeout (no response within 30s) | **Not retried** — the body has almost certainly been received by the endpoint, so a retry would risk duplicate delivery. Counted as a single failed delivery. |

### Retry schedule

Each delivery makes up to **3 attempts** in total. Sleeps before each retry use exponential backoff with a base of 3:

| Attempt | Sleep before     |
| ------- | ---------------- |
| 1       | none (immediate) |
| 2       | 1 second         |
| 3       | 3 seconds        |

The per-attempt request timeout is **30 seconds**.

### Auto-disable on failed deliveries

A single failed delivery cycle (all 3 attempts for the same payload exhausted, or a non-retried 4xx) increments the destination's `consecutiveFailures` counter. Once the counter reaches **1**, the destination is automatically disabled (`disabledAt` is set) and no further deliveries are attempted until the destination is manually re-enabled from the dashboard. The successful delivery of any subsequent payload resets the counter to zero. Auto-disable is intentionally aggressive: webhook endpoints are integrator-controlled, and continuing to retry against a broken endpoint causes more harm than good.

Timeouts are the exception. A timed-out chunk is recorded as a failed delivery, but it does **not** increment `consecutiveFailures`, does not disable the destination, and does not halt delivery of the remaining chunks in the sync.

## Deduplication

Entity IDs (transaction IDs and trade IDs) that have been successfully delivered are tracked per destination. Each successful delivery upserts the row, refreshing `deliveredAt` — the 90-day retention window therefore runs from the **last successful delivery** of that entity, not the first. If an entity is still inside its window when the next sync runs, it will not appear in `data.new`. Transactions and trades are deduplicated independently — a trade ID that happens to match a transaction ID won't collide. Your endpoint should still handle potential duplicates gracefully (e.g. using `<event.type>:<object.id>` as an idempotency key) in case of network-level retries.
