diff --git a/contents/docs/integrate/provisioning.mdx b/contents/docs/integrate/provisioning.mdx index c46356c2f26b..ca9498de548b 100644 --- a/contents/docs/integrate/provisioning.mdx +++ b/contents/docs/integrate/provisioning.mdx @@ -80,6 +80,7 @@ Create a JSON file at a stable HTTPS URL, for example `https://yourapp.com/.well ``` Requirements: + - **`client_id`** must exactly match the URL where this document is hosted. - **`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. @@ -162,17 +163,17 @@ curl -X POST https://us.posthog.com/api/agentic/provisioning/account_requests \ **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](#available-scopes). | -| `configuration.region` | string | No | `"US"` (default) or `"EU"` | -| `configuration.organization_name` | string | No | Organization name (defaults to `"Partner (email)"`) | +| 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](#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): @@ -235,11 +236,11 @@ curl -X POST https://us.posthog.com/api/agentic/oauth/token \ **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) | +| 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): @@ -296,11 +297,11 @@ curl -X POST https://us.posthog.com/api/agentic/provisioning/resources \ **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 personal API key shown in PostHog, up to 25 characters. The key is labeled `{label_prefix} - {team_name}`. If omitted, empty, or whitespace-only, the key is labeled with just the team name. | -| `configuration.project_name` | string | No | Project name (defaults to `"Default project"`) | +| 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 personal API key shown in PostHog, up to 25 characters. The key is labeled `{label_prefix} - {team_name}`. If omitted, empty, or whitespace-only, the key is labeled with just the team name. | +| `configuration.project_name` | string | No | Project name (defaults to `"Default project"`) | **Response** (HTTP 200): @@ -312,8 +313,7 @@ curl -X POST https://us.posthog.com/api/agentic/provisioning/resources \ "complete": { "access_configuration": { "api_key": "phc_abc123...", - "host": "https://us.posthog.com", - "personal_api_key": "phx_def456..." + "host": "https://us.posthog.com" } } } @@ -321,11 +321,11 @@ curl -X POST https://us.posthog.com/api/agentic/provisioning/resources \ **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`) | -| `personal_api_key` | A personal API key scoped to this project – use this for the PostHog API | +| 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`) | +| `personal_api_key` | A personal API key scoped to this project. Only returned for partner apps with personal API key issuance enabled – most apps don't receive this field. Use the OAuth access token for API access instead. | ### Deep linking @@ -345,9 +345,9 @@ Authenticate with the `access_token` you got from [Step 2](#step-2-exchange-the- **Request fields:** -| Field | Type | Required | Description | -|---|---|---|---| -| `purpose` | string | No | What the deep link should land on. Currently only `dashboard` is supported. Defaults to `dashboard`. | +| Field | Type | Required | Description | +| --------- | ------ | -------- | ---------------------------------------------------------------------------------------------------- | +| `purpose` | string | No | What the deep link should land on. Currently only `dashboard` is supported. Defaults to `dashboard`. | **Response:** @@ -367,7 +367,7 @@ This endpoint is gated on the `provisioning_can_issue_deep_links` flag on your p ### Rotate project credentials -Rotate the project token and create a new personal API key for an existing provisioned project: +Rotate the project token for an existing provisioned project. If your app has personal API key issuance enabled, a new personal API key is also created: ```bash curl -X POST https://us.posthog.com/api/agentic/provisioning/resources/12345/rotate_credentials \ @@ -381,11 +381,11 @@ curl -X POST https://us.posthog.com/api/agentic/provisioning/resources/12345/rot **Request fields:** -| Field | Type | Required | Description | -|---|---|---|---| -| `label_prefix` | string | No | Label prefix for the new personal API key shown in PostHog, up to 25 characters. The key is labeled `{label_prefix} - {team_name}`. If omitted, empty, or whitespace-only, the key is labeled with just the team name. | +| Field | Type | Required | Description | +| -------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `label_prefix` | string | No | Label prefix for the new personal API key shown in PostHog, up to 25 characters. The key is labeled `{label_prefix} - {team_name}`. If omitted, empty, or whitespace-only, the key is labeled with just the team name. | -The response has the same shape as the project provisioning response and includes the rotated `api_key`, `host`, and new `personal_api_key`. +The response has the same shape as the project provisioning response and includes the rotated `api_key` and `host`. The `personal_api_key` field is only included if your app has personal API key issuance enabled. ### Refresh tokens @@ -415,23 +415,23 @@ Each refresh token is single-use. The response includes a new refresh token for The `scopes` field in the account request controls what permissions the access token receives. If omitted, a default set of scopes is granted. Available scopes: -| Scope | Description | -|---|---| -| `customer_journey:read` | Read customer journey data | -| `query:read` | Execute read-only queries | -| `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 | +| Scope | Description | +| ----------------------- | ------------------------------------------ | +| `customer_journey:read` | Read customer journey data | +| `query:read` | Execute read-only queries | +| `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 @@ -468,29 +468,31 @@ 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 | -| `forbidden` | 403 | Partner not authorized for this action | -| `expired` | 400 | Account request has expired | -| `invalid_grant` | 400 | Authorization code is invalid or expired (token endpoint) | -| `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_scope` | 400 | Unrecognized scope requested | -| `rate_limited` | 429 | Rate limit exceeded | -| `account_creation_failed` | 500 | Server error during account creation | +| Code | HTTP Status | Description | +| ------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `invalid_request` | 400 | Missing or invalid field | +| `unauthorized` | 401 | Authentication failed | +| `forbidden` | 403 | Partner not authorized for this action | +| `expired` | 400 | Account request has expired | +| `invalid_grant` | 400 | Authorization code is invalid or expired (token endpoint) | +| `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_scope` | 400 | Unrecognized scope requested | +| `rate_limited` | 429 | Rate limit exceeded | +| `account_creation_failed` | 500 | Server error during account creation | ## Rate limits All provisioning endpoints are rate limited. **CIMD registration** (first request from a new `client_id`): + - 5 requests per minute per IP (burst) - 10 requests per hour per IP (sustained) - 100 new client registrations per hour globally - 5 new client registrations per domain per hour **Account requests** (per partner, per hour): + - 10/hour for unverified CIMD partners - 100/hour for partners linked to a PostHog organization via a [verification token](#link-your-partner-app-to-a-posthog-organization-optional) @@ -501,67 +503,61 @@ Email [team-growth@posthog.com](mailto:team-growth@posthog.com) if you need a hi Here's a complete example in Node.js: ```javascript -import crypto from 'node:crypto'; +import crypto from "node:crypto"; -const CLIENT_ID = 'https://yourapp.com/.well-known/posthog-client.json'; -const BASE_URL = 'https://us.posthog.com'; +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'); + 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' }, - }), - } - ); + 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)}` + `Account request failed (${accountRes.status}): ${err.error?.message || JSON.stringify(err)}`, ); } const account = await accountRes.json(); - if (account.type === 'requires_auth') { + if (account.type === "requires_auth") { // Existing user - redirect them to account.requires_auth.url - return { type: 'requires_auth', url: 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'); + 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', + method: "POST", headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'API-Version': '0.1d', + "Content-Type": "application/x-www-form-urlencoded", + "API-Version": "0.1d", }, body: new URLSearchParams({ - grant_type: 'authorization_code', + grant_type: "authorization_code", code: account.oauth.code, code_verifier: codeVerifier, }), @@ -570,44 +566,41 @@ async function provisionPostHogAccount(email, name) { if (!tokenRes.ok) { const err = await tokenRes.json(); throw new Error( - `Token exchange failed (${tokenRes.status}): ${err.error_description || JSON.stringify(err)}` + `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' }, - }), - } - ); + 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)}` + `Resource provisioning failed (${resourceRes.status}): ${err.error?.message || JSON.stringify(err)}`, ); } const resource = await resourceRes.json(); return { - type: 'provisioned', + type: "provisioned", apiKey: resource.complete.access_configuration.api_key, host: resource.complete.access_configuration.host, - personalApiKey: resource.complete.access_configuration.personal_api_key, + personalApiKey: resource.complete.access_configuration.personal_api_key, // may be undefined projectId: resource.id, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, @@ -619,23 +612,20 @@ For the "Open in PostHog" button, call this from your click handler with the use ```javascript async function getDeepLinkUrl(accessToken) { - 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({ purpose: 'dashboard' }), - } - ); + 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({ purpose: "dashboard" }), + }); if (!res.ok) { const err = await res.json(); throw new Error( - `Deep link request failed (${res.status}): ${err.error?.message || JSON.stringify(err)}` + `Deep link request failed (${res.status}): ${err.error?.message || JSON.stringify(err)}`, ); }