> For the complete AppRefer docs index, see https://apprefer.com/llms.txt (every page as markdown) or https://apprefer.com/llms-full.txt (all docs in one file).

# REST API

Manage apps, links and integrations, read analytics and subscriptions, and send server-side events from your own backend.

All endpoints live under `https://apprefer.com/api/v1` (on a self-hosted AppRefer, your own domain). The API is for servers: an organisation API key is a secret, so never put one in an app or a web page. Apps talk to AppRefer through the SDKs with the public `pk_` SDK key instead.

## Agent workspace and login endpoints

`POST /api/agent/workspaces` starts a test workspace with a scoped seven-day key and human claim link. `GET /api/agent/workspaces/{id}` returns its credential-bound status. Existing access-request endpoints remain supported. For exact payloads, limits, MCP OAuth discovery, PKCE, rotating tokens, approvals and revocation, read [auth.md](https://apprefer.com/auth.md) and [AI agents & MCP](https://apprefer.com/docs/mcp.md).

## Authentication

Send an organisation API key as a bearer token on every request:

```http
GET /api/v1/apps
Authorization: Bearer ak_live_...
```

-   Create keys in the dashboard under **API keys** (account menu at the foot of the sidebar). You need the admin role. The full key is shown once; AppRefer stores only its hash.
-   A key belongs to one company and reaches only that company's apps. Any other app id answers `404`, exactly like an app that doesn't exist.
-   Revoking a key in the dashboard stops it everywhere within a minute.

## Scopes

Each key carries a list of scopes. A request without the scope its endpoint needs gets `403 FORBIDDEN`, and the message names the scope. Give each key only what its job needs, for example `users:erase` alone for an account-deletion worker. The dashboard offers three presets: **Full Access** (`*`), **Read Only** (the five `:read` scopes for analytics, apps, links, integrations and SKAN) and **Analytics Only**.

| Scope | Allows |
| --- | --- |
| `*` | Everything below. |
| `analytics:read` | Stats, overview, campaigns, events, live events, attributions and the portfolio. Also reads support and billing readiness. |
| `apps:read` | List apps and read an app's settings and SDK keys, subscription metrics and help articles. Also reads support and billing readiness. |
| `apps:write` | Create, update and delete apps, rotate the live SDK key, send website events and purchases, and save help articles. |
| `links:read` | List tracking links. |
| `links:write` | Create and delete tracking links. |
| `integrations:read` | Read ad network, RevenueCat, Superwall and Support delivery settings, and the billing configuration (secrets are always masked). Also reads billing readiness. |
| `integrations:write` | Change those integrations and the billing configuration and catalogue. |
| `skan:read` | Read the SKAN conversion-value table and the postback summary. |
| `skan:write` | Save the SKAN conversion-value table. |
| `billing:read` | Look up a customer's entitlements, read offering experiment results and billing readiness. |
| `billing:write` | Grant and revoke promotional entitlements. The standard `*` scope also includes this permission. |
| `users:erase` | Erase an end user (account deletion) and read the status of an erase request. |
| `support:agent` | Run an AI support agent: claim the answer queue, post answers, read and write its knowledge and the app's help articles (new articles default to AI-only). Also reads support readiness. |
| `support:context` | Send Support the customer context your backend knows (credits, orders, jobs, failures) and issue identity tokens. The standard `*` scope also includes this permission. |
| `support:testimonials` | Register photo/video testimonial campaigns, ask a user for one, claim and report rewards, and export the approved library. The standard `*` scope also includes this permission. |
| `support:actions` | Register the Support actions your backend can perform, claim runs your team or the AI asked for, and report their results. The standard `*` scope also includes this permission. |

## Responses and errors

Most endpoints wrap their result in `data`; lists that page add `meta`. Errors use one shape:

```json
{ "data": { "appId": "app_3f9c2a1b7d4e", "name": "Sparkly" } }

{ "error": { "code": "FORBIDDEN", "message": "Missing required scope: links:write" } }
```

| Status | Code | When |
| --- | --- | --- |
| 400 | `BAD_REQUEST` | A missing or invalid field. The message says which. |
| 401 | `UNAUTHORIZED` | No key, an unknown key or a revoked key. |
| 403 | `FORBIDDEN` | The key lacks the scope this endpoint needs. |
| 404 | `NOT_FOUND` | The app, link or record doesn't exist, or belongs to another company. |
| 409 | varies | A conflict: the link id is taken, or an idempotent request was retried with different data. |
| 429 | `RATE_LIMITED` | Too many requests; see below. |
| 503 | `SERVICE_UNAVAILABLE` | A backing service (for example the analytics warehouse) is unavailable. Retry later. |

A few server-to-server endpoints answer without the `data` wrapper, because their callers retry on the status alone: `users/erase` and its status, `web-events` (errors are `{ "error": { "code": "invalid_click_id" } }`), `web-purchases` (errors are `{ "error": "message" }`) and the support agent endpoints.

## Rate limits

Requests from all of a company's keys share one one-minute window. A request is refused with `429` once the window already holds more requests than that endpoint allows. The response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (Unix seconds). Back off and retry after the reset.

| Endpoints | Per minute |
| --- | --- |
| Reads, website events, support agent queue and answers | 120 |
| Writes (apps, links, integrations, billing configuration, SKAN, help articles, agent knowledge PUT) | 30 |
| Billing customer lookup, support context, identity tokens and support actions | 600 |
| Experiment results, new erase requests, agent knowledge GET | 60 |

## Pagination

`/attributions` and `/events` page with a cursor. Pass `limit` (1 to 500, default 50) and, for the next page, the `cursor` from the previous response. When `meta.hasMore` is `false` there is nothing more. Other lists return everything in one response.

```json
{
  "data": [ ... ],
  "meta": { "cursor": "eyJpZCI6ImFwcF8zZjljX2Rldl8xMjMifQ==", "hasMore": true }
}
```

Analytics endpoints take `days` (1 to 365, default 14) and report live (production) data only. Revenue is net USD: paid revenue minus refunds.

## Endpoints

Paths below are relative to `/api/v1`.

### Apps

| Endpoint | Scope | What it does |
| --- | --- | --- |
| `GET /apps` | apps:read | Your company's apps: appId, name, sdkEnabled, storeUrl, createdAt. |
| `POST /apps` | apps:write | Create an app from `{ name, iosUrl?, androidUrl? }`. Returns appId and both SDK keys (`sdkKeyLive`, `sdkKeyTest`). |
| `GET /apps/{appId}` | apps:read | One app: name, SDK keys, store URLs, the settings below and its integrations with secrets masked. |
| `PATCH /apps/{appId}` | apps:write | Change `name`, `storeUrl` (`{ ios, android }`) or any setting below. Other fields are ignored. Returns the fields updated. |
| `DELETE /apps/{appId}` | apps:write | Delete the app (its SDK keys stop resolving). |
| `POST /apps/{appId}/sdk-key/rotate` | apps:write | Replace the live SDK key (`pk_live_`) and return the new one. The old key stops working at once, so every app build that still uses it loses attribution, events, Billing and Support until users update. Plan a release with the new key before rotating, and only rotate a key that has leaked. The test key is unchanged. |

App settings you can change with `PATCH /apps/{appId}`:

| Field | Default | Effect |
| --- | --- | --- |
| `sdkEnabled` | true | Kill switch. false tells every SDK install to stop sending. |
| `probabilisticMatching` | true | false turns off multi-signal probabilistic matching. Installs then match only through Apple AdServices, Play Install Referrer click ids and an exact single-candidate IP match. |
| `trackingConsentRequired` | false | true treats installs with no tracking consent recorded (SDKs before 0.6.0, purchases not tied to an install) as not granted: no click matching or ad-network forwarding for them. Turn it on once every live app version sends consent. |
| `webEvents` | { enabled: false } | Accept verified website receipts on `/web-events`. The object takes only `enabled`. |
| `supportAi` | { mode: "off" } | The support AI policy and persona. Replaced whole, so always send `mode`. See [Support: AI answers](https://apprefer.com/docs/support.md#ai-answers). |

**Turn off probabilistic matching and require consent**

```bash
curl -X PATCH "https://apprefer.com/api/v1/apps/app_3f9c2a1b7d4e" \
  -H "Authorization: Bearer ak_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "probabilisticMatching": false, "trackingConsentRequired": true }'

{ "data": { "appId": "app_3f9c2a1b7d4e", "updated": ["trackingConsentRequired", "probabilisticMatching"] } }
```

### Tracking links

| Endpoint | Scope | What it does |
| --- | --- | --- |
| `GET /apps/{appId}/links` | links:read | Every link, newest first: id, name, network, url, destinationMode, store destinations, external ids, clicks, installs. |
| `POST /apps/{appId}/links` | links:write | Create a link. Body: `linkId` (3 to 50 letters, digits and dashes), `name`, `network?` (default `organic`), `storeUrl?`, `iosStoreUrl?`, `androidStoreUrl?`, `storeVariantKey?`, `landingPage?` (Google and ChatGPT Ads links), `externalCampaignId?`, `externalAdGroupId?`, `externalAdId?`. A taken id is `409`. |
| `DELETE /apps/{appId}/links/{linkId}` | links:write | Delete a link. |

The returned `url` depends on the network: `/api/c/` for most, `/g/` for Google Ads, `/o/` for ChatGPT Ads and `/x/` for X Ads (which also needs a clean HTTPS `storeUrl`). See [Tracking links](https://apprefer.com/docs/tracking-links.md).

**Create a Meta link**

```bash
curl -X POST "https://apprefer.com/api/v1/apps/app_3f9c2a1b7d4e/links" \
  -H "Authorization: Bearer ak_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "linkId": "spring-sale", "name": "Spring sale", "network": "meta" }'

{
  "data": {
    "id": "spring-sale",
    "name": "Spring sale",
    "network": "meta",
    "url": "https://apprefer.com/api/c/spring-sale",
    "destinationMode": "legacy_redirect",
    "storeUrl": "",
    "clicks": 0,
    "installs": 0
  }
}
```

### Integrations

| Endpoint | Scope | What it does |
| --- | --- | --- |
| `GET /apps/{appId}/integrations` | integrations:read | Every configured integration with its connection status. Secrets come back masked (starting with •). |
| `PATCH /apps/{appId}/integrations/{network}` | integrations:write | Update one integration. `network`: `meta`, `google`, `tiktok`, `openai`, `x_ads`, `apple_ads`, `revenuecat`, `superwall` or `support`. Send only the fields you change; a masked value sent back keeps the stored secret. Returns `connected` (plus `reportingConnected` for ChatGPT Ads and `enabled` for X Ads, which stays off until you set `enabled: true`). |

Fields per network are on each integration page: [Meta](https://apprefer.com/docs/integrations/meta.md), [Google](https://apprefer.com/docs/integrations/google.md), [TikTok](https://apprefer.com/docs/integrations/tiktok.md), [ChatGPT Ads](https://apprefer.com/docs/integrations/openai.md), [X Ads](https://apprefer.com/docs/integrations/x.md), [RevenueCat](https://apprefer.com/docs/integrations/revenuecat.md). The `support` network holds push and email delivery (see [Support](https://apprefer.com/docs/support.md#notifications-and-email)).

### Analytics

| Endpoint | Scope | What it does |
| --- | --- | --- |
| `GET /apps/{appId}/stats?days=` | analytics:read | KPIs (installs, revenue, grossRevenue, refunds, trials, purchases) and a daily chart. |
| `GET /apps/{appId}/overview?days=` | analytics:read | KPIs, chart data, a breakdown by network and the top campaigns. |
| `GET /apps/{appId}/campaigns?days=&network=&groupBy=` | analytics:read | Clicks, installs, revenue and synced spend per row, plus totals. `network`: meta, google, tiktok, apple\_ads, openai or x\_ads (default all). `groupBy`: campaign (default), adgroup, ad, keyword, searchterm or country. |
| `GET /apps/{appId}/events?limit=&cursor=&deviceId=` | analytics:read | The event log, newest first, paged. deviceId filters to one install. |
| `GET /apps/{appId}/events/live?limit=` | analytics:read | The most recent events (limit up to 100), for a live view. |
| `GET /apps/{appId}/attributions?limit=&cursor=` | analytics:read | Install attributions, newest first, paged: id, deviceId, network, campaignName, matchType, createdAt. Test-key installs are left out. |
| `GET /portfolio?days=` | analytics:read | Totals and a per-app comparison across every app of your company. |

Event names and properties are described in [Events](https://apprefer.com/docs/events.md).

### Billing and subscriptions

| Endpoint | Scope | What it does |
| --- | --- | --- |
| `GET /apps/{appId}/billing` | integrations:read | The AppRefer Billing configuration (mode, stores, products, catalogue, outbound webhook) with secrets masked. |
| `PUT /apps/{appId}/billing` | integrations:write | Merge an update: `mode` (off, shadow or primary), `apple`, `google`, `stripe`, `webhook`, `products`, `defaultEntitlement`, and the catalogue sections `entitlements`, `offerings`, `currentOfferingId`, `placements`, `targeting`, `experiments` (each catalogue section sent is replaced whole). Validated before saving; returns the new configuration, masked. |
| `GET /apps/{appId}/billing/customers/{appUserId}` | billing:read | Server-side entitlement check for your own backend: the same customer info the SDK sees (`entitlements`, `activeSubscriptions`). URL-encode the user id. `404` when Billing is off for the app. |
| `PUT /apps/{appId}/billing/customers/{appUserId}/entitlements/{entitlementId}` | billing:write | Grant an entitlement with no purchase (App Review accounts, migrated customers, goodwill). Body `{ expiresAt, reason }`: an ISO 8601 time with a zone, at least a minute ahead and no later than 2100-12-31T23:59:59Z, and why (up to 500 characters). The entitlement must be defined for the app. Repeating the same grant changes nothing; a new `expiresAt` replaces the old one. Returns `grant`, `changed` and the resulting `customerInfo`. See [promotional grants](https://apprefer.com/docs/billing.md#promotional-grants). |
| `DELETE /apps/{appId}/billing/customers/{appUserId}/entitlements/{entitlementId}` | billing:write | Revoke that grant at once (optional body `{ reason }`). Purchases are untouched. `404` when there is no grant; repeating it changes nothing. |
| `GET /apps/{appId}/billing/experiments/{experimentId}/results` | billing:read | Per-variant results of an offering experiment. |
| `GET /apps/{appId}/billing/readiness` | analytics, apps, billing or integrations read | For release checks: mode, connected stores and their public ids, which secrets are set (never their values), when each store last sent a notification, catalogue counts. |
| `GET /apps/{appId}/subscriptions/metrics?start=&end=&bucket=&store=&sandbox=` | apps:read | Revenue, trials, conversions, churn, active subscriptions and MRR. Dates `YYYY-MM-DD` (UTC, inclusive, default the last 30 days, up to 731 days); `bucket` day, week or month; `store` all, app\_store, play\_store or stripe; `sandbox=true` for test purchases. |

**Is this user premium?**

```bash
curl "https://apprefer.com/api/v1/apps/app_3f9c2a1b7d4e/billing/customers/user_8421" \
  -H "Authorization: Bearer ak_live_..."

{
  "data": {
    "appUserId": "user_8421",
    "entitlements": {
      "premium": {
        "id": "premium",
        "isActive": true,
        "productId": "com.example.app.annual",
        "store": "app_store",
        "expiresAtMs": 1792000000000,
        "willRenew": true,
        "periodType": "normal",
        "state": "active",
        "subscriptionKey": "2000000123456789"
      }
    },
    "activeSubscriptions": ["com.example.app.annual"],
    "requestDateMs": 1760870400000
  }
}
```

Set-up, store notifications and the outbound webhook are covered in [Billing](https://apprefer.com/docs/billing.md); the catalogue and experiments in [Offerings](https://apprefer.com/docs/offerings.md); metric definitions in [Subscription analytics](https://apprefer.com/docs/subscriptions.md).

### Website conversions

| Endpoint | Scope | What it does |
| --- | --- | --- |
| `POST /apps/{appId}/web-events` | apps:write | Verified website receipts from your backend: `purchase`, `renewal`, `refund`, and the funnel steps `sign_up`, `checkout_started` and `start_trial`. Needs `webEvents.enabled` on the app (otherwise `503 web_events_disabled`). Unknown fields are refused. `202` means durably accepted; a retry with the same `eventId` is idempotent, different data is `409`. |
| `POST /apps/{appId}/web-purchases` | apps:write | The original ChatGPT Ads web-funnel purchase receipt. Same idempotency rules; new integrations should use /web-events. |

Payloads and network mapping: [Events](https://apprefer.com/docs/events.md), [ChatGPT Ads](https://apprefer.com/docs/integrations/openai.md) and [X Ads](https://apprefer.com/docs/integrations/x.md).

### End users

| Endpoint | Scope | What it does |
| --- | --- | --- |
| `POST /apps/{appId}/users/erase` | users:erase | Erase one of your app's users when they delete their account. Body: `requestId` (8 to 64 of A-Z, a-z, 0-9, \_ and -), `appUserId`, `deviceIds?` (up to 20), `environment?` (`all` by default, `live` or `sandbox`). Answers `202` for the first call and for every retry with the same requestId; reusing a requestId with different input is `409`. |
| `GET /apps/{appId}/users/erase/{requestId}` | users:erase | Status of an erase request: status, warehouse step, counts erased, ignored device ids. |

What is erased and what is kept for accounting: [Privacy](https://apprefer.com/docs/privacy.md).

### Support

| Endpoint | Scope | What it does |
| --- | --- | --- |
| `GET /apps/{appId}/support/articles?body=` | apps:read or support:agent | Every help article, drafts and AI-only ones included. body=false leaves the bodies out. |
| `PUT /apps/{appId}/support/articles` | apps:write or support:agent | Save, publish, unpublish or remove articles: `{ articles?, publish?, unpublish?, remove? }`, up to 200 changes. |
| `GET /apps/{appId}/support/readiness?refresh=` | any read scope or support:agent | Push credentials checked with Apple and Google, email set-up, help-centre counts and the AI mode. refresh=true checks again (at most once a minute). |
| `PUT /apps/{appId}/support/customers/{appUserId}/context` | support:context | Replace one source's context snapshot for a customer: `{ environment?, source?, observedAt, staleAfterMinutes?, records }`. Typed records only, up to 50. An older observedAt is `409 STALE_SNAPSHOT`. See [live customer context](https://apprefer.com/docs/support.md#live-customer-context). |
| `GET /apps/{appId}/support/customers/{appUserId}/context?environment=` | support:context | What is stored for the customer, per source, with stale flags. |
| `DELETE /apps/{appId}/support/customers/{appUserId}/context?environment=&source=` | support:context | Remove one source, or every source. |
| `POST /apps/{appId}/support/identity-tokens` | support:context | A single-use identity token for a signed-in user: `{ appUserId, environment?, ttlSeconds? }` (30-600, default 300). The app passes it to `support.verifyIdentity(token)` (SDK 0.12.0+), which verifies its current support session. |
| `PUT /apps/{appId}/support/actions` | support:actions | Replace the app's action catalogue: `{ actions: [{ id, label, effect, params?, staff?, ai?, refundUsdParam?, environments?, timeoutSeconds?, identityMaxAgeMinutes?, enabled? }] }`, up to 20. See [resolution actions](https://apprefer.com/docs/support.md#resolution-actions). |
| `GET /apps/{appId}/support/actions` | support:actions | The registered actions (with their version) and when your backend last claimed work. |
| `POST /apps/{appId}/support/actions/claim` | support:actions | Claim waiting runs: `{ environment?, limit? 1-10 }`, each for 2 minutes. Re-delivered with the same `executionId` if no result arrives: apply each at most once. |
| `POST /apps/{appId}/support/actions/runs/{executionId}/complete` | support:actions | Report a result: `{ leaseId, status: succeeded|failed, code?, message?, data? }`. The same result again is a no-op; a different one is `409 ALREADY_COMPLETED`. |
| `PUT /apps/{appId}/support/testimonials/campaigns` | support:testimonials | Replace the app's photo/video campaigns (at most 10): what to ask, media, reward, public-use permission text, frequency, AI and automation. Each stays off until a company admin switches it on in Support. |
| `GET /apps/{appId}/support/testimonials/campaigns` | support:testimonials | The campaigns with their version and on/off state, and when your backend last claimed rewards. |
| `POST /apps/{appId}/support/testimonials/requests` | support:testimonials | Ask one user (from your screens or your coach tool): `{ campaignId, appUserId, environment?, source?: host|coach, idempotencyKey }`. Returns `requestId` for the SDK's `testimonials.present`. The reward comes from the campaign. |
| `GET /apps/{appId}/support/testimonials/requests/{requestId}` | support:testimonials | A request's status, media summary, publication permission and reward state. |
| `POST /apps/{appId}/support/testimonials/rewards/claim` | support:testimonials | Claim earned rewards (`{ environment?, limit? 1-10 }`, 2-minute leases). Apply each `fulfillmentId` at most once. |
| `POST /apps/{appId}/support/testimonials/rewards/{fulfillmentId}/complete` | support:testimonials | Report a reward: `{ leaseId, status: fulfilled|failed, receiptId?, code?, message? }`; fulfilled needs your receiptId. |
| `GET /apps/{appId}/support/testimonials/library?environment=&limit=` | support:testimonials | Approved media with public-use permission as a manifest: scopes, whether rewarded, 10-minute download links. No customer identifiers. |
| `GET /apps/{appId}/support/actions/runs/{executionId}` | support:actions | A run's status, attempts and result. |
| `GET /support/agent/queue?limit=` | support:agent | Claim conversations waiting for an AI answer (limit 1 to 20, default 5), each for 5 minutes. |
| `POST /support/agent/conversations/{id}` | support:agent | Post the agent's answer. Returns what AppRefer did with it: send, schedule, draft or none. |
| `GET /support/agent/knowledge?appId=&sandbox=` | support:agent | Published help articles and your team's recent answers, personal details masked. |
| `PUT /support/agent/knowledge` | support:agent | Add or replace articles for one app: `{ appId, articles, publish?, unpublish?, remove? }`. New articles are AI-only unless they say `audience: "public"`. |

Field-by-field details: [Support](https://apprefer.com/docs/support.md).

### SKAdNetwork and AdAttributionKit

| Endpoint | Scope | What it does |
| --- | --- | --- |
| `GET /apps/{appId}/skan` | skan:read | The conversion-value table saved in the dashboard (`{ rows }`). It is for reference: the SDKs use AppRefer's built-in conversion schema. |
| `PUT /apps/{appId}/skan` | skan:write | Save that table: `{ rows: [...] }`. |
| `GET /apps/{appId}/skan/postbacks?days=` | skan:read | Summary of Apple's verified postbacks for the app (days 1 to 400, default 30). |

See [SKAdNetwork](https://apprefer.com/docs/integrations/skan.md).

## Calling the API

**curl**

```bash
curl "https://apprefer.com/api/v1/apps/app_3f9c2a1b7d4e/campaigns?days=30&groupBy=adgroup" \
  -H "Authorization: Bearer $APPREFER_API_KEY"
```

**Node**

```typescript
const res = await fetch(
  "https://apprefer.com/api/v1/apps/app_3f9c2a1b7d4e/campaigns?days=30&groupBy=adgroup",
  { headers: { Authorization: `Bearer ${process.env.APPREFER_API_KEY}` } },
);
if (!res.ok) throw new Error((await res.json()).error?.message);
const { data } = await res.json();
console.log(data.totals, data.campaigns);
```

**Python**

```python
import os, requests

res = requests.get(
    "https://apprefer.com/api/v1/apps/app_3f9c2a1b7d4e/campaigns",
    headers={"Authorization": f"Bearer {os.environ['APPREFER_API_KEY']}"},
    params={"days": 30, "groupBy": "adgroup"},
)
res.raise_for_status()
print(res.json()["data"]["totals"])
```

> **OpenAPI and agents**
>
> An OpenAPI file for the core endpoints (apps, links, integrations, analytics and SKAN) is at `/openapi.yaml`, with an interactive viewer at `/api/v1/docs`; this page is the complete list. To let a coding agent work with AppRefer, see [AI agents and MCP](https://apprefer.com/docs/mcp.md).

## Self-hosted operator endpoints

`/api/v1/admin/*` (organisations, their apps and stats, one app and its links) accept only the deployment's platform admin key (`ak_admin_`, the `APPREFER_ADMIN_KEY` set on the server), never an organisation key. The same admin key is also accepted by the billing configuration, readiness and help article endpoints above. They exist for whoever runs an AppRefer deployment.

---

Source: https://apprefer.com/docs/api · Last updated: 2026-10-19
