---
title: "API Reference"
source: https://docs.myappaffiliate.com/api
docs: "MyAppAffiliate — Developer Documentation"
index: https://docs.myappaffiliate.com/llms.txt
---
# API Reference

Base URL (prototype): `https://maa-api-cccf50.fly.dev`

All bodies are JSON. Times are epoch milliseconds. Money is integer **USD cents**.

## Authentication

The SDK endpoints (`/sdk/install`, `/sdk/identify`) require your **SDK key**:

```
Authorization: Bearer pk_live_…
```

`/clicks` is open (it only logs a click for a known link).

Everything under `/apps/:appId` takes an **account key**:

```
Authorization: Bearer maa_sk_live_…
```

An account key is scoped to one account: it sees that account's apps and nothing else.
Anything belonging to another account returns `404` — the same response as an id that
doesn't exist, so the API is never an oracle for what other accounts own.

Each key carries explicit permissions, and a call missing the one it needs gets `403`:

| Permission | Grants |
|---|---|
| `read` | Overviews, timeseries, every list, diagnostics |
| `write` | Affiliates, links, app settings, bulk operations |
| `setup` | Creating apps, rotating keys and webhook secrets |
| `payout` | The payout export — money out. Never granted by default. |

SDK keys (`pk_live_…`) authenticate `/sdk/*` only and are rejected everywhere else; an
account key is likewise rejected on `/sdk/*`. Account creation and key minting
(`/accounts/:accountId/keys`) stay dashboard-only — a key that could mint keys would make
revocation meaningless.

## POST /clicks

Logs a click and returns the link's destination (used by the link Worker). Raw IP is
never sent — only a hash.

```json
{
  "customerAppId": "cmqn…",
  "slug": "lumi",
  "ipHash": "…",
  "userAgent": "…",
  "claimToken": "uuid",
  "clickedAt": 1782000000000
}
```

→ `200 { "clickId": "…", "affiliateId": "…", "destinationUrl": "https://apps.apple.com/…" }`

## POST /sdk/install  · auth

First-open / install. Provide a `claimToken` (Universal Link) **or** an `affiliateCode`
(manual). On success the attributed affiliate is stored and returned.

```json
{ "deviceId": "…", "claimToken": "uuid", "firstOpenAt": 1782000000000 }
```

→ `200 { "attributionId": "…", "affiliateId": "…" }` · `401` invalid key · `404` no attributable click

## POST /sdk/identify  · auth

Binds your app's user id to the device's attribution.

```json
{ "deviceId": "…", "customerUserId": "user_123", "identifiedAt": 1782000000000 }
```

→ `200 { "attributionId": "…", "customerUserId": "user_123" }`

## POST /webhooks/:provider/:customerAppId

