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.
Four tabs sit under the heading:
- Webhooks, the endpoints on this site
- Insights, the counts and a chart for the period you pick
- 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 tells you to remove one to add another, or to contact support to request a higher limit.
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 2,048 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",
"domains": [
"pottery.example.com",
"pottery-studio.com",
"www.pottery-studio.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, deploy-hook when a deploy hook started it, and system when the platform finalised it. A publish started by calling a hook's URL has no person behind it, so its publishedBy is null. project.customDomain is the site's primary domain: the first custom domain, in the order you added them, that is connected, else the first connected www variant, or null while none is connected, and project.customDomainUrl is its address. project.domains lists every connected custom domain, www variants included, with the primary first, and is empty while none is connected. project.url is the primary domain's address when there is 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 before the second attempt, 5 minutes before the third, 30 minutes before the fourth and 2 hours before each one after that. 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 5 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 insights
The Insights tab reads "Every delivery to this site's webhooks." Next to the heading sits the date picker the Analytics tab uses: Today (from midnight UTC), Last day (the default, the last 24 hours), Last week, Last month, Last 3 months, Last year, All time or Custom range. The cards and the chart follow the period you pick, and so does the Deliveries tab.
Four cards count the chosen period across every webhook on the site: Deliveries, Delivered, In progress and Failed. In progress is a delivery that is still pending or still being retried, and Failed is one that used up its attempts. Test deliveries count like any other. Under the cards, the Deliveries over time chart covers the same period, split into delivered, in progress and failed. It draws one bar per hour for Today, Last day and a custom range of a single day, one per day for Last week, Last month, Last 3 months and custom ranges up to 92 days, and one per month for anything longer. Hovering a bar shows the time and the three counts. Time is counted in UTC.
The tab keeps itself up to date while it is open, checking again every 15 seconds, so new deliveries show up without reloading the page. It pauses while the browser tab is in the background.
With nothing logged in the period the chart shows No deliveries, "Counts appear once a webhook fires." If the counts cannot be read the four cards show a dash and the chart shows Could not load chart, "Check your connection and try again.", with a Try again button. A delivery removed from the log drops out of the counts too.
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.
The date picker from Insights comes first and keeps only the attempts in that period, starting at Last day. Three dropdowns filter it further: by endpoint (All webhooks), by event (All events, the three publish events, or Test events) and by order (Newest first, Oldest first). Load more pages through it. With nothing in the period the list shows No deliveries, Pick a longer period to see more., and with All time picked it reads "Delivery attempts appear here."
The list keeps itself up to date while it is open, checking again every 15 seconds, so new attempts and finished retries show up without reloading the page. It pauses while the browser tab is in the background.
Hovering a row reveals a small trash icon that removes that single attempt from the log.
Deliveries are kept until you remove them or clear the log. A removed or cleared attempt leaves the log at once and is erased for good 30 days later.
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 reads "Remove every recorded attempt from the Deliveries tab. Your webhooks keep firing on publish." Its Clear log button asks Clear the delivery log? with "Every recorded delivery attempt for this site is removed from the log. Your webhooks keep firing on publish." The toast reads Delivery log cleared with a count, such as Cleared 3 deliveries from the log., or Nothing to clear when the log was already empty.
Delete all webhooks reads "Permanently delete every webhook on this site and its delivery log. Nothing will be notified when this site publishes afterwards." Its Delete all webhooks button asks Delete every webhook? with the same warning. The toast reads Webhooks deleted, or Nothing to delete when the site had none.
Both danger zone buttons are disabled unless your workspace role holds the Delete projects permission, with the tooltip "You do not have permission to do this.", and both ask you to confirm first.
What the AI can do
The AI in the editor chat can list your webhook endpoints, their recent delivery attempts and the delivery counts for the last 30 days when you ask about them. So "did my webhook fire?", "why is this endpoint failing?", "why did Modulify turn it off?" and "how many fired last week?" are questions it can answer from the same records this tab shows you.
It changes them too when you ask. It adds an endpoint with the events and payload format you want, edits one, turns one on or off, sends a test, deletes one and clears the delivery log, with the same limits and checks as this tab. It asks you first before deleting an endpoint or clearing the delivery log. Treat the URL you give it like a password: chat never repeats it back in full.
It can also delete every webhook at once, after you confirm, with the same Delete projects permission as the Settings tab. It is never handed a signing secret, so revealing and rotating one stay yours, along with Export webhooks.
Chat reads and changes scheduled jobs and deploy hooks in the same way.
From an AI client over MCP
Everything on this page is reachable from a connected AI client through 14 tools:
list_site_webhooks,list_webhook_deliveries,get_webhook_delivery_statsandexport_site_webhooksread the endpoints, the log and the counts over time, and needconfig:readcreate_site_webhook,update_site_webhook,toggle_site_webhook,test_site_webhook,delete_site_webhook,delete_all_site_webhooks,delete_webhook_deliveryandclear_webhook_deliverieschange them, and needconfig:writeget_site_webhook_secretandrotate_site_webhook_secretread and replace the signing secret in plain text, and needcredentials:reveal
config:write and credentials:reveal 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.
- Deploy hooks go the other way: your systems call Modulify to start a publish.