Provisioning API
Contents
PostHog's provisioning API lets you create PostHog accounts for your users and deep link them into their PostHog project.
It's intended for partners and platform integrations. If you're integrating your own app with your own PostHog account, you probably want OAuth or a personal API key instead.
Jump to the full Node.js example to see the complete flow in code.
How it works
The flow uses OAuth 2.0 with PKCE (Proof Key for Code Exchange), so there are no shared secrets to manage. Your application is identified by a metadata document hosted on your domain.
Onboarding (once per user)
Deep linking (per click, after onboarding)
Reuses the access token from onboarding to mint a single-use URL that logs the user into their PostHog project.
Set up as a partner
Host a CIMD metadata document
PostHog uses Client ID Metadata Documents (CIMD) for partner registration. There's no signup form to fill out – you host a JSON document at an HTTPS URL on your domain, and that URL becomes your client_id.
Create a JSON file at a stable HTTPS URL, for example https://yourapp.com/.well-known/posthog-client.json:
Requirements:
client_idmust exactly match the URL where this document is hosted.com.posthog.provisioningmust betrueto use the provisioning API. Yourclient_idis a public URL, so a request that names it proves nothing about who sent it. Declaring the opt-in in the document, which only you can publish, is what grants your app provisioning access. It must be the JSON literaltrue, not the string"true".redirect_urisis required and must contain at least one HTTPS URI. This is where PostHog redirects existing users during the consent flow.logo_uri(optional) must be HTTPS if provided.token_endpoint_auth_methodmust be"none"(a public client, no client secret) or"private_key_jwt"with an HTTPSjwks_uri. Withprivate_key_jwt, your app authenticates by signing an assertion with your own key, which also raises your rate limits. See sign requests with a key.- The document must be served with
Content-Type: application/jsonand be under 5 KB. - The URL must use HTTPS, include a path component, and must not contain query parameters or fragments.
PostHog fetches and caches this document automatically.
Subsequent requests reuse the cached version and refresh it in the background based on your Cache-Control: max-age header (clamped between 5 minutes and 24 hours, default 1 hour).
Register your app
Once your metadata document is live, register it. PostHog fetches the document, validates it, and turns on provisioning access if it declares com.posthog.provisioning.
This call is not authenticated, because an app that hasn't registered yet has no credential to present. That's why the opt-in lives in your document rather than in this request.
Response (HTTP 200):
scopes is your scope ceiling: the scopes you declared, or every scope a user can grant if you declared none. capabilities says which endpoints you can call, including the ones PostHog has to enable for you.
Each check is reported separately, so a setup problem names its own cause instead of arriving as a bare 401 later. If registration fails, you get HTTP 400 with "registered": false, the same checks array, and an error object whose message is the first failed check.
Call this endpoint again whenever you want to re-check your setup. It re-fetches your document and re-reports every check. Registering again never reactivates an app PostHog has deactivated.
You can also verify a signed assertion end to end by sending client_assertion and client_assertion_type, which is worth doing once before you depend on private_key_jwt. Verifying consumes the assertion's jti, so mint a fresh one for the check rather than reusing it afterwards.
Until your app is registered, the other endpoints return HTTP 401 with a message pointing back here.
Sign requests with a key (optional)
With private_key_jwt, your app signs a short-lived JWT, called a client assertion, with a private key that only you hold. You send it on each request that identifies your app, and PostHog checks the signature against the public keys you publish at your jwks_uri. Nothing secret is shared with PostHog, and you rotate keys on your own schedule. Signing is required for deep links.
Turn on key signing
Generate a signing key and publish its public half as a JWKS at an HTTPS URL. See publish your keys.
In your metadata document, set
token_endpoint_auth_methodto"private_key_jwt"and addjwks_uri. Thejwks_urimust use HTTPS, include a path, have no query parameters or fragment, and resolve to a public address:JSONCall the registration endpoint, on both
us.posthog.comandeu.posthog.comif you use both regions. It re-reads your document and switches your app over in the same request. The response shows"token_endpoint_auth_method": "private_key_jwt", and itsjwkscheck lists the key IDs PostHog found at yourjwks_uri. If that check fails, fix it right away: your app has already switched, so signed requests fail until PostHog can read your keys.Start signing the requests below.
Once the switch applies, unsigned account requests and token calls fail with a 401. Before it applies, signed account requests fail. Without step 3, the switch waits until PostHog's cached copy of your document expires and a later account request triggers a background refresh. Token calls and Bearer requests never trigger one.
Which requests to sign
| Request | Assertion |
|---|---|
POST /api/agentic/provisioning/account_requests | Required |
POST /api/agentic/oauth/token, for both code exchange and refresh | Required |
POST /api/agentic/provisioning/limits | Optional, instead of an access token |
POST /api/agentic/provisioning/client_registration | Optional, to check your setup |
Endpoints that take your access token as a Bearer header, such as /resources and /deep_links, don't take an assertion.
Send the assertion as two fields next to the request's other fields, as form fields at the token endpoint and /limits, and as JSON fields for account requests and registration:
client_assertion_type- alwaysurn:ietf:params:oauth:client-assertion-type:jwt-bearerclient_assertion- the signed JWT
For account requests, add both fields to the body from step 1. client_id is optional except at the registration endpoint, because PostHog reads your client ID from the assertion's sub. If you send it, it must match. PostHog accepts each assertion once per region, so mint a new one for every request, including retries.
Build the assertion
The JWT header needs:
alg- one ofRS256,RS384,RS512,PS256,PS384,PS512,ES256,ES384, orES512, matching the key you sign with.kid- the ID of the signing key in your JWKS. PostHog requires it whenever your JWKS holds more than one key, which it does during every rotation, so always send it.
The claims:
| Claim | Value |
|---|---|
iss | Your client_id, exactly |
sub | Your client_id, exactly |
aud | ["https://us.posthog.com", "https://eu.posthog.com"], with no trailing slashes. PostHog can forward a request to the other region, and this value is valid in both. |
iat | When you created the assertion, in Unix seconds |
exp | When it expires, in Unix seconds, no more than 300 seconds after iat |
jti | A unique string, such as a random UUID |
nbf | Optional, checked if present |
iat and exp must be JSON numbers, not strings. PostHog allows 30 seconds of clock skew, so an assertion is still accepted up to 30 seconds after exp, and iat or nbf can be up to 30 seconds in the future.
Publish your keys
Your jwks_uri serves a JSON Web Key Set (JWKS): a JSON object with a keys array of public keys.
Requirements:
- Use RSA keys, or EC keys on the P-256, P-384, or P-521 curve. Publish only public keys.
- Give every key a unique
kid. - Set
algon each key to the algorithm you sign with. PostHog verifies with the key's algorithm, not the one in the assertion header. Withoutalg, an RSA key only verifiesRS256, and an EC key uses its curve's algorithm:ES256for P-256,ES384for P-384, andES512for P-521. - Keep it to 10 keys or fewer, and under 16 KB.
- Serve it with HTTP 200 and no redirects. PostHog fetches it with a short timeout and the user agent
PostHog-OAuth-JWKS/1.0.
To generate a key pair with jose:
PostHog caches your JWKS for 1 hour, whatever your Cache-Control header says, and re-fetches it early when an assertion names a kid it hasn't seen, at most once a minute. The registration endpoint re-fetches it right away, and if that fetch fails, the failure replaces the cached copy, so signed requests fail for the next minute.
To rotate keys without downtime:
- Add the new key to your JWKS next to the old one, with a new
kid. - Optionally, call the registration endpoint and check that its
jwkscheck lists both key IDs. - Start signing with the new key.
- Once every assertion signed with the old key has expired, remove the old key.
If a private key leaks, remove it from your JWKS, then call the registration endpoint in each region you use. Otherwise PostHog can keep accepting the removed key for up to an hour.
Switch back to a public client
Set token_endpoint_auth_method to "none", call the registration endpoint in each region you use, then stop sending assertions, because a public client's account requests fail if they carry one. Your tier drops to public: 1x, or 2x if your app is linked to an org via a verification token. Deep links stop working, with /deep_links returning a 403 forbidden. Access and refresh tokens you already hold keep working. Removing jwks_uri while token_endpoint_auth_method stays "private_key_jwt" doesn't switch you back: PostHog rejects that document and keeps the configuration it last accepted, and the registration endpoint reports the problem in its metadata_document check.
Sign a request in Python or Node.js
Troubleshoot assertion errors
Account requests and /limits return a 401 unauthorized, and the token endpoint returns a 401 invalid_client. The message names the problem:
| Message | What to check |
|---|---|
This client must authenticate or A client_assertion is required | PostHog found no usable assertion. Send both fields, with client_assertion_type spelled exactly. |
No provisioning client is registered for this client_id... | PostHog found no registered app for your client ID in the region that handled the request. Without a client_id field, PostHog reads your client ID from the assertion's sub, so on an account request this message also means the assertion is missing, client_assertion_type is misspelled, sub is missing, or the value isn't a JWT. Send client_id to get a more specific message. |
This client does not authenticate with a client assertion | PostHog still sees a public client. Call the registration endpoint to apply your document change. |
This client is not registered for private_key_jwt authentication | You sent an assertion to the registration endpoint before your document declared private_key_jwt. |
Client assertion is invalid | The signature, alg, iss, aud, exp, iat, or nbf check failed, or a required claim is missing. PostHog doesn't say which, so check each against build the assertion. |
Client assertion does not match this grant | Token endpoint only. The client_id you sent, or the assertion's sub, isn't the app the code or refresh token was issued to. |
Client assertion is signed by an unknown key | The kid isn't in your JWKS. |
Client assertion header is malformed | The value you sent with client_id isn't a JWT. |
Client keys could not be retrieved | PostHog couldn't fetch or read your JWKS, now or in the last minute. Check the requirements in publish your keys. |
JWKS could not be parsed: ... | Your JWKS isn't a valid key set. PostHog returns this on the first failed fetch, then Client keys could not be retrieved for the next minute. |
Link your partner app to a PostHog organization (optional)
By default, a CIMD partner app is unverified. You can link the app to a PostHog organization with a verification token, which raises your rate limits and surfaces the partner integration to that org's admins.
In PostHog, go to Organization settings → CIMD verification tokens and click Create token. Copy the
phvt_…value – it's only shown once.Add the token inside a
com.posthognamespace in your CIMD metadata document:JSONThe next time PostHog refreshes the metadata document, the app is linked to the matching organization and your rate limits go up.
The token is only used to prove ownership of the partner app – it isn't sent on API requests. You can rotate or revoke a token at any time from the same settings page. Revocation clears the link on the next metadata refresh, so a leaked or stale token can't keep an app linked to your org.
Note: The legacy top-level
posthog_verification_tokenfield is still supported as a fallback. PostHog readscom.posthog.verification_tokenfirst and falls back to the top-level field if the nested one is absent or unrecognized. New integrations should use thecom.posthognamespace.
Declare OAuth scopes for your app (optional)
Provisioning apps use the same required and optional scope declarations as other CIMD clients. See declare your OAuth scopes to set your app's scope ceiling and mark scopes that people can decline.
API reference
All endpoints are on https://us.posthog.com (US region) or https://eu.posthog.com (EU region).
Every request must include the API-Version: 0.1d header.
Step 1: Create an account
Create a PostHog account for a user by email. If the user is new, PostHog creates the account and returns an authorization code immediately. If the user already exists, the response tells you to redirect them for consent.
Generate a PKCE code verifier and challenge before making this request:
Request fields:
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Your unique request ID (for idempotency) |
email | string | Yes | User's email address |
name | string | No | User's full name |
client_id | string | Yes | Your CIMD metadata URL |
code_challenge | string | Yes | Base64url-encoded SHA-256 hash of your code verifier (43-128 chars) |
code_challenge_method | string | Yes | Must be "S256" |
scopes | list | No | OAuth scopes to request for the access token. See available scopes. |
configuration.region | string | No | "US" (default) or "EU" |
configuration.organization_name | string | No | Organization name (defaults to "Partner (email)") |
New user response (HTTP 200):
The user receives a welcome email with a link to set their password and access their dashboard.
Existing user response (HTTP 200):
When type is requires_auth, redirect the user to the provided URL. After they approve, PostHog redirects them to your redirect_uris with a code query parameter that you use in step 2.
Step 2: Exchange the code for tokens
Exchange the authorization code for an access token and refresh token. The token endpoint uses standard application/x-www-form-urlencoded encoding.
Request fields:
| Field | Type | Required | Description |
|---|---|---|---|
grant_type | string | Yes | Must be "authorization_code" |
code | string | Yes | The authorization code from step 1 |
code_verifier | string | Yes | The original PKCE code verifier (must match the challenge from step 1) |
Response (HTTP 200):
Authorization codes expire after 5 minutes and can only be used once. Access tokens expire after 1 hour. Use the refresh token to get new tokens.
Token endpoint errors use the standard OAuth 2.0 format:
Step 3: Provision a project
Use the access token to provision a PostHog project and get credentials.
Request fields:
| Field | Type | Required | Description |
|---|---|---|---|
service_id | string | No | The plan to provision. "analytics" (default) provisions a standard project. "free" and "pay_as_you_go" set the billing plan explicitly. |
label_prefix | string | No | Label prefix for the provisioned personal API key, up to 25 characters, used only when a personal API key is issued (off by default). When issued, the key is labeled {label_prefix} - {team_name}; if omitted, empty, or whitespace-only, just the team name. |
configuration.project_name | string | No | Project name (defaults to "Default project") |
Response (HTTP 200):
Response fields:
| Field | Description |
|---|---|
api_key | The project token (starts with phc_) – use this to initialize PostHog SDKs |
host | The API host (https://us.posthog.com or https://eu.posthog.com) |
For authenticated REST API calls, use the OAuth access_token from Step 2 as a Bearer token. It carries the scopes you requested in the account request. Provisioning does not return a personal_api_key in this response.
Deep linking
For recurring deep links from your application into PostHog – the most common case is an "Open in PostHog" button on a user's connected project – use the deep-link endpoint. Each call returns a short-lived, single-use URL that logs the user into their PostHog project on click. There's no consent screen and no email-mismatch friction: the URL mints a fresh PostHog session for the right user, overriding any other session the browser happens to have.
Authenticate with the access_token you got from Step 2 – the same Bearer credential you use for /resources. The token is scoped to a single team, so the resulting deep link lands the user in the correct project.
Request fields:
| Field | Type | Required | Description |
|---|---|---|---|
path | string | No | The in-app path to land the user on after login, for example /project/12345/replay/<recording_id>. Must be a relative, same-origin path beginning with a single /. If omitted, the link lands on the project home (/project/<team_id>). |
purpose | string | No | Free-form label recorded for your own analytics. Defaults to dashboard. It does not affect where the link lands – use path for that. |
The path is validated when the link is minted and again when it's opened. PostHog rejects open-redirect forms (absolute URLs, protocol-relative //, backslashes, and javascript:) as well as control characters and whitespace. To find the path for a destination, open it in PostHog and copy everything after the host, including the leading /.
Response:
The returned url is valid for 10 minutes and can only be opened once, so don't pre-render it as the button's href. Wire the button click to your backend, call /deep_links there, then redirect the user to the returned URL.
Requires a trusted partner record
This endpoint is gated on the provisioning_can_issue_deep_links flag on your partner record, which is only enabled for partners admin-onboarded by PostHog. If you get a deep_links_not_enabled 403, ask PostHog to enable it for your CIMD app.
Deep links also require private_key_jwt client authentication. A public client, identified only by a client_id anyone can send, has no deep-link budget because a deep link mints a full PostHog session.
Rotate project credentials
Rotate the project token for an existing provisioned project:
Request fields:
| Field | Type | Required | Description |
|---|---|---|---|
label_prefix | string | No | Label prefix for the personal API key, up to 25 characters, used only when a personal API key is issued (off by default). When issued, the key is labeled {label_prefix} - {team_name}; if omitted, empty, or whitespace-only, just the team name. |
The response has the same shape as the project provisioning response and includes the rotated api_key and host.
Refresh tokens
Access tokens expire after 1 hour. Use the refresh token to get new credentials:
Response (HTTP 200):
Each refresh token is single-use. The response includes a new refresh token for subsequent refreshes. Refreshing can only keep or narrow a token's scopes, never add them. To grant additional scopes, request them in a new account request; they apply to connections provisioned afterward.
Available scopes
The scopes field in the account request controls what permissions the access token receives. If omitted, a default set of scopes is granted. The default set does not include every scope below, so explicitly request the scopes your integration needs. Available scopes:
| Scope | Description |
|---|---|
customer_journey:read | Read customer journey data |
query:read | Execute read-only queries |
session_recording:read | Read session recordings |
conversation:read | Read PostHog AI conversations |
conversation:write | Create and update PostHog AI conversations |
experiment:read | Read experiments |
feature_flag:read | Read feature flags |
insight:read | Read insights |
organization:read | Read organization details |
person:read | Read person data |
project:read | Read project settings |
ticket:read | Read tickets |
ticket:write | Create and update tickets |
user:read | Read user information |
hog_flow:read | Read Hog flows |
hog_flow:write | Create and update Hog flows |
What the user gets
When you provision a new account, the user receives:
- A welcome email with a link to set their password.
- Full dashboard access at us.posthog.com (or eu.posthog.com for EU).
- The PostHog free tier across all products – no credit card required.
Your integration gets back the project token and host, so you can start sending events the moment the API call returns.
Error handling
Provisioning endpoints (account_requests, resources) return errors in this format:
The token endpoint uses the standard OAuth 2.0 error format instead:
Common error codes:
| Code | HTTP Status | Description |
|---|---|---|
invalid_request | 400 | Missing or invalid field |
unauthorized | 401 | Authentication failed |
registration_failed | 400 | Registration did not complete. The checks array in the same response says which step failed |
forbidden | 403 | Partner not authorized for this action |
access_blocked | 403 | PostHog blocked this account |
expired | 400 | Account request has expired |
invalid_grant | 400 | Authorization code is invalid or expired (token endpoint) |
invalid_client | 401 | Client authentication failed (token endpoint). See assertion errors |
invalid_label_prefix | 400 | label_prefix is not a string, is longer than 25 characters after trimming, or contains control or Unicode format characters |
invalid_path | 400 | Deep-link path is not a relative, same-origin in-app path beginning with a single / |
invalid_scope | 400 | Unrecognized scope requested |
rate_limited | 429 | Rate limit exceeded. The Retry-After header says how many seconds to wait. See rate limits. |
account_creation_failed | 500 | Server error during account creation |
Rate limits
Every provisioning endpoint has a per-partner budget: a burst you can spend at once, refilling continuously at an hourly rate. There is no fixed window, so you never wait for the top of the hour. When you run out, the response is a 429 with a Retry-After header saying exactly how many seconds until your next request can succeed.
Requests that fail validation (4xx errors like invalid_request) are refunded, so debugging an integration doesn't spend your budget.
Your tier
Budgets scale with how strongly your app identifies itself, on two axes:
| Client authentication | Not linked to an org | Linked via a verification token |
|---|---|---|
none (public, PKCE) | 1x | 2x |
private_key_jwt | 5x | 10x |
Both upgrades are self-serve: declare private_key_jwt with a jwks_uri in your metadata document, or add a verification token. They apply the next time PostHog reads your metadata document, and you can make that happen right away by calling the registration endpoint. Nobody to ask.
Budgets
At the 1x tier:
| Endpoints | Burst | Refill |
|---|---|---|
| Account requests | 5 | 10/hour |
| Token exchange | 10 | 20/hour |
Project provisioning (/resources) | 10 | 30/hour |
| Deep links | Blocked | Blocked |
| Everything else (token refresh, reads, credential rotation) | 30 | 120/hour |
So account requests, the tightest budget, run at 10/20/50/100 per hour across the four tiers, and token exchanges at 20/40/100/200. Token refreshes have their own budget, separate from token exchanges, so keeping many users' tokens alive never competes with onboarding new ones.
Deep links have no budget at the 1x and 2x tiers: a public client gets a 403 forbidden from that endpoint. Declare private_key_jwt and they run at 150 burst and 600/hour at 5x, or 300 burst and 1,200/hour at 10x. Polling a GitHub grant's repositories is the one budget that does not scale with tier: 30 burst and 120/hour per grant, because how fast a visitor's repo picker polls has nothing to do with how strongly your app identifies itself.
Limits are per region: US and EU count separately.
Registration is limited separately, because it fetches your document while you wait:
- 30 registration calls per hour per
client_id - 120 registration calls per hour per IP
- 5 requests per minute per IP, and 10 per hour per IP, for a
client_idPostHog has never seen - 100 new client registrations per hour globally
- 5 new client registrations per domain per hour
If the top tier still isn't enough for production use, open a support request in the app, choosing Authentication as the topic. PostHog can set per-endpoint overrides for your app.
Check your limits
Every response that counted against a budget includes RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers, so you can pace requests instead of reacting to 429s.
To see everything at once, call the limits endpoint. It returns your tier, why you have it, and every endpoint's budget with current headroom.
The endpoint is POST only, and you have to prove the request is yours. Send an access token, or authenticate as your client the same way you do at the token endpoint:
A client_id on its own is not enough. Your client_id is published, so accepting it here would let anyone read your limits and spend your budget for this endpoint. Requests that only carry a client_id get a 401.
Response:
remaining is whole tokens available now, and reset is seconds until the budget is full again. A blocked endpoint is unavailable at your tier; an unlimited one has a PostHog-set override.
Full example
Here's a complete example in Node.js:
For the "Open in PostHog" button, call this from your click handler with the user's stored access_token, then redirect the browser to the returned URL. Pass a path to land the user on a specific page, or omit it to land on the project home: