CDP reverse ETL to Snowflake and BigQuery
The CDP records, segments, and computed traits you build in Devotel Orbit are only half the value loop — a team running a warehouse still needs the data to LAND there nightly without hand-rolled pipelines. Reverse ETL is the second leg: a push of CDP profiles and events out to the warehouse you already own, with per-row idempotency (delete-then-insert onid) so re-runs never double-write.
Supported destinations: BigQuery (via the /api/v1/cdp/reverse-etl surface), and for non-Google warehouses — Snowflake, Redshift, self-managed Postgres, Databricks, and ClickHouse — the /api/v1/cdp/reverse-etl/warehouse/:warehouse surface. The two surfaces share one cadence model and one credentials envelope.
1. What gets mirrored
Each run pushes only the rows that changed since the destination’s last successful run:- Contacts — keyed on
updated_at, so edits roll forward without a full-snapshot re-ship. Exported set includes identifiers, attributes, tags, lifecycle stage, aggregates, and DNC / block flags. - CDP events — keyed on
received_at, so append-only event rows show up in the destination once per run. - Opt-in mirrors (BigQuery only, expand the export with
PATCH /api/v1/cdp/reverse-etl):messages,conversations,usage_events, andcall_logsdeltas. First provision the destination table, then flip the flag — the destination 404s otherwise. - Field mappings — optional per-table exclude / rename map, applied row-by-row before push (
field_mappingsin the config); theidcolumn is locked so the dedup key always survives.
2. Snowflake setup
The connector speaks Snowflake’s SQL REST API with a key-pair RS256 JWT — nosnowflake-sdk on the worker, and no password travels.
-
Create a Snowflake service user for the export (e.g.
ORBIT_SVC), grant it the role that can write to the destination database / schema. -
Generate an RSA key-pair and register the public half:
-
In Snowflake, bind the exported public fingerprint to the service user:
-
PATCH the warehouse config — only the key pair and the namespace go over the wire; values are validated, credentials encrypted at rest (
credentials_encrypted) and never returned by GET: -
Watch for status:
GET /api/v1/cdp/reverse-etl/sync-runsreports the last run per destination —ok,failing,stale,never_run, ordisabled.
3. BigQuery setup
The BigQuery connector uses a Google service-account JSON (encrypted at rest on PATCH). It is the simplest destination because the OAuth + streaming insertAll form is the single-document contract.- Create a Google Cloud service account with BigQuery Data Editor on the target project + dataset.
-
Fetch a JSON key for it (a
type: "service_account"blob). -
PATCH the settings body;
service_account_jsonis encrypted at rest and never returned by GET — GET returns onlyservice_account_configured: -
For each opt-in dataset (
export_messages,export_conversations,export_usage_events,export_call_logs) provision the corresponding BigQuery table first — the insert fails loudly otherwise, and the destination flips tofailingwith the missing-table error visible inGET /api/v1/cdp/reverse-etl/sync-runs.
4. Sync cadence, and how the cursor works
- Two run gates per destination: the 02:00 UTC settle window (so the last writers of the day get a two-hour grace before the snapshot) and a per-destination
sync_interval_minutesin the [60, 30-days-minutes] range. The default — omittingsync_interval_minutes— reproduces the historical nightly cadence (23h). - Cursor (is idempotent by design): the destination’s
last_run_atadvances only on a successful run. A pod crash mid-export, a SIGTERM, or a transient Cloud SQL blip means the next run re-reads from the samelast_run_at, so you never miss the delta and you never see doubles. - Backfill: a PATCH-set
backfill_since(ISO instant, or epoch for a full-history replay) resends an older window on the next run. The replay is idempotent — delete-then-insert onid— so a one-time catch-up never leaves duplicate rows. - Sync-now: request an on-demand run with
POST /api/v1/cdp/reverse-etl-sync-now(or setsync_now_requestedon the config); it overrides cadence without lowering the export floor. - Idempotent upsert: every batch writes delete-then-insert keyed on
id, so a re-run can never surface duplicates downstream.
5. Operational verification
- Confirm destination health —
GET /api/v1/cdp/reverse-etl/sync-runs— after the first scheduled run completes; the returned object reports per-warehouselast_run_at, row counts, and a derived health indicator (ok / failing / stale / never_run / disabled). - Look at
last_run_rows_contactsandlast_run_rows_events— they should be non-zero once any contacts / events exist in the delta window. - When a destination goes
failing, the response carries a sanitizedlast_errorcode (config_or_credential,tenant_client_open,export_stream) — never the raw driver message — so the error panel is safe to share with an audit channel. - Watch
consecutive_failures— the operator UI paints a warning when it exceeds zero; the same envelope feeds the shared alerting (this guide does not block on the shared alert path, it is a plain count in the GET response).
What stays internal
- Credentials are encrypted at rest under the
enc:v1:envelope; GET returns only acredentials_configuredboolean. - Per-run
last_errorrecords a coarse enum (as above), not the raw driver text — a malformed-credential fix never leaks an internal DSN into the dashboard. - Reverse ETL is not a generic ETL tool: only contacts + cdp_events flow by default, and the messages / conversations / usage_events / call_logs arms need the explicit opt-in on the BigQuery surface.
Related guides
- CDP segments and computed traits — the source-leg of the value loop; build the audience before you push it.
- API reference — CDP endpoints — the generated per-operation shapes for
/api/v1/cdp/reverse-etl*and/api/v1/cdp/reverse-etl/warehouse/*.