API (Beta)
Errors and rate limits
Handle OutboundSync API v1 errors: auth and scope gates, JSON 404s for unknown routes, 504 query time limits, rate limits, and retries.
When to use this guide: Use this reference when an OutboundSync API request fails, returns
401,403,404,429, or504, or needs retry logic.
OutboundSync API v1 uses standard HTTP status codes. Every error response is JSON with statusCode, error, and a human-readable message, including unknown routes and query timeouts.
Error response shape
Section titled “Error response shape”{ "statusCode": 401, "message": "Invalid API key", "error": "Unauthorized"}Treat message and the HTTP status code as the stable troubleshooting fields. On 400 validation errors, message can be an array of strings; join them.
If you ever get a response that isn’t JSON, it came from the hosting layer rather than the API (for example during a deploy or a platform incident). Treat it as a transient server error and retry with backoff.
Status codes
Section titled “Status codes”| Status | Meaning | Common cause | What to do |
|---|---|---|---|
200 |
Success | The key is valid and has at least one API-enabled connection. | Continue using the response. |
401 |
Unauthorized | Missing, malformed, revoked, rotated, or invalid API key. | Confirm the Authorization: Bearer osapi_... header and rotate the key if needed. |
403 |
Forbidden | Valid key, but API access, scopes, connection scope, Webhooks, or blocklist resync (canBlockList) block the route. |
See 403 forbidden below. |
404 |
Not found | The resource id doesn’t exist or isn’t visible to the key, or the path isn’t an API route. | Check the id and path. See 404 unknown route. |
409 |
Conflict | Sync retry is not eligible, missing context, or the CRM is not HubSpot/Salesforce; or a blocklist resync is already in progress / has no CRM source. | See POST /api/v1/syncs/:id/retry and POST /api/v1/blocklists/:id/resync. |
429 |
Too many requests | The IP or account exceeded its rate limit. | Wait Retry-After seconds before retrying. Throttle on X-RateLimit-Remaining to avoid it. |
500 |
Server error | Unexpected server-side failure. | Retry with backoff; contact support if it repeats. |
504 |
Query time limit exceeded | A heavy read (metrics, sync lists, prior outreach) ran past the 20-second database limit and was cancelled. | Narrow the from/to window or filter to one connection, then retry. See 504 query time limit. |
401 invalid API key
Section titled “401 invalid API key”OutboundSync returns a uniform 401 for missing, malformed, invalid, revoked, and rotated keys.
{ "statusCode": 401, "message": "Invalid API key", "error": "Unauthorized"}The response includes this header so clients know Bearer authentication is expected:
WWW-Authenticate: Bearer realm="OutboundSync API"403 forbidden
Section titled “403 forbidden”A key can be valid but still forbidden. Treat the message as the stable signal:
API access disabled
Section titled “API access disabled”No accessible CRM connection has API access enabled.
{ "statusCode": 403, "message": "API access is not enabled for this account", "error": "Forbidden"}Enable API access for the CRM connection in the OutboundSync admin app, then retry.
Missing write scope
Section titled “Missing write scope”Mutations on /api/v1/webhooks*, POST /api/v1/syncs/:id/retry, destination delivery replay, and blocklist pause / resync require the write scope.
Webhook mutations:
{ "statusCode": 403, "message": "This endpoint requires the 'write' scope", "error": "Forbidden"}Sync retry:
{ "statusCode": 403, "message": "API key requires the \"write\" scope", "error": "Forbidden"}Ask OutboundSync support for a key that includes write. Sync retry, destination replay, and blocklist pause/resync allow a connection-scoped key; webhook mutations require an account-wide key. See Creating API keys, POST /api/v1/syncs/:id/retry, and POST /api/v1/blocklists/:id/resync.
Block lists not enabled
Section titled “Block lists not enabled”POST /api/v1/blocklists/:id/resync also returns 403 when the connection does not have block lists enabled (canBlockList). Pause does not use this gate — pause 403 is missing write only.
{ "statusCode": 403, "message": "Block lists are not enabled for this connection", "error": "Forbidden"}Connection-scoped key on webhooks
Section titled “Connection-scoped key on webhooks”All /api/v1/webhooks* routes (including GETs) require an account-scoped key.
{ "statusCode": 403, "message": "Webhook endpoint management requires an account-scoped API key", "error": "Forbidden"}Create or request an All connections (account-wide) key.
Webhooks not enabled
Section titled “Webhooks not enabled”/api/v1/webhooks* and /api/v1/events* require Webhooks (canUseWebhooks).
{ "statusCode": 403, "message": "Platform webhooks are not enabled for this account", "error": "Forbidden"}The message string above is the live API text (still says “Platform webhooks”). Product docs and the admin UI label this surface Webhooks. Ask an OutboundSync admin to enable Webhooks for the account. Setup guide: Setting up webhooks.
404 unknown route
Section titled “404 unknown route”A GET to a path that doesn’t exist, or to an endpoint that isn’t shipped yet, returns a JSON 404. This applies under /api, /health, and /webhooks:
{ "statusCode": 404, "message": "Cannot GET /api/v1/nonexistent", "error": "Not Found"}The message echoes the path without its query string. Don’t retry: fix the path, or check the API v1 reference and the OpenAPI spec for the routes that exist. Unknown routes are never answered with a 200 HTML page, so a client can trust that a 2xx is a real API response.
429 rate limit exceeded
Section titled “429 rate limit exceeded”Authenticated /api/v1/* routes (including webhooks, events, and blocklists) apply these limits:
| Layer | Limit | Window | When it applies |
|---|---|---|---|
| IP address | 120 requests | 60 seconds, fixed | Before API key validation (general routes) |
| Account owner | 120 requests | 60 seconds, sliding | After API key validation (general routes). All keys on the account share this budget. |
| Contact outreach | 600 requests | 60 seconds, fixed | GET /api/v1/contacts/outreach — dedicated per-account bucket |
The account limit is a sliding window: each request counts against the budget for 60 seconds after it is made, so capacity frees up gradually rather than all at once. Requests rejected with 429 still count. A client that keeps calling while limited stays limited, so back off instead of retrying in a tight loop.
The higher 600 / 60s outreach bucket lets enrichment tools (ZoomInfo, Clay, Databar, Freckle) fan out across large audiences without exhausting the general limit — see GET /api/v1/contacts/outreach.
When a request is rate limited, OutboundSync returns 429 and a Retry-After header with the number of seconds to wait before retrying.
Retry-After: 37Rate limit headers
Section titled “Rate limit headers”Responses from the general account routes carry the account budget, so clients can slow down before they hit 429:
| Header | Value |
|---|---|
X-RateLimit-Limit |
Requests allowed per 60 seconds for the account (120). |
X-RateLimit-Remaining |
Requests left in the current 60-second window. |
X-RateLimit-Reset |
Seconds until the oldest counted request ages out and the next request slot frees up. A relative number of seconds, not a Unix timestamp. |
X-RateLimit-Limit: 120X-RateLimit-Remaining: 0X-RateLimit-Reset: 12Retry-After: 12The headers appear on every authenticated response from the general routes, including that limit’s own 429. They are not sent on:
GET /api/v1/contacts/outreach(its 600 / 60s bucket returns onlyRetry-Afteron429)- the per-IP
429, which fires before the key is checked and returns onlyRetry-After 401and403authentication failures- auth-free discovery routes (
/api/v1/openapi.json,/api/v1/openapi.yaml,/health/*)
Treat the headers as optional: read them when present, and fall back to Retry-After on 429.
504 query time limit
Section titled “504 query time limit”Heavy reads run under a 20-second database time limit. If the query runs past it, OutboundSync cancels the work and returns 504 with the standard JSON error body, instead of letting the request hang until the connection times out.
{ "statusCode": 504, "message": "Request exceeded the 20s query time limit. Narrow the from/to window or pass connectionId.", "error": "Gateway Timeout"}These endpoints can return it:
| Endpoint | What to do |
|---|---|
GET /api/v1/account/metrics |
Narrow from/to or pass connectionId. |
GET /api/v1/syncs and GET /api/v1/syncs/metrics |
Narrow from/to or pass connectionId. |
GET /api/v1/sources/:sourceId/syncs |
Narrow from/to. |
GET /api/v1/requests/metrics |
Narrow from/to or filter by sourceId. |
GET /api/v1/destinations/:id/deliveries/metrics |
Narrow from/to. |
GET /api/v1/contacts/outreach |
Retry shortly; contact support if it keeps happening. |
The message always includes the hint for that endpoint. A 504 is safe to retry; nothing was written.
Retry pattern
Section titled “Retry pattern”const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
async function getOutboundSyncMe(apiKey) { const response = await fetch('https://app.outboundsync.com/api/v1/me', { headers: { Authorization: `Bearer ${apiKey}` }, });
if (response.status === 429) { const retryAfter = response.headers.get('Retry-After') ?? '60'; throw new Error(`OutboundSync rate limit exceeded. Retry after ${retryAfter} seconds.`); }
// Slow down before the next call when the account budget is nearly spent. // The headers are absent on some routes (e.g. contacts/outreach), so treat them as optional. const remaining = Number(response.headers.get('X-RateLimit-Remaining') ?? Number.POSITIVE_INFINITY); if (remaining <= 5) { await sleep(Number(response.headers.get('X-RateLimit-Reset') ?? '1')); }
if (response.status === 401) { throw new Error('OutboundSync API key is missing, invalid, revoked, or rotated.'); }
if (response.status === 403) { const body = await response.json().catch(() => ({})); throw new Error(body.message || 'OutboundSync API request was forbidden.'); }
if (response.status === 504) { // The 20s query time limit was hit and the work was cancelled; nothing was written. const body = await response.json().catch(() => ({})); throw new Error(body.message || 'OutboundSync query time limit exceeded. Narrow the from/to window and retry.'); }
if (response.status >= 500) { throw new Error(`OutboundSync API returned ${response.status}. Retry with backoff.`); }
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.message || `OutboundSync API request failed with ${response.status}.`); }
return response.json();}Ask an AI coding assistant
Section titled “Ask an AI coding assistant”Add retry handling for OutboundSync API calls.For 429, read Retry-After and wait that many seconds before retrying once.When X-RateLimit-Remaining is present and low, wait X-RateLimit-Reset seconds before the next call (the headers are optional; outreach and per-IP 429s omit them).For 401, tell the user to check the Bearer osapi_ key.For 504, narrow the from/to date range (or filter to one connection) and retry.For 404, don't retry: the id or path is wrong, or the route isn't shipped.For other 5xx or a non-JSON body, retry with exponential backoff.For 403, read the message: enable API access, request write scope, use an account-scoped key for /webhooks, enable Webhooks, or enable block lists on the connection for resync.