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

# Webhooks

> Receive signed, idempotent event notifications from the Kira API.

Receive signed event notifications when activity happens in your account. Webhooks let you react to user verification, virtual account, deposit, and payout lifecycle changes without polling.

## Setting up webhooks

Webhook delivery is **configured for you by the Kira team** — there is no self-serve registration endpoint. When you're ready to receive events, contact Kira at [support@kirafin.ai](mailto:support@kirafin.ai) with the **HTTPS URL** where deliveries should be sent, and we'll register it for your account.

**You get one URL per account.** Every event type — onboarding, virtual account, deposit, and payout — is delivered to that single URL; there's no filtering by user, product, or event type. If your onboarding and transaction systems run on different hosts, register one of them and route internally on your side. To confirm or change the URL we have on file, email [support@kirafin.ai](mailto:support@kirafin.ai) — there's no API to look it up or update it yourself.

<Note>
  Your webhook **signing secret** is provisioned by the Kira team at the same time. If you manage your own secret material, tell Kira which value to use. Signature verification (below) is only meaningful once the secret is in place.
</Note>

## Verify the signature

Every delivery carries the **`x-signature-sha256`** header. Its value is the **hex HMAC-SHA256 over the raw request body**, keyed with your webhook secret.

<Note>
  Signature verification is only meaningful once your **signing secret** is set (provisioned by the Kira team during setup), so confirm yours is in place before you rely on `x-signature-sha256`.
</Note>

To verify a delivery:

<Steps>
  <Step title="Read the raw request body">
    Verify against the **raw bytes** exactly as received. Do **not** re-serialize the JSON before hashing — re-serialization changes whitespace and key order, which breaks the signature.
  </Step>

  <Step title="Compute the HMAC">
    Compute `HMAC-SHA256(raw_body, webhook_secret)` and hex-encode it. Use the **exact, case-sensitive** secret.
  </Step>

  <Step title="Compare in constant time">
    Compare your computed hex digest against the `x-signature-sha256` header value using a **constant-time** comparison to avoid timing attacks.
  </Step>
</Steps>

<CodeGroup>
  ```javascript verify.js theme={null}
  import crypto from "node:crypto";

  function verifyWebhook(rawBody, signatureHeader, secret) {
    const expected = crypto
      .createHmac("sha256", secret)
      .update(rawBody) // raw bytes — do NOT re-serialize
      .digest("hex");

    const a = Buffer.from(expected);
    const b = Buffer.from(signatureHeader);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }
  ```

  ```python verify.py theme={null}
  import hmac, hashlib

  def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
      expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, signature_header)
  ```
</CodeGroup>

## Delivery semantics

<Warning>
  **Single delivery, NO retry** on this pin — your endpoint must be **highly available**. A missed delivery is not re-sent. **We also abort after 30 seconds** if your endpoint hasn't responded — treat that the same as a missed delivery. Return `2xx` immediately and handle any processing afterward, asynchronously.
</Warning>

* **De-duplicate on `data.event_id`.** This is live-verified: `event_id` sits at `data.event_id` in **both** envelope shapes. There is **no** root-level `event_id`.
* **Make processing idempotent.** Because deliveries are not retried and you must dedupe, design your handler so that re-processing the same `data.event_id` has no additional effect.
* **Return `2xx` (and log) for unknown event types.** New event types may appear; acknowledge them so they are not treated as failures.

## Envelope shapes

Kira emits **two** active envelope shapes — read the `event` field first, then branch. In **both**, `event_id` sits at **`data.event_id`**; there is **no** root-level `event_id`.

### Standard flat shape

Most events (`user.*`, `virtual_account.*`, and the simple `payout.created` / `pending` / `processing` / … notifications) use a flat `{ event, data }` envelope:

```json theme={null}
{
  "event": "virtual_account.deposit_funds_received",
  "data": {
    "event_id": "491e0d6e-a5e1-4158-a331-db8accc80a57",
    "status": "completed",
    "amount": "123.45000000",
    "currency": "USD",
    "virtual_account_id": "f236ae11-ce2d-4bb8-a580-c8601af98cbd"
  }
}
```

