Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.natural.co/llms.txt

Use this file to discover all available pages before exploring further.

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 spec — verify it before trusting the payload. See the 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.
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" }
      }
    }
  }'
enabledEvents accepts any event type from the event catalog, or the wildcard "*" (which must be the only entry). description and tags are optional.
The signingSecret (whsec_...) appears only in this response. If you lose it, rotate it with POST /webhooks/{webhookId}/rotate-secret.

2. The event payload

Every delivery is a POST with this envelope as the JSON body. The resource snapshot lives at data.object:
{
  "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):
HeaderDescription
webhook-idThe event ID (evt_...) — stable across retries. Use for idempotency.
webhook-timestampUnix epoch seconds when the delivery was signed.
webhook-signatureOne or more space-separated v1,<base64> signatures.

3. Verify the signature

Always verify before trusting a payload. Use the official standardwebhooks 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:
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}
Verify against the raw request body — the exact bytes Natural sent. Parsing and re-serializing the JSON first changes the bytes and breaks verification.
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.
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.
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

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):
AttemptDelay after previous
1immediate
25 seconds
35 minutes
430 minutes
52 hours
68 hours
712 hours
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.

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

Event catalog

Every event type and its full payload

Idempotency

Make request handling idempotent