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)

1. POST /account_requests → authorization code
2. POST /oauth/token → access + refresh tokens
3. POST /resources → project token + host

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.

4. POST /deep_links → single-use URL (10 min TTL)

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:

JSON
{
"client_id": "https://yourapp.com/.well-known/posthog-client.json",
"client_name": "Your App Name",
"redirect_uris": ["https://yourapp.com/callbacks/posthog"],
"logo_uri": "https://yourapp.com/logo.png",
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code"],
"response_types": ["code"],
"com.posthog": {
"provisioning": true
}
}

Requirements:

  • client_id must exactly match the URL where this document is hosted.
  • com.posthog.provisioning must be true to use the provisioning API. Your client_id is 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 literal true, not the string "true".
  • redirect_uris is 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_method must be "none" (a public client, no client secret) or "private_key_jwt" with an HTTPS jwks_uri. With private_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/json and 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.

Terminal
curl -X POST https://us.posthog.com/api/agentic/provisioning/client_registration \
-H "Content-Type: application/json" \
-H "API-Version: 0.1d" \
-d '{"client_id": "https://yourapp.com/.well-known/posthog-client.json"}'

Response (HTTP 200):

JSON
{
"client_id": "https://yourapp.com/.well-known/posthog-client.json",
"registered": true,
"client_type": "public",
"token_endpoint_auth_method": "none",
"jwks_uri": null,
"scopes": ["insight:read", "project:read"],
"provisioning": {
"active": true,
"can_create_accounts": true,
"can_provision_resources": true
},
"capabilities": {
"account_requests": true,
"github_grants": false,
"wizard_runs": false
},
"checks": [
{ "name": "metadata_document", "ok": true, "detail": "Fetched and validated" },
{ "name": "provisioning_enabled", "ok": true, "detail": "Active" },
{
"name": "jwks",
"ok": true,
"detail": "No jwks_uri, so this client authenticates with PKCE only and cannot use the GitHub grant endpoints"
}
]
}

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

  1. Generate a signing key and publish its public half as a JWKS at an HTTPS URL. See publish your keys.

  2. In your metadata document, set token_endpoint_auth_method to "private_key_jwt" and add jwks_uri. The jwks_uri must use HTTPS, include a path, have no query parameters or fragment, and resolve to a public address:

    JSON
    "token_endpoint_auth_method": "private_key_jwt",
    "jwks_uri": "https://yourapp.com/.well-known/posthog-jwks.json"
  3. Call the registration endpoint, on both us.posthog.com and eu.posthog.com if 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 its jwks check lists the key IDs PostHog found at your jwks_uri. If that check fails, fix it right away: your app has already switched, so signed requests fail until PostHog can read your keys.

  4. 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

RequestAssertion
POST /api/agentic/provisioning/account_requestsRequired
POST /api/agentic/oauth/token, for both code exchange and refreshRequired
POST /api/agentic/provisioning/limitsOptional, instead of an access token
POST /api/agentic/provisioning/client_registrationOptional, 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 - always urn:ietf:params:oauth:client-assertion-type:jwt-bearer
  • client_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 of RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, or ES512, 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:

ClaimValue
issYour client_id, exactly
subYour 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.
iatWhen you created the assertion, in Unix seconds
expWhen it expires, in Unix seconds, no more than 300 seconds after iat
jtiA unique string, such as a random UUID
nbfOptional, 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.

JSON
{
"keys": [
{
"kty": "EC",
"crv": "P-256",
"x": "<base64url x coordinate>",
"y": "<base64url y coordinate>",
"kid": "yourapp-example-key-1",
"alg": "ES256",
"use": "sig"
}
]
}

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 alg on each key to the algorithm you sign with. PostHog verifies with the key's algorithm, not the one in the assertion header. Without alg, an RSA key only verifies RS256, and an EC key uses its curve's algorithm: ES256 for P-256, ES384 for P-384, and ES512 for 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:

JavaScript
import { exportJWK, exportPKCS8, generateKeyPair } from "jose";
const { publicKey, privateKey } = await generateKeyPair("ES256", { extractable: true });
const jwk = { ...(await exportJWK(publicKey)), kid: "yourapp-example-key-1", alg: "ES256", use: "sig" };
console.log(JSON.stringify({ keys: [jwk] }, null, 2)); // publish at your jwks_uri
console.log(await exportPKCS8(privateKey)); // store as a secret

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:

  1. Add the new key to your JWKS next to the old one, with a new kid.
  2. Optionally, call the registration endpoint and check that its jwks check lists both key IDs.
  3. Start signing with the new key.
  4. 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

# pip install "pyjwt[crypto]" requests
import time
import uuid
import jwt
import requests
CLIENT_ID = "https://yourapp.com/.well-known/posthog-client.json"
KEY_ID = "yourapp-example-key-1"
AUDIENCE = ["https://us.posthog.com", "https://eu.posthog.com"]
ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
def build_client_assertion(private_key_pem: str) -> str:
now = int(time.time())
claims = {
"iss": CLIENT_ID,
"sub": CLIENT_ID,
"aud": AUDIENCE,
"iat": now,
"exp": now + 60,
"jti": str(uuid.uuid4()),
}
return jwt.encode(claims, private_key_pem, algorithm="ES256", headers={"kid": KEY_ID})
def refresh_tokens(private_key_pem: str, refresh_token: str) -> dict:
response = requests.post(
"https://us.posthog.com/api/agentic/oauth/token",
headers={"API-Version": "0.1d"},
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_assertion_type": ASSERTION_TYPE,
"client_assertion": build_client_assertion(private_key_pem),
},
)
response.raise_for_status()
return response.json()

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:

MessageWhat to check
This client must authenticate or A client_assertion is requiredPostHog 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 assertionPostHog still sees a public client. Call the registration endpoint to apply your document change.
This client is not registered for private_key_jwt authenticationYou sent an assertion to the registration endpoint before your document declared private_key_jwt.
Client assertion is invalidThe 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 grantToken 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 keyThe kid isn't in your JWKS.
Client assertion header is malformedThe value you sent with client_id isn't a JWT.
Client keys could not be retrievedPostHog 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.

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.

  1. In PostHog, go to Organization settings → CIMD verification tokens and click Create token. Copy the phvt_… value – it's only shown once.

  2. Add the token inside a com.posthog namespace in your CIMD metadata document:

    JSON
    {
    "client_id": "https://yourapp.com/.well-known/posthog-client.json",
    "client_name": "Your App Name",
    "redirect_uris": ["https://yourapp.com/callbacks/posthog"],
    "logo_uri": "https://yourapp.com/logo.png",
    "token_endpoint_auth_method": "none",
    "grant_types": ["authorization_code"],
    "response_types": ["code"],
    "com.posthog": {
    "provisioning": true,
    "verification_token": "phvt_..."
    }
    }
  3. The 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_token field is still supported as a fallback. PostHog reads com.posthog.verification_token first and falls back to the top-level field if the nested one is absent or unrecognized. New integrations should use the com.posthog namespace.

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:

Terminal
# Generate PKCE values
CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=' | tr '+/' '-_')
CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr -d '=' | tr '+/' '-_')
Terminal
BODY=$(cat <<JSON
{
"id": "req_unique_request_id",
"email": "user@example.com",
"name": "Jane Doe",
"client_id": "https://yourapp.com/.well-known/posthog-client.json",
"code_challenge": "$CODE_CHALLENGE",
"code_challenge_method": "S256",
"configuration": {
"region": "US",
"organization_name": "Acme Corp"
}
}
JSON
)
curl -X POST https://us.posthog.com/api/agentic/provisioning/account_requests \
-H "Content-Type: application/json" \
-H "API-Version: 0.1d" \
-d "$BODY"

Request fields:

FieldTypeRequiredDescription
idstringYesYour unique request ID (for idempotency)
emailstringYesUser's email address
namestringNoUser's full name
client_idstringYesYour CIMD metadata URL
code_challengestringYesBase64url-encoded SHA-256 hash of your code verifier (43-128 chars)
code_challenge_methodstringYesMust be "S256"
scopeslistNoOAuth scopes to request for the access token. See available scopes.
configuration.regionstringNo"US" (default) or "EU"
configuration.organization_namestringNoOrganization name (defaults to "Partner (email)")

New user response (HTTP 200):

JSON
{
"id": "req_unique_request_id",
"type": "oauth",
"oauth": {
"code": "abc123...authorization_code"
}
}

The user receives a welcome email with a link to set their password and access their dashboard.

Existing user response (HTTP 200):

JSON
{
"id": "req_unique_request_id",
"type": "requires_auth",
"requires_auth": {
"url": "https://us.posthog.com/api/agentic/authorize?state=xyz..."
}
}

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.

Terminal
curl -X POST https://us.posthog.com/api/agentic/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "API-Version: 0.1d" \
-d "grant_type=authorization_code&code=abc123...authorization_code&code_verifier=$CODE_VERIFIER"

Request fields:

