# Webhooks

Set a URL in **Delivery** → **Webhook**. SEO for SaaS sends a `POST` to it:

| `event` | When |
|---|---|
| `content.created` | An article is published without going through review. |
| `content.updated` | A published article changed: rewritten, edited, approved from review, or unpublished. The slug and URL stay the same. |
| `webhook.test` | You select **Send test event**. No article changed, so there's nothing to refresh. |

Refresh on both content events: an article approved from the review queue arrives as `content.updated`.

The body only names the event, so read articles from the API, never from the payload:

```json
{ "event": "content.created", "projectId": "…" }
```

The test event adds a timestamp and a message:

```json
{ "event": "webhook.test", "projectId": "…", "timestamp": "2026-09-29T12:00:00.000Z", "data": { "message": "Test event from the dashboard. No content changed." } }
```

## Verify, then act

We sign every delivery with [Standard Webhooks](https://www.standardwebhooks.com/) and the **signing secret** (**Delivery** → **Webhook** → **Signing secret** → **Reveal**). Signing starts the first time you reveal the secret. Until then, deliveries go out unsigned, and a verifying endpoint rejects them. Verify against the raw body, exactly as received: re-serialized JSON won't match.

The TypeScript tab uses the SEO for SaaS handler, `lib/seoforsaas/webhook.ts`, a fetch-style function for Next.js route handlers, Astro endpoints, Hono, Bun, Deno, and Cloudflare Workers. It reads `SEOFORSAAS_WEBHOOK_SECRET` from `process.env`; where there's none, pass the secret: `seoforsaasWebhook(onChange, { secret: import.meta.env.SEOFORSAAS_WEBHOOK_SECRET })` in Astro, the `env` binding in Workers.

```bash group=install title="shadcn"
npx shadcn@latest add https://seoforsaas.dev/r/webhook.json
```

```bash group=install title="curl"
for f in types.ts webhook.ts; do
  curl -fsSL --create-dirs -o "src/lib/seoforsaas/$f" "https://seoforsaas.dev/r/files/lib/seoforsaas/$f"
done
npm install standardwebhooks
```

What we send:

```http
POST <your webhook URL>
content-type: application/json
webhook-id: msg_…
webhook-timestamp: 1759132800
webhook-signature: v1,…

{ "event": "content.created", "projectId": "YOUR_PROJECT_ID" }
```

TypeScript:

```ts
import { seoforsaasWebhook } from "./lib/seoforsaas/webhook";
import { refreshSite } from "./refresh-site"; // yours: purge a cache, or call a deploy hook

// Verifies the signature (401 when it fails), ignores the dashboard's test
// event, and calls you when an article is published, changed, or unpublished.
export const handleWebhook = seoforsaasWebhook(() => refreshSite());

// Mount it wherever your framework takes a fetch-style handler, for example
// Next.js: export const POST = handleWebhook;
```

Python:

```python
import os
from collections.abc import Callable, Mapping

from standardwebhooks.webhooks import Webhook, WebhookVerificationError

webhook = Webhook(os.environ["SEOFORSAAS_WEBHOOK_SECRET"])


def handle_seoforsaas_webhook(
    body: bytes, headers: Mapping[str, str], on_content_change: Callable[[dict], None]
) -> int:
    """The HTTP status to answer with.

    Pass the RAW body: the signature covers those bytes.
    """
    try:
        event = webhook.verify(body, dict(headers))
    except WebhookVerificationError:
        return 401
    if event["event"] in ("content.created", "content.updated"):
        on_content_change(event)  # your cache purge or deploy hook
    return 204
```

## Delivery

- We wait up to 5 seconds and follow no redirects. Any `2xx` counts as delivered. **Delivery** → **Recent deliveries** lists every call and its response for 15 days. We don't retry a failed delivery. To resend it, select **Send again** in its row menu.
- Treat a delivery as a nudge to refresh: your next build or request reads the current articles from the API, so a missed delivery costs freshness, never data.
- **Rolling the secret** (**Webhook** → **Roll**): for 24 hours every delivery carries signatures from both the old and the new secret, so update your site within that window.
- A static site needs no endpoint: paste your host's deploy hook instead. See [Static sites](/docs/guides/static-sites).
