Webhooks
Get an HTTP call at your own endpoint every time this site publishes.
On this page
A webhook is the opposite of a cron. Instead of Modulify calling your URL on a clock, it calls your URL when something happens to the site. Today that something is publishing.
Webhooks belong to a project. Any member of the workspace that owns the project can add, test and delete them.
Before you begin
The endpoint has to be public and use https. A private address or a http URL is rejected when you save.
Your endpoint has to answer with a status in the 200 to 299 range within 5 seconds. A redirect counts as a failure: the delivery records "Endpoint attempted a redirect, which is not allowed."
Open the Webhooks tab
Click More in the tab strip, then Webhooks. The address ends in /webhooks.
Three tabs sit under the heading:
- Webhooks, the endpoints on this site
- Deliveries, every attempt and what came back
- Settings, export and the danger zone
An empty site shows No webhooks with the line "Send publish updates to external services."
A site can hold 10 webhooks. At the limit the Add webhook button is disabled and a warning explains you have to remove one first.
Add a webhook
Click Add webhook. The form has four fields.
Name is how the list and the delivery log refer to this endpoint, up to 64 characters.
Endpoint URL is where the request goes, up to 2048 characters.
Payload format is one of Raw JSON, Slack, Discord or Ping. Raw JSON is the default and the one to use for your own server.
Events is a set of checkboxes. At least one has to be ticked. Publish succeeded is ticked for you.
The button reads Add webhook, and Save changes when you are editing an existing one.
The events
| Event | When it fires |
|---|---|
publish.succeeded |
A publish finished and the site is live |
publish.failed |
A publish stopped with an error |
publish.cancelled |
Someone stopped a publish that was running |
A webhook only receives the events it is subscribed to, and only while its switch is on.
The payload
A Raw JSON webhook receives a POST with Content-Type: application/json and this body.
{
"schemaVersion": "1.0",
"event": "publish.succeeded",
"success": true,
"deliveryId": "0f2a6c1e-4f6b-4a2f-9b41-2b0f0a8c9d33",
"timestamp": "2026-08-25T09:14:02.881Z",
"environment": "production",
"trigger": "manual",
"test": false,
"project": {
"id": "665f1c9a4d2b8e0012ab34cd",
"name": "Pottery Studio",
"slug": "pottery-studio",
"url": "https://pottery.example.com",
"subdomain": "pottery-studio",
"subdomainUrl": "https://pottery-studio.modulify.website",
"customDomain": "pottery.example.com",
"customDomainUrl": "https://pottery.example.com"
},
"deployment": {
"id": "665f1d114d2b8e0012ab3501",
"buildId": "k3f9xq1a",
"status": "ready",
"startedAt": "2026-08-25T09:12:40.102Z",
"finishedAt": "2026-08-25T09:14:02.640Z"
},
"publishedBy": {
"id": "6650aa3e4d2b8e0012ab1122",
"name": "Ana"
},
"dashboardUrl": "https://modulify.ai/dashboard/projects/pottery-studio",
"error": null
}success is true only for publish.succeeded. trigger is manual when a person started the publish and system when the platform finalised it. project.url is the custom domain if the site has one, otherwise the Modulify subdomain. deployment and publishedBy are null when there is nothing to report, and error carries the failure reason on publish.failed.
The other formats
Slack posts {"text": "..."} and Discord posts {"content": "..."}. Both carry the same short message: a title line such as ✅ Publish succeeded: Pottery Studio, then the site URL, then the error if there was one. A test event prefixes the title with [Test]. Point either one at an incoming webhook URL from that service and the message appears in the channel.
Ping sends an empty body. Use it when you only care that something happened and your endpoint reads the headers.
The headers
Every delivery carries these headers.
| Header | Value |
|---|---|
X-Modulify-Event |
The event name, for example publish.succeeded |
X-Modulify-Delivery |
The delivery id, stable across retries of the same delivery |
X-Modulify-Attempt |
Which attempt this is, starting at 1 |
X-Modulify-Timestamp |
Unix seconds at the moment the request was signed |
X-Modulify-Signature |
sha256= followed by the hex signature |
X-Modulify-Delivery is the field to deduplicate on. A retry reuses it, so seeing the same id twice means the same event, not a new one.
Verify the signature
Each webhook has its own signing secret, shown in the row as whsec_ followed by dots. The eye icon reveals it, the copy icon copies it, and the circular arrow rotates it.
The signature is an HMAC SHA-256, in hex, of the timestamp and the raw body joined by a period, keyed with the secret.
import { createHmac, timingSafeEqual } from 'crypto'
const Verify = (rawBody, headers, secret) => {
const timestamp = headers['x-modulify-timestamp']
const received = headers['x-modulify-signature']
const expected = 'sha256=' + createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
if (received.length !== expected.length) return false
return timingSafeEqual(Buffer.from(received), Buffer.from(expected))
}Sign the body exactly as it arrived, before any JSON parsing. Rotating the secret invalidates the old one immediately, so update your endpoint straight after you rotate.
Send a test event
Open the three dots menu on a webhook and choose Send test event. It delivers a publish.succeeded payload for this project with "test": true, using the real URL, format and secret.
The toast tells you what happened: "Test event delivered successfully." if your endpoint accepted it, or "Test event sent, but the endpoint did not accept it." if it did not. Either way the attempt appears in the Deliveries tab tagged Test.
Test deliveries are never retried, and they do not count towards the sent and failed totals on the row or towards the auto-disable threshold.
Retries and auto-disable
A failed delivery is retried up to 5 attempts in total, waiting 1 minute, then 5 minutes, then 30 minutes, then 2 hours between them. In the delivery log the states read:
| State | Meaning |
|---|---|
| Pending | Recorded, not yet answered |
| Delivered | The endpoint accepted it |
| Retrying | This attempt failed and another is scheduled |
| Failed | Every attempt was used up |
Retries stop if you turn the webhook off in the meantime.
A delivery only counts as a failure once all five attempts are used up. After 20 such failures in a row the webhook disables itself and the row reads "Auto-disabled after repeated delivery failures." in amber. A successful delivery resets the count. Flip the switch back on once your endpoint is healthy.
Read the delivery log
The Deliveries tab lists attempts newest first: the state, the event name, the response status, the endpoint name, the error if there was one, how long ago it happened and the duration in milliseconds.
Three dropdowns filter it: by endpoint (All webhooks), by event (All events, the three publish events, or Test events) and by order (Newest first, Oldest first). Refresh re-reads the list and Load more pages through it.
Hovering a row reveals a small trash icon that removes that single attempt from the log.
Deliveries are kept for 90 days.
What the row tells you
Back on the Webhooks tab, each row shows the format badge, the endpoint URL as a link, one chip per subscribed event, a count of how many deliveries were sent, a count of how many failed, and when the last delivery landed with a green or red dot.
The switch pauses the endpoint without deleting it. The three dots menu holds Edit, Send test event and Delete. Deleting asks you to confirm, and the dialog explains that the endpoint will no longer receive publish events.
Export and clear
The Settings tab holds three things.
Export webhooks downloads every webhook on the site as JSON, including its URL and events. Signing secrets are never included.
Clear delivery log empties the Deliveries tab and leaves the webhooks firing.
Delete all webhooks permanently deletes every webhook on the site along with its log. Both danger zone buttons are disabled unless your workspace role can delete projects, and both ask you to confirm first.
From an AI client over MCP
Everything on this page is reachable from a connected AI client: list_site_webhooks, create_site_webhook, update_site_webhook, delete_site_webhook, delete_all_site_webhooks, toggle_site_webhook and test_site_webhook, plus list_webhook_deliveries, delete_webhook_delivery and clear_webhook_deliveries for the log.
Creating and changing webhooks needs the config:write scope. Reading or rotating a signing secret needs credentials:reveal, a separate scope that hands the client the secret in plain text. Both are unticked by default. See MCP tools.
Next
- Scheduled jobs call your URL on a clock instead of on an event.
- Publish a site is the action that fires these events.
- Publish history is the same record kept inside Modulify.