Setting Up A Custom Rest Source
PostHogOfficialPublished by PostHog
- Skill
- Workflow
- Data and AI
- MIT
- No issues found
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill provides a structured workflow for integrating third-party REST APIs into the PostHog data warehouse when no native connector exists. By authoring a JSON-based RESTAPIConfig manifest, users can define authentication, pagination, and data extraction logic without writing custom code. The process emphasizes validation and live data previewing to ensure accurate schema mapping and reliable data ingestion.
What it does
- Checks for existing native connectors to prevent redundant work.
- Guides the creation of a JSON manifest for API authentication and data extraction.
- Supports multiple authentication schemes including Bearer, API Key, HTTP Basic, and OAuth2.
- Validates manifest structure and credentials against live API endpoints.
- Previews live data samples to verify field mappings and primary keys.
- Enables automated synchronization of API resources into queryable warehouse tables.
When to use it
- When you need to import data from a REST API that lacks a built-in PostHog connector.
- When you have access to API documentation or endpoint descriptions.
- When you need to build a custom data warehouse source for internal services or niche SaaS platforms.
Advantages
- No per-source Python code required.
- Leverages a battle-tested generic REST engine for auth and pagination.
- Provides built-in validation and preview tools to catch errors before deployment.
- Supports incremental sync to optimize data ingestion.
Usage examples
- “I need to import data from my internal API which has no native PostHog connector.”
- The agent will first verify the lack of a native connector, then guide you through drafting a JSON manifest based on your API documentation.
- “How do I connect a custom REST source to PostHog?”
- The agent will initiate the workflow to gather your API details, help you draft the RESTAPIConfig, and walk you through the validation and preview steps.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/posthog/setting-up-a-custom-rest-source/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip setting-up-a-custom-rest-source.zip -d .agents/skills
Documentation by PostHog
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 a beta 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 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.
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_credentialsor a pre-obtained refresh token; the interactiveauthorization_codeflow 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. 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: "<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.
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: "<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_selectoris right —rowsare the records you expect, not a wrapper object. Ifrowslooks like[{ "data": [...] }]you pointed at the envelope, not the array; fixdata_selector.primary_keyexists in the rows and is unique.the incremental
cursor_pathfield 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: "<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.
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 inmanifest_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 overcreated_at(it catches edits), and setcursor_typewhen 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.authblock 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 setauth_oauth2_integration_idyourself (it is server-owned); to reconnect a source whose token broke, update it with re-enteredauth_oauth2_client_secret/auth_oauth2_refresh_token. See the OAuth2 section of the manifest reference for the auth block fields.
Files
- ATTRIBUTION.mdAdded by AutoGineer881 B
- LICENSELicence file added by AutoGineer1.0 KiB
- SKILL.md11.9 KiB
- references/manifest-reference.md14.4 KiB
references/manifest-reference.md
Custom REST source manifest reference
The manifest is a JSON RESTAPIConfig. The generic REST engine consumes it directly, so every field below maps to a
real engine behavior. Only the fields the engine reads are documented here; unknown keys are ignored.
Contents
Top-level shape
Auth
Endpoint fields
Paginators
Incremental sync
Parent/child fan-out
Worked examples
Top-level shape
{
"client": { "base_url": "...", "auth": { ... }, "headers": { ... }, "paginator": { ... } },
"resource_defaults": { "endpoint": { ... } },
"resources": [ { "name": "...", "primary_key": "...", "endpoint": { ... } } ]
}client.base_url(required) — every resource path resolves against this. On PostHog Cloud it must behttps://. Internal/private hosts are rejected (SSRF guard).client.headers(optional) — static headers sent on every request.client.paginator(optional) — a default paginator for all resources; override per resource on the endpoint.resource_defaults.endpoint(optional) — endpoint fields merged into every resource (e.g. a shared paginator ordata_selector). Incremental config isn't allowed here; setendpoint.incrementalon each resource instead.resources(required, 1–50) — one entry per table you want to import. Each has a uniquename(becomes the table name) and anendpoint.
Auth
The auth type lives in the manifest; the secret value travels in a separate payload key and is injected at run time. Never put the secret in the manifest.
client.auth block | Secret payload key(s) | Sends |
|---|---|---|
{ "type": "bearer" } | auth_token | Authorization: Bearer <token> |
{ "type": "api_key", "name": "X-Api-Key", "location": "header" } | auth_api_key | the key in header / query param name |
{ "type": "api_key", "name": "api_key", "location": "query" } | auth_api_key | ?api_key=<key> |
{ "type": "http_basic", "username": "user" } | auth_password | HTTP Basic with the given username + password |
{ "type": "oauth2", ... } (see below) | auth_oauth2_client_secret (+ auth_oauth2_refresh_token for the refresh_token grant only) | Authorization: Bearer <minted access token> |
location is one of header, query, param, cookie. query and param are synonyms — both append
name=<key> to the URL query string, so use either (prefer query); they differ only in spelling, not behavior. For
an API with no auth, omit auth entirely.
OAuth2
For APIs where the customer brings their own OAuth2 client, the engine mints access tokens itself from the token endpoint declared in the manifest:
{
"type": "oauth2",
"client_id": "my-client-id",
"token_url": "https://auth.example.com/oauth/token",
"grant_type": "refresh_token",
"scopes": "read:orders read:users"
}grant_type—client_credentials(machine-to-machine; default) orrefresh_token(the user supplies a pre-obtained refresh token). The interactiveauthorization_codeflow is not supported — for providers that only issue tokens that way, the user must obtain a refresh token out of band first.Secrets travel in
auth_oauth2_client_secret(the client secret) and, for therefresh_tokengrant,auth_oauth2_refresh_token. Never inline them in the manifest.Optional knobs for non-standard token endpoints:
access_token_name/expires_in_name(response fields when they aren'taccess_token/expires_in),expiry_date_format(strptime format for absolute-datetime expiries),extra_token_request_params(extra form params, e.g.audience),token_request_headers, andclient_auth_method(body, the default, orbasicfor HTTP Basic client auth).
PostHog adopts the OAuth2 secrets into a server-managed credential store on the first validation, preview, or create call — they are never kept in the source's stored config, and any rotated single-use refresh token the provider returns is persisted server-side. Two practical consequences:
Keep the entire
client.authblock identical across the db-schema → preview → create calls of one setup. The stored credential is found again by matchingclient_id+token_url+grant_type, but changing any auth-block field (scopes, token-request knobs,client_auth_method, …) makes PostHog treat the re-submitted refresh token as a deliberately new credential and discard the stored rotation — with a single-use-rotating provider the next mint then fails withinvalid_grant. Only change the auth block mid-setup together with a freshly issued refresh token.auth_oauth2_integration_idmay appear in a stored source's config — it is the server-owned pointer to the credential store. Never set or copy it yourself; on create it is ignored, and to reconnect a broken credential you update the source with re-enteredauth_oauth2_client_secret/auth_oauth2_refresh_tokeninstead.
Endpoint fields
{
"path": "/users",
"method": "GET",
"params": { "status": "active" },
"json": { ... },
"data_selector": "data",
"paginator": { "type": "json_response", "next_url_path": "next" },
"incremental": { "cursor_path": "updated_at", "start_param": "since" }
}path(required) — appended tobase_url.method—GET(default) orPOST. POST is for read/query-style endpoints;PUT/PATCH/DELETEare rejected.params— static query params, or the engine's incremental/resolve specs (see below).json— request body (for POST query endpoints).data_selector— JSONPath to the array of records inside the response. For{ "data": [ ... ] }use"data"; for{ "results": { "items": [ ... ] } }use"results.items". Omit only if the response body is the array.paginator— see below. Required for any paginated endpoint. Omitting it (or{ "type": "auto" }) fetches only the first response — there is no auto-detection — so a paginated API would silently import just page one.incremental— see below.
primary_key sits on the resource, not the endpoint: "primary_key": "id" or "primary_key": ["org_id", "id"].
Paginators
Set endpoint.paginator (or client.paginator) to one of:
| Type | Config keys | Use when |
|---|---|---|
json_response | next_url_path (JSONPath to the next-page URL in the body) | response carries a next-page URL/path |
header_link | links_next_key (rel, default next) | pagination via the Link response header |
cursor | cursor_path (JSONPath to the next cursor), cursor_param | response returns an opaque cursor you pass back as a query param |
offset | limit, offset_param, limit_param, total_path, maximum_offset | classic ?offset=&limit= pagination |
page_number | base_page (first page number, e.g. 0 or 1), page_param, total_path, maximum_page | classic ?page=N pagination |
single_page | — | endpoint returns everything in one response |
auto | — | fetches only the first response — no auto-detection; single-page endpoints only |
cursor, offset, and page_number also take param_location: "query" (default) or "json" to write the page
fields into the request body. With "json", a dotted name is a path into nested objects, so "variables.offset"
sets {"variables": {"offset": ...}}. Use this for GraphQL APIs, which read their arguments from variables.
Incremental sync
endpoint.incremental makes re-syncs fetch only new/changed rows:
{
"cursor_path": "updated_at",
"start_param": "since",
"end_param": "until",
"cursor_type": "datetime",
"datetime_format": "%Y-%m-%dT%H:%M:%SZ"
}cursor_path— JSONPath to the cursor field on each record (the high-watermark).start_param— query param the engine sets to the last-seen cursor value on the next sync.end_param(optional) — upper-bound param if the API supports a window.param_location(optional) —"query"(default) or"json"to sendstart_paramandend_paramin the request body. With"json", a dotted name such as"variables.since"is a path into nested objects. A child resource in a fan-out sends no body, so it can't use"json".cursor_type(optional) —datetime(default),date,timestamp,integer,numeric, orobjectid. Set it when the cursor is not a datetime (e.g. an autoincrement id) so it's stored and compared correctly.datetime_format(optional) — strftime pattern for how the watermark is serialized intostart_param. Defaults to ISO-8601. Set it for strict APIs that reject the default.
Prefer an updated_at-style cursor over created_at: created_at misses edits to existing rows.
Parent/child fan-out
When a resource's rows must be fetched per parent row (/forms/{form_id}/responses), bind the parent field into the
child path with a resolve param:
{
"resources": [
{ "name": "forms", "primary_key": "id", "endpoint": { "path": "/forms", "data_selector": "items" } },
{
"name": "responses",
"primary_key": "token",
"endpoint": {
"path": "/forms/{form_id}/responses",
"data_selector": "items",
"params": { "form_id": { "type": "resolve", "resource": "forms", "field": "id" } }
}
}
]
}Rules: the {form_id} placeholder must sit within the path (not start it, so a parent value can't redirect the
request off base_url); the parent must be a top-level resource; only one level of nesting is supported; and a
resource may have at most one resolve param.
Worked examples
Bearer + next-URL pagination + incremental
{
"client": { "base_url": "https://api.acme.com/v2", "auth": { "type": "bearer" } },
"resources": [
{
"name": "orders",
"primary_key": "id",
"endpoint": {
"path": "/orders",
"data_selector": "data",
"paginator": { "type": "json_response", "next_url_path": "links.next" },
"incremental": { "cursor_path": "updated_at", "start_param": "updated_since" }
}
}
]
}Credential: auth_token.
API key in query + offset pagination + integer cursor
{
"client": {
"base_url": "https://data.example.org",
"auth": { "type": "api_key", "name": "api_key", "location": "query" }
},
"resources": [
{
"name": "events",
"primary_key": "event_id",
"endpoint": {
"path": "/events",
"data_selector": "results",
"paginator": { "type": "offset", "limit": 200, "total_path": "meta.total" },
"incremental": { "cursor_path": "sequence", "start_param": "after_id", "cursor_type": "integer" }
}
}
]
}Credential: auth_api_key.
HTTP basic + single page
{
"client": {
"base_url": "https://reports.example.net",
"auth": { "type": "http_basic", "username": "svc_account" }
},
"resources": [
{
"name": "daily_summary",
"primary_key": "date",
"endpoint": { "path": "/summary/today", "data_selector": "rows", "paginator": { "type": "single_page" } }
}
]
}Credential: auth_password.
OAuth2 refresh-token grant + page-number pagination
{
"client": {
"base_url": "https://api.example.com/v2",
"auth": {
"type": "oauth2",
"client_id": "warehouse-import",
"token_url": "https://auth.example.com/oauth/token",
"grant_type": "refresh_token"
}
},
"resources": [
{
"name": "invoices",
"primary_key": "id",
"endpoint": {
"path": "/invoices",
"data_selector": "items",
"paginator": { "type": "page_number", "page_param": "page", "total_path": "total_pages" },
"incremental": { "cursor_path": "updated_at", "start_param": "modified_since" }
}
}
]
}Credentials: auth_oauth2_client_secret + auth_oauth2_refresh_token.
Bearer + cursor pagination + incremental
paginator.cursor_path and incremental.cursor_path are independent and easily confused: the paginator's
cursor_path reads the next-page cursor from the response envelope (drives pagination within a sync); the
incremental's cursor_path reads the watermark from each record (drives what a re-sync re-fetches).
{
"client": { "base_url": "https://api.acme.com/v1", "auth": { "type": "bearer" } },
"resources": [
{
"name": "tickets",
"primary_key": "id",
"endpoint": {
"path": "/tickets",
"data_selector": "data",
"paginator": { "type": "cursor", "cursor_path": "meta.next_cursor", "cursor_param": "cursor" },
"incremental": { "cursor_path": "updated_at", "start_param": "since" }
}
}
]
}Credential: auth_token.
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs an API key
Security
No issues found by our static scan· No issues found
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- PostHog
- Publisher
- PostHog (Official)
- Source
- PostHog/skills · skills/omnibus/setting-up-a-custom-rest-source · commit a5d30e7
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by PostHog or PostHog.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download setting-up-a-custom-rest-source.zip