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

# Webhooks

> Have Venly push asynchronous events to your endpoint instead of polling for them.

Much of what the Finance API does finishes **after** your request returns. A pay-in session converts
minutes later. A verification verdict arrives when a reviewer gets to it. A pay-out's fiat leg settles
on the bank's schedule — and can bounce afterwards.

You can poll for all of it, but webhooks are the better answer: register an HTTPS endpoint once, and
Venly pushes each state change to you.

## What's worth a webhook

| Area                                                                                                                  | Why polling hurts                                                                                      |
| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| [Pay-in sessions](/api-reference/Finance-API/fiat-to-crypto-payment-sessions/create-a-fiat-to-crypto-payment-session) | There is **no** endpoint to read a session back — a callback or webhook is the only way to observe it. |
| [Pay-outs](/guides/finance/payouts)                                                                                   | A `201` doesn't mean sent, and `COMPLETED` can still become `RETURNED` later.                          |
| [Verification](/guides/finance/onboarding/lifecycle)                                                                  | Verdicts are human-paced; polling a party for hours is wasteful.                                       |
| [Partner-terms consent](/guides/finance/onboarding/partner-terms-consent)                                             | Your end customer accepts on their own schedule — there is nothing to poll usefully.                   |
| [Virtual bank accounts](/guides/finance/virtual-bank-accounts)                                                        | EUR deposit rails may populate after creation returns.                                                 |

## Registering an endpoint

[`POST /webhooks`](/api-reference/Finance-API/webhooks/register-a-webhook)

```json theme={null}
{
  "url": "https://client.example/hooks/venly",
  "name": "Production events",
  "authenticationMethod": {
    "type": "API_KEY",
    "headerName": "X-Webhook-Key",
    "apiKey": "whsec_9f2c41ab7d"
  }
}
```

The `url` must be an absolute `https://` endpoint — plain HTTP is rejected with `400`.

### Authenticating the caller

Your endpoint is public, so it needs a way to tell a real Venly delivery from anyone else who found
the URL. Pick one method at registration:

<CardGroup cols={2}>
  <Card title="API_KEY" icon="key">
    Venly injects `apiKey` into the header you name in `headerName`. Compare it against your stored
    value using a constant-time comparison.
  </Card>

  <Card title="BASIC_AUTHENTICATION" icon="lock">
    Standard HTTP basic auth with `username` and `password`.
  </Card>
</CardGroup>

<Warning>
  Secrets (`apiKey`, `password`) are **write-only**. They are accepted on registration and update, and
  never returned by any read. Store your own copy — if you lose it, you must
  [update the webhook](/api-reference/Finance-API/webhooks/update-a-webhook) with a new secret rather
  than recovering the old one.
</Warning>

## Testing your endpoint

Once registered, [send a ping](/api-reference/Finance-API/webhooks/ping-a-webhook):

```bash theme={null}
curl -X POST https://api.venlyfinance.com/v1/webhooks/{webhookId}/ping \
  -H "Authorization: Bearer {access_token}"
```

This queues a synthetic `PING` event to that one webhook, exercising the real delivery path —
including the authentication header your handler expects.

<Note>
  Delivery is **asynchronous**. A `200` means the ping was accepted for delivery, not that your endpoint
  received it. Confirm arrival in your own logs.
</Note>

## Updating is a full replacement

[`PUT /webhooks/{webhookId}`](/api-reference/Finance-API/webhooks/update-a-webhook) replaces the whole
configuration — it is not a partial patch.

Because secrets are never returned by a read, you cannot fetch the current config, tweak one field,
and send it back: the secret would be missing. Always send every field you want to keep, including a
complete `authenticationMethod`.

This is also how you rotate a key or password.

## What you receive

Every delivery has the same two-field shape — the event type, and an event-specific `result`:

```json theme={null}
{
  "eventType": "TRANSFER_COMPLETED",
  "result": {
    "transferId": "9d41f0c7-2b8e-4a15-93cf-6e07ba5d1284",
    "senderAccountId": "4a7c33e1-59db-4f02-8c6a-b1e9d7205fa3",
    "receiverAccountId": "c8e5b204-71fa-4d38-95b7-30a6f8c41de9",
    "asset": "USDC",
    "chain": "BASE",
    "transactionHash": "0x4f7a2c9e18b3d05a6c7e94f120d8b3a5e6f1c07d92b4a8e35f6c1d0b7a92e438",
    "occurredAt": "2026-08-19T09:14:52.318472901ZZ"
  }
}
```