Your billing provider posts here. `:provider` is `revenuecat`, `stripe`, `adapty`,
`superwall` or `paddle`; each is verified with its own scheme — a static
**Authorization** token, an HMAC signature header, or Svix (see
[Billing & Webhooks](https://docs.myappaffiliate.com/webhooks)). Then the same pipeline for all of them: verifies →
stores raw → normalizes → dedupes → attributes → writes a commission. Idempotent on
`(source, sourceEventId)`.

→ `200 { "eventId": "…", "commissionId": "…" | null }` · `200 { "duplicate": true }` · `401` bad token

## GET /apps

→ `200 [{ "id": "…", "name": "…" }]`

## GET /apps/:appId/overview

Founder dashboard data: funnel (`clicks/installs/identified/paid`), `revenueCents`,
commission totals (`pending/matured/paid/lifetime`), affiliates, links, recent commissions.

## GET /apps/:appId/affiliates/:affiliateId/overview

Creator dashboard data, scoped to that affiliate: funnel, earnings totals, links,
`nextPayoutAt`. A creator can only ever see their own data.

## GET /apps/:appId

App settings: commission rate, attribution window, hold period, deferred window,
link subdomain.

## Lists

`GET /apps/:appId/affiliates` · `…/links` · `…/commissions` · `…/payouts`

All four are cursor-paginated and return `{ items, nextCursor }`. `limit` defaults to 50
and caps at 100; pass the previous response's `nextCursor` as `?cursor=` for the next
page, and stop when it comes back `null`.

| Endpoint | Filters | Notes |
|---|---|---|
| `…/affiliates` | `status`, `sort=created\|name\|earnings` | Each row carries `clicks`, `installs`, `commissionCents` |
| `…/links` | `affiliateId`, `active=true\|false` | Each row carries `clicks` and `affiliateName` |
| `…/commissions` | `affiliateId`, `status`, `from`, `to` | `rateBps` is the rate snapshotted at calculation time |
| `…/payouts` | `affiliateId` | `itemCount` reconciles to the CSV row count |

`sort=earnings` returns the top `limit` creators by lifetime commission with
`nextCursor: null` — ranking by an aggregate has no stable key to resume from. Use
`created` or `name` to walk the whole list.

## Writes

| Endpoint | Does |
|---|---|
| `POST …/affiliates` | Create one creator → `201` |
| `POST …/affiliates/bulk` | Up to 100 at once → `200 { created, failed }` |
| `PATCH …/affiliates/:affiliateId` | `name`, `email`, `status`, `defaultCommissionRateBps` |
| `POST …/links` | Create one link → `201`, or `409` if the slug is taken |
| `POST …/links/bulk` | Up to 100 at once → `200 { created, failed }` |
| `PATCH …/links/:linkId` | `destinationUrl`, `campaign`, `active` |
| `PATCH …/settings` | Commission rate, attribution window, hold period |

Setting an affiliate's `status` to `paused` or `removed` stops future commissions; it
never rewrites existing ones. `defaultCommissionRateBps: null` clears the override so the
affiliate inherits the app rate.

**Link slugs cannot be renamed.** A slug is baked into every link a creator has already
shared, so rename would break live traffic. Set `active: false` and mint a new link.

**Bulk calls always return `200`**, with `created` and `failed` arrays — one bad row
fails only itself, and `failed[].index` points at the submitted row. A row that fails
*schema* validation rejects the whole batch with `400` instead, since the fix is to
correct the payload.

Changing `commissionRateBps` is **not retroactive**: `Commission.rateBps` snapshots the
rate at calculation time, so existing commissions keep the rate they were created with.

## Payouts

### GET /apps/:appId/payouts/preview

What a payout **would** pay — writes nothing. Needs only `read`.

→ `200 { "asOf": "…", "rows": [{ "affiliateId": "…", "affiliateName": "…", "count": 3, "totalCents": 1200 }], "totalCents": 1200 }`

### POST /apps/:appId/affiliates/:affiliateId/payouts/export · `payout`

Matures due commissions, creates a payout, marks them `paid`, and returns a **CSV**
download. Idempotent — re-exporting pays nothing.

This is irreversible: there is no un-pay. It requires the `payout` permission, which is
never granted by default. Call the preview first.

→ `200` `text/csv` (`payout_id,affiliate_id,affiliate_name,commission_id,amount_usd_cents,amount_usd,currency,event_id,occurred_at`)

## Diagnostics

Three endpoints that turn "the dashboard shows zero" into a named cause.

### GET /apps/:appId/diagnostics/identity

Do billing and attribution agree on what a user is called? This is the most common
integration failure: `identify(userId)` and whatever your billing provider reports —
RevenueCat's `logIn()`, Adapty's customer user id, Stripe's
`metadata.customer_user_id`, Paddle's custom data — carrying different strings.
Everything looks healthy and no commission is ever created, because the join key never
matches.

→ `200 { "verdict": "ok" | "no_overlap" | "no_data", "overlapCount": 0, "billingOnlySamples": ["auth0|abc123"], "attributionOnlySamples": ["usr_abc123"], "truncated": false }`

A `no_overlap` verdict with samples from both sides usually makes the fix obvious.

### GET /apps/:appId/diagnostics/unattributed

Paid events that produced no commission — money a creator was not credited for — each
with a `likelyCause`.

### GET /apps/:appId/diagnostics/webhooks

Recent deliveries with `signatureValid`, `processingStatus`, `lastError` and the number
of events each produced. Raw payloads are never returned.
