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

# Verifying webhooks

> Check that each delivery comes from Alura with the Standard Webhooks libraries for Node and Python, test your endpoint, and rotate its secret without missing an event

Alura signs every delivery to your webhook endpoint the [Standard Webhooks](https://www.standardwebhooks.com) way, with the endpoint's secret (`whsec_...`). Anyone can POST to your URL, so verify the signature before you trust a delivery. The official `standardwebhooks` libraries do it in one call.

## What a delivery looks like

Alura sends a `POST` with a JSON body: the event exactly as [`GET /v1/events/{id}`](https://docs.alura.io/api-reference/events/get) returns it, byte for byte.

```json theme={null}
{
  "object": "event",
  "id": "evt_01JMOCK0000000000000000000",
  "type": "order.paid",
  "created_at": "2026-10-22T09:15:02.000Z",
  "shop_id": "48911633",
  "data": {
    "event_type": "order.paid",
    "resource_url": "https://openapi.etsy.com/v3/application/shops/48911633/receipts/3456789012",
    "shop_id": 48911633
  }
}
```

| Header | Value |
| - | - |
| `webhook-id` | The event's ID. It's the same on every retry of the event, so de-duplicate on it. |
| `webhook-timestamp` | When this attempt was sent, in Unix seconds. |
| `webhook-signature` | `v1,` and a base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{body}`, keyed with the base64-decoded part of your secret after `whsec_`. During a secret rotation it holds two signatures, separated by a space. |
| `alura-delivery-id` | This attempt's ID (`dlv_...`), as `GET /v1/webhooks/{id}/deliveries` lists it. |
| `alura-delivery-attempt` | `1` for the first attempt, up to `8`. |

## Verify in Node

Install the library with `npm install standardwebhooks`. Verify the raw body as it arrived: JSON that was parsed and written again doesn't match its signature.

```typescript theme={null}
import { createServer } from 'node:http';
import { Webhook } from 'standardwebhooks';

// The endpoint's secret (whsec_...), from your secret store.
const webhook = new Webhook(process.env.ALURA_WEBHOOK_SECRET ?? '');

interface AluraEvent {
  id: string;
  type: string;
  shop_id: string | null;
  data: Record<string, unknown>;
}

createServer(async (req, res) => {
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(chunk as Buffer);
  const body = Buffer.concat(chunks).toString('utf8');
  let event: AluraEvent;
  try {
    // Throws on a missing or wrong signature, or a timestamp more than 5 minutes off.
    event = webhook.verify(body, req.headers as Record<string, string>) as AluraEvent;
  } catch {
    res.writeHead(400).end();
    return;
  }
  // Answer quickly with any 2xx, then do the work: Alura waits 15 seconds at most.
  res.writeHead(204).end();
  console.log(`${event.id} ${event.type} for shop ${event.shop_id}`);
}).listen(Number(process.env.PORT ?? 3000));
```

## Verify in Python

Install the library with `pip install standardwebhooks`. With Flask:

```python theme={null}
import os

from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
# The endpoint's secret (whsec_...), from your secret store.
webhook = Webhook(os.environ["ALURA_WEBHOOK_SECRET"])


@app.post("/alura/webhooks")
def alura_webhook():
    try:
        # The raw body, never request.json: re-serialized JSON doesn't match its signature.
        event = webhook.verify(request.get_data(), dict(request.headers))
    except WebhookVerificationError:
        return "", 400
    # Answer quickly with any 2xx, then do the work: Alura waits 15 seconds at most.
    print(f"{event['id']} {event['type']} for shop {event['shop_id']}")
    return "", 204
```

## Send a test event

`POST /v1/webhooks/{id}/test` sends your endpoint a `webhook.test` event, signed like every delivery. It has the same envelope, with `shop_id` set to `null` and `data` set to `{"event_type": "webhook.test"}`. It's sent once, without retries, and it isn't stored as an event, so `GET /v1/events` doesn't list it. Watch the result with [`GET /v1/webhooks/{id}/deliveries`](https://docs.alura.io/api-reference/webhooks/deliveries/list).

```bash cURL theme={null}
# Queues a webhook.test event to the endpoint; it is sent within seconds.
curl --fail-with-body -sS -X POST "https://api.alura.io/v1/webhooks/we_01JMOCK0000000000000000000/test" \
  -H "Authorization: Bearer $ALURA_API_KEY"
```

```typescript TypeScript theme={null}
const endpointId = 'we_01JMOCK0000000000000000000';
const res = await fetch(`https://api.alura.io/v1/webhooks/${endpointId}/test`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.ALURA_API_KEY}` } });
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
const delivery = (await res.json()) as { id: string; event_id: string; status: string };

// Your endpoint receives a webhook.test event with this event_id as its webhook-id header.
console.log(`${delivery.id}: ${delivery.status}, event ${delivery.event_id}`);
console.log(`follow it: GET /v1/webhooks/${endpointId}/deliveries`);
```

```python Python theme={null}
import json
import os
import urllib.request

endpoint_id = "we_01JMOCK0000000000000000000"
request = urllib.request.Request(
    f"https://api.alura.io/v1/webhooks/{endpoint_id}/test",
    method="POST",
    headers={"Authorization": f"Bearer {os.environ['ALURA_API_KEY']}"},
)
with urllib.request.urlopen(request) as response:
    delivery = json.load(response)

# Your endpoint receives a webhook.test event with this event_id as its webhook-id header.
print(f"{delivery['id']}: {delivery['status']}, event {delivery['event_id']}")
print(f"follow it: GET /v1/webhooks/{endpoint_id}/deliveries")
```

## Rotate the secret

`POST /v1/webhooks/{id}/rotate-secret` returns a new secret, once. The old secret keeps signing for 24 hours, and every delivery in that time carries a signature from each. To switch without missing a delivery:

1. Rotate, and store the new secret.
2. Deploy your verifier with the new secret. Deliveries verify with either secret during the 24 hours, so the order of your deploys doesn't matter.
3. After 24 hours, deliveries carry only the new signature.

Rotating again within the 24 hours keeps the secret you have deployed for the rest of its 24 hours and ends the one in between at once, so a delivery never carries more than two signatures.

```bash cURL theme={null}
# The answer carries the new secret once. The old one keeps signing for 24 hours.
curl --fail-with-body -sS -X POST "https://api.alura.io/v1/webhooks/we_01JMOCK0000000000000000000/rotate-secret" \
  -H "Authorization: Bearer $ALURA_API_KEY" \
  -H "Idempotency-Key: 3f6c2a9e-1b4d-4c7a-9e2f-8d5b6a7c1e0f"
```

```typescript TypeScript theme={null}
const endpointId = 'we_01JMOCK0000000000000000000';
const res = await fetch(`https://api.alura.io/v1/webhooks/${endpointId}/rotate-secret`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ALURA_API_KEY}`,
    // Reuse it only to retry this same rotation: a retry answers the same secret.
    'Idempotency-Key': crypto.randomUUID(),
  },
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
const endpoint = (await res.json()) as { id: string; secret: string };

// Shown once: store it, then switch your verifier to it within 24 hours.
console.log(`${endpoint.id}: new secret starts ${endpoint.secret.slice(0, 6)}`);
```

```python Python theme={null}
import json
import os
import urllib.request
import uuid

endpoint_id = "we_01JMOCK0000000000000000000"
request = urllib.request.Request(
    f"https://api.alura.io/v1/webhooks/{endpoint_id}/rotate-secret",
    method="POST",
    headers={
        "Authorization": f"Bearer {os.environ['ALURA_API_KEY']}",
        # Reuse it only to retry this same rotation: a retry answers the same secret.
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
with urllib.request.urlopen(request) as response:
    endpoint = json.load(response)

# Shown once: store it, then switch your verifier to it within 24 hours.
print(f"{endpoint['id']}: new secret starts {endpoint['secret'][:6]}")
```

## Good practice

* **Reject what doesn't verify** with a 4xx. Alura counts any 2xx as delivered, and anything else as a failure to retry.
* **De-duplicate on `webhook-id`.** A retry, or an attempt sent again after an interruption on Alura's side, carries the same `webhook-id`.
* **Answer within 15 seconds.** Alura gives up on an attempt after 15 seconds and retries it later. Queue slow work.
* **Don't allowlist IP addresses.** Deliveries come from Google Cloud addresses that change. The signature is the check.
* **Redirects aren't followed.** A `3xx` answer counts as a failure, so point the endpoint at its final URL.


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