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

# Webhook Events

> Reference for the Brale webhook event envelope and the transfer, payment, address, and account event types your handler can subscribe to.

## Event envelope

All webhook events use a JSON envelope:

```json theme={null}
{
  "id": "event-id",
  "type": "transfer.completed",
  "created": "2026-04-29T23:30:00.000000Z",
  "data": {
    "id": "resource-id",
    "status": "complete"
  }
}
```

| Field | Type | Description |
| - | - | - |
| `id` | string | Stable event identifier (the event ID). Use this for deduplication. This is not the same as `data.id`. |
| `type` | string | Event type, such as `transfer.completed`. |
| `created` | string | UTC timestamp when the event was created. |
| `data` | object | Event-specific payload. `data.id` is the resource ID (e.g., transfer ID or payment ID), which may differ from the event `id`. |

## Managing accounts and event fan-in

If your integration uses [managed accounts](https://docs.brale.xyz/guides/managed-accounts.md) or [individual accounts](https://docs.brale.xyz/guides/individual-accounts.md), a single managing account can receive events for every account it manages.

* A webhook subscription owned by a **managing account** receives events for every account it manages, plus its own.
* A webhook subscription owned by a **managed account** receives only that account's own events.

This is derived from the subscription's owner. There is no scope parameter to set when creating the subscription.

To attribute an event to the correct managed account, read `data.account_id` from the payload. It is present on `payment.completed`, `address.created`, `address.ready`, `transfer.created`, `transfer.failed`, `transfer.refunding`, and `transfer.refunded`, in addition to the `account.verification.*` events that already carry it.

Your handler should branch on `type`.

```tsx theme={null}
switch (event.type) {
  case "transfer.completed":
    await handleTransferCompleted(event.data);
    break;
  case "transfer.failed":
    await handleTransferFailed(event.data);
    break;
  case "transfer.refunding":
  case "transfer.refunded":
    await handleRefundEvent(event);
    break;
  case "payment.completed":
    await handlePaymentCompleted(event.data);
    break;
  case "address.created":
  case "address.ready":
    await handleAddressEvent(event);
    break;
  default:
    // Unknown future event type.
    // Store and acknowledge safely.
    break;
}
```

## `transfer.created`

Emitted when a Brale transfer is created.

Example:

```json theme={null}
{
  "id": "36KkY9pF34Qy6nB3Wwd25rq4f5z",
  "type": "transfer.created",
  "created": "2026-04-29T23:30:00.000000Z",
  "data": {
    "id": "36Kr3QA7YeEEBY3ABp3e2zS8iq9",
    "account_id": "3Ar9BnQCKIrB3SYjKGBzCtFs6XL",
    "status": "pending",
    "amount": {
      "value": "100.00",
      "currency": "USD"
    }
  }
}
```

Notes:

* Treat `data.id` as the transfer ID.
* `data.account_id` identifies the account the transfer belongs to. Managing accounts use this to attribute events to a specific managed account.
* Additional fields may be present as the transfer schema evolves.
* Your integration should ignore unknown fields.

## `transfer.completed`

Emitted when a Brale transfer reaches `complete` status.

Use this event to replace polling transfer status.

Example:

```json theme={null}
{
  "id": "36Ky7AjzQHb6BAEcn6zJ4A3DdvH",
  "type": "transfer.completed",
  "created": "2026-04-29T23:30:00.000000Z",
  "data": {
    "id": "36KyrNktBXtnjfObINf5AjxvUlK",
    "status": "complete",
    "amount": {
      "value": "100.00",
      "currency": "USD"
    }
  }
}
```

Notes:

* Treat `data.id` as the transfer ID.
* Treat `data.status` as the completed transfer state. The event name is `transfer.completed`; the API status is `complete`.
* For outbound domestic wires and outbound ACH payouts, `transfer.completed` is not a guarantee that the payout cannot be returned later. If the bank returns the payout, the same transfer moves to `failed` and Brale emits [`transfer.failed`](#transfer-failed). Keep accepting `transfer.failed` for transfers you recorded as complete.
* Additional fields may be present as the transfer schema evolves.
* Your integration should ignore unknown fields.

## `transfer.failed`

Emitted when a Brale transfer reaches `failed` status.

Use this event to react to permanent transfer failures (e.g., ACH returns, outbound domestic wire returns, or other rail/provider failures). For ACH-specific return details, inspect `data.failure.ach_return`. For wire return details, inspect `data.failure.wire_return`.

Example:

```json theme={null}
{
  "id": "36KsiC47wqaMl9Xvq2ZG4MzAOUQ",
  "type": "transfer.failed",
  "created": "2026-04-29T23:30:00.000000Z",
  "data": {
    "id": "36KklImCvBPt4R5YhuIG43KIjFA",
    "account_id": "3Ar9BnQCKIrB3SYjKGBzCtFs6XL",
    "status": "failed",
    "amount": {
      "value": "100.00",
      "currency": "USD"
    },
    "failure": {
      "type": "ach_return",
      "occurred_at": "2026-04-29T23:29:55.000000Z",
      "retriable": false,
      "ach_return": {
        "code": "R01",
        "reason": "Insufficient Funds",
        "category": "administrative"
      }
    }
  }
}
```

Notes:

* Treat `data.id` as the transfer ID.
* `data.account_id` identifies the account the transfer belongs to. Managing accounts use this to attribute events to a specific managed account.
* Treat `data.status` as the failed transfer state.
* `data.failure` is usually populated for `transfer.failed`, but may be `null` when Brale does not have structured failure details. Always null-check before reading nested fields.
* `data.failure.retriable` indicates whether the failure is retriable. Spelled `retriable`, not `retryable`.
* `data.failure.ach_return` is only set for ACH return failures. See [the transfer `failure` field](https://docs.brale.xyz/api-reference/brale/get-transfer#failure) for the full schema.
* Additional fields may be present as the transfer schema evolves.
* Your integration should ignore unknown fields.

### Returned outbound wires

Brale uses the existing `transfer.failed` event for returned outbound domestic wires. There is no separate returned event. If Brale later refunds the payout, it reports the refund with the separate [refund events](#refund-events) `transfer.refunding` and `transfer.refunded`. The same transfer can produce `transfer.completed` and later `transfer.failed` if the wire is returned after completion.

Example (illustrative, not an observed production payload):

```json theme={null}
{
  "id": "36KHQsiJoUGm1YtmaD7v3dNi8Lf",
  "type": "transfer.failed",
  "created": "2026-10-06T18:30:05.000000Z",
  "data": {
    "id": "36KppWTv5aspzhU8QrTzhJqmHUo",
    "account_id": "3Ar9BnQCKIrB3SYjKGBzCtFs6XL",
    "status": "failed",
    "amount": {
      "value": "100.00",
      "currency": "USD"
    },
    "failure": {
      "type": "wire_return",
      "occurred_at": "2026-10-06T18:30:00Z",
      "retriable": false,
      "ach_return": null,
      "wire_return": {
        "code": "AC04",
        "reason": "Account closed"
      }
    }
  }
}
```

How to handle it:

* Read `data.status` for the payout state. Read `data.failure.type` for the failure classification when `data.failure` is present.
* When `data.failure.type` is `wire_return`, read `data.failure.wire_return`, not `data.failure.ach_return`.
* Do not require an ACH return code or a specific bank reason code to recognize a wire return. `data.failure.wire_return.code` is `WIRE_RETURN` when the provider supplies no code, and `data.failure.wire_return.reason` can be `null`.
* Missing structured failure details do not mean the transfer succeeded. If `data.status` is `failed`, update your records even when `data.failure` is `null`.
* `transfer.failed` confirms that the payout failed or was returned. It does not confirm that stablecoins were refunded or that refunded funds are available.
* Track payout state, return classification, and any refund credit separately. Not every failed or returned payout is refunded. Use [`transfer.refunding`](#transfer-refunding) and [`transfer.refunded`](#transfer-refunded) to track a refund.
* Do not assume event ordering. Retrieve the [latest transfer](https://docs.brale.xyz/api-reference/brale/get-transfer.md) when you need to reconcile its current state.

## Refund events

Brale reports the refund lifecycle with two events in the transfer family. They are separate from `transfer.failed`:

| Event | What it tells you | What it does not tell you |
| - | - | - |
| `transfer.failed` | The payout failed or was returned. | That a refund has started or completed. |
| `transfer.refunding` | The refund process has started. | That the refund is complete. |
| `transfer.refunded` | The refund has completed. | — |

Refund events apply to a redemption (stablecoin to fiat payout) whose payout was returned or canceled and then refunded. They use the same transfer as the original payout, so `data.id` matches the `data.id` you received on `transfer.failed` or `transfer.canceled`. A refunded payout produces this sequence:

`transfer.failed` (or `transfer.canceled`) → `transfer.refunding` → `transfer.refunded`

After the refund starts, the transfer's status is `refunding` and then `refunded`. Brale does not send `transfer.completed`, `transfer.failed`, or `transfer.canceled` for the transfer after that point.

A failed or returned payout is not automatically followed by a refund. Do not wait for `transfer.refunding` or `transfer.refunded` after every `transfer.failed`.

Subscriptions that use the `transfer.*` wildcard already receive both refund events. Subscriptions that list event types by name receive them only after you add `transfer.refunding` and `transfer.refunded`. See [Subscribe to refund events](https://docs.brale.xyz/webhooks/subscription-api-reference.md#subscribe-to-refund-events).

### `transfer.refunding`

Emitted when the refund process starts.

Notes:

Example (illustrative):

```json theme={null}
{
  "id": "36KppWTv5aspzhU8QrTzhJqmHUo",
  "type": "transfer.refunding",
  "created": "2026-10-07T15:00:00.000000Z",
  "data": {
    "id": "36KppWTv5aspzhU8QrTzhJqmHUo",
    "account_id": "3Ar9BnQCKIrB3SYjKGBzCtFs6XL",
    "status": "refunding",
    "amount": {
      "value": "100.00",
      "currency": "USD"
    },
    "failure": {
      "type": "wire_return",
      "occurred_at": "2026-10-06T18:30:00Z",
      "retriable": false,
      "ach_return": null,
      "wire_return": {
        "code": "AC04",
        "reason": "Account closed"
      }
    }
  }
}
```

Notes:

* Treat this event as "refund in progress." It does not confirm that the refund is complete or that refunded funds are available.
* Do not credit your end user's balance as final based on this event alone. Wait for `transfer.refunded`.
* `data.status` is always `refunding` on this event. Brale still delivers `transfer.refunding` if the refund completes before the event is sent, so the transfer's current status can already be `refunded`.
* `created` is the time the refund started.

### `transfer.refunded`

Emitted when the refund completes.

Notes:

Example (illustrative):

```json theme={null}
{
  "id": "36KppWTv5aspzhU8QrTzhJqmHUo",
  "type": "transfer.refunded",
  "created": "2026-10-07T15:05:00.000000Z",
  "data": {
    "id": "36KppWTv5aspzhU8QrTzhJqmHUo",
    "account_id": "3Ar9BnQCKIrB3SYjKGBzCtFs6XL",
    "status": "refunded",
    "amount": {
      "value": "100.00",
      "currency": "USD"
    },
    "failure": {
      "type": "wire_return",
      "occurred_at": "2026-10-06T18:30:00Z",
      "retriable": false,
      "ach_return": null,
      "wire_return": {
        "code": "AC04",
        "reason": "Account closed"
      }
    }
  }
}
```

Notes:

* Treat this event as confirmation that the refund has completed.
* `data.status` is `refunded`. `created` is the time the refund completed.
* Delivery order is not guaranteed. You can receive `transfer.refunded` before `transfer.refunding`, or without having processed `transfer.refunding`.

### Handle refund events

Both refund events carry the transfer resource in `data`:

| Field | Description |
| - | - |
| `data.id` | Transfer ID of the original redemption. |
| `data.account_id` | Account the transfer belongs to. |
| `data.status` | `refunding` or `refunded`. |
| `data.amount` | Transfer amount (`value`, `currency`). |
| `data.source`, `data.destination` | Transfer legs, when available. |
| `data.failure` | The original payout failure, in the same shape as [the transfer `failure` field](https://docs.brale.xyz/api-reference/brale/get-transfer#failure). Can be `null`. |

Ignore unknown fields in `data`. Retrieve the [latest transfer](https://docs.brale.xyz/api-reference/brale/get-transfer.md) when you need its current state.

```tsx theme={null}
async function handleRefundEvent(event) {
  // Signature already verified against the raw request body.

  // Process idempotently: skip events you have already handled.
  // Key on id and type. Both refund events for a transfer share the same id.
  if (await alreadyProcessed(event.id, event.type)) return;

  if (event.type === "transfer.refunding") {
    // Refund started. Do not treat it as complete.
    // Do not overwrite a refund you already recorded as complete.
    await markRefundInProgressUnlessComplete(event);
  } else if (event.type === "transfer.refunded") {
    // Refund completed.
    await markRefundComplete(event);
  }

  await recordProcessed(event.id, event.type);
}
```

How to handle them:

* [Verify the signature](https://docs.brale.xyz/webhooks/signatures.md) before you process the event.
* Deduplicate on the event `id` and `type` together. Brale can deliver the same event more than once, and `transfer.refunding` and `transfer.refunded` for the same transfer share the same `id`.
* Do not assume delivery order. Never move a refund you recorded as complete back to in progress.
* Retrieve the latest transfer to reconcile when events arrive out of order or you are unsure of the current state.

## `payment.completed`

Emitted when a payment reaches `complete` status.

Example:

```json theme={null}
{
  "id": "36KZe95b9eGe0vRBbgi09qynDAk",
  "type": "payment.completed",
  "created": "2026-04-28T21:30:00.000000Z",
  "data": {
    "id": "36KY8ISwYDFHL3tVTNYTHPzpppp",
    "account_id": "3Ar9BnQCKIrB3SYjKGBzCtFs6XL",
    "status": "complete",
    "amount": {
      "value": "1000.00",
      "currency": "USD"
    },
    "direction": "inbound",
    "type": "wire",
    "inserted_at": "2026-04-28T20:00:00.000000Z",
    "updated_at": "2026-04-28T21:30:00.000000Z"
  }
}
```

Notes:

* `data.account_id` identifies the account the payment belongs to. Managing accounts use this to attribute events to a specific managed account.
* `data.id` is a payment ID. It is not a Transfer ID. Brale creates a separate Transfer for Automation deposits.
* `payment.completed` means Brale received the fiat payment. It does not mean the stablecoin mint was delivered. Use [`transfer.completed`](#transfer-completed) or the Transfer's `status` for that.
* `data.type` identifies the payment rail. The example shows a wire. For FedNow deposits, do not hard-code an expected `data.type` value. Correlate with the Transfer instead.
* To find the Transfer for an Automation deposit, list Transfers with `automation_id` and `transfer_type` (for example, `transfer_type=fednow_credit`), then match `data.account_id`, the amount, and `source.payment_details.received_at`. See [Receive FedNow deposits](https://docs.brale.xyz/guides/fednow-inbound.md#find-the-transfer).
* Brale also emits `payment.completed` for inbound payments created by [Simulate an Automation deposit](https://docs.brale.xyz/api-reference/brale/simulate-automation-deposit.md) on testnet. There is no simulation-specific webhook type, so the same handler covers simulated and real deposits.

## `transfer.canceled`

Emitted when a Brale transfer is canceled.

Example:

```json theme={null}
{
  "id": "36K6uEp3c4dsa7lC360A9y6YnD1",
  "type": "transfer.canceled",
  "created": "2026-04-29T23:30:00.000000Z",
  "data": {
    "id": "36K4TdDo9EgZmCnu77Svtuuj596",
    "status": "canceled",
    "amount": {
      "value": "100.00",
      "currency": "USD"
    }
  }
}
```

Notes:

* Treat `data.id` as the transfer ID.
* Treat `data.status` as the canceled transfer state.
* Additional fields may be present as the transfer schema evolves.
* Your integration should ignore unknown fields.

## Address events

Brale reports new addresses with two events in the `address` family. Both cover wallets (internal custodial wallets and external wallets) and external bank accounts, including bank accounts linked through Plaid. Subscribe to both with `address.*`.

| Event | When it fires |
| - | - |
| `address.created` | A wallet or bank account is added to the account. A custodial wallet that is still `pending` triggers this event. |
| `address.ready` | The wallet or bank account reaches `active` status and can be used as a transfer source or destination. |

An address that is created already `active` produces both events. A custodial wallet created as `pending` produces `address.created` first, then `address.ready` when it becomes `active`. See [Address lifecycle](https://docs.brale.xyz/key-concepts/addresses.md#address-lifecycle).

### `address.created`

Emitted when a wallet or bank account is added to an account.

Example (wallet, illustrative):

```json theme={null}
{
  "id": "2VZvtmVc2j3gQ80CTlcuQXbGrwC",
  "type": "address.created",
  "created": "2026-10-22T18:00:00.000000Z",
  "data": {
    "id": "2VZvtmVc2j3gQ80CTlcuQXbGrwC",
    "account_id": "3Ar9BnQCKIrB3SYjKGBzCtFs6XL",
    "kind": "wallet",
    "status": "pending",
    "type": "internal",
    "ownership": "self-owned",
    "name": "Treasury Wallet",
    "transfer_types": ["spark"],
    "address": null,
    "account_number": null,
    "routing_number": null,
    "account_type": null
  }
}
```

Notes:

* The event `id` and `data.id` are both the address ID.
* `created` is the time the address was added.
* `data.status` is the address status when Brale builds the event: `pending`, `active`, `archived`, or `disabled`. A delayed delivery can report a status other than the one the address had when it was added.
* Do not use the address in transfers based on this event alone. Wait for [`address.ready`](#address-ready) or check that `data.status` is `active`.

### `address.ready`

Emitted when a wallet or bank account reaches `active` status and can be used in transfers.

Example (bank account, illustrative):

```json theme={null}
{
  "id": "2VZw1Kq9YdPz3hL0mN5sR8tUvXa:ready",
  "type": "address.ready",
  "created": "2026-10-22T18:05:00.000000Z",
  "data": {
    "id": "2VZw1Kq9YdPz3hL0mN5sR8tUvXa",
    "account_id": "3Ar9BnQCKIrB3SYjKGBzCtFs6XL",
    "kind": "fiat_account",
    "status": "active",
    "type": "external",
    "ownership": "customer-owned",
    "name": "Chase Bank",
    "transfer_types": ["ach_credit", "wire"],
    "address": null,
    "account_number": "****1234",
    "routing_number": "021000021",
    "account_type": "checking"
  }
}
```

Notes:

* The event `id` is the address ID with a `:ready` suffix, so it never matches the `address.created` event `id` for the same address. `data.id` is the address ID without the suffix.
* `created` is the time the address was last updated.
* `data.status` is always `active` on this event.

### Address event payload

Both address events carry the same `data` object. Every field is always present.

| Field | Type | Description |
| - | - | - |
| `data.id` | string | Address ID. Use it with the [Addresses API](https://docs.brale.xyz/api-reference/brale/get-address.md) and as a transfer `address_id`. |
| `data.account_id` | string | Account the address belongs to. |
| `data.kind` | string | `wallet` for an onchain wallet or `fiat_account` for a bank account. |
| `data.status` | string | `pending`, `active`, `archived`, or `disabled`. |
| `data.type` | string | `internal` for a custodial wallet or `external` for an external wallet or bank account. |
| `data.ownership` | string | `self-owned` or `customer-owned`. |
| `data.name` | string or null | Address name. |
| `data.transfer_types` | array of strings or null | Transfer types the address supports, using the same values as the Addresses API. `null` for a bank account with no transfer types configured yet. |
| `data.address` | string or null | Onchain address for a wallet. `null` for bank accounts, and `null` for a custodial wallet that is still `pending`. |
| `data.account_number` | string or null | Masked bank account number (for example, `****1234`). `null` for wallets. |
| `data.routing_number` | string or null | Bank routing number. `null` for wallets. |
| `data.account_type` | string or null | `checking` or `savings`. `null` for wallets. |

Ignore unknown fields in `data`. Retrieve the address with the Addresses API when you need its current state.

## `account.verification.documents_required`

Emitted when Brale's KYB review determines one or more documents must be uploaded.

Use this event to prompt the account holder to submit the requested documents. For each document with `status: "pending_upload"`, [stage the file](https://docs.brale.xyz/api-reference/brale/stage-verification-document.md) (use `document_kind` and `person_label` from the payload), then [link staged submissions](https://docs.brale.xyz/api-reference/brale/link-verification-documents.md). You can also [poll required documents](https://docs.brale.xyz/api-reference/brale/list-verification-documents.md) instead of using this webhook.

Example:

```json theme={null}
{
  "id": "3Ar9BnQCKIrB3SYjKGBzCtFs6XL",
  "type": "account.verification.documents_required",
  "created": "2026-06-09T14:32:00Z",
  "data": {
    "account_id": "3Ar9BnQCKIrB3SYjKGBzCtFs6XL",
    "updated_at": "2026-06-09T14:32:00Z",
    "documents": [
      {
        "document_kind": "certificate_of_good_standing",
        "document_type": "business_document",
        "display_name": "Certificate of Good Standing",
        "status": "pending_upload",
        "person_label": null
      },
      {
        "document_kind": "government_photo_id",
        "document_type": "individual_document",
        "display_name": "Government-issued photo ID",
        "status": "pending_upload",
        "person_label": "Jane Doe"
      }
    ]
  }
}
```

Notes:

* Treat `data.account_id` as the account ID.
* Each entry in `data.documents` describes a required document. `status` is `pending_upload` until the document is submitted.
* Include `person_label` when staging individual (KYC) documents.
* Your integration should ignore unknown fields.

## `account.verification.completed`

Emitted when account verification completes and the account is enabled.

Example:

```json theme={null}
{
  "id": "3Ar9BnQCKIrB3SYjKGBzCtFs6XL",
  "type": "account.verification.completed",
  "created": "2026-06-09T16:00:00Z",
  "data": {
    "account_id": "3Ar9BnQCKIrB3SYjKGBzCtFs6XL",
    "status": "complete",
    "updated_at": "2026-06-09T16:00:00Z"
  }
}
```

Notes:

* Treat `data.account_id` as the account ID.
* Treat `data.status` as the completed verification state (`complete`).
* This event fires only when the account reaches `complete` status.
* Your integration should ignore unknown fields.

## `account.verification.rejected`

Emitted when account verification is declined. This is the terminal outcome for a review: the account will not become `complete` on its own, and no further verification events follow.

Example:

```json theme={null}
{
  "id": "3Ar9BnQCKIrB3SYjKGBzCtFs6XL",
  "type": "account.verification.rejected",
  "created": "2026-06-09T16:00:00Z",
  "data": {
    "account_id": "3Ar9BnQCKIrB3SYjKGBzCtFs6XL",
    "status": "rejected",
    "updated_at": "2026-06-09T16:00:00Z"
  }
}
```

Notes:

* Treat `data.account_id` as the account ID.
* Treat `data.status` as the declined verification state (`rejected`).
* The account's `status` from `GET /accounts/{account_id}` becomes `rejected` at the same time, so polling and webhooks agree.
* Uploading more documents does not reopen a declined account. Contact Brale if you believe a decline is incorrect.
* Your integration should ignore unknown fields.

> [!NOTE]
>
> Subscriptions to the `account.verification.*` wildcard receive this event automatically. If you subscribed to `account.verification.completed` by name, add `account.verification.rejected` to your subscription to be told about declines.

## Handling future event types

Brale may add new event types over time.

Best practices:

* Use `GET /accounts/{account_id}/webhooks/event_types` to discover supported events.
* Branch on `event.type`.
* Acknowledge unknown event types safely.
* Ignore unknown fields in `data`.
* Do not assume event ordering.
* To automatically receive future events in a family (e.g., new `transfer.*`, `address.*`, or `account.*` events), subscribe with a namespace wildcard like `transfer.*` or `account.*` when you [create](https://docs.brale.xyz/api-reference/brale/create-webhook-subscription.md#wildcard-event-subscriptions) or [update](https://docs.brale.xyz/api-reference/brale/update-webhook-subscription.md) a subscription. Wildcards work for every event family.
