MyAppAffiliate

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.

Set this up with your AI coding agent
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:

ProviderField we readHow to set it
RevenueCatapp_user_idPurchases.logIn(userId)
Adaptycustomer_user_id, falling back to profile_idAdapty.identify(userId)
SuperwalloriginalAppUserIdSuperwall.identify(userId)
Stripemetadata.customer_user_id, then client_reference_id on sessions, then the Stripe customer idset metadata at checkout — see below
Paddlecustom_data.customer_user_id, falling back to customer_idcustomData on the transaction

Two ways to satisfy it, whichever is less work for you:

  1. Tell the provider your id. Stamp your user id into the provider's metadata / customer-user-id field. This is the usual choice.
  2. 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:

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

ProviderWhere to register itHow we verify the delivery
RevenueCatIntegrations → Webhooksthe secret in the Authorization header, compared constant-time against the SHA-256 we stored
Adaptywebhook integration settingssame scheme as RevenueCat: Authorization header
Superwallwebhook settingsSvix — paste the whsec_… signing secret; we verify svix-id / svix-timestamp / svix-signature over the raw body
StripeDevelopers → Webhooks → Add endpointthe endpoint's whsec_… signing secret; timestamped HMAC in Stripe-Signature, replay-safe
PaddleDeveloper tools → Notificationsthe 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:

TypeScript
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

TypeScript
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 typeCommissionable?Comes from
purchaseyesRevenueCat INITIAL_PURCHASE / NON_RENEWING_PURCHASE · Stripe invoice.paid (subscription_create) and checkout.session.completed (mode: payment) · Paddle transaction.completed · Adapty and Superwall first payment
renewalyesRevenueCat RENEWAL · Stripe invoice.paid (subscription_cycle) · Paddle transaction.completed with origin: subscription_recurring · Adapty subscription_renewed · Superwall renewal
trial_startno — $0, but counts in the funnelRevenueCat INITIAL_PURCHASE flagged as a trial · Adapty trial_started
refundno — reverses the commissionRevenueCat CANCELLATION with cancel_reason: CUSTOMER_SUPPORT · Stripe charge.refunded · Paddle adjustment.created with action: refund · Adapty subscription_refunded
refund_reversednoRevenueCat REFUND_REVERSED (App Store only)
cancelnoRevenueCat CANCELLATION with any other cancel_reason · Stripe customer.subscription.deleted · provider equivalents
expirationnoRevenueCat 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, created pending then matured after 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:

Shell
pnpm --filter @maa/api smoke

For Stripe, use the Stripe CLI:

Shell
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.