Billing & Webhooks
This is how revenue becomes attributed commissions. Whoever bills your customers, the shape is the same: they POST an event to us, and we match it to an attribution.
Integrate MyAppAffiliate into my project. Documentation (read these first, they are the source of truth): - Page: https://docs.myappaffiliate.com/webhooks.md - Full docs index: https://docs.myappaffiliate.com/llms.txt Task: Connect my billing provider to MyAppAffiliate: register the webhook endpoint, save the signing secret, make sure the user id in my revenue events matches what the SDK identifies, and verify the event mapping end to end. Rules: - Follow the documented API exactly — no invented method names, endpoints, parameters or field names. - Match the conventions already used in my codebase. - Ask me for my SDK key instead of guessing, and never hard-code it — read it from config/environment. - Do NOT set an API base URL anywhere. Every SDK compiles the production host in; a staging or self-hosted host belongs in a build setting, not in code. - Tell me afterwards which steps I still have to do by hand (dashboard settings, capabilities, webhook configuration).
The one thing that matters: the user id
Attribution joins on your user id. Nothing else. The string you pass to
identify(userId) (or trackSignup({ userId })) has to be the string that arrives in
the revenue event. If the two differ, everything looks healthy and no commission is
ever created — which is the single most common integration failure.
You do not need to pass an affiliate id into your billing provider. Older versions
of these docs described setting an affiliate_id subscriber attribute; nothing reads
it, and it is no longer part of the integration.
Each provider has its own name for the field:
| Provider | Field we read | How to set it |
|---|---|---|
| RevenueCat | app_user_id | Purchases.logIn(userId) |
| Adapty | customer_user_id, falling back to profile_id | Adapty.identify(userId) |
| Superwall | originalAppUserId | Superwall.identify(userId) |
| Stripe | metadata.customer_user_id, then client_reference_id on sessions, then the Stripe customer id | set metadata at checkout — see below |
| Paddle | custom_data.customer_user_id, falling back to customer_id | customData on the transaction |
Two ways to satisfy it, whichever is less work for you:
- Tell the provider your id. Stamp your user id into the provider's metadata / customer-user-id field. This is the usual choice.
- Use the provider's id as yours. Every provider above falls back to its own
customer id, so calling
identify(stripeCustomerId)(or the Adapty profile id, the Paddle customer id) works with no metadata at all — handy when you cannot easily thread your id into the checkout.
Endpoints
Every provider posts to the same shape. <appId> comes from onboarding:
https://api.myappaffiliate.com/webhooks/<provider>/<appId><provider> is one of revenuecat, stripe, adapty, superwall, paddle.
Onboarding provisions a secret per provider — you only configure the ones you use.
| Provider | Where to register it | How we verify the delivery |
|---|---|---|
| RevenueCat | Integrations → Webhooks | the secret in the Authorization header, compared constant-time against the SHA-256 we stored |
| Adapty | webhook integration settings | same scheme as RevenueCat: Authorization header |
| Superwall | webhook settings | Svix — paste the whsec_… signing secret; we verify svix-id / svix-timestamp / svix-signature over the raw body |
| Stripe | Developers → Webhooks → Add endpoint | the endpoint's whsec_… signing secret; timestamped HMAC in Stripe-Signature, replay-safe |
| Paddle | Developer tools → Notifications | the destination's secret key; HMAC in Paddle-Signature over the raw body |
A bad or missing signature is recorded and rejected with 401.
Provider specifics
RevenueCat, Adapty, Superwall (mobile subscriptions)
Nothing to write in your app beyond identify(userId) and the provider's own
logIn / identify call with that same id. Register the webhook, paste the secret,
done.
Stripe
Select these events on the endpoint:
invoice.paid, charge.refunded, customer.subscription.deleted,
checkout.session.completed.
Stamp your user id when you create the checkout session:
await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: "price_…", quantity: 1 }],
subscription_data: {
metadata: { customer_user_id: user.id }, // renewals read this
},
client_reference_id: user.id, // one-time payments read this
success_url: "https://yourapp.com/welcome",
cancel_url: "https://yourapp.com/pricing",
});An invoice with none of those fields still falls back to the Stripe customer id — so it only earns a commission if that is what you identified with.
Paddle Billing
await paddle.transactions.create({
items: [{ priceId: "pri_…", quantity: 1 }],
customData: { customer_user_id: user.id },
});Falls back to the Paddle customer_id when custom data is absent.
Event mapping
Every provider is normalized to the same event types before it reaches the attribution engine, so commissions behave identically whatever bills your customers.
| Normalized type | Commissionable? | Comes from |
|---|---|---|
purchase | yes | RevenueCat INITIAL_PURCHASE / NON_RENEWING_PURCHASE · Stripe invoice.paid (subscription_create) and checkout.session.completed (mode: payment) · Paddle transaction.completed · Adapty and Superwall first payment |
renewal | yes | RevenueCat RENEWAL · Stripe invoice.paid (subscription_cycle) · Paddle transaction.completed with origin: subscription_recurring · Adapty subscription_renewed · Superwall renewal |
trial_start | no — $0, but counts in the funnel | RevenueCat INITIAL_PURCHASE flagged as a trial · Adapty trial_started |
refund | no — reverses the commission | RevenueCat CANCELLATION with cancel_reason: CUSTOMER_SUPPORT · Stripe charge.refunded · Paddle adjustment.created with action: refund · Adapty subscription_refunded |
refund_reversed | no | RevenueCat REFUND_REVERSED (App Store only) |
cancel | no | RevenueCat CANCELLATION with any other cancel_reason · Stripe customer.subscription.deleted · provider equivalents |
expiration | no | RevenueCat EXPIRATION · Adapty subscription_expired · Superwall expiration |
Anything else (billing issues, product changes, transfers, paywall impressions) is ignored.
RevenueCat sends no event called REFUND. A refund arrives as a CANCELLATION
carrying cancel_reason: CUSTOMER_SUPPORT; their reference describes CANCELLATION
as "A subscription or non-renewing purchase was canceled or refunded". If you build
your own mapping and key it on a REFUND event, that branch never fires and every
refund is recorded as ordinary churn. We read only CANCELLATION this way, never
EXPIRATION: a refund often fires both, and expiration_reason uses the same
vocabulary, so reading both would reverse one commission twice.
Stripe's checkout.session.completed is handled only for mode: payment: a
subscription checkout is counted from its first invoice.paid, so nothing is
double-counted.
Reliability (all providers)
- Persist-before-process: the raw payload is stored before parsing, so a parser bug never loses a financial event.
- Idempotent: a re-fired webhook with the same event id produces no second event or
commission (unique
(source, sourceEventId)). - Commission:
amount_usd_cents × rate_bps / 10000(rate snapshotted), attributed to the most recent referral within the attribution window, createdpendingthenmaturedafter the hold period — so a refund inside the hold never pays out.
Not yet supported
App Store Server Notifications, Apphud and Lemon Squeezy are on the roadmap. Until they land, report revenue from an unsupported provider by mapping it onto one of the five above (most billing stacks can forward to a Stripe-shaped endpoint), or get in touch — adding a provider is a normalizer plus a signature check.
Test it
Without a device, rehearse the whole loop against the live DB:
pnpm --filter @maa/api smokeFor Stripe, use the Stripe CLI:
stripe listen --forward-to https://api.myappaffiliate.com/webhooks/stripe/<appId>then stripe trigger invoice.payment_succeeded in test mode. Every provider's
dashboard also has a "send test event" button; a delivery that verifies shows up in
your webhook diagnostics.