Two things to build around:

<Note>
  **No event carries an amount.** This is deliberate. Amounts, balances and bank coordinates stay
  behind the authenticated REST API — an event tells you *what changed*, and you read the values back
  over REST. So a `PAYOUT_RETURNED` tells you a return happened, not how much came back.
</Note>

<Note>
  **Every webhook receives every event.** Registration has no event-type selection, so you cannot
  narrow what an endpoint gets. Switch on `eventType` in your handler and ignore what you don't need.
</Note>

`occurredAt` is when the state change happened, not when it was delivered — deliveries typically
land one to twenty seconds later, and a retry can arrive much later.

## Event catalogue

Thirteen event types:

| `eventType`                    | Fires when                                                                                                                                                                                                           | `result` fields                                                                                                              |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `PING`                         | You call the test endpoint. Carries no business data.                                                                                                                                                                | `message`                                                                                                                    |
| `PAY_IN_SETTLED`               | A pay-in's crypto credit is confirmed on-chain and available to the account.                                                                                                                                         | `payInId`, `accountId`, `status`, `asset`, `chain`, `transactionHash`, `occurredAt`                                          |
| `PAY_IN_FAILED`                | A pay-in ended without ever crediting the account. No `PAY_IN_SETTLED` will follow.                                                                                                                                  | `payInId`, `accountId`, `status`, `reason`, `occurredAt`                                                                     |
| `TRANSFER_COMPLETED`           | An account-to-account transfer settled with the receiving account.                                                                                                                                                   | `transferId`, `senderAccountId`, `receiverAccountId`, `asset`, `chain`, `transactionHash`, `occurredAt`                      |
| `TRANSFER_FAILED`              | A transfer failed terminally. The funds remain with the sender.                                                                                                                                                      | `transferId`, `senderAccountId`, `receiverAccountId`, `asset`, `chain`, `reason`, `occurredAt`                               |
| `PAYOUT_PROCESSING`            | The crypto left the account wallet and reached the banking partner. The send leg can no longer fail.                                                                                                                 | `payoutId`, `accountId`, `status`, `occurredAt`                                                                              |
| `PAYOUT_COMPLETED`             | The beneficiary has been paid.                                                                                                                                                                                       | `payoutId`, `accountId`, `status`, `occurredAt`                                                                              |
| `PAYOUT_REJECTED`              | The pay-out was refused **before** execution began, so no funds left the account.                                                                                                                                    | `payoutId`, `accountId`, `status`, `reason`, `occurredAt`                                                                    |
| `PAYOUT_FAILED`                | Execution had already begun and then failed terminally. No money reached the beneficiary.                                                                                                                            | `payoutId`, `accountId`, `status`, `reason`, `occurredAt`                                                                    |
| `PAYOUT_RETURNED`              | The receiving bank sent the pay-out back.                                                                                                                                                                            | `payoutId`, `accountId`, `status`, `reason`, `occurredAt`                                                                    |
| `VIRTUAL_BANK_ACCOUNT_CREATED` | The account can now receive deposits.                                                                                                                                                                                | `virtualBankAccountId`, `accountId`, `status`, `occurredAt`                                                                  |
| `ACCOUNT_WALLETS_PROVISIONED`  | An account's wallet pair cleared AML screening and can be used.                                                                                                                                                      | `accountId`, `walletPairId`, `walletType`, `chain`, `accountWalletAddress`, `escrowWalletAddress`, `amlStatus`, `occurredAt` |
| `PARTY_VERIFICATION_COMPLETED` | An individual party's verification produced an outcome. Read `status` — it is usually `VERIFIED` or `REJECTED`, but `VERIFICATION_PENDING` is also possible, so don't assume the event means a decision was reached. | `partyId`, `status`, `occurredAt`                                                                                            |

