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

# Webhooks

> Subscribe to order, payment account, KYC and KYB events. Verify signatures and handle retries.

Etherfuse sends a signed `POST` to your URL when an order, payment account, KYC or KYB status changes. Order and payment account events include the full object.

<Info>
  **Webhooks vs. polling.** Webhooks arrive when the state changes. Polling is slower and most requests return no change. Use webhooks for anything time-sensitive, and read the object with its `GET` endpoint when you need to confirm the current state.
</Info>

## Subscribe

Create one subscription per URL and event with [`POST /webhook_subscription`](/v2/api-reference/webhook-subscriptions/create-webhook-subscription):

```json theme={null}
{
  "url": "https://example.com/etherfuse/webhooks",
  "eventType": "orderUpdated"
}
```

| `eventType` | Sent when | Payload |
| - | - | - |
| `orderUpdated` | An order's status or funding instructions change. | [`{ event, eventId, occurredAt, order }`](/v2/api-reference/webhook-events/orderupdated) |
| `paymentAccountUpdated` | A payment account's status, or the status of one of its rail directions, changes. | [`{ event, eventId, occurredAt, organizationId, paymentAccount }`](/v2/api-reference/webhook-events/paymentaccountupdated) |
| `kycUpdated` | A customer's KYC status changes. | [`{ event, eventId, occurredAt, organizationId, kyc }`](/v2/api-reference/webhooks/kycupdated) |
| `kybUpdated` | A business customer's KYB status changes. | [`{ event, eventId, occurredAt, organizationId, kyb }`](/v2/api-reference/webhook-events/kybupdated) |

The response is `201` and includes a `secret`:

```json theme={null}
{
  "id": "9d0e1f2a-3b4c-4d5e-8f6a-7b8c9d0e1f2a",
  "eventType": "orderUpdated",
  "url": "https://example.com/etherfuse/webhooks",
  "createdAt": "2026-09-28T17:00:00Z",
  "updatedAt": "2026-09-28T17:00:00Z",
  "secret": "<base64url secret>"
}
```

<Warning>
  **We return the `secret` only once**, in this response. Store it; you need it to verify every delivery. `GET` and list responses do not include it.
</Warning>

* In production the URL must use `https`. Sandbox also accepts `http`.
* A second subscription for the same URL and event returns `409` `conflict`.
* [`GET /webhook_subscriptions`](/v2/api-reference/webhook-subscriptions/list-webhook-subscriptions) lists your subscriptions. [`GET /webhook_subscription/{id}`](/v2/api-reference/webhook-subscriptions/get-webhook-subscription) reads one. [`DELETE /webhook_subscription/{id}`](/v2/api-reference/webhook-subscriptions/delete-webhook-subscription) removes one.
* Subscriptions belong to your organization and do not take an organization id in the route. One subscription covers your organization and every customer organization, so you do not subscribe per customer. Each payload tells you which organization the event is for.

## Payload

`orderUpdated` and `paymentAccountUpdated` share one envelope:

| Field | Meaning |
| - | - |
| `event` | The event name: `orderUpdated` or `paymentAccountUpdated`. |
| `eventId` | Unique per event. A retried delivery carries the same `eventId`, so use it to drop duplicates. |
| `occurredAt` | When the change happened, ISO 8601. |
| `organizationId` | `paymentAccountUpdated` only. The organization that holds the payment account: yours or one of your customer organizations. |
| `order` or `paymentAccount` | The full object, the same one [`GET /organization/{organizationId}/order/{id}`](/v2/api-reference/orders/get-order) or [`GET /organization/{organizationId}/payment_account/{id}`](/v2/api-reference/payment-accounts/get-payment-account) returns. The order's `organizationId` is the organization the order belongs to. |

```json theme={null}
{
  "event": "orderUpdated",
  "eventId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
  "occurredAt": "2026-09-28T17:09:41Z",
  "order": {
    "id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
    "status": "funded",
    "organizationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "...": "the full order"
  }
}
```

```json theme={null}
{
  "event": "paymentAccountUpdated",
  "eventId": "7c8d9e0f-1a2b-4c3d-8e4f-5a6b7c8d9e0f",
  "occurredAt": "2026-09-28T17:06:12Z",
  "organizationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "paymentAccount": {
    "id": "3f9a2c1e-5b7d-4e8f-9a01-2b3c4d5e6f70",
    "status": "ready",
    "...": "the full payment account"
  }
}
```

## When each event is sent

### orderUpdated