### `payout.status_changed` V2 shape

`payout.status_changed` keeps the outer `{ event, data }` keys but **double-nests** the payload: `data` carries `event_id` + `event_type` + `created_at`, and the actual payout fields sit one level deeper at `data.data` (`status` **UPPERCASE**, `previous_status`, `amount`, `payout_id`, `recipient{…}`, optional `review_reason`, `destination_amount`, …) — unwrap defensively. `event_id` is **still** at `data.event_id`. This is the envelope that surfaces `KYT_PENDING` / `IN_REVIEW`.

```json theme={null}
{
  "event": "payout.status_changed",
  "data": {
    "event_id": "f6e3c92c-43b5-49e5-8545-de31dc1105c9",
    "event_type": "payout.status_changed",
    "created_at": "2026-05-23T00:37:56.874Z",
    "data": {
      "status": "IN_REVIEW",
      "previous_status": "PROCESSING",
      "amount": "100.00",
      "payout_id": "e2503e1d-6a42-4602-bc83-4eddc15a18aa",
      "review_reason": "HTTP 500 - payout provider is not configured",
      "destination_amount": "70.00"
    }
  }
}
```

<Note>
  **Parser strategy:** if `data.data` is present (or `data.event_type == "payout.status_changed"`) → V2: read the status at `data.data.status` (UPPERCASE) and `data.data.previous_status`. Otherwise → standard flat. In **both** shapes, dedupe on **`data.event_id`**.
</Note>

<Note>
  **Legacy V1 (not emitted on the `2026-04-14` pin).** A legacy V1 envelope carried `event_id` at the root level; it is not emitted on this pin, so you only need to handle the two shapes above. For the resource state machines that drive these events, see [State machines](/guides/state-machines).
</Note>

## Event catalog

These are the **real** event names emitted today on the `2026-04-14` pin. (Older `user.verification.passed`, `virtual_account.deposit.completed`, and `payout.status_changed`-only models are wrong — use the names below.) Each status's lifecycle is defined in [State machines](/guides/state-machines).

### User

| Event                           | When                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `user.created`                  | User created                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `user.updated`                  | User record changed                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `user.status_changed`           | Fired on **every** KYC/user status transition (`CREATED→VERIFYING`, `→REVIEW`, `→VERIFIED`, `→REJECTED`). Carries `previous_status` / `new_status`.                                                                                                                                                                                                                                                                                                                                                                                     |
| `user.verification.accepted`    | Automatic verification approved — fires for **both** individual (KYC) and business (KYB) approvals                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `user.verification.failed`      | Verification reached terminal `REJECTED`: the provider **declined on the data** (reasons forwarded in `data.reasons[]`), or — rare backstop — a verification session the provider **did** receive **expired** after the maximum polling window (`data.reasons: ["Verification session expired"]`). A verification-**provider call failure** (network / 5xx / timeout reaching the provider) is **not** this event — it is a retryable system error, the user stays non-terminal, and the transition surfaces via `user.status_changed`. |
| `user.liveness_completed`       | A user's liveness check (started from a liveness link) reached a terminal result. Carries `data.result`: `"approved"` or `"rejected"`, and `data.person_reference_id` identifying which beneficial owner completed (for a business) or `null` (for an individual). This is **independent** of the user's main KYC status — the user's `status` is unchanged by a liveness result.                                                                                                                                                       |
| `user.document.download.failed` | A submitted document URL could not be downloaded                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |

<Note>
  A verification-**provider call failure** (Kira could not reach its KYC/KYB provider) is a retryable system error, **not** a rejection: the sub-client stays non-terminal (`VERIFYING`) and Kira retries — it never fires `user.verification.failed`. If the failure persists past the automatic retries, Kira's team is alerted and re-runs the verification; that verification is eventually closed out internally, and doing so never rejects the sub-client or fires `user.verification.failed`. Only a provider **result** of declined (or, rarely, the expiry of a session the provider received) produces `user.verification.failed` + terminal `REJECTED`. Key your onboarding UI off `user.status_changed` (or the `status` field), so a transient provider blip never looks like a rejection.
