Standard Operating Procedure — API / Automation

REST API Client (Headless / Automated Migrations)

Step-by-step procedure for starting and monitoring a migration job through the REST API instead of the migration wizard — the path for CI/CD pipelines, scheduled jobs, and any script that needs to kick off a migration without a human clicking through Step 5.

Tool: Endrias Bridge · Interface: REST API (FastAPI) · Est. time: 5–10 minutes

📚 Overview & Scope

Endrias Bridge ships a local REST API (migrationbridge api) alongside the desktop wizard. It runs the exact same migration engine — same schema copy, same parallel data copy, same same-engine-family views/procedures/triggers step — just triggered by an HTTP request instead of a button click. There are two ways to drive it:

In the desktop app: the API Client tab (added in v1.3.43) lets you start/stop the server and send requests without leaving the GUI — this SOP mostly walks that path. From a script: the same server also has an auto-generated interactive Swagger UI at /docs, and can be called directly with curl/requests/any HTTP client — see the curl reference below.

🔗 Endpoints Reference

EndpointWhat it does
GET /healthLiveness check — returns {"status": "ok"} if the server is up.
POST /replicationsStart a migration job from a connection-string JSON body. Returns a job_id immediately (202 Accepted) — the job runs in the background.
GET /jobsList every job the server knows about (this process's lifetime only — no persistence across restarts).
GET /jobs/{job_id}Poll one job's state (queued/running/done/failed/stopped), progress %, full log, and final report.
DELETE /jobs/{job_id}Request cancellation of a running job (also usable via the native POST /jobs EB-native job format — /replications jobs share the same store).

Prerequisites

RequirementNotes
Endrias Bridge desktop app installed (or the Python package on a server)The API server is part of the same install — no separate download.
fastapi and uvicorn installedpip install "fastapi>=0.110" "uvicorn[standard]>=0.28" if running the CLI standalone. The desktop app's Start API Server button installs/uses the same environment it's already running in.
Source and target SQL Server connection details on handHost, port, database, username, password for both sides — the same details Step 5 of the wizard asks for.
1

Start the API Server

From the desktop app: open the API Client tab, set a port (default 8765), and click Start API Server. The status label turns green (Running on http://localhost:8765) once it responds to a health check — usually under two seconds.

From a terminal (useful for a server/CI box with no GUI at all):

python -m migrationbridge api --port 8765
The server binds to 0.0.0.0 by default — reachable from other machines on the network, not just localhost. Use --host 127.0.0.1 to restrict it to the local machine, and don't expose this port to the public internet as-is — there is no authentication on the API in this version.
2

Open the API Client

Once the status label shows Running, you have two equivalent ways to send requests:

3

Submit a Start Replication Request

Select Start Replication — POST /replications from the API Action dropdown. The Request pane pre-fills a template — edit the two connectionString values for your real source and target:

{
  "source": {
    "connectionString": "Data Source=HOST,1433;Initial Catalog=DB;User ID=USER;Password=PASSWORD;Encrypt=True;TrustServerCertificate=True",
    "encryptDataInTransit": "True",
    "changeTrackingMethod": "ChangeDataCapture",
    "changeTrackingRetentionPeriod": "3 DAYS"
  },
  "destination": {
    "connectionString": "Data Source=HOST,1433;Initial Catalog=DB;User ID=USER;Password=PASSWORD;Encrypt=True;TrustServerCertificate=True",
    "encryptDataInTransit": "True"
  },
  "replicationName": "my-job",
  "replicationMethod": "OneTime",
  "bulkCopyBatchSize": "100000",
  "parallelTablesLimit": "4",
  "pkRangeStreams": "4",
  "replicateTablesOnly": "False",
  "replicateViews": "True",
  "replicateStoredProcedures": "True",
  "replicateLoginsUsersAndRoles": "False",
  "addSourceObjectsToExclusionList": { "tables": "", "views": "", "storedProcedures": "" },
  "addNewTablesToExclusionList": "False",
  "dynamicTablesExclusionRules": "",
  "stagingBufferType": "TempDb"
}

Faster alternative to hand-typing connection strings: the API Client tab has the same Working database picker every other tab uses, fed by the active Migration Project's configured source/target databases. Pick a database pair there, then click Fill from Selected Project to build both connectionString values automatically instead of typing them by hand — only the job-level options (parallelTablesLimit, exclusions, etc.) are left for you to adjust.

changeTrackingMethod is informational only — this endpoint always performs a one-time full seed regardless of what's set here. changeTrackingRetentionPeriod (e.g. "3 DAYS") is applied for real, but not until the stream actually starts: it's carried on the seed job and applied via sp_cdc_change_job when you later call POST /replications/{job_id}/stream (Step 5) — the connecting login needs db_owner on the source for that call to succeed; if it doesn't, the stream still starts, with a warning in its log instead of the retention change. Once the seed job reaches state done, call Step 5 to start continuous CDC streaming for cutover.

Click Submit Request. The Response pane returns immediately with a job_id — the migration itself keeps running in the background:

HTTP 202

{
  "job_id": "b6ac19bf-f16b-48d1-963d-25d7cfff37a8",
  "replicationName": "my-job",
  "resume": false,
  "notes": []
}
A non-empty notes array means part of the request was accepted but isn't wired up yet (e.g. requesting continuous CDC, or a login/role replication flag) — read it, it tells you exactly what didn't happen and why. See Known Limitations.

Migrating multiple databases in one request

Add a databases list to migrate several databases from the same source server to the same target server in one call — one job is started per database, mirroring what the desktop app's Projects tab does for a batch. When set, the Initial Catalog in both connection strings is ignored and replaced with each name in the list:

{
  "source": {"connectionString": "Data Source=src-host,1433;Initial Catalog=ignored;User ID=USER;Password=PASSWORD;Encrypt=True;TrustServerCertificate=True"},
  "destination": {"connectionString": "Data Source=tgt-host,1433;Initial Catalog=ignored;User ID=USER;Password=PASSWORD;Encrypt=True;TrustServerCertificate=True"},
  "replicationName": "batch-job",
  "databases": ["Sales", "Inventory", "Billing"]
}

The response returns a map instead of a single ID — poll each one individually with Job Status:

{
  "job_id": null,
  "job_ids": {"Sales": "...", "Inventory": "...", "Billing": "..."},
  "replicationName": "batch-job",
  "resume": false,
  "notes": []
}
Source and destination must be different servers (or different database names) — this is enforced. The engine truncates the target table, then reads from the source; if source and destination resolve to the literal same database, the truncate deletes the only copy of the data before anything is read back. The API rejects a request with HTTP 400 if source and destination resolve to the same server + database name — in batch mode this means source and destination must be different servers entirely, since every entry in databases uses the same name on both sides.

Migrating several differently-named databases (separate calls)

The databases list only helps when every database keeps the same name on both source and destination. When source/destination database names don't match per pair — a common real-world shape, e.g. environment-suffixed database names — submit one independent POST /replications call per database instead, each with its own full connection strings:

{
  "source": {"connectionString": "Data Source=src-host,1433;Initial Catalog=db1;User ID=USER;Password=PASSWORD;Encrypt=True;TrustServerCertificate=True"},
  "destination": {"connectionString": "Data Source=tgt-host,1433;Initial Catalog=z1;User ID=USER;Password=PASSWORD;Encrypt=True;TrustServerCertificate=True"},
  "replicationName": "db1-to-z1"
}
{
  "source": {"connectionString": "Data Source=src-host,1433;Initial Catalog=db2;User ID=USER;Password=PASSWORD;Encrypt=True;TrustServerCertificate=True"},
  "destination": {"connectionString": "Data Source=tgt-host,1433;Initial Catalog=z2;User ID=USER;Password=PASSWORD;Encrypt=True;TrustServerCertificate=True"},
  "replicationName": "db2-to-z2"
}
{
  "source": {"connectionString": "Data Source=src-host,1433;Initial Catalog=db3;User ID=USER;Password=PASSWORD;Encrypt=True;TrustServerCertificate=True"},
  "destination": {"connectionString": "Data Source=tgt-host,1433;Initial Catalog=z3;User ID=USER;Password=PASSWORD;Encrypt=True;TrustServerCertificate=True"},
  "replicationName": "db3-to-z3"
}

Each call returns its own independent job_id — track and poll them separately, same as any single-database request. This is also the right pattern when source and destination are the same server pair for every database but you want per-database control over options like replicateTablesOnly or table exclusions, since databases mode applies the same options to every entry in the list.

Validated: three independent POST /replications calls fired concurrently (one per database, distinct source and destination names per pair) all completed successfully with no cross-contamination — each target database ended up with exactly its own source database's rows, nothing else's. Confirmed August 11 2026.
4

Poll Job Status

Copy the job_id from Step 3 into the Job ID field, switch the API Action dropdown to Job Status — GET /jobs/{job_id}, and click Submit Request again — repeat every few seconds until state is done or failed.

stateMeaning
queuedAccepted, hasn't started on its worker thread yet.
runningIn progress — check progress (0–100) and the tail of log.
doneFinished with no table-level errors.
failedFinished, but at least one table (or the job itself) hit an error — see report.results for which.
stoppedCancelled via DELETE /jobs/{job_id} before it finished.

Resuming an interrupted job (crash, power loss, closed window)

If the machine running migrationbridge api loses power, the process gets killed, or the window it was running in gets closed, the job dies — but its progress isn't lost. Each table's PK-range checkpoint is written to disk after every committed batch, not held only in memory, so it survives the process itself dying.

  1. Start migrationbridge api again (or the desktop app's API Client tab → Start API Server) — the old job_id is gone along with the process; that's expected, checkpoints don't depend on it.
  2. Resubmit the exact same request you used originally — same source database name(s), same target host/database — with "resume": true added:
{
  "source": {
    "connectionString": "Data Source=HOST,1433;Initial Catalog=DB;User ID=USER;Password=PASSWORD;Encrypt=True;TrustServerCertificate=True",
    "encryptDataInTransit": "True",
    "changeTrackingMethod": "ChangeDataCapture",
    "changeTrackingRetentionPeriod": "3 DAYS"
  },
  "destination": {
    "connectionString": "Data Source=HOST,1433;Initial Catalog=DB;User ID=USER;Password=PASSWORD;Encrypt=True;TrustServerCertificate=True",
    "encryptDataInTransit": "True"
  },
  "replicationName": "my-job",
  "replicationMethod": "OneTime",
  "bulkCopyBatchSize": "100000",
  "parallelTablesLimit": "4",
  "pkRangeStreams": "4",
  "replicateTablesOnly": "False",
  "replicateViews": "True",
  "replicateStoredProcedures": "True",
  "replicateLoginsUsersAndRoles": "False",
  "addSourceObjectsToExclusionList": { "tables": "", "views": "", "storedProcedures": "" },
  "addNewTablesToExclusionList": "False",
  "dynamicTablesExclusionRules": "",
  "stagingBufferType": "TempDb",
  "resume": true
}

The response echoes "resume": true back immediately, so you can confirm at submission time — before waiting for the job to reach done — that it was accepted as a resume rather than silently treated as a fresh start:

{
  "job_id": "9f2a7c1e-...",
  "replicationName": "my-job",
  "resume": true,
  "notes": []
}

This is the same full request you'd use to start the job — every field carries over unchanged, with "resume": true added at the end. source, destination, and replicationName must be byte-for-byte identical to the original submission (see the warning below); the rest of the options don't affect checkpoint lookup and can differ if needed.

In databases batch mode, add "resume": true at the top level alongside "databases": [...] — it applies to every database in the list. Confirm it actually took effect by checking the new job's log for [info] Resuming from checkpoint (rather than [info] Cleared checkpoint file for fresh start, which means it started over).

The source database name and target connection must match the original run exactly. The checkpoint file is looked up by those two things — a different name, host, or target database won't find it, and the job will silently behave like a fresh start instead of resuming. Databases that never got as far as their first checkpoint have nothing to resume from either way, so resume: true is harmless for them — they just run as if it were a fresh submission.
As of v1.3.66: a resumed job no longer recreates the target schema, so tables the interrupted run already copied are left alone instead of being dropped and rebuilt from scratch. Resume now also skips (rather than attempts and fails) any table whose foreign-key parent didn't finish copying this run — you'll see a line like SKIPPED — FK parent table(s) failed to copy (...); will be picked up on next Resume. That's expected: fix whatever made the parent fail (check its own log line above the SKIPPED one), then resubmit with "resume": true again and the skipped child will be retried along with it.
5

Watch It Live on Migration Health

While a job is running, switch to the desktop app's Migration Health tab — it shows the same live per-table progress dashboard whether the migration was started from the Migrate tab or, like here, the REST API. No extra setup: the dashboard polls the local API server automatically whenever it's running.

Migration Health Monitor showing five tables migrating live
Five tables migrating — status badge, per-table list, elapsed time, and throughput, driven by an API-triggered job.
6

Read the Result

The full response includes log (every line the migration engine printed, same content you'd see in the wizard's Migration Log tab) and report (structured per-table results). For a script, the useful check is usually:

import json, urllib.request

with urllib.request.urlopen(f"http://localhost:8765/jobs/{job_id}") as r:
    d = json.load(r)

if d["state"] == "failed":
    errors = [x for x in d["report"]["results"] if x["status"] == "error"]
    raise SystemExit(f"{len(errors)} table(s) failed: {errors}")

Continuous CDC streaming after the seed

Once a seed job reaches state: "done", start continuous CDC streaming for that same source/target pair with POST /replications/{job_id}/stream — no request body needed. This requires CDC enabled on the source tables (EXEC sys.sp_cdc_enable_db, then EXEC sys.sp_cdc_enable_table ... @supports_net_changes = 1 per table — tables without it are skipped and logged, not an error):

curl -X POST http://localhost:8765/replications/<job_id>/stream
# -> {"stream_job_id": "...", "notes": []}

The stream is tracked as its own job — poll GET /jobs/{stream_job_id} the same way, except it stays in state running indefinitely (it doesn't resolve to done on its own). The log shows applied changes as they happen:

CDC: 3 change(s) applied (total 3), rate 0.5/s, LSN 0000002d000006180003

Stop it with DELETE /jobs/{stream_job_id} — but only once lag is at zero, per the cutover procedure below. Stopping earlier means whatever changes hadn't been applied yet are lost, same as stopping the desktop app's CDC Stream early.

Cutover after the stream is running: this endpoint starts and stops the stream — the actual cutover sequence (stop application writes, wait for lag to reach zero, final row count check, stop the stream, flip connection strings) is the same procedure regardless of whether the stream was started from the API or the GUI, and is documented step by step in MigrationBridge_LiveMigration_Runbook.html (Pre-Cutover Checklist → Execute Cutover → Post-Cutover Validation). That part is deliberately not automated here — stopping application writes and flipping connection strings happen outside the database tool, on infrastructure this tool doesn't control.
7

Stop the Job / Server

To cancel a running job early: select Stop Job — DELETE /jobs/{job_id} with the Job ID filled in, and submit. To shut the server down entirely, click Stop API Server in the desktop app (or Ctrl+C the terminal if you started it from the CLI). Closing the desktop app also stops any server it started, so you don't end up with an orphaned background process.

8

Review the API Logs

Switch to the API Logs sub-tab (next to Request) to see every past request: time, method, path, status, and job ID, newest first. Use the DB (Job ID) dropdown to narrow the list to one job, and click a row to see its full redacted request body below the table (passwords always show as ***, even here).

The Retention dropdown controls how long entries are kept — 7, 14, or 28 days, applied immediately to the running server (no restart needed). It defaults to 7 days, or whatever api_log_retention_days is set to in migrationbridge.conf if you want that as the default on every server start instead of picking it each time.

💻 Calling It From a Script (curl)

Everything above works identically from any HTTP client. A full start-and-poll sequence with curl:

\
curl -X POST http://localhost:8765/replications \
  -H "Content-Type: application/json" \
  -d '{
    "source": {"connectionString": "Data Source=src-host,1433;Initial Catalog=MyDB;User ID=sa;Password=***;Encrypt=True;TrustServerCertificate=True"},
    "destination": {"connectionString": "Data Source=tgt-host,1433;Initial Catalog=MyDB;User ID=sa;Password=***;Encrypt=True;TrustServerCertificate=True"},
    "replicationName": "nightly-sync",
    "parallelTablesLimit": "4"
  }'
# -> {"job_id": "...", "replicationName": "nightly-sync", "resume": false, "notes": []}

curl http://localhost:8765/jobs/<job_id>
# -> poll until "state" is "done" or "failed"

Known Limitations

Field / capabilityCurrent behavior
changeTrackingMethod/replicationMethod: "ContinuousWithAutoSeeding" on the initial requestStill seed-only in the request itself — notes points you at POST /replications/{job_id}/stream to start continuous CDC streaming as a separate step once the seed is done. See "Continuous CDC streaming after the seed" above.
databases batch mode — concurrencyNo concurrency cap — every database in the list starts its job on its own thread immediately. Fine for a handful of databases; a very large batch (dozens+) would open that many concurrent connections/migrations at once with no throttling, unlike the desktop Projects tab's bounded thread pool.
replicateLoginsUsersAndRolesNot yet available through the API — use the desktop app's Users & Logins tab after the job completes.
dynamicTablesExclusionRules (regex-based exclusion)Not yet supported — use addSourceObjectsToExclusionList.tables with explicit table names instead.
replicateViews / replicateStoredProcedures set differentlyCopied as one group today (views + procedures + triggers + functions together) — setting only one to false is accepted but both are still copied unless both are false.
AuthenticationNone in this version — the API is unauthenticated. Bind to 127.0.0.1 (not 0.0.0.0) unless the machine is otherwise network-isolated.