Skip to content

Webhooks ​

Mounted at /api/v1/webhooks and excluded from the authenticated middleware — they authenticate by provider secret instead. apps/api/src/router/webhookRoutes.ts.

MethodPathProviderAuthStatus
POST/webhooks/stripe-transfersStripe (v1 snapshot events)Stripe-Signature vs STRIPE_TRANSFER_WEBHOOK_SECRET (fails closed)Shipped
POST/webhooks/revenuecatRevenueCatAuthorization header vs REVENUECAT_WEBHOOK_SECRET (fails closed in production)Shipped
POST/webhooks/muxMuxMux-Signature vs MUX_WEBHOOK_SECRET (fails closed in production)Shipped
POST/webhooks/stripeStripe—Empty handler

RevenueCat ​

POST /api/v1/webhooks/revenuecat

The best-built integration in the codebase, and the pattern to copy.

Handling ​

  1. Authorize. The Authorization header must equal REVENUECAT_WEBHOOK_SECRET, compared in constant time. With the secret unset, production answers 503 to everything; other environments accept unauthenticated calls for local testing.
  2. Validate. zod-parses the body (RCWebhookBodySchema), 400 on failure. Unknown fields are kept: RevenueCat adds fields without a version change.
  3. Process, then acknowledge. 200 only once the event is handled; any error answers 500. RevenueCat retries anything but a 200 (5 times over about 80 minutes) and times out at 60s. Coin processing is one short database transaction, so it fits easily.

Idempotency ​

Delivery is at-least-once, so the same event can arrive twice, even at the same time.

  • RevenueCatEvent.eventId is checked before any side effect, and written after with skipDuplicates, so a concurrent duplicate doesn't fail. The row keeps the event's type and full payload for audit and later reprocessing.
  • Coin purchases don't rely on that check. Each is one CoinPurchase row, unique on (store, storeTransactionId), and its ledger transaction is keyed purchase:<store>:<transaction id>. Concurrent deliveries, two events for one transaction, and /coins/purchases/sync all credit it once between them.

Events ​

EventEffect
NON_RENEWING_PURCHASE, INITIAL_PURCHASE for a coin packageCredit coins (recordCoinPurchase)
INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE otherwiseSync the platform subscription, or handle a VIP purchase, routed by product id
CANCELLATION of a coin package (a refund)Claw the coins back (processCoinRefund)
REFUND_REVERSED of a coin package (App Store only)Put back what the clawback took
EXPIRATION, CANCELLATION otherwiseMark the subscription inactive
UNCANCELLATIONReactivate
PRODUCT_CHANGEMove to the new tier
TRANSFERMove entitlements between app user ids
anything elseLogged and ignored

A coin purchase is credited only when:

  • the product is a row in CoinPackage (an unknown product is logged and ignored);
  • the buyer resolves from app_user_id, original_app_user_id or aliases (otherwise 500, so RevenueCat retries);
  • it has a transaction_id (otherwise 500);
  • it isn't sandbox in production (environment other than PRODUCTION counts as sandbox; refused with a 200 so it isn't retried, unless COIN_ALLOW_SANDBOX_PURCHASES=true).

Coins credited are CoinPackage.coins × quantity. Price, buyer currency, and the store's commission and tax percentages are stored on CoinPurchase (money as integer micros).

Refunds are keyed by the store transaction, so they work even without an app_user_id. Every CANCELLATION of a coin product counts as a refund: a one-time purchase can't be cancelled any other way. A refund with no transaction_id answers 500 so RevenueCat retries. See Monetization.

VIP passes are matched by checkout

A NON_RENEWING_PURCHASE of a PriceTier product (a VIP pass) is applied to the checkout named by the checkout_intent_id subscriber attribute, or kept as a credit if there's no usable one; a CANCELLATION of one is a refund. See Monetization.

Mux ​

POST /api/v1/webhooks/mux

EventEffect
video.asset.readyMedia.status = ready, dimensions and duration stored, Post.status = ready, media:processing:update emitted
video.live_stream.activeLiveStream.status = active, User.isLive = true, activeLiveStreamId set
video.live_stream.idle / .disconnectedLiveStream.status = idle, User.isLive = false, activeLiveStreamId cleared
.connected, .recording, .updated, .enabled, .disabled, .warningAcknowledged, no action

Responds 200 for any correctly signed event, including unrecognised ones, so Mux does not retry them.

Verification ​

app.ts mounts express.raw({ type: "application/json" }) on this path, because Mux signs the exact bytes it sends. The handler checks Mux-Signature with mux.webhooks.verifySignature(rawBody, headers, MUX_WEBHOOK_SECRET) before parsing the JSON. A bad or missing signature answers 401. With no secret set, production answers 503 to everything (otherwise anyone could mark a creator live and push "X is live" to all their followers), and other environments skip the check for local testing.

No idempotency

Unlike the RevenueCat handler there is no event-id check, so a redelivered webhook re-applies its effect. Currently harmless (the writes are idempotent in practice), but it is a landmine for any future handler that increments something.

Stripe ​

ts
// app.ts, registered before express.json()
app.use("/api/v1/webhooks/stripe", express.raw({ type: "application/json", limit: "1mb" }));

Stripe signs the exact bytes it sends, so this route reads the raw body. The global express.json() then skips it. The handler is in apps/api/src/services/stripeWebhook.ts:

  1. Verifies Stripe-Signature with STRIPE_WEBHOOK_SECRET (parseEventNotification). Returns 400 on failure, and 503 if the secret or the Stripe key is unset. It fails closed.
  2. On these thin (v2) events it re-reads the account from Stripe, never trusting the payload:
    • v2.core.account.updated
    • v2.core.account[requirements].updated
    • v2.core.account[configuration.recipient].updated
    • v2.core.account[configuration.recipient].capability_status_updated
  3. Updates User.payoutsEnabled (true when stripe_transfers is active) and CreatorProfile.stripeRequirementsDue.

Because every event triggers a re-read, redelivered and out-of-order events are harmless, and no event-id dedupe table is needed. A thrown error returns 500, so Stripe retries.

Setup. In the Stripe Dashboard, create an event destination for Connected accounts with the thin payload style. Point it at https://api.playpalz.com/api/v1/webhooks/stripe and subscribe it to the four events above. Locally:

bash
stripe listen --forward-thin-to localhost:4000/api/v1/webhooks/stripe \
  --thin-events 'v2.core.account[requirements].updated,v2.core.account[configuration.recipient].capability_status_updated'

Still unhandled: transfer.failed and payout.paid (v1 snapshot events), so failed or completed creator payouts are visible only in anansi's records and the Stripe dashboard. Planned

Registering webhook URLs ​

ProviderURL
RevenueCathttps://api.playpalz.gg/api/v1/webhooks/revenuecat
Muxhttps://api.playpalz.gg/api/v1/webhooks/mux
Stripehttps://api.playpalz.gg/api/v1/webhooks/stripe (once implemented)

Locally, tunnel to reach your machine:

bash
ngrok http 4000
# register https://<id>.ngrok.io/api/v1/webhooks/mux

Without a tunnel, video media stays pending forever locally even though Mux transcoded it fine.

Internal documentation — PlayPalz platform