Webhooks
Mounted at /api/v1/webhooks and excluded from the authenticated middleware — they authenticate by provider secret instead. apps/api/src/router/webhookRoutes.ts.
| Method | Path | Provider | Auth | Status |
|---|---|---|---|---|
POST | /webhooks/stripe-transfers | Stripe (v1 snapshot events) | Stripe-Signature vs STRIPE_TRANSFER_WEBHOOK_SECRET (fails closed) | Shipped |
POST | /webhooks/revenuecat | RevenueCat | Authorization header vs REVENUECAT_WEBHOOK_SECRET (fails closed in production) | Shipped |
POST | /webhooks/mux | Mux | Mux-Signature vs MUX_WEBHOOK_SECRET (fails closed in production) | Shipped |
POST | /webhooks/stripe | Stripe | — | Empty handler |
RevenueCat
POST /api/v1/webhooks/revenuecat
The best-built integration in the codebase, and the pattern to copy.
Handling
- Authorize. The
Authorizationheader must equalREVENUECAT_WEBHOOK_SECRET, compared in constant time. With the secret unset, production answers503to everything; other environments accept unauthenticated calls for local testing. - Validate. zod-parses the body (
RCWebhookBodySchema),400on failure. Unknown fields are kept: RevenueCat adds fields without a version change. - Process, then acknowledge.
200only once the event is handled; any error answers500. RevenueCat retries anything but a200(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.eventIdis checked before any side effect, and written after withskipDuplicates, so a concurrent duplicate doesn't fail. The row keeps the event'stypeand fullpayloadfor audit and later reprocessing.- Coin purchases don't rely on that check. Each is one
CoinPurchaserow, unique on(store, storeTransactionId), and its ledger transaction is keyedpurchase:<store>:<transaction id>. Concurrent deliveries, two events for one transaction, and/coins/purchases/syncall credit it once between them.
Events
| Event | Effect |
|---|---|
NON_RENEWING_PURCHASE, INITIAL_PURCHASE for a coin package | Credit coins (recordCoinPurchase) |
INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE otherwise | Sync 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 otherwise | Mark the subscription inactive |
UNCANCELLATION | Reactivate |
PRODUCT_CHANGE | Move to the new tier |
TRANSFER | Move entitlements between app user ids |
| anything else | Logged 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_idoraliases(otherwise500, so RevenueCat retries); - it has a
transaction_id(otherwise500); - it isn't sandbox in production (
environmentother thanPRODUCTIONcounts as sandbox; refused with a200so it isn't retried, unlessCOIN_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
| Event | Effect |
|---|---|
video.asset.ready | Media.status = ready, dimensions and duration stored, Post.status = ready, media:processing:update emitted |
video.live_stream.active | LiveStream.status = active, User.isLive = true, activeLiveStreamId set |
video.live_stream.idle / .disconnected | LiveStream.status = idle, User.isLive = false, activeLiveStreamId cleared |
.connected, .recording, .updated, .enabled, .disabled, .warning | Acknowledged, 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
// 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:
- Verifies
Stripe-SignaturewithSTRIPE_WEBHOOK_SECRET(parseEventNotification). Returns400on failure, and503if the secret or the Stripe key is unset. It fails closed. - On these thin (v2) events it re-reads the account from Stripe, never trusting the payload:
v2.core.account.updatedv2.core.account[requirements].updatedv2.core.account[configuration.recipient].updatedv2.core.account[configuration.recipient].capability_status_updated
- Updates
User.payoutsEnabled(true whenstripe_transfersisactive) andCreatorProfile.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:
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
| Provider | URL |
|---|---|
| RevenueCat | https://api.playpalz.gg/api/v1/webhooks/revenuecat |
| Mux | https://api.playpalz.gg/api/v1/webhooks/mux |
| Stripe | https://api.playpalz.gg/api/v1/webhooks/stripe (once implemented) |
Locally, tunnel to reach your machine:
ngrok http 4000
# register https://<id>.ngrok.io/api/v1/webhooks/muxWithout a tunnel, video media stays pending forever locally even though Mux transcoded it fine.
