← Migration Gate / API
Tokens

Drive Migration Gate from your own code

Everything the web page does is available over HTTP: post a database migration, get the same structured production-safety audit back. The natural use is a CI job that audits every migration a pull request adds and fails the build when verdict comes back block — the one value that means "running this as pasted, against a database of the stated size, with the stated downtime budget, would take the application down or lose data". The same call run over a directory of migrations gives you ten identical checks per file, diffable row by row.

The app slug is migration-gate. Every example on this page uses the app's own bundled example migration — the four-statement golang-migrate file that adds a NOT NULL column, builds an index and backfills 40M rows inside one transaction — so you can paste any of them and get a real answer.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }

Send your app slug as X-App-Slug: migration-gate and your token as Authorization: Bearer … on every call. error.details is free-form and endpoint-specific; the only field worth branching on is error.code.

Error codes

codestatuswhat to do
validation_error400The input object is missing a required field — migration is the usual one — or a field is the wrong type. prescan_facts must be an object with resources and flags arrays, not a bare array; dialect, scale and downtime must be one of their documented values.
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits. Call /estimate first, compare hold_credits against the balance from /me, and top up before submitting. The web app never submits into a 402: it disables the audit button and names the shortfall instead.
forbidden403The token is valid but not for this app, or a guest token tried a metered run. Check the X-App-Slug header, and sign in for a personal token.
not_found404Unknown job id, unknown collection, or the app slug does not exist. An audits collection that is not declared on the release you are calling also lands here.
conflict409The same Idempotency-Key was replayed with a different body. Bump the attempt suffix, or send the original input back unchanged.
rate_limited429Too many requests. Back off and retry; do not tight-loop the job poller — two seconds between polls is plenty.
internal5xxA server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice.

1. Get a token

The easiest route is the token page: it shows the token this browser already holds for migration-gate, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. You never need to open the developer console.

A guest token, minted by POST /guest, can call /me and /estimate. Auditing a migration is metered, so /run and /run-stream need a personal token from signing in — a guest token that posts a run gets a 403 forbidden. Each POST /guest also mints a new guest subject, which matters later: the audits collection is scoped to the calling subject, so a fresh guest token sees an empty history.

# A guest token is enough for /me and /estimate. Auditing a migration is metered
# and needs a personal token: open the token page and press "Sign in".
#
#   https://migration-gate.skillsafe.ai/tokens.html
#
# That page also gives you a ready-made shell export:
#   export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead:
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" -H "X-App-Slug: migration-gate"
# {"ok":true,"data":{"token":"...","guest_id":"..."}}

2. A tiny client

One helper that adds the headers, unwraps data and raises on error.

# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="migration-gate"
TOKEN="YOUR_TOKEN"        # from https://migration-gate.skillsafe.ai/tokens.html

call() {                  # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "X-App-Slug: $SLUG" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN" -H "X-App-Slug: $SLUG"
  fi
}

3. Check the session and the balance

GET /me tells you whether the token is a guest or a person, and what the balance is. subject_type is user for a personal token and guest for a guest one — branch on it before you spend a call finding out the hard way. Compare credits against hold_credits from the next step before you run, so a shortfall surfaces as your own clear message rather than a 402. The web app does exactly this: if the balance is under the reservation it disables the audit button, names the shortfall in credits and offers a top-up link instead of submitting.

call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
#
# subject_type is "guest" for a guest token; a guest can /estimate but not /run.
# credits are platform credits: 10,000 credits is one US dollar.

4. Price the run

POST /estimate is free, creates no job and charges nothing. The body is the input object itself — nothing wraps it. Use it to price the audit and to prove the app is bound to the model you expect: the reply names both the alias and the concrete model it currently resolves to.

hold_credits is a reservation, priced as though the audit ran to the full output cap. It is not the price. The finished job carries charged_credits, which is what you actually pay and is usually far lower. If the balance sits between min_credits and hold_credits the run still executes, with a reduced output cap, and the job comes back "truncated": true — treat that as an incomplete audit and top up rather than trusting a clipped verdict.

# The body of /estimate IS the input object. Nothing wraps it.
read -r -d '' INPUT <<'JSON'
{
  "migration": "-- file: 000004_add_display_name.up.sql\nBEGIN;\nALTER TABLE users ADD COLUMN display_name TEXT NOT NULL;\nCREATE INDEX idx_users_display_name ON users (display_name);\nUPDATE users SET display_name = username;\nCOMMIT;",
  "dialect": "postgres",
  "scale": "large",
  "downtime": "zero",
  "context": "users is about 40 million rows and takes constant writes. The app deploys an hour after the migration."
}
JSON

