Airbyte Sync API
Provision a BigQuery destination and manage per-client ad-platform data connections, syncs, schedules, and sync health through the Airbyte Sync API.
Overview
The Airbyte Sync API lets an agency stand up managed data pipelines for its clients without touching the Airbyte UI. You provision a single BigQuery destination for the agency, then create one connection per client and ad platform. Mythic manages the underlying Airbyte workspace, sources, and connections on your behalf and records every sync outcome.
Behind the scenes each connection wires an Airbyte source (Google Ads, Meta Ads, and others) to your agency's BigQuery destination. Sync results stream back into an immutable event log that powers the statistics and health endpoints.
Already running Airbyte yourself? Adopt it, do not duplicate it. POST /config/provision can take an existing airbyte_workspace_id and airbyte_destination_id — plus your own airbyte_client_id/airbyte_client_secret when that setup lives in your Airbyte org rather than Mythic's. Provisioning a second workspace against a dataset you already sync into gives you duplicate rows, two sets of incremental cursor state, and two Airbyte bills, with no error anywhere to tell you. See Provisioning.
Base URL
All endpoints share the same base URL.
https://mythic-analytics.gulp.workers.dev/client/v1/airbyte
Key concepts
| Concept | Description |
|---|---|
| Config | Your agency's Airbyte setup — workspace, BigQuery destination, and optional staging bucket. One per agency. Created by Mythic, or adopted from Airbyte you already run. |
| Platform | A supported source (e.g. google_ads, meta_ads) with its required fields and default streams. |
| Connection | A client-scoped pipeline from a platform source into your BigQuery destination. |
| Stream | A table/entity within a platform (e.g. campaigns), synced incrementally or by full refresh. |
| Consent handoff | For platforms with supportsOauth: true, POST /oauth/initiate returns a consent URL so the connection is authorised by the account owner instead of by a pasted token. Requires a registered return origin. |
supportsOauth | "The provider credential comes from consent", not "there is nothing to fill in". Read manualFields for the inputs you must still supply. |
| Sync event | An immutable record of one completed sync — outcome, record and byte counts, timing. |
Typical workflow
Provision or adopt the destination
Call POST /config/provision — with your BigQuery project, dataset, and GCP service-account credentials to have Mythic create the workspace and destination, or with airbyte_workspace_id + airbyte_destination_id to adopt an Airbyte setup you already run. Safe to re-run: it updates in place rather than accumulating destinations. See Provisioning.
Discover platforms
Call GET /platforms to see supported sources and the fields each one requires.
Create a connection
Either hand the user off to the provider's consent screen with POST /oauth/initiate (preferred — see below), or call POST /connections directly with a client_id, platform, and the platform's source_config. The client must belong to your agency.
Sync and monitor
Trigger a sync with POST /connections/{id}/sync, adjust cadence with PATCH /connections/{id}/schedule, and review outcomes via /sync-events, /stats, and /health.
Response envelope
Responses are JSON. Most endpoints wrap their payload in a data field; write and action endpoints add a success flag; list endpoints for sync events add pagination fields.
Resource payload. Shape depends on the endpoint.
Present on provision, sync, schedule, and delete responses.
Total matching sync events. Present on GET /sync-events alongside limit and offset.
Error message. Present only on failed requests.
Example error response:
{
"error": "Missing authorization"
}
Provisioning
POST /config/provision runs in one of two modes.
Create. Send bigquery_project_id, bigquery_dataset and gcp_credentials_json; Mythic creates an Airbyte workspace and a BigQuery destination for you.
Adopt. Send the ids of an Airbyte setup you already run, and Mythic uses it instead of building a parallel one:
| Field | When you need it |
|---|---|
airbyte_workspace_id | Always, when adopting. Sources are created here. |
airbyte_destination_id | The existing BigQuery destination every connection writes through. Requires airbyte_workspace_id. |
airbyte_client_id / airbyte_client_secret | When that workspace lives in your Airbyte org. Mythic's own application credentials cannot see it, so every subsequent Airbyte call for your agency is made with yours. Encrypted at rest, never returned — GET /config reports has_airbyte_credentials only. |
When you adopt a destination, the BigQuery fields are not required: the destination already carries its own target and credentials. The id is verified at provision time, so a wrong id, or one belonging to another workspace, fails here rather than on your first connection.
In create mode Mythic lists the target dataset first and refuses with 409 dataset_in_use if it already holds Airbyte-managed tables. Two Airbyte workspaces writing the same streams into one dataset duplicate rows and split incremental cursor state, and neither side reports an error — you find out from the numbers or the invoice. Adopt the existing destination, pick another dataset, or resend with confirm_shared_dataset: true if the sharing is intended.
Re-running provision
Provision is the natural thing to re-run when changing dataset or loading method, so it is built to repeat:
| Resource | On a repeat call |
|---|---|
| Workspace | Reused. Never duplicated. |
| Destination created by Mythic | Updated in place — same destination_id, new configuration. Existing connections keep working, since they reference it by id. |
| Destination you adopted | Untouched. It is yours and may serve your own pipelines. A later create-mode provision builds a separate Mythic-owned destination instead of rewriting yours. |
| Stored credentials | Replaced only by a call that supplies them. |
The response's destination_reused tells you which path ran, and GET /config reports destination_adopted so you always know whose destination is in play.
Staging is optional, and all-or-nothing
Standard inserts are the default: send no staging fields and the destination loads directly. To use GCS staging instead, send all three of staging_bucket_name, hmac_key_access_id and hmac_key_secret (staging_bucket_path defaults to staging). A partial set is rejected with 400 staging_incomplete naming what is missing.
The HMAC pair is a GCS interoperability credential created for a service account — gcloud storage hmac create SA_EMAIL — not the service-account JSON key. It does not have to belong to the same service account as gcp_credentials_json, but whichever account owns it needs write access to the staging bucket, so using the same one is simplest.
Failures
GET /config reports loading_method (Standard or GCS Staging), derived from the destination's real configuration — a reprovision that switches to standard inserts clears the staging bucket rather than leaving a stale one behind.
A provision that fails leaves no config row behind if there was none before, so GET /config keeps returning 404 not_configured — "never provisioned" and "provisioning failed" stay distinguishable. The failure response carries the reason itself (code: provision_failed with the upstream message, or a specific 400/409 code); you should never have to read it back off GET /config.
PATCH /config does not require a provisioned agency — it creates the config row if there is none. You can register oauth_return_origins before provisioning. That row unlocks nothing on its own: POST /oauth/initiate still requires an active workspace.
Supported platforms
The platform catalog is returned live by GET /platforms. At the time of writing it includes:
| Key | Label |
|---|---|
google_ads | Google Ads |
meta_ads | Meta Ads (Facebook) |
bing_ads | Bing Ads (Microsoft) |
tiktok_ads | TikTok Ads |
salesforce | Salesforce |
Always read requiredFields and defaultStreams from GET /platforms rather than hard-coding them — the catalog is the source of truth for what each connection needs.
supportsOauth means no long-lived token, not no inputs
Every platform above is supportsOauth: true. That means the provider credential is obtained through consent and held by Airbyte — you never paste, store, or refresh an access or refresh token. It says nothing about the other fields a connector needs. Those you still send in source_config, in either flow.
manualFields on each catalog entry lists exactly what is left for you to supply — requiredFields minus everything flagged oauth: true:
| Platform | supportsOauth | manualFields | preConsentFields |
|---|---|---|---|
google_ads | true | customer_id (accepts several) | — |
meta_ads | true | account_id (accepts several) | — |
bing_ads | true | developer_token, tenant_id | tenant_id |
tiktok_ads | true | — none | — |
salesforce | true | — none | is_sandbox (optional) |
Bing is the widest gap: fully OAuth-capable and still needs two inputs, because Microsoft's developer_token is issued to the application rather than granted by a user's consent, and its tenant_id selects which Microsoft tenant the user logs into. TikTok and Salesforce are the other end — consent supplies everything.
preConsentFields must be sent at initiation, not after. The provider's login URL is built from them, so a tenant_id supplied later cannot retroactively change which tenant the user authenticated against. Send them in source_config on POST /oauth/initiate and Mythic forwards them to the provider.
One connection, several ad accounts
Google Ads and Meta both let a single source pull from more than one ad account, and their catalog entries flag it with multiple: true. Send several however you like — a comma-separated string, a whitespace-separated one, or an array:
{ "platform": "meta_ads", "source_config": { "account_id": "act_111111111111111, act_222222222222222" } }
{ "platform": "google_ads", "source_config": { "customer_id": ["123-456-7890", "098-765-4321"] } }
Both reach the connector in the shape it actually requires, which differs between them and is not something you should have to know: Meta's account_ids is an array of bare numeric ids, Google's customer_id is one comma-separated string with no spaces. Formatting is normalised on the way through — Meta's act_ prefix is stripped, and dashes are removed from both, so act_123-456-789 and 123-456-7890 are accepted as typed.
A Google customer ID that is not exactly 10 digits after stripping is rejected with 400 invalid_customer_id, naming the offending value, rather than surfacing later as a schema error from inside Airbyte.
The connection's account_id records every account the source syncs, comma-separated — not just the first.
This is per source, so all the accounts share one schedule, one stream selection, and one set of destination tables, with the account id as a column. Split them into separate connections when they need different cadences or belong to different clients.
Every key in manualFields is a real property of the connector's own published spec — they are derived from it, not from what each ad platform's UI calls things, and a drift check fails if the catalog and the connector ever disagree. Two consequences worth knowing: bing_ads filters accounts by account_names (a list of { name, operator } predicates, optional) rather than by an account id, and tiktok_ads has no advertiser id in its OAuth flow at all.
Connecting with OAuth
Platforms whose catalog entry has supportsOauth: true can be connected without anyone handling a provider token. Fields the consent flow supplies are flagged oauth: true in requiredFields — never prompt for those. Everything in manualFields you must still collect and send in source_config.
Register where consent may land
Once per agency: PATCH /config with oauth_return_origins, the list of origins (https://portal.example.com, no path) your portal returns to. The list starts empty and a return_url outside it is rejected at initiation — that check is what keeps the unauthenticated callback from being usable as an open redirector. This works before provisioning; the config row is created if it does not exist.
Start the handoff
POST /oauth/initiate with client_id, platform, a return_url, and the non-secret connector fields in source_config (account identifiers, and Bing's app-issued developer_token — everything not flagged oauth: true). meta_ads and google_ads require an account identifier here; it is validated before consent so a missing one cannot surface later as an opaque catalog-discovery failure. You get back a consent_url and a single-use state, valid for 15 minutes.
Send the browser to consent
Redirect the user to consent_url. They authorise at the provider; the tokens are stored and refreshed by Airbyte, never by you and never by Mythic.
Land back on your app
The provider redirects to /oauth/callback/{state}, which creates the source and connection and then redirects to your return_url with mythic_oauth=success and mythic_connection_id=…, or mythic_oauth=error and mythic_error_code=…. Your own query parameters are preserved. Sync and monitor the connection like any other.
A failed consent leaves nothing behind in Airbyte: if the connection cannot be created, the source created moments earlier is deleted too, so retrying does not accumulate half-built sources.
/oauth/initiate validates your request before it checks whether the agency is provisioned, so missing_account_id, return_url_not_allowed and unsupported_platform come back with their own codes even on an agency that has never provisioned. not_provisioned therefore means the request itself was fine.
mythic_error_code comes from a closed set you can branch on — state_invalid, missing_secret_id, missing_account_id, not_configured, not_provisioned, source_config_rejected, oauth_secret_expired, account_not_accessible, connection_failed. They differ in who can fix them:
| Code | Who fixes it |
|---|---|
source_config_rejected | Mythic. Airbyte refused the source payload against the connector's schema. Consenting again cannot help — surface it, do not prompt a retry. |
oauth_secret_expired | The user, by starting the connection again. |
account_not_accessible | You, by correcting the account id sent at initiation — the consent itself worked. |
connection_failed | Unknown; read the sync-event row below. |
A failed callback also writes a sync-event row with error_origin: oauth_callback, the code in error_type, and the underlying Airbyte message in error_message, readable at GET /sync-events?client_id=…. Because no connection exists yet, its connection_id is the sentinel oauth:<platform>:<client_id> rather than a UUID, so these rows are easy to filter. That is the only trail a failed consent leaves — no connection row is created — so surface it when a user comes back with mythic_oauth=error.
The state token is also your correlation handle: the connection id does not exist until the callback finishes, so hold state against whatever you are provisioning in your own UI.
Account identifiers are normalised for you. Meta accepts account_id or account_ids and the act_ prefix its own UI shows is stripped; a Google customer_id may keep its dashes. Both are mistakes that would otherwise pass silently at create time and fail on the first sync.
POST /connections with raw credentials in source_config still works and stays supported — use it for service-account style credentials, or for a platform with no OAuth flow.
Both ends are rate limited: 20 initiations per 10 minutes per agency, and 20 callbacks per 10 minutes per IP.
Stream sync modes
Every incremental stream is sent to Airbyte as incremental_deduped_history, which upserts on the stream's primary key. That matters for daily insights tables keyed on composites like (date_start, account_id, ad_id, region): a resync re-keys those rows rather than appending them, so spend is not double counted. Append-only incremental is not offered.
incremental and full_refresh are accepted as aliases of incremental_deduped_history and full_refresh_overwrite when you send a streams array, and older stored selections may still read back in the short form.
Stream names come from the connector, not the ad platform
A connector's stream names are its own, and they routinely differ from what the platform's UI calls the same thing — Meta's "Ad sets" is the stream ad_sets, not adsets. Airbyte rejects the first unrecognised name outright (No streams found with name [...]) and only reveals the next one after that is fixed.
So Mythic reconciles against the source's discovered catalog when a connection is created:
- You omit
streams— the platform defaults are used, filtered to what the source actually offers. A default the connector has since renamed is dropped and logged rather than failing your connection. - You supply
streams— an unrecognised name is rejected with400 unknown_streamsand the response lists the names this source does offer. Nothing you explicitly asked for is ever silently dropped.
The stored connection's tables_json always reflects the streams actually configured, so read it back if you want to know what a connection ended up with.
defaultStreams is read once, when a connection is created — adding a stream to the catalog does not reach connections that already exist. Send an explicit streams array to PUT /connections/{id} to add one to a live connection, remembering that enabling a stream triggers a backfill from the source's start date.
Some streams sync weekly, on their own connection
A platform can also declare weeklyStreams in GET /platforms. Those streams are expensive to sync and slow to change — Meta's ad_creatives_from_ads is a full refresh of every creative on every ad — so they do not belong on the same cadence as daily insights.
Airbyte's cadence is a property of the connection, not the stream, so this is expressed the only way it can be: one POST /connections for Meta creates two connections off one source.
- Primary — the
defaultStreams, on whateversync_frequencyyou asked for. - Weekly companion — the
weeklyStreamsonly,manualin Airbyte, run by the pipeline one day a week (sync_dow, Sunday by default).
They share a source, so there is one consent, one credential and one account list. The response to POST /connections (and the connection created by an OAuth callback) carries a companions array with the second connection's row, plus a pipeline object saying whether both were added to the client's pipeline scope. Both stream sets are disjoint — no stream is ever written by both connections.
{
"id": "…", "platform": "meta_ads", "tables_json": [ /* daily streams */ ],
"companions": [
{ "id": "…", "display_name": "meta_ads - acme (weekly)", "sync_dow": 1,
"tables_json": [{ "name": "ad_creatives_from_ads", "syncMode": "full_refresh_overwrite" }] }
],
"pipeline": { "enrolled": true, "pipeline_id": "…", "connection_ids": ["…", "…"], "pipeline_enabled": true }
}
Three things follow from the split, all of them things a portal has to handle:
- Supplying your own
streamsopts out. An explicit stream list is a decision, so no companion is created — put the weekly streams wherever you want them. - A companion needs the pipeline. It is
manualin Airbyte on purpose; if the client has no pipeline, nothing will ever trigger it. It appears inGET /pipelines/{clientId}/candidateslike any other connection — add it, or sync it by hand withPOST /connections/{id}/sync. - Deleting one does not delete the other. The shared source is removed only with the last connection using it; the response reports
source_deletedandsiblings_kept.
sync_dow (0 = Sunday … 6 = Saturday, null = every run) is writable on PUT /connections/{id} if Sunday is the wrong day. It is read by the pipeline only — a connection on its own Airbyte cron ignores it, because the cron already says when it runs.
Sync frequency
manual is the default. A connection created without an explicit sync_frequency does not sync on a schedule — you trigger it with POST /connections/{id}/sync. Scheduled syncing is something you opt into.
| Value | Cadence |
|---|---|
manual | No automatic syncs (default) |
24h | Once daily |
12h | Every 12 hours |
6h | Every 6 hours |
Choosing the hour
sync_hour_utc (0–23) sets when a cadence runs, in UTC. It is accepted on POST /connections, PUT /connections/{id} and PATCH /connections/{id}/schedule, and defaults to 2 when unset.
Sub-daily cadences anchor on hour % interval, so the hour you ask for is always one of the run times and the runs stay evenly spaced:
sync_frequency | sync_hour_utc | Runs at (UTC) |
|---|---|---|
24h | 8 | 08:00 |
24h | unset | 02:00 |
12h | 8 | 08:00, 20:00 |
6h | 8 | 02:00, 08:00, 14:00, 20:00 |
6h | unset | 02:00, 08:00, 14:00, 20:00 |
Responses from the schedule endpoints include runs_at_utc, the concrete list above, so you can tell a client when their data lands without decoding a cron expression. Changing sync_frequency alone keeps the connection's existing hour rather than resetting it.
You send a cadence and an hour, never a cron expression. Internally these become Quartz cron (six fields, seconds first), which is what Airbyte's scheduler accepts — a five-field Unix expression is rejected outright.
Sync results
Every sync outcome Mythic reports comes from Airbyte's own workspace notifications, which it POSTs to Mythic when a job finishes. Provisioning registers that automatically; POST /config/notifications does it for a workspace provisioned earlier or one you adopted, and is idempotent.
No notifications means no history, not a healthy history. A workspace that was never registered reports zero syncs, a 100% success rate and an empty /sync-events — indistinguishable from one that simply has not run yet. GET /config returns notifications_registered_at; null means nothing is being recorded.
Where to read what:
| You want | Read |
|---|---|
| The state of one connection right now | last_sync and consecutive_failures on the connection |
| A run history, newest first | GET /sync-events |
| Volumes and success rate over time | GET /stats |
| What needs a human today | GET /health → needs_attention |
success is not the same as data arriving
A sync can succeed and commit zero records. In one measured week of real notifications that was 7 out of 221 — a revoked ad-account permission, an emptied date range, a paused campaign. Every status field calls those green, and a connection can stay green for months while its data quietly goes stale.
So each sync also carries landed: success and at least one record committed. Read landed when the question is "did the data arrive", and success only when the question is "did the job run". GET /health lists the difference under no_data_connections.
Two related distinctions in the same numbers:
records_committedvsrecords_emitted. Emitted is what the source produced; committed is what the destination has. Bill, alert and chart on committed. A real failure in the sample emitted 1,485 records and committed none.consecutive_failures. One red run is a flake; four in a row is broken since Tuesday. A single event cannot tell those apart, so the streak is counted for you andfailing_connectionsis ordered worst-first.
Backfilling history
POST /connections/{id}/backfill imports past jobs from Airbyte's jobs API for a connection that ran before notifications were registered. Backfilled rows are thinner — that API reports one combined row count with no emitted/committed split and no error classification, so records_emitted is null rather than a copy of the committed figure.
Both paths key on (connection_id, job_id), so a backfill never duplicates a notification, re-running one is a no-op, and a re-delivered notification never double-counts. Jobs still running are skipped rather than recorded as failures; the response reports how many under skipped_in_flight.
Getting sync results pushed to you
Airbyte allows exactly one webhook URL per event type per workspace, and Mythic holds it. Everything on this page — /sync-events, /stats, /health, last_sync, landed, consecutive_failures — is built from those posts, so a workspace pointed somewhere else reports a permanently empty, permanently healthy-looking history.
So instead of taking the slot, subscribe and Mythic relays to you:
curl -X POST https://mythic-analytics.gulp.workers.dev/client/v1/airbyte/webhooks \
-H "Authorization: Bearer ak_..." -H "Content-Type: application/json" \
-d '{"url": "https://you.example.com/hooks/airbyte?secret=...", "label": "portal"}'
Your endpoint receives Airbyte's payload verbatim under data, plus the client_id and platform Airbyte cannot know. The URL is treated as opaque and stored encrypted — put your own authentication in it, since Airbyte's notifications are themselves unsigned. GET /webhooks reads subscriptions back with every query-string value replaced by ***, alongside last_error and consecutive_failures.
Dedupe on job_id, order on finished_at. A relay adds a hop, so a retried older failure can arrive after a newer success. A receiver that stamps its own clock and takes last-write-wins will flip a healthy connection to failed and alert on it. Both fields are forwarded unmodified for exactly this; delivered_at is Mythic's clock and is a diagnostic, never an ordering key.
Delivery is best-effort and there is no redelivery queue: the event is already durable, so reconcile a gap from GET /sync-events rather than waiting for a retry. An endpoint that fails 20 times in a row is parked (active: false) so a dead URL stops costing a request on every sync — POST the same URL again to resume it. Posting a URL that already exists updates it rather than adding a second subscription, which would deliver everything twice.
Orchestrated syncs
By default each connection runs on its own Airbyte schedule. That is fine until something has to run after the data lands — a transform, an export, your own pipeline. Twelve connections on twelve independent crons never produce a moment that means "today's data is here", so a consumer either guesses a time and is sometimes wrong, or polls forever.
A pipeline fixes that. Its connections go to manual, Mythic triggers them, and once the whole set is done Mythic POSTs to a URL you provide.
Pick the scope
GET /pipelines/{clientId}/candidates lists the client's connections with a suggested flag. Use it to pre-tick boxes — but let a human confirm the set. Which connections belong to a client is not reliably answerable from an Airbyte workspace: real ones mix per-connector namespaces with per-client ones, and a bare-uuid namespace can turn out to belong to somebody else. Syncing a neighbour's connection on this client's schedule and omitting a source your transform needs are both more expensive than one click.
Create it
curl -X PUT https://mythic-analytics.gulp.workers.dev/client/v1/airbyte/pipelines/acme-retail \
-H "Authorization: Bearer ak_..." -H "Content-Type: application/json" \
-d '{"enabled": true, "connection_ids": ["...","..."], "run_hour_utc": 8,
"webhook_url": "https://you.example.com/hooks/mythic",
"webhook_secret": "a-secret-you-generate"}'
Receive the completion
Your endpoint gets a POST with X-Mythic-Run-Id and, if you set a secret, X-Mythic-Signature: sha256=<hex> — an HMAC-SHA256 of the raw body.
Enabling a pipeline takes the schedules over. Every in-scope connection is set to manual in Airbyte, because a run can only mean anything if Mythic is what triggers the syncs. The original schedule is stashed and handed back when a connection leaves scope or the pipeline is disabled — but if you change those schedules directly in the Airbyte UI while a pipeline owns them, that connection will sync twice a day and the completion signal stops meaning what it says.
What "done" means
A run holds rather than lying. The rules, in order:
- Skipped connections never gate. A connection disabled in Airbyte can't sync, and must not block the transform every day forever.
- A first-time backfill doesn't gate — it can legitimately run for days and nothing downstream references it yet. Unless it failed, in which case that connection has never delivered anything and someone should know. A re-sync of a connection that had data always gates, because that is exactly when a consumer would publish half-rebuilt history as if it were complete.
- Anything still running → wait.
- Anything failed → hold. The run stays open, retries the failed steps every five minutes, and completes on its own the moment they pass.
heldis a parking state, not an outcome. - Otherwise → done, and the webhook fires.
Failed steps are retried twice, then wait for a person. POST .../retry resets them once the cause is fixed upstream; POST .../force closes a held run anyway.
Forcing is deliberately a per-run decision, not a pipeline setting. The moment "partial is acceptable" becomes configuration, nobody looks at a held run again and the whole gate becomes decoration. A forced run carries forced: true in its webhook forever, because everything built on it was built on knowingly incomplete data.
Reading the payload
The body is {type: "airbyte.pipeline.completed", delivered_at, data} where data is a PipelineRun. Three fields deserve attention:
complete, notstatus. They are different questions.status: "succeeded"with two connections skipped is a real and normal outcome;completeis what tells you whether everything that could sync, did.connections[].reason.skippedalone collapses "disabled on purpose", "inactive", "first-time backfill" and "not due this week" into one word, and only some of those mean data you expected is missing. A weekly connection readsweekly — only runs on Sunday (UTC)on the six days it is skipped; that is a complete run, not a partial one, and it never holds the gate.connections[].last_success_at. "All done" next to a source that last succeeded nine days ago is a very different message, and you cannot work that out from a run that skipped it today.
Delivery is at-least-once: dedupe on run_id and don't fail closed on a duplicate. A delivery that fails is retried automatically on later ticks, up to five attempts, after which it needs POST .../redeliver. Your endpoint has 10 seconds to respond — accept and process asynchronously rather than doing work inline, or you will be timed out and retried. A delivery failure marks the run but never un-finishes it — the syncs really did complete, and a run that flipped back to held because your endpoint was briefly down would be a lie about the data. POST .../redeliver re-sends on demand.
Timing and guarantees
Runs are advanced by a five-minute tick, so completion is detected within about five minutes of the last sync finishing — not instantly. Nothing anywhere is blocked waiting on a sync, which is what lets a first historical backfill run for days without holding a connection open.
If the worker is unavailable across your run_hour_utc, the run is started as soon as it is back — in the early hours of the next UTC day if the hour itself was missed — and stamped with the date it was for, so a day is never silently lost.
Exactly one run per client per day, enforced in the database. A run already in flight rejects a manual start with 409 run_in_flight, since a second run would stack a duplicate sync on every connection the first one owns. A run that somehow wedges — nothing in flight, no progress for 36 hours — fails itself loudly rather than leaving the client unable to ever refresh again.
Error handling
| Status | Meaning |
|---|---|
400 | Missing required fields, unsupported platform, incomplete GCS staging config, or workspace not provisioned |
401 | Authorization missing or invalid |
402 | Airbyte pipelines is not included in this account's plan (feature_not_in_plan) |
403 | Write attempted with a read-only sk_ key, or client not linked to the agency |
404 | Config or connection not found (or not owned by the calling agency) |
502 | airbyte_unavailable — Airbyte returned something that is not a valid response. Distinct from an empty result, which is a real answer |
409 | dataset_in_use — provisioning would create a second Airbyte destination against a dataset already holding Airbyte-managed tables |
429 | Rate limited — POST /oauth/initiate (per agency) and the OAuth callback (per IP) allow 20 per 10 minutes |
502 | provision_failed — Airbyte rejected the provisioning request; the upstream reason is in error |
409 | notifications_conflict — the workspace already posts sync notifications to another URL; retry with overwrite_notifications: true |
409 | run_in_flight — a pipeline run is already running for this client; a second would duplicate every sync |
409 | run_exists_today — runs are one per client per day; retry the existing run's failed steps instead |
400 | unknown_connections — a connection_ids entry does not belong to this client |
400 | empty_scope / not_held — the pipeline has no connections, or the run being forced is not held |
409 | run_changed — the scheduled tick advanced this run while your request was in flight; re-read it and retry |
400 | invalid_customer_id — a Google Ads customer ID that is not 10 digits after stripping dashes and spaces |
503 | Server-side encryption key not configured |
500 | Internal server error |
Check both the HTTP status code and the error field in the response body for programmatic error handling.