Skip to main content

Reverse-ETL source query refusals

When you create a warehouse source on POST /api/v1/cdp/sources, Orbit validates the query text and the connector kind before anything is persisted and before anything runs against your warehouse. Two refusals are specific to authoring sources; this page explains each one, what to do instead, and how to read the source catalog so you know up front which kinds are live. The destination half of reverse-ETL is covered by the reverse-ETL destination model and the warehouse exports guide.

1. The single-SELECT rule

Every warehouse source query must pass three checks, in order:
  1. It starts with SELECT or WITH. Source queries are read-only; anything else is refused with VALIDATION_ERROR and details.reason: "CDP_SOURCE_QUERY_MUST_BE_SELECT".
  2. It is exactly one statement. One trailing semicolon is accepted and stripped; any other semicolon anywhere in the query text refuses the request with CDP_SOURCE_QUERY_MULTI_STATEMENT_FORBIDDEN (HTTP 400).
  3. It contains the \{cursor\} placeholder exactly once. \{cursor\} is the incremental watermark: each run binds your last-seen value to it and advances it to the maximum of your cursor column, so rows are synced once, not re-emitted every tick. Without it the request is refused with VALIDATION_ERROR and details.reason: "CDP_SOURCE_QUERY_MISSING_CURSOR_PLACEHOLDER".
Why one statement, enforced this strictly: the scheduled sync runs the exact query text you saved against your warehouse connection, and it validates that text before it ever executes. Rejecting anything that is not a single read-only statement at create time is a fail-closed guarantee that a malformed or batched query can never partially execute on your warehouse mid-sync. The same checks run again when you update a source’s query, so a source cannot drift into a multi-statement query after creation. The multi-statement check is deliberately textual: it does not parse or tokenize your SQL, so a semicolon inside a string literal is rejected too. If a literal in your query needs one, rewrite the literal so it does not.

2. What to do instead of multiple statements

  • One source per query. If you were chaining statements to read two tables, create two sources; each emits its own event name on its own schedule.
  • Move the staging into the warehouse. If earlier statements built intermediate results (temporary tables, stepwise transforms), materialize the final result as a view in your warehouse and point a single SELECT at it. A WITH ... SELECT (common table expressions) covers most multi-step shaping inside one statement and is explicitly accepted.
  • Compose downstream. A source emits one event per returned row. Joins between two synced tables happen either in the warehouse, before the query reads them, or in the downstream tooling that consumes the exported events, not in the source query.
A minimal accepted query looks like:

3. CDP_SOURCE_KIND_NOT_YET_AVAILABLE: catalogued vs live

The source catalogs list every connector Orbit publishes, including connectors whose ingestion is not live yet. A not-yet-live connector is catalogued for discovery: the picker shows it with a Request access action instead of Add source, and creating a source with that kind over the API returns CDP_SOURCE_KIND_NOT_YET_AVAILABLE (HTTP 400). The refusal is not a defect in your request; it is the catalog’s honest maturity signal holding at the API boundary — the connector row is published, ingestion for that kind is not accepting sources yet. Kinds you can create and sync today: postgres, bigquery, snowflake, and databricks. The warehouse catalog currently lists redshift as coming soon; sending kind: "redshift" is the canonical way to see this refusal. Where the live kinds are shown:
  • GET /api/v1/cdp/warehouse-source-catalog returns the warehouse connectors with a status per row (ga = live, coming_soon = published but not yet persistable), and GET /api/v1/cdp/warehouse-source-catalog/:connector_id/activation reports whether a specific kind is creatable today.
  • The source picker in the dashboard renders the same catalog: ga rows get Add source, coming_soon rows get Request access.
  • The other source families follow the same pattern: GET /api/v1/cdp/source-catalog (cloud apps), GET /api/v1/cdp/streaming-source-catalog (streaming/SDK), and GET /api/v1/cdp/event-bus-source-catalog (event buses) each publish their connectors with the same statuses and activation checks.

4. Decision table

5. FAQ

Can I end my query with a semicolon? Yes. Exactly one trailing semicolon is stripped before the check runs. A second semicolon anywhere else refuses the request. Are CTEs allowed? Yes. WITH ... SELECT is a single statement and is explicitly accepted, so multi-step shaping that fits in common table expressions needs no workaround. Is the refusal at create time or at sync time? At create and update time. The guards run before the source is persisted, so a refused query never becomes a failing scheduled run; nothing has executed against your warehouse. Will a coming-soon kind become available? The catalog row flips to ga when ingestion goes live, and the picker’s Request access action becomes Add source at the same moment. Poll the catalog endpoint or watch the picker rather than retrying POST /api/v1/cdp/sources against a coming-soon kind.

See also