API reference
Paths, authentication, and conventions for the Whalesync API
The Whalesync API creates and monitors syncs 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. Other Whalesync features will get their own prefix under the same base URL.Spec: OpenAPI 3.1 at
https://api.whalesync.com/v1/openapi.jsonDiscovery:
https://api.whalesync.com/llms.txtCompanion 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
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 endpoint that creates an API key. API keys are created manually by a human in the app. A request without a key returns a 401 naming the smallest sufficient scope and carrying a key-creation link to give to a person.
Keys have one of two scopes:
readwrite: full access.read: monitoring only. Anything that changes something fails with403 insufficient_scope. The onePOSTareadkey may call is…/validate, which only checks a document and never writes. Note thatreadexposes the contents of synced records (before/after values in the operations log).
There is no OAuth for the REST API itself. Browser sign-in without an API key is available through the MCP server.
Conventions
Error responses
Every error has the same shape:
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
Some steps require a human signed in to Whalesync (connecting an app, starting a sync). A sync lists them in pending_actions:
Calling an endpoint one of them is blocking returns a 409 with the same object as required_action:
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.
Pagination
List endpoints take limit and cursor, and return {"data": […], "has_more": true, "next_cursor": "…"}.
IDs
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.
Links
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
Send an Idempotency-Key header on POST /sync/syncs and on the mappings PUT, the two calls that create objects, to make retries safe. 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
Per-key limits; 429 with Retry-After when exceeded. RateLimit-* headers on every response show the budget.
Credentials are not returned
Credentials you send (connection strings, connector API keys) are never included in any response.
Connectors
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.
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": "…"}}}.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):
Syncs
A sync connects two apps (its left and right sides) and keeps mapped tables in sync.
Creating a sync
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 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.
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
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.
Fields look like:
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.
Views
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:
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
The mappings document declares which tables and fields sync, and in which direction. It is read and written as a whole:
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:
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
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, before the first 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
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:
Once started, pause and activate toggle the sync freely from the API, until the mappings change, which returns it to draft.
Monitoring
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}:
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):
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
These answer questions about one record: why it hasn't arrived, whether something is blocking it, whether it's waiting on a delete review. They report state only. 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.
Pending deletes
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.
Approving or ignoring a delete is not in this API. The decision is irreversible, so it stays with a person in the app. Relay the entry's review_url.
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
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.
Last updated
Was this helpful?

