A paying customer cancelled, then recovered. RevenueCat sent both events. The handler verified the signatures and returned 200 twice.
The wallet ended on the cancelled state.
The older EXPIRATION had been retried after the newer RENEWAL. Arrival order was not event order, and the last write won.
Someone in an Indie Hackers thread put the same bug in one sentence:
My RevenueCat webhooks write to a wallet balance, and the real failures were never bad code, just two events landing out of order.
What the handler assumed
The code looked ordinary:
app.post("/webhooks/revenuecat", async (req, res) => {
const event = verifyRevenueCat(req);
if (event.type === "RENEWAL") {
await db.users.update(event.app_user_id, { credits: 100, entitled: true });
}
if (event.type === "EXPIRATION") {
await db.users.update(event.app_user_id, { credits: 0, entitled: false });
}
res.sendStatus(200);
});
It treated each POST as current truth. That only works if HTTP arrival order matches the subscription lifecycle.
RevenueCat does not promise that. Failed deliveries retry after about 5, 10, 20, 40, and 80 minutes. A retry reuses event.id and event_timestamp_ms. It recomputes the signature timestamp t for that HTTP attempt.
So this is a legal sequence:
10:00 EXPIRATION happens
10:01 RENEWAL recovery happens
10:02 RENEWAL webhook arrives → credits = 100
11:22 EXPIRATION retry arrives → credits = 0
Both signatures can be valid. Both handlers can return 200. The customer is locked out because the stale write landed last.
The three clocks that disagree
Do not sort RevenueCat events by whichever timestamp is closest to the request.
| Field | What it actually is |
|---|---|
purchased_at_ms / expiration dates |
When the store action happened |
event_timestamp_ms |
When RevenueCat generated the event |
signature t
|
When this delivery attempt was signed |
t is the worst field to use as business order. An 80-minute retry of an old event gets a fresh signature time, so it looks newer than events that genuinely happened after it.
event.id is the idempotency key. Use it to refuse a duplicate delivery. It does not tell you whether this event is still current.
The doorbell pattern
The Indie Hackers replies kept circling the same fix: stop treating the payload as a command.
Store the event. Return 200. Then ask RevenueCat what is true now.
await db.processedEvents.insert({ id: event.id }).catch(duplicate => {
return res.sendStatus(200);
});
const entitlements = await revenuecat.customers.activeEntitlements(
event.app_user_id,
);
await db.users.update(event.app_user_id, {
entitled: entitlements.some((e) => e.id === "premium"),
});
A late retry of an old event then triggers the same current-state read. It cannot roll the wallet backward just because it arrived last.
Wallet credits still need a ledger. If one event means one additive grant, insert event.id under a unique constraint in the same transaction as the credit row. Idempotency answers “did I already process this id?” Ordering answers “is this still the latest truth?” Those are different questions.
The test happy-path fixtures hide
Most RevenueCat webhook tests send one event, assert 200, and stop. A webhook sandbox is only useful here if it can replay the same signed events in more than one order.
The useful fixture sends the same event set in more than one order:
RENEWAL → EXPIRATION
EXPIRATION → RENEWAL
RENEWAL, then the same RENEWAL again
old EXPIRATION retried after a newer RENEWAL
For every permutation, read the application state, not the HTTP status:
final entitlement == RevenueCat active entitlements
each event.id creates at most one side effect
a stale delivery cannot overwrite newer state
the handler did not “fix” order by ignoring every event
That last line is the trap. A patch that writes nothing is perfectly order-independent and completely wrong.
Another comment in that thread asked for the same honesty in the receipt: if order dependence between two events was never tested, say so instead of reporting green.
Prove it from the coding agent, then keep it in CI
Connect FetchSandbox MCP in Cursor or Claude Code. The RevenueCat workflows do not stop at “we received the webhook.” webhook_event_verified and entitlement_verified_after_purchase both read /active_entitlements after the write.
Ask the agent:
Audit my RevenueCat webhook for out-of-order wallet updates.
Keep the still-broken tree. Propose a diff. Call prove_fix before
writing it to disk. The probe must fail on the old code when an
older EXPIRATION arrives after a newer RENEWAL, and hold on the
patched code. Paste the receipt.
Green is only allowed on a measured flip:
buggy tree → terminal state diverged → exit 1
fixed tree → all tested orders agree → exit 0
If the probe cannot reproduce the order bug, the result stays unproven. A model saying “this looks idempotent now” is not the gate.
Then keep the same fixtures in CI so a later change does not bring the last-write-wins handler back:
- name: Run API integration workflows
run: |
npx fetchsandbox run "$FETCHSANDBOX_ID" --all --json \
> fetchsandbox-workflows.json
Paste the prove_fix receipt into the pull request by hand. Automatic GitHub comments are not a shipped FetchSandbox feature.
## RevenueCat order proof
- event set: RENEWAL, EXPIRATION
- duplicate event.id: exercised
- before: wallet followed arrival order
- after: wallet matched active entitlements
- not verified: two concurrent deliveries for the same customer
- receipt: https://fetchsandbox.com/runs/...
The full timestamp map, retry schedule, and checklist live in the canonical guide.
Top comments (0)