---
title: "Be told, instead of asking"
description: "The event catalog, the payload envelope, and a runnable signature check."
canonical: "https://writavo.com/docs/webhooks"
last-updated: "2026-08-07"
---

# Be told, instead of asking

Register an HTTPS endpoint and get a signed POST whenever your content changes, whether the change came from the API or the dashboard.

## Setting one up

1. **Stand up an HTTPS endpoint** It must be publicly resolvable. Private, loopback, link-local and metadata addresses are refused, both when you save and when we deliver.
2. **Register it** `POST /webhooks` with the URL and the events you want. The signing secret appears in that response only, so save it.
3. **Verify every delivery** Before you parse the body (recipe below). Otherwise anyone who learns the URL can send you a forged POST.
4. **Answer quickly with a 2xx** You have ten seconds. Queue the work and return; do not rebuild inside the request.

**Register an endpoint** (returns 201)

```bash

curl -X POST https://api.writavo.com/v1/webhooks \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: b6f4b0e2-0000-4000-8000-0000000000aa" \
  -d '{
  "url": "https://example.com/hooks/writavo",
  "events": [
    "article.published",
    "article.unpublished",
    "article.deleted"
  ],
  "description": "Rebuild the static site"
}'

```

The signing secret is in this response and nowhere else, ever. Record it before you close the connection.

> A Site may register at most ten endpoints.

## The event catalog

Fired for changes from this API or the dashboard. More may be added within v1; ignore unknown
types. `article.published` is the usual rebuild signal; also handle `article.unpublished` and
`article.deleted`, or removed content stays live on your site.

## The payload

The JSON body of every delivery: what the signature covers, byte-identical across retries. `data`
is the resource as the REST API returns it (only its `id` for `*.deleted`). No `website_id`.

body:

```json
{
  "id": "8b1f0f5a-0000-4000-8000-00000000000a",
  "event": "article.published",
  "created_at": "2026-08-06T09:41:12.004Z",
  "data": {
    "id": "3f1b0c7a-0000-4000-8000-000000000001",
    "status": "published",
    "title": "How to choose a headless CMS",
    "slug": "how-to-choose-a-headless-cms",
    "published_at": "2026-08-06T09:41:12.004Z"
  }
}
```

The headers that come with it:

headers:

```
Writavo-Signature: v1=<hex hmac>
Writavo-Timestamp: 1786000872
Writavo-Delivery: <this attempt's id>
Writavo-Attempt: 1
Writavo-Event: article.published
Content-Type: application/json
User-Agent: writavo-webhook/1
```

> Deduplicate on the body's `id`: the **event** id, the same across every retry and redelivery. `Writavo-Delivery` identifies one HTTP attempt, not the event.

## Verifying a delivery

The signature is `HMAC-SHA256` over `.`, hex encoded, sent as `v1=`.

- Read the raw request body as **bytes, before any JSON parse**. Re-serialising parsed JSON gives a different string, and the signature will not match.
- Build the signed material: `` `${Writavo-Timestamp}.${rawBody}` ``.
- Compute `hex(HMAC_SHA256(your_signing_secret, signed))`.
- Compare `Writavo-Signature` to `v1=<expected>` **in constant time**. A plain equality check leaks the signature byte by byte.
- Reject anything where `|now - Writavo-Timestamp| > 300` seconds.

> The timestamp is signed too, so a captured delivery cannot be replayed later: its signature only validates with its original timestamp.

## Rotating the secret

- `POST /webhooks/{id}/rotate-secret` issues a new secret and returns it once.
- The old secret stops working immediately. There is no grace window.
- Deliveries that fail verification before you update your receiver are retried on the normal backoff, so update it promptly.

## Retries, backoff and auto-disable

Delivery is at least once, with up to six attempts per event.

- Each delay has up to 25% jitter.
- Failures: any response outside 200 to 299, no response within ten seconds, a redirect to a non-HTTPS or private target, or more than one redirect.
- After **12 consecutive failed attempts** (two events that used every retry), the endpoint is disabled and the reason is recorded on it. Any 2xx resets the count to zero.
- Re-enable with `PATCH /webhooks/{id}` and `enabled: true`. **Re-enabling does not replay what you missed.** Catch up with `GET /articles?updated_since=...`.

**Read the delivery log** (returns 200)

```bash

curl https://api.writavo.com/v1/webhooks/3f1b0c7a-0000-4000-8000-000000000004/deliveries \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"

```

One row per attempt. Every attempt of one event shares its `event_id`, which is what you deduplicate on.

**Send one delivery again** (returns 202)

```bash

curl -X POST https://api.writavo.com/v1/webhooks/3f1b0c7a-0000-4000-8000-000000000004/deliveries/3f1b0c7a-0000-4000-8000-000000000005/redeliver \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"

```

Same event id, byte identical body, attempt counter back to 1. Use it after fixing a receiver that was down.

## Writing a handler that survives

- **Be idempotent.** You may get the same event twice. Deduplicate on the payload `id`.
- **Ignore unknown event types and fields.** Both may be added within `/v1`.
- **Handle `article.unpublished` and `article.deleted`**, or content stays live on your site after it is gone from ours.
- **Verify the signature before trusting the payload.**
- **Return fast.** The timeout is ten seconds. A slow endpoint only delays its own deliveries, never a publish.
