Experiments
For instructions on how to authenticate to use this endpoint, see API overview.
Endpoints
GET | |
POST | |
GET | |
PATCH | |
DELETE | |
GET | |
POST | |
POST | |
POST | |
POST | |
POST | |
GET | |
GET | |
POST | |
GET | |
GET | |
GET | |
POST | |
POST |
List all experiments
List experiments for the current project. Supports filtering by status and archival state.
Required API key scopes
experiment:readQuery parameters
- archivedboolean
- created_by_idstring
- eventstring
- excluded_tagsstring
- feature_flag_idinteger
- limitinteger
- offsetinteger
- orderstring
- prompt_namestring
- searchstring
- statusstringOne of:
"all""complete""draft""exposure_frozen""paused""running""stopped" - tagsstring
Response
Example request
GET /api /projects /:project_id /experimentsExample response
Status 200
Create experiments
Also available via the PostHog MCP server:
experiment-create— Create experiment
Create a new experiment in draft status with optional metrics.
Required API key scopes
experiment:writeRequest parameters
- namestring
- descriptionstring | null
- start_datestring | null
- end_datestring | null
- feature_flag_keystring
- feature_flag
- holdout_idinteger | null
- parameters
- running_time_calculation
- excluded_variantsarray | null
- secondary_metrics
- saved_metrics_idsarray | null
- filters
- archivedbooleanDefault:
false - deletedboolean | null
- type
- exposure_criteria
- metrics
- metrics_secondary
- stats_config
- scheduling_config
- allow_unknown_eventsbooleanDefault:
false - _create_in_folderstring
- conclusion
- conclusion_commentstring | null
- repositorystring | null
- primary_metrics_ordered_uuidsarray | null
- secondary_metrics_ordered_uuidsarray | null
- only_count_matured_usersboolean
- update_feature_flag_paramsbooleanDefault:
false - versioninteger | null
- original_experimentobject | null
- tagsarray
Response
Example request
POST /api /projects /:project_id /experimentsExample response
Status 201
Retrieve experiments
Also available via the PostHog MCP server:
experiment-get— Get experiment details
Retrieve a single experiment by ID, including its current status, metrics, feature flag, and results metadata.
Required API key scopes
experiment:readPath parameters
- idinteger
Response
Example request
GET /api /projects /:project_id /experiments /:idExample response
Status 200
Update experiments
Also available via the PostHog MCP server:
experiment-update— Update experiment
Update an experiment. Use this to modify experiment properties such as name, description, metrics, variants, and configuration. Metrics can be added, changed and removed at any time. Feature-flag config (variants, rollout, payloads) is sent via the feature_flag object.
Required API key scopes
experiment:writePath parameters
- idinteger
Request parameters
- namestring
- descriptionstring | null
- start_datestring | null
- end_datestring | null
- feature_flag_keystring
- feature_flag
- holdout_idinteger | null
- parameters
- running_time_calculation
- excluded_variantsarray | null
- secondary_metrics
- saved_metrics_idsarray | null
- filters
- archivedbooleanDefault:
false - deletedboolean | null
- type
- exposure_criteria
- metrics
- metrics_secondary
- stats_config
- scheduling_config
- allow_unknown_eventsbooleanDefault:
false - _create_in_folderstring
- conclusion
- conclusion_commentstring | null
- repositorystring | null
- primary_metrics_ordered_uuidsarray | null
- secondary_metrics_ordered_uuidsarray | null
- only_count_matured_usersboolean
- update_feature_flag_paramsbooleanDefault:
false - versioninteger | null
- original_experimentobject | null
- tagsarray
Response
Example request
PATCH /api /projects /:project_id /experiments /:idExample response
Status 200
Delete experiments
Hard delete of this model is not allowed. Use a patch API call to set "deleted" to true
Required API key scopes
experiment:writePath parameters
- idinteger
Example request
DELETE /api /projects /:project_id /experiments /:idExample response
Status 405 No response body
Retrieve experiments activity
Change history for this experiment.
Returns a paginated audit trail of changes to the experiment, its holdouts and shared metrics, and its linked feature flag: who made each change, what changed (field-level before/after values), and when. Ordered newest first.
Required API key scopes
activity_log:readPath parameters
- idinteger
Query parameters
- limitintegerDefault:
10 - pageintegerDefault:
1
Response
Example request
GET /api /projects /:project_id /experiments /:id /activityExample response
Status 200
Status 404 No response body
Create experiments archive
Archive an ended experiment.
Hides the experiment from the default list view. The experiment can be restored at any time by updating archived=false. When the linked feature flag is still enabled, pass disable_feature_flag=true to also disable and archive it. Returns 400 if the experiment is already archived or has not ended yet.
Required API key scopes
experiment:writePath parameters
- idinteger
Request parameters
- disable_feature_flagbooleanDefault:
false
Response
Example request
POST /api /projects /:project_id /experiments /:id /archiveExample response
Status 200
Create experiments copy to project
Copy an experiment into another project in the same organization as a new draft.
Required API key scopes
experiment:writePath parameters
- idinteger
Request parameters
- target_team_idinteger
- feature_flag_keystring
- namestring
Response
Example request
POST /api /projects /:project_id /experiments /:id /copy_to_projectExample response
Status 200
Create experiments create exposure cohort for experiment
Mixin for ViewSets to handle approval-gate exceptions raised from decorated serializers.
Intercepts ApprovalRequired (409) and PolicyConflict (400) raised by the @approval_gate decorator on serializer methods and converts them into the same responses the viewset path produces (see decorators._result_to_response), so both paths share one contract.
Required API key scopes
experiment:writePath parameters
- idinteger
Request parameters
- namestring
- descriptionstring | null
- start_datestring | null
- end_datestring | null
- feature_flag_keystring
- holdout_idinteger | null
- parameters
- running_time_calculation
- excluded_variantsarray | null
- secondary_metrics
- saved_metrics_idsarray | null
- filters
- archivedbooleanDefault:
false - deletedboolean | null
- type
- exposure_criteria
- metrics
- metrics_secondary
- stats_config
- scheduling_config
- allow_unknown_eventsbooleanDefault:
false - _create_in_folderstring
- conclusion
- conclusion_commentstring | null
- repositorystring | null
- primary_metrics_ordered_uuidsarray | null
- secondary_metrics_ordered_uuidsarray | null
- only_count_matured_usersboolean
- update_feature_flag_paramsbooleanDefault:
false - versioninteger | null
- original_experimentobject | null
- tagsarray
Example request
POST /api /projects /:project_id /experiments /:id /create_exposure_cohort_for_experimentExample response
Status 200 No response body
Create experiments duplicate
Mixin for ViewSets to handle approval-gate exceptions raised from decorated serializers.
Intercepts ApprovalRequired (409) and PolicyConflict (400) raised by the @approval_gate decorator on serializer methods and converts them into the same responses the viewset path produces (see decorators._result_to_response), so both paths share one contract.
Required API key scopes
experiment:writePath parameters
- idinteger
Request parameters
- namestring
- descriptionstring | null
- start_datestring | null
- end_datestring | null
- feature_flag_keystring
- holdout_idinteger | null
- parameters
- running_time_calculation
- excluded_variantsarray | null
- secondary_metrics
- saved_metrics_idsarray | null
- filters
- archivedbooleanDefault:
false - deletedboolean | null
- type
- exposure_criteria
- metrics
- metrics_secondary
- stats_config
- scheduling_config
- allow_unknown_eventsbooleanDefault:
false - _create_in_folderstring
- conclusion
- conclusion_commentstring | null
- repositorystring | null
- primary_metrics_ordered_uuidsarray | null
- secondary_metrics_ordered_uuidsarray | null
- only_count_matured_usersboolean
- update_feature_flag_paramsbooleanDefault:
false - versioninteger | null
- original_experimentobject | null
- tagsarray
Example request
POST /api /projects /:project_id /experiments /:id /duplicateExample response
Status 200 No response body
Create experiments end
End a running experiment without shipping a variant.
Sets end_date to now and marks the experiment as stopped. The feature flag is NOT modified — users continue to see their assigned variants and exposure events ($feature_flag_called) continue to be recorded. However, only data up to end_date is included in experiment results.
Use this when:
- You want to freeze the results window without changing which variant users see.
- A variant was already shipped manually via the feature flag UI and the experiment just needs to be marked complete.
The end_date can be adjusted after ending via PATCH if it needs to be backdated (e.g. to match when the flag was actually paused).
Other options:
- Use ship_variant to end the experiment AND roll out a single variant to 100%% of users.
- Use pause to deactivate the flag without ending the experiment (stops variant assignment but does not freeze results).
Returns 400 if the experiment is not running.
Required API key scopes
experiment:writePath parameters
- idinteger
Request parameters
- conclusion
- conclusion_commentstring | null
- open_cleanup_prbooleanDefault:
false - repositorystring | null
- set_repository_as_team_defaultbooleanDefault:
false
Response
Example request
POST /api /projects /:project_id /experiments /:id /endExample response
Status 200
Retrieve experiments flag cleanup target
Repository a flag-cleanup pull request for this experiment would be opened in.
Resolution order: the experiment's saved repository, else the environment's default
cleanup repository, else the team's only connected GitHub repository. When the team
has several repositories and none is saved (source=ambiguous), pass one via
repository on end/ship_variant.
Required API key scopes
experiment:readPath parameters
- idinteger
Response
Example request
GET /api /projects /:project_id /experiments /:id /flag_cleanup_targetExample response
Status 200
Retrieve experiments flag cleanup task
Status of the flag-cleanup Desktop task opened for this experiment.
When an experiment was ended or shipped with open_cleanup_pr=true, a Desktop task removes the experiment's feature-flag code and opens a draft pull request. This returns that task's latest run status and the PR URL once one is opened. Poll until is_terminal is true. Returns 404 when no cleanup task was opened.
Required API key scopes
experiment:readPath parameters
- idinteger
Response
Example request
GET /api /projects /:project_id /experiments /:id /flag_cleanup_taskExample response
Status 200
Create experiments freeze exposure
Freeze exposure on a running experiment while metrics keep flowing.
Snapshots the already-exposed users into a static cohort and narrows the
linked feature flag so only those users keep matching — new users can no
longer enter the experiment. end_date is left null so long-term metrics
(revenue/LTV/renewals/retention) keep accumulating. Enrolled users keep
their assigned variant. The serialized status becomes 'exposure_frozen'.
Returns 400 if the experiment is not running, exposure is already frozen, the experiment is group-aggregated (group flags cannot be frozen with a person cohort), or the exposed set is too large to snapshot synchronously.
Required API key scopes
experiment:writePath parameters
- idinteger
Response
Example request
POST /api /projects /:project_id /experiments /:id /freeze_exposureExample response
Status 200
Retrieve experiments health
Health check findings for this experiment: problems in its configuration and its feature flag
that keep it from producing trustworthy results, each with a fix. checks gives the result of
each check, so a check that found no problem can be told from one that did not apply or could
not read an input.
The checks here read the experiment, its flag, its linked metrics, the project's event and
property definitions, the cohorts of the exposure criteria, and the changes of the flag's
variant split since launch. The checks that read exposure counts come with the
exposure query. The split-change check compares the split changes with the exposures only
for exposure criteria that name an event without property filters. When the split changed
after launch and the criteria have filters or an action, or an API key lacks query:read,
it returns an info finding with the subcode not_checked and the cause in
reason. A caller who can't view the linked flag's history (viewer access to the
flag and, with an API key, activity_log:read) gets that finding for every running
experiment, because the changes are not read.
Two checks read whether the project received an event from its event definitions: a
definition without last_seen_at, such as one Wizard wrote for a planned event, counts as
not received. A name whose definition was deleted since launch, or in the last day for a
draft, is not checked, because ingestion writes the definition again only when the event
arrives. No missing definition is checked when ingestion saw none of the experiment's events
in the last day, because ingestion can be set to skip a team. metric_event_not_ingested
names each metric that
reads an event the project never received, in the evidence keys metric_N,
metric_N_role and metric_N_events. exposure_criteria_cannot_match reports a custom
exposure event the project never received (exposure_event_not_ingested), a
$feature_flag filter on the flag call events that requires another flag
(filter_on_another_flag), and a filter on an event or person property that the project has
no definition of, with an operator that a missing property fails
(filter_on_property_not_ingested, the keys in evidence.properties). A property whose
definition was ever deleted is not checked, and neither is an event property filter on an
action or on $groupidentify, whose properties ingestion keeps no definitions of. An event
or property not received is info before launch, in the first 24 hours after launch, and when
only secondary metrics read it, and a warning otherwise. filter_on_another_flag is a
warning in every state, filter_on_property_not_ingested also reports after the end, and
the other findings stop at the end.
exposure_filters reads the cohort filters of the exposure criteria and of the activation,
not the test account filters. exposure_filter_on_dynamic_cohort is a warning in every
state: the results use the members of a dynamic cohort at the calculation, not at exposure.
Its subcode is cohort_forces_variant when a release condition of the flag that matches the
cohort's members gives one variant, cohort_in_release_conditions when the release
conditions match its members otherwise, and evidence.cohort_ids names the cohorts. Both
subcodes need the exposure criteria to keep the members too, not to leave them out.
metric_cannot_compile builds and compiles the query of each metric without running it, as
the caller, so a metric that every calculation fails on shows before launch. A metric that
reads a warehouse table or view the caller may not query is not checked for them. The finding
names each failing metric in the evidence keys metric_N, metric_N_role, metric_N_error
(the error class, which is also the subcode of the first metric) and metric_N_message (the
compiler's message). It is a warning when a primary metric fails, also after the end, because
the calculation after the fix reads the events up to the end. It is info otherwise. A failure
inside ClickHouse, such as a type that the data holds, isn't found. A failing metric compiles
again on every request. A metric that compiles is cached per caller for a day, so a cohort or
table that is deleted after a compile can take up to a day to show.
metric_calculation_failed reports each metric whose latest calculation failed, from the
latest finished calculation that the results page shows. It leaves out a metric that
metric_cannot_compile reports. The subcode is the kind of the first failure: busy,
query_limit (the query reached the read, memory, time or length limit), settings (a setting of
the metric or the experiment), not_enough_data, experiment_too_old (the experiment
started too long ago to calculate again) or unexpected_error. Each metric is in the
evidence keys metric_N, metric_N_role, metric_N_failure (the kind), metric_N_error
(the limit, or the family of a setting) and metric_N_message (only a message that PostHog
wrote for users), and calculated_at is when the calculation finished. It is a
warning when a primary metric fails for a kind that a change fixes (query_limit,
settings, experiment_too_old), also after the end, because the calculation after the fix
reads the events up to the end. It is info otherwise. When the first failure reached the
read, memory or time limit, reason names the cause that the calculation shows: metric
when another metric of the same calculation completed the same read of the exposures, or
exposure_criteria when every metric reached a limit and an exposure or test account filter
makes each query read all the properties of an event, person or group. evidence.properties
then names the filters. A query that read precomputed exposures doesn't apply those filters,
so it can't get exposure_criteria. It is null otherwise.
metric_all_zero reports a metric that reads zero in every variant, from the same latest
finished calculation, so the results page shows "Not enough data yet" at any sample size.
Each side of a ratio that is zero in every variant counts on its own. It reads the stored
result and the project's event definitions, and no events. A primary metric needs 1,000
exposures in the result and 72 hours of data, because most zeros before that are outcomes
that take time. A retention metric counts those hours from the start of its retention
window. The subcode is the cause of the first metric: blank_event (an event with an empty
name), event_stopped (the project last received the event before launch, while ingestion
still records the project), outlier_bound (an upper outlier bound that is 0 while some
exposures have a value) or null when none shows a cause. blank_event and outlier_bound
also report a secondary metric, and need only 100 exposures and 12 hours of data, because
they read 0 at any traffic. A bound on the largest value of the exposures can't be 0 while
one has a value, so it is no cause. Neither is a bound on a metric with breakdowns, which
gets one bound per breakdown group. Until the end, the check leaves out a metric that uses an
event metric_event_not_ingested reports. Each metric, or side of a ratio, is in the
evidence keys metric_N, metric_N_role, metric_N_side, metric_N_exposures and
metric_N_cause. It is a warning for a primary metric. After the end it stays a warning only
for a cause that a setting fixes, because the calculation after the fix reads the events up
to the end. It is info otherwise.
The severity of the some_conditions_pinned finding follows the largest share that the
users of the pinned release conditions make up among the users who got one variant from
the flag, in the last 7 days, or since launch or the flag's last save if later. It reads
the flag's calls that name the matched condition, not the experiment's exposure criteria:
info up to 5%, warning above, and no finding under 1% when at least half of the calls
that returned a variant name a condition. The finding is info when the share is unknown,
and reason says why: not_measured (for example an API key without
query:read, or a caller denied a flag call property), too_few_people (under 100), or
evaluated_outside_flag_service (most calls come from local evaluation, a bootstrap or
the caller's own assignment). A pin to a variant with 0% in the split adds an info,
because that variant's results show only the pinned users, unless the experiment
excludes the variant from the analysis.
Two findings say that the variants are assigned outside PostHog, as info. A running
experiment whose flag has a 0% rollout gets assigned_outside_flag_service instead of
running_but_no_rollout when 10 or more people got a variant from flag calls without a
flag version that are no local evaluation, from an hour after the window starts. When
every condition that includes users pins the variant whose key one property holds,
assigned_by_property replaces all_conditions_pinned. When none of them includes
everyone, so that every other user gets no variant, closed_to_matched_users replaces it,
as a warning. A condition without filters, or with only filters that leave users out,
includes everyone.
A draft whose flag has no recorded call gets the info new_flag_no_traffic. The record
is the flag's last_called_at, which a periodic task updates, so it trails a call. The
check reports not_checked when the organization's flag calls skip the events table,
and when the project has no active flag, because the web SDK then requests no flags.
A running experiment whose flag is disabled gets paused_from_experiment, as info, when
its latest pause or resume since launch is a pause of the experiment. Otherwise it gets
running_but_flag_disabled, as a warning. A caller who can't view the linked flag's
history gets the warning, because the pause is not read. A draft whose flag is already
active with multiple variants gets not_started_but_multiple_variants_rolled_out as info.
An ended experiment whose flag is still active with multiple variants gets no finding when
another experiment on the flag is running or launched after the end. Otherwise it gets
ended_flag_shared_with_draft as info when a draft experiment uses the flag,
ended_but_multiple_variants_rolled_out as a warning when the flag's last_called_at is
after the end and within the last 3 days, and ended_flag_not_called as info when it is
not, from 3 days and an hour after the end, because last_called_at trails a call. Until
then without such a call, or when the organization's flag calls can't be read, it gets
ended_flag_calls_unknown as info.
A running experiment whose first primary metric is a funnel gets the info
early_significance (subcode below_detectable_effect) when a significant variant's
difference is smaller than the smallest difference its sample can detect, while that
sample is below the one the running time estimate plans for the minimum detectable
effect. It reads the metric's current stored result, the one the results page shows
after its last calculation, and the running time calculator's formula.
A running experiment with only_count_matured_users on gets the info
metrics_not_matured while it is younger than the conversion or retention window of
every primary metric, because no user can count for those metrics until a window passes.
Required API key scopes
experiment:readPath parameters
- idinteger
Response
Example request
GET /api /projects /:project_id /experiments /:id /healthExample response
Status 200
Retrieve experiments health inputs
Staff only. The inputs that the health checks read for this reader, to debug why a finding shows or not:
the context of the checks in health, the exposure query's answer that the exposure checks read, and facts
about the project from the setup context's providers (the team's experiment defaults and which SDKs call
flags). The exposure answer comes from the cache. When it is missing or stale, a calculation starts in the
background and exposures.status says so.
Required API key scopes
experiment:readPath parameters
- idinteger
Response
Example request
GET /api /projects /:project_id /experiments /:id /health_inputsExample response
Status 200
Retrieve experiments in session exposure
How the recordings tab's in-session exposure scope reads on this experiment.
Resolved through the same seam as the recordings query's in_session refusal, so the
scope control disables exactly what a query would be refused for, and the copy can say
when sessions are matched on the stamped flag property rather than on the exposure event.
Postgres reads only, so it can serve the tab's mount path.
Required API key scopes
experiment:readPath parameters
- idinteger
Response
Example request
GET /api /projects /:project_id /experiments /:id /in_session_exposureExample response
Status 200
Create experiments launch
Launch a draft experiment.
Validates the experiment is in draft state, activates its linked feature flag, sets start_date to the current server time, and transitions the experiment to running. Returns 400 if the experiment has already been launched or if the feature flag configuration is invalid (e.g. fewer than 2 variants).
Required API key scopes
experiment:writePath parameters
- idinteger
Response
Example request
POST /api /projects /:project_id /experiments /:id /launchExample response
Status 200
Create experiments metrics recalculation
Trigger a batch recalculation of all metrics for this experiment.
Returns 201 with the new pending recalculation, or 200 with the active one if a recalculation is
already pending or in progress for this experiment. An experiment that started too long ago gets 201 with
a run that is already failed: every metric carries the recalculation_window_too_long error in
metric_errors, no workflow starts, and a retry gets the same answer. A manual trigger within five
minutes after the latest completed run finished returns 429 with a Retry-After header. The response payload intentionally does not
include the results array — at POST time the workflow has just been queued and no per-metric
results exist yet. Clients should poll GET metrics_recalculation/{id}/ for results as the workflow
progresses.
Required API key scopes
experiment:writePath parameters
- idinteger
Request parameters
- triggerDefault:
manual
Response
Example request
POST /api /projects /:project_id /experiments /:id /metrics_recalculation




