Setting up a data warehouse source
Use this skill when the user wants to connect an external data source to PostHog's data warehouse for the first time.
Default to the one-step flow: data-warehouse-source-setup validates credentials, discovers every table, enables
them with sensible sync defaults (incremental where possible), and creates the source in a single call — no
schemas array to assemble. For credentials, hand the user a secure browser link with data-warehouse-source-connect-link
instead of collecting secrets in chat. Only drop to the manual wizard → db-schema → create flow when the user wants to
hand-pick which tables sync or set non-default sync types per table.
When to use this skill
- The user wants to connect a new source: "connect Stripe", "import my Postgres orders table", "sync Hubspot contacts"
- The user isn't sure what source types PostHog supports
- The user has credentials but doesn't know how to structure the
schemas payload
- The user wants guidance on which sync method to pick per table
Available tools
| Tool |
Purpose |
data-warehouse-source-connect-link |
Preferred for credentials — get a secure browser/OAuth link so the user authenticates without pasting secrets in chat |
data-warehouse-source-setup |
Preferred to create — one call: validate creds, discover tables, apply sync defaults, create the source |
external-data-sources-wizard |
Discover which source types exist and what fields each needs (advanced flow) |
external-data-sources-db-schema |
Validate credentials and list tables with available sync methods per table (advanced flow) |
external-data-sources-create |
Advanced create — requires a schemas array built from the db-schema response |
external-data-sources-check-cdc-prerequisites-create |
Postgres CDC pre-flight check (optional, only for Postgres CDC) |
external-data-sources-webhook-info-retrieve |
Check if a source supports webhooks and whether one has been registered |
external-data-sources-create-webhook-create |
Register a webhook with the external service after source creation |
external-data-sources-update-webhook-inputs-create |
Supply the signing secret manually when auto-registration failed |
external-data-sources-list |
After creation, confirm the source is listed and see its initial status |
external-data-schemas-list |
See per-table sync status once the source is created |
Pre-flight: credential gotchas that cause most failures
Surface these before collecting credentials — they're the top reasons setup fails on the first try. Validating
against them up front avoids burning credential prompts on retries.
- The host must be reachable from PostHog's network.
localhost, 127.0.0.1, and private/RFC-1918 hosts
(10.x, 192.168.x, 172.16–31.x) are rejected — PostHog runs the connection from its own infrastructure, not the
user's machine. Serverless/managed Postgres (Neon, Supabase, RDS behind strict rules) often also needs PostHog's
egress IPs allowlisted first. If the DB isn't publicly reachable, route to the browser deep-link
(data-warehouse-source-connect-link) or an SSH tunnel rather than collecting credentials that can't validate.
- Supabase is Postgres — don't collect it twice. Use the Session pooler connection, not the direct host (the
direct host is IPv6-only). The pooler host looks like
aws-0-<region>.pooler.supabase.com, the username must be
postgres.<project-ref>, and the port is 6543 (not 5432). The password is the database password (Settings →
Database), which is distinct from the anon/service_role JWT keys and from the Supabase account password. If
SUPABASE_URL is in the project env, derive the project ref from db.<ref>.supabase.co to pre-fill these instead of
asking the user to guess.
- Many SaaS sources need a specific key type or plan — get the right one before the create call fails:
- Stripe — a restricted key (
rk_live_…), not the standard secret key (sk_live_…).
- RevenueCat — a v2 secret key (
sk_…) with the read scopes enabled.
- Sentry — an internal-integration token, not a DSN and not a personal auth token.
- Convex — requires the Professional plan.
- Twilio — API Key SID + Secret, not the account auth token.
- Mailchimp — the key carries its datacenter suffix (
key-usX).
- For send-only services (Resend, Mailgun), the key already in the project env is often restricted; the warehouse
import needs a full/read-access key.
- Never pass an unresolved secret reference. If a credential field is still a
{"secretRef": ...} object, PostHog
can't resolve it — the create/db-schema/setup calls reject it with a clear error. Resolve it to the real value
first, or collect credentials via data-warehouse-source-connect-link and pass the resulting credential_id.
Recommended: one-step setup
Most setups should use this path — it avoids the most common failures (skipping db-schema, malformed schemas,
missing required fields).
Discover the source type and its fields (optional): external-data-sources-wizard lists every source type and
the credential fields each needs. Use it to know what to ask the user for; skip it if the source type is obvious.
Collect credentials securely: call data-warehouse-source-connect-link({source_type}). It returns a
connect_url to a minimal connect page rendering the source's full connection form — the user authorizes via
OAuth or enters credentials there, whichever the source offers (the response's auth_method tells you which to
expect). The page validates the details against a live connection and stashes them encrypted in a temporary
store — it does NOT create the source. After the user confirms they're done, find the stored credential id via
data-warehouse-stored-credentials-list (filter by source_type, newest first; the page also shows the id to
the user) and pass {"credential_id": <id>} to setup. Stored credentials are single-use — deleted as soon as
setup consumes them — and expire after 24 hours.
Never ask the user to paste raw database passwords, API keys, or OAuth tokens into the chat.
Create in one call: data-warehouse-source-setup({source_type, payload, prefix}). The server validates
credentials, discovers all tables, enables them with sync defaults (incremental where a tracking column exists,
else append, else full_refresh — never CDC), sets created_via=mcp, and creates the source. The payload carries
a credential reference ({"credential_id": ...} or the OAuth integration id key) — or inline credentials for
headless automation; no schemas array is needed. On success you get the new source id; call
external-data-schemas-list to show the user what was enabled and how each table will sync.
Notes specific to this path:
- All discovered tables are enabled. That's intended (incremental defaults keep ongoing cost low), but flag row
counts for very large tables and offer the advanced flow if the user wants to sync only a subset.
- Webhooks are auto-registered for sources that support them (currently Stripe). Check the
webhook key in the
setup response: on success, webhook-capable tables sync in real time and webhook-only tables (e.g. Stripe Discount)
are enabled too; on failure (e.g. the API key can't create webhooks), tables keep the polling defaults and
webhook-only tables stay disabled — relay the webhook.error to the user and offer Step 6 to register manually.
If webhook.pending_inputs is non-empty, collect those values and submit via
external-data-sources-update-webhook-inputs-create. CDC is never chosen automatically; use the advanced flow +
CDC steps for near-real-time Postgres.
- Inline credentials in
payload still work for headless/automation, but prefer the connect-link handoff above.
Advanced: hand-pick tables (three-step flow)
Use this when the user wants to choose exactly which tables sync or set non-default sync types. Don't try to shortcut
to external-data-sources-create — you need the db-schema response to build a valid schemas payload.
┌────────────────────┐
│ 1. wizard │ What source types exist? What fields does each need?
└────────┬───────────┘
▼
┌────────────────────┐
│ 2. db-schema │ Validate creds. List tables + available sync methods per table.
└────────┬───────────┘
▼
┌────────────────────┐
│ 3. create │ Send source_type + credentials + schemas[] to actually create.
└────────────────────┘
Workflow
Step 1 — Discover the source type
Call external-data-sources-wizard with source_type set to the kind(s) you need (comma-separated, e.g.
Postgres,Stripe). The unfiltered response describes every supported source and is hundreds of KB — large enough to
blow your context budget. Only omit source_type when you genuinely need to enumerate every available type, and
expect a big payload if you do. The response is a dict keyed by source type. Each entry describes:
name — the canonical source_type string you'll pass to later calls (e.g. "Postgres", "Stripe", "Hubspot").
label / caption — human-readable.
fields — the config fields needed (host, port, database, api_key, client_id/secret, ...). Each has name,
type (input, password, switch, select, file-upload), and required.
featured, unreleasedSource — use to gauge readiness. Skip sources marked unreleasedSource: true unless the
user explicitly asked for a preview.
Match the user's request to a source. If they said "Postgres", look up Postgres. If they said something ambiguous
like "database", present the top relevant matches (Postgres, MySQL, MongoDB, BigQuery, Snowflake, Redshift) and let
them pick.
For OAuth-based sources (Hubspot, Salesforce, Google Ads), the wizard entry hints at an OAuth flow. These typically
need the user to authorize in the PostHog UI rather than pasting credentials — explain this and direct them to the
source setup page rather than trying to collect tokens in chat. OAuth is about authentication, not about how data
flows; OAuth sources still use polling bulk sync, not webhooks.
Gather the required credentials from the user. Never ask for more fields than the wizard entry says are required —
asking for an unnecessary port when the source doesn't need one confuses users.
Step 2 — Validate credentials and discover tables
Call external-data-sources-db-schema with source_type plus all credential fields. This does two things at once:
- Validates the credentials against the live source. Returns 400 with a
message if anything is wrong (bad host,
wrong password, permission denied). Show the error verbatim — it's often actionable ("password authentication
failed for user 'x'").
- If valid, returns an array of table entries. Each entry:
{
"table": "orders",
"should_sync": false,
"rows": 1_250_000,
"incremental_available": true, # can do sync_type=incremental
"append_available": true, # can do sync_type=append
"cdc_available": true, # can do sync_type=cdc (null = not enabled for team)
"supports_webhooks": false, # can do sync_type=webhook for real-time push
"incremental_fields": [ # candidates: usually updated_at, created_at, id
{"field": "updated_at", "type": "datetime", "label": "updated_at", ...},
{"field": "created_at", "type": "datetime", ...},
{"field": "id", "type": "integer", ...}
],
"detected_primary_keys": ["id"],
"available_columns": [{"field": "id", "type": "integer", "nullable": false}, ...],
"description": "..."
}
Present this to the user. Don't dump the raw JSON — summarize: which tables were found, row counts, and the default
sync method recommendation per table (see sync-type decision guide).
Step 3 — Confirm per-table sync configuration
For each table the user wants to sync, pick a sync_type. See the
sync-type decision guide for detailed rules, but the short version is:
- Small / dimension tables (<50k rows, no natural ordering column):
full_refresh — simple and always correct.
- Large tables with an
updated_at / modified_at: incremental — much cheaper per sync.
- Append-only immutable tables (logs, events):
append if available — preserves history.
- Postgres with CDC enabled and you need near-real-time:
cdc — requires primary keys and Postgres prerequisites.
- Sources that support webhooks (currently Stripe): for near-real-time ingestion set
sync_type: "webhook" on
the tables where supports_webhooks: true, then register the webhook as a post-create step (see step 6 below).
Tables that don't support webhooks on the same source still need a bulk sync_type.
For each schema that will use incremental/append/cdc, you also need:
incremental_field — which column to track for high-water-mark ordering. Pick from the incremental_fields list
returned by db-schema. Prefer updated_at over created_at (updated_at catches late-arriving updates;
created_at misses them). For integer-only tables, use the monotonically increasing primary key.
incremental_field_type — must match the chosen field's type (datetime, timestamp, date, integer,
numeric, objectid).
primary_key_columns — required for CDC. Use detected_primary_keys from db-schema.
Step 4 — Pick a good prefix
The source's prefix is prepended to table names in HogQL. Tables end up as {prefix}_{table_name}.
- Default to the source type lowercased if there's only one source of that type:
stripe, postgres.
- If the user already has a Postgres source, pick something distinguishing:
postgres_prod, postgres_analytics.
- Use lowercase, underscore-separated. The prefix becomes part of every HogQL query the user writes.
Confirm the prefix with the user before creating — changing it later is possible but renames every table.
Step 5 — Create the source
Call external-data-sources-create with:
{
"source_type": "Postgres",
"prefix": "postgres_prod",
"payload": {
"host": "...",
"port": "5432",
"dbname": "...",
"user": "...",
"password": "...",
"schema": "public",
"schemas": [
{
"name": "orders",
"should_sync": true,
"sync_type": "incremental",
"incremental_field": "updated_at",
"incremental_field_type": "datetime",
"primary_key_columns": ["id"]
},
{
"name": "users",
"should_sync": true,
"sync_type": "full_refresh"
},
{
"name": "audit_log",
"should_sync": false
}
]
}
}
Rules for the schemas array:
- Every table returned by db-schema should be included, even ones the user doesn't want (set
should_sync: false).
Tables the user didn't mention default to should_sync: false.
sync_type is required only when should_sync: true.
incremental_field / incremental_field_type must be present when sync_type is incremental or append.
primary_key_columns must be present when sync_type is cdc.
On success you'll get back a source with a new id. The first sync is triggered automatically.
Step 6 — Register a webhook (only when any schema is sync_type: "webhook")
Webhook-type schemas don't start receiving data just by existing — the external service needs to know where to POST
events, and PostHog needs to know how to verify them. This is a second call after source creation, not part of the
external-data-sources-create payload. Do this before telling the user the setup is complete, otherwise they
hear "syncs are running" while the push channel is still unregistered.
Only needed when at least one schema on the source has sync_type: "webhook" and should_sync: true. Currently only
Stripe implements this flow; for everything else skip this step.
Before calling create-webhook, check external-data-sources-webhook-info-retrieve({id}). If it already returns
exists: true, do NOT call create-webhook again — each successful call registers a new external endpoint and would
result in duplicate deliveries.
Call external-data-sources-create-webhook-create({id}). PostHog:
- creates the HogFunction that will receive webhook POSTs,
- builds a schema_mapping from external event types to PostHog schema ids,
- calls the source's API (e.g. Stripe) to register the webhook URL and subscribe to the relevant events,
- on Stripe, auto-captures the
signing_secret and stores it securely.
Returns {success, webhook_url, error}. On success report the webhook_url to the user for their records — but
they don't need to paste it anywhere; registration is already done.
If success: false with a permissions error like "API key doesn't have permission to create webhooks":
- The HogFunction is still created, just disabled.
- Ask the user to create the webhook manually in the source's dashboard using the returned
webhook_url.
- Have them copy the signing secret from the source's webhook settings.
- Call
external-data-sources-update-webhook-inputs-create({id}, {inputs: {signing_secret: "whsec_..."}}) to
store it. The HogFunction picks it up and verifies incoming payloads.
Verify with external-data-sources-webhook-info-retrieve({id}). A healthy webhook has exists: true,
external_status.status: "enabled", and no error.
Webhooks are supplementary to bulk sync. The first load of a webhook-enabled schema is still done via polling
(initial_sync_complete flips to true when done); after that, the webhook becomes the primary ingestion path. A
webhook schema will still have a sync_frequency that schedules a periodic bulk refresh as a safety net. This is
expected — not something to "fix".
Step 7 — Confirm and explain what happens next
After creation (and, for webhook schemas, after Step 6):
- Call
external-data-schemas-list to show the user the initial state.
- Explain: every enabled schema enters
Running, then moves to Completed when the first sync finishes. First
syncs can take anywhere from seconds to hours depending on row count — a multi-million-row table is fine, just
slow.
- Tell them how to query:
SELECT * FROM {prefix}_{table_name} LIMIT 10 in HogQL.
- Offer to check back in a few minutes to confirm the initial syncs succeeded.
CDC setup for Postgres (optional, when requested)
If the user wants near-real-time replication from Postgres:
- Before calling db-schema, run
external-data-sources-check-cdc-prerequisites-create with their Postgres creds.
It returns {valid, errors[]} listing anything missing (wal_level, replication slot, publication, permissions).
- If
valid: false, present the errors and ask the user to fix on the Postgres side. Don't try to create a CDC
source that will immediately fail.
- Once prerequisites pass, proceed to db-schema and create. Set
sync_type: "cdc" on the tables that need it, and
include primary_key_columns for each (CDC requires them).
Important notes
- Always validate creds with db-schema before create. The create endpoint will accept invalid creds and then fail
asynchronously — the source appears in the list with status
Error and no tables. Skipping the validation step
just pushes the failure into the background.
- Present the table list before creating. Large databases may have hundreds of tables. Don't auto-select them all
— row counts and relevance matter for billing. Let the user opt in explicitly.
- Don't invent schemas. Every entry in the
schemas array must correspond to a real table from the db-schema
response. You can't "also add an orders table" unless db-schema found one.
- Prefix is load-bearing. It's part of every HogQL query the user will ever write against these tables. Pick
something short, descriptive, and not already taken.
- Prefer the secure connect-link for any credentials. Use
data-warehouse-source-connect-link so the user
authenticates in their browser — the connect page renders the source's full connection form (OAuth and credential
options alike) and stores the result without creating the source. Don't collect OAuth tokens or database passwords
in chat; pass the credential_id reference to setup — source creation always happens through setup, not the UI.
(An already-connected OAuth integration can also be passed directly via its id key, e.g.
{"hubspot_integration_id": 123}.)
- Webhooks are a separate step after create. Setting
sync_type: "webhook" on a schema doesn't register the
webhook — the create-webhook call does. Always follow create → create-webhook → webhook-info for webhook-type
schemas, and never leave a webhook schema dangling without registration (it just won't receive events).
- Webhook support is source-specific and sparse. Currently only Stripe implements
WebhookSource. Don't promise
webhooks for Hubspot, Salesforce, or Postgres — they'll use polling sync.
- Row counts drive billing. Warehouse syncing is metered by rows synced. A chatty 500M-row events table synced
hourly is very different from a 10k-row dimension table synced daily. Flag large tables and offer longer sync
frequencies (
sync_frequency: "24hour") as the default.
1---2name: setting-up-a-data-warehouse-source3description: Guide the user through connecting a new data warehouse source — Postgres, MySQL, Stripe, Hubspot, MongoDB, Salesforce, BigQuery, Snowflake, and so on. Use when the user wants to "connect Stripe", "import data from Postgres", "add a new data source", "sync my warehouse tables", or wants to pick sync methods for each table. Walks through source-type discovery, credential validation, table discovery, per-table sync_type selection, and the final create call. Also covers picking a good prefix and what to do right after creation.4---56# Setting up a data warehouse source78Use this skill when the user wants to connect an external data source to PostHog's data warehouse for the first time.910**Default to the one-step flow:** `data-warehouse-source-setup` validates credentials, discovers every table, enables11them with sensible sync defaults (incremental where possible), and creates the source in a single call — no12`schemas` array to assemble. For credentials, hand the user a secure browser link with `data-warehouse-source-connect-link`13instead of collecting secrets in chat. Only drop to the manual `wizard → db-schema → create` flow when the user wants to14hand-pick which tables sync or set non-default sync types per table.1516## When to use this skill1718- The user wants to connect a new source: "connect Stripe", "import my Postgres orders table", "sync Hubspot contacts"19- The user isn't sure what source types PostHog supports20- The user has credentials but doesn't know how to structure the `schemas` payload21- The user wants guidance on which sync method to pick per table2223## Available tools2425| Tool | Purpose |26| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |27| `data-warehouse-source-connect-link` | **Preferred for credentials** — get a secure browser/OAuth link so the user authenticates without pasting secrets in chat |28| `data-warehouse-source-setup` | **Preferred to create** — one call: validate creds, discover tables, apply sync defaults, create the source |29| `external-data-sources-wizard` | Discover which source types exist and what fields each needs (advanced flow) |30| `external-data-sources-db-schema` | Validate credentials and list tables with available sync methods per table (advanced flow) |31| `external-data-sources-create` | Advanced create — requires a `schemas` array built from the db-schema response |32| `external-data-sources-check-cdc-prerequisites-create` | Postgres CDC pre-flight check (optional, only for Postgres CDC) |33| `external-data-sources-webhook-info-retrieve` | Check if a source supports webhooks and whether one has been registered |34| `external-data-sources-create-webhook-create` | Register a webhook with the external service after source creation |35| `external-data-sources-update-webhook-inputs-create` | Supply the signing secret manually when auto-registration failed |36| `external-data-sources-list` | After creation, confirm the source is listed and see its initial status |37| `external-data-schemas-list` | See per-table sync status once the source is created |3839## Pre-flight: credential gotchas that cause most failures4041Surface these **before** collecting credentials — they're the top reasons setup fails on the first try. Validating42against them up front avoids burning credential prompts on retries.4344- **The host must be reachable from PostHog's network.** `localhost`, `127.0.0.1`, and private/RFC-1918 hosts45 (`10.x`, `192.168.x`, `172.16–31.x`) are rejected — PostHog runs the connection from its own infrastructure, not the46 user's machine. Serverless/managed Postgres (Neon, Supabase, RDS behind strict rules) often also needs PostHog's47 egress IPs allowlisted first. If the DB isn't publicly reachable, route to the browser deep-link48 (`data-warehouse-source-connect-link`) or an SSH tunnel rather than collecting credentials that can't validate.49- **Supabase is Postgres — don't collect it twice.** Use the **Session pooler** connection, not the direct host (the50 direct host is IPv6-only). The pooler host looks like `aws-0-<region>.pooler.supabase.com`, the **username** must be51 `postgres.<project-ref>`, and the **port is 6543** (not 5432). The password is the **database** password (Settings →52 Database), which is distinct from the `anon`/`service_role` JWT keys and from the Supabase account password. If53 `SUPABASE_URL` is in the project env, derive the project ref from `db.<ref>.supabase.co` to pre-fill these instead of54 asking the user to guess.55- **Many SaaS sources need a specific key type or plan** — get the right one before the create call fails:56 - **Stripe** — a _restricted_ key (`rk_live_…`), not the standard secret key (`sk_live_…`).57 - **RevenueCat** — a v2 secret key (`sk_…`) with the read scopes enabled.58 - **Sentry** — an internal-integration token, not a DSN and not a personal auth token.59 - **Convex** — requires the Professional plan.60 - **Twilio** — API Key SID + Secret, not the account auth token.61 - **Mailchimp** — the key carries its datacenter suffix (`key-usX`).62 - For send-only services (Resend, Mailgun), the key already in the project env is often restricted; the warehouse63 import needs a full/read-access key.64- **Never pass an unresolved secret reference.** If a credential field is still a `{"secretRef": ...}` object, PostHog65 can't resolve it — the create/db-schema/setup calls reject it with a clear error. Resolve it to the real value66 first, or collect credentials via `data-warehouse-source-connect-link` and pass the resulting `credential_id`.6768## Recommended: one-step setup6970Most setups should use this path — it avoids the most common failures (skipping db-schema, malformed `schemas`,71missing required fields).72731. **Discover the source type and its fields** (optional): `external-data-sources-wizard` lists every source type and74 the credential fields each needs. Use it to know what to ask the user for; skip it if the source type is obvious.752. **Collect credentials securely**: call `data-warehouse-source-connect-link({source_type})`. It returns a76 `connect_url` to a minimal connect page rendering the source's full connection form — the user authorizes via77 OAuth or enters credentials there, whichever the source offers (the response's `auth_method` tells you which to78 expect). The page validates the details against a live connection and stashes them encrypted in a temporary79 store — it does NOT create the source. After the user confirms they're done, find the stored credential id via80 `data-warehouse-stored-credentials-list` (filter by `source_type`, newest first; the page also shows the id to81 the user) and pass `{"credential_id": <id>}` to setup. Stored credentials are single-use — deleted as soon as82 setup consumes them — and expire after 24 hours.8384 Never ask the user to paste raw database passwords, API keys, or OAuth tokens into the chat.85863. **Create in one call**: `data-warehouse-source-setup({source_type, payload, prefix})`. The server validates87 credentials, discovers all tables, enables them with sync defaults (incremental where a tracking column exists,88 else append, else full_refresh — never CDC), sets `created_via=mcp`, and creates the source. The `payload` carries89 a credential reference (`{"credential_id": ...}` or the OAuth integration id key) — or inline credentials for90 headless automation; no `schemas` array is needed. On success you get the new source `id`; call91 `external-data-schemas-list` to show the user what was enabled and how each table will sync.9293Notes specific to this path:9495- **All discovered tables are enabled.** That's intended (incremental defaults keep ongoing cost low), but flag row96 counts for very large tables and offer the advanced flow if the user wants to sync only a subset.97- **Webhooks are auto-registered** for sources that support them (currently Stripe). Check the `webhook` key in the98 setup response: on success, webhook-capable tables sync in real time and webhook-only tables (e.g. Stripe Discount)99 are enabled too; on failure (e.g. the API key can't create webhooks), tables keep the polling defaults and100 webhook-only tables stay disabled — relay the `webhook.error` to the user and offer Step 6 to register manually.101 If `webhook.pending_inputs` is non-empty, collect those values and submit via102 `external-data-sources-update-webhook-inputs-create`. CDC is never chosen automatically; use the advanced flow +103 CDC steps for near-real-time Postgres.104- Inline credentials in `payload` still work for headless/automation, but prefer the connect-link handoff above.105106## Advanced: hand-pick tables (three-step flow)107108Use this when the user wants to choose exactly which tables sync or set non-default sync types. Don't try to shortcut109to `external-data-sources-create` — you need the db-schema response to build a valid `schemas` payload.110111```text112 ┌────────────────────┐113 │ 1. wizard │ What source types exist? What fields does each need?114 └────────┬───────────┘115 ▼116 ┌────────────────────┐117 │ 2. db-schema │ Validate creds. List tables + available sync methods per table.118 └────────┬───────────┘119 ▼120 ┌────────────────────┐121 │ 3. create │ Send source_type + credentials + schemas[] to actually create.122 └────────────────────┘123```124125## Workflow126127### Step 1 — Discover the source type128129Call `external-data-sources-wizard` **with `source_type` set to the kind(s) you need** (comma-separated, e.g.130`Postgres,Stripe`). The unfiltered response describes every supported source and is hundreds of KB — large enough to131blow your context budget. Only omit `source_type` when you genuinely need to enumerate every available type, and132expect a big payload if you do. The response is a dict keyed by source type. Each entry describes:133134- `name` — the canonical source_type string you'll pass to later calls (e.g. `"Postgres"`, `"Stripe"`, `"Hubspot"`).135- `label` / `caption` — human-readable.136- `fields` — the config fields needed (host, port, database, api_key, client_id/secret, ...). Each has `name`,137 `type` (input, password, switch, select, file-upload), and `required`.138- `featured`, `unreleasedSource` — use to gauge readiness. Skip sources marked `unreleasedSource: true` unless the139 user explicitly asked for a preview.140141Match the user's request to a source. If they said "Postgres", look up `Postgres`. If they said something ambiguous142like "database", present the top relevant matches (Postgres, MySQL, MongoDB, BigQuery, Snowflake, Redshift) and let143them pick.144145For OAuth-based sources (Hubspot, Salesforce, Google Ads), the wizard entry hints at an OAuth flow. These typically146need the user to authorize in the PostHog UI rather than pasting credentials — explain this and direct them to the147source setup page rather than trying to collect tokens in chat. OAuth is about _authentication_, not about how data148flows; OAuth sources still use polling bulk sync, not webhooks.149150Gather the required credentials from the user. Never ask for more fields than the wizard entry says are required —151asking for an unnecessary `port` when the source doesn't need one confuses users.152153### Step 2 — Validate credentials and discover tables154155Call `external-data-sources-db-schema` with `source_type` plus all credential fields. This does two things at once:1561571. Validates the credentials against the live source. Returns 400 with a `message` if anything is wrong (bad host,158 wrong password, permission denied). Show the error verbatim — it's often actionable ("password authentication159 failed for user 'x'").1602. If valid, returns an array of table entries. Each entry:161162```text163{164 "table": "orders",165 "should_sync": false,166 "rows": 1_250_000,167 "incremental_available": true, # can do sync_type=incremental168 "append_available": true, # can do sync_type=append169 "cdc_available": true, # can do sync_type=cdc (null = not enabled for team)170 "supports_webhooks": false, # can do sync_type=webhook for real-time push171 "incremental_fields": [ # candidates: usually updated_at, created_at, id172 {"field": "updated_at", "type": "datetime", "label": "updated_at", ...},173 {"field": "created_at", "type": "datetime", ...},174 {"field": "id", "type": "integer", ...}175 ],176 "detected_primary_keys": ["id"],177 "available_columns": [{"field": "id", "type": "integer", "nullable": false}, ...],178 "description": "..."179}180```181182Present this to the user. Don't dump the raw JSON — summarize: which tables were found, row counts, and the default183sync method recommendation per table (see [sync-type decision guide](./references/sync-types.md)).184185### Step 3 — Confirm per-table sync configuration186187For each table the user wants to sync, pick a sync_type. See the188[sync-type decision guide](./references/sync-types.md) for detailed rules, but the short version is:189190- **Small / dimension tables (<50k rows, no natural ordering column):** `full_refresh` — simple and always correct.191- **Large tables with an `updated_at` / `modified_at`:** `incremental` — much cheaper per sync.192- **Append-only immutable tables (logs, events):** `append` if available — preserves history.193- **Postgres with CDC enabled and you need near-real-time:** `cdc` — requires primary keys and Postgres prerequisites.194- **Sources that support webhooks (currently Stripe):** for near-real-time ingestion set `sync_type: "webhook"` on195 the tables where `supports_webhooks: true`, then register the webhook as a post-create step (see step 6 below).196 Tables that don't support webhooks on the same source still need a bulk sync_type.197198For each schema that will use `incremental`/`append`/`cdc`, you also need:199200- `incremental_field` — which column to track for high-water-mark ordering. Pick from the `incremental_fields` list201 returned by db-schema. Prefer `updated_at` over `created_at` (updated_at catches late-arriving updates;202 created_at misses them). For integer-only tables, use the monotonically increasing primary key.203- `incremental_field_type` — must match the chosen field's type (`datetime`, `timestamp`, `date`, `integer`,204 `numeric`, `objectid`).205- `primary_key_columns` — required for CDC. Use `detected_primary_keys` from db-schema.206207### Step 4 — Pick a good prefix208209The source's `prefix` is prepended to table names in HogQL. Tables end up as `{prefix}_{table_name}`.210211- Default to the source type lowercased if there's only one source of that type: `stripe`, `postgres`.212- If the user already has a Postgres source, pick something distinguishing: `postgres_prod`, `postgres_analytics`.213- Use lowercase, underscore-separated. The prefix becomes part of every HogQL query the user writes.214215Confirm the prefix with the user before creating — changing it later is possible but renames every table.216217### Step 5 — Create the source218219Call `external-data-sources-create` with:220221```json222{223 "source_type": "Postgres",224 "prefix": "postgres_prod",225 "payload": {226 "host": "...",227 "port": "5432",228 "dbname": "...",229 "user": "...",230 "password": "...",231 "schema": "public",232 "schemas": [233 {234 "name": "orders",235 "should_sync": true,236 "sync_type": "incremental",237 "incremental_field": "updated_at",238 "incremental_field_type": "datetime",239 "primary_key_columns": ["id"]240 },241 {242 "name": "users",243 "should_sync": true,244 "sync_type": "full_refresh"245 },246 {247 "name": "audit_log",248 "should_sync": false249 }250 ]251 }252}253```254255Rules for the `schemas` array:256257- Every table returned by db-schema should be included, even ones the user doesn't want (set `should_sync: false`).258 Tables the user didn't mention default to `should_sync: false`.259- `sync_type` is required only when `should_sync: true`.260- `incremental_field` / `incremental_field_type` must be present when `sync_type` is `incremental` or `append`.261- `primary_key_columns` must be present when `sync_type` is `cdc`.262263On success you'll get back a source with a new `id`. The first sync is triggered automatically.264265### Step 6 — Register a webhook (only when any schema is `sync_type: "webhook"`)266267Webhook-type schemas don't start receiving data just by existing — the external service needs to know where to POST268events, and PostHog needs to know how to verify them. This is a second call after source creation, not part of the269`external-data-sources-create` payload. Do this **before** telling the user the setup is complete, otherwise they270hear "syncs are running" while the push channel is still unregistered.271272Only needed when at least one schema on the source has `sync_type: "webhook"` and `should_sync: true`. Currently only273Stripe implements this flow; for everything else skip this step.274275Before calling create-webhook, check `external-data-sources-webhook-info-retrieve({id})`. If it already returns276`exists: true`, do NOT call create-webhook again — each successful call registers a new external endpoint and would277result in duplicate deliveries.2782791. Call `external-data-sources-create-webhook-create({id})`. PostHog:280 - creates the HogFunction that will receive webhook POSTs,281 - builds a schema_mapping from external event types to PostHog schema ids,282 - calls the source's API (e.g. Stripe) to register the webhook URL and subscribe to the relevant events,283 - on Stripe, auto-captures the `signing_secret` and stores it securely.284285 Returns `{success, webhook_url, error}`. On success report the `webhook_url` to the user for their records — but286 they don't need to paste it anywhere; registration is already done.2872882. If `success: false` with a permissions error like "API key doesn't have permission to create webhooks":289 - The HogFunction is still created, just disabled.290 - Ask the user to create the webhook manually in the source's dashboard using the returned `webhook_url`.291 - Have them copy the signing secret from the source's webhook settings.292 - Call `external-data-sources-update-webhook-inputs-create({id}, {inputs: {signing_secret: "whsec_..."}})` to293 store it. The HogFunction picks it up and verifies incoming payloads.2942953. Verify with `external-data-sources-webhook-info-retrieve({id})`. A healthy webhook has `exists: true`,296 `external_status.status: "enabled"`, and no `error`.297298Webhooks are supplementary to bulk sync. The first load of a webhook-enabled schema is still done via polling299(`initial_sync_complete` flips to true when done); after that, the webhook becomes the primary ingestion path. A300webhook schema will still have a `sync_frequency` that schedules a periodic bulk refresh as a safety net. This is301expected — not something to "fix".302303### Step 7 — Confirm and explain what happens next304305After creation (and, for webhook schemas, after Step 6):306307- Call `external-data-schemas-list` to show the user the initial state.308- Explain: every enabled schema enters `Running`, then moves to `Completed` when the first sync finishes. First309 syncs can take anywhere from seconds to hours depending on row count — a multi-million-row table is fine, just310 slow.311- Tell them how to query: `SELECT * FROM {prefix}_{table_name} LIMIT 10` in HogQL.312- Offer to check back in a few minutes to confirm the initial syncs succeeded.313314## CDC setup for Postgres (optional, when requested)315316If the user wants near-real-time replication from Postgres:3173181. Before calling db-schema, run `external-data-sources-check-cdc-prerequisites-create` with their Postgres creds.319 It returns `{valid, errors[]}` listing anything missing (wal_level, replication slot, publication, permissions).3202. If `valid: false`, present the errors and ask the user to fix on the Postgres side. Don't try to create a CDC321 source that will immediately fail.3223. Once prerequisites pass, proceed to db-schema and create. Set `sync_type: "cdc"` on the tables that need it, and323 include `primary_key_columns` for each (CDC requires them).324325## Important notes326327- **Always validate creds with db-schema before create.** The create endpoint will accept invalid creds and then fail328 asynchronously — the source appears in the list with status `Error` and no tables. Skipping the validation step329 just pushes the failure into the background.330- **Present the table list before creating.** Large databases may have hundreds of tables. Don't auto-select them all331 — row counts and relevance matter for billing. Let the user opt in explicitly.332- **Don't invent schemas.** Every entry in the `schemas` array must correspond to a real table from the db-schema333 response. You can't "also add an orders table" unless db-schema found one.334- **Prefix is load-bearing.** It's part of every HogQL query the user will ever write against these tables. Pick335 something short, descriptive, and not already taken.336- **Prefer the secure connect-link for any credentials.** Use `data-warehouse-source-connect-link` so the user337 authenticates in their browser — the connect page renders the source's full connection form (OAuth and credential338 options alike) and stores the result without creating the source. Don't collect OAuth tokens or database passwords339 in chat; pass the `credential_id` reference to setup — source creation always happens through setup, not the UI.340 (An already-connected OAuth integration can also be passed directly via its id key, e.g.341 `{"hubspot_integration_id": 123}`.)342- **Webhooks are a separate step after create.** Setting `sync_type: "webhook"` on a schema doesn't register the343 webhook — the `create-webhook` call does. Always follow create → create-webhook → webhook-info for webhook-type344 schemas, and never leave a webhook schema dangling without registration (it just won't receive events).345- **Webhook support is source-specific and sparse.** Currently only Stripe implements `WebhookSource`. Don't promise346 webhooks for Hubspot, Salesforce, or Postgres — they'll use polling sync.347- **Row counts drive billing.** Warehouse syncing is metered by rows synced. A chatty 500M-row events table synced348 hourly is very different from a 10k-row dimension table synced daily. Flag large tables and offer longer sync349 frequencies (`sync_frequency: "24hour"`) as the default.