> ## 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.

> ## Agent Instructions
> When building application code, use `@naturalpay/sdk` for TypeScript or `naturalpay` for Python.
> When operating Natural directly for a user, use the payments MCP at `https://mcp.natural.com`.
> When searching Natural documentation, use the docs MCP at `https://docs.natural.com/mcp`.
> Use the Natural CLI for terminal and CI automation.
> Use REST only for unsupported languages or when the user explicitly requests raw HTTP.
> Start integration development and testing in the sandbox. Do not use production unless the user explicitly requests it.
> For sandbox requests that require `customerPartyId`, list customers first. Reuse only the intended fixture; otherwise create a customer.
> REST and SDK amounts use integer minor units. Payments MCP amounts use decimal strings with a required currency code.

# Receive events

> Register a webhook, verify each delivery, and handle retries

Register a URL, verify every delivery Natural signs, and process each event exactly once even when it arrives more than once. For the delivery model, read the [Webhooks overview](/guides/concepts/webhooks).

<Snippet file="shared/prerequisites.mdx" />

## Register a webhook

Register your URL and the event types it subscribes to with [`POST /webhooks`](/api-reference/webhooks/create-webhook); the signing secret comes back once, in this response only, so store it immediately. Webhooks are managed with an API key or a user session; an agent key cannot manage them.

<CodeGroup>
  ```python Python theme={null}
  import uuid
  from naturalpay import Natural

  # NATURAL_API_KEY=sk_ntl_prod_...
  client = Natural()

  webhook = client.webhooks.create(
      url="https://yourdomain.com/hooks/natural",
      enabled_events=["wallet.created", "party.updated"],
      sources=["own", "connected"],
      idempotency_key=str(uuid.uuid4()),
  )
  signing_secret = webhook.data.attributes.signing_secret  # shown once, store securely
  ```

  ```typescript TypeScript theme={null}
  import Natural from "@naturalpay/sdk";

  // NATURAL_API_KEY=sk_ntl_prod_...
  const client = new Natural();

  const webhook = await client.webhooks.create({
    url: "https://yourdomain.com/hooks/natural",
    enabledEvents: ["wallet.created", "party.updated"],
    sources: ["own", "connected"],
    idempotencyKey: crypto.randomUUID(),
  });
  const signingSecret = webhook.data.attributes.signingSecret; // shown once, store securely
  ```

  ```bash CLI theme={null}
  # NATURAL_API_KEY=sk_ntl_prod_...
  natural webhooks create \
    --json '{"url": "https://yourdomain.com/hooks/natural", "enabledEvents": ["wallet.created", "party.updated"], "sources": ["own", "connected"]}' \
    --idempotency-key "$(uuidgen)"
  ```

  ```bash cURL theme={null}
  curl -X POST https://api.natural.com/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"],
          "sources": ["own", "connected"],
          "tags": { "env": "prod" }
        }
      }
    }'
  ```
</CodeGroup>

The response carries the webhook with its `signingSecret`:

<Snippet file="api-examples/webhooks.create.response.mdx" />

A new webhook is `ENABLED`; it becomes `DISABLED` when you set that status or when it fails five events in a row.

`url` must be HTTPS and publicly reachable. `enabledEvents` needs at least one entry: any event `type` from the [Event catalog](/api-reference/event-catalog), or the wildcard `"*"` (which must be the only entry). `sources` defaults to `["own"]`; add `"connected"` to also receive events a customer connection authorizes. Omitting `"connected"` never opts a webhook into customer events, and the event type must still match `enabledEvents`. `description` (up to 100 characters) and `tags` are optional.

## Verify a signature

Every delivery is a POST whose JSON body is the event, with `type` naming what happened and the resource snapshot at `data.object`, and three lowercase headers carry the signature:

| 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.                 |

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 theme={null}
  # FastAPI
  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 theme={null}
  // Express
  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>

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

The library handles two details for you: it rejects deliveries whose `webhook-timestamp` is outside a tolerance window, and it accepts a delivery if any of the space-separated signatures verifies. That second rule is what lets a rotation with [`POST /webhooks/{webhookId}/rotate-secret`](/api-reference/webhooks/rotate-webhook-signing-secret) overlap two valid secrets: during the grace period you set with `expiresInSeconds` (0 to 86400 seconds), each delivery carries one signature per active secret, newest first.

<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>

## Handle retries and replay

Treat every delivery as a possible duplicate, keyed on the event ID, and send a stored event again with [`POST /webhooks/{webhookId}/events/{eventId}/redeliver`](/api-reference/events/redeliver-event) when your handler missed it.

