Documentation Index
Fetch the complete documentation index at: https://docs.redbark.co/llms.txt
Use this file to discover all available pages before exploring further.
Webhooks are in beta. The payload format and signing scheme may change.
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 objects in new and updated |
trades.synced | Brokerage trade sync completes | Trade 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.
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 | Unique UUID for this delivery attempt (matches id in the payload) |
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.
{
"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. |
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", "fee", "interest", or "other" |
account_id | string | UUID of the account. Use this to match transactions to accounts from the Accounts API. |
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 converted to the user’s timezone (YYYY-MM-DD). Based on the timezone in the user’s settings. |
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 for the full list. |
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 |
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).
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.
{
"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 |
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.
| 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
- Extract the
X-Redbark-Timestamp and X-Redbark-Signature headers
- Concatenate the timestamp and raw request body with a period:
<timestamp>.<body>
- Compute the HMAC-SHA256 digest of this string using your signing secret
- Compare your computed signature with the one in the header (strip the
sha256= prefix)
- Check that the timestamp is within 5 minutes of the current time to prevent replay attacks
Example: Node.js
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
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, or a timeout) 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.
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.