# Webhooks (/docs/guides/webhooks)

A webhook tells your site that what the delivery API answers has changed, so it can rebuild or revalidate. Add one under **Settings → Webhooks**, or, in develop and staging, through the [agent API](/docs/api/agents) (`createWebhook`). Production webhooks are added in the dashboard only. Like an API token, it belongs to one space and one environment, so a production site only hears about production.

The signing secret (`whsec_…`) is shown once, when the webhook is added. Keep it server-side: it proves a request came from Obelism Content.

## Events [#events]

| Event                    | Sent when                                                                                                                                                   | `data`                                    |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `entry.published`        | An entry is published (first time or again)                                                                                                                 | `{ entry }`                               |
| `block.published`        | A [shared block](/docs/guides/content-model#shared-blocks) is published: once, for every live page it updated                                               | `{ entry, entries }`                      |
| `entry.unpublished`      | An entry is taken off the delivery API                                                                                                                      | `{ entry }`                               |
| `entry.deleted`          | A published entry is deleted                                                                                                                                | `{ entry }`                               |
| `entry.moved`            | An entry's parent, slug or position changes                                                                                                                 | `{ entry, previousPath, previousPaths }`  |
| `asset.updated`          | An asset's alt text, title or focal point changes, or its file is replaced, in each environment whose content uses it                                       | `{ asset, entries }`                      |
| `model.changed`          | Content types are created, changed or deleted                                                                                                               | `{ contentTypes: [{ apiId, action }] }`   |
| `locales.changed`        | The space's locales change (every environment)                                                                                                              | `{ defaultLocale, locales }`              |
| `entry.review_requested` | Someone asks for review in a [reviewed environment](/docs/guides/publishing#review-before-publishing)                                                       | `{ entry, revision, note }`               |
| `entry.review_closed`    | A review is approved (published) or sent back                                                                                                               | `{ entry, outcome, revision, reason? }`   |
| `product.updated`        | A product of the space's [Shopify store](/docs/guides/shopify) is created or changed (price, images, title, variants), or a variant goes in or out of stock | `{ product, collections, entries, tags }` |
| `product.deleted`        | A product is deleted in the store                                                                                                                           | `{ product, collections, entries, tags }` |
| `collection.updated`     | A collection is created or changed, including products added or removed by hand                                                                             | `{ collection, entries, tags }`           |
| `collection.deleted`     | A collection is deleted in the store                                                                                                                        | `{ collection, entries, tags }`           |
| `webhook.test`           | **Send test** is pressed in Settings                                                                                                                        | `{}`                                      |

A webhook with no events selected receives all of them. Select **content types** to hear only about entries of those types: entry events, and `asset.updated` and `model.changed` when they involve one of them. Events about no content type (`locales.changed`, `webhook.test`) always go out. `entry` is `{ id, contentType, name, path, paths }`: enough to know what to revalidate, never the content itself; fetch that from the delivery API. `path` is the default locale's and `paths` the path in every locale (`{ [locale]: path }`, see [paths per locale](/docs/api/delivery#paths-per-locale)); `path` is `null` and `paths` `{}` for data entries. When an entry moves, everything below `previousPath` (and, per locale, `previousPaths`) moved with it. `asset.updated` names the asset (`{ id, filename }`) and up to 100 entries that use it, drafts included.

A shared block's publish is one `block.published` event, not an `entry.published` per page: `entry` is the block and `entries` the live pages it updated. Pages left on the version they show now are absent. A webhook limited to content types hears it when the block's own type is selected; it names the pages in `entries` so you can rebuild each of them.

### Shopify changes [#shopify-changes]

With a Shopify store connected, the store's changes reach your site through the same webhooks, so it can revalidate what it cached of a product or collection: its prices, stock, images and title, the pages that show it, and your feeds. They go to every webhook that selects them, also one limited to some content types, and to every environment.

`product` and `collection` are `{ id, handle }` (the GID and the handle). `collections` are the collections the product is in, whose pages and feeds list it. `entries` are the published pages whose content names the item or those collections (its product page, collection pages, grids, links), with their `paths`, up to 100. `tags` are `shopify:product:<number>` and `shopify:collection:<number>`: if your site tags what it fetches from Shopify with them, revalidate by tag and pages Content doesn't know of (a feed, a search) follow too. A deleted product has no `collections` and its `handle` is `null`.

```ts
export const POST = createWebhookHandler({
	secret: process.env.CONTENT_WEBHOOK_SECRET!,
	onEvent: (event) => {
		if (event.type === 'product.updated' || event.type === 'product.deleted') {
			for (const tag of event.data.tags) revalidateTag(tag)
			for (const entry of event.data.entries)
				for (const path of Object.values(entry.paths))
					revalidatePath(`/${path}`)
		}
	},
})
```

## Requests [#requests]

Each event is one `POST` with a JSON body:

```json
{
	"type": "entry.published",
	"timestamp": "2026-10-13T19:30:00.000Z",
	"space": "space_…",
	"environment": "production",
	"data": {
		"entry": {
			"id": "entry_…",
			"contentType": "page",
			"name": "Hello",
			"path": "blog/hello",
			"paths": { "en": "blog/hello", "nl": "blog/hallo" }
		}
	}
}
```

Requests follow the [Standard Webhooks](https://www.standardwebhooks.com/) spec, so its libraries verify them:

| Header              | Value                                                       |
| ------------------- | ----------------------------------------------------------- |
| `webhook-id`        | The delivery's id; the same on every retry of that delivery |
| `webhook-timestamp` | Seconds since the epoch, when this attempt was sent         |
| `webhook-signature` | `v1,` + base64 HMAC-SHA256 of `{id}.{timestamp}.{body}`     |

The HMAC key is the base64-decoded part of the secret after `whsec_`.

```ts title="app/api/revalidate/route.ts"
import { revalidateTag } from 'next/cache'
import { Webhook } from 'standardwebhooks'

const webhook = new Webhook(process.env.CONTENT_WEBHOOK_SECRET!)

export async function POST(request: Request) {
	const body = await request.text()
	// Throws when the signature or timestamp is wrong.
	webhook.verify(body, Object.fromEntries(request.headers))
	revalidateTag('content')
	return new Response(null, { status: 204 })
}
```

Reject requests whose timestamp is more than a few minutes old, which the libraries do, and use `webhook-id` to ignore a delivery you already handled.

### With the SDK [#with-the-sdk]

With `@obelism/content` 0.6.0 or later, `createWebhookHandler` verifies the request and hands you the event, typed per `type`:

```ts title="app/api/revalidate/route.ts"
import { createWebhookHandler } from '@obelism/content/webhooks'
import { revalidatePath, revalidateTag } from 'next/cache'

export const POST = createWebhookHandler({
	secret: process.env.CONTENT_WEBHOOK_SECRET!,
	onEvent: (event, { id }) => {
		// `id` is the webhook-id: the same on every retry of a delivery.
		if (event.type === 'entry.published' && event.data.entry.path) {
			revalidatePath(`/${event.data.entry.path}`)
		} else {
			revalidateTag('content')
		}
	},
})
```

It answers `401` to a bad signature or a timestamp more than 5 minutes off (`tolerance`, in seconds), `204` once `onEvent` is done, and `500` when it throws, so the delivery is retried. `verifyWebhook(request, secret)` only verifies and returns the event; see [the JavaScript SDK](/docs/sdk/javascript#webhooks).

**On a host without tag revalidation.** Oxygen (Shopify) has no tag cache, can't purge the live deployment's cache, and its Cache API is per data centre: a webhook there can warm a page or clear one key where it lands, not everywhere. Content already purges its own edge cache when something is published, so a short TTL on the site covers most of it ([Shopify Oxygen](/docs/getting-started/oxygen#4-cache-with-a-ttl-not-with-tags)).

## Delivery and retries [#delivery-and-retries]

Answer with any `2xx` within 10 seconds. Anything else, a redirect included, counts as a failure and is retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours and 6 hours: eight attempts over about 11 hours. After that the delivery is marked failed. Order isn't guaranteed; use `timestamp` or re-read the delivery API when it matters.

**Edit** changes a webhook's name, URL, events and content types. **Pause** stops new events being queued for it until you **Resume**; deliveries already queued still go out, and **Send test** still works. **New secret** replaces the signing secret, shown once; the old one stops working at once, so update your site's secret right after.

**Settings → Webhooks** also lists the latest deliveries with their status, attempts and last error, and can send a finished delivery again (same `webhook-id`). Deliveries are kept for 30 days. The agent API has the same list (`GET …/webhooks/deliveries`), test (`POST …/webhooks/{id}/test`) and redelivery (`POST …/webhooks/deliveries/{id}/redeliver`), so an agent that sets up a webhook can check it gets a 2xx. Outside production, agents can also edit, pause and rotate webhooks (`PATCH …/webhooks/{id}`, `POST …/webhooks/{id}/rotate-secret`).

URLs must use `https`.