</Note>

Example `user.status_changed` payload:

```json theme={null}
{
  "event": "user.status_changed",
  "data": {
    "event_id": "evt_9f1c…",
    "user_id": "usr_…",
    "previous_status": "VERIFYING",
    "new_status": "VERIFIED"
  }
}
```

### Virtual account (lifecycle)

| Event                       | When                                                    |
| --------------------------- | ------------------------------------------------------- |
| `virtual_account.created`   | VA provisioned (may arrive while still activating)      |
| `virtual_account.activated` | VA reached active — the **only** funds-ready VA webhook |

### Deposit (`virtual_account.deposit_*`)

| Event                                          | When                                                                             |
| ---------------------------------------------- | -------------------------------------------------------------------------------- |
| `virtual_account.deposit_funds_received`       | Inbound funds detected (also re-sent with `data.status: "refunded"` on a return) |
| `virtual_account.microdeposit_funds_received`  | Sub-\$1.00 verification micro-deposit received                                   |
| `virtual_account.deposit_funds_in_transit`     | Funds in transit                                                                 |
| `virtual_account.deposit_funds_in_destination` | Credited — terminal; carries `tx_hash` / `net_amount`                            |
| `virtual_account.deposit_funds_failed`         | Settlement failed → `FAILED`                                                     |
| `virtual_account.deposit_returned`             | Deposit returned → `REFUNDED`                                                    |

<Note>
  The deposit payload's `source.*` fields are **rail-dependent**. `payment_rail` and `sender_name` are common; **wire** adds `imad`, `omad`, `wire_message`; **ACH** instead carries `sender_account_number`, `reference_number`, `trace_number`, `sec_code`, `memo`. Treat each `source.*` field as optional.
</Note>

### Payout

| Event                     | When                                                   | Resulting `status`                                   |
| ------------------------- | ------------------------------------------------------ | ---------------------------------------------------- |
| `payout.created`          | Payout created                                         | `CREATED`                                            |
| `payout.pending`          | Payout queued                                          | `PENDING`                                            |
| `payout.processing`       | Payout sent to rail                                    | `PROCESSING`                                         |
| `payout.completed`        | Payout delivered                                       | `COMPLETED`                                          |
| `payout.failed`           | Payout failed                                          | `FAILED`                                             |
| `payout.returned`         | Returned by the beneficiary bank                       | `FAILED` (+ `error_code: "va-payout-bank-returned"`) |
| `payout.expired`          | Crypto payout never funded by deadline                 | `EXPIRED` (crypto-only)                              |
| `payout.deposit_received` | Crypto-funded payout: on-chain funding seen            | *(no status change)*                                 |
| `payout.status_changed`   | Generic envelope carrying `status` + `previous_status` | the ONLY way `KYT_PENDING` / `IN_REVIEW` surface     |

<Note>
  The **Resulting `status`** column is the **payout resource** status on `GET /v1/payouts/{id}` (UPPERCASE). The **event's own** `data.status` can differ — `payout.returned` carries `data.status: "returned"`, and an ACT-rail cancellation arrives as `payout.failed` with `data.status: "cancelled"`. See [Status and casing notes](#status-and-casing-notes) below.
</Note>

<Note>
  **Only two events echo custom `metadata`.** Two resource events echo the resource's custom `metadata` — the string→string pairs you attached at create time: `user.created` and `virtual_account.created`. A user's `metadata` can also include **client-level default keys** Kira has configured for your account — defaults are merged in at create time (your request keys win on conflict), so you may see keys you did not send. The value reflects the persisted state at emit time and follows the standard metadata constraints (up to 50 entries, keys 1–40 characters, values up to 500 characters, no `[` or `]` in keys); a resource with no metadata sends an empty object `{}` rather than omitting the field. Other events — `user.updated`, `user.status_changed`, `user.verification.accepted`/`.failed`, `user.liveness_completed`, the `virtual_account.deposit_funds_received`/`microdeposit_funds_received` deposit events, and the terminal `payout.completed`/`payout.failed`/`payout.returned` events — do **not** currently include a `metadata` field.
