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

# Paywall API

> Fetch remote paywall configuration, submit purchase receipts, and log paywall funnel events.

<Note>
  These endpoints are live in production today, served by the FloKit payments gateway. They are separate from the v1 REST API, which is still in design-partner preview.
</Note>

The paywall API powers remote paywalls: your app fetches a published paywall configuration (with deterministic A/B variant selection), submits store receipts for verification, and logs funnel events for paywall analytics.

**Base URL:** `https://payments-gateway.flokitai.com`

## Authentication

These endpoints use request-scoped credentials instead of server API keys:

* `x-app-key` header — your app's **publishable key** (`pk_...`), created per app in the FloKit dashboard. It scopes the request to your app and is safe to embed in your app build (it is not a secret and does not authenticate the user). Required on entitlement reads and funnel event writes; not needed for `GET /api/paywall/config`.
* `x-app-token` header — an optional short-lived **app-session token** minted from the publishable key via [`POST /api/paywall/token`](#post-apipaywalltoken). Anywhere `x-app-key` is accepted, `x-app-token` works instead — send one or the other.
* `x-user-id` header — the user's identity. `userId` or `anonymousId` query parameters are accepted as a fallback where custom headers are inconvenient (the header takes precedence).

Tenant resolution happens server-side from the app key and user identity — clients never send a company or tenant ID.

<Note>
  `x-app-key` enforcement is being phased in ahead of GA. Integrations should send it on every request today; once enforcement is on, requests without a valid key receive `401` on the endpoints marked as requiring it. `@flokit/subscriptions-sdk` v0.2.0+ sends it automatically via the `appKey` option.
</Note>

***

## GET /api/paywall/config

Returns the published paywall configuration for an app, with a single variant selected for the requesting user. Variant selection is deterministic — the same user always receives the same variant for a given paywall, and traffic splits respect each variant's `traffic_weight` when an experiment is active.

### Query parameters

<ParamField query="app_id" type="string" required>
  Your application identifier.
</ParamField>

<ParamField query="paywall_id" type="string" required>
  The paywall to fetch, e.g. `onboarding`.
</ParamField>

<ParamField query="userId" type="string">
  User identity fallback if the `x-user-id` header is not set.
</ParamField>

<ParamField query="anonymousId" type="string">
  Anonymous identity fallback for pre-login users.
</ParamField>

### Example

<CodeGroup>
  ```bash cURL theme={null}
  curl "https://payments-gateway.flokitai.com/api/paywall/config?app_id=your_app&paywall_id=onboarding" \
    -H "x-user-id: usr_abc123"
  ```

  ```typescript TypeScript theme={null}
  const res = await fetch(
    'https://payments-gateway.flokitai.com/api/paywall/config' +
      '?app_id=your_app&paywall_id=onboarding',
    { headers: { 'x-user-id': 'usr_abc123' } },
  );

  const config = await res.json();
  // config.variant.offers → render the paywall
  ```
</CodeGroup>

### Response

```json theme={null}
{
  "paywall_id": "onboarding",
  "version": 3,
  "variant": {
    "variant_id": "annual_emphasis",
    "layout": "single_offer",
    "offers": [
      {
        "product_id": "com.yourapp.annual",
        "name": "Annual",
        "price_usd_cents": 4999,
        "currency": "USD",
        "billing_period": "yearly",
        "trial_days": 7
      }
    ],
    "cta_copy": "Start your free trial"
  },
  "fallbackUsed": false
}
```

| Field                | Type    | Description                                                                      |
| -------------------- | ------- | -------------------------------------------------------------------------------- |
| `paywall_id`         | string  | The requested paywall identifier                                                 |
| `version`            | number  | Published configuration version                                                  |
| `variant.variant_id` | string  | The variant selected for this user                                               |
| `variant.layout`     | string  | Layout hint, e.g. `single_offer` or `standard`                                   |
| `variant.offers[]`   | array   | Offers to render (see below)                                                     |
| `variant.cta_copy`   | string  | Optional call-to-action copy                                                     |
| `fallbackUsed`       | boolean | `true` when no published config was found and a safe empty fallback was returned |

Each offer:

| Field             | Type   | Description                                               |
| ----------------- | ------ | --------------------------------------------------------- |
| `product_id`      | string | Store product identifier                                  |
| `name`            | string | Display name                                              |
| `price_usd_cents` | number | Price in minor units                                      |
| `currency`        | string | ISO 4217 currency code                                    |
| `billing_period`  | string | `weekly`, `monthly`, `quarterly`, `yearly`, or `lifetime` |
| `trial_days`      | number | Free trial length; `0` means no trial                     |

If no published configuration exists for the requested paywall, the endpoint still returns `200` with a fallback: `version: 0`, variant `control` with an empty `offers` array, and `fallbackUsed: true`. Your app should treat an empty offer list as "do not show the paywall."

Responses are cacheable: `Cache-Control: public, max-age=60, stale-while-revalidate=300`.

### Errors

| Status | Body                                                                             | Condition               |
| ------ | -------------------------------------------------------------------------------- | ----------------------- |
| `400`  | `{ "error": "app_id and paywall_id are required." }`                             | Missing query parameter |
| `401`  | `{ "error": "x-user-id header or userId/anonymousId query param is required." }` | No identity provided    |

***

## POST /api/paywall/receipt

Submits an in-app purchase receipt for server-side verification. Send the Apple StoreKit 2 signed transaction (JWS) or the Google Play package name + purchase token; the gateway forwards it to the verification service and returns the verification result.

### Headers

| Header         | Value                                  |
| -------------- | -------------------------------------- |
| `x-user-id`    | Your authenticated user ID (required)  |
| `x-app-key`    | Your publishable app key (recommended) |
| `Content-Type` | `application/json`                     |

Receipt verification is protected by the store signature on the receipt itself, so `x-app-key` is not enforced here — but send it anyway for uniformity.

### Request body

<ParamField body="provider" type="string" required>
  `apple` or `google`.
</ParamField>

<ParamField body="signedTransactionInfo" type="string">
  Apple StoreKit 2 signed transaction (JWS). Required for `apple`.
</ParamField>

<ParamField body="packageName" type="string">
  Android package name. Required for `google`.
</ParamField>

<ParamField body="purchaseToken" type="string">
  Google Play purchase token. Required for `google`.
</ParamField>

### Example

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://payments-gateway.flokitai.com/api/paywall/receipt \
    -H "x-user-id: usr_abc123" \
    -H "Content-Type: application/json" \
    -d '{
      "provider": "apple",
      "signedTransactionInfo": "eyJhbGciOiJFUzI1NiIs..."
    }'
  ```

  ```typescript TypeScript theme={null}
  const res = await fetch(
    'https://payments-gateway.flokitai.com/api/paywall/receipt',
    {
      method: 'POST',
      headers: {
        'x-user-id': 'usr_abc123',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        provider: 'google',
        packageName: 'com.yourapp',
        purchaseToken: 'opaque-play-purchase-token',
      }),
    },
  );

  const result = await res.json();
  // { active: true, provider: "google", ... }
  ```
</CodeGroup>

### Response

The verification result is returned as produced by the verification service, for example:

```json theme={null}
{
  "active": true,
  "provider": "apple",
  "product_id": "com.yourapp.annual"
}
```

### Errors

| Status | Body                                      | Condition                                                                |
| ------ | ----------------------------------------- | ------------------------------------------------------------------------ |
| `400`  | `{ "error": "Invalid receipt request." }` | Body fails validation                                                    |
| `401`  | `{ "error": "x-user-id is required." }`   | Missing user identity                                                    |
| `422`  | `{ "error": "..." }`                      | Verification rejected upstream, e.g. no tenant provisioned for this user |

***

## POST /api/paywall/events

Logs paywall funnel events — impressions, offer taps, purchases, conversions, cancellations — for paywall analytics. Accepts 1–50 events per request.

The gateway resolves the request's country at the edge; a server-resolved country takes precedence over any client-supplied `country` value.

### Headers

| Header         | Value                                 |
| -------------- | ------------------------------------- |
| `x-app-key`    | Your publishable app key (required)   |
| `x-user-id`    | Your authenticated user ID (required) |
| `Content-Type` | `application/json`                    |

Every event's `app_id` must belong to the app the key was issued for — mismatches are rejected with `403`.

### Request body

<ParamField body="events" type="array" required>
  1–50 event objects.
</ParamField>

Each event:

<ParamField body="event_type" type="string" required>
  One of `impression`, `offer_tap`, `purchase_start`, `trial_start`, `convert`, `cancel`.
</ParamField>

<ParamField body="app_id" type="string" required>
  Your application identifier.
</ParamField>

<ParamField body="properties" type="object" required>
  Must include `paywall_id` and `variant_id`. Additional properties are passed through.
</ParamField>

<ParamField body="country" type="string">
  ISO 3166-1 alpha-2 country code. Used only if server-side resolution fails.
</ParamField>

<ParamField body="platform" type="string">
  Client platform, e.g. `ios` or `android`.
</ParamField>

<ParamField body="occurred_at" type="string">
  ISO 8601 timestamp of when the event occurred.
</ParamField>

### Example

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://payments-gateway.flokitai.com/api/paywall/events \
    -H "x-app-key: pk_live_your_app_key" \
    -H "x-user-id: usr_abc123" \
    -H "Content-Type: application/json" \
    -d '{
      "events": [
        {
          "event_type": "impression",
          "app_id": "your_app",
          "platform": "ios",
          "occurred_at": "2026-06-21T10:30:00Z",
          "properties": {
            "paywall_id": "onboarding",
            "variant_id": "annual_emphasis"
          }
        }
      ]
    }'
  ```

  ```typescript TypeScript theme={null}
  const res = await fetch(
    'https://payments-gateway.flokitai.com/api/paywall/events',
    {
      method: 'POST',
      headers: {
        'x-app-key': 'pk_live_your_app_key',
        'x-user-id': 'usr_abc123',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        events: [
          {
            event_type: 'offer_tap',
            app_id: 'your_app',
            platform: 'ios',
            occurred_at: new Date().toISOString(),
            properties: {
              paywall_id: 'onboarding',
              variant_id: 'annual_emphasis',
              product_id: 'com.yourapp.annual',
            },
          },
        ],
      }),
    },
  );

  const result = await res.json();
  // { accepted: 1 }
  ```
