# Deploy hooks

Source: https://modulify.ai/docs/automations/deploy-hooks

Publish your site from anywhere by calling a secret URL with a plain GET request.

A deploy hook is a secret URL that publishes your site when something calls it. Point a CI pipeline, a script or a scheduled job at it, and every call starts a build of the site, the same build the **Publish** button starts.

Deploy hooks belong to a site. Any member of the workspace that owns the project can add, trigger, rotate and delete them.

## Before you begin

The URL is the only thing a caller needs. Treat it like a password: anyone who has it can publish your site without signing in. If you think it has leaked, [rotate it](#rotate-the-url).

A call is a plain `GET` request. It needs no header, no body and no other token, so anything that can fetch a URL can call a hook.

Only one build runs per site at a time. A call that arrives while a build is running does not start a second one and does not queue one for later. It is logged as **Already building**.

A call publishes the latest saved version of the site, exactly like pressing **Publish**. The site does not need to have been published before, so a hook can make its first publish too. See [Publish a site](/docs/publish/publish-a-site).

## Open the Deploy hooks tab

Click **More** in the tab strip, then **Deploy hooks**. The address ends in `/deploy-hooks`.

Four tabs sit under the heading:

- **Hooks**, the URLs on this site
- **Insights**, the counts and the daily chart for the last 30 days
- **Calls**, every call and what it did
- **Settings**, export and the danger zone

An empty site shows **No deploy hooks** with the line "Add one to publish from anywhere."

A site can hold **10 deploy hooks**. At the limit the **Add deploy hook** button is disabled with the tooltip "You have reached the maximum of 10 deploy hooks.", and a warning tells you to remove one to add another, or to contact support to request a higher limit.

## Create a deploy hook

Click **Add deploy hook**. The form has one field, **Name**, up to 64 characters, with the placeholder `GitHub Actions`. Name the hook after whatever will call it, because the name is how the call log and the Publishes tab refer to it.

Click **Create deploy hook**, or **Cancel** to close the form. A toast reads **Deploy hook created** with "Copy the URL and call it with a GET request to start a build."

The new row shows the URL masked, like every other hook. Copy it with the copy button and store it as a secret in the system that will call it.

### The URL on the row

The URL has the form `https://<api host>/deploy-hooks/mdh_...`, where everything after `mdh_` is the secret part. Use the URL shown on the hook row exactly as it is.

The row shows the URL masked, as `mdh_` followed by dots and the last four characters, so you can tell two hooks apart. Two icon buttons sit right after it: one reveals the full URL and one copies it. Copying works on a masked URL too. It shows **URL copied** with "Keep it secret, anyone with it can start a build."

## Call a hook

Send a `GET` request to the URL.

```bash
curl "YOUR_DEPLOY_HOOK_URL"
```

That is the whole integration. A query string or a header does not change what the call does. Keep the request a `GET`: many tools, curl's `-d` among them, switch to `POST` as soon as they send a body, and a `POST`, `PUT` or `DELETE` answers `405` and starts nothing.

### From a CI pipeline

In GitHub Actions, save the URL as a repository secret, for example `MODULIFY_DEPLOY_HOOK_URL`, pass it to a step as an environment variable of the same name, and give the step this `run` command:

```bash
curl --fail-with-body -sS "$MODULIFY_DEPLOY_HOOK_URL"
```

`--fail-with-body` fails the step when the call is rejected and still prints the reason. **Already building** answers with a `200`, so it does not fail the step. Any other CI system works the same way: store the URL as a secret and fetch it.

### From a scheduled job

To rebuild the site on a clock, create a [scheduled job](/docs/automations/scheduled-jobs) with **Method** set to `GET` and the hook URL as its **Request URL**. The form starts on `POST`, so change **Method** first, or every run answers `405` and starts nothing. Both a started build and **Already building** answer in the 200 range, so neither counts as a failed run.

A scheduled job shows its URL in the list, in the run history and in the export, so anyone who can read the site's crons can read a hook URL stored in one. The editor chat and connected AI clients see that URL with its secret part hidden, as `mdh_<redacted>`.

### What a call answers

Every answer is JSON with `success`, `message`, `data`, `code` and `version`. A call that started a build looks like this:

```json
{
    "success": true,
    "message": "The latest saved version of the site is being published.",
    "data": {
        "status": "started",
        "deploymentId": "665f1d114d2b8e0012ab3501",
        "callId": "665f1d0f4d2b8e0012ab34ff"
    },
    "code": 202,
    "version": "0.0.1577"
}
```

`data.status` tells you what happened, and `data.deploymentId` is the publish it started or joined.

| Status | `data.status` | Message | What it means |
| --- | --- | --- | --- |
| `202` | `started` | `The latest saved version of the site is being published.` | A build started |
| `200` | `already-building` | `This site is already building, so this call did not start another build.` | A build was already running, and `deploymentId` is that build |
| `403` | `disabled` | `This deploy hook is turned off, so no build was started!` | The hook is [turned off](#turn-a-hook-off) |
| `404` | none, `data` is `null` | `This deploy hook does not exist!` | No hook has this URL, because it was mistyped, rotated or deleted, or its site was deleted |
| `405` | none, `data` is `null` | `Call this deploy hook with a GET request, no build was started!` | The call was a `POST`, `PUT` or `DELETE`, so nothing started and nothing was logged |
| `422` | `refused` | The reason | The site cannot publish right now |
| `429` | `rate-limited` | `This deploy hook was called more than 10 times in a minute. Wait a minute and call it again!` | Too many calls, see below |
| `500` | `failed` | `Something went wrong starting the build!` | The build could not be started |

Most answers also carry a `callId`, the id of the row the call wrote to the [call log](#read-the-call-log). A `404` or a `405` writes nothing.

A `422` carries the same reason a publish from the editor would get:

- `This site is locked because it exceeds your plan limits. Upgrade your plan or add an extra site to publish it!` See [Limits and quotas](/docs/plans/limits-and-quotas).
- `The code for this site is being moved to your GitHub. Try again in a moment!`
- `Modulify no longer has access to the GitHub repository that holds this site. Grant the Modulify GitHub app access to the repository and try again!`
- `The GitHub repository that held this site was deleted, so the site can no longer be edited or published!`
- `The code for this site lives in your own GitHub, which needs a paid plan. Upgrade the workspace to keep editing and publishing it!`

The last four only apply to a site whose code lives in your own GitHub. See [Move your code to GitHub](/docs/editor/move-code-to-github).

A hook takes **10 calls a minute**. Past that, a call answers `429` with a `Retry-After` header and `data.retryAfter`, both in seconds. Calls to a hook that is turned off count towards the limit too.

A `202` means the build started, not that it went live. The build can still fail afterwards, for example with `Your latest changes are still being saved, so nothing was published. Please try again in a moment!` Follow it in the Publishes tab, in the build state on the call row, or with a [webhook](/docs/automations/webhooks) on `publish.succeeded` and `publish.failed`.

Every answer carries `Cache-Control: no-store` and `X-Robots-Tag: noindex, nofollow`.

### HEAD requests

A `HEAD` request only checks the URL. It answers `200` when a hook has that URL and `404` when none does. It never starts a build and is never logged, so a link checker or an uptime monitor that sends `HEAD` is safe to point at a hook.

:::warning{title="Do not paste the URL into chat"}
Chat apps and other tools that preview links can fetch a URL the moment it is posted, and a `GET` starts a build. Keep the URL out of messages, tickets and shared documents, and store it only as a secret in the system that calls it.
:::

## Turn a hook off

The switch on the row turns a hook off and on without deleting it. A turned off hook keeps its URL and its call log, and the row shows an **Off** badge.

A call to a turned off hook starts nothing. It answers `403` and is logged as **Hook turned off**, so you can still see who is calling it. It still counts towards the 10 calls a minute, so past that it answers `429` like any other call.

Turning it off shows **Deploy hook turned off**, "Calls to its URL are logged but start nothing." Turning it back on shows **Deploy hook turned on**, "Calls to its URL start builds again."

## Trigger, rename, rotate and delete

The three dots menu on a row holds **Trigger now**, **View calls**, **Rename**, **Rotate URL** and **Delete**. **View calls** opens the **Calls** tab filtered to that hook.

### Trigger now

**Trigger now** starts a build from the dashboard, through this hook. It asks **Start a build now?** with "This publishes the latest saved version of the site, like pressing Publish." and a **Start build** button. It is disabled while the hook is turned off.

The toast reads **Build started**, **Already building** or **Build not started**, with the same message a call to the URL would get. The call is logged with the source **Dashboard** and your name, and it counts towards the 10 calls a minute.

### Rename

**Rename** opens the form with the current name, and **Save changes** saves it. The URL stays the same. The toast reads **Deploy hook renamed**, "The new name shows in the call log from now on."

### Rotate the URL

**Rotate URL** replaces the secret part of the URL with a new one. It asks **Rotate this URL?** with "The current URL stops working immediately. Update every system that calls it." and a **Rotate URL** button.

The row shows the new URL in full straight away. The old one answers `404` from that moment. The toast reads **URL rotated**, "The old URL stopped working. Update anything that called it." The call log is kept.

### Delete

**Delete** sits last, in red. It asks **Delete this deploy hook?** with "Anything still calling this URL stops starting builds, and its call log is deleted." and a **Delete** button. The toast reads **Deploy hook deleted**, "Its URL no longer starts builds."

The URL answers `404` afterwards. Publishes the hook already started stay in the Publishes tab, but no longer name the hook.

## What the row tells you

Each row shows the hook name, the **Off** badge when it is turned off, and the masked URL. Under that, a line counts the calls and the builds it started in the last 30 days, such as `12 calls` and `9 builds`, and hovering either reads "In the last 30 days". **Already building** calls count as calls, not builds.

The line ends with **Last called** and how long ago, with the outcome of that call, or **Never called** for a hook nothing has called yet.

Under the list, a hint reads "Call a hook with a GET request. One build runs at a time, so a call during a build is logged as Already building."

## Read the insights

The **Insights** tab reads "Every call to this site's deploy hooks in the last 30 days."

Four cards count the last 30 days across every hook on the site: **Calls**, **Builds started**, **Already building** and **Rejected**. Under them, the **Calls per day** chart covers the **Last 30 days** with one bar per day, split into builds started, already building and rejected. Hovering a bar shows the date and the three counts. Days are counted in UTC. **Refresh** re-reads the numbers.

With nothing logged yet the chart shows **No calls**, "Daily counts appear once a hook runs." 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, and a **Refresh** that fails shows **Could not refresh insights**.

## Read the call log

The **Calls** tab reads "Every call to this site's deploy hooks, newest first."

Three dropdowns filter the list: by hook (**All hooks**, or one hook by name), by outcome (**All outcomes**, **Build started**, **Already building**, **Rejected**) and by order (**Newest first**, **Oldest first**). A refresh button re-reads the list, and **Load more** pages through it.

Each call row shows:

- the outcome
- the hook name
- where the call came from, **URL**, **Dashboard**, **Chat** or **MCP**
- who called it, which is the member's name for **Dashboard**, **Chat** and **MCP**, and the IP address and user agent of the caller, shortened, for **URL**
- how long ago it happened
- the reason, for a call that was rejected
- the state of the build it started or joined, **Building**, **Published**, **Failed** or **Cancelled**

### The outcomes

| Outcome | What it means |
| --- | --- |
| Build started | The call started a build |
| Already building | A build was already running, so the call started nothing and the row follows that build |
| Hook turned off | The hook's switch was off, so nothing started |
| Refused | The site could not publish, and the row shows why |
| Rate limited | The hook had already taken 10 calls that minute. Only the first rate limited call in a minute is logged, so a runaway caller adds one row, not hundreds |
| Failed | Something went wrong before the build could start |

The **Rejected** card and filter cover the last four.

### Remove a call

Hovering a row reveals a small trash icon. It asks **Remove this call from the log?** with "It is hidden from the log. The build it started is not affected." and a **Remove** button. The toast reads **Call removed**, "It no longer shows in the call log." A removed call also drops out of the cards, the chart and the counts on the hook row.

Calls are kept for **30 days**.

With nothing logged yet the tab shows **No calls**, "Calls show up once a hook runs." If the log cannot be read it shows **Could not load calls**, "Check your connection and try again.", with a **Try again** button.

## What shows in Publishes

A publish a deploy hook started is marked in the [Publishes](/docs/publish/publish-history) tab. When the URL was called, the row names the hook with a lightning icon, such as `v8 by GitHub Actions`. When a member started it with **Trigger now**, from chat or over MCP, the row names both, such as `v9 by Sarah via GitHub Actions`. The hook's name only shows while the hook exists, so once it is deleted, or the project is transferred, a row the URL started reads `by -`, and one a member started names only the member.

Your [webhooks](/docs/automations/webhooks) see the difference too. The `publish.succeeded` and `publish.failed` events of a publish a hook started carry `"trigger": "deploy-hook"`. `publishedBy` is `null` when the URL was called, and names the member otherwise.

## Export and clear

The **Settings** tab holds three things.

**Export** reads "Download every deploy hook as JSON, without the secret URLs." The **Export** button downloads each hook's name, whether it is on, its masked URL, when it was last called and when it was created. The toast reads **Deploy hooks exported**, "The file lists every hook without its secret URL."

**Clear call log** reads "Remove every call from the log. Hooks keep working." Its **Clear log** button asks **Clear the call log?** with "Every call is removed from the log. Hooks and their URLs keep working." The toast reads **Call log cleared** with a count, such as `Cleared 3 calls from the log.`, or **Nothing to clear** when the log was already empty.

**Delete all deploy hooks** reads "Delete every hook and its call log. Their URLs stop working." Its **Delete all** button asks **Delete every deploy hook?** with "Every hook URL stops working and all call logs are deleted. This cannot be undone." The toast reads **Deploy hooks 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."

## What the AI can do

The AI in the editor chat manages this site's deploy hooks from chat. It can:

- list the hooks, which also hands it the **Insights** counts: the totals for the last 30 days and the busiest and most recent days
- create one
- rename one
- turn one on or off
- delete one
- rotate a hook's URL
- start a build through a hook
- read the call log, ten calls at a time by default and up to 30, filtered by hook or outcome

It never shows a hook's URL, not even right after creating or rotating one. It tells you to copy the URL from this tab, with the copy button on the hook row. Exporting the hooks, clearing the call log and deleting every hook are yours alone, in the **Settings** tab.

Before it deletes a hook, rotates a URL or starts a build, it asks you first, unless your message asked for exactly that or chat created that hook in the same reply. A build it starts is logged with the source **Chat** and your name. When a build is already running, chat tells you no second build was started, and that is not treated as an error.

It is the reverse for [webhooks](/docs/automations/webhooks), which chat can read but never change.

## From an AI client over MCP

Everything on this page is reachable from a connected AI client through 14 tools:

- `list_deploy_hooks`, `list_deploy_hook_calls`, `get_deploy_hook_call_stats` and `export_deploy_hooks` read the hooks, the log and the daily counts, and need `config:read`
- `create_deploy_hook`, `rename_deploy_hook`, `toggle_deploy_hook`, `delete_deploy_hook`, `delete_all_deploy_hooks`, `delete_deploy_hook_call` and `clear_deploy_hook_calls` change them, and need `config:write`
- `trigger_deploy_hook` starts a build, and needs `publish:write`
- `get_deploy_hook_url` and `rotate_deploy_hook_url` read and replace the URL in plain text, and need `credentials:reveal`

Listing and exporting return the masked URL only, and creating a hook does not return its URL either. A build started over MCP is logged with the source **MCP**. `config:write` and `credentials:reveal` are unticked by default. See [MCP tools](/docs/mcp/tools#deploy-hooks).

## When a project is transferred

[Transferring a project](/docs/projects/transfer-a-project) to another person deletes every deploy hook on it along with the call log, because the URLs belong to the previous owner's systems. The new owner creates their own.

## Next

- [Webhooks](/docs/automations/webhooks) go the other way: Modulify calls your endpoint when the site publishes.
- [Scheduled jobs](/docs/automations/scheduled-jobs) can call a hook on a clock.
- [Publish a site](/docs/publish/publish-a-site) is the build a hook starts.
- [Publish history](/docs/publish/publish-history) lists every publish, including the ones a hook started.
- [MCP tools](/docs/mcp/tools) covers the deploy hook tools in full.