</Note>

<Note>
  **Payouts use two overlapping event families.** A single transition can produce **both** a granular `payout.*` event (e.g. `payout.pending` / `payout.processing`) and a `payout.status_changed` carrying the same status — with different payload shapes. And terminal success arrives as **either** `payout.completed` **or** `payout.status_changed` with `data.data.status: "COMPLETED"`, depending on the rail/provider. Drive your reconciliation off the **status value** (compared case-insensitively across the flat `data.status` and the V2 `data.data.status`) and treat both families as authoritative — don't wait for one specific event name.
</Note>

## Status and casing notes

<Warning>
  A beneficiary-bank return arrives as the **`payout.returned`** event carrying **`data.status: "returned"`** (lowercase, flat shape). The payout **resource** then resolves to a terminal failed state — `GET /v1/payouts/{id}` returns `FAILED` and carries **`error_code: "va-payout-bank-returned"`** (the `error_code` is on the resource, **not** in the webhook payload). Returned funds come back to your balance, minus any return fee. Identify a return by the **event name** `payout.returned` (or `data.status == "returned"`), not by a resource status of `RETURNED` — there is none.
</Warning>

<Warning>
  A returned/refunded deposit resolves to **status `REFUNDED`** — there is **no `RETURNED` deposit status** either. Branch on `data.status == "refunded"`, **not** the event name.
</Warning>

* `KYT_PENDING` / `IN_REVIEW` surface **only** via `payout.status_changed`.
* A provider **cancellation** (ACT rail) arrives as a **`payout.failed`** event carrying **`data.status: "cancelled"`**; the payout resource resolves to `FAILED`.
* **Not emitted:** `virtual_account.failed`, `virtual_account.deactivated`, `payout.kyt_pending`, `payout.in_review`, and any dispute events.

<Note>
  **Compliance holds resolve on the resource.** A deposit or payout held for transaction-monitoring (KYT) or manual review surfaces the hold itself — for payouts, as `payout.status_changed` with `data.data.status` of `KYT_PENDING` or `IN_REVIEW`. The **resolution** of that review (cleared or rejected) is reflected on the resource and is not guaranteed to arrive as a separate terminal webhook. For any held transaction, confirm the final state with `GET /v1/payouts/{id}` or `GET /v1/virtual-accounts/{id}/deposits/{depositId}` rather than waiting on a terminal event.
</Note>

### Casing

Status casing differs by event family — **always compare statuses case-insensitively**:

| Event family            | Status location    | Casing    | Examples                                         |
| ----------------------- | ------------------ | --------- | ------------------------------------------------ |
| Flat payout / VA events | `data.status`      | lowercase | `created`, `pending`, `processing`, `activating` |
| `payout.status_changed` | `data.data.status` | UPPERCASE | `IN_REVIEW`, `PROCESSING`                        |
| `user.*` events         | `data.status`      | UPPERCASE | `CREATED`                                        |

<Warning>
  The payout `201` create response is lowercase `"created"` while `GET` returns `CREATED` — **always compare statuses case-insensitively**.
</Warning>

## Example event payloads

The following are example event payloads, illustrating each envelope shape.