</CodeGroup>

### Response

```json theme={null}
{
  "accepted": 1
}
```

### Errors

| Status | Body                                                      | Condition                                                                      |
| ------ | --------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `400`  | `{ "error": "Invalid paywall events request." }`          | Bad `event_type`, empty array, more than 50 events, or missing required fields |
| `401`  | `{ "error": "x-app-key is required." }`                   | Missing app key (once enforcement is on)                                       |
| `401`  | `{ "error": "Invalid app key." }`                         | Unknown or revoked app key                                                     |
| `401`  | `{ "error": "x-user-id is required." }`                   | Missing user identity                                                          |
| `403`  | `{ "error": "event app_id does not match the app key." }` | An event claims an `app_id` the key was not issued for                         |
| `422`  | `{ "error": "..." }`                                      | Rejected upstream, e.g. no tenant attributed for this user                     |

***

## POST /api/paywall/token

Exchanges the publishable key for a short-lived **app-session token** bound to the requesting app and user. Use it when you'd rather not attach the raw `pk_...` key to every request: mint once at SDK init, send the token as `x-app-token` on subsequent calls, and re-mint when it expires.

### Headers

| Header      | Value                                                                  |
| ----------- | ---------------------------------------------------------------------- |
| `x-app-key` | Your publishable app key (required — always enforced on this endpoint) |
| `x-user-id` | Your authenticated user ID (required)                                  |

