·
Last changedVersion:
No hardening report available.
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.
schemas payload| Tool | Purpose |
|---|---|
| Preferred for credentials — get a secure browser/OAuth link so the user authenticates without pasting secrets in chat |
| Preferred to create — one call: validate creds, discover tables, apply sync defaults, create the source |
| Discover which source types exist and what fields each needs (advanced flow) |
| Validate credentials and list tables with available sync methods per table (advanced flow) |
| Advanced create — requires a |
| Postgres CDC pre-flight check (optional, only for Postgres CDC) |
| Check if a source supports webhooks and whether one has been registered |
| Register a webhook with the external service after source creation |
| Supply the signing secret manually when auto-registration failed |
| After creation, confirm the source is listed and see its initial status |
| See per-table sync status once the source is created |
| See existing destinations, including the PostHog warehouse |
| Secure browser link where the user enters a destination's credentials; returns nothing secret |
| Create a destination from the integration the connect page saved |
| Attach existing sources to a destination |
| Set a source's full destination list, for example to keep the PostHog warehouse before attaching |
| Diagnose a destination that is failing or paused (does not resume it) |
| Fallback to find a stored credential id when the user cannot give the id the connect page showed |
| Fallback to find the integration a destination connect page saved |
| See which destinations a source writes to |
| See per-table destination overrides for a source |
| Read a destination's details, including its latest error |
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.
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.
Recommend a dedicated read-only database user, a restricted tunnel account, and allowlisting limited to PostHog's
egress IPs.aws-0-<region>.pooler.supabase.com, the username must be
postgres.<project-ref>; have the user copy the exact host, port and username from the Session pooler connection
details in the Supabase dashboard. 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, ask the user before reading it (it is a non-secret identifier), then derive the project ref from db.<ref>.supabase.co to pre-fill these instead of
asking the user to guess.rk_live_…), not the standard secret key (sk_live_…).sk_…) with the read scopes enabled.key-usX).{"secretRef": ...} object, PostHog
can't resolve it — the create/db-schema/setup calls reject it with a clear error. Instead, collect credentials via data-warehouse-source-connect-link and pass the resulting credential_id.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, ask the user for the id the page showed them. Only if they cannot
give it, use data-warehouse-stored-credentials-list (filter by source_type, newest first) and confirm the
entry's source type and creation time with the user before using it. Then pass {"credential_id": <id>} to setup. Stored credentials are single-use — deleted as soon as
setup consumes them — and expire after 24 hours. Tell the user the link and the credential id are for them alone and should
be kept private, and use the stored credential promptly.
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:
data-warehouse-source-setup, tell the user that every
discovered table will be enabled and that row counts drive billing, and get their explicit confirmation. If the
user does not confirm, or wants only a subset, use the advanced flow instead.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.payload still work for headless/automation, but prefer the connect-link handoff above.
Use inline credentials only for non-interactive automation where the values come from a secret store and no
person types them into chat.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.
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 when
you do, pass fields: ['*.name', '*.caption'] so the response carries just the names instead of every source's config
field definitions. 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").caption — human-readable description.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 hand them a link from
data-warehouse-source-connect-link 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.
Use the wizard fields to explain what the user will need. Collect secrets through
data-warehouse-source-connect-link where possible; use this hand-pick flow with directly supplied credentials only
when the user knowingly chooses it, and ask the user before handling any inline secret value. 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.
Call external-data-sources-db-schema with source_type plus all credential fields. This does two things at once:
message if anything is wrong (bad host,
wrong password, permission denied). Show the error to the user as quoted text — it's often actionable ("password authentication
failed for user 'x'").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).
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:
full_refresh — simple and always correct.updated_at / modified_at: incremental — much cheaper per sync.append if available — preserves history.cdc — requires primary keys and Postgres prerequisites.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.The source's prefix is prepended to table names in HogQL. Tables end up as {prefix}_{table_name}.
stripe, postgres.postgres_prod, postgres_analytics.Confirm the prefix with the user before creating — changing it later is possible but renames every table.
Call external-data-sources-create with:
The credential values in this example are placeholders showing the payload shape; obtain real values only through the secure path described above, with the user's consent.
Rules for the schemas array:
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.
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 every create-webhook call, always 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:
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 the call does not succeed, use this manual fallback:
webhook_url.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".
After creation (and, for webhook schemas, after Step 6):
external-data-schemas-list to show the user the initial state.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.SELECT * FROM {prefix}_{table_name} LIMIT 10 in HogQL.If the user wants near-real-time replication from Postgres:
external-data-sources-check-cdc-prerequisites-create with their Postgres creds. Recommend a
dedicated read-only database user with only the minimal replication grants CDC needs.
It returns {valid, errors[]} listing anything missing (wal_level, replication slot, publication, permissions).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.sync_type: "cdc" on the tables that need it, and
include primary_key_columns for each (CDC requires them).Only do this when the user asks for their data in their own Snowflake, BigQuery, Postgres, Redshift, Databricks, S3 or Azure Blob storage. These tools exist only when the feature is on for the project.
Get explicit user confirmation before calling external-data-destinations-create,
external-data-sources-destinations-update or external-data-destinations-add-sources: state the target, which
sources and tables will be copied out of PostHog, the effect on PostHog tables, and the billing impact.
external-data-destinations-list first. If a destination for that target already exists, reuse it, but only
when its status is healthy. A failing or paused one needs fixing first (see the end of this section).external-data-destinations-connect-link with the destination_type (for S3-compatible storage such as R2 or
MinIO, also pass integration_kind: "s3-compatible"). Share connect_url and wait until the user says they are
done. The page saves an integration and shows its id.integrations-list with the returned
integration_kind and take the entry with the latest created_at (the list is oldest first). Confirm its
display_name and created_at with the user, matched against this session, before you use it.external-data-destinations-create with type, name, integration and config. Creation tests the
connection for every type except Redshift, and a 400 names the field to fix. A wrong Redshift target only shows up
when the first sync fails. The target cannot change later, so confirm it before creating.external-data-sources-destinations-retrieve. When destination_ids holds only the PostHog warehouse
destination (is_posthog_warehouse in external-data-destinations-list), the source may write there by
default, and attaching would stop its PostHog tables updating. If the user wants to keep them, call
external-data-sources-destinations-update with that same list before step 6: it makes the warehouse an
explicit link. Skip that call while any destination in external-data-destinations-list is paused, because it
would delete a hidden link to that destination. Tell the user instead. When destination_ids is empty, tell
the user the PostHog tables stop updating.external-data-schemas-destinations-retrieve for the source's tables. Those with inherits_from_source: false
will not reach the new destination.external-data-destinations-add-sources with the source ids. A source that has already synced sends only
new rows to the destination. Offer resync: true to copy the history, pass it only after the user separately agrees, and say that a resync re-reads every row
(including overridden tables that do not reach the new destination) and bills every destination the source
writes to.If a destination shows status: "failing" or "paused", run external-data-destinations-test-connection and relay
the message of the first failed step. Redshift has no test: relay latest_error from
external-data-destinations-retrieve instead. The test only reports. A paused destination stays paused until the user
fixes the problem and saves it on the warehouse destinations page.
Error and no tables. Skipping the validation step
just pushes the failure into the background.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.external-data-sources-destinations-retrieve shows where a source writes, when the feature is on for
the project). Setting that up is a separate step (see "Sending a source to the user's own warehouse") and never something to do as part of creating a source — adding a
destination to a source that has already synced leaves it holding none of the history until the tables are resynced,
and a resync bills every destination the source writes to.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}.)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).WebhookSource. Don't promise
webhooks for Hubspot, Salesforce, or Postgres — they'll use polling sync.sync_frequency: "24hour" for large tables), and set it only after the user confirms, in both
the one-step and advanced flows.Bring Chainguard's hardening pipeline to your first-party skills — custom rulesets, automated remediation, full audit trail.
skills.cgr.dev/public/github.com/posthog/skills/omnibus/setting-up-a-data-warehouse-source:latest
MIT
HARDENING.md
LICENSE
SKILL.md
references/sync-types.md