From 1d290d159c2e371dbc1aba21fb821d77d58c3c17 Mon Sep 17 00:00:00 2001 From: "inkeep[bot]" <257615677+inkeep[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:03:52 +0000 Subject: [PATCH 1/2] docs: document optional `path` parameter for deep links Updates provisioning API docs to reflect PR #60740 which adds an optional `path` parameter to the deep links endpoint, allowing partners to redirect users to any specific in-app page instead of just the project home. --- contents/docs/integrate/provisioning.mdx | 269 ++++++++++++----------- 1 file changed, 135 insertions(+), 134 deletions(-) diff --git a/contents/docs/integrate/provisioning.mdx b/contents/docs/integrate/provisioning.mdx index c46356c2f26b..17bc5667ddca 100644 --- a/contents/docs/integrate/provisioning.mdx +++ b/contents/docs/integrate/provisioning.mdx @@ -39,7 +39,7 @@ sequenceDiagram ### 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. +Reuses the access token from onboarding to mint a single-use URL that logs the user into their PostHog project. You can optionally specify a `path` to send the user to a specific in-app page. ``` 4. POST /deep_links → single-use URL (10 min TTL) @@ -56,7 +56,7 @@ sequenceDiagram PH-->>P: { url, expires_at } P-->>U: redirect to url U->>PH: GET /agentic/login?token=... - PH-->>U: redirect to /project/N (session minted) + PH-->>U: redirect to path or /project/N (session minted) ``` ## Set up as a partner @@ -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): @@ -321,15 +322,15 @@ 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 – use this for the PostHog API | ### 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. +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. You can optionally specify a `path` to land the user on a specific page, such as a session recording, insight, or settings screen. 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. ```bash curl -X POST https://us.posthog.com/api/agentic/provisioning/deep_links \ @@ -337,7 +338,8 @@ curl -X POST https://us.posthog.com/api/agentic/provisioning/deep_links \ -H "Authorization: Bearer phx_abc123..." \ -H "API-Version: 0.1d" \ -d '{ - "purpose": "dashboard" + "purpose": "dashboard", + "path": "/project/12345/replay/019e6d10-c3b0-7000-8000-000000000000" }' ``` @@ -345,9 +347,12 @@ 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 | A free-form label for analytics. Defaults to `dashboard`. | +| `path` | string | No | The in-app path to redirect to after authentication. Must be a relative path starting with `/`. If omitted, the user lands on the project home (`/project/N`). Examples: `/project/12345/replay/SESSION_ID`, `/project/12345/insights/INSIGHT_ID`. | + +The `path` must be a relative, same-origin in-app path starting with `/`. Paths that could cause open redirects – such as absolute URLs (`https://...`), protocol-relative URLs (`//...`), or paths containing backslashes or control characters – are rejected with a `400 invalid_path` error. **Response:** @@ -381,9 +386,9 @@ 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`. @@ -415,23 +420,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 +473,32 @@ 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 | +| `invalid_path` | 400 | The deep link `path` is not a valid relative in-app path | ## 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 +509,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,41 +572,38 @@ 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, @@ -615,27 +614,29 @@ async function provisionPostHogAccount(email, name) { } ``` -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: +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 an optional `path` to deep link to a specific page: ```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' }), - } - ); +async function getDeepLinkUrl(accessToken, path = null) { + const body = { purpose: "dashboard" }; + if (path) { + body.path = 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(body), + }); 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)}`, ); } From 3c72a94c54308ddcd3b932a1c72d13106a186bb8 Mon Sep 17 00:00:00 2001 From: "inkeep[bot]" <257615677+inkeep[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:08:50 +0000 Subject: [PATCH 2/2] docs(provisioning): mention path parameter in deep links description paragraph --- contents/docs/integrate/provisioning.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contents/docs/integrate/provisioning.mdx b/contents/docs/integrate/provisioning.mdx index 17bc5667ddca..0982758af227 100644 --- a/contents/docs/integrate/provisioning.mdx +++ b/contents/docs/integrate/provisioning.mdx @@ -343,7 +343,7 @@ curl -X POST https://us.posthog.com/api/agentic/provisioning/deep_links \ }' ``` -Authenticate with the `access_token` you got from [Step 2](#step-2-exchange-the-code-for-tokens) – 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. +Authenticate with the `access_token` you got from [Step 2](#step-2-exchange-the-code-for-tokens) – the same Bearer credential you use for `/resources`. The token is scoped to a single team. Use the optional `path` parameter to send users directly to a specific page like a session recording, insight, or settings screen. **Request fields:**