What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
Extensibility
Every Agility webhook event and its payload, the Standard Webhooks headers, signing and secret rotation, retries and what counts as success, delivery history, and how to handle duplicates.
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. For signature verification code in Node.js, C#, Python and PHP, see Verifying Signed Webhooks.
| 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 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 |
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).
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 |
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.
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 (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.
Use contentID or pageID with languageCode to identify an item, not referenceName alone. One container holds many items.
These are the payloads Agility sends for each content and page event. The IDs and dates are examples.
{
"state": "Saved",
"instanceGuid": "046a1a87",
"languageCode": "en-us",
"referenceName": "posts",
"contentID": 39,
"contentVersionID": 300,
"changeDateUTC": "2019-11-06T15:10:24.9905899Z"
}
{
"state": "Published",
"instanceGuid": "046a1a87",
"languageCode": "en-us",
"referenceName": "posts",
"contentID": 39,
"contentVersionID": 300,
"changeDateUTC": "2019-11-06T15:10:24.9905899Z"
}
{
"state": "Deleted",
"instanceGuid": "046a1a87",
"languageCode": "en-us",
"referenceName": "posts",
"contentID": 39,
"contentVersionID": 301,
"changeDateUTC": "2019-11-06T15:14:02.5561907Z"
}
{
"state": "Saved",
"instanceGuid": "046a1a87",
"languageCode": "en-us",
"pageID": 2,
"pageVersionID": 76,
"changeDateUTC": "2019-11-06T15:08:54.7977419Z"
}
{
"state": "Published",
"instanceGuid": "046a1a87",
"languageCode": "en-us",
"pageID": 2,
"pageVersionID": 76,
"changeDateUTC": "2019-11-06T15:08:54.7977419Z"
}
{
"state": "Deleted",
"instanceGuid": "046a1a87",
"languageCode": "en-us",
"pageID": 2,
"pageVersionID": 0,
"changeDateUTC": "2019-11-06T15:12:41.1047362Z"
}
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.
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.
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;
}
When secure delivery is on, every delivery carries the three Standard Webhooks 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 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.
whsec_ followed by base64. Each webhook has its own. Agility always generates it: a secret you send on a save is ignored.{webhook-id}.{webhook-timestamp}.{raw body}, keyed with the base64-decoded part of the secret after whsec_, sent as v1,<base64 signature>.standardwebhooks (no hyphen) and pass it the whole whsec_ string. Libraries for other languages are listed at standardwebhooks.com.webhook-timestamp more than about five minutes from your clock, which stops a captured request being replayed later.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.
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:
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:
// JavaScript / TypeScript (@agility/management-sdk)
const rotated = await apiClient.webhookMethods.rotateWebhookSecret(guid, webhookID);
await saveToSecretManager(rotated.signingSecret); // whsec_...
// .NET (Agility.Management.SDK 2.0)
Webhook rotated = await client.Webhooks.RotateSigningSecretAsync(guid, webhookId);
StoreSecret(rotated.SigningSecret!);
A safe rotation:
webhook-signature matches. Standard Webhooks libraries already do.signatureKeyCount of 2 in the API).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:
2xx response. A success ends the retry chain.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.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.
Every webhook has a History action in Settings > Webhooks. It lists each delivery, newest first, with:
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.
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 |
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:
webhook-id.webhook-id. Two webhooks pointed at the same URL send you two deliveries for one change.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:
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).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.webhook-id, queue the work and return 2xx.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.
Deleted as well as Publishedwhsec_ secret stored as configuration, and the raw body verified2xx quickly and does the work in the backgroundwebhook-id recorded, and the work safe to repeatcontentID or pageID ignored or used to trigger a resync