# REST API

Everything the TryFast app does goes through the same REST API, so your scripts can create projects, build and launch experiments and read results. AI agents get the same operations through the [MCP server](mcp.md).

- Base URL: `https://tryfast.eu/api/v1`
- OpenAPI 3.1 description: `https://tryfast.eu/api/openapi.yaml`

## Authentication

| Credential | How to send it | Who uses it |
|---|---|---|
| API key `tf_pk_...` | `Authorization: Bearer tf_pk_...` | Scripts, CI, headless agents. Create one in the app under **API keys**, or get one when you [create a workspace from a script](#start-without-an-account). |
| Session cookie `tryfast_session` | Sent by the browser | The TryFast app, after sign-in. |
| Browser cookie `tryfast_grant` | Sent by the browser | The TryFast app for [workspaces without an account](account.md). |

API keys have one of three permissions, chosen when you create them: **Read** (`experiments:read`), **Build** (adds `experiments:write`; launches wait for approval) and **Full** (adds `experiments:publish`). A key never has more rights than the role of the person who created it, and it stops working when that person leaves the workspace. The key is shown once; TryFast stores only a hash.

The recovery token of an unsaved workspace (`tfe_...`) is not a credential for the API. It only saves the workspace to an account or opens it in a browser; give its link to the person.

Approving or rejecting an approval request accepts only a person's browser session, never a Bearer credential.

## Quick start

Who am I, and which workspaces can I see:

```bash
curl -s https://tryfast.eu/api/v1/me -H "Authorization: Bearer $TRYFAST_API_KEY"
```

Create a project and get its snippet:

```bash
curl -s https://tryfast.eu/api/v1/workspaces/ws_4fK9zQ2mPx/projects \
  -H "Authorization: Bearer $TRYFAST_API_KEY" -H 'Content-Type: application/json' \
  -d '{"name": "Acme shop", "domains": ["acme-shop.de", "www.acme-shop.de"]}'

curl -s https://tryfast.eu/api/v1/projects/prj_k2L9mQ0aZx/snippet \
  -H "Authorization: Bearer $TRYFAST_API_KEY"
```

Create a click goal and a complete draft experiment:

```bash
curl -s https://tryfast.eu/api/v1/projects/prj_k2L9mQ0aZx/goals \
  -H "Authorization: Bearer $TRYFAST_API_KEY" -H 'Content-Type: application/json' \
  -d '{"name": "Started checkout", "key": "started_checkout", "type": "click", "selector": "a.checkout"}'

curl -s https://tryfast.eu/api/v1/projects/prj_k2L9mQ0aZx/experiments \
  -H "Authorization: Bearer $TRYFAST_API_KEY" -H 'Content-Type: application/json' \
  -d '{
    "name": "Free shipping headline",
    "hypothesis": "Naming free shipping in the headline makes more visitors start checkout.",
    "targeting": {"pages": {"include": [{"match": "exact", "value": "/pricing"}]}},
    "variations": [
      {"name": "Control", "is_control": true},
      {"name": "Free shipping", "changes": [
        {"op": "set_text", "selector": "#hero h1", "value": "Free shipping on every order"}
      ]}
    ],
    "goals": [{"goal_id": "goal_Pu9rC4s3Ez", "role": "primary"}]
  }'
```

Launch it, then read the results:

```bash
curl -s -X POST https://tryfast.eu/api/v1/experiments/exp_7Hq2kD9xQa/start \
  -H "Authorization: Bearer $TRYFAST_API_KEY" -H 'Content-Type: application/json' -d '{}'

curl -s https://tryfast.eu/api/v1/experiments/exp_7Hq2kD9xQa/results \
  -H "Authorization: Bearer $TRYFAST_API_KEY"
```

With a Full key the start answers `200` and the experiment runs. With a Build key it answers `202` with `"status": "awaiting_approval"` and an `approve_url` for a person to open.

### Start without an account

A script can create a workspace without an account, with an optional project and API key:

```bash
curl -s https://tryfast.eu/api/v1/anonymous-workspaces \
  -H 'Content-Type: application/json' \
  -d '{"website_url":"https://shop.example","issue_api_key":true}'
```

The `201` response contains `workspace`, `project` and `snippet`, `api_key` (shown once), `mcp_url`, `expires_at` and `claim_url`. The `claim_url` carries the workspace's recovery token after `#token=`: it is for the person, never for the script or agent. Launches requested with the key wait for approval, and approving needs a signed-in person who opens the `claim_url`. Creation is limited to 3 workspaces per hour per network address. Creating a workspace accepts the [Terms of Service](../legal/terms-of-service.md) and the [Data Processing Agreement](../legal/data-processing-agreement.md); the [Privacy policy](../legal/privacy-policy.md) explains how we handle data. [Workspaces and accounts](account.md) explains the limits and the lifecycle of unsaved workspaces.

## Conventions

- **JSON** in and out (`Content-Type: application/json`), `snake_case` field names, timestamps in RFC 3339 UTC.
- **Ids** carry a prefix: `ws_`, `prj_`, `exp_`, `var_`, `goal_`, `aud_`, `apr_`, `key_`. A project also has a public 10-character `key` used in its snippet URL; it is not a credential.
- **Traffic** shares are integer basis points (`10000` = 100%). Rates and lifts are fractions (`0.12` = 12%).
- **Lists** return `{"items": [...], "next_cursor": "..."}`; pass `limit` (1 to 200, default 50) and `cursor` for the next page. `next_cursor` is `null` on the last page.
- **Dry runs**: every create, update, delete and lifecycle action of projects, goals, audiences, experiments and variations, and the visitor erase, accepts `?dry_run=true` (the OpenAPI file lists it on each). Nothing is saved; the response shows the diff, the status it would have, whether it would need approval, and the preflight checks. Other writes answer `dry_run=true` with 400 instead of running, and writes refuse unknown query parameters, so a typo never executes the call.
- **Idempotency**: creates accept an `Idempotency-Key` header (8 to 64 characters, remembered for 24 hours). Repeating the request returns the first response. Operations that return a secret (workspace creation, API keys, invitations, recovery, preview and editor links) reject the header.
- **Versions**: objects with a `version` accept `expected_version`. When the object changed since you read it, the write answers `409 version_conflict` with the current version.
- **Approvals**: launch-type actions (start, resume, schedule, traffic changes, stop, ship, ending a rollout, and project settings that change live behavior) need the `experiments:publish` permission. Without it they answer `202` with an `ActionResult` whose `status` is `awaiting_approval`, an `approval` object and an `approve_url`. Poll `GET /approvals/{approval_id}`.
- **Unknown fields** in a request body are rejected (`400 invalid_request`), so typos never pass silently.

## Endpoints

The OpenAPI file documents every request and response. An overview:

| Area | Endpoints |
|---|---|
| Account | `GET /me`, `DELETE /me`, `GET /workspaces`, `POST /workspaces`, `GET`, `PATCH`, `DELETE /workspaces/{id}`, `GET /workspaces/{id}/usage`, `GET /workspaces/{id}/activity` |
| Without an account | `POST /anonymous-workspaces`, `POST /anonymous-workspaces/redeem`, `POST /workspaces/{id}/claim`, `POST /workspaces/{id}/edit-token` |
| Team (people only) | `GET`, `PATCH`, `DELETE /workspaces/{id}/members/...`, `GET`, `POST`, `DELETE /workspaces/{id}/invitations/...`, `GET /invitations/{token}`, `POST /invitations/{token}/accept` |
| Access | `GET`, `POST /workspaces/{id}/api-keys`, `DELETE /api-keys/{id}`, `GET /workspaces/{id}/oauth-grants`, `DELETE /oauth-grants/{id}` |
| Approvals | `GET /workspaces/{id}/approvals`, `GET /approvals/{id}`, `POST /approvals/{id}/approve`, `POST /approvals/{id}/reject` |
| Projects | `GET`, `POST /workspaces/{id}/projects`, `GET`, `PATCH`, `DELETE /projects/{id}`, `GET /projects/{id}/snippet`, `GET /projects/{id}/install-status`, `POST /projects/{id}/install-check`, `POST /projects/{id}/inspect`, `GET /projects/{id}/events`, `POST /projects/{id}/estimate`, `GET /projects/{id}/activity`, `POST /projects/{id}/visitors/erase` |
| Goals | `GET`, `POST /projects/{id}/goals`, `GET`, `PATCH`, `DELETE /goals/{id}` |
| Audiences | `GET`, `POST /projects/{id}/audiences`, `GET`, `PATCH`, `DELETE /audiences/{id}` |
| Experiments | `GET`, `POST /projects/{id}/experiments`, `GET`, `PATCH`, `DELETE /experiments/{id}`, `GET /experiments/{id}/preflight`, `POST /experiments/{id}/duplicate`, `GET /experiments/{id}/activity` |
| Lifecycle | `POST /experiments/{id}/start`, `/pause`, `/resume`, `/traffic`, `/stop`, `/ship`, `/archive`, `/unarchive` |
| Variations | `POST /experiments/{id}/variations`, `PATCH`, `DELETE /variations/{id}`, `PUT /variations/{id}/changes` |
| Results | `GET /experiments/{id}/results`, `GET /experiments/{id}/results.csv`, `GET /experiments/{id}/estimate` |
| Preview and editor | `POST /experiments/{id}/preview-links`, `POST /experiments/{id}/preview-links/reset`, `POST /experiments/{id}/editor-links` |
| Reference | `GET /targeting/conditions` (the [targeting](experiments/targeting.md) condition catalog), `GET /docs/search?q=` |

The results format is explained in [Statistics](statistics.md); the experiment format in [Experiments](experiments/index.md), [Variations](experiments/variations.md) and [Targeting](experiments/targeting.md).

## Errors

Every error response (status 400 and above) has the same shape:

```json
{
  "error": {
    "code": "validation_failed",
    "message": "The experiment has 2 problems.",
    "next_step": "Fix the listed fields and retry.",
    "doc_url": "https://tryfast.eu/docs/api#errors",
    "details": {},
    "field_errors": [
      {"field": "variations[1].js", "code": "compile_error", "message": "Unexpected \"}\"", "line": 3, "column": 1},
      {"field": "variations[0].weight_bp", "code": "invalid", "message": "Weights must sum to 10000 (got 9000)."}
    ]
  }
}
```

`message` says what happened, `next_step` what to do. `details` and `field_errors` appear when they apply. Server errors carry a `details.request_id`; include it when you write to support@tryfast.eu. The MCP server returns the same object inside its tool errors.

| Code | HTTP | Meaning |
|---|---|---|
| `invalid_request` | 400 | Malformed JSON, wrong content type, an unknown field, a bad query parameter, or an `Idempotency-Key` on an operation that returns a secret. |
| `unauthorized` | 401 | No credential or an invalid one. The response carries `WWW-Authenticate: Bearer realm="tryfast"`. |
| `insufficient_scope` | 403 | The credential is valid but its permission is too narrow (`details.required_scope`). |
| `forbidden` | 403 | Your role does not allow this, a Bearer credential tried to approve or reject, or an editor token was used for another project. |
| `csrf_failed` | 403 | A cookie request without same-origin proof. |
| `claim_required` | 403 | The action needs a workspace saved to an account or a signed-in person: custom JavaScript going live, members, invitations, more API keys, or approving in a workspace created by a script. |
| `plan_limit_reached` | 403 | A plan limit was reached. `details` holds `limit` (`projects`, `running_experiments`, `monthly_tested_users` or `free_workspaces`), `current`, `max`, `plan` and `upgrade_url`. |
| `not_found` | 404 | The object does not exist or is not visible to you. Objects of other workspaces always answer this. |
| `method_not_allowed` | 405 | The path exists but not with this method. |
| `conflict` | 409 | A unique key is taken or a precondition failed; `details.reason` is one of `duplicate_key`, `last_owner`, `goal_in_use`, `goal_key_has_conversions`, `currency_locked`, `sole_owner_with_members`, `audience_in_use`. |
| `version_conflict` | 409 | The object changed since you read it (`details.current_version`). Read it again and retry. |
| `invalid_transition` | 409 | The lifecycle action is not allowed from the current status (`details.status`, `details.allowed`). |
| `experiment_running_immutable` | 409 | The change would invalidate the statistics of a running experiment. Stop it and duplicate it instead. |
| `approval_stale` | 409 | The experiment or project changed after the approval was requested. The request was replaced; ask again. |
| `idempotency_key_reused` | 409 | The same `Idempotency-Key` was sent with a different request. |
| `payload_too_large` | 413 | The request body is over the limit. |
| `validation_failed` | 422 | The request is well formed but not valid. `field_errors` lists every problem. |
| `preflight_failed` | 422 | The experiment cannot start; `details.checks` lists the [preflight checks](experiments/lifecycle.md#preflight-checks). |
| `bundle_too_large` | 422 | Publishing would make the project's script larger than 200 KB uncompressed. |
| `workspace_frozen` | 423 | The unsaved workspace is paused after 30 days without use. Reading works; save it to an account to make changes. |
| `rate_limited` | 429 | Too many requests. Wait for the number of seconds in `Retry-After`. |
| `fetch_failed` | 502 | A page check could not fetch the URL (`details.reason`: `blocked_address`, `timeout`, `too_large`, `status_<code>`, `redirect_off_domain` or `tls`). |
| `idp_unavailable` | 503 | The sign-in service is not reachable. Sign-in only. |
| `busy` | 503 | The service is busy. Retry after the seconds in `Retry-After`. |
| `internal` | 500 | Something failed on our side. Include `details.request_id` when you contact support. |

Field error codes: `required`, `invalid`, `too_long`, `too_many`, `out_of_range`, `compile_error` (with `line` and `column`), `invalid_selector`, `invalid_regex`, `not_on_project_domain`, `unknown_goal`, `unknown_audience`, `unsafe_html`, `unsafe_url`, `missing_from_list`, `unknown_field`. The `field` is a JSON path into your request, such as `variations[1].changes[0].selector` or `targeting.audience.items[1].value`.