call estimate "$INPUT"
# {"ok":true,"data":{"hold_credits":1611,"min_credits":182,"model":"gpt-5.6-terra",
#                    "markup_bps":1000,"model_alias":"gpt-terra","sponsor_enabled":false,"byok":false}}

5. Run it, then poll the job

POST /run takes the same input object and returns {"job_id": "job_..."}. Poll GET /jobs/{job_id} until status is terminal. Always send an Idempotency-Key: a retry with the same key returns the same job instead of starting — and billing — a second one, which matters because a network blip mid-run is exactly when you want to retry.

The audit arrives as a string in output.output, not as a nested object: parse it a second time. Strip a leading ```json fence defensively before parsing, as the web app does — the contract forbids fences, but a defensive strip costs one line and saves a failed build.

# Idempotency-Key makes a retry safe: the same key returns the same job
# instead of starting - and billing - a second one.
KEY="mg:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-App-Slug: $SLUG" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | jq -r '.data.job_id')

# Poll until terminal.
while true; do
  J=$(curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN" -H "X-App-Slug: $SLUG")
  ST=$(printf '%s' "$J" | jq -r '.data.status')
  [ "$ST" = "succeeded" ] || [ "$ST" = "failed" ] && break
  sleep 2
done

# The audit itself is a JSON string inside the job output.
printf '%s' "$J" | jq -r '.data.output.output' | jq '.verdict, .headline'

6. Stream it instead

POST /run-stream is the same call over server-sent events. Deltas arrive as they are generated, which is what drives the progress card in the web app: it watches for the section keys ("statements", "checks", "findings", "safe_migration") appearing in the stream and advances a named stage as each one lands. Concatenate every text delta and parse the result exactly as in step 5. The same Idempotency-Key rules apply.

# Server-sent events. Same body, same Idempotency-Key; the deltas arrive as
# they are generated, which is what the web app's progress card is driven by.
curl -sS -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-App-Slug: $SLUG" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -H "Accept: text/event-stream" \
  -d "$INPUT"

# event: delta   data: {"text":"{\"audit_name\":\"add display_name"}
# event: job     data: {"job_id":"job_..."}
# event: done    data: {"status":"succeeded","charged_credits":934}

7. Gate a pull request on the verdict

The reason to drive this from code rather than from the page: a migration that reaches production unreviewed is the failure this app exists to prevent, and CI is where that gets caught. The script below audits a migration file, prints every statement that is not safe, and exits non-zero when the verdict is block or any finding is critical.

Two notes before you wire it in. First, this run is metered — a gate on every push to a busy repository spends real credits, so scope it to pull requests that actually touch your migrations directory. Second, the audit is a review of pasted text: it is a good gate and a bad oracle. Keep it advisory on caution and blocking only on block until you trust it on your own schema.

#!/usr/bin/env bash
# migration-gate-ci.sh - fail the build on a blocking migration.
set -euo pipefail

SQL_FILE="$1"
INPUT=$(jq -n --rawfile m "$SQL_FILE" \
  '{migration: $m, dialect: "postgres", scale: "large", downtime: "zero",
    context: "CI gate on the migrations directory."}')

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" -H "X-App-Slug: $SLUG" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: mg:$(shasum -a 256 "$SQL_FILE" | cut -c1-16):a1" \
  -d "$INPUT" | jq -r '.data.job_id')

until [ "$(curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN" \
           -H "X-App-Slug: $SLUG" | jq -r '.data.status')" != "running" ]; do sleep 2; done

AUDIT=$(curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN" \
        -H "X-App-Slug: $SLUG" | jq -r '.data.output.output')

echo "$AUDIT" | jq -r '"\(.verdict | ascii_upcase): \(.headline)"'
echo "$AUDIT" | jq -r '.findings[] | select(.priority == "critical" or .priority == "high")
                       | "  [\(.priority)] \(.statement_ref) \(.problem)"'

VERDICT=$(echo "$AUDIT" | jq -r '.verdict')
CRIT=$(echo "$AUDIT" | jq '[.findings[] | select(.priority == "critical")] | length')
if [ "$VERDICT" = "block" ] || [ "$CRIT" -gt 0 ]; then
  echo "Migration Gate blocked this migration." >&2
  exit 1
fi