* When the order's `status` changes, for example to `funded`, `completed`, `failed` or `canceled`.
* When the order's funding instructions arrive or are replaced. `order.fundingInstructions.destination` then holds where to send the funds.

Creating an order does not send a webhook. The `POST /organization/{organizationId}/order` response already holds the order.

What your application does on each status:

| `status` | What happened | What your application does |
| - | - | - |
| `created` | Waiting for the source funds. | When `fundingInstructions.destination` is present, show the customer where to pay. |
| `funded` | We received the source funds. | Optionally show "payment received". |
| `claimable` | We delivered the funds, and the customer has to claim them. | Prepare the claim with [`POST /organization/{organizationId}/order/{id}/claim`](/v2/api-reference/orders/prepare-claim), then have the customer finish it. |
| `completed` | Delivered to the destination. | **Mark the order fulfilled** and notify the customer. |
| `failed` | We could not complete the order. `failure` holds a `code` and a `message`. | Notify the customer and reconcile. |
| `canceled` | Canceled before completion, either by you or because the funding deadline passed. | Close the order in your system. |
| `refunded` | We returned the source funds. | Reconcile and tell the customer the funds came back. |

### paymentAccountUpdated

Sent when the account's `status` changes, or when the `status` of any rail direction in `rails` changes (for example `destination` on `spei` going from `pending` to `ready`). Quote a direction only once it is `ready`.

### kycUpdated

Sent when a customer's KYC status changes, and when a write you sent was accepted but could not be applied. `kyc.status` is one of `notStarted`, `inProgress`, `submitted`, `approved` or `denied`. Do not let a customer transact until `kyc.approved` is `true`. When `kyc.needsWork` is `true`, `kyc.userMessage` says what the customer has to fix.

To learn what is still outstanding, read [`GET /organization/{organizationId}/kyc?requirements=true`](/v2/api-reference/kyc/get-kyc-status) when the webhook arrives. Do not poll it on a timer. A `kycUpdated` that moves the customer from `submitted` back to `inProgress` means the next stage has arrived and the list holds new requirements. See [Tracking verification status](/v2/guides/onboarding#tracking-verification-status) and [Requirements unlock in stages](/v2/guides/onboarding#requirements-unlock-in-stages).

### kybUpdated

Sent when a business customer's KYB status changes. `kyb.status` uses the same values as KYC, and `kyb.approvedAt` is set once the business is approved.

## Verify the signature

Each delivery has an `X-Signature: sha256={hex}` header. We compute it as HMAC-SHA256 over the [RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785) canonicalized JSON body. The HMAC key is your subscription's `secret`, decoded from URL-safe base64.

<Note>
  The secret uses the URL-safe base64 alphabet (RFC 4648 section 5: `-` and `_` in place of `+` and `/`), with `=` padding. It decodes to 32 bytes. Decode it with a URL-safe decoder, for example `base64.urlsafe_b64decode` in Python or `Buffer.from(secret, "base64url")` in Node.js. A standard base64 decoder fails or returns the wrong key for secrets that contain `-` or `_`.
</Note>

See [Verifying Webhooks](/v2/guides/verifying-webhooks) for Rust, Node.js and Python code.

## Delivery and retries

* Return any `2xx` to acknowledge a delivery.
* We make up to 3 attempts per delivery, 5 seconds apart. We try again after a connection error, a timeout, a `5xx`, a `408`, a `429`, or any other non-`2xx` status not in the next item.
* We stop after the first response when your endpoint returns `400`, `401`, `403`, `404`, `405`, `410` or `422`.
* We do not send an abandoned delivery later. Read the object with its `GET` endpoint to recover the current state.

## Best practices

* **Queue events before processing.** Save the payload to a queue and return `2xx` promptly to avoid timeouts and retries.
* **Ignore duplicate events.** Use `eventId` to avoid processing a retried delivery twice.
* **Check event timestamps.** Deliveries can arrive out of order. Compare `occurredAt` before updating stored state; use the resource's `GET` endpoint to confirm its current state.
* **Expose a public HTTPS endpoint.** For local development, use a tunnel such as [ngrok](https://ngrok.com) so we can reach your handler.

## Migrating from v1

v1 and v2 subscriptions are separate. A v1 subscription keeps receiving the v1 payload and does not appear in `GET /webhook_subscriptions`. To receive the v2 payloads, create `orderUpdated`, `paymentAccountUpdated`, `kycUpdated` and `kybUpdated` subscriptions. See the [V1 → V2 guide](/v2/migration-guide#7-subscribe-to-v2-webhooks).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.