A webhook is an HTTPS address Cendia posts to when what your site serves changes. Use one to purge a CDN, update a search index, rebuild a static site, or notify a team.
The short version: add the endpoint under Settings > Webhooks, give it a secret, and press Send Test. The rest of this guide is what your receiver has to do.
What you can rely on
- Nothing is lost. Each event is written to the database in the same transaction as the change that caused it. A restart, a deploy or your endpoint being down delays a delivery; it does not drop one.
- At least once. A delivery is retried until your endpoint answers with a 2xx status. If your
answer is lost on the way back, you receive the same event again. Use
eventIdto recognise it. - No promised order. A retry can arrive after a later event. Use
occurredAt, or ask the Delivery API for the current state instead of trusting the event's. - Sent straight away. Publishing never waits for your endpoint: the change is saved, and a background job named Deliver Webhooks starts within a second to send it. The same job also runs once a minute to make the retries that have come due.
Retries
A delivery is tried 8 times: at once, then after 1, 5 and 15 minutes, and 1, 3, 6 and 12 hours. That is a little under a day. After the eighth failure it is marked Failed and stays in the log, where Resend queues it again with a fresh set of attempts.
An attempt fails when your endpoint answers anything other than 2xx, takes longer than 10 seconds, or cannot be reached. Redirects are not followed. Answer quickly and do the work afterwards.
Events and their deliveries are kept for 30 days.
Events
| Event | Sent when |
|---|---|
content.published |
A version of a page, block or media item went live in one language. Also sent when an item is restored from the trash. |
content.unpublished |
An item, or one language of it, stopped being served: moved to the trash, a language removed, or deleted by a release. |
content.expired |
An item passed its expiration. Delivery stops serving it at that moment; the event follows within about a minute. |
content.moved |
A page changed position in the tree. Its address changed, and so did the address of every page beneath it. |
media.changed |
A file was uploaded, had its bytes replaced, was renamed or was moved to another folder. |
release.published |
A release went live. Each item in it is also sent as its own event. |
release.reverted |
A published release was rolled back. Each item in it is also sent as its own event. |
webhook.ping |
You pressed Send Test. |
A webhook with no events ticked receives all of them, including any added in a later version. Saving a draft sends nothing: an event means a visitor could now be served something different.
The request
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Cendia-Webhooks
X-Cendia-Event: content.published
X-Cendia-Delivery: 5b0c7a52-9f0e-4d0c-8a53-0e9d7f1f3a11
X-Cendia-Attempt: 1
X-Cendia-Signature: sha256=3f5a...
{"eventId":"0d1c...","eventType":"content.published","occurredAt":"2026-10-11T15:04:05.1234567+00:00","itemId":"7a9e...","culture":"en-US","kind":"Page","contentTypeId":"site.page.article","version":12}
Every body has eventId, eventType and occurredAt. The rest depends on the event:
| Field | On | Meaning |
|---|---|---|
itemId |
content and media events | The item. Ask the Delivery API for its content and address. |
culture |
content events | The language. null means every language of the item. |
kind, contentTypeId |
most content events | Page, Block or Media, and the content type. Left out when the platform did not already have them to hand. |
version |
content.published |
The version number that is now live. |
reason |
some content events | Why: trashed, restored, release, release-reverted, language-removed, language-restored. |
change |
media.changed |
uploaded, replaced, renamed or moved. |
releaseId, releaseName |
release events | The release. release.published also carries publishedAt. |
X-Cendia-Delivery stays the same across the retries of one delivery, and X-Cendia-Attempt
counts them.
Verifying the signature
When the webhook has a secret, X-Cendia-Signature is sha256= followed by the lowercase hex
HMAC-SHA256 of the request body, keyed with the secret. Compute it over the raw bytes you
received, before parsing them.
app.MapPost("/hooks/cendia", async (HttpRequest request) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer);
var body = buffer.ToArray();
var expected = "sha256=" + Convert.ToHexString(
HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), body)).ToLowerInvariant();
var given = request.Headers["X-Cendia-Signature"].ToString();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(given)))
return Results.Unauthorized();
// Queue the work and answer now.
return Results.Accepted();
});
The secret is write-only: the page shows its last four characters and never the value. Saving one
needs a secret store (Cendia:SecretStore:Provider set to DataProtection). Without a secret,
deliveries are sent unsigned.
Which addresses are allowed
A webhook added under Settings must be https:// and must not point at localhost or a private
network address. The host is checked again on every attempt, so a name that later resolves to an
internal address is refused.
For a receiver on your own machine or inside your network, list it in configuration instead:
"Cendia": {
"WebhookEndpoints": [
{ "Url": "http://localhost:9000/hook", "Secret": "dev-secret" }
]
}
Configured endpoints are sent release.published only, with the same retries. They appear on the
Webhooks page under From configuration, read-only.
Environments that are not production
Webhooks added under Settings are stored in the database. If you copy a production database down to a test or staging environment, its webhooks come with it, and that environment would call your production endpoints.
Switch delivery off everywhere except production:
"Cendia": {
"Webhooks": { "DeliveryEnabled": false }
}
Or as an environment variable: Cendia__Webhooks__DeliveryEnabled=false.
With delivery off, events are still recorded but nothing is sent, and nothing is kept to send later. Deliveries that were waiting are marked Failed with the reason. The Webhooks page shows a warning at the top. Turning delivery back on starts from that moment.
Troubleshooting
| What you see | Likely cause |
|---|---|
| Deliveries stay Queued | The Deliver Webhooks job is switched off or Cendia:EnableScheduledJobs is false. Check Settings > Scheduled Jobs. |
| Failed, "delivery is switched off for this installation" | Cendia:Webhooks:DeliveryEnabled is false in this environment. |
| Failed, "The host resolves to an address inside the network" | The URL's host name points at a private address. Use a configured endpoint for internal receivers. |
| Failed, "The webhook's secret could not be read" | The Data Protection key ring changed. Save the secret again. |
| Your receiver rejects the signature | It is hashing parsed-and-reserialised JSON. Hash the raw body. |
| The same event arrives twice | Expected after a lost answer. Ignore an eventId you have already handled. |