Natural treats any 2xx response as success and gives up on a single request after 30 seconds. A failed delivery (non-2xx, network error, or timeout) is retried up to 7 attempts in total, with delays of 5 seconds, 5 minutes, 30 minutes, 2 hours, 8 hours, and 12 hours plus up to 20% jitter. Every retry reuses the same `webhook-id`, so record the event IDs you have processed and skip any you have already seen. A webhook that fails every attempt for five consecutive events becomes `DISABLED`; set it back to `ENABLED` with [`PATCH /webhooks/{webhookId}`](/api-reference/webhooks/update-webhook) once your handler is healthy. A connected delivery is authorized against the customer connection immediately before each send; if that check is temporarily unavailable, the delivery is deferred and re-checked about every 5 minutes for up to 24 hours without consuming a retry attempt.

The `webhook-id` header is the event ID. [`GET /events`](/api-reference/events/list-events) lists the same events on demand and [`GET /events/{eventId}`](/api-reference/events/get-event) reads one, which is how you fill a gap after an outage. For a connected customer, pass `partyId` and `eventType`: history covers connected events created since the connection began plus any events actually delivered to one of your webhooks, and after revocation only the delivered records remain.

<CodeGroup>
  ```python Python theme={null}
  redelivery = client.events.redeliver(
      "whk_019cd1798d8f8560c82adba1d2912643",
      "evt_019cd1798d9190112b89219ccb1aaa44",
      idempotency_key=str(uuid.uuid4()),
  )
  print(redelivery.data.attributes.status)
  ```

  ```typescript TypeScript theme={null}
  const redelivery = await client.events.redeliver({
    webhookId: "whk_019cd1798d8f8560c82adba1d2912643",
    eventId: "evt_019cd1798d9190112b89219ccb1aaa44",
    idempotencyKey: crypto.randomUUID(),
  });
  console.log(redelivery.data.attributes.status);
  ```

  ```bash CLI theme={null}
  natural events redeliver \
    --webhook-id whk_019cd1798d8f8560c82adba1d2912643 \
    --event-id evt_019cd1798d9190112b89219ccb1aaa44 \
    --idempotency-key "$(uuidgen)"
  ```

  ```bash cURL theme={null}
  curl -X POST https://api.natural.com/webhooks/whk_019cd1798d8f8560c82adba1d2912643/events/evt_019cd1798d9190112b89219ccb1aaa44/redeliver \
    -H "Authorization: Bearer $NATURAL_API_KEY" \
    -H "Idempotency-Key: $(uuidgen)"
  ```
</CodeGroup>

The response carries the pending redelivery:

<Snippet file="api-examples/events.redeliver.response.mdx" />

A redelivery starts `PENDING`, moves to `DELIVERING` while the request is in flight, and ends `DELIVERED` or `FAILED`; it is `UNKNOWN` when no response was recorded and `CANCELED` when the authorization check before sending no longer passes.

You can redeliver an event for 90 days after it was created, to a webhook that was one of its original destinations. The webhook must be `ENABLED` and must still accept the event's source: removing `"connected"` from `sources` closes redelivery of its connected events (`webhook_source_disabled`). One manual redelivery per event and webhook runs at a time, and you can request up to 10 per webhook per minute; beyond that the call returns `redelivery_rate_limited`. A redelivery keeps the original payload and `webhook-id` but uses the webhook's current URL and active signing secret with a fresh `webhook-timestamp` and signature, and it can overlap with automatic retries, so handle it as a duplicate. A manual failure does not count toward automatic disabling.

For a delegated event, the delegation that authorized the original delivery must still be active and grant access to that event type. Revoking it permanently closes redelivery for that event and webhook; a new delegation does not reopen it. A delegated redelivery goes only to the webhook that received the original delivery, never to the customer's webhooks.

Connected copies carry the same payload as the owner's copy, with no connection metadata added and no fields removed. Which events reach a webhook with `"connected"` in `sources`:

| Event                                                                                                            | Connected delivery while the customer connection is active                     |
| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `compliance_case.updated`, `wallet.created`, `deposit.completed`                                                 | Yes, independent of an agent's operational permissions.                        |
| `delegation.activated`, `delegation.revoked`, `agent_delegation.revoked`, `agent_delegation_invitation.accepted` | No connected copy. Natural emits each identity event directly to both parties. |
| Every other event type                                                                                           | Only while an active agent delegation grants the matching read permission.     |

Payment milestones can produce separate sender and recipient events. `sourcePartyId` identifies the party whose view the event represents, and the recipient view omits sender-only details. A webhook connected to both parties can receive both views with different event IDs, so deduplicate on the event ID, not the payment ID.
