Deploys
Deploy hooks
Paste one URL into your hosting dashboard. From then on every production deploy is marked and watched for regressions, with nothing in CI and nobody remembering to do it.
How it works
- Create a hook in Settings → Deploy hooks (or with the API below). You get a URL like
https://upbutler.com/hooks/deploy/dh_…. - Paste it into the webhook settings of your hosting provider. One URL takes every provider and every project; UpButler recognises the sender from the request.
- Each production deploy creates a deploy marker with the commit, branch, pull request, author and provider, and starts a deploy guard on the monitors that belong to that project. Preview deploys, builds still running and failed builds are logged and ignored.
- If a watched monitor that was healthy goes down or degraded inside the window (10 minutes by default), the guard's verdict is
regression, adeploy.guard.verdictevent is emitted, and the incident gets an internal note such as “Started 4 min after deploy abc1234 (PR #212) by Vercel.” Otherwise the verdict isclear.
The same URL also receives preview deployments. They are not marked or guarded; instead your monitors run once against the preview and the verdict goes to the pull request. See Preview checks.
Deploy hooks also close the loop for Fix with Claude: when a deploy carries the pull request an agent opened for an incident, its guard decides whether the incident is resolved. Payloads that name the GitHub repository (Vercel, GitHub, Netlify, Railway, or "repo": "owner/name" in the generic body) link that repository to the project.
Set up your provider
Vercel
- Team Settings → Webhooks (account webhooks need a Pro or Enterprise plan).
- Tick Deployment Succeeded (optionally Promoted and Rollback) and choose the projects.
- Paste the URL and click Create Webhook. Optional: save the secret Vercel shows once as the hook's Vercel signing secret.
Marked: deployment.succeeded with target: "production", deployment.promoted and deployment.rollback. A succeeded and a promoted event for the same deployment are one release. deployment.created, deployment.error and deployment.canceled are logged and ignored. The project name is the Vercel project; commit, branch, author and PR number come from the Git metadata. On the Hobby plan, use the GitHub webhook instead: Vercel's GitHub integration reports the same deploys as deployment statuses.
Railway
- Open the project → Settings → Webhooks.
- Paste the URL and keep the deployment events selected.
- Save Webhook.
Marked: deployments that reach SUCCESS in an environment named production (or prod, live, main; change the list on the hook). PR environments are ignored. The project name is the Railway service name. Railway does not sign webhooks; to add a secret, set a custom header Authorization: Bearer <secret> there and save the same value as the hook's Railway secret.
Netlify
- Project configuration → Notifications → Deploy notifications → Add notification → HTTP POST request.
- Event Deploy succeeded, URL to notify = the hook URL.
- Save. Optional: set a JWS secret token and save the same value as the hook's Netlify secret.
Marked: deploys with state: "ready" and context: "production". Deploy previews and branch deploys are ignored. The project name is the site name; the site's own URL is used to find monitors on the same domain.
Cloudflare Pages and Workers
Pages, through Cloudflare Notifications:
- Dashboard → Notifications → Destinations → Webhooks → Create: paste the URL (optional secret = the hook's Cloudflare secret, sent as
cf-webhook-auth). - Notifications → Add → Pages Project updates, event Deployment success.
- Choose the webhook as the destination and save.
Workers Builds publishes build events to a Queue, not to a URL. Subscribe a queue to Workers Builds events and forward each message unchanged from a small consumer Worker:
// Cloudflare Workers Builds → UpButler. Bind a Queue subscribed to Workers Builds events.
export default {
async queue(batch, env) {
for (const msg of batch.messages) {
await fetch(env.UPBUTLER_DEPLOY_HOOK, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(msg.body),
});
msg.ack();
}
},
};Marked: Pages EVENT_DEPLOYMENT_SUCCESS in ENVIRONMENT_PRODUCTION, and Workers build.succeeded events whose deploy command is not wrangler versions upload (those are preview versions). The project name is the Pages project or the Worker name.
GitHub
- Repository → Settings → Webhooks → Add webhook.
- Payload URL = the hook URL, content type
application/json, optional secret (= the hook's GitHub secret). - Let me select individual events → tick only Deployment statuses → Add webhook.
Marked: deployment_status with state: "success" on a production environment (production_environment: true, or an environment named like production). This covers anything that reports GitHub deployments: Actions jobs with environment:, Vercel, Render, Fly and Heroku integrations. The project name is the repository name. Other events, including the initial ping, are logged and ignored.
With the UpButler GitHub App installed you do not need this webhook: turn on Use GitHub deployments as a deploy source for the installation and the same deployment_status events arrive through the App, signed with its own secret.
Coolify
Coolify v4 sends its notifications to a webhook. The setting belongs to the team, not to one application, so one URL reports every application of that team (on a single-team instance: the whole instance).
- In Coolify, open Notifications in the sidebar and choose the Webhook tab.
- Paste the hook URL into Webhook URL, save, and click Enable. Send test then appears under Recent deliveries as “Coolify test notification: the webhook is connected”.
- Under Deployments, tick Deployment success and save. It is off by default; without it Coolify only reports failures and no deploy is ever marked.
{
"success": true,
"message": "New version successfully deployed",
"event": "deployment_success",
"application_name": "acme-api",
"application_uuid": "wk8s4gc0kgk8s4o0wsc8kcw0",
"deployment_uuid": "j4cs0g4wo0ko4c8w8s8kkgok",
"deployment_url": "https://coolify.acme.dev/project/…/environment/…/application/wk8s4gc0kgk8s4o0wsc8kcw0/deployment/j4cs0g4wo0ko4c8w8s8kkgok",
"project": "Acme",
"environment": "production",
"fqdn": "https://api.acme.dev"
}Marked: deployment_success without a pull_request_id, whatever the Coolify environment is called. The project name is the application name (application_name), not the Coolify project; fqdn finds monitors on the application's domain, and the marker links to the deployment log in Coolify. deployment_failed is logged and ignored (nothing new went live), and so is every other notification you leave ticked there (backups, server reachability, resource status): none of them is read as a deploy.
Two limits come from Coolify itself. The payload carries no commit, branch or author, so a Coolify marker shows the application and the time only, and the pull request of a Fix with Claude run cannot be recognised from it; add the GitHub webhook or a post-deployment curl when you need the commit. And Coolify cannot sign the request or add a header: the token in the URL is the only credential.
Preview deployments (a deployment_success with pull_request_id and preview_fqdn) go to preview checks. Coolify serves a preview under the application's own domain (Preview URL Template, default {{pr_id}}.{{domain}}), so the preview host is trusted only when it is a subdomain of the application's fqdn and that domain is one your HTTP monitors watch. For a template that puts previews elsewhere, such as {{pr_id}}-{{domain}}, add the pattern to the allowed preview hosts.
Several applications on one Coolify instance
Because the webhook is per team, UpButler tells applications apart by what the payload says. For each deploy the first match wins:
- a mapping whose project is the application name, then one whose project is the application uuid (the last path segment of the application's page in Coolify);
- monitors tagged or named like the application;
- HTTP monitors on the application's first domain.
# by application name …
curl -X PUT https://upbutler.com/api/v1/deploy-mappings/acme-api \
-H "Authorization: Bearer $UPBUTLER_API_KEY" -H "Content-Type: application/json" \
-d '{"monitorIds": ["mon_api_health"], "tags": ["api"]}'
# … or by application uuid, when two applications share a name
curl -X PUT https://upbutler.com/api/v1/deploy-mappings/wk8s4gc0kgk8s4o0wsc8kcw0 \
-H "Authorization: Bearer $UPBUTLER_API_KEY" -H "Content-Type: application/json" \
-d '{"monitorIds": ["mon_worker_health"]}'An application with a domain and a monitor on that domain needs no setup at all. Map by name for applications without a domain (workers, queues) and for extra monitors a deploy should watch; map by uuid when names repeat, for example an api in two Coolify projects. Applications with no match are guarded on the * mapping, else on every monitor, so set a * mapping if the instance also hosts things you do not monitor.
Fly, Render and scripts
Anything that can run curl at the end of a deploy:
curl -fsS -X POST "https://upbutler.com/hooks/deploy/<token>" \
-H "Content-Type: application/json" \
-d "{\"service\": \"api\", \"commit\": \"$GIT_SHA\", \"branch\": \"main\", \"pr\": 212}" || true| Field | Meaning |
|---|---|
service | Project name, the key for the monitor mapping (also project, app) |
environment | Default production. Other names are previews unless listed on the hook |
version, commit, branch, pr, author | What shipped. All optional |
url | Link shown with the deploy; its domain also finds monitors |
status | Default success. started, failed and canceled are logged and ignored |
id | Your deploy id, for idempotency. An Idempotency-Key header works too |
Query parameters override the body for providers whose payload you cannot shape: ?service=api, ?environment=production and ?provider=generic.
Responses
{
"ok": true,
"outcome": "accepted",
"provider": "vercel",
"event": "deployment.succeeded",
"deployId": "dep_0n3qc1a2b3c4d5e6f7g",
"guardId": "grd_0n3qc1a2b3c4d5e6f7h",
"watching": { "source": "tag", "monitors": 2 },
"guardUrl": "https://upbutler.com/api/v1/deploys/guards/grd_0n3qc1a2b3c4d5e6f7h"
}{ "ok": true, "outcome": "ignored", "provider": "vercel", "event": "deployment.succeeded",
"reason": "\"preview\" deploy: only production deploys are marked (turn on \"include previews\" to change that)" }| Status | When |
|---|---|
200 | Accepted, or ignored with a reason (preview, not finished, failed, canceled, duplicate, not a deploy event). Ignored deliveries answer 200 so providers do not retry them |
400 | The body is not JSON |
401 | A signing secret is stored for this provider and the signature is missing or wrong |
404 | Unknown or rotated token |
413 / 429 | Body over 512 KB / more than 120 deliveries a minute on one hook |
Provider retries never create a second marker: each deployment id is recorded once per hook. A GET on the URL confirms the hook is live without recording anything. Every delivery from the last 30 days, with its outcome and reason, is under Recent deliveries on the settings page and at GET /api/v1/deploy-hooks/deliveries.
Which monitors are watched
The project name from the payload decides. The first rule that finds monitors wins:
- The mapping saved for that project (monitors, status page components and tags). A mapping can also be keyed by the provider's own project id, such as a Coolify application uuid; the name is tried first.
- Monitors tagged or named like the project (case-insensitive). Tagging your monitors with the Vercel project or Railway service name is all the setup most workspaces need.
- HTTP monitors on the deploy's domain, when the provider sends one (Netlify site URL, Cloudflare custom domain, GitHub
environment_url, Vercel aliases, Coolifyfqdn, genericurl). - The
*mapping, the workspace default. - Every monitor in the workspace.
curl -X PUT https://upbutler.com/api/v1/deploy-mappings/acme-web \
-H "Authorization: Bearer $UPBUTLER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"tags": ["web"], "monitorIds": ["mon_0n3qc1a2b3c4d5e6f7g"], "minutes": 15}'| Field | Type | Description |
|---|---|---|
projectrequired | string | Project / service / site / repository name as the provider sends it (case-insensitive). "*" = the workspace defaultmax length 120 |
monitorIds | string[] | Monitors to watch after a deploy of this projectmax 200 items |
componentIds | string[] | Status page components; their monitors are watchedmax 100 items |
tags | string[] | Watch every monitor carrying one of these tagsmax 20 items |
guard | boolean | false = record the deploy marker but start no guard |
minutes | integer | Guard window for this projectmin 1 · max 120 |
Preview the result for a project before the next deploy:
curl "https://upbutler.com/api/v1/deploy-mappings/resolve?project=acme-web&url=https://acme.com" \
-H "Authorization: Bearer $UPBUTLER_API_KEY"
# → { "source": "mapping", "monitorIds": ["mon_…", "mon_…"], "scope": "targeted", "guard": true }Monitors that are already failing when the guard starts never count as regressions, and at most 20 guards watch at once per workspace; a deploy beyond that is still marked, and the delivery log says the guard was skipped.
Signature verification
The token in the URL is enough to authenticate a delivery. If you also store a provider's signing secret on the hook, deliveries from that provider must carry a valid signature, and anything else is rejected with 401.
| Provider | Checked |
|---|---|
| Vercel | x-vercel-signature: HMAC-SHA1 of the raw body with the webhook secret |
| GitHub | X-Hub-Signature-256: sha256= + HMAC-SHA256 of the raw body |
| Netlify | X-Webhook-Signature: JWS (HS256) with iss: "netlify" and the SHA-256 of the body |
| Cloudflare | cf-webhook-auth equals the secret |
| Coolify | Nothing: Coolify sends no signature and no custom header. Leave its secret unset |
| Railway, generic | Authorization: Bearer <secret>, or x-upbutler-signature: sha256=<HMAC-SHA256 of the body> |
curl -X PATCH https://upbutler.com/api/v1/deploy-hooks/dhk_0n3qc1a2b3c4d5e6f7g \
-H "Authorization: Bearer $UPBUTLER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"secrets": {"github": "the-secret-you-typed-into-github"}}'Secrets are write-only and stored encrypted; the API only lists which providers have one (signatures). Send null to remove one.
API
curl -X POST https://upbutler.com/api/v1/deploy-hooks \
-H "Authorization: Bearer $UPBUTLER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Production deploys"}'{
"_id": "dhk_0n3qc1a2b3c4d5e6f7g",
"name": "Production deploys",
"url": "https://upbutler.com/hooks/deploy/dh_Zk3…", // shown once
"urlHint": "https://upbutler.com/hooks/deploy/dh_Zk3x1Q…",
"productionEnvironments": ["production", "prod", "live", "main"],
"includePreviews": false,
"guard": { "enabled": true, "minutes": 10 },
"signatures": []
}| Field | Type | Description |
|---|---|---|
name | string | Label, e.g. "Vercel production"max length 80 |
service | string | Per-service hook: treat every delivery as a deploy of this project, whatever the payload namesmax length 120 |
productionEnvironments | string[] | Environment names that count as production when the provider doesn't say (default production, prod, live, main)max 10 items |
includePreviews | boolean | Also mark and guard preview / staging deploys (default false) |
guard | object | Deploy guard started on each production deploy (default on, 10 minutes) |
guard.enabled | boolean | |
guard.minutes | integer | min 1 · max 120 |
secrets | map<string, string | null> | Signing secret per provider: vercel, github, netlify, cloudflare, railway, coolify, generic. Write-only. |
| Endpoint | MCP tool | Does |
|---|---|---|
GET /deploy-hooks | deploy_hooks_list | Hooks, the last accepted delivery and the last guard with its verdict |
POST /deploy-hooks | deploy_hooks_create | Create a hook; returns url once |
PATCH /deploy-hooks/:id | deploy_hooks_update | Name, guard window, previews, production environment names, signing secrets, service pin |
POST /deploy-hooks/:id/rotate | deploy_hooks_rotate | New URL; the old one stops working |
DELETE /deploy-hooks/:id | deploy_hooks_delete | Remove a hook and its delivery log |
GET /deploy-hooks/deliveries | deploy_hooks_deliveries | Recent deliveries: accepted, ignored, rejected, and why |
GET /deploy-mappings | deploy_mappings_list | Project → monitors mappings |
PUT /deploy-mappings/:project | deploy_mappings_put | Set one (idempotent) |
DELETE /deploy-mappings/:project | deploy_mappings_delete | Remove one |
GET /deploy-mappings/resolve | deploy_mappings_resolve | Dry run for a project name or URL |
GET /deploys/guards | deploys_guards_list | Guards newest first: did the last deploy break anything |
A hook pinned to one service treats every delivery as a deploy of that project, which suits providers that cannot name it. These tools are in the full MCP toolset, not in ?toolset=core.