Deploys
Preview checks
Your monitors already say what “working” means in production. Preview checks ask the same questions of every pull request's preview deployment and put the answer where the agent and the reviewer will see it, before the merge.
How it works
- Connect a deploy hook. The same URL that reports production deploys also receives the provider's preview deployments (Vercel previews, Netlify deploy previews, Cloudflare Pages previews, Railway PR environments, Coolify pull request previews, GitHub
deployment_statusfor any environment that is not production, or a generic body with"environment": "preview"). - For a finished preview deploy, UpButler takes the HTTP monitors mapped to that project and points each one at the preview: the origin is swapped, the path, query, method, body and assertions stay.
https://acme.com/checkoutbecomeshttps://acme-web-git-fix-acme.vercel.app/checkout. - Each check runs once, from the main region. A failing check is tried one more time after a few seconds, because previews answer their first request cold. Nothing touches the monitor itself: no state change, no incident, no alert, no check history.
- Every result is compared with the monitor's latest production result, and the run gets a verdict:
pass,failorerror. - The verdict is reported as a GitHub commit status, a signed
preview.verdictevent, a row on the Deploys page, and throughpreviews_getfor an agent that asks “is my PR green?”.
Preview checks are on by default as soon as a deploy hook is connected, on every plan (the Free plan includes 25 runs a month). There is nothing to configure for a Vercel, Netlify or Cloudflare Pages project whose monitors are on its production domain.
The hook answers a preview delivery at once; the checks run a moment later:
{
"ok": true,
"outcome": "preview",
"provider": "vercel",
"event": "deployment.succeeded",
"previewRunId": "pvr_0n3qc1a2b3c4d5e6f7g",
"previewUrl": "https://acme-web-git-fix-rounding-acme.vercel.app",
"checks": 4,
"verdictUrl": "https://upbutler.com/api/v1/previews/pvr_0n3qc1a2b3c4d5e6f7g"
}The verdict
Each check ends in one of five results. Only fail fails the verdict.
| Result | Means | Counts against the PR |
|---|---|---|
pass | The status code and every assertion hold on the preview. | No |
fail | Broken on the preview while production is healthy: wrong status, a failed keyword / JSON / header assertion, a timeout or a connection error. | Yes |
warn | Only a latency assertion failed, or a soft (severity: degraded) assertion, or the request was redirected off the preview. Previews are small and cold, so speed is not held against them. | No |
preexisting | The same monitor is down or degraded in production right now. Not this pull request's doing. | No |
blocked | The preview refused the request before your app saw it (Vercel Deployment Protection without a bypass secret). | No; all blocked = error |
Verdict pass: no check failed. fail: at least one did. error: the preview could not be tested at all; this says nothing about the change, and verdict.summary names what to set up.
The diff against production on each check has status (production vs preview status code, when they differ), latencyMs (both values and the delta), outcome, and failedAssertions (the assertions that fail on the preview). “Production” is the monitor's latest scheduled check, so no extra request goes to your live site. TLS-expiry and “slower than N ms = degraded” settings are left out on previews.
For agents: is my PR green?
After gh pr create or a push to a branch, poll previews_get (MCP) or GET /api/v1/previews/latest with pr, commit (a prefix is enough) or branch until state is no longer pending.
curl "https://upbutler.com/api/v1/previews/latest?pr=212" \
-H "Authorization: Bearer $UPBUTLER_API_KEY"{
"found": true,
"state": "fail", // pending | pass | fail | error | none
"summary": "1 of 4 checks fails on the preview of PR #212 (abc1234): Checkout: Expected status 200-299, got 500 Internal Server Error (production: 200).",
"next": "Do not merge. Fix what the failing checks name, push, and ask again for the new commit.",
"run": {
"_id": "pvr_0n3qc1a2b3c4d5e6f7g",
"state": "fail",
"previewUrl": "https://acme-web-git-fix-rounding-acme.vercel.app",
"provider": "vercel", "project": "acme-web",
"commit": "abc1234def5678901234567890abcdef12345678", "branch": "fix/rounding", "pr": 212, "repo": "acme/acme-web",
"verdict": { "status": "fail", "total": 4, "passed": 3, "failed": 1, "warned": 0, "preexisting": 0, "blocked": 0 },
"checks": [
{
"monitorId": "mon_0n3qc1a2b3c4d5e6f7g",
"name": "Checkout",
"method": "GET",
"url": "https://acme-web-git-fix-rounding-acme.vercel.app/checkout",
"result": "fail", // pass | fail | warn | preexisting | blocked
"preview": { "outcome": "down", "statusCode": 500, "latencyMs": 412, "error": "Expected status 200-299, got 500 Internal Server Error", "assertions": [] },
"production": { "state": "up", "outcome": "up", "statusCode": 200, "latencyMs": 131, "at": "2026-10-10T09:11:02.000Z" },
"diff": { "changed": true, "outcome": { "production": "up", "preview": "down" }, "status": { "production": 200, "preview": 500 },
"latencyMs": { "production": 131, "preview": 412, "delta": 281 }, "failedAssertions": [] },
"headersSent": false, "attempts": 2
}
],
"dashboardUrl": "https://upbutler.com/app/deploys/previews/pvr_0n3qc1a2b3c4d5e6f7g"
}
}state | What to do |
|---|---|
pending | Ask again in about 20 seconds. |
pass | The preview is green. |
fail | Do not merge. run.checks names what broke, with the production result next to it. Fix, push, ask again for the new commit. |
error | Not tested. Tell the human what summary says is missing. |
none | found: false. summary says why no run exists: no deploy hook, the preview is not deployed yet, the hook refused its URL, or the month's preview runs are used up. |
The Claude Code plugin tells the agent this by itself: after a push to a non-default branch or gh pr create it adds one note naming the exact previews_get call, when a deploy hook is connected and preview checks are on. previews_get is in the full MCP toolset; it is not part of ?toolset=core, but a core session can still call it by name.
Run it yourself
No deploy hook, or a provider that reports nothing? Check a preview URL on request, from CI or an agent. The call waits for the verdict by default, so a pipeline can gate on it:
curl -fsS -X POST https://upbutler.com/api/v1/previews \
-H "Authorization: Bearer $UPBUTLER_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"url\": \"$PREVIEW_URL\", \"project\": \"acme-web\", \"commit\": \"$GITHUB_SHA\", \"pr\": $PR_NUMBER}" \
| jq -e '.state == "pass"'| Field | Type | Description |
|---|---|---|
urlrequired | string (url) | The preview deployment to check, e.g. https://acme-web-git-fix-acme.vercel.app. Its host must match the provider pattern for the project or the allowed preview hostsmax length 2,000 |
project | string | Project / service name as in the deploy mappings; picks the monitors and binds the hostmax length 120 |
commit | string | Commit SHA (the full 40 characters to get a GitHub commit status)max length 80 |
branch | string | max length 200 |
pr | integer | Pull request numbermax 1,000,000,000 |
repo | string | GitHub repository as owner/name (default: the repository linked to the project)pattern ^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100}$ |
wait | boolean | string | Wait for the verdict (default true; false returns the queued run at once) |
GitHub commit status
Give UpButler a token and every run sets a commit status on the pull request's head commit: pending when the checks start, then success, failure or error, with a one-line description and a link to the run. Require the status in branch protection and a broken preview cannot be merged.
- GitHub → Settings → Developer settings → Fine-grained tokens → Generate new token.
- Repository access: only the repositories you deploy. Permissions → Repository → Commit statuses: Read and write. Nothing else.
- Paste it under Settings → Deploy hooks → Preview checks, or send it as
github.tokentoPATCH /previews/settings.
The token is stored encrypted and never returned. The status is named UpButler / preview (change it with github.context). It is sent with POST /repos/{owner}/{repo}/statuses/{sha}, so the run needs the repository (from the provider payload, a "repo" field, or the repo: of your upbutler.yaml) and the full 40-character commit SHA. If GitHub refuses, the settings page and the run say why in words.
Check Runs with the GitHub App
With the UpButler GitHub App installed on the repository, the verdict is a Check Run instead: no token to paste, and its summary is a table of every check with the preview result next to production. fail concludes as failure, a preview that could not be tested as neutral. The App also sets the commit status UpButler / preview status, so a branch protection rule can require it (or the Check Run UpButler / preview) without a token. If a token is saved, the status is set with that token and its name instead.
A failing preview goes back to the agent
When the verdict is fail on a pull request that Fix with Claude opened, the agent is paged with the failing checks and their production diff and pushes a fix commit to that pull request's branch. For every pull request: turn on Fix failing previews, or ask for one run with POST /previews/:id/fix.
The preview.verdict event
Every finished run emits preview.verdict. It is in the event stream and is delivered, signed, to every alert channel that lists it (or *). Channels do not get it by default: twenty pull requests a day should not land in the on-call channel unasked. Slack, Discord, Telegram and email channels get a readable message with the failing checks.
{
"id": "evt_0n3qc1a2b3c4d5e6f7g",
"type": "preview.verdict",
"timestamp": "2026-10-10T09:12:44.000Z",
"workspaceId": "ws_…",
"data": {
"preview": {
"id": "pvr_0n3qc1a2b3c4d5e6f7g",
"state": "fail",
"verdict": { "status": "fail", "summary": "1 of 4 checks fails on the preview of PR #212 (abc1234): …", "total": 4, "passed": 3, "failed": 1, "warned": 0, "preexisting": 0, "blocked": 0 },
"previewUrl": "https://acme-web-git-fix-rounding-acme.vercel.app",
"provider": "vercel", "project": "acme-web", "environment": "preview",
"commit": "abc1234def5678901234567890abcdef12345678", "branch": "fix/rounding", "pr": 212, "repo": "acme/acme-web",
"checks": [ { "monitorId": "mon_…", "name": "Checkout", "result": "fail", "url": "…/checkout",
"preview": { "outcome": "down", "statusCode": 500, "latencyMs": 412, "error": "Expected status 200-299, got 500 Internal Server Error" },
"production": { "outcome": "up", "statusCode": 200, "latencyMs": 131 },
"diff": { "changed": true, "status": { "production": 200, "preview": 500 }, "failedAssertions": [] } } ],
"url": "https://upbutler.com/api/v1/previews/pvr_0n3qc1a2b3c4d5e6f7g",
"dashboardUrl": "https://upbutler.com/app/deploys/previews/pvr_0n3qc1a2b3c4d5e6f7g"
}
}
}Which monitors run
- Only HTTP monitors (status, keyword, JSON, header and latency assertions). TCP, DNS, heartbeat, manifest, script, browser and AI monitors are skipped: they have no origin to swap, or carry their URLs inside code.
- By default, the monitors the deploy mapping gives for the project, narrowed to the ones on the project's production domain. A monitor of another origin in the same mapping (a payment provider, the docs site) is left out, because it could only fail against this preview.
- Opt a monitor out with
excludein the settings or the file, or by giving it the tagno-preview. - List monitors explicitly with
monitorIds(previews.monitorsin the file) when the automatic choice is not what you want. - Paused monitors never run. A run checks at most 40 monitors.
Security: what a preview may receive
- Request headers stay home. Monitors can store headers (API keys, cookies, basic auth). They are not sent to previews unless you turn on
sendHeaders. When a check fails with 401 or 403 for that reason, the run says so. Turn it on only when your previews are private and reviewed code is all that deploys there. - The preview host must be expected. A URL from a webhook is accepted only when it is on the provider's preview domain and carries the project's name (
acme-web-….vercel.app,…--acme-docs.netlify.app,….acme-landing.pages.dev,api-….up.railway.app; for Coolify, a subdomain of the application's own monitored domain such as212.acme.com), or when it matches yourallowedHosts, or when it comes out of your ownurlTemplate. Anything else is refused and the delivery log says why. A wildcard on a shared hosting domain must pin your own name:acme-*.vercel.appis accepted,*.vercel.appis not. - Production is never a preview. A “preview” whose host is one of your monitored production hosts is refused.
- No private addresses. Plain
http://, IP literals and hosts that resolve to private or link-local ranges are refused, exactly as for monitors (self-hosted installs opt in withALLOW_PRIVATE_TARGETS). - Stored URLs lose their query string, and the response body is not kept.
Vercel Deployment Protection
Vercel protects preview deployments by default: a request without a login gets a 401 page. UpButler recognises that page, marks the checks blocked and the verdict error instead of failing your pull request. To let the checks through:
- Vercel → Project → Settings → Deployment Protection → Protection Bypass for Automation → add a secret.
- Paste it under Preview checks → Vercel bypass secret (or
vercelBypassSecretinPATCH /previews/settings).
It is stored encrypted, never returned, and sent as the x-vercel-protection-bypass header only to previews reported by Vercel or served from *.vercel.app.
Where the preview URL comes from
| Provider | Preview event | URL |
|---|---|---|
| Vercel | deployment.succeeded with target: null | payload.deployment.url |
| Netlify | deploy ready, context deploy-preview or branch-deploy | deploy_ssl_url |
| Cloudflare Pages | EVENT_DEPLOYMENT_SUCCESS in ENVIRONMENT_PREVIEW | Derived: <first 8 of the deployment id>.<project>.pages.dev. Set a template if your project's subdomain differs from its name. |
| Railway | deployment SUCCESS in an ephemeral (PR) environment | The deployment's static URL when the payload has one; otherwise a template. |
| Coolify | deployment_success with a pull_request_id | preview_fqdn, when it is under the application's fqdn. Details |
| GitHub | deployment_status success for a non-production environment | environment_url |
| Workers Builds | build.succeeded with wrangler versions upload | Not in the event: use a template. |
| Generic | any body with environment other than production | previewUrl (then liveUrl, url) |
A URL template covers the rest and wins over the payload: https://pr-{pr}.preview.acme.dev. Placeholders: {pr}, {branch}, {commit}, {commit7}, {project}, {deploymentId}, {deploymentId8}. Values are reduced to letters, digits and dashes, and the domain itself must be literal, so a branch name cannot point the checks somewhere else.
curl -fsS -X POST "https://upbutler.com/hooks/deploy/<token>" \
-H "Content-Type: application/json" \
-d "{\"service\": \"api\", \"environment\": \"preview\", \"previewUrl\": \"https://pr-$PR.preview.acme.dev\",
\"commit\": \"$GIT_SHA\", \"branch\": \"$BRANCH\", \"pr\": $PR, \"repo\": \"acme/api\"}"Settings
In the dashboard: Settings → Deploy hooks → Preview checks. Through the API (only the fields you send change):
curl -X PATCH https://upbutler.com/api/v1/previews/settings \
-H "Authorization: Bearer $UPBUTLER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"allowedHosts": ["pr-*.preview.acme.dev"],
"exclude": ["mon_0n3qc1a2b3c4d5e6f7g"],
"github": { "token": "github_pat_…" }
}'| Field | Type | Description |
|---|---|---|
enabled | boolean | Run checks against preview deploys reported by a deploy hook (default true) |
monitorIds | string[] | Only run these monitors. Empty = automatic: the HTTP monitors mapped to the deployed project on its production domainmax 200 items |
exclude | string[] | Monitors never run against a preview (the monitor tag "no-preview" does the same)max 200 items |
sendHeaders | boolean | Send the monitors' stored request headers (API keys, cookies) to previews. Default false: a preview runs unreviewed pull request code |
allowedHosts | string[] | Extra preview hosts to trust, besides the provider pattern bound to the project: "*.preview.acme.dev", "acme-*.vercel.app"max 30 items |
urlTemplate | string | null | Where a preview lives when the provider payload does not say (Railway, Workers Builds, custom domains): "https://pr-{pr}.preview.acme.dev". Placeholders: {pr} {branch} {commit} {commit7} {project} {deploymentId} {deploymentId8}. null removes itmax length 300 · nullable |
vercelBypassSecret | string | null | Vercel "Protection Bypass for Automation" secret, sent as x-vercel-protection-bypass to Vercel previews only. Stored encrypted, never returned. null removes itmin length 8 · max length 200 · nullable |
github | object | Report the verdict as a GitHub commit status on the pull request |
github.token | string | null | Fine-grained GitHub token with "Commit statuses: Read and write" on the repository. Stored encrypted, never returned. null disconnectsmin length 20 · max length 400 · nullable |
github.context | string | Name of the commit status (default "UpButler / preview")max length 100 |
In upbutler.yaml:
# upbutler.yaml
version: 1
project: acme-web
monitors:
- id: homepage
url: https://acme.com/
- id: checkout
url: https://acme.com/checkout
keyword: "Pay now"
- id: admin
url: https://acme.com/admin
previews:
enabled: true # default
exclude: [admin] # login-only: makes no sense on a preview
sendHeaders: false # default: stored request headers stay home
# monitors: [homepage, checkout] # only these (default: automatic)
# allowedHosts: ["pr-*.preview.acme.dev"] # hosts beyond the provider's own pattern
# urlTemplate: https://pr-{pr}.preview.acme.devThe file's monitors and exclude lists replace only the entries of that project; the token and the bypass secret are workspace settings and are never written to the file.
Retries
One deployment is one run. Providers retry webhooks and send several events per deployment; they all find the same run (keyed on the provider's deployment id, an Idempotency-Key header, or project + commit + URL), spend one run of the allowance and produce one verdict. A new commit on the same pull request is a new deployment and a new run.
Plans
| Plan | Preview runs per month |
|---|---|
| Free | 25 (the 14-day trial has the Pro allowance) |
| Starter | 500 |
| Pro | 3,000 |
| Business | 20,000 |
A run is one preview deployment, however many monitors it checks. When the month's runs are used up, further previews are logged as ignored with that reason and no status is posted; nothing else stops working. GET /previews/settings returns usage.
API
| Operation | REST | MCP tool |
|---|---|---|
| The verdict for a PR, commit or branch | GET /previews/latest?pr=…, GET /previews/:id | previews_get |
| List runs | GET /previews | previews_list |
| Check a preview URL now | POST /previews | previews_run |
| Settings | GET / PATCH /previews/settings | previews_settings_get, previews_settings_update |
Runs are kept for 90 days.