webhooks
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 /webhookswith 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 201curl -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.
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.
article
- article.created
- article.updated
- article.deleted
- article.published
- article.unpublished
- article.scheduled
- article.unscheduled
category
- category.created
- category.updated
- category.deleted
tag
- tag.created
- tag.updated
- tag.deleted
author
- author.created
- author.updated
- author.deleted
media
- media.created
- media.updated
- media.deleted
pipeline
- pipeline.run.completed
- pipeline.run.failed
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.
{
"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:
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/1id, 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 <timestamp>.<raw body>, hex encoded, sent as v1=<hex>.
- 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-Signaturetov1=<expected>in constant time. A plain equality check leaks the correct signature one byte at a time. - Reject anything where
|now - Writavo-Timestamp| > 300seconds.
const crypto = require("node:crypto");
const TOLERANCE_SECONDS = 300;
/**
* Verify a Writavo webhook delivery.
*
* rawBody MUST be the exact bytes you received, before any JSON parse. If your framework
* parses the body for you, re-serialising it will produce a different string and the
* signature will not match.
*/
function verifyWritavoSignature(secret, headers, rawBody) {
const signature = headers["writavo-signature"];
const timestamp = headers["writavo-timestamp"];
if (!signature || !timestamp) return false;
// The timestamp is inside the signed material, so a captured payload cannot be replayed
// later. Reject anything outside the tolerance window before doing any work.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = "v1=" + crypto
.createHmac("sha256", secret)
.update(timestamp + "." + rawBody)
.digest("hex");
// Constant time. A plain === leaks the correct signature one byte at a time.
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
module.exports = { verifyWritavoSignature };import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_writavo_signature(secret: str, headers: dict, raw_body: bytes) -> bool:
"""Verify a Writavo webhook delivery.
raw_body MUST be the exact bytes you received, before any JSON parse. Re-serialising a
parsed body produces a different string and the signature will not match.
"""
signature = headers.get("writavo-signature")
timestamp = headers.get("writavo-timestamp")
if not signature or not timestamp:
return False
# The timestamp is inside the signed material, so a captured payload cannot be replayed
# later. Reject anything outside the tolerance window before doing any work.
try:
age = abs(int(time.time()) - int(timestamp))
except ValueError:
return False
if age > TOLERANCE_SECONDS:
return False
signed = timestamp.encode() + b"." + raw_body
expected = "v1=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# Constant time. A plain == leaks the correct signature one byte at a time.
return hmac.compare_digest(signature, expected)Both snippets are executed on every build against vectors produced by the same function the platform signs with, and must accept a valid delivery and reject a tampered body, a forged signature, a replay outside the tolerance and a delivery with no signature at all. A snippet that stopped matching would fail the build rather than ship.
Rotating the secret#
POST /webhooks/{id}/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.
| After attempt | Next attempt in |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 15 minutes |
| 4 | 1 hour |
| 5 | 6 hours |
| 6 | no further attempts |
- 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}andenabled: true. Re-enabling does not replay what you missed. Reconcile withGET /articles?updated_since=....
Read the delivery log
returns 200curl 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 202curl -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.