<Note>
  **Coming to this catalogue: partner-terms acceptance.** An event announcing that an end customer has
  completed [partner-terms consent](/guides/finance/onboarding/partner-terms-consent) is being added, so
  you will not have to poll the party to learn that onboarding can proceed. The event type is being
  finalised — until it lands, read the party back to check its consent state.
</Note>

Four of these need care:

* **`PAYOUT_REJECTED` vs `PAYOUT_FAILED`** — rejected means nothing was ever sent; failed means execution started and then broke. Only the second can leave a pay-out needing investigation.
* **`PAYOUT_RETURNED` does not imply an earlier `PAYOUT_COMPLETED`.** A return can be reported while the pay-out is still processing, so never treat it as a correction to a completion you must have received.
* **`VIRTUAL_BANK_ACCOUNT_CREATED` fires on readiness, not on creation.** The resource exists earlier, without usable bank coordinates. This event means deposits will now land.
* **`PARTY_VERIFICATION_COMPLETED` fires on every verdict**, including a re-verification of the same party. Others are keyed per resource, so a second delivery for the same resource is a duplicate; this one may be genuinely new.

## Event payloads

<AccordionGroup>
  <Accordion title="Transfers" icon="right-left">
    Crypto and fiat-originated transfers publish the **same** event with an identical payload — nothing reveals which one it was.

    ```json TRANSFER_COMPLETED theme={null}
    {
      "eventType": "TRANSFER_COMPLETED",
      "result": {
        "transferId": "9d41f0c7-2b8e-4a15-93cf-6e07ba5d1284",
        "senderAccountId": "4a7c33e1-59db-4f02-8c6a-b1e9d7205fa3",
        "receiverAccountId": "c8e5b204-71fa-4d38-95b7-30a6f8c41de9",
        "asset": "USDC",
        "chain": "BASE",
        "transactionHash": "0x4f7a2c9e18b3d05a6c7e94f120d8b3a5e6f1c07d92b4a8e35f6c1d0b7a92e438",
        "occurredAt": "2026-08-19T09:14:52.318472901ZZ"
      }
    }
    ```

    ```json TRANSFER_FAILED theme={null}
    {
      "eventType": "TRANSFER_FAILED",
      "result": {
        "transferId": "7b02ea56-c3d9-4718-8f45-2ac6be901f37",
        "senderAccountId": "4a7c33e1-59db-4f02-8c6a-b1e9d7205fa3",
        "receiverAccountId": "e1f7d9a3-46b2-4c85-91da-587c0e2b6d34",
        "asset": "EURC",
        "chain": "AVALANCHE",
        "reason": "INSUFFICIENT_ALLOWANCE",
        "occurredAt": "2026-08-19T09:21:07.664215330ZZ"
      }
    }
    ```

    `reason` is one of `ON_CHAIN_FAILED`, `INSUFFICIENT_FUNDS`, `INSUFFICIENT_ALLOWANCE`, `NOT_SUBMITTED`, `UNKNOWN`.

    A failed transfer has no `transactionHash` when it never reached the chain (`NOT_SUBMITTED`).
  </Accordion>

  <Accordion title="Pay-outs" icon="building-columns">
    A pay-out publishes up to two events: one when it reaches the banking partner, and one for its outcome.

    ```json PAYOUT_PROCESSING theme={null}
    {
      "eventType": "PAYOUT_PROCESSING",
      "result": {
        "payoutId": "5c8fd317-a094-42be-b6e1-7d3f02951ac8",
        "accountId": "83b16e40-df75-4a29-9c03-e5a18427bd6f",
        "status": "PROVIDER_PROCESSING",
        "occurredAt": "2026-08-19T10:02:41.127903558ZZ"
      }
    }
    ```

    ```json PAYOUT_COMPLETED theme={null}
    {
      "eventType": "PAYOUT_COMPLETED",
      "result": {
        "payoutId": "5c8fd317-a094-42be-b6e1-7d3f02951ac8",
        "accountId": "83b16e40-df75-4a29-9c03-e5a18427bd6f",
        "status": "COMPLETED",
        "occurredAt": "2026-08-19T10:05:18.844061272ZZ"
      }
    }
    ```

    ```json PAYOUT_RETURNED theme={null}
    {
      "eventType": "PAYOUT_RETURNED",
      "result": {
        "payoutId": "a0d64b98-31ce-4f27-85ba-c9147e0d2f63",
        "accountId": "83b16e40-df75-4a29-9c03-e5a18427bd6f",
        "status": "RETURNED",
        "reason": "RETURNED_BY_BANK",
        "occurredAt": "2026-08-19T10:11:33.209774615ZZ"
      }
    }
    ```

    `PAYOUT_REJECTED` and `PAYOUT_FAILED` have the same shape as `PAYOUT_RETURNED`, with `status` `REJECTED` or `FAILED`.

    `reason` is one of `REJECTED_BY_PROVIDER`, `ROUTE_UNAVAILABLE`, `SEND_FAILED`, `RETURNED_BY_BANK`, `UNKNOWN`.

    <Note>
      The `reason` is a fixed enum, never free text. Operational notes recorded against a pay-out internally are not delivered — if you need more detail than the enum, [read the pay-out back](/api-reference/Finance-API/payouts/get-a-payout).
    </Note>
  </Accordion>

  <Accordion title="Pay-ins" icon="arrow-down-to-arc">
    ```json PAY_IN_SETTLED theme={null}
    {
      "eventType": "PAY_IN_SETTLED",
      "result": {
        "payInId": "b3719ce5-8f24-4d60-a917-4c8e05f2d6b1",
        "accountId": "62ad8f14-70e3-49c5-b8f2-1d90ae637c05",
        "status": "SETTLED",
        "asset": "USDC",
        "chain": "BASE",
        "transactionHash": "0xa19c47f2e8b06d35c1f7094ae2b8d5306f4c19e7ad025b8f3c6019e74bd2f581",
        "occurredAt": "2026-08-19T11:37:09.552840173ZZ"
      }
    }
    ```

    ```json PAY_IN_FAILED theme={null}
    {
      "eventType": "PAY_IN_FAILED",
      "result": {
        "payInId": "d5e8017b-49ac-4f31-96b0-8a27ce4f10d3",
        "accountId": "62ad8f14-70e3-49c5-b8f2-1d90ae637c05",
        "status": "FAILED",
        "reason": "REJECTED_BY_PROVIDER",
        "occurredAt": "2026-08-19T11:44:26.981037462ZZ"
      }
    }
    ```

    `reason` is one of `SETTLEMENT_FAILED`, `REJECTED_BY_PROVIDER`, `UNKNOWN`.

    A `PAY_IN_FAILED` is terminal for that pay-in — no `PAY_IN_SETTLED` will follow it.
  </Accordion>

  <Accordion title="Onboarding" icon="user-check">
    ```json ACCOUNT_WALLETS_PROVISIONED theme={null}
    {
      "eventType": "ACCOUNT_WALLETS_PROVISIONED",
      "result": {
        "accountId": "18fc6a92-b537-4e08-91d4-cb70a3f5e264",
        "walletPairId": "7e3d0b85-1a4f-4926-83c7-59d2e0148fab",
        "walletType": "VENLY_MANAGED",
        "chain": "AVALANCHE",
        "accountWalletAddress": "0x6D3fA92c15E0847bB1de5C90a7F328e4051cB7d9",
        "escrowWalletAddress": "0xB84e07f1cA935D620387be4dF16c8a0952E7d34C",
        "amlStatus": "APPROVED",
        "occurredAt": "2026-08-19T08:52:14.703918046ZZ"
      }
    }
    ```

    This is the one event that carries on-chain addresses — they are the point of it. One event per wallet pair, not per account.

    ```json PARTY_VERIFICATION_COMPLETED theme={null}
    {
      "eventType": "PARTY_VERIFICATION_COMPLETED",
      "result": {
        "partyId": "2f95c8e0-6b71-4a3d-8e14-07db5c9f2a68",
        "status": "VERIFIED",
        "occurredAt": "2026-08-19T13:26:55.410682937ZZ"
      }
    }
    ```

    `status` is `VERIFIED`, `REJECTED`, or `VERIFICATION_PENDING`. The verdict and nothing else — no reasons, documents, or personal data. A rejection tells you the outcome, not why.

    ```json VIRTUAL_BANK_ACCOUNT_CREATED theme={null}
    {
      "eventType": "VIRTUAL_BANK_ACCOUNT_CREATED",
      "result": {
        "virtualBankAccountId": "4c1e73d9-a825-4b06-97fe-3d6082ca4157",
        "accountId": "62ad8f14-70e3-49c5-b8f2-1d90ae637c05",
        "status": "ACTIVE",
        "occurredAt": "2026-08-19T12:08:47.336529814ZZ"
      }
    }
    ```

    The bank coordinates are **not** in the event. Read them from
    [Get a virtual bank account](/api-reference/Finance-API/virtual-bank-accounts/get-virtual-bank-account-details) once this arrives.
  </Accordion>

  <Accordion title="Ping" icon="signal">
    ```json PING theme={null}
    {
      "eventType": "PING",
      "result": { "message": "ping" }
    }
    ```

    Sent only by the [ping endpoint](/api-reference/Finance-API/webhooks/ping-a-webhook). Handle it as a no-op, but do return `2xx` — it is how you prove your endpoint works.
  </Accordion>
