SafeCommit
AAPI reference

Four calls, one guarantee

Quote a change to learn what it costs and how risky it is. Execute to authorize payment and run it. Poll for status. Roll back on demand. Every response is JSON; every failure carries a stable error.code.

  • POST/api/quotePrice a change, get its risk
  • POST/api/executeAuthorize payment and run it
  • GET/api/runs/:idPoll status and artifacts
  • POST/api/runs/:id/rollbackUndo on demand
  • GET/api/runsList the account's runs
  • GET/api/metaEnums and deployment info
01

Authentication

Agents authenticate with a token created on the tokens page. Send it as a bearer token. Each token carries its own spending policy: a per-run ceiling, a daily ceiling, a list of approved repositories, and whether production is permitted at all. Those limits are checked when you request a quote and again when you execute.

header
Authorization: Bearer sc_live_8f21_a1b2c3d4e5f6…
02

POST /api/quote

Describe the change. You get a price, a risk score, and a run_id held in quoted state. Nothing is applied and nothing is charged. Policy violations surface here rather than at execute time, so an agent learns it is out of bounds before doing any work.

request · migration
POST /api/quote
Authorization: Bearer sc_live_8f21_…
Content-Type: application/json

{
  "kind": "supabase_migration",
  "repository": "northwind/checkout-api",
  "target": "production",
  "summary": "Retire the legacy shipping address constraint",
  "payload": "ALTER TABLE public.orders\n  DROP CONSTRAINT orders_shipping_address_text_check;",
  "files_changed": 4,

  // Naming a database makes this real. Send it again to execute; only
  // host/database is stored. Omit it for a priced dry run.
  "database_url": "postgresql://user:pass@db.example.com:5432/postgres"
}
request · deployment
POST /api/quote
Authorization: Bearer sc_live_8f21_…
Content-Type: application/json

{
  "kind": "vercel_deployment",
  "repository": "northwind/web",
  "target": "production",
  "summary": "Ship the new checkout flow",
  "payload": "diff --git a/src/checkout.tsx …",

  // Both together make this real: SafeCommit promotes this deployment,
  // watches your live domain, and reassigns the alias back if it degrades.
  // GET /api/vercel/projects lists the ids.
  "vercel_project_id": "prj_VoeuNFmEB2v9nURe…",
  "deployment_id": "dpl_7zvchYYYtEr1Nbwu…",

  // What healthy means. Omit and SafeCommit checks that / does not 5xx.
  // expect_body is the one that matters: an app that throws in a Server
  // Component still answers 200 with an error boundary rendered, and a
  // status check alone calls that healthy.
  "health_checks": [
    { "path": "/",            "expect_body": "Start your order" },
    { "path": "/api/health",  "expect_status": 200 },
    { "path": "/checkout",    "expect_body": "Pay now" }
  ],

  // Keep probing after the run returns. The in-request window is seconds
  // long, which only catches a deploy that is outright broken. A leak or a
  // cache that fills under load needs minutes.
  "watch_minutes": 30
}
request · combined release
POST /api/quote
Authorization: Bearer sc_live_8f21_…
Content-Type: application/json

{
  "kind": "combined_release",
  "repository": "northwind/checkout-api",
  "target": "production",
  "summary": "Add a fulfilment reference and the code that writes it",
  "payload": "ALTER TABLE public.orders ADD COLUMN fulfilment_ref text;",

  // All three. The schema and the code ship as one protected unit.
  "database_url": "postgresql://user:pass@db.example.com:5432/postgres",
  "vercel_project_id": "prj_VoeuNFmEB2v9nURe…",
  "deployment_id": "dpl_7zvchYYYtEr1Nbwu…",
  "watch_minutes": 60
}
response
201 Created

{
  "run_id": "run_9c2f5e10",
  "status": "quoted",
  "risk": {
    "level": "high",
    "score": 64,
    "factors": [
      { "code": "target_env",         "label": "Targets production",           "weight": 30, "detail": "…" },
      { "code": "sql_drop_constraint","label": "Drops a constraint",           "weight": 14, "detail": "…" },
      { "code": "no_down_migration",  "label": "No down migration supplied",   "weight": 12, "detail": "…" },
      { "code": "kind_migration",     "label": "Schema migration",             "weight":  8, "detail": "…" }
    ]
  },
  "quote": {
    "total_cents": 850,
    "lines": [
      { "code": "protection_base", "label": "Protected run",                    "amount_cents": 200 },
      { "code": "checkpoint",      "label": "Database checkpoint",              "amount_cents": 180 },
      { "code": "monitoring",      "label": "Post-apply monitoring (production)","amount_cents": 150 },
      { "code": "risk_surcharge",  "label": "Risk surcharge (high, 64/100)",    "amount_cents": 320 }
    ]
  },
  "expires_at": "2026-07-27T14:22:11.000Z"
}

