# Webhook Events and Payload Reference

> Source: https://agilitycms.com/docs/developers/webhook-events-and-payloads

This page is the reference for what an Agility webhook sends your endpoint: which events exist, what each payload contains, which headers arrive, how signing, retries and delivery history work, and how to build an endpoint that copes with duplicates.

For a walkthrough of adding a webhook in **Settings > Webhooks**, see [Webhooks](/docs/developers/webhooks). For signature verification code in Node.js, C#, Python and PHP, see [Verifying Signed Webhooks](/docs/developers/verifying-signed-webhooks).

## At a glance

| Topic | Behavior |
| --- | --- |
| Transport | An HTTP `POST` with a JSON body to the URL on the webhook |
| Event categories | Content Publish, Content Save, Content Workflow. Each webhook subscribes to any combination |
| Signing | Opt-in per webhook (**Enable secure delivery**). HMAC-SHA256 following the [Standard Webhooks](https://www.standardwebhooks.com) specification, with a per-webhook `whsec_` secret |
| Success | Any `2xx` response. Anything else, including a redirect or a timeout, is a failure |
| Retries | Opt-in per webhook. 1 to 8 retries after the first attempt, at a fast, standard or slow back-off |
| Delivery history | Every attempt, with payload and response, kept for 90 days |
| Duplicates | Possible. Use the `webhook-id` header as your idempotency key |

## Event categories

When you add or edit a webhook you choose which categories of event it receives. The same choices are boolean fields on the webhook in the Management API and the Management SDKs.

| Setting in Agility | API field | `state` values you receive |
| --- | --- | --- |
| Content Publish Events | `contentPublishEvents` | `Published`, `Deleted` |
| Content Save Events | `contentSaveEvents` | `Saved`, `Deleted` |
| Content Workflow Events | `contentWorkflowEvents` | `AwaitingApproval`, `Approved`, `Declined` |

Events cover both content items and pages. A webhook can also be switched off without deleting it (`enabled` in the API).

## Event catalog

The `state` field in the payload tells you what happened.

| `state` | What happened | Sent to webhooks with |
| --- | --- | --- |
| `Published` | A content item or page was published | Content Publish Events |
| `Saved` | A content item or page was created or updated | Content Save Events |
| `Deleted` | A content item or page was unpublished, deleted, or reached its scheduled unpublish date | Content Publish Events or Content Save Events |
| `AwaitingApproval` | A content item or page was requested for approval | Content Workflow Events |
| `Approved` | A request for approval was approved | Content Workflow Events |
| `Declined` | A request for approval was declined | Content Workflow Events |

> [!IMPORTANT]
> There is no `Unpublished` state. Unpublishing is reported as `Deleted`, the same as a delete. If your handler only checks for `Published`, it will never remove anything.

## Payload fields

Every payload is a flat JSON object. Fields that don't apply to an event are left out, so a content event has no `pageID` and a page event has no `referenceName`.

| Field | Type | Present on | What it holds |
| --- | --- | --- | --- |
| `state` | string | Every event | The event, from the catalog above |
| `instanceGuid` | string | Every event | The instance the event came from |
| `languageCode` | string | Content and page events | The locale that changed, for example `en-us` |
| `referenceName` | string | Content events | The reference name of the item's container. Compare it case-insensitively |
| `contentID` | number | Content events | The content item's ID |
| `contentVersionID` | number | Content events | The version of the item the event refers to |
| `pageID` | number | Page events | The page's ID |
| `pageVersionID` | number | Page events | The version of the page. Always `0` when a page is deleted |
| `changeDateUTC` | string | Content and page events | When the change happened, in UTC (ISO 8601) |

A payload tells you **what** changed, not the new content. To get the content, call the [Content Fetch API](/docs/developers/content-fetch-api) (or the GraphQL API) with the IDs from the payload. The one exception is `Deleted`: remove the item from your copy straight away instead of re-fetching it, because the Fetch API can briefly still return the old version. See [Indexing Agility Content for Search](/docs/developers/indexing-content-for-search).

Use `contentID` or `pageID` with `languageCode` to identify an item, not `referenceName` alone. One container holds many items.

## Sample payloads

These are the payloads Agility sends for each content and page event. The IDs and dates are examples.

### Content item saved

```json
{
  "state": "Saved",
  "instanceGuid": "046a1a87",
  "languageCode": "en-us",
  "referenceName": "posts",
  "contentID": 39,
  "contentVersionID": 300,
  "changeDateUTC": "2019-11-06T15:10:24.9905899Z"
}
```

### Content item published

```json
{
  "state": "Published",
  "instanceGuid": "046a1a87",
  "languageCode": "en-us",
  "referenceName": "posts",
  "contentID": 39,
  "contentVersionID": 300,
  "changeDateUTC": "2019-11-06T15:10:24.9905899Z"
}
```

### Content item unpublished or deleted

```json
{
  "state": "Deleted",
  "instanceGuid": "046a1a87",
  "languageCode": "en-us",
  "referenceName": "posts",
  "contentID": 39,
  "contentVersionID": 301,
  "changeDateUTC": "2019-11-06T15:14:02.5561907Z"
}
```

### Page saved

```json
{
  "state": "Saved",
  "instanceGuid": "046a1a87",
  "languageCode": "en-us",
  "pageID": 2,
  "pageVersionID": 76,
  "changeDateUTC": "2019-11-06T15:08:54.7977419Z"
}
```

### Page published

```json
{
  "state": "Published",
  "instanceGuid": "046a1a87",
  "languageCode": "en-us",
  "pageID": 2,
  "pageVersionID": 76,
  "changeDateUTC": "2019-11-06T15:08:54.7977419Z"
}
```

### Page unpublished or deleted

```json
{
  "state": "Deleted",
  "instanceGuid": "046a1a87",
  "languageCode": "en-us",
  "pageID": 2,
  "pageVersionID": 0,
  "changeDateUTC": "2019-11-06T15:12:41.1047362Z"
}
```

### Workflow events

Workflow events (`AwaitingApproval`, `Approved`, `Declined`) use the same fields, with the workflow value in `state`. To see the exact payload your instance sends, open the webhook's **History** in **Settings > Webhooks** and expand a delivery: it shows the body that was sent.

### Events with no content or page ID

A few `Deleted` events, such as a URL redirect change or a change to a whole list, carry no `contentID` or `pageID` at all. Ignore them, or treat them as a signal to resync or rebuild.

### A TypeScript type for the payload

```ts
interface AgilityWebhookEvent {
  state: "Published" | "Saved" | "Deleted" | "AwaitingApproval" | "Approved" | "Declined";
  instanceGuid: string;
  languageCode?: string;
  referenceName?: string;   // content events
  contentID?: number;       // content events
  contentVersionID?: number;
  pageID?: number;          // page events
  pageVersionID?: number;
  changeDateUTC?: string;
}
```

## Headers

When secure delivery is on, every delivery carries the three [Standard Webhooks](https://www.standardwebhooks.com) headers:

| Header | Example | What it is |
| --- | --- | --- |
| `webhook-id` | `2516140263959328992.1d67def1-643a-…` | The ID of this delivery. Retries of the same delivery resend the same value. It is also the ID shown for the delivery in delivery history |
| `webhook-timestamp` | `1788274141` | Unix time, in seconds, of the delivery attempt |
| `webhook-signature` | `v1,FXFAantus+70xZDTqPimI6Bg+…` | One signature, or two separated by a space during the 24 hours after a secret roll |

Treat `webhook-id` as an opaque string. Its format is not part of the contract, so don't parse it.

## Signing

Signing is opt-in per webhook. Tick **Enable secure delivery** on the webhook (or set `secureDeliveryEnabled` through the API) and Agility mints a signing secret on that save. Webhooks without it, including every webhook created before signing shipped, are sent unsigned as before.

- **The secret** looks like `whsec_` followed by base64. Each webhook has its own. Agility always generates it: a secret you send on a save is ignored.
- **The signature** is an HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded part of the secret after `whsec_`, sent as `v1,<base64 signature>`.
- **Verify with a library.** For Node.js, install [`standardwebhooks`](https://www.npmjs.com/package/standardwebhooks) (no hyphen) and pass it the whole `whsec_` string. Libraries for other languages are listed at [standardwebhooks.com](https://www.standardwebhooks.com).
- **Verify the raw body.** Read the body as text or bytes before any JSON parsing. Re-serialized JSON will not match the signature.
- **Check the timestamp.** Standard Webhooks libraries reject a `webhook-timestamp` more than about five minutes from your clock, which stops a captured request being replayed later.
- **Seeing the secret** in Agility requires **Full Control**. Users with less permission can still manage the webhook, but the secret is masked for them.

> [!WARNING]
> The instance **Security Key** under **Settings > API Keys** is for preview, not webhooks. Agility does not send it, or any other shared secret, with a webhook. Secure delivery is the only way to verify that a request came from Agility.

Full verification samples are in [Verifying Signed Webhooks](/docs/developers/verifying-signed-webhooks).

## Rotating a signing secret

Roll a secret if it may have been exposed, or on whatever schedule your security policy sets. Rolling generates a new secret immediately. For the next **24 hours**, Agility signs each delivery with both the new and the previous secret and sends both signatures in `webhook-signature`, so you can deploy the new secret without dropping deliveries.

**In Agility:** edit the webhook and choose **Roll Secret**.

**Through the Management API:**

```text
POST /api/v1/instance/{guid}/webhook/{id}/rotate-secret
```

The response is the webhook with the new secret in `signingSecret`, the old one in `previousSigningSecret`, and the time of the roll in `secretRolledUtc`. The new secret is returned in full only in this response, so store it straight away. Rolling requires **Full Control**.

**With the Management SDKs:**

```ts
// JavaScript / TypeScript (@agility/management-sdk)
const rotated = await apiClient.webhookMethods.rotateWebhookSecret(guid, webhookID);
await saveToSecretManager(rotated.signingSecret); // whsec_...
```

```csharp
// .NET (Agility.Management.SDK 2.0)
Webhook rotated = await client.Webhooks.RotateSigningSecretAsync(guid, webhookId);
StoreSecret(rotated.SigningSecret!);
```

A safe rotation:

1. Make sure your endpoint accepts a request when **any** signature in `webhook-signature` matches. Standard Webhooks libraries already do.
2. Roll the secret and store the new value.
3. Deploy the new secret to your endpoint within 24 hours.
4. Open the webhook's **History** and confirm new deliveries succeed. A delivery signed with both secrets shows as signed with 2 keys (`signatureKeyCount` of `2` in the API).

## Retries and what counts as success

Retries are **opt-in per webhook**. With retries off (the default), Agility attempts each delivery once, and a failure is recorded in delivery history but not retried.

| Setting | API field | Values |
| --- | --- | --- |
| Enable retries | `retriesEnabled` | `true` or `false` (default `false`) |
| Retry count | `retryCount` | 1 to 8 retries **after** the first attempt |
| Retry speed | `retrySpeed` | `fast`, `standard` or `slow` |

The retry speed sets the delay before the first retry. Each later delay is four times the previous one, with random jitter, and no delay is longer than 24 hours.

| Speed | First retry after | Example sequence |
| --- | --- | --- |
| `fast` | about 30 seconds | 30 s, 2 min, 8 min, ... |
| `standard` | about 5 minutes | 5 min, 20 min, 80 min, ... |
| `slow` | about 30 minutes | 30 min, 2 h, 8 h, ... |

What counts:

- **Success is any `2xx` response.** A success ends the retry chain.
- **Everything else is a failure:** a redirect (`3xx`), any `4xx` or `5xx`, a timeout, or a connection error. With retries on, every failure is retried until the retry count runs out. That includes a `401` your own endpoint returns for a bad signature.
- **A timeout or connection error** is recorded in delivery history with an HTTP code of `0` and a short error description.

Point the webhook at its final URL rather than one that redirects, and acknowledge quickly: return a `2xx` as soon as you have verified and stored the event, and do the slow work afterwards. If deliveries fail and you suspect a timeout, the delivery history entry shows it.

## Delivery history

Every webhook has a **History** action in **Settings > Webhooks**. It lists each delivery, newest first, with:

- the status and HTTP response code, or a network error or timeout when no response came back
- whether the delivery was signed (what that delivery actually did, not what the webhook is set to now)
- whether it was a retry, and which attempt
- when the next retry is due, or that no further attempts are coming
- when it was queued and when it was last attempted

Expand a delivery to see its `webhook-id`, the target URL, the payload that was sent, your endpoint's response body and the last error. Because the `webhook-id` is the value your endpoint received in the header, it's how you match a row to your own logs.

History is kept for **90 days**. It starts from when delivery history shipped (September 2026), so an older webhook shows nothing until it next fires. Deleting a webhook also deletes its history.

### Reading history through the API

```text
GET /api/v1/instance/{guid}/webhook/{id}/history?fromDate=&toDate=&take=&token=
```

| Parameter | Default | Notes |
| --- | --- | --- |
| `fromDate` | 7 days before `toDate` | UTC date |
| `toDate` | Today | UTC date. The range can span at most 366 days |
| `take` | 20 | At most 100 per page |
| `token` | | The continuation token from the previous page |

Results come back newest first. Reading history requires **Manage** permission on webhooks. The SDK methods are `getWebhookHistory` (JavaScript) and `GetWebhookHistoryAsync` (.NET).

Useful fields on each record:

| Field | What it holds |
| --- | --- |
| `rowKey` | The delivery ID. It is the `webhook-id` header value your endpoint received |
| `webhookRowKey` | The ID of the webhook the delivery belongs to |
| `eventKey` | The ID of the underlying event, shared by every webhook that received it. Not the `webhook-id` |
| `contentPublishEvent`, `contentSaveEvent`, `contentWorkflowEvent` | Which category of event fired |
| `payload` | The JSON body that was sent |
| `queuedDate`, `lastAttemptDate` | When the delivery was queued, and when it was last attempted |
| `sendDate` | When an attempt succeeded. Set only after a `2xx` |
| `httpResponseCode` | The status of the last attempt. `0` when the request itself failed, such as a timeout |
| `success` | Whether the delivery succeeded |
| `responseText` | Your endpoint's response body, truncated to 8 KB |
| `attemptCount` | Attempts made so far. `1` is the first attempt |
| `nextAttemptUtc` | When the next retry is due. A failed delivery with no `nextAttemptUtc` will not be tried again |
| `lastError` | A short description of the last failure |
| `signed`, `signatureKeyCount` | Whether the last attempt was signed, and with how many signatures: `0` unsigned, `1` normally, `2` during the 24 hours after a secret roll |

## Duplicates, ordering and idempotency

Design your endpoint so that handling the same event twice does no harm. Several things can deliver an event more than once or out of order:

- **Retries.** If your endpoint did the work but the response never reached Agility (a timeout, a dropped connection), the retry sends the same event again with the **same** `webhook-id`.
- **More than one webhook.** Each webhook gets its own delivery with its own `webhook-id`. Two webhooks pointed at the same URL send you two deliveries for one change.
- **Back-off.** A delivery being retried can arrive after a newer event for the same item has already succeeded.

Agility does collapse some duplicates before sending: repeated events within a 30-second window are merged into one delivery. Treat that as an optimization, not a guarantee. It also means a delivery is a signal that an item changed, not a complete log of every change.

Practical rules:

1. **Use `webhook-id` as your idempotency key.** Record each one you have processed and skip any you have seen. Keep them for longer than your retry sequence can run (it is capped at 24 hours per delay).
2. **Make the work itself idempotent.** "Re-fetch item 39 and update the cache" is safe to repeat. "Append item 39 to a feed" is not.
3. **Don't trust arrival order.** Compare `changeDateUTC` or the version ID with what you already have, or re-fetch the current state from the Fetch API, rather than applying events in the order they arrive.
4. **Acknowledge fast, work later.** Verify, record the `webhook-id`, queue the work and return `2xx`.

```ts
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.AGILITY_WEBHOOK_SECRET!); // the whsec_ string

export async function POST(req: Request) {
  const rawBody = await req.text(); // raw body: verify before parsing
  const headers = {
    "webhook-id": req.headers.get("webhook-id") ?? "",
    "webhook-timestamp": req.headers.get("webhook-timestamp") ?? "",
    "webhook-signature": req.headers.get("webhook-signature") ?? "",
  };

  let event: AgilityWebhookEvent;
  try {
    event = wh.verify(rawBody, headers) as AgilityWebhookEvent;
  } catch {
    return new Response("Invalid signature", { status: 401 });
  }

  // A retry resends the same webhook-id: skip work already done.
  if (await alreadyProcessed(headers["webhook-id"])) {
    return new Response("OK", { status: 200 });
  }

  await enqueue(event); // the slow work happens in the background
  await markProcessed(headers["webhook-id"]);
  return new Response("OK", { status: 200 }); // any 2xx marks the delivery successful
}
```

`alreadyProcessed`, `markProcessed` and `enqueue` stand for your own storage and queue.

## Checklist for a production endpoint

- [ ] Subscribed to the right event categories, and handling `Deleted` as well as `Published`
- [ ] Secure delivery on, the `whsec_` secret stored as configuration, and the raw body verified
- [ ] Verification accepts any matching signature, so a secret roll needs no code change
- [ ] Retries on, with a speed that suits how long your endpoint could be down
- [ ] Returns `2xx` quickly and does the work in the background
- [ ] Idempotent: `webhook-id` recorded, and the work safe to repeat
- [ ] Out-of-order events handled by comparing dates or versions, or by re-fetching
- [ ] Payloads without a `contentID` or `pageID` ignored or used to trigger a resync
- [ ] Someone knows to check **History** when content doesn't update

## Related

- [Webhooks](/docs/developers/webhooks): adding a webhook and choosing events
- [Verifying Signed Webhooks](/docs/developers/verifying-signed-webhooks): verification code in several languages
- [Webhook Configuration](/docs/training-guide/admin-webhooks): the administrator's guide
- [Management SDK: Webhooks (JavaScript)](/docs/javascript/management-sdk/webhooks) and [Management SDK: Webhooks (.NET)](/docs/dotNet/management-sdk-dotnet-webhooks): managing webhooks in code
- [Indexing Agility Content for Search](/docs/developers/indexing-content-for-search): a webhook-driven search index, including deletes
