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/revenuecatRevenueCatAuthorization header vs REVENUECAT_WEBHOOK_SECRETShipped
POST/webhooks/muxMuxNoneUnverified
POST/webhooks/stripeStripeEmpty handler
GET/webhooks/mux-test/:idNoneDebug endpoint

RevenueCat

POST /api/v1/webhooks/revenuecat

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

Handling

ts
// 1. authorize
const secret = process.env.REVENUECAT_WEBHOOK_SECRET;
if (secret) {
  if (req.headers.authorization !== secret) return res.status(401).json({ error: "Unauthorized" });
}

// 2. validate
const parsed = RCWebhookBodySchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "Invalid webhook payload" });

// 3. acknowledge before processing
res.status(200).json({ ok: true });

// 4. process, with errors logged rather than propagated
try { await processEvent(parsed.data.event); } catch (err) { logger.error({ err }, "…"); }

Acknowledging first means a slow database never causes RevenueCat to retry a webhook it already delivered successfully.

Idempotency

RevenueCatEvent.eventId is the primary key, checked before any side effect and written after:

ts
const existing = await prisma.revenueCatEvent.findUnique({ where: { eventId: event.id } });
if (existing) return;                                   // already applied
// … handle …
await prisma.revenueCatEvent.create({ data: { eventId: event.id } });

Events

EventEffect
INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASECredit coins, sync the platform subscription, or handle a VIP purchase — routed by product id
EXPIRATION, CANCELLATIONMark the subscription inactive
UNCANCELLATIONReactivate
PRODUCT_CHANGEMove to the new tier
TRANSFERMove entitlements between app user ids
anything elseLogged and ignored

Product ids map through COIN_PRODUCT_MAP and APP_PRODUCT_TIER_MAP in RevenueCatTypes.ts. An unmapped id logs a warning and does nothing — remember to update the maps when adding a SKU.

The secret check is conditional

ts
if (secret) { /* verify */ }

With REVENUECAT_WEBHOOK_SECRET unset the endpoint accepts anything, letting anyone credit themselves coins or grant subscriptions. Set it in every deployed environment, and consider failing closed when it is missing in production.

VIP purchases need a client follow-up

The webhook cannot determine which creator a VIP subscription is for, so the mobile app calls POST /api/v1/vip-subscriptions after purchase. A purchase completing while the app is killed can be lost. See Commerce & Wallet.

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

Always responds 200, so Mux does not retry on an unrecognised event.

No signature verification

MUX_WEBHOOK_SECRET is configured in the environment and in the deployment manifests, but the handler never checks it. Anyone who can reach the endpoint can:

  • Mark an arbitrary creator live or offline
  • Mark arbitrary media ready with attacker-supplied dimensions

Fix by verifying Mux-Signature with mux.webhooks.verifySignature(rawBody, headers, secret) before the switch. This also requires preserving the raw body for that route — express.json() has already consumed it, so mount express.raw({ type: "application/json" }) on the Mux path specifically.

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
router.post("/stripe", async (req, res) => {});

An empty handler. Nothing consumes Stripe events, which means:

  • account.updated is ignored, so Connect onboarding completion does not set payoutsEnabled
  • transfer.failed is ignored, so a failed creator payout is only visible in anansi's own records
  • payout.paid is ignored, so there is no confirmation that money reached a creator's bank

Implementing this is the natural next step for the payments work. It should verify the Stripe signature, dedupe on event id (mirroring RevenueCatEvent), and write Ledger rows with stripeEventId populated. Planned

Debug endpoint

ts
router.get("/mux-test/:id", async (req, res) => {
  const asset = await mux.video.assets.retrieve(id);
  res.json(asset);
});

Unauthenticated, returns raw Mux asset metadata for any id, and swallows errors with a bare console.log. Remove it, or put it behind the admin token.

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