Naming a target is what makes a run real. A migration needs database_url, a deployment needs vercel_project_id and deployment_id, and a combined_release needs all three. Send none and the run is priced, scored and rendered without touching anything, which is the way to dry-run a change. Mixing them is rejected, so a migration can never be executed as a promotion.

health_checksis what "healthy" means. Up to five paths, each with an optional expect_status and expect_body. They are probed on the candidate build, on production before the change, and on production after it. A 5xx or a transport error always counts as a failure; a 4xx does not, because that is usually the app correctly rejecting an unauthenticated probe. An expect_body that stops matching is a breach, which is the only way to catch a page that answers 200 while rendering an error screen.

watch_minutes outlives the request. A serverless function cannot hold a request open for half an hour, so the window inside the call is only seconds long, enough to catch a deploy that is outright broken. Setting this registers a background watch that keeps probing on a schedule and carries the same authority: a breach twenty minutes later still reassigns the production alias back to the checkpoint, rewrites the run, and notifies you. Poll GET /api/runs/:id to see the outcome change.

A combined release is expand-only. Schema and code ship together, and before anything commits SafeCommit proves the migration is backward compatible: nothing dropped, no column made required, no type narrowed, no rows lost. That proof is what makes the next part safe. If the new code misbehaves, the deployment is rolled back on its own and the schema is deliberately left in place, because the previous build still works against it. A migration that is not expand-only is refused before it commits and nothing is deployed, since there would be no safe way back. Ship the removal in a later release.

Risk scoring is a rule table, not a model. Each factor carries a fixed weight and an explanation, the weights sum to a 0–100 score, and the same input always produces the same result. Price is a pure function of that score, so an agent can predict its own spend.

03

POST /api/execute

Authorize payment from the credit balance and run the protected pipeline: checkpoint, gates, apply, monitor. The call returns once the run has reached a terminal state: committed, rolled_back, or failed. Send max_price_cents to refuse execution if the quote no longer matches what you agreed to.

request
POST /api/execute
Authorization: Bearer sc_live_8f21_…
Content-Type: application/json

{
  "run_id": "run_9c2f5e10",
  "max_price_cents": 1000
}
response
200 OK

{
  "run_id": "run_9c2f5e10",
  "status": "rolled_back",
  "outcome": { "committed": false, "rolled_back": true, "failed": false },
  "charged_cents": 850,
  "refunded_cents": 0,
  "credit_balance_cents": 5570,
  "checkpoint": {
    "id": "ckpt_k3f9a21b7c",
    "kind": "postgres_snapshot",
    "restore_command": "npx supabase db restore --project-ref … --snapshot ckpt_k3f9a21b7c",
    "retained_until": "2026-08-03T09:14:02.000Z"
  },
  "rollback": {
    "trigger": "monitor_breach",
    "reason": "p95 query latency reached 940ms against a 120ms threshold (baseline 31ms).",
    "restored_checkpoint_id": "ckpt_k3f9a21b7c",
    "duration_ms": 31420,
    "evidence": [ "…" ]
  }
}
committed

Gates green, monitors inside baseline. Charged in full.

rolled_back

Applied, a monitor breached, checkpoint restored. Charged in full; the rollback is the product working.

failed

A gate blocked it pre-apply. Nothing shipped. Charged in full: catching it is the outcome you were paying for.

04

GET /api/runs/:id

Full status with every artifact: test gates and their console output, the checkpoint, monitor readings against baseline, rollback evidence, and the timeline. Readable by any caller holding the run id, so a polling loop stays a single unauthenticated GET.

response
GET /api/runs/run_9c2f5e10