</AccordionGroup>

## Writing a handler

A few rules that hold regardless of which events you subscribe to:

<Steps>
  <Step title="Verify before you trust">
    Check the authentication header first and reject anything that fails, before parsing the body.
  </Step>

  <Step title="Respond fast, work later">
    Acknowledge with a `2xx` immediately and hand the payload to a queue. Slow handlers look like
    failures to any delivery system.
  </Step>

  <Step title="Expect duplicates">
    Treat delivery as at-least-once — retries do happen in practice. Key your processing on the
    event's **resource id plus target status** so a repeat is a no-op — for a second delivery of
    `TRANSFER_COMPLETED`, the `transferId` is already `COMPLETED` on your side and there is nothing
    to do. Same discipline as [idempotency](/guides/finance/idempotency) on the request side.
  </Step>

  <Step title="Don't assume ordering">
    A later state can arrive before an earlier one, so compare against the state you already hold.

    <Warning>
      Do **not** simply discard anything that looks like a step backwards. Some transitions legitimately
      reverse an outcome — a pay-out can go `COMPLETED` → `RETURNED` when the receiving bank sends it
      back, sometimes long afterwards. Decide per event type which transitions are real, rather than
      ordering states and dropping everything below the high-water mark.
    </Warning>
  </Step>

  <Step title="Re-read the resource for anything that matters">
    For money movement, treat the event as a *hint that something changed* and confirm against the API
    — for example [Get a payout](/api-reference/Finance-API/payouts/get-a-payout) — before crediting a
    ledger or releasing goods.
  </Step>
