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.
{
"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.
{ "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.
{ "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). 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.