> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getflowpilot.io/llms.txt
> Use this file to discover all available pages before exploring further.

# SDK REST API

> The /v1 HTTP contract both FlowPilot SDKs use to resolve flows and send events.

Both FlowPilot SDKs (iOS and Expo) talk to two endpoints: **resolve** (fetch the flow for a placement and user, with a **batch** variant that warms several placements in one round-trip) and **events** (send analytics). Most developers never call these directly. This page is here for debugging and advanced use. Treat the SDK as the supported surface; this contract is owned by the backend and can change.

## Base URLs

| Environment | Base URL                              |
| ----------- | ------------------------------------- |
| production  | `https://api.flowpilot.io/v1`         |
| staging     | `https://staging-api.flowpilot.io/v1` |
| development | `https://dev-api.flowpilot.io/v1`     |

## Authentication

Every request carries an SDK API key as a bearer token:

```text theme={null}
Authorization: Bearer fp_live_xxxxxxxx_...
```

Keys are workspace-scoped and must be type `sdk`. A key of any other type is rejected with `401`. Key prefixes:

* `fp_live_` for production keys.
* `fp_test_` for staging/test keys.

Create and manage keys in the dashboard (see [API keys](/dashboard/api-keys)). The workspace is derived from the key; you never send a workspace ID.

## Resolve

Fetch the flow for a placement and user.

```text theme={null}
POST /v1/apps/{appId}/placements/{placementId}/resolve
```

* `appId` is the app UUID (the App ID from the dashboard).
* `placementId` is the human-readable placement key (for example `onboarding`).

### Request

<ParamField path="user_id" type="string" required>
  Stable identifier for the user. The SDK uses a persistent per-install UUID by default.
</ParamField>

<ParamField path="session_id" type="string" required>
  Identifier for this session.
</ParamField>

<ParamField path="device_platform" type="string" required>
  One of `ios`, `android`, `web`.
</ParamField>

<ParamField path="attributes" type="object">
  Targeting attributes for audience matching (for example `{"country": "US", "app.version": "2.1.0"}`). See [Audience targeting](/dashboard/audience-targeting).
</ParamField>

### Response (200)

A match returns the flow. No match returns `200` with `flow_id: null` (and the backend records a `resolve_no_flow` event server-side).

<ResponseField name="flow_id" type="string | null">
  The matched flow ID, or `null` when no flow matched.
</ResponseField>

<ResponseField name="flow_version_id" type="string">
  The specific published version served.
</ResponseField>

<ResponseField name="flow_version" type="number">
  The version number.
</ResponseField>

<ResponseField name="schema_version" type="number">
  The wire format version (see [Schema versions](/reference/schema-versions)).
</ResponseField>

<ResponseField name="flow_schema" type="object">
  The full flow JSON. See [Flow schema reference](/reference/flow-schema).
</ResponseField>

<ResponseField name="media_base_url" type="string">
  Base URL for relative image and media paths.
</ResponseField>

<ResponseField name="icon_base_url" type="string">
  Base URL for icon SVGs.
</ResponseField>

<ResponseField name="fonts" type="array">
  Font files to download and register. Each item is `{ family, weight, style, url }`.
</ResponseField>

<ResponseField name="experiment_id" type="string">
  Set when an experiment is active on the placement.
</ResponseField>

<ResponseField name="variant_id" type="string">
  The assigned variant, when in an experiment.
</ResponseField>

<ResponseField name="variant_name" type="string">
  The assigned variant's name.
</ResponseField>

<ResponseField name="cache_ttl_seconds" type="number">
  How long the SDK may treat the result as fresh. The SDK uses `300` (5 minutes) when omitted.
</ResponseField>

### Status codes

| Code | Meaning                                                                |
| ---- | ---------------------------------------------------------------------- |
| 200  | Resolved (including the no-flow case, `flow_id: null`).                |
| 400  | Missing/invalid `app_id`, `placement_id`, body, or validation failure. |
| 401  | Missing/invalid key, or wrong key type.                                |
| 403  | The app does not belong to the key's workspace.                        |
| 404  | App not found.                                                         |
| 429  | Rate limited.                                                          |

