--- name: setting-up-a-custom-rest-source description: > Connect an arbitrary REST API to the PostHog data warehouse as a Custom source by authoring a JSON manifest, with no per-source code. Use when the user points at an API that has no built-in PostHog connector — "import data from this REST API", "sync my internal API", "connect this API from its docs", "build a custom data warehouse source" — and gives a docs URL or a natural-language description of the endpoints. Walks through drafting the RESTAPIConfig manifest (auth — bearer, API key, HTTP basic, or OAuth2 client credentials / refresh token — pagination, record path, incremental cursor, parent/child fan-out), validating it, test-reading live rows to verify the field mappings, and creating the source. If the API already has a native PostHog connector, use setting-up-a-data-warehouse-source instead — this skill checks the connector registry first and only handles APIs with no native connector. --- # Setting up a Custom REST source 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. ## When to use this skill - The user wants to import an API that has **no built-in connector** — an internal service, a niche SaaS, a public API — and can give you its docs URL or describe its endpoints. - The user explicitly asks for a "custom REST source", "custom source manifest", or to "build a connector from docs". 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. ## The grammar Read [references/manifest-reference.md](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: ```json { "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. ## Available tools | 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. | ## Workflow ### Step 0 — Check for a native connector first 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. ### Step 1 — Gather the API shape 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: - the **path** (relative to a common `base_url`) and method (GET, or POST for query-style read endpoints), - the **auth scheme** (bearer token / API key in header or query / HTTP basic / OAuth2 with a customer-owned client — `client_credentials` or a pre-obtained refresh token; the interactive `authorization_code` flow is not supported), - the **record path** — where the array of records sits in the JSON response (e.g. `data`, `results`, `items`), - how the API **paginates** (next-URL, link header, cursor, offset, page number, or single page), - a **primary key** field, and - optionally an **incremental cursor** field (`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. ### Step 2 — Draft the manifest Author the `RESTAPIConfig` from [references/manifest-reference.md](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. ### Step 3 — Validate and list resources Call `external-data-sources-db-schema` with `{ source_type: "Custom", manifest_json: "", auth_token: "" }`. 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. ### Step 4 — Test-read each resource For each resource, call `external-data-sources-preview-resource` with `{ source_type: "Custom", payload: { manifest_json, auth_token }, resource_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. - **the incremental `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. ### Step 5 — Create the source Call `data-warehouse-source-setup` with `{ source_type: "Custom", payload: { manifest_json, auth_token }, prefix: "" }` (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`. ## Important notes - **Always preview before creating.** db-schema proves the manifest parses and the credential works; preview proves the field mappings (`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. - **Secrets only in `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). - **One level of fan-out.** A child resource may depend on a top-level parent; a parent can't itself be a child. - **GET and POST only.** The engine reads upstream data; PUT/PATCH/DELETE are rejected so a manifest can't mutate the source API. - **Pick the cursor carefully.** Prefer an `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. - **OAuth2 secrets are adopted into a server-managed credential store** on the first db-schema / preview / create call, and any rotated single-use refresh token is persisted server-side — so keep the entire `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.