<AccordionGroup>
  <Accordion title="user.created" icon="user-plus">
    ```json theme={null}
    {
      "event": "user.created",
      "data": {
        "event_id": "0af1a2f4-49c4-41a3-accf-d4ba74691bbe",
        "type": "individual",
        "email": "user@example.com",
        "phone": "",
        "status": "CREATED",
        "user_id": "5f575683-93b6-4a4d-b70c-d71c402b5a90",
        "metadata": {},
        "created_at": "2026-05-23T00:37:38.769Z",
        "missing_fields": {
          "usa-virtual-accounts": ["birth_date", "phone", "nationality", "address_street", "address_city", "address_state", "address_zip_code", "address_country", "document_type", "document_number", "document_country", "identifying_information:front", "identifying_information:back:unless_doc_type:passport", "identifying_information:file_proof_of_address"],
          "usa-virtual-accounts-act": ["birth_date", "phone", "nationality", "address_street", "address_city", "address_state", "address_zip_code", "address_country", "document_type", "document_number", "document_country", "immigration_status", "additional_info:has_us_bank_account", "additional_info:has_denied_bank_account", "employment_status"]
        },
        "verification_mode": "automatic",
        "verification_status": "unverified",
        "verification_triggered": false
      }
    }
    ```
  </Accordion>

  <Accordion title="user.liveness_completed" icon="shield-check">
    ```json theme={null}
    {
      "event": "user.liveness_completed",
      "data": {
        "event_id": "c1a2b3d4-5e6f-4708-9234-56789abcdef0",
        "user_id": "5f575683-93b6-4a4d-b70c-d71c402b5a90",
        "person_reference_id": null,
        "result": "approved"
      }
    }
    ```

    Shown for an individual, hence `person_reference_id: null`. For a business, this instead carries the beneficial owner's provider reference id — the same value returned in that UBO's entry from `POST /v1/users/{user_id}/liveness-link` — so you can route the result to the right person.
  </Accordion>

  <Accordion title="virtual_account.deposit_funds_received" icon="arrow-down-to-line">
    ```json theme={null}
    {
      "event": "virtual_account.deposit_funds_received",
      "data": {
        "event_id": "491e0d6e-a5e1-4158-a331-db8accc80a57",
        "amount": "123.45000000",
        "source": {
          "imad": "24314815FMFNUS815455",
          "omad": "44473246FMFNUS272296",
          "sender_name": "Simulated Sender",
          "payment_rail": "wire",
          "wire_message": "Simulated wire deposit from Simulated Sender"
        },
        "status": "completed",
        "currency": "USD",
        "created_at": "2026-05-23T00:37:45.897Z",
        "deposit_id": "72b6581c-76f4-41a3-8169-8ba6c36c138d",
        "virtual_account_id": "f236ae11-ce2d-4bb8-a580-c8601af98cbd"
      }
    }
    ```

    The `source.*` fields depend on the **payment rail**. `payment_rail` and `sender_name` are common; **wire** adds `imad`, `omad`, and `wire_message` (shown above), while **ACH** instead carries `sender_account_number`, `reference_number`, `trace_number`, `sec_code`, and `memo`. Read defensively — treat any individual `source.*` field as optional.
  </Accordion>

  <Accordion title="payout.created" icon="arrow-up-from-line">
    ```json theme={null}
    {
      "event": "payout.created",
      "data": {
        "event_id": "ee02c66f-56dd-4a30-a209-35c5d8e8d0d7",
        "fees": {
          "total": "30.00",
          "base_fees": {
            "total": "30.00",
            "fixed_fee": "30.00",
            "percentage_fee": "0.00",
            "bank_account_fee": "0.00",
            "bank_account_fee_percentage": "0"
          },
          "total_fees": "30.00",
          "network_fee": "0.00",
          "client_markup": {
            "total": "0.00",
            "fixed_fee": "0.00",
            "percentage_fee": "0.00"
          }
        },
        "amount": "100.00",
        "status": "created",
        "currency": "USD",
        "payout_id": "e2503e1d-6a42-4602-bc83-4eddc15a18aa",
        "created_at": "2026-05-23T00:37:46.373Z",
        "recipient_id": "e67383b6-04c0-42f9-b199-f4523909178f",
        "recipient_amount": "70.00",
        "recipient_currency": "USD",
        "virtual_account_id": "f236ae11-ce2d-4bb8-a580-c8601af98cbd"
      }
    }
    ```
  </Accordion>

  <Accordion title="payout.pending / payout.processing" icon="spinner">
    ```json theme={null}
    {
      "event": "payout.processing",
      "data": {
        "event_id": "50df79a7-832d-4567-a63e-f62e4bb0ad74",
        "fees": {
          "total": "30.00",
          "base_fees": {
            "total": "30.00",
            "fixed_fee": "30.00",
            "percentage_fee": "0.00",
            "bank_account_fee": "0.00",
            "bank_account_fee_percentage": "0"
          },
          "total_fees": "30.00",
          "network_fee": "0.00",
          "client_markup": {
            "total": "0.00",
            "fixed_fee": "0.00",
            "percentage_fee": "0.00"
          }
        },
        "amount": "100.00",
        "status": "processing",
        "currency": "USD",
        "payout_id": "e2503e1d-6a42-4602-bc83-4eddc15a18aa",
        "recipient_id": "e67383b6-04c0-42f9-b199-f4523909178f",
        "recipient_amount": "70.00",
        "recipient_currency": "USD",
        "virtual_account_id": "f236ae11-ce2d-4bb8-a580-c8601af98cbd"
      }
    }
    ```
  </Accordion>

  <Accordion title="payout.status_changed (terminal, V2 shape)" icon="arrows-rotate">
    ```json theme={null}
    {
      "event": "payout.status_changed",
      "data": {
        "event_id": "f6e3c92c-43b5-49e5-8545-de31dc1105c9",
        "data": {
          "amount": "100.00",
          "status": "IN_REVIEW",
          "currency": "USD",
          "provider": "kira",
          "payout_id": "e2503e1d-6a42-4602-bc83-4eddc15a18aa",
          "recipient": {
            "name": "Eu Recipient",
            "bank_name": "CaixaBank",
            "company_name": null,
            "account_number": "****1332",
            "routing_number": null
          },
          "reference": "",
          "wallet_id": "",
          "review_reason": "HTTP 500 - payout provider is not configured",
          "previous_status": "PROCESSING",
          "destination_amount": "70.00",
          "destination_currency": "USD"
        },
        "created_at": "2026-05-23T00:37:56.874Z",
        "event_type": "payout.status_changed"
      }
    }
    ```
  </Accordion>

  <Accordion title="payout.returned / payout.failed (terminal, flat shape)" icon="arrow-rotate-left">
    A bank return or failure uses the flat envelope. The event's own `data.status` is lowercase (`returned`, `failed`, or `cancelled`); the payout **resource** resolves to `FAILED` and carries the `error_code` (`GET /v1/payouts/{id}`) — the `error_code` is **not** in the webhook payload.

    ```json theme={null}
    {
      "event": "payout.returned",
      "data": {
        "event_id": "9b1c2d3e-4f56-4789-a0b1-c2d3e4f56789",
        "payout_id": "e2503e1d-6a42-4602-bc83-4eddc15a18aa",
        "virtual_account_id": "f236ae11-ce2d-4bb8-a580-c8601af98cbd",
        "amount": "100.00",
        "currency": "USD",
        "recipient_amount": "70.00",
        "recipient_currency": "USD",
        "recipient": {
          "name": "Eu Recipient",
          "bank_name": "CaixaBank",
          "company_name": null,
          "account_number": "****1332",
          "routing_number": null
        },
        "status": "returned",
        "uetr": "a1b2c3d4-5e6f-7081-9234-56789abcdef0",
        "reference_number": "a1b2c3d4-5e6f-7081-9234-56789abcdef0",
        "imad": null,
        "omad": null,
        "updated_at": "2026-05-23T00:38:10.512Z"
      }
    }
    ```
  </Accordion>
</AccordionGroup>

<Note>
  **Terminal flat payout events carry the payment's tracking reference.** Terminal flat payout events for virtual-account fiat payouts (`payout.completed`, `payout.failed`, `payout.returned`) carry `reference_number` and a `recipient` object alongside the standard fields. Some wire payouts additionally carry `uetr`, `imad`, and `omad` — treat all three as optional and absent by default.

  Read `reference_number`: it is the reference the sending bank assigned, in whatever form the rail uses — an IMAD for Fedwire, a trace number for ACH, or a UETR on rails whose bank reports one — and it is the same value `GET /v1/payouts/{payout_id}` reports, including on a payout that completed and was later returned. It stays `null` until the payment has actually been sent. `uetr`, where present, carries the wire value only and is **deprecated**; migrate to `reference_number`.

  As elsewhere, `data.status` on these flat events is lowercase (`completed` / `failed` / `returned`) even though the same payout reads UPPERCASE on `GET` — compare case-insensitively.
</Note>
