HTTP API
The web interface has no privileged path into Syncle. Everything it does goes through the REST API documented on this page, so anything you can do in the interface you can also do from a script. Every route lives under /api, requests and responses are JSON, and a session cookie is the only authentication.
Conventions
The origin that serves the interface serves the API too: the web app proxies /api through to the API server, so with a default install the base URL is http://localhost:3002/api. The API container itself is not published outside the Docker network unless you expose it, so go through the web origin — the installation page covers the ports.
Every successful response is wrapped in a data envelope; every error is an error object with a machine-readable code, a human message, and a details field that is null unless the error carries extra data:
$ curl http://localhost:3002/api/health
{"data":{"ok":true,"checks":{"database":"ok","redis":"ok"}}}
$ curl http://localhost:3002/api/connections
{"error":{"code":"UNAUTHORIZED","message":"Authentication required","details":null}}Scripts should branch on the code, not the HTTP status alone — two codes share status 400:
| Code | Status | Meaning |
|---|---|---|
BAD_REQUEST | 400 | Invalid body or query parameter (schema validation) |
QUERY_FAILED | 400 | The target database rejected a statement |
UNAUTHORIZED | 401 | No valid session cookie |
FORBIDDEN | 403 | Signed in, but not allowed to do this |
NOT_FOUND | 404 | No such resource |
CONFLICT | 409 | The request contradicts current state, such as deleting a connection a bridge still uses |
RATE_LIMITED | 429 | Too many login or setup attempts; wait and retry |
INTERNAL | 500 | Unexpected error |
UNSUPPORTED | 501 | The engine cannot do what was asked |
CONNECTION_FAILED | 502 | The target database could not be reached |
Authentication
The first account is created on first run with the setup token (the quickstart walks through that); it is an admin, and can make more, each with a role. Signing in sets db_session, a signed httpOnly cookie with SameSite=Lax, valid for the sessionTtlMinutes setting — one week by default. Keep it in a cookie jar and send it back on every call:
# sign in once, keeping the cookie
curl -c cookies.txt -H 'Content-Type: application/json' \
-d '{"username":"admin","password":"your-password"}' \
http://localhost:3002/api/auth/login
# every later call sends it back
curl -b cookies.txt http://localhost:3002/api/bridgesGET /api/version says which release is running — { version, source, node }, where source is build when the container image carried the version of the release tag it was built from, and package for a source checkout. It is behind the login on purpose: the public health probe says nothing beyond "up", and a version number is what someone scanning for a known flaw wants to read without asking. The same line is at the bottom of the Settings dialog.
Repeated failed logins lock the account out per IP and username and answer 429 with code RATE_LIMITED; setup attempts are limited per IP. Changing the password invalidates every outstanding session at once — the response re-issues the cookie for the session that made the change.
| Endpoint | Body | Purpose |
|---|---|---|
GET /api/auth/status | — | Returns { needsSetup, authenticated, user }; public, so a client can decide which screen to show |
POST /api/auth/setup | { username, password, setupToken } | Create the admin account on first run and sign in |
POST /api/auth/login | { username, password } | Sign in; sets the session cookie |
POST /api/auth/logout | — | Clear the cookie |
GET /api/auth/me | — | The signed-in user |
POST /api/auth/change-password | { currentPassword, newPassword } | Change the password; invalidates all other sessions |
Workspaces
Workspaces are the top-level container for connections and bridges. A default workspace always exists, so you only meet these routes once you create a second one.
| Endpoint | Purpose |
|---|---|
GET /api/workspaces | List workspaces |
POST /api/workspaces | Create one — { name, color? } |
GET /api/workspaces/:id | Fetch one |
PUT /api/workspaces/:id | Update name or color |
DELETE /api/workspaces/:id | Tears down every bridge inside — CDC slots dropped, watchers stopped, in-flight jobs canceled — then deletes the workspace |
Connections
A connection is a saved database config. Passwords, SSH secrets and connection strings are encrypted at rest and redacted in every response. Routes that touch data accept a ?database= query parameter to address one database on the server; leave it off to use the connection's default.
| Endpoint | Purpose |
|---|---|
GET /api/connections?workspaceId= | List connections |
POST /api/connections | Create one |
POST /api/connections/test | Test an unsaved config without storing it. Add ?from=:id when it is an edit of a saved connection: secrets sent back redacted are then taken from the stored copy. Returns { success, sshHostKey? } — the jump host's fingerprint, when a tunnel was used. The body takes tls (mode: disable | require | verify-ca | verify-full, with optional PEM ca, cert, key and a servername) and ssh.hostKey, as every connection body does |
GET /api/connections/:id | Fetch one |
PUT /api/connections/:id | Update; any pooled connection is evicted |
DELETE /api/connections/:id | Delete — answers 409 CONFLICT while any bridge still uses it as source or destination, or while Syncle still has a replication slot to remove through it (?force=true deletes it regardless; drop the slot on the server yourself first) |
POST /api/connections/:id/test | Test a saved connection |
Data operations
These are the routes behind the database workbench. What each engine supports varies — the driver's capability flags say which of them apply.
| Endpoint | Purpose |
|---|---|
GET /api/connections/:id/databases | List databases on the server |
GET /api/connections/:id/schema?database= | Introspect tables, columns, keys and indexes |
POST /api/connections/:id/browse?database= | Paged reads with filters and sort; limit 1–1000, default 100 |
POST /api/connections/:id/query?database= | Ad-hoc query — { statement, params }. On a connection saved with readOnly: true the statement runs only when every part of it is recognisably a read (otherwise 403), and the engine holds it to reading too; every route below that writes answers 403 on such a connection. See production, and read-only |
POST /api/connections/:id/rows | Insert a row |
PATCH /api/connections/:id/rows | Update a row, identified by its primary key values |
DELETE /api/connections/:id/rows | Delete a row |
DDL, backup and restore
| Endpoint | Purpose |
|---|---|
POST /api/connections/:id/ddl/database | Create a database — { name } |
POST /api/connections/:id/ddl/drop-database | Drop a database (pooled connections to it close first) |
POST /api/connections/:id/ddl/table?database= | Create a table from a column-definition list |
POST /api/connections/:id/ddl/drop-table?database= | Drop a table — { table, schema? } |
POST /api/connections/:id/ddl/truncate-table?database= | Truncate a table |
POST /api/connections/:id/backup?database= | Dump a database; returns { filename, format, content } |
POST /api/connections/:id/restore?database= | Restore from { content, format } |
Backups come in two formats: json, a portable dump any engine can read back, and sql, a DDL-plus-INSERT script for relational engines only — MongoDB and Redis support just json.
Drivers and settings
GET /api/drivers lists the five supported engines with their labels, default ports, capability flags and the fields their connection forms need — the interface builds its connection dialog from this list, and a script can do the same.
GET /api/settings returns the resolved app settings (stored overrides merged over defaults); PUT /api/settings applies a partial update. The configuration page documents each setting and its default.
GET /api/settings/encryption reports on a change of master key: { previousKeys, reencrypted, unreadable, checkedAt } — how many previous keys are still accepted, and what the last pass over the stored secrets moved to the current key or could not read with any. POST /api/settings/encryption/rotate runs that pass now (it also runs at every start); signed in only, not with an API key.
GET /api/settings/instances lists the API processes that are alive on this database — [{ id, startedAt, version, leader, self }] — which one leads, and which one answered. One entry is the usual answer.
Accounts
An admin's, signed in (never with an API key). GET /api/auth/users lists them — { id, username, role, disabledAt, lastLoginAt, createdAt, updatedAt }; POST /api/auth/users with { username, password, role } makes one (role admin, operator or viewer; default operator); PUT /api/auth/users/:id with any of { role, newPassword, disabled } changes one (a new password or a disable ends its sessions); DELETE /api/auth/users/:id removes one; POST /api/auth/users/:id/sessions/end signs it out everywhere. A 400 says why something is refused: the last admin that can sign in, or your own account. GET /api/auth/me now carries role, and a request an account's role does not allow is a 403 that names the role it takes. POST /api/auth/reset/request takes an optional { username }: whose code to print; unnamed, the first admin's.
The activity log
GET /api/audit — an admin's — answers { entries: [{ id, at, actor: { type, id, name }, action, target: { type, id, name } | null, details, ip }], next }, newest first. limit (1–200, default 50); before = the next of the page before, for the page after it; action (one of the names below), actor (an actor's name, exactly), targetId, targetType narrow it. What is recorded and why is in the self-hosting guide. Actions: auth.setup, auth.login, auth.login_failed, auth.logout, auth.password_changed, auth.reset_requested, auth.password_reset, user.*, api_key.*, settings.update, encryption.rotate, workspace.*, connection.* (create, update, delete, query, rows_*, ddl, restore), bridge.* (create, update, delete, import, bulk_create, clone, run, cancel, retry, skip, start, stop, dead_letters_*, verify, verify_cancel, schema_accepted, slot_surrendered, cleanups_retry, cleanup_dismissed), retention.run, alert_channel.*.
API keys
A script or a CI job should not be given the operator's password. Create a key in Settings › Security and send it as a bearer token:
$ curl -H "Authorization: Bearer syn_…" http://localhost:3002/api/bridges- The key is shown once, when it is created. What is stored is its SHA-256 — the key is 32 random bytes, so there is nothing to brute-force, and a copy of the metadata store yields no usable key. Lost keys are revoked and replaced, not recovered.
- Scope.
readmayGETand nothing else — not even aPOSTthat only reads, so that what a read key can do is answerable by looking at the verb.fullmay do what the operator can, except anything about credentials: no key of any scope can list, create or revoke keys, change the password, or end sessions (403). A leaked key cannot mint more keys or lock you out. - A key can be given an expiry; a revoked or expired key answers 401 at once. Revoked keys stay in the list, crossed out, with when they were last used — so "which key was that?" has an answer.
| Endpoint (signed in only) | Purpose |
|---|---|
GET /api/auth/api-keys | List keys: name, how each starts, scope, expiry, last use |
POST /api/auth/api-keys | { name, scope, expiresInDays? } — the answer carries key, this once |
DELETE /api/auth/api-keys/:id | Revoke |
Health, metrics and alerts
| Endpoint | Purpose |
|---|---|
GET /api/health | Is the API alive, and can it reach its metadata store? 200 — or 503 when the store is unreachable — with { ok, checks: { database, redis } }. It reports Redis without failing on it: what a container health check should use. Public |
GET /api/health/ready | Can it do everything? 503 unless the store and Redis answer (replays, polling bridges and the CDC spool run on Redis). What an uptime monitor or a load balancer should use. Public |
GET /api/metrics | Prometheus text format. Exists only when SYNCLE_METRICS_TOKEN is set (404 otherwise) and answers only to Authorization: Bearer <token>. See monitoring for what it exposes |
GET /api/alerts/channels | Alert channels. Secrets — everything after a URL's origin, a signing secret, header values, an SMTP password — come back as •••••••• |
POST /api/alerts/channels, PUT …/:id, DELETE …/:id | Create, replace, delete. On a PUT, a secret sent back as •••••••• keeps what is stored; a mask with nothing stored behind it is refused. Kinds: webhook (url, optional headers and secret), slack (url), email (smtp, from, to); each with name, enabled and events |
POST /api/alerts/channels/:id/test | Sends a test alert through the channel as stored; answers { ok, detail } and records the outcome on the channel |
Bridges
A bridge is the saved sync path, a job is one execution of it, and a delivery is one row or batch within a job — how bridges work covers the model. The routes below manage all three.
| Endpoint | Purpose |
|---|---|
GET /api/bridges?workspaceId= | List bridges |
GET /api/bridges/statuses?workspaceId= | Latest job status per bridge, in one call |
POST /api/bridges | Create a bridge; a draft job is prepared so the timeline shows the planned deliveries right away |
GET /api/bridges/:id | Fetch one |
PUT /api/bridges/:id | Update; a live watch or CDC listener is stopped first and restarted on the new config |
DELETE /api/bridges/:id | Full lifecycle teardown, then delete |
POST /api/bridges/bulk | One bridge per table, for many tables at once. Body { source: { connectionId, database?, schema?, tables: [...] }, destination: { connectionId, database?, schema?, tablePrefix? }, trigger: { kind: "cdc", startFrom?, slot? } | { kind: "replay" }, delivery? }. Each table is copied as it is into tablePrefix + table, keyed by its primary key; on PostgreSQL the bridges share one replication slot unless slot: "own". Answers { created: [{ id, name, table }], skipped: [{ table, reason }] } — a table with no primary key, or one that cannot be read, is skipped with the reason and does not stop the others. Nothing is started |
POST /api/bridges/:id/clone | A copy under a new name, credential included (it never leaves the instance); no job, no position |
GET /api/bridges/:id/export, GET /api/bridges/export?workspaceId= | One bridge, or a workspace's, as a document — no secret is in it |
POST /api/bridges/import | { document, connectionMap?, workspaceId? }. All or nothing. 400 with details.reason = "unresolved-connections" and the candidates when the file refers to a connection this instance has no obvious counterpart for |
The body of a create or an update is the bridge's whole configuration. Its source can carry filters (ANDed; operators eq, neq, gt, gte, lt, lte, contains, startsWith, endsWith, in, isNull, notNull), and its transform.columns is the ordered list of column transforms — at most 100, each one of the five kinds below. A kind the server does not know is refused with a 400, never ignored.
A CDC trigger is { "kind": "cdc", "operations": [...], "startFrom": "now" | "beginning" }. beginning copies the table, then follows its changes with nothing lost in between; now (the default, and what every bridge saved before this option existed is) follows changes only.
Filters and column transforms in a bridge body
{
"source": {
"kind": "table", "connectionId": "…", "table": "customers",
"filters": [
{ "column": "age", "operator": "gte", "value": 18 },
{ "column": "deleted_at", "operator": "isNull" }
]
},
"transform": {
"columns": [
{ "kind": "text", "column": "email", "op": "lower" },
{ "kind": "mask", "column": "email", "mode": "hash", "salt": "…" },
{ "kind": "mask", "column": "card", "mode": "partial", "keepStart": 0, "keepEnd": 4, "fill": "*" },
{ "kind": "cast", "column": "joined", "to": "date", "onError": "null" },
{ "kind": "default", "column": "tier", "value": "standard" },
{ "kind": "set", "column": "full_name", "template": "{{first}} {{last}}" }
]
}
}mask.mode is partial, redact, hash or null; cast.to is string, number, integer, boolean, date or json, and its onError is fail (the default), null or keep; text.op is trim, lower or upper. A column that a set or a default adds has to be named in transform.fields (when that list is pinned) and in a database target's mapping to be delivered, like any other column.
Jobs and deliveries
| Endpoint | Purpose |
|---|---|
POST /api/bridges/:id/preview | Render what would be delivered without delivering — body { sampleRow?, limit }, limit 1–10, default 3. For a database destination each target also reports exists and, when a run would create the table, plannedColumns (name, source type, target type, nullable, primary key); warnings names every column the target cannot hold faithfully. Read-only: nothing is created |
POST /api/bridges/preview | The same dry run for a bridge that is not saved — body { bridge, sampleRow?, limit }, where bridge is exactly what POST /api/bridges takes. This is what the builder's Dry run button calls. Nothing is stored, created or delivered |
POST /api/bridges/:id/jobs | Start a job; the body may carry resumeJobId, jobId (start a prepared draft) or retryFailedOf |
GET /api/bridges/:id/jobs | List jobs |
GET /api/bridges/:id/jobs/:jobId | Job detail — status, counts, cursor, error |
POST /api/bridges/:id/jobs/:jobId/retry-failed | Re-queue the same job to re-send only its failed deliveries. On a watch or CDC bridge whose failed rows are in the dead-letter queue, retries the queue instead — without stopping the bridge. A replay that had stopped at a failure (on failure: abort) then carries on from where it stopped: the rows after the failure were never read, and "completed" has to mean them too |
POST /api/bridges/:id/jobs/:jobId/deliveries/:sequence/retry | Retry one failed delivery, now; answers with the delivery as it is afterwards. Rows a live bridge set aside are retried from its dead-letter queue (re-read from the source); anything else is re-sent from what was captured. 400 unless the delivery is failed, 409 while the job is active |
GET /api/bridges/:id/jobs/:jobId/failures?format=csv|ndjson | The job's failed deliveries as a file download: sequence, operation, row count, the rows' keys, attempts, HTTP status, error, time and the payload that was sent. Streamed, so a job with very many failures is fine. CSV cells that would be read as a formula by a spreadsheet are neutralised with a leading apostrophe |
POST /api/bridges/:id/jobs/:jobId/cancel | Cancel; for a watch or CDC job this stops the listener and the job pauses, keeping its cursor |
GET /api/bridges/:id/jobs/:jobId/deliveries | Delivery list; filters status= (one of success, failed, skipped), from, to, offset, limit |
POST /api/bridges/:id/jobs/:jobId/skip | Skip queued deliveries by { sequences }; returns { skipped } |
The numeric delivery-list parameters must be non-negative integers — anything else is a 400, not an empty result. Skipping only affects deliveries that are still queued.
Dead letters
Rows a watch or CDC bridge could not deliver under onError: continue, kept in full. How bridges work explains when a row lands here and what a retry does.
| Endpoint | Purpose |
|---|---|
GET /api/bridges/:id/dead-letters | Newest first; filters status= (one of pending, resolved, discarded), offset, limit (default 100, at most 500). Returns { items, pendingEntries, pendingRows } — the two counts cover the whole bridge, not just the page |
POST /api/bridges/:id/dead-letters/retry | Body { ids?, force? }. Omit ids to retry every pending entry (up to 500 per call, oldest first). Returns { resolved, stillFailing, needsForce }. Safe while the bridge is running; a second call while one is in progress is a 409 |
POST /api/bridges/:id/dead-letters/discard | Body { ids? }. Marks pending entries as never to be delivered; returns { discarded }. The entries stay on record and their delivery stays failed |
Each item carries op (insert, update, delete, or null for a watch bridge), the source rows as read, error, attempts, the sequence of the delivery it came from, and needsForce. In rows, binary values are shown as a byte count rather than dumped; the stored copy is complete. An entry with needsForce: true was left alone by a plain retry because its source row is gone and the bridge does not propagate deletes — send force: true to write the recorded row anyway, or discard it.
Live listening
| Endpoint | Purpose |
|---|---|
POST /api/bridges/cdc/readiness | Probe whether a source can do CDC — { connectionId, database?, schema?, table, bridgeId? }. Pass bridgeId for a bridge that already exists, so that the replication slot it owns is not counted against it. Besides checks and instructions the answer may carry advisories: things that do not block a start but are worth knowing first. See CDC setup for what readiness means per engine |
POST /api/bridges/:id/watch/start | Start live listening; routed to CDC or polling watch by the bridge's trigger. If the bridge's place in the source's change log is gone, it answers 400 with details: { reason: "position-lost" }; send { "fromNow": true } to continue from the current position and accept the gap. On a CDC bridge whose trigger has startFrom: "beginning", add "recopy": true to copy the table again first; without it, "from now" copies nothing |
GET /api/bridges/:id/source-hold | What a CDC bridge is holding on its source — { kind, name, exists, active, retainedBytes, limitBytes, status, level, message, running }, or null when it holds nothing. For PostgreSQL, retainedBytes is the WAL pinned by the bridge's replication slot |
GET /api/bridges/:id/loops | Is this bridge tied to others in a ring — { guard, fedBy: [{ bridgeId, name }], feeds: [...], heldBack }. fedBy are the bridges that write the table this one reads, feeds the enabled watch and CDC bridges that read a table it writes (both not empty = a ring); heldBack counts the changes it recognised as Syncle's own and did not send round again. guard: false means loop prevention is switched off |
GET /api/bridges/:id/schema-drift | Has the source table changed since the bridge was set up — { baselineAt, checkedAt, drift: { added, removed, retyped } | null, missingUsed }. missingUsed lists the columns the bridge uses that the table no longer has: not empty means the bridge will not run. Reads the source; changes nothing |
POST /api/bridges/:id/schema-drift/accept | The table as it is now becomes what the bridge is built for. 400 with details.reason: "schema-drift" and details.missingUsed while the bridge still uses a column that is gone — edit the bridge instead; a PUT that no longer uses it accepts the table. The same reason comes back from watch/start when a live bridge is refused for it |
POST /api/bridges/:id/verify | Start a verification in the background. Body { mode: "verify" | "reconcile", deleteExtra?: boolean }; answers 202 with the verification. 400 (details.reason: "not-verifiable") for a bridge with a query source or an HTTP destination, 409 while one is running or a replay of the bridge is |
GET /api/bridges/:id/verifications[/:verificationId] | The last ten, newest first — or one. Each: { id, mode, status, sourceRows, sourceTotal, inSync, error, targets: [{ target, unsupported, notes, checked, missing, different, extra, fixed, removed, samples }] }. inSync is null until it has completed; extra is null when rows that are only in the destination were not looked for. Poll this for progress |
POST /api/bridges/:id/verifications/:verificationId/cancel | Stop one that is queued or running |
GET /api/bridges/:id/schedule | A replay bridge's schedule — { schedule, active, nextRuns, lastTickAt, lastOutcome, lastError }. active is whether it is registered and firing; lastOutcome is started, skipped-active (the run before was still going) or failed. The schedule itself is part of the bridge: set it with trigger.schedule on POST/PUT /api/bridges |
POST /api/bridges/schedule-preview | { cron, timezone } → { nextRuns: [5 ISO times] }, worked out by the library that fires schedules. 400 with the reason for a line or a zone that cannot be used |
POST /api/bridges/retention/run | Apply the delivery-history retention now instead of at the next hourly sweep. Answers { expiredDeliveries, overflowDeliveries, deadLetters, jobsEmptied, limited }; limited means the sweep stopped at its 500,000-row limit and the rest goes next time |
GET /api/bridges/cdc/cleanups | Replication slots of deleted or edited bridges that could not be dropped yet. They are retried every minute; POST /api/bridges/cdc/cleanups/retry tries now, and DELETE /api/bridges/cdc/cleanups/:id stops tracking one you removed by hand |
POST /api/bridges/:id/watch/stop | Stop listening and return the finalized job |
Events
GET /api/events is a stream of server-sent events: one message per thing that changed, as it changes. The web app listens to it instead of asking every few seconds whether a run has moved on, and anything else with a session or an API key may listen the same way (curl -N -H "Authorization: Bearer …"). Each message's data is one JSON event:
{ "type": "bridge.job", "bridgeId": "…", "jobId": "…", "at": "2026-09-23T10:00:00.000Z" }An event says only that something changed and what it was about — ask for the thing itself, the way you would have anyway. The kinds: bridge (bridgeId), bridge.job (bridgeId, jobId), bridge.deliveries (jobId), bridge.verification and bridge.deadLetters (bridgeId), connection and workspace (id), settings, users, apiKeys, audit, alertChannels. An id is present when the change was about one thing; a change to many at once names none. A burst of changes about one thing (the deliveries of a run, ten a second) is thinned to one event at once and one when the burst pauses. Events from every API process reach every stream, whichever process it was opened on.
The stream sends a comment every 25 seconds so that nothing between the two ends decides it is idle, and is ended by the server after 15 minutes; a browser's EventSource opens a new one by itself (it is told to wait 2 seconds), and any other client should do the same. Nothing is replayed on reconnection — ask again for what you care about, then listen. A proxy in front of Syncle has to pass the stream through unbuffered: the response says Cache-Control: no-transform and X-Accel-Buffering: no, which the usual ones honour.
Limits
- JSON request bodies cap at 50 MB — sized so backup and restore payloads, which carry whole dumps, fit.
- Ad-hoc query results cap at the
maxQueryRowssetting, default 5000 rows; the configuration page covers raising it globally or per connection. - Browse pages return at most 1000 rows per request (default 100).
- A single skip call accepts at most 10,000 sequence numbers.