### Example

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://payments-gateway.flokitai.com/api/paywall/token \
    -H "x-app-key: pk_live_your_app_key" \
    -H "x-user-id: usr_abc123"
  ```

  ```typescript TypeScript theme={null}
  const res = await fetch(
    'https://payments-gateway.flokitai.com/api/paywall/token',
    {
      method: 'POST',
      headers: { 'x-app-key': 'pk_live_your_app_key', 'x-user-id': 'usr_abc123' },
    },
  );

  const { token } = await res.json();
  // send as x-app-token on subsequent calls
  ```
</CodeGroup>

### Response

```json theme={null}
{
  "token": "v1.eyJhIjoi...",
  "token_type": "app-session",
  "expires_in": 900
}
```

The token is opaque to clients, expires after 15 minutes, and is only valid for the same `x-user-id` it was minted for — a token leaked from one user cannot read another user's entitlements.

### Errors

| Status | Body                                                                        | Condition                                        |
| ------ | --------------------------------------------------------------------------- | ------------------------------------------------ |
| `401`  | `{ "error": "x-app-key is required." }` / `{ "error": "Invalid app key." }` | Missing or invalid key                           |
| `401`  | `{ "error": "x-user-id is required." }`                                     | Missing user identity                            |
| `503`  | `{ "error": "App session tokens are not enabled." }`                        | Token minting not configured on this environment |

***

## Provider webhook passthrough

The gateway also exposes webhook receiver endpoints for subscription and attribution providers. Bodies are forwarded as raw bytes — provider signature headers are preserved and verified downstream, so configure these URLs directly in each provider's dashboard.

| Endpoint                                           | Provider                                        |
| -------------------------------------------------- | ----------------------------------------------- |
| `POST /webhooks/stripe`                            | Stripe (signature in `stripe-signature` header) |
| `POST /webhooks/apple`                             | Apple App Store Server Notifications V2         |
| `POST /webhooks/google`                            | Google Play RTDN (Pub/Sub push)                 |
| `POST /api/providers/revenuecat/webhook`           | RevenueCat                                      |
| `POST /api/providers/adapty/webhook`               | Adapty                                          |
| `POST /api/providers/generic-subscription/webhook` | Generic subscription provider                   |
| `POST /api/integrations/mmp/generic-attribution`   | Generic MMP attribution callback                |

If the downstream service cannot be reached, the gateway responds `502`; if it times out (10 seconds), `504` — providers treat both as delivery failures and retry.
