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

# Webhook integration guide

> Register a webhook endpoint, verify Standard Webhooks signatures, and handle Natural events reliably

Webhooks push Natural events to your server as they happen, so you don't have to poll. Natural signs every delivery with the [Standard Webhooks](https://www.standardwebhooks.com) spec — verify it before trusting the payload. See the [event catalog](/api-reference/event-catalog) for every event type and its full payload.

## 1. Register an endpoint

`POST /webhooks` registers your URL and subscribes it to event types. The signing secret is returned **once** in the response — store it immediately.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.natural.co/webhooks \
    -H "Authorization: Bearer $NATURAL_API_KEY" \
    -H "Idempotency-Key: $(uuidgen)" \
    -H "Content-Type: application/json" \
    -d '{
      "data": {
        "attributes": {
          "url": "https://yourdomain.com/hooks/natural",
          "description": "Production webhook",
          "enabledEvents": ["wallet.created", "party.updated"],
          "tags": { "env": "prod" }
        }
      }
    }'
  ```

  ```python Python theme={null}
  import httpx, uuid

  response = httpx.post(
      "https://api.natural.co/webhooks",
      headers={
          "Authorization": f"Bearer {api_key}",
          "Idempotency-Key": str(uuid.uuid4()),
      },
      json={
          "data": {
              "attributes": {
                  "url": "https://yourdomain.com/hooks/natural",
                  "enabledEvents": ["wallet.created", "party.updated"],
              }
          }
      },
  )
  webhook = response.json()["data"]
  signing_secret = webhook["attributes"]["signingSecret"]  # shown once — store securely
  ```

  ```typescript TypeScript theme={null}
  const response = await fetch("https://api.natural.co/webhooks", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Idempotency-Key": crypto.randomUUID(),
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      data: {
        attributes: {
          url: "https://yourdomain.com/hooks/natural",
          enabledEvents: ["wallet.created", "party.updated"],
        },
      },
    }),
  });
  const webhook = (await response.json()).data;
  const signingSecret = webhook.attributes.signingSecret; // shown once — store securely
  ```
</CodeGroup>

`enabledEvents` accepts any event `type` from the [event catalog](/api-reference/event-catalog), or the wildcard `"*"` (which must be the only entry). `description` and `tags` are optional.

<Warning>
  The `signingSecret` (`whsec_...`) appears only in this response. If you lose it, rotate it with
  `POST /webhooks/{webhookId}/rotate-secret`.
</Warning>

## 2. The event payload

Every delivery is a POST with this envelope as the JSON body. The resource snapshot lives at `data.object`:

```json theme={null}
{
  "id": "evt_019cd3444a7a70efaf554fd8450d221a",
  "object": "event",
  "type": "wallet.created",
  "resourceId": "wal_019cd3444a7a70efaf554fd8450d334b",
  "resourceType": "wallet",
  "createdAt": "2026-01-15T14:30:00Z",
  "data": {
    "object": {
      "partyId": "pty_019cd34e27bf78399b4e75b327d2ab25",
      "tier": "standard",
      "status": "active",
      "displayName": "My Wallet",
      "currency": "usd"
    }
  }
}
```

Three signature headers ride on each delivery (lowercase, per the Standard Webhooks spec):

| Header              | Description                                                            |
| ------------------- | ---------------------------------------------------------------------- |
| `webhook-id`        | The event ID (`evt_...`) — stable across retries. Use for idempotency. |
| `webhook-timestamp` | Unix epoch seconds when the delivery was signed.                       |
| `webhook-signature` | One or more space-separated `v1,<base64>` signatures.                  |

## 3. Verify the signature

Always verify before trusting a payload. Use the official [`standardwebhooks`](https://www.standardwebhooks.com) library (Python and Node) — construct it with your `whsec_` secret and pass the raw body plus headers. `verify` returns the parsed event, or raises on a bad signature:

<CodeGroup>
  ```python Python (FastAPI) theme={null}
  import os
  from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
  from standardwebhooks import Webhook

  app = FastAPI()
  wh = Webhook(os.environ["NATURAL_WEBHOOK_SECRET"])  # the whsec_... value

  @app.post("/hooks/natural")
  async def handle(request: Request, background_tasks: BackgroundTasks):
      body = await request.body()  # raw bytes — required for verification
      try:
          event = wh.verify(body, dict(request.headers))
      except Exception:
          raise HTTPException(status_code=401, detail="Invalid signature")
      # Hand off async work and return 2xx fast.
      background_tasks.add_task(process_event, event, request.headers["webhook-id"])
      return {"received": True}
  ```

  ```typescript TypeScript (Express) theme={null}
  import express from "express";
  import { Webhook } from "standardwebhooks";

  const app = express();
  const wh = new Webhook(process.env.NATURAL_WEBHOOK_SECRET!); // the whsec_... value

  // express.raw keeps the body as exact bytes for verification.
  app.post("/hooks/natural", express.raw({ type: "application/json" }), (req, res) => {
    let event;
    try {
      event = wh.verify(req.body, req.headers as Record<string, string>);
    } catch {
      return res.status(401).send("Invalid signature");
    }
    // Hand off async work and return 2xx fast.
    void processEvent(event, req.headers["webhook-id"]);
    res.json({ received: true });
  });
  ```
</CodeGroup>

<Warning>
  Verify against the **raw request body** — the exact bytes Natural sent. Parsing and re-serializing
  the JSON first changes the bytes and breaks verification.
</Warning>

The library handles two details for you: it rejects deliveries whose `webhook-timestamp` is outside a tolerance window (replay defense), and it accepts a delivery if **any** of the space-separated signatures verifies — which is what lets a secret rotation overlap two valid secrets.

<Accordion title="Verify manually, without the library">
  The signed content is the string `{webhook-id}.{webhook-timestamp}.{body}`. Strip the `whsec_`
  prefix from the secret, base64-decode the remainder for the HMAC key, compute HMAC-SHA256, and
  base64-encode the result. Compare it against each space-separated `v1,<sig>` entry.

  <CodeGroup>
    ```python Python theme={null}
    import base64, hashlib, hmac

    def verify(body: bytes, headers: dict, signing_secret: str) -> bool:
        key = base64.b64decode(signing_secret.removeprefix("whsec_"))
        signed = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.{body.decode()}"
        expected = base64.b64encode(hmac.new(key, signed.encode(), hashlib.sha256).digest()).decode()
        for entry in headers["webhook-signature"].split(" "):
            version, _, sig = entry.partition(",")
            if version == "v1" and hmac.compare_digest(sig, expected):
                return True
        return False
    ```

    ```typescript TypeScript theme={null}
    import { createHmac, timingSafeEqual } from "crypto";

    function verify(body: string, headers: Record<string, string>, signingSecret: string): boolean {
      const key = Buffer.from(signingSecret.replace(/^whsec_/, ""), "base64");
      const signed = `${headers["webhook-id"]}.${headers["webhook-timestamp"]}.${body}`;
      const expected = Buffer.from(createHmac("sha256", key).update(signed).digest("base64"));
      return headers["webhook-signature"].split(" ").some((entry) => {
        const [version, sig] = entry.split(",");
        if (version !== "v1" || !sig) return false;
        const sigBuf = Buffer.from(sig);
        return sigBuf.length === expected.length && timingSafeEqual(sigBuf, expected);
      });
    }
    ```
  </CodeGroup>
</Accordion>

## 4. Delivery, retries & failures

Natural treats any **2xx** response as success. The delivery request times out after **30 seconds**.

Failed deliveries (non-2xx, network error, or timeout) are retried up to **7 attempts** total with jittered backoff (\~20% jitter):

| Attempt | Delay after previous |
| ------- | -------------------- |
| 1       | immediate            |
| 2       | 5 seconds            |
| 3       | 5 minutes            |
| 4       | 30 minutes           |
| 5       | 2 hours              |
| 6       | 8 hours              |
| 7       | 12 hours             |

<Note>
  An endpoint that fails every attempt for 5 consecutive events is automatically disabled. Re-enable
  it with `PUT /webhooks/{webhookId}` once your endpoint is healthy.
</Note>

## Best practices

* **Return 2xx fast.** Acknowledge as soon as the signature verifies, then process the event asynchronously — slow handlers risk the 30-second timeout and trigger retries.
* **Deduplicate on `webhook-id`.** Retries reuse the same `webhook-id`, and Natural may deliver an event more than once — record processed IDs and skip duplicates.
* **Store the `whsec_` secret in a secrets manager**, never in source control. Rotate it with `POST /webhooks/{webhookId}/rotate-secret`; the previous secret stays valid for the grace period you specify, so both signatures arrive during the overlap.
* **Don't depend on event ordering.** Retries and independent delivery mean events can arrive out of order — use the `version` field on the resource snapshot, or refetch the resource, when ordering matters.

## Next steps

<CardGroup cols={2}>
  <Card title="Event catalog" href="/api-reference/event-catalog">
    Every event type and its full payload
  </Card>

  <Card title="Idempotency" href="/api-reference/idempotency">
    Make request handling idempotent
  </Card>
</CardGroup>
