From posthog
Walks through authoring a RESTAPIConfig manifest to connect an arbitrary REST API without a native PostHog connector as a custom warehouse source.
How this skill is triggered — by the user, by Claude, or both
Slash command
/posthog:setting-up-a-custom-rest-sourceThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
A **Custom source** imports any HTTP REST API into queryable warehouse tables from a JSON **manifest** — no
A Custom source imports any HTTP REST API into queryable warehouse tables from a JSON manifest — no
per-source Python. The manifest is a RESTAPIConfig: the same shape that powers PostHog's built-in REST connectors
(Intercom, Attio, Sentry, …), so the generic REST engine handles auth, pagination, JSONPath record extraction, and
incremental cursors for you. Your job is to author a correct manifest and prove it against live data before creating
the source.
This is an alpha capability. Caps: at most 50 resources per manifest, and at most 5 Custom sources per project.
A Custom source is the fallback — only correct when no native connector fits. PostHog ships hundreds of native
connectors, far more than could be listed here, so never assume an API has no built-in — most well-known SaaS apps
and databases do, and guessing wrong misroutes them into a hand-authored manifest that duplicates a battle-tested
connector. Step 0 below makes you check the registry before drafting anything; if a native connector matches, hand off
to setting-up-a-data-warehouse-source instead.
Read references/manifest-reference.md before drafting — it is the full
RESTAPIConfig field reference (auth types, the six paginators, incremental cursors, parent/child fan-out) with worked
examples for each. Draft the manifest from that grammar; don't guess field names.
The skeleton:
{
"client": {
"base_url": "https://api.example.com/v1",
"auth": { "type": "bearer" }
},
"resources": [
{
"name": "users",
"primary_key": "id",
"endpoint": {
"path": "/users",
"data_selector": "data",
"paginator": { "type": "json_response", "next_url_path": "next" },
"incremental": { "cursor_path": "updated_at", "start_param": "since" }
}
}
]
}
Secrets never go inline in the manifest. manifest_json holds only the non-secret structure. The credential
travels in a separate payload key chosen by the manifest's client.auth.type: auth_token (bearer), auth_api_key
(api_key), auth_password (http_basic), or auth_oauth2_client_secret for oauth2 (plus
auth_oauth2_refresh_token for the refresh-token grant only). The
engine injects it at run time, and PostHog redacts it from every response. Putting a token inline is rejected at
validation.
| Tool | Purpose |
|---|---|
external-data-sources-wizard | List every native source type PostHog supports. Run this first (Step 0) to check whether the target API already has a built-in connector before drafting a manifest. |
external-data-sources-db-schema | Validate the manifest + credential and list the resources (tables) it exposes, with detected primary keys and incremental cursors. This is the validate-and-list step. |
external-data-sources-preview-resource | Read a small live sample of rows for one resource — verify data_selector / primary_key / cursor_path against real data before creating anything. |
data-warehouse-source-setup | Create the source. Enables all manifest resources with sync defaults in one call. |
external-data-sources-create | Advanced create — lets the user hand-pick which resources sync via a schemas array. |
external-data-schemas-list | After creation, watch per-table sync status. |
Before drafting anything, call external-data-sources-wizard to list the native source types and check whether the
target API is among them, matching on the service name. A Custom source is the fallback for APIs with no native
connector; do not skip this check on the assumption that a well-known API isn't supported — most are.
If a native connector matches, stop and tell the user the built-in path is simpler and battle-tested (it handles
auth, pagination, schema, and incremental sync for you), and hand off to setting-up-a-data-warehouse-source. Only
continue with this skill when the user has no matching native connector, or explicitly wants to exercise the custom
REST path despite one existing.
Get either a docs URL (fetch it and read the auth scheme, the list endpoints, their response envelopes, and any pagination) or a natural-language description of the endpoints. You need, per resource you'll import:
base_url) and method (GET, or POST for query-style read endpoints),client_credentials or a pre-obtained refresh token; the interactive authorization_code flow is not supported),data, results, items),updated_at-style) so re-syncs only fetch new/changed rows.Ask the user for the credential value, but tell them you'll only ever place it in the auth_* payload key, never in
the manifest.
Author the RESTAPIConfig from references/manifest-reference.md. Match the auth
block to the scheme, pick the paginator that matches the docs, set data_selector to the record path, and add an
incremental block when the API has an updated_at-style cursor and a matching query param. For an endpoint whose
rows must be fetched per parent (e.g. /forms/{form_id}/responses), use a parent/child resolve param — see the
fan-out example. Keep it to one level of nesting.
Call external-data-sources-db-schema with { source_type: "Custom", manifest_json: "<stringified manifest>", auth_token: "<credential>" }. The credential key is not literally auth_* — use the one for your auth type:
auth_token (bearer), auth_api_key (api_key), auth_password (http_basic), or auth_oauth2_client_secret (+
auth_oauth2_refresh_token for the refresh-token grant). It validates the manifest structure,
the fan-out graph, and the credential (a bounded live probe),
then returns one table entry per resource with detected_primary_keys and incremental_fields. If it returns a 400,
the message is plain English (e.g. resources[0].endpoint.path: must not be empty) — fix the manifest and retry.
Loop here until it validates.
For each resource, call external-data-sources-preview-resource with { source_type: "Custom", payload: { manifest_json, auth_token }, resource_name: "<name>", limit: 10 } (the auth_token key varies by auth type, as in
Step 3). It returns up to limit real rows plus the inferred
columns. Check that:
data_selector is right — rows are the records you expect, not a wrapper object. If rows looks like
[{ "data": [...] }] you pointed at the envelope, not the array; fix data_selector.primary_key exists in the rows and is unique.cursor_path field is present in the rows and looks like a sortable timestamp/id.A live failure (unreachable host, auth rejected) comes back as error with empty rows — fix credentials or the URL
and retry. Iterate Steps 2–4 until the sample looks right.
Call data-warehouse-source-setup with { source_type: "Custom", payload: { manifest_json, auth_token }, prefix: "<short_name>" } (the auth_token key varies by auth type, as in Step 3). It enables every resource in the manifest with sensible sync defaults (incremental where the
manifest declares a cursor, else full refresh) and creates the source. If the user only wants a subset of resources,
use external-data-sources-create with a schemas array instead (see setting-up-a-data-warehouse-source for the
schemas shape). Pick a short lowercase prefix — tables become {prefix}_{resource_name} in HogQL.
After creation, call external-data-schemas-list to show the user the initial sync status, and tell them how to query:
SELECT * FROM {prefix}_{resource_name} LIMIT 10.
data_selector / primary_key / cursor_path) against real rows. Skipping preview is the most
common way to create a source that syncs zero or malformed rows.auth_*. Never inline a token/key/password in manifest_json — it's rejected, and the manifest
is non-secret (it round-trips to the client).updated_at-style field over created_at (it catches edits), and set
cursor_type when the cursor isn't a datetime (e.g. an integer id) so it's compared with the right type.client.auth block
identical across those calls within one setup, and re-submit the same secrets each time. Changing any auth-block
field mid-setup discards the stored rotation, and providers that rotate single-use refresh tokens will then reject
the next mint until the user fetches a fresh token. Never set auth_oauth2_integration_id yourself (it is server-owned); to
reconnect a source whose token broke, update it with re-entered auth_oauth2_client_secret /
auth_oauth2_refresh_token. See the OAuth2 section of the manifest reference for the auth block fields.claude plugin install posthog@claude-plugins-officialGuides connecting external data sources (Postgres, MySQL, Stripe, etc.) to PostHog's data warehouse with credential validation, table discovery, and sync type selection.
Plans new DataHub connectors by classifying source systems, researching them, and generating a _PLANNING.md blueprint with entity mapping and architecture decisions.
Operates the anysite CLI for web data extraction, dataset pipelines, batch API processing, scheduling, SQL queries, database loading, and LLM-powered data analysis.