Modulify

Deploy hooks

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

On this page

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.

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.

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.

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:

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 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:

{
    "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
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. 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.
  • 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.

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 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.

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 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 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, 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.

When a project is transferred

Transferring 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