MyAppAffiliate

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:

Code
Authorization: Bearer pk_live_…

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

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

Code
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:

PermissionGrants
readOverviews, timeseries, every list, diagnostics
writeAffiliates, links, app settings, bulk operations
setupCreating apps, rotating keys and webhook secrets
payoutThe 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). 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.

EndpointFiltersNotes
…/affiliatesstatus, sort=created|name|earningsEach row carries clicks, installs, commissionCents
…/linksaffiliateId, active=true|falseEach row carries clicks and affiliateName
…/commissionsaffiliateId, status, from, torateBps is the rate snapshotted at calculation time
…/payoutsaffiliateIditemCount 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

EndpointDoes
POST …/affiliatesCreate one creator → 201
POST …/affiliates/bulkUp to 100 at once → 200 { created, failed }
PATCH …/affiliates/:affiliateIdname, email, status, defaultCommissionRateBps
POST …/linksCreate one link → 201, or 409 if the slug is taken
POST …/links/bulkUp to 100 at once → 200 { created, failed }
PATCH …/links/:linkIddestinationUrl, campaign, active
PATCH …/settingsCommission 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.