# Webhooks

Source: https://modulify.ai/docs/automations/webhooks

Get an HTTP call at your own endpoint every time this site publishes.

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.

```json
{
    "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.

```javascript
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](/docs/mcp/tools).
## Next

- [Scheduled jobs](/docs/automations/scheduled-jobs) call your URL on a clock instead of on an event.
- [Publish a site](/docs/publish/publish-a-site) is the action that fires these events.
- [Publish history](/docs/publish/publish-history) is the same record kept inside Modulify.