FieldTypeRequiredDescription
grant_typestringYesMust be "authorization_code"
codestringYesThe authorization code from step 1
code_verifierstringYesThe original PKCE code verifier (must match the challenge from step 1)

Response (HTTP 200):

JSON
{
"token_type": "bearer",
"access_token": "pha_abc123...",
"refresh_token": "phr_def456...",
"expires_in": 3600,
"account": {
"id": "01234567-89ab-cdef-0123-456789abcdef",
"payment_credentials": "orchestrator",
"available_teams": [
{
"id": 12345,
"name": "Default project",
"organization_id": "01234567-89ab-cdef-0123-456789abcdef",
"organization_name": "Acme Corp"
}
]
}
}

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:

JSON
{
"error": "invalid_grant",
"error_description": "Invalid or expired authorization code"
}

Step 3: Provision a project

Use the access token to provision a PostHog project and get credentials.

Terminal
curl -X POST https://us.posthog.com/api/agentic/provisioning/resources \
-H "Content-Type: application/json" \
-H "Authorization: Bearer pha_abc123..." \
-H "API-Version: 0.1d" \
-d '{
"service_id": "analytics",
"label_prefix": "Acme Co",
"configuration": {
"project_name": "My App - Production"
}
}'

Request fields:

FieldTypeRequiredDescription
service_idstringNoThe plan to provision. "analytics" (default) provisions a standard project. "free" and "pay_as_you_go" set the billing plan explicitly.
label_prefixstringNoLabel 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_namestringNoProject name (defaults to "Default project")

Response (HTTP 200):

JSON
{
"status": "complete",
"id": "12345",
"service_id": "analytics",
"complete": {
"access_configuration": {
"api_key": "phc_abc123...",
"host": "https://us.posthog.com"
}
}
}

Response fields:

FieldDescription
api_keyThe project token (starts with phc_) – use this to initialize PostHog SDKs
hostThe 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.

Terminal
curl -X POST https://us.posthog.com/api/agentic/provisioning/deep_links \
-H "Content-Type: application/json" \
-H "Authorization: Bearer pha_abc123..." \
-H "API-Version: 0.1d" \
-d '{
"path": "/project/12345/replay/019e6d10-c3b0-7000-8000-000000000000"
}'

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:

FieldTypeRequiredDescription
pathstringNoThe 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>).
purposestringNoFree-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:

JSON
{
"purpose": "dashboard",
"url": "https://us.posthog.com/agentic/login?token=...&team_id=12345",
"expires_at": "2026-05-20T21:00:00Z"
}

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:

Terminal
curl -X POST https://us.posthog.com/api/agentic/provisioning/resources/12345/rotate_credentials \
-H "Content-Type: application/json" \
-H "Authorization: Bearer pha_abc123..." \
-H "API-Version: 0.1d" \
-d '{
"label_prefix": "Acme Co"
}'

Request fields:

FieldTypeRequiredDescription
label_prefixstringNoLabel 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:

Terminal
curl -X POST https://us.posthog.com/api/agentic/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "API-Version: 0.1d" \
-d "grant_type=refresh_token&refresh_token=phr_def456..."

Response (HTTP 200):

JSON
{
"token_type": "bearer",
"access_token": "pha_new_token...",
"refresh_token": "phr_new_refresh...",
"expires_in": 3600
}

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:

ScopeDescription
customer_journey:readRead customer journey data
query:readExecute read-only queries
session_recording:readRead session recordings
conversation:readRead PostHog AI conversations
conversation:writeCreate and update PostHog AI conversations
experiment:readRead experiments
feature_flag:readRead feature flags
insight:readRead insights
organization:readRead organization details
person:readRead person data
project:readRead project settings
ticket:readRead tickets
ticket:writeCreate and update tickets
user:readRead user information
hog_flow:readRead Hog flows
hog_flow:writeCreate 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:

JSON
{
"type": "error",
"error": {
"code": "error_code",
"message": "Human-readable description"
}
}

The token endpoint uses the standard OAuth 2.0 error format instead:

JSON
{
"error": "error_code",
"error_description": "Human-readable description"
}

Common error codes:

