# HTTP API reference

> The /api/sdk endpoints behind every InfluTo SDK, the RevenueCat and Google Play webhooks InfluTo receives, and where the agent REST API is documented.

The SDKs are thin clients over a small HTTP API. Use this page when your stack has no SDK, or to see what an SDK sends while you debug. If an SDK exists for your platform, use it — it also stores the attribution, sets RevenueCat attributes and de-duplicates for you. The full wire contract, including the rules every SDK follows, is at [docs.influ.to/contract.md](https://docs.influ.to/contract.md) (wire version 1.6.0).

## Basics

- **Base URL:** `https://influ.to/api`
- **Auth:** `Authorization: Bearer <SDK key>` on every request. The key starts with `it_` and is on **Setup & SDK**; one key per app covers sandbox and production. It only works on the `/api/sdk/*` endpoints of its own app. It ships inside your app, so treat it as public.
- **Format:** JSON bodies with `Content-Type: application/json`. Field names are snake_case. `/sdk/event` and `/sdk/purchase` also accept camelCase, which is what the SDKs send there.
- **Errors:** `{"detail": "…"}` with the status code. A missing or mistyped field is a `422` with the field list in `detail`. A request over the rate limit gets a plain `429` page. All codes: [Limits and errors](https://docs.influ.to/reference/limits-and-errors.md#http-status-codes-for-apisdk).
- The server reads the client IP and User-Agent from the request itself; don't put them in the body.

```bash
curl https://influ.to/api/sdk/validate-code \
  -H "Authorization: Bearer $INFLUTO_SDK_KEY" \
  -H "Content-Type: application/json" \
  -d '{"code": "MAYA"}'
```

## Endpoints

| Method | Path | Purpose | SDK method |
|---|---|---|---|
| `POST` | `/sdk/init` | Start-up check; says whether the platform is store-direct | `initialize` |
| `POST` | `/sdk/track-install` | Decide whether this install was referred | `checkAttribution` |
| `POST` | `/sdk/identify` | Attach your user ID to the install | `identifyUser` |
| `POST` | `/sdk/event` | Record a custom event | `trackEvent` |
| `POST` | `/sdk/validate-code` | Check a typed code | `validateCode` |
| `POST` | `/sdk/set-referral-code` | Apply a code; redeem a free-access code | `setReferralCode` |
| `POST` | `/sdk/purchase` | Report a store purchase (store-direct) | `reportPurchase` |
| `GET` | `/sdk/access` | Whether a user has free access | `checkAccess` |
| `GET` | `/sdk/campaigns` | Active public campaigns | `getActiveCampaigns` |
| `GET` | `/sdk/attribution` | A paying user's attribution | — |
| `GET` | `/sdk/recent-conversions` | A user's recent purchase outcomes, for testing | — |

## POST /sdk/init

Call once per app start. It marks the SDK as installed on the Setup page and tells the client whether this platform uses store-direct validation.

| Field | Required | Notes |
|---|---|---|
| `app_version` | Yes | Your app's version (`"unknown"` if you don't have it) |
| `sdk_version` | Yes | Your client's version |
| `platform` | Yes | `ios`, `android` or `web` |
| `sdk_name`, `runtime` | No | Sent by the web SDK (`web`; `capacitor`, `cordova`, `twa`, `pwa` or `browser`) |

```json
{
  "app_id": "3f6c…",
  "app_name": "Your App",
  "attribution_window_hours": 24,
  "campaigns": [{ "id": "a1b2…", "name": "Launch", "description": null }],
  "store_direct": false,
  "initialized": true
}
```

`campaigns` lists every active campaign of the app. `store_direct` is `true` when this platform validates purchases directly with Apple or Google; the SDKs start automatic purchase capture only then.

## POST /sdk/track-install

Decides once per install whether a creator referred it — by the code a link carried into the app, or by link-click matching within 72 hours. Call it on first launch and store the answer; the SDKs never call it again after they have one.

| Field | Required | Notes |
|---|---|---|
| `platform` | Yes | `ios`, `android` or `web` |
| `device_id` | Recommended | A random UUID you generate once per install and persist. Installs are counted once per `device_id`. |
| `device_brand`, `device_model`, `os_version`, `screen_resolution`, `timezone`, `language` | No | Device details the SDKs send for matching. camelCase also accepted. |
| `referral_code`, `click_id` | No | From a link that carried the code into the app (`influto_code` / `influto_click` on a web app's URL, or the Play Install Referrer). The install is then attributed exactly (`link_param`). |

```json
{
  "attributed": true,
  "referral_code": "MAYA",
  "attribution_method": "ip_device_match",
  "clicked_at": "2026-10-09T08:12:44.120000",
  "click_id": "9d2e…",
  "install_recorded": true,
  "message": "Install attributed successfully"
}
```

- Organic: `{"attributed": false, "install_recorded": true, "message": "No matching referral click found (organic install)"}`.
- `install_recorded` is `false` when this `device_id` was seen before.
- A device that already claimed a click within the window gets the same answer again, with `"re_claimed": true` — so a lost response can be fetched again without taking someone else's click.
- `attribution_method`: `link_param` or `ip_device_match` (link-click matching). See [attribution](https://docs.influ.to/advanced/attribution.md).

## POST /sdk/identify

Attaches your user ID — usually the RevenueCat app user ID — to the install. When a RevenueCat event later arrives without the `influto_code` attribute, InfluTo looks for an attributed click with that user ID.

| Field | Required | Notes |
|---|---|---|
| `app_user_id` | Yes | Your user ID. Empty is a `400`. |
| `properties` | No | Object, stored for your reference |
| `device_id` (+ `platform` and the device fields) | No | Send the same `device_id` you sent to `track-install` and the server matches this exact install. Without it, it falls back to the request's IP and only uses an unambiguous, recent match. |

```json
{ "identified": true, "app_user_id": "user_123", "attribution_backfilled": true }
```

The first identify for a click wins. The same key and user identified again within 60 seconds is answered from memory with `"deduplicated": true`. This endpoint has its own, lower rate limit — 2 requests per second per IP — so call it once per launch, not in a loop.

## POST /sdk/event

Records a custom event for the dashboard funnel. It never creates a conversion or a commission.

| Field | Required | Notes |
|---|---|---|
| `event_type` / `eventType` | Yes | For example `paywall_viewed`, `trial_started` |
| `app_user_id` / `appUserId` | Yes | Your user ID |
| `properties` | No | Object |
| `referral_code` / `referralCode` | No | Code to associate |
| `event_id` / `eventId` | No | Your idempotency key — a repeat with the same ID is dropped |

```json
{ "tracked": true, "event_id": "c71a…" }
```

A dropped repeat answers `{"tracked": true, "event_id": "…", "duplicate": true, "dedup_reason": "…"}`. Besides `event_id`, the server keeps `trial_started`, `subscription_purchased` and `subscription_renewed` to one per user and property set, and drops an identical event without an `event_id` repeated within 60 seconds.

## POST /sdk/validate-code

Checks a code a user typed, against the app's active creator codes and then its free-access codes. Always `200` (unless the request itself is rejected); look at `valid`.

| Field | Required | Notes |
|---|---|---|
| `code` | Yes | The server uppercases it and drops characters outside `A–Z 0–9 _ -` |

Creator code:

```json
{
  "valid": true,
  "code": "MAYA",
  "campaign": { "id": "a1b2…", "name": "Launch", "description": null, "commission_percentage": 30.0, "campaign_type": "public" },
  "influencer": { "name": "Maya R.", "social_handle": "@maya", "follower_count": null },
  "custom_data": null,
  "message": "Valid code! Referred by Maya R."
}
```

A free-access code returns `campaign.name` `"Free Access"`, `campaign.campaign_type` `"FREE_ACCESS"`, `commission_percentage` `0`, `influencer: null` and the code's `custom_data`. `influencer` and `message` contain the creator's personal name — don't show them to users unless creators agreed.

Invalid: `{"valid": false, "error": "Code not found or inactive", "error_code": "CODE_NOT_FOUND"}`. The `error_code` values are `INVALID_FORMAT` (not 4–20 characters), `CODE_NOT_FOUND` and `CODE_EXPIRED` (a free-access code that expired or has no seats left).

## POST /sdk/set-referral-code

Applies a code for this user. For a creator code it records the attribution; for a free-access code it grants the free access.

| Field | Required | Notes |
|---|---|---|
| `code` | Yes | Same cleanup as `validate-code` |
| `app_user_id` | For free-access codes | Without it a free-access code fails with `MISSING_USER_ID` |

Creator code:

```json
{ "success": true, "code": "MAYA", "message": "Referral code set successfully", "campaign": { "id": "a1b2…", "name": "Launch" } }
```

If this creator's link already attributed an install from the same IP within the window, nothing new is recorded: `"message": "Code already attributed from link click"` with `"already_attributed": true`. A new manual entry counts as a click and an install for the creator.

Free-access code:

```json
{
  "success": true,
  "code": "PRESS2026",
  "free_access": true,
  "grants_access": true,
  "already_granted": false,
  "entitlement": "premium",
  "access_duration_years": 1,
  "access_duration_days": 365,
  "expires_at": "2027-10-09T08:15:02",
  "rc_grant": { "attempted": true, "ok": true, "error": null },
  "message": "Free access activated — 1 year of premium."
}
```

- Access is granted in InfluTo whether or not RevenueCat is involved; `rc_grant` only reports the optional RevenueCat promotional-entitlement mirror. Gate premium on `GET /sdk/access`, not on `rc_grant`.
- Redeeming the same code again for the same user re-grants without using another seat (`already_granted: true`).
- A free-access failure is still `200`, with `success: false`, `free_access: true`, and `error_code` `CODE_EXPIRED` or `MISSING_USER_ID`.
- An unknown or inactive code is `404` `{"detail": "Referral code not found or inactive"}`.

## POST /sdk/purchase

Store-direct only. Sends the store's signed proof of a purchase; InfluTo verifies it with Apple or Google, converts the price to USD at the purchase date's rate, and records it exactly like a RevenueCat event. See [store-direct](https://docs.influ.to/integrations/store-direct.md).

| Field | Required | Notes |
|---|---|---|
| `platform` | Yes | `ios` or `android` |
| `signed_transaction` / `signedTransaction` | iOS | StoreKit 2 `jwsRepresentation` (the signed JWS) |
| `purchase_token` / `purchaseToken` | Android | Play Billing `purchaseToken` |
| `product_id` / `productId` | Android one-time products | Routes the purchase to one-time validation. Omit for subscriptions. |
| `price`, `currency` | Android one-time products | Price paid (major units) and ISO currency, from `ProductDetails` — Google doesn't return a one-time price |
| `referral_code` / `referralCode` | First purchase | The stored referral code, so the purchase binds to the creator |
| `app_user_id` / `appUserId` | No | Your user ID; also makes the purchase show up in `recent-conversions` |

```json
{
  "success": true,
  "validated": "apple",
  "environment": "PRODUCTION",
  "event_type": "INITIAL_PURCHASE",
  "duplicate": false,
  "recorded": true,
  "result": { "processed": true, "event_id": "…" }
}
```

| Status | Meaning |
|---|---|
| `200` | Verified and processed. `duplicate: true` means it was already recorded — reporting twice is safe. |
| `400` | The platform isn't switched to store-direct yet (switching needs a green credential check), credentials are missing, the proof is missing, verification failed, or the platform isn't `ios`/`android`. |
| `409` | Android one-time purchase that isn't in the PURCHASED state (for example, still pending). |
| `503` | The exchange rate isn't available right now. Retry later. |

Apple purchases are keyed on the original transaction ID, Google subscriptions on the purchase token, Google one-time purchases on the order ID — so renewals and refunds found later attach to the same conversion.

## GET /sdk/access

`/sdk/access?app_user_id=user_123` — whether InfluTo currently grants this user free access from a free-access code.

```json
{ "has_access": true, "source": "comp", "entitlement": "premium", "expires_at": "2027-10-09T08:15:02", "code": "PRESS2026" }
```

Without access: `{"has_access": false, "source": null, "entitlement": null, "expires_at": null, "code": null}`. Seat limits and expiry are checked on every call; a blocked or expired code removes access. An empty `app_user_id` is a `400`.

## GET /sdk/campaigns

The app's active **public** campaigns, for showing offers in the app.

```json
[{ "id": "a1b2…", "name": "Launch", "description": null, "commission_percentage": 30.0 }]
```

## GET /sdk/attribution

`/sdk/attribution?app_user_id=user_123` — which creator a paying user is attributed to. It reads conversions, so it answers `attributed: true` only once an attributed purchase exists.

```json
{
  "attributed": true,
  "referral_code": "MAYA",
  "campaign": { "id": "a1b2…", "name": "Launch" },
  "attribution_timestamp": "2026-10-09T09:01:13",
  "attribution_method": "ip_device_match"
}
```

Otherwise: `{"attributed": false, "message": "User has no attribution (organic user)"}`.

## GET /sdk/recent-conversions

`/sdk/recent-conversions?app_user_id=user_123&limit=10` — the user's latest purchase events in this app, newest first, so a test build can confirm a purchase reached InfluTo. `limit` is 1–50 (default 10). No amounts and no creator names.

```json
{
  "app_user_id": "user_123",
  "count": 1,
  "attributed_count": 1,
  "conversions": [{
    "event_type": "INITIAL_PURCHASE", "product_id": "pro_monthly", "store": "APP_STORE",
    "environment": "SANDBOX", "is_test": true, "attributed": true, "referral_code": "MAYA",
    "status": "active", "event_timestamp": "2026-10-09T09:01:13"
  }]
}
```

## Inbound webhooks

InfluTo receives purchase events from RevenueCat; on store-direct apps it checks purchases with Apple and Google itself. It sends no webhooks to your servers. To follow what happens, use **Real-Time Events** in the dashboard or the [agent API](#agent-rest-api).

### RevenueCat

`POST https://influ.to/api/webhooks/revenuecat/{app_id}` — **Setup & SDK** shows the full URL with your app's ID. Set it up as described in [RevenueCat](https://docs.influ.to/integrations/revenuecat.md).

- **Auth:** RevenueCat's "Authorization header value" must be `Bearer rc_webhook_…`, the secret InfluTo generates under **Setup & SDK → Webhook** (shown once; regenerate it there or in **Settings → Integration**). The bare token, a lowercase `bearer` and stray whitespace are accepted too. Rejected calls are counted and shown on the Setup page.
- **Body:** RevenueCat's standard webhook JSON. InfluTo reads the code from the subscriber attribute `influto_code`. `influto_referral` is for your own RevenueCat targeting; InfluTo doesn't read it. Without a code, it looks for an attributed click with the event's app user ID — see [`/sdk/identify`](#post-sdkidentify). A user who already converted for a creator stays with that creator, whatever code arrives later.
- **Environments:** send both. `SANDBOX`, `XCODE` and `LOCAL_TESTING` events go through attribution but never reserve money or count in stats.

| Event | What InfluTo does |
|---|---|
| `TEST` | Marks the webhook as connected |
| `INITIAL_PURCHASE` | Creates the conversion when the user is attributed; a paid purchase earns commission, a trial waits until it converts |
| `RENEWAL` | Commission for each paid renewal |
| `NON_RENEWING_PURCHASE` | Commission for each one-time purchase |
| `CANCELLATION`, `UNCANCELLATION`, `EXPIRATION` | Updates the subscription's state |
| `PRODUCT_CHANGE` | Updates the product; the next renewal's price sets the commission |
| `BILLING_ISSUE` | Logged |
| `REFUND` | Reverses the commission |

Promotional grants (store `PROMOTIONAL`, including the ones InfluTo makes for free-access codes) are acknowledged and ignored, as are event types not in the table.

| Response | When |
|---|---|
| `200` `{"received": true, "event_id": "…"}` | Accepted and processed |
| `200` `{"received": true, "error": "…", "event_id": "…"}` | Accepted, but processing failed. Not retried by RevenueCat or by InfluTo — the error is in the response body in RevenueCat's delivery log. |
| `400` | The body isn't JSON |
| `401` | Missing or wrong Authorization value; the body says how to fix it |
| `404` | No app with this ID |

Events are idempotent on app, event type, transaction ID and event timestamp, so a redelivered event is recorded once.

## Agent REST API

The API that AI agents and scripts use to run your program — apps, campaigns, creators, analytics, integration status — lives under `https://influ.to/api/agent/*`. It uses an agent key (`it_mcp_…`, from **Connect AI Agent**) or an OAuth token, never the SDK key. The MCP tools that read or change your account are wrappers over these endpoints.

- Endpoints, scopes and errors: [MCP tools, scopes and REST API](https://docs.influ.to/agents/tools.md#rest-endpoints)
- OpenAPI spec: [https://influ.to/api/agent/openapi.json](https://influ.to/api/agent/openapi.json)
- Status codes: [Limits and errors](https://docs.influ.to/reference/limits-and-errors.md#agent-api-and-mcp)
