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

# Webhooks

> Receive nxos events in real time, verify them, and handle retries.

## Overview

Webhooks let your integration receive events as they happen — an organization's verification status changing, a transaction moving through its lifecycle — instead of polling the API. You register an HTTPS URL, we sign and `POST` each event to it, and you verify the signature before acting on it.

All webhook management is scoped to your organization by your API key.

## Quickstart

```bash theme={null}
# 1. Register an endpoint
curl -X POST https://api.nxos.io/v1/webhooks/endpoints \
  -H "Authorization: Bearer $NXOS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://your-app.com/webhooks/nxos" }'
# → { "object": "webhook_endpoint", "id": "ep_…", … }

# 2. Fetch the signing secret
curl https://api.nxos.io/v1/webhooks/endpoints/ep_…/secret \
  -H "Authorization: Bearer $NXOS_API_KEY"
# → { "key": "whsec_…" }
```

On every delivery: **verify the signature, then return any `2xx` status.**

<Note>Sandbox uses `https://api.sandbox.nxos.io`. Endpoints must be **HTTPS**.</Note>

## Managing endpoints

| Method   | Path                                        | Description                                       |
| -------- | ------------------------------------------- | ------------------------------------------------- |
| `POST`   | `/v1/webhooks/endpoints`                    | Register a URL to receive events                  |
| `GET`    | `/v1/webhooks/endpoints`                    | List your endpoints                               |
| `GET`    | `/v1/webhooks/endpoints/{id}`               | Retrieve one                                      |
| `PATCH`  | `/v1/webhooks/endpoints/{id}`               | Change the URL, event types, or enable/disable it |
| `DELETE` | `/v1/webhooks/endpoints/{id}`               | Stop deliveries                                   |
| `GET`    | `/v1/webhooks/endpoints/{id}/secret`        | Get the signing secret                            |
| `POST`   | `/v1/webhooks/endpoints/{id}/secret/rotate` | Rotate the signing secret                         |

When creating an endpoint you can pass `eventTypes` to subscribe to a subset. **Omit it to receive all event types**, including ones added in the future.

```json theme={null}
{
  "url": "https://your-app.com/webhooks/nxos",
  "eventTypes": ["transaction.status.updated"],
  "description": "Production receiver"
}
```

## Delivery format

Each delivery is an HTTPS `POST`. The body is the event envelope:

```json theme={null}
{
  "type": "transaction.status.updated",
  "timestamp": "2026-06-10T12:00:00.000Z",
  "data": {}
}
```

with these headers:

| Header           | Description                                                                             |
| ---------------- | --------------------------------------------------------------------------------------- |
| `svix-id`        | Unique message id. Use it to deduplicate retries.                                       |
| `svix-timestamp` | Unix timestamp (seconds). Reject deliveries that are too old to prevent replay attacks. |
| `svix-signature` | Space-delimited list of `v1,<signature>` entries (supports key rotation).               |

## Verifying signatures

Verification is a plain HMAC-SHA256 check. No special library or SDK required — it works in any language.

1. Take your endpoint secret, drop the `whsec_` prefix, and base64-decode it. That's your HMAC key.
2. Build the signed content: `svix-id` + `.` + `svix-timestamp` + `.` + raw body.
3. HMAC-SHA256 it with the key, base64-encode the result.
4. Compare it (constant-time) to each `v1,<signature>` in the `svix-signature` header. If any matches, the delivery is authentic.

```ts theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(secret: string, headers: Record<string, string>, rawBody: string): boolean {
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const signedContent = `${headers['svix-id']}.${headers['svix-timestamp']}.${rawBody}`;
  const expected = createHmac('sha256', key).update(signedContent).digest('base64');
  const expectedBuf = Buffer.from(expected);

  return headers['svix-signature']
    .split(' ')
    .map((part) => part.split(',')[1])
    .filter(Boolean)
    .some((sig) => {
      const sigBuf = Buffer.from(sig);
      return sigBuf.length === expectedBuf.length && timingSafeEqual(sigBuf, expectedBuf);
    });
}
```

Always verify against the **raw** request body (before any JSON parsing), and reject deliveries whose `svix-timestamp` is outside a small tolerance (e.g. 5 minutes).

## Retries & idempotency

A delivery is considered successful when your endpoint returns a `2xx` status. Non-`2xx` responses or timeouts are **retried** with exponential backoff over several hours. Endpoints that keep failing are automatically disabled.

Retries reuse the same `svix-id`, so make your handler **idempotent**: record the `svix-id` of each event you process and ignore repeats.

## Rotating the signing secret

`POST /v1/webhooks/endpoints/{id}/secret/rotate` issues a new secret; the old one stops verifying immediately. Fetch the new value with `GET …/secret`.

Because the secret is retrievable at any time, your **API key** is the credential to guard — if it leaks, rotate the API key and your webhook secrets.

## Event catalog

### `organization.verification.updated`

An organization's verification (KYC/KYB) status changed.

```json theme={null}
{
  "type": "organization.verification.updated",
  "timestamp": "2026-06-10T12:00:00.000Z",
  "data": {
    "object": "organization",
    "organization_id": "org_…",
    "status": "APPROVED",
    "previous_status": "PENDING",
    "reason": null,
    "occurred_at": "2026-06-10T12:00:00.000Z"
  }
}
```

`status` is one of `APPROVED`, `ON_HOLD`, `PENDING`, `RESUBMISSION_REQUIRED`.\
`reason` is populated when an org is placed on hold or asked to resubmit.

### `transaction.status.updated`

A transaction changed status. Fires on **every** transition.

```json theme={null}
{
  "type": "transaction.status.updated",
  "timestamp": "2026-06-10T12:00:00.000Z",
  "data": {
    "object": "transaction",
    "transaction_id": "txn_…",
    "transaction_type": "FIAT_PAYOUT",
    "status": "COMPLETED",
    "previous_status": "LOCKED",
    "account_id": "acct_…",
    "organization_id": "org_…",
    "role": "SENDER",
    "occurred_at": "2026-06-10T12:00:00.000Z"
  }
}
```

`status` is one of `EXPECTED`, `LOCKED`, `COMPLETED`, `DECLINED`, `REFUNDED`.

Payloads carry **public IDs only**. Call the relevant API endpoint to hydrate full details.

## Brokers & sub-organizations

If your organization manages sub-organizations, webhook delivery follows your authorization relationship:

* **Every organization receives its own events on its own endpoints**, scoped strictly to itself.
* **A broker additionally receives a sub-org's events** on the broker's endpoints. The payload's `organization_id` identifies which sub-org the event is about, so a broker can monitor its whole portfolio from one endpoint.

Broker visibility of a sub-org's events is gated by the **Letter of Authorization (LOA)**:

| Sub-org event                       | Without a signed LOA           | With an active, signed LOA |
| ----------------------------------- | ------------------------------ | -------------------------- |
| `organization.verification.updated` | ✅ delivered (track onboarding) | ✅ delivered                |
| `transaction.status.updated`        | 🚫 suppressed                  | ✅ delivered                |

A broker can always see a sub-org's onboarding status, but only sees its money movements once the customer has signed the LOA.

See [Cross-org access](/concepts/cross-org-access) for how LOAs are established.
