# Be told, instead of asking

Register an HTTPS endpoint, subscribe it to events, and receive a signed POST whenever your content changes. A change made in the dashboard fires the same event as a change made through this API.

## Setting one up

1. **Stand up an HTTPS endpoint** It must be publicly resolvable. Private, loopback, link-local and metadata addresses are refused, at save time and again at delivery time, so a webhook cannot be pointed at internal infrastructure.
2. **Register it** `POST /webhooks` with the URL and the events you want. The signing secret is in that response and nowhere else, ever. Record it before you close the connection.
3. **Verify every delivery** Before you parse the body. The recipe is below, and an unverified endpoint will accept a forged POST from anyone who learns the URL.
4. **Answer quickly with a 2xx** You have ten seconds. Queue the work and return; do not do the 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

Events you can subscribe to. Every one of them corresponds to a mutation this API can
perform, and each fires whether the change came from this API or from the dashboard.

More may be added within v1, so ignore an event type you do not recognise rather than
failing the delivery.

`article.published` is the one most integrations want: it is the signal to rebuild a
statically generated site. `article.unpublished` and `article.deleted` are the ones people
forget, and forgetting them leaves content live on your site after it is gone from ours.

## The payload

The body of every delivery. It is JSON, it is what the signature is computed over, and it
is byte-identical across retries and redeliveries of the same event.

`data` is the resource in the same shape the REST API returns it, so a handler for
`article.published` can use the same parser as `GET /articles/{id}`. The one exception is
a `*.deleted` event, where the object no longer exists and `data` carries only its `id`.

`website_id` deliberately appears nowhere: you already know which Site you configured.

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`, which is the **event** id and is stable across every retry and every manual redelivery. `Writavo-Delivery` is the id of this individual HTTP attempt, which is a different thing: it identifies the request rather than the fact.

## 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**. If your framework parses it for you, re-serialising will produce 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 correct signature one byte at a time.
- Reject anything where `|now - Writavo-Timestamp| > 300` seconds.

> The timestamp is inside the signed material, and that is the whole reason it is sent. A captured payload cannot be replayed later, because its signature only ever validates against the timestamp it was signed with. Signing the body alone would make every delivery replayable for ever.

## Rotating the secret

`POST /webhooks//rotate-secret` issues a new one and returns it once. The old secret stops working immediately, with no grace window, because a window in which two secrets both validate is a window in which a leaked secret still works.

Deliveries sent between the rotation and your receiver being updated will fail verification. They are retried on the normal backoff, so update the receiver promptly and the queue drains itself.

## Retries, backoff and auto-disable

Delivery is at least once. A delivery is an attempt, and there are up to six of them per event.

- Each delay carries up to 25% jitter, so a fleet of endpoints failing on the same publish does not retry in lockstep.
- A response outside 200 to 299 is a failure. So is no response within ten seconds, a redirect to a non-HTTPS or private target, and more than one redirect.
- After **12 consecutive failed attempts**, exactly two events that exhausted the full ladder, the endpoint is disabled automatically and the reason is recorded on it.
- Any 2xx resets the counter to zero.
- Re-enable with `PATCH /webhooks/{id}` and `enabled: true`. **Re-enabling does not replay what you missed.** Reconcile 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.** At least once means you will see the same event twice. Deduplicate on the payload `id`.
- **Tolerate unknown event types and unknown fields.** Both may be added within `/v1`. Ignore what you do not recognise rather than failing the delivery.
- **Handle `article.unpublished` and `article.deleted`.** These are the ones people forget, and forgetting them leaves content live on your site after it is gone from ours.
- **Do not trust the payload without verifying the signature.** The URL is the only thing an attacker needs to guess.
- **Return fast.** Ten seconds is the timeout, and a slow receiver only delays itself: delivery is out of band, so a hung endpoint can never slow down a publish.