</Steps>

## Managing registrations

| Operation | Endpoint                                                                               |
| --------- | -------------------------------------------------------------------------------------- |
| List all  | [`GET /webhooks`](/api-reference/Finance-API/webhooks/list-webhooks) — not paginated   |
| Read one  | [`GET /webhooks/{webhookId}`](/api-reference/Finance-API/webhooks/get-webhook-details) |
| Replace   | [`PUT /webhooks/{webhookId}`](/api-reference/Finance-API/webhooks/update-a-webhook)    |
| Remove    | [`DELETE /webhooks/{webhookId}`](/api-reference/Finance-API/webhooks/delete-a-webhook) |

Registering requires the `manage:webhooks` role; reading requires `view:webhooks`.

There is a per-company cap on registered webhooks — exceeding it returns `409`. If you need more
endpoints than the cap allows, fan out on your own side from a single receiver.

<Note>
  Webhook delivery is handled by Venly. A `503` on any of these endpoints means
  that service was briefly unreachable, not that your registration is broken — retry with backoff.
</Note>

## Next steps

<CardGroup cols={2}>
  <Card title="Pay-outs" icon="money-bill-transfer" href="/guides/finance/payouts">
    Where webhooks matter most — a `201` isn't a success signal.
  </Card>

  <Card title="Idempotency" icon="rotate" href="/guides/finance/idempotency">
    The request-side counterpart to duplicate-tolerant handlers.
  </Card>
</CardGroup>