200 OK
{
  "run_id":  "run_9c2f5e10",
  "status":  "rolled_back",
  "terminal": true,
  "risk":     { "level": "high", "score": 64 },
  "tests":    [ { "name": "migration-applies", "status": "passed", "duration_ms": 8412, "output": [ "…" ] } ],
  "monitors": [ { "metric": "pg_p95_ms", "baseline": "31ms", "observed": "940ms", "status": "breached" } ],
  "rollback": { "trigger": "monitor_breach", "…": "…" },
  "timeline": [ { "at": "…", "phase": "checkpoint", "detail": "…", "level": "success" } ]
}
05

POST /api/runs/:id/rollback

Automatic rollback already covers monitor breaches. This is the escape hatch for everything the monitors cannot see: a product decision, a customer report, a downstream system that only noticed an hour later. Restores the same checkpoint and records the same evidence trail. Only valid on a run in committed state; reason is required.

request
POST /api/runs/run_4b81c7a2/rollback
Authorization: Bearer sc_live_8f21_…
Content-Type: application/json

{
  "reason": "Support reports checkout failing for EU customers since this shipped."
}
06

A complete agent loop

Everything an agent needs, end to end: price it, decide, authorize, and read back what happened.

agent.ts
import { setTimeout as sleep } from 'node:timers/promises'

const API = 'https://safecommit.dev/api'
const headers = {
  Authorization: `Bearer ${process.env.SAFECOMMIT_TOKEN}`,
  'Content-Type': 'application/json',
}

export async function protectedChange(change) {
  // 1. Price it. Nothing is applied and nothing is charged.
  const quote = await fetch(`${API}/quote`, {
    method: 'POST', headers, body: JSON.stringify(change),
  }).then((r) => r.json())

  if (quote.error) throw new Error(quote.error.message)

  // 2. Decide. The risk assessment is a rule table, not a model;
  //    the same input always produces the same score.
  if (quote.risk.level === 'critical') {
    return { skipped: true, reason: 'critical risk', risk: quote.risk }
  }

  // 3. Authorize. Returns once the run has reached a terminal state.
  const run = await fetch(`${API}/execute`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      run_id: quote.run_id,
      max_price_cents: quote.quote.total_cents,
    }),
  }).then((r) => r.json())

  if (run.status === 'rolled_back') {
    // The change went out and came back. Read why, then try something else.
    return { reverted: true, reason: run.rollback.reason }
  }

  if (run.status === 'failed') {
    // A gate caught it before apply. The logs say which one.
    const detail = await fetch(`${API}/runs/${run.run_id}`).then((r) => r.json())
    return { blocked: true, tests: detail.tests.filter((t) => t.status === 'failed') }
  }

  return { committed: true, checkpoint: run.checkpoint.id }
}
07

Vocabulary

status
quotedauthorizedrunningcommittedrolled_backfailed
risk.level
lowelevatedhighcritical
kind
vercel_deploymentsupabase_migrationcombined_release
target
productionstagingpreview

Machine-readable at GET /api/meta.

08

Errors

Every failure returns { "error": { "code", "message" } }. Validation failures add error.fields keyed by field name.

401unauthorizedNo bearer token and no session cookie.
403repository_not_allowedThe token is not approved for that repository.
403production_not_allowedThe token may not target production.
403per_run_limit_exceededThe quote is above the token's per-run ceiling.
403daily_limit_exceededThe token has spent its daily allowance.
402insufficient_creditThe account balance will not cover the run.
404run_not_foundNo run exists with that id.
409invalid_transitionThe run is not in a state that allows this call.
409price_above_limitThe quote exceeds the `max_price_cents` you sent.
422validation_failedA field is missing or malformed; see `error.fields`.
09

What actually happens to your infrastructure

Runs that name a target are executed against it. A deployment run reassigns your production alias through the Vercel API: the checkpoint is the build currently serving production, the gates are its real build state and real HTTP responses, the monitor readings are measured against your live domain, and a rollback is an alias reassignment back to the checkpoint.

A migration run connects to the database you name and applies your SQL inside a transaction. The result is inspected while it is still uncommitted, and the transaction is committed only if the checks pass. A rollback is ROLLBACK, so the database is exactly where it started and no other connection ever saw the change.

Leave those fields off and the run is simulated instead: it prices and scores the change and renders artifacts from src/lib/simulate.ts without touching anything. That is the mode to use for dry runs. Every run reports which one it was as execution_mode.

persistence: supabase · billing: live · execution: real