API reference
Whalesync API base URL, authentication, pagination, and endpoints for syncs, mappings, schema, records, and monitoring. Live Export has its own page.
The Whalesync API creates and monitors syncs and Live Exports programmatically. It is a REST API with JSON request and response bodies, API-key authentication, cursor pagination, and stable machine-readable error codes. Responses include URLs for the next available operations, and anything that requires a human comes back as a structured action to relay.
- Base URL:
https://api.whalesync.com/v1. Paths in this reference are relative to it:GET /sync/connectorsmeansGET https://api.whalesync.com/v1/sync/connectors. - Prefix: Everything under
/sync/belongs to the sync feature and is documented on this page. Everything under/live-export/belongs to Live Export and is documented in the Live Export API reference. - Spec: OpenAPI 3.1 at
https://api.whalesync.com/v1/openapi.json - Discovery:
https://api.whalesync.com/llms.txt - Companion pages: Agent quickstart walks through the typical sync creation flow · Error reference documents every error code
All request and response fields are snake_case. The one exception is the keys inside a side's auth: those are the credential field ids each connector declares (for example Postgres's connectionString), passed through verbatim, so GET /sync/connectors and auth always agree and nothing is translated.
Authentication
Section titled “Authentication”Authenticate every request with an API key in the Authorization header:
Authorization: Bearer ws_tok_XXXXXXXXXXXXXXXXXXXXXXXXCreate keys in Settings → API keys in the Whalesync app. You can have multiple named keys; each key's secret is shown once at creation. Keys can be revoked at any time in Settings, revocation is immediate, and each key shows its last_used_at.
There is no OAuth for the REST API itself. Browser sign-in without an API key is available through the MCP server.
Scopes
Section titled “Scopes”Each key has one of three scopes. From weakest to strongest they are read, operate, and readwrite, and each scope includes everything the weaker ones allow.
| Scope | Label in Settings | Allows |
|---|---|---|
read |
Read only | Reading syncs, mappings, status, operations, issues, and records. The one POST a read key may call is …/validate, which only checks a document and never writes. |
operate |
Read & operate | Everything read allows, plus running things: pausing and activating syncs, retrying issues, refetching records, and triggering and canceling Live Export runs. It can't change how anything is set up. Activating never starts a draft sync; that still needs a person in the app. |
readwrite |
Read & write | Everything, including creating, editing, and deleting syncs, mappings, and Live Exports. |
These endpoints need operate:
POST /sync/syncs/{sync_id}/pausePOST /sync/syncs/{sync_id}/activatePOST /sync/issues/{id}/retryPOST /sync/records/{record_id}/refetchPOST /live-export/live-exports/{id}/triggerPOST /live-export/runs/{id}/cancelA key below the scope an endpoint needs gets 403 insufficient_scope, and the message names both scopes: This API key has the "read" scope. The endpoint requires the "operate" scope. Scope is fixed when the key is created. Note that every scope, including read, exposes the contents of synced records (before/after values in the operations log).
Keys limited to one sync
Section titled “Keys limited to one sync”When creating a key in Settings → API keys, choose All syncs (the default) or One sync and pick the sync from the list. The key list shows which syncs each key can reach. Scope and the sync limit are independent, so a key can be read or operate and also limited to one sync.
A key limited to one sync:
- Sees only that sync.
GET /sync/syncsreturns just that sync. - Gets
404 not_foundfor any other sync, and for the issues, operations, and records of other syncs, as if they didn't exist. - Is refused with
403 sync_restricted_keyon endpoints that aren't about one existing sync: creating a sync (POST /sync/syncs) and every Live Export endpoint. - Can still list connectors with
GET /sync/connectors.
Conventions
Section titled “Conventions”Error responses
Section titled “Error responses”Every error has the same shape:
{"error": {"type": "invalid_request_error", "code": "incompatible_field_types", "message": "…", "doc_url": "https://docs.whalesync.com/api/errors#…"}}code values are stable. Branch on them, not on message. Some errors add a details object with machine-readable specifics, and requires_action errors add a required_action object for a human. Every code, and the type values that classify them, are documented in the Error reference.
Pending actions
Section titled “Pending actions”Some steps require a human signed in to Whalesync (connecting an app, starting a sync). A sync lists them in pending_actions:
"pending_actions": [ {"type": "user_authorization", "audience": "end_user", "action": "open_in_browser", "side": "right", "connector": "airtable", "url": "https://app.whalesync.com/syncs/edit/9f2c…/connect/right?connector=airtable", "instruction": "Give this link to a person. They sign in to Whalesync and connect Airtable in the browser — credentials entered there never pass through the API or an agent. Agents cannot complete this step."}]Calling an endpoint one of them is blocking returns a 409 with the same object as required_action:
{"error": {"type": "requires_action", "code": "auth_required", "message": "Both sides have to be connected before mappings can be read or written.", "required_action": {"type": "user_authorization", "audience": "end_user", "action": "open_in_browser", "side": "right", "connector": "airtable", "url": "https://app.whalesync.com/syncs/edit/9f2c…/connect/right?connector=airtable", "instruction": "Give this link to a person. …"}}}type is user_authorization (connect an app), user_confirmation (review and start the sync), or user_api_key (create the key itself, which only ever appears on a 401). Relay instruction and url to your user; don't fetch the URL. On Live Export, side is source or destination instead of left or right.
Pagination
Section titled “Pagination”List endpoints take limit and cursor, and return {"data": […], "has_more": true, "next_cursor": "…"}. Live Export lists accept a limit of 1 to 25, defaulting to 10.
Whalesync IDs are prefixed (sync_…, table_…, field_…). Schema objects also carry the remote_id from the connected app (Airtable tbl…/fld…, Postgres table names, and so on), and anywhere you reference a table or field you may use either form.
Responses include *_url fields (tables_url, fields_url, mappings_url, and more) pointing at the next valid steps for that resource's current state. Follow them instead of constructing URLs.
Idempotency
Section titled “Idempotency”Send an Idempotency-Key header on POST /sync/syncs, the sync mappings PUT, and POST /live-export/live-exports, the calls that create objects, to make retries safe. The Live Export mappings PUT doesn't accept it; use If-Match there. The other writes are already safe to repeat: pause, activate, and issue retry converge on the same state.
A retry with the same key and body replays the original response (marked with an Idempotent-Replayed: true header). The same key with a different body is a 400 idempotency_key_reused, and a retry that lands while the first request is still running is a 409 idempotency_key_in_use. Keys expire after 24 hours; a failed request releases its key so the retry runs fresh.
Rate limits
Section titled “Rate limits”120 requests per minute per key, shared across all endpoints; 429 with Retry-After when exceeded. RateLimit-* headers on every response show the budget.
Credentials are not returned
Section titled “Credentials are not returned”Credentials you send (connection strings, connector API keys) are never included in any response.
Connectors
Section titled “Connectors”GET /sync/connectors All connectors: type slug, auth method, credential fields, capabilities.GET /sync/connectors tells you how each connector authenticates: "auth": {"method": "oauth"} or "auth": {"method": "api_key", "fields": [{"id": "connectionString", "label": "Postgres connection string", …}]}. Each field's id is the key to send under a side's auth; send it exactly as given. Connectors your plan doesn't include are still listed, annotated with "available": false and the required plan.
The auth object is exactly method ("oauth" or "api_key") and fields (an array for api_key, null for oauth). An app that also offers a browser sign-in, such as Airtable, Webflow, or Notion, still lists as api_key here, because the token is the only method the API can use; omit auth and base to have a person connect it in the browser instead, where they may sign in.
{"type": "airtable", "auth": {"method": "api_key", "fields": [{"id": "apiKey", "label": "Personal access token", "optional": false, "placeholder": "pat...", "options": null, "help": {"label": "How to create a personal access token with the scopes Whalesync needs", "url": "https://docs.whalesync.com/connectors/airtable/personal-access-tokens"}}]}}Webflow and Notion list the same way, with one apiKey field each: a site API token or an internal integration secret.
A side keeps the method it was created with. A side connected by signing in can't be switched to a token through the API; create a new side instead.
Credentials
Section titled “Credentials”Each sync side holds its own credentials: inline auth for API-key connectors, given when the sync is created, or a browser sign-in for OAuth connectors, done by a human in Whalesync. Credentials are scoped to their sync. There are no connection endpoints, nothing to reuse across syncs, and nothing to clean up.
- Anything the API exposes about a credential (auth status, error codes) appears nested on the side object in sync responses, for example
"left": {"auth_status": "error", "auth_error": "invalid_credentials", …}. - A broken credential also opens an issue (raising the sync's
open_issuescount) whoseremediationexplains how to fix it. - To fix or rotate an API-key credential, PATCH the side with a new
auth, same shape as at creation:PATCH /sync/syncs/{sync_id} {"right": {"auth": {"connectionString": "…"}}}. This includes a token side of an app that also offers a sign-in, such as Airtable, Webflow, or Notion. A side a person connected by signing in cannot be rotated to a token; the PATCH fails withinvalid_authand a message saying the connection was set up with a sign-in. - To finish an API-key side that was deferred at creation (declared by connector alone, still
null), PATCH it withconnector,auth, andbasetogether while the sync is adraft. This fills the unconnected side over the API instead of waiting for a person — the alternative to relaying its connect link. OAuth sides can't be filled this way; they are always connected in the browser. - OAuth credentials can only be reconnected in the Whalesync app; the issue's
remediationsends your user there. - To see which remote bases or workspaces a side's credentials can reach (for example after an ambiguous
basename):
GET /sync/syncs/{sync_id}/sides/{side}/basesA sync connects two apps (its left and right sides) and keeps mapped tables in sync.
POST /sync/syncs Create a sync with both sides declared.GET /sync/syncs List syncs with status + open_issues.GET /sync/syncs/{sync_id} Full sync incl. per-side status and next-step URLs.PATCH /sync/syncs/{sync_id} Rename, amend a side while draft, update a side's auth.DELETE /sync/syncs/{sync_id} Stops the sync and deletes it. Returns {"id": "sync_…", "deleted": true}.Creating a sync
Section titled “Creating a sync”POST /sync/syncs{"name": "CRM sync", "left": {"connector": "airtable"}, "right": {"connector": "postgres", "auth": {"connectionString": "postgres://…"}, "base": "public"}}An API-key side takes a connector and, optionally, auth (inline credentials) and a base. On such a side auth and base travel together: send both, or neither.
- Both. The side is built during the create and its credentials are validated live.
- Neither. The side is deferred to a person, exactly like an OAuth side. It comes back
nulland the sync carries auser_authorizationpending action whose link sends someone to a Whalesync page where they enter the credentials in the browser and pick the base. This is not only for OAuth apps — any API-key side can be left for a person, so your user need not paste an app's credentials into an agent. Unlike an OAuth side, a deferred API-key side can still be finished over the API instead (see Credentials). - One without the other.
basewithoutauthfails with400 missing_auth;authwithoutbasefails with400 missing_base.
base is the ID of the base/workspace/schema in the connected app: an Airtable app… ID, a Webflow site ID, a Notion workspace ID, a Postgres schema name, and so on. An exact display name is accepted as a fallback when it's unambiguous. It resolves during the create itself: if it can't be found (or matches more than one base) the create fails with base_not_found / base_ambiguous, and the error's details.bases lists what the credentials can reach. Fix the request and retry.
An OAuth side takes only its connector: no auth, no base. Its browser sign-in requires a human, so the API leaves that side unbuilt. It comes back null and the sync carries a user_authorization pending action naming the app you asked for. Relay it to your user; the link opens that app's sign-in directly, they pick the base, and you poll the sync until the side appears. Apps that also take a pasted token, such as Airtable, Webflow, and Notion, are not among these: each takes its token inline as auth with a base, like any API-key connector (see Connectors), or can be left for a person like any other side.
- That page requires a normal Whalesync login as the account's owner. It is not a capability link, so it can't be used to attach someone else's account to your sync.
- Until both sides exist, mappings, schema, and activate calls answer
409 requires_action(auth_required) carrying the same action. - When only one side is built, it takes the left slot; the side your user connects becomes the right. Read the sync back once both exist and write mappings against that shape.
Sync statuses: draft → active ⇄ paused. A sync is draft until your user starts it in Whalesync, and any mappings edit returns it to draft. There is no separate health field. A sync with problems has a nonzero open_issues count (and auth_status: "error" on the affected side); read /sync/issues for the details.
A sync also says how it handles deletes. delete_approval is review_required, where a record that goes missing on one side waits for a person before the delete is applied to the other side, or auto_approve, where deletes are applied as they're detected. Under review_required, pending_deletes counts what is waiting right now and pending_deletes_url links to the queue. See Pending deletes.
Schema discovery
Section titled “Schema discovery”Table and field listings are fetched from the connected app on demand and cached. Each listing carries a top-level fetched_at saying when its cache was filled, and each table in the table listing also carries fields_fetched_at, null until you ask for that table's fields. Pass ?refresh=true to refetch; like in the app, refreshing needs the sync to be off, since an active sync's schema is kept fresh automatically. A first fetch can take a few seconds; if another request is already loading the same schema the endpoint returns 202 with {"loading": true} and a Retry-After header. Retry the same GET.
GET /sync/syncs/{sync_id}/tables?side=left Tables on one side.GET /sync/syncs/{sync_id}/tables/{table_id}/fields Fields with type metadata.Fields look like:
{"id": "field_7d…", "remote_id": "fldXYZ", "name": "Amount", "type": "currency", "read": true, "write": true, "required": false, "primary": false, "type_details": {"display_name": "Currency", "allows_multiple_values": false}}type_details carries whatever extra the connector says about that field type, and is null when there's nothing to add.
type is the connector's own field type. Every connector has its own set, so there is no global type enum. Use the capability flags (read, write, required, primary) to plan mappings, and POST …/validate to check compatibility: it is the authoritative check, and incompatible pairs come back with a specific code and message explaining why.
Some connectors sync a table through one of its views (Airtable, Salesforce). Those tables come back with a views object saying both whether you have to pick one and what the choices are:
"views": {"required": true, "options": [{"value": "ALL_RECORDS", "name": "All Records"}, {"value": "viwActive", "name": "Active"}]}Put the value in that side's view in the mappings document. views is null when the connector has no such concept, in which case view must be omitted. Both POST …/validate and the mappings PUT check this and name the legal values back to you: view_required when one is missing, view_not_found for a value the table doesn't have, view_not_supported for a view on a table with none. The PUT rejects before writing anything and repeats the detail in details.issues. Note that view is part of the full-replace document: leave it out of a later PUT and the previous selection is cleared, which fails the same way.
Mappings
Section titled “Mappings”The mappings document declares which tables and fields sync, and in which direction. It is read and written as a whole:
GET /sync/syncs/{sync_id}/mappings Current document (+ ETag).PUT /sync/syncs/{sync_id}/mappings Full replace. Supports If-Match and Idempotency-Key.POST /sync/syncs/{sync_id}/validate Check a document (body optional; defaults to the stored one).{"tables": [{ "left_table": "tblContacts", "right_table": {"create": {"name": "contacts"}}, "direction": "left_to_right", "right": {"record_delete_behavior": "do_nothing"}, "fields": [ {"left_field": "fldName", "right_field": {"create": {"name": "name"}}}, {"left_field": "fldEmail", "right_field": {"create": {"name": "email"}}} ]}]}- Table and field references are strings for existing objects (a
remote_id, a Whalesync id, or an exact name when it's unique on that side) or{"create": {"name": "…"}}to have Whalesync create the table or field on that side, typed from its mapped counterpart. Creation happens when youPUT; the response returns the document with real ids filled in, and retries safely adopt already-created objects. Only one side of a pair may be acreate. directionper table:left_to_right | right_to_left | two_way. Fields default to their table's direction. A read can additionally show"invalid"for a pair whose allowed directions have all been disabled by schema drift; fix the mapping and PUT back a real direction.- A side's
record_delete_behaviorsays what a delete on the other side does to this table's records:sync(the default) ordo_nothing, which is Delete protection for that table. - Full-replace semantics: anything omitted is unmapped, and omitted settings revert to defaults. Fetch-modify-put is the intended editing pattern.
validatereturns issues you can fix mechanically:
{"issues": [{"severity": "error", "code": "incompatible_field_types", "path": "/tables/0/fields/1", "side": "right", "message": "…"}]}Errors block activation (not saving a draft); warnings never block. There are two failure channels. A structurally malformed document (wrong types, an invalid direction, a create placeholder on both sides of a pair) is a 400 invalid_mappings naming the path. Everything semantic about a well-formed document (unknown or orphaned references, incompatibilities, unmapped required fields) comes back as a 200 with issues.
A PUT can also fail on a table the connector couldn't prepare for syncing — some connectors add a Whalesync ID column to a table first — which is a 400 table_setup_failed.
Read-only table settings
Section titled “Read-only table settings”A table pair carries two settings that are configured in the app and returned for reading only. Both are absent when there is nothing to report, and both are ignored on a PUT.
filteris the sync filter on the pair. Records that fail it sync in neither direction, and showis_filteredon their record status. It is a structured object:match(allorany) overconditions, which may nest further groups, plus asummarysentence safe to relay to a person. Filters are edited in the app, when you create the table mapping or later by pausing the sync. See Filters.advanced_settingslists the pair's advanced settings that are off their default:delay_before_syncing(changes wait out a quiet period),row_level_sync(only records whose sync-enabled field is true sync out of that table), andconnector_option(a per-table choice the connector asks for). Each entry names thesideit's on, itsvalue, and a one-sentencedescriptionsafe to relay.
Starting and stopping
Section titled “Starting and stopping”POST /sync/syncs/{sync_id}/activate Turn a paused sync back on.POST /sync/syncs/{sync_id}/pause Turn an active sync off.A sync only runs under a configuration a human has started in the Whalesync app. While a sync is draft (newly created, or edited since it last ran) there is nothing for the API to activate: the sync carries a user_confirmation pending action (and review_url, the same page). Relay it to your user; they review what was built (including record matching for tables that have data on both sides) and start the sync with a click. Poll the sync until status is active.
Calling activate on a draft sync returns the standard prerequisite error:
{"error": {"type": "requires_action", "code": "confirmation_required", "message": "This sync's configuration hasn't been reviewed yet. Your user needs to review and start it in Whalesync.", "required_action": {"type": "user_confirmation", "audience": "end_user", "action": "open_in_browser", "side": null, "url": "https://app.whalesync.com/syncs/9f2c…", "instruction": "Give this link to a person. …"}}}Once started, pause and activate toggle the sync freely from the API, until the mappings change, which returns it to draft.
Both endpoints need the operate scope.
Monitoring
Section titled “Monitoring”GET /sync/syncs/{sync_id}/status Live snapshot: pending pushes, polling state, change detection.GET /sync/operations?sync=… Record-level change log (also filter by table and since).GET /sync/operations/{id}GET /sync/issues?sync=… Open issues, with remediation guidance.GET /sync/issues/{id}POST /sync/issues/{id}/retry Clear an issue and retry the failed work. Needs operate.Operations and issues are their own collections rather than sub-resources of a sync, filtered by ?sync=, with the filter param named after the resource. The sync filter is required: both collections are always read one sync at a time. Every sync response links to its own slices via operations_url and issues_url.
An operation, as returned by GET /sync/operations/{id}:
{"id": "op_81…", "occurred_at": "…", "sync_id": "sync_9f…", "action": "updated", "trigger": "push", "table": {"id": "table_1d…", "name": "Contacts"}, "record": {"remote_id": "recABC", "name": "Jane Doe", "url": "https://airtable.com/…"}, "field_changes": [{"field": "Stage", "before": "Lead", "after": "Won"}]}trigger is push (Whalesync wrote the change) or poll (Whalesync detected it). The list returns the same object without field_changes and with a null record.url; both need the full stored log for each row, which is too expensive per page. Fetch an operation on its own for them. The operation's table carries no remote_id: the log is stored denormalized, so only the Whalesync id and the display name at the time are on the row.
An issue (note remediation, written to be actionable by an agent):
{"id": "iss_44…", "type": "connection", "code": "authentication_error", "sync_id": "sync_9f…", "connector": "airtable", "message": "Airtable authorization expired.", "remediation": {"explanation": "…", "suggested_action": "Reconnect Airtable…", "links": [{"name": "Reconnecting an app", "url": "https://…"}]}, "table": null, "record": null, "first_seen_at": "…", "last_seen_at": "…", "occurrence_count": 14}table and record are null on issues that aren't about one table or one record. A record-level issue carries that record's id, remote_id, name, and a link into the connected app, and the same issue also appears on the record's status document.
code is a broad category (authentication_error, connection_error, record_error, webhook_error, validation_error, sync_error), deliberately coarse: it does not reveal more about your credentials than that they failed. The specifics you should act on are in remediation and message. type is the area the issue is in (connection, record, webhook, sync_preview, or other) and is what ?type= filters on.
Records
Section titled “Records”GET /sync/records?sync=…&query=… Find records by remote id, rec_ id, or display text.GET /sync/records/{record_id} Everything known about one record's sync state.POST /sync/records/{record_id}/refetch Fetch the record again from the connected app. Needs operate.These answer questions about one record: why it hasn't arrived, whether something is blocking it, whether it's waiting on a delete review. Nothing in this API creates, edits, or deletes record data in a connected app. Writing records is what the sync itself does.
Search is capped rather than paginated. limit is at most 25, next_cursor is always null, and has_more: true means more matched than came back, so narrow the query. Exact id matches come first, and table restricts the search to one table. Each hit carries a url to that record's status document and a browser_url that opens the record in the connected app.
The status document gathers everything about one record:
leftandright, each null when that side has no copy of the record, withis_deleted,is_filtered(a sync filter currently excludes it),is_sync_disabled(turned off through a sync-enabled field), andqueued_operations.issues, the record's open issues. An issue here usually blocks it from syncing.pending_delete, a delete awaiting review, or null, pluspending_delete_eventsas history (detected,approved,ignored).operations, the most recent operations on the record with both sides merged, plusoperations_has_moreandoperations_urlfor the rest.
queued_operations distinguishes empty from unknown: [] means nothing is queued, null means the queue couldn't be consulted just then. Each entry has a type (push writes to the app, refetch re-reads the record, verify_delete checks that a record detected as missing is really gone) and its position in the queue.
record_id may be a rec_ id or the record's id in the connected app. For the latter, ?sync= is required, and ?table= picks a table when the same id exists in more than one; without it the call fails with 400 ambiguous_record.
POST /sync/records/{record_id}/refetch fetches the record again from the connected app and syncs whatever changed, like the Refetch record button in the app. It takes the same record_id, ?sync=, and ?table= as the status document. Send {"side": "left"} or {"side": "right"} to fetch one side only; with no body, every side that has a copy is fetched. The fetch is queued after any operations already queued for the record, and the response is a 202:
{"record_id": "rec_1d0b…", "sides": ["left", "right"]}A side with no copy of the record fails with 400 no_copy_to_refetch. Refetching needs the operate scope.
Pending deletes
Section titled “Pending deletes”GET /sync/pending-deletes?sync=… One sync's deletes waiting on a person, newest first.When a record goes missing on one side of a sync with delete_approval: "review_required", Whalesync holds the delete rather than applying it to the other side, so an outage, a pagination bug, or a mistaken write can't destroy data downstream. Delete approval queue describes the feature itself.
state defaults to awaiting_review; pass ignored for the deletes a person chose to keep the record for. Neither state is resolved — an ignored delete is dormant and stops being raised, and can be restored in the app. Standard limit and cursor pagination.
Each entry names the missing_side whose record disappeared, the delete_from_side the record would be deleted from, pending_since, ignored_at, and the record itself (null when the other side no longer holds a copy of it). record_url is that record's status document; review_url is the page where a person decides.
A sync that auto-approves deletes has no queue, and this endpoint answers 409 delete_approval_disabled. The sync's delete_approval field tells you which mode it's in before you call.
Planned additions
Section titled “Planned additions”Not yet in the API: webhooks, both per-sync record-change deliveries (webhook_url) and API-level events (/webhook_endpoints) · org-scoped API keys. Filters and advanced settings can be read on a mapped table pair but only changed in the app, and deciding a pending delete is deliberately app-only.