CodeHTTP StatusDescription
invalid_request400Missing or invalid field
unauthorized401Authentication failed
registration_failed400Registration did not complete. The checks array in the same response says which step failed
forbidden403Partner not authorized for this action
access_blocked403PostHog blocked this account
expired400Account request has expired
invalid_grant400Authorization code is invalid or expired (token endpoint)
invalid_client401Client authentication failed (token endpoint). See assertion errors
invalid_label_prefix400label_prefix is not a string, is longer than 25 characters after trimming, or contains control or Unicode format characters
invalid_path400Deep-link path is not a relative, same-origin in-app path beginning with a single /
invalid_scope400Unrecognized scope requested
rate_limited429Rate limit exceeded. The Retry-After header says how many seconds to wait. See rate limits.
account_creation_failed500Server 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 authenticationNot linked to an orgLinked via a verification token
none (public, PKCE)1x2x
private_key_jwt5x10x

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:

EndpointsBurstRefill
Account requests510/hour
Token exchange1020/hour
Project provisioning (/resources)1030/hour
Deep linksBlockedBlocked
Everything else (token refresh, reads, credential rotation)30120/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_id PostHog 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:

Terminal
# With an access token. Public clients use this after their first token exchange.
curl -X POST https://us.posthog.com/api/agentic/provisioning/limits \
-H "Authorization: Bearer pha_abc123..."
# Or as a private_key_jwt client, with the signed assertion in the form body
curl -X POST https://us.posthog.com/api/agentic/provisioning/limits \
-d "client_id=https://yourapp.com/.well-known/posthog-client.json" \
-d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
-d "client_assertion=eyJhbGci..."

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:

JSON
{
"tier": "public_attested",
"tier_basis": {
"client_authentication": "none",
"attested": true
},
"endpoints": {
"account_requests": { "per_hour": 20, "burst": 10, "remaining": 9, "reset": 180 },
"token_exchanges": { "per_hour": 40, "burst": 20, "remaining": 20, "reset": 0 },
"deep_links": { "blocked": true }
}
}

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:

JavaScript
import crypto from "node:crypto";
const CLIENT_ID = "https://yourapp.com/.well-known/posthog-client.json";
const BASE_URL = "https://us.posthog.com";
async function provisionPostHogAccount(email, name) {
// Generate PKCE values
const codeVerifier = crypto.randomBytes(32).toString("base64url");
const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url");
// Step 1: Create account
const accountRes = await fetch(`${BASE_URL}/api/agentic/provisioning/account_requests`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"API-Version": "0.1d",
},
body: JSON.stringify({
id: crypto.randomUUID(),
email,
name,
client_id: CLIENT_ID,
code_challenge: codeChallenge,
code_challenge_method: "S256",
configuration: { region: "US" },
}),
});
if (!accountRes.ok) {
const err = await accountRes.json();
throw new Error(
`Account request failed (${accountRes.status}): ${err.error?.message || JSON.stringify(err)}`,
);
}
const account = await accountRes.json();
if (account.type === "requires_auth") {
// Existing user - redirect them to account.requires_auth.url
return { type: "requires_auth", url: account.requires_auth.url };
}
if (account.type !== "oauth") {
throw new Error(account.error?.message || "Unexpected response type");
}
// Step 2: Exchange code for tokens
const tokenRes = await fetch(`${BASE_URL}/api/agentic/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"API-Version": "0.1d",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: account.oauth.code,
code_verifier: codeVerifier,
}),
});
if (!tokenRes.ok) {
const err = await tokenRes.json();
throw new Error(
`Token exchange failed (${tokenRes.status}): ${err.error_description || JSON.stringify(err)}`,
);
}
const tokens = await tokenRes.json();
// Step 3: Provision a project
const resourceRes = await fetch(`${BASE_URL}/api/agentic/provisioning/resources`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access_token}`,
"API-Version": "0.1d",
},
body: JSON.stringify({
service_id: "analytics",
label_prefix: "Acme Co",
configuration: { project_name: "Production" },
}),
});
if (!resourceRes.ok) {
const err = await resourceRes.json();
throw new Error(
`Resource provisioning failed (${resourceRes.status}): ${err.error?.message || JSON.stringify(err)}`,
);
}
const resource = await resourceRes.json();
return {
type: "provisioned",
apiKey: resource.complete.access_configuration.api_key,
host: resource.complete.access_configuration.host,
projectId: resource.id,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
};
}

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:

JavaScript
async function getDeepLinkUrl(accessToken, path) {
const res = await fetch(
`${BASE_URL}/api/agentic/provisioning/deep_links`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
'API-Version': '0.1d',
},
body: JSON.stringify(path ? { path } : {}),
}
);
if (!res.ok) {
const err = await res.json();
throw new Error(
`Deep link request failed (${res.status}): ${err.error?.message || JSON.stringify(err)}`,
);
}
const { url } = await res.json();
return url;
}

Still have questions?

Was this page useful?