### Variations

* **Offline export:** add `?export=true` to get the deterministic default flow for the placement. The resolver skips the audience filter and A/B assignment and always returns the placement's default flow version, so the export is stable. Used to snapshot a flow for [offline bundling](/ios-sdk/offline-bundled-flows).
* **Legacy route:** `POST /v1/placements/{placementId}/resolve` exists for backward compatibility and is deprecated. Prefer the app-scoped route.

### Example

```bash theme={null}
curl -X POST \
  "https://api.flowpilot.io/v1/apps/APP_UUID/placements/onboarding/resolve" \
  -H "Authorization: Bearer fp_live_xxxxxxxx_..." \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "u_123",
    "session_id": "s_456",
    "device_platform": "ios",
    "attributes": { "country": "US", "app.version": "2.1.0" }
  }'
```

## Batch resolve

Resolve several placements for one identity in a single round-trip. The SDK uses this for [launch prefetch](/ios-sdk/prefetching) (when `prefetchOnLaunch` declares two or more placements) so it can warm many placements without one request per placement. If the call fails, the SDK falls back to per-placement resolves, so launch prefetch never depends on it.

```text theme={null}
POST /v1/apps/{appId}/placements/resolve-batch
```

### Request

The identity fields (`user_id`, `session_id`, `device_platform`, `attributes`) apply to every key in `placement_keys`, exactly as a single resolve personalizes one placement.

<ParamField path="placement_keys" type="string[]" required>
  The placement keys to resolve (for example `["onboarding", "paywall"]`). Must be non-empty. Duplicates are removed server-side, and the de-duplicated set is capped at **50** placements.
</ParamField>

<ParamField path="user_id" type="string" required>
  Stable identifier for the user.
</ParamField>

<ParamField path="session_id" type="string" required>
  Identifier for this session.
</ParamField>

<ParamField path="device_platform" type="string" required>
  One of `ios`, `android`, `web`.
</ParamField>

<ParamField path="attributes" type="object">
  Targeting attributes for audience matching, applied to every placement. See [Audience targeting](/dashboard/audience-targeting).
</ParamField>

### Response (200)

A `results` array with one entry per de-duplicated placement key. Each entry is the **same shape as a single resolve response** (`flow_id`, `flow_version_id`, `flow_schema`, `fonts`, `cache_ttl_seconds`, and so on), tagged with its `placement_key`.

Resolution is best-effort per placement, so one placement never sinks the batch:

* A placement that matched no flow returns `flow_id: null` (and the backend records a `resolve_no_flow` event server-side, as with a single resolve).
* A placement that failed to resolve returns `flow_id: null` and an `error` string. The overall response is still `200`.

<ResponseField name="results" type="array">
  One result per requested placement key.
</ResponseField>

<ResponseField name="results[].placement_key" type="string">
  The placement key this result is for. Use it to correlate results regardless of order.
</ResponseField>

<ResponseField name="results[].error" type="string">
  Present only when that one placement failed to resolve. Absent on success and on the no-flow case.
</ResponseField>

