Documentation menu

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. 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. 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. 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. 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
curl
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.

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.

body
{
  "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 <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-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.
Node.js (verify.js)
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 };
Python (verify.py)
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 attemptNext attempt in
11 minute
25 minutes
315 minutes
41 hour
56 hours
6no 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} and enabled: true. Re-enabling does not replay what you missed. Reconcile with GET /articles?updated_since=....

Read the delivery log

returns 200
curl
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
curl
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.