Every other field on a result matches the single [resolve response](#response-200) (`flow_id`, `flow_version_id`, `flow_version`, `schema_version`, `flow_schema`, `media_base_url`, `icon_base_url`, `fonts`, `experiment_id`, `variant_id`, `variant_name`, `cache_ttl_seconds`).

### Status codes

| Code | Meaning                                                                                                          |
| ---- | ---------------------------------------------------------------------------------------------------------------- |
| 200  | Resolved. Per-placement no-flow and per-placement errors are reported inside `results`, not as a non-200 status. |
| 400  | Missing/invalid `app_id`, empty `placement_keys`, more than 50 placements, or a validation failure.              |
| 401  | Missing/invalid key, or wrong key type.                                                                          |
| 403  | The app does not belong to the key's workspace.                                                                  |
| 404  | App not found.                                                                                                   |
| 429  | Rate limited.                                                                                                    |

### Example

```bash theme={null}
curl -X POST \
  "https://api.flowpilot.io/v1/apps/APP_UUID/placements/resolve-batch" \
  -H "Authorization: Bearer fp_live_xxxxxxxx_..." \
  -H "Content-Type: application/json" \
  -d '{
    "placement_keys": ["onboarding", "paywall"],
    "user_id": "u_123",
    "session_id": "s_456",
    "device_platform": "ios",
    "attributes": { "country": "US", "app.version": "2.1.0" }
  }'
```

## Events

Send one analytics event or a batch.

```text theme={null}
POST /v1/apps/{appId}/events
```

The body is either a single event object or an array of them.

### Request fields (`EventInput`)

Required:

<ParamField path="event_id" type="string (uuid)" required>Unique event ID.</ParamField>
<ParamField path="app_id" type="string (uuid)" required>Must match the `appId` in the URL (mismatches are rejected).</ParamField>
<ParamField path="flow_id" type="string (uuid)" required>The flow the event belongs to.</ParamField>
<ParamField path="flow_version_id" type="string (uuid)" required>Must reference a real flow version in the app, or the event is rejected.</ParamField>
<ParamField path="user_id" type="string" required>Stable user identifier.</ParamField>
<ParamField path="session_id" type="string" required>Session identifier.</ParamField>
<ParamField path="device_platform" type="string" required>One of `ios`, `android`, `web`.</ParamField>
<ParamField path="event_type" type="string" required>One of the recognized [event types](/reference/event-taxonomy).</ParamField>
<ParamField path="timestamp" type="string (RFC3339)" required>When the event happened.</ParamField>
<ParamField path="sdk_version" type="string" required>The SDK version that produced the event.</ParamField>

Optional: `placement_id`, `flow_version`, `experiment_id`, `variant_id`, `variant_name`, `screen_id`, `screen_name`, `screen_index`, `element_id`, `element_type`, `interaction_type`, `revenue`, `currency`, `time_since_flow_start_ms`, `time_on_screen_ms`, `app_version`, `country`, `properties`.

### Limits

* Up to **100 events** per request.
* Up to **256 KB** payload.

### Response (202)

<ResponseField name="accepted" type="number">Events buffered for storage.</ResponseField>
<ResponseField name="rejected" type="number">Events dropped (validation failure, app\_id mismatch, or out-of-scope flow version).</ResponseField>

The endpoint returns `202 Accepted`; events are written to analytics asynchronously.

### Variations

* **Legacy route:** `POST /v1/events` exists for backward compatibility and is deprecated. Prefer the app-scoped route.

### Example

```bash theme={null}
curl -X POST \
  "https://api.flowpilot.io/v1/apps/APP_UUID/events" \
  -H "Authorization: Bearer fp_live_xxxxxxxx_..." \
  -H "Content-Type: application/json" \
  -d '[{
    "event_id": "550e8400-e29b-41d4-a716-446655440000",
    "app_id": "APP_UUID",
    "flow_id": "FLOW_UUID",
    "flow_version_id": "VERSION_UUID",
    "user_id": "u_123",
    "session_id": "s_456",
    "device_platform": "ios",
    "event_type": "flow_complete",
    "timestamp": "2026-01-10T12:00:05Z",
    "sdk_version": "1.0.0"
  }]'
```

## Notes

* The app-scoped routes are authoritative for `app_id`: the value in the URL wins, and a conflicting body `app_id` is rejected.
* This contract may change. Build against the SDK, not raw HTTP, for production apps.

## Related pages

* [API keys](/dashboard/api-keys)
* [Placements](/dashboard/placements)
* [Prefetching and readiness](/ios-sdk/prefetching)
* [Event taxonomy](/reference/event-taxonomy)
