Skip to main content

Mcp

RevenueCat Webhooks Out of Order: Keep Entitlements Correct

RevenueCat webhooks can arrive out of order. Deduplicate by event.id, read authoritative entitlement state, and test convergence before production.

2026-09-14FetchSandbox Engineering

Two RevenueCat webhooks reach your server for the same customer. The newer subscription state is applied first, an older event lands second, and your app finishes with the wrong wallet balance or entitlement.

Every request can return 200. Every signature can be valid. The final state can still be wrong.

Can RevenueCat webhooks arrive out of order?

Yes. RevenueCat documents that some billing-related webhook events are dispatched in order, but says network irregularities can cause them to be received in a different order. Retries also arrive later, so delivery order is not a safe substitute for event order or current subscription state.

RevenueCat retries failed deliveries after increasing delays of approximately 5, 10, 20, 40, and 80 minutes. A retry reuses the event's id and event_timestamp_ms, but RevenueCat recomputes the webhook signature timestamp for each delivery attempt.

That means three clocks can disagree:

  • when the subscription action happened
  • when RevenueCat generated the event
  • when a particular HTTP delivery was signed and reached your server

A handler that treats arrival time or signature time as the business order will eventually apply stale state after newer state.

What did Indie Hackers say about RevenueCat webhook failures?

In a FetchSandbox discussion about AI making code cheap but verification expensive, an Indie Hackers commenter described the production failure more clearly than a generic feature request:

“My RevenueCat webhooks write to a wallet balance, and the real failures were never bad code, just two events landing out of order.”

The thread produced 87 comments. Across four related Reddit threads, FetchSandbox collected 111 comments about integration failures and verification. The same community also reported:

  • staging that still double-charged after generated billing code passed local tests
  • a model grading its own output and confirming its own assumptions
  • "Connected" confirming only the OAuth handshake, not that writes landed
  • a retry arriving hours later after an in-memory dedup cache was gone
  • a unique-constraint-before-work pattern that caught two double-charge paths in CI

The useful signal was not “add RevenueCat” by itself. It was the invariant hidden inside the story:

Given the same set of RevenueCat events,
the final local state must be identical under every delivery order
and must equal the correct terminal state.

That is testable. “The webhook endpoint returned 200” is not enough.

Why does an out-of-order RevenueCat webhook corrupt local state?

The common bug is applying each webhook payload directly to a mutable local field:

if (event.type === "RENEWAL") {
  await users.update(event.app_user_id, { hasPremium: true });
}

if (event.type === "EXPIRATION") {
  await users.update(event.app_user_id, { hasPremium: false });
}

This code assumes the last HTTP request to arrive represents the newest truth.

Imagine this delivery order:

10:00  EXPIRATION happens
10:01  RENEWAL recovery happens
10:02  RENEWAL webhook arrives     → hasPremium = true
10:07  EXPIRATION webhook arrives  → hasPremium = false

The handler processed both events successfully. The paying customer is now locked out because the older state arrived last.

The same shape affects:

  • virtual-currency balances
  • usage credits
  • feature entitlements
  • subscription status
  • grace-period access
  • cancellation and expiration handling

The issue is not malformed JSON or a bad SDK call. It is a distributed-systems ordering assumption hidden inside ordinary application code.

Which RevenueCat timestamp should determine webhook order?

Do not use the webhook signature timestamp t to order subscription events. RevenueCat states that t is when it signed that delivery attempt, and it recomputes the value for every retry.

An old event retried 80 minutes later therefore receives a newer signature timestamp than events that actually happened after it.

RevenueCat's event_timestamp_ms is the time the event was generated. It is useful for diagnosing order and detecting an obviously stale transition, but it is not a universal sequence number. RevenueCat also notes that multiple events can be generated together and that event timestamps should not be the sole basis for granting or removing access.

Use each field for its actual job:

  • event.id: durable idempotency key for duplicate delivery
  • event_timestamp_ms: event-generation context and stale-event defense
  • signature t: replay-freshness check for this HTTP delivery
  • authoritative customer, subscription, or entitlement read: current state

Conflating these fields creates bugs that happy-path webhook tests never exercise.

How should you handle RevenueCat webhooks safely?

Handle RevenueCat webhooks as durable change notifications, not as unquestionable snapshots of current state.

A robust flow is:

  1. Verify the signature against the raw request bytes.
  2. Insert event.id into a table with a unique constraint.
  3. Return success for an already-recorded event without repeating side effects.
  4. Persist or enqueue the event before returning 200.
  5. Read the customer's current subscription or entitlement state from RevenueCat.
  6. Update local access from that authoritative result.
  7. Record the source event, observed provider state, and resulting local state.
  8. Reconcile periodically so a permanently missed delivery does not create permanent drift.

RevenueCat recommends idempotent processing and explicitly suggests tracking the event id. Its retry contract makes durable storage important: an in-memory deduplication cache may be gone by the 80-minute retry.

What changes for wallet balances and credits?

Wallet balances need a ledger invariant in addition to entitlement read-back.

If one webhook represents one additive credit, store the provider transaction or event identity in a ledger with a unique constraint and apply the balance mutation in the same database transaction:

await db.transaction(async (tx) => {
  const inserted = await tx.processedEvents.insertIfAbsent({
    provider: "revenuecat",
    eventId: event.id,
  });

  if (!inserted) return;

  await tx.walletEntries.insert({
    userId: event.app_user_id,
    providerEventId: event.id,
    amount: creditDelta,
  });
});

This prevents a duplicate event from adding the same credit twice.

Ordering is a separate property. If events set an absolute balance or reverse previous entries, the update must either:

  • reject a transition older than the stored version
  • derive the balance from an append-only transaction ledger
  • reconcile against RevenueCat's current virtual-currency or customer state

Idempotency answers “did I process this event already?” It does not answer “is this event still current?”

Why is re-fetching authoritative state safer?

Re-fetching converts the webhook from a state command into a doorbell.

The payload says:

Something changed for this customer.

The follow-up read answers:

What is true for this customer now?

If an old event arrives after a new event, both deliveries trigger the same current-state read. The second HTTP request no longer rolls local state backward merely because it arrived last.

This pattern costs an additional API read, so queueing, rate limits, and reconciliation still matter. But the application now converges toward provider truth instead of treating network arrival order as truth.

For pure ledger events, preserve the immutable event and transaction identities as well. A current-state fetch should not erase the audit trail that explains how a balance was reached.

How should you test out-of-order RevenueCat webhooks?

Test the same event set in multiple delivery orders and assert both convergence and correctness.

At minimum, cover:

RENEWAL → BILLING_ISSUE → EXPIRATION
EXPIRATION → BILLING_ISSUE → RENEWAL
BILLING_ISSUE → RENEWAL → EXPIRATION
duplicate RENEWAL
old EXPIRATION retried after a newer RENEWAL

For every permutation, assert:

  • the final entitlement is the expected terminal value
  • the local record agrees with the provider read-back
  • each event ID creates at most one side effect
  • a stale delivery cannot overwrite newer state
  • the handler does not silently ignore every event

The last check matters. A patch that stops all webhook writes will appear perfectly order-independent while leaving the product broken.

The invariant needs a positive control:

The correct entitlement or credit is granted exactly once.

It also needs a negative control:

Changing delivery order does not change the final correct state.

What does FetchSandbox verify for RevenueCat?

FetchSandbox includes RevenueCat workflows designed around authoritative read-back rather than stopping at webhook receipt.

The entitlement_verified_after_purchase workflow:

  1. creates a customer
  2. grants an entitlement
  3. reads /active_entitlements back from RevenueCat
  4. lists the subscription backing that entitlement

The webhook_event_verified workflow reads the customer and active entitlements after a delivered event. The paired entitlement_drift scenario checks the terminal property that reordered, duplicated, dropped, or partially processed webhooks can break: the application's stored entitlement must agree with authoritative state.

This scenario does not pretend that a successful read-back alone proves every possible network ordering. An order-specific fix proof needs an executable probe that reproduces the bad sequence on the old code and verifies convergence on the patched code.

With FetchSandbox MCP, a coding agent can run:

find_bugs
  → identify the order-dependent RevenueCat state update
fix_bug
  → propose the patch
prove_fix
  → run the same order-sensitive invariant on broken and patched code

FetchSandbox permits green only when the probe exits 1 on the still-broken tree and 0 on the patched copy. If the order bug cannot be reproduced, the result remains unproven.

That is the same distinction Indie Hackers commenters made: a green local test is not proof, and a self-graded agent result is not independent verification. Connect FetchSandbox MCP, run the RevenueCat workflows, and keep the same fixtures in API integration testing in CI. For the broader eval contract, see deterministic evals for coding agents.

What should a RevenueCat webhook proof receipt report?

A useful receipt should report what ran and what did not run.

The same Indie Hackers feedback produced this request:

“My webhook bug would've been caught immediately if anything had been forced to say ‘order dependence between these two events was never tested’ instead of just reporting green.”

For an out-of-order RevenueCat fix, the pull request should include:

## RevenueCat webhook verification

- Event set: RENEWAL, BILLING_ISSUE, EXPIRATION
- Delivery orders exercised: 5
- Duplicate event ID exercised: yes
- Old code: terminal entitlement diverged
- Patched code: all tested orders converged
- Authoritative read-back: matched
- Not verified: concurrent delivery of the same customer
- Receipt: https://fetchsandbox.com/runs/...

“Not verified” is not a weakness in the receipt. It tells the reviewer where human judgment or another test is still required.

How does this fit into CI/CD?

Keep the exact event fixtures and terminal-state assertions in CI after the patch is proven.

The inner-loop proof answers:

Did this proposed patch change a reproduced failure into the required behavior?

The CI job answers:

Does that behavior still hold after later branch changes?

Store captured or representative RevenueCat payloads with:

  • stable event IDs
  • event timestamps
  • retry deliveries
  • at least one out-of-order sequence
  • expected authoritative state

Run the same fixture set against every change to webhook, subscription, wallet, entitlement, or billing code. A deterministic final-state assertion is more useful than a screenshot of three 200 responses.

RevenueCat webhook checklist

Before shipping a RevenueCat webhook handler:

  • verify the signature against raw bytes
  • deduplicate on event.id
  • do not order events using signature timestamp t
  • treat event_timestamp_ms as context, not a universal sequence number
  • persist before acknowledging
  • keep balance updates transactional
  • read subscription and entitlement state back
  • test duplicates and multiple delivery orders
  • assert the exact terminal state
  • report any ordering or concurrency case that was not tested

Questions about RevenueCat webhook ordering

Does RevenueCat guarantee webhook delivery order?

No. RevenueCat documents that network irregularities can cause events to be received in a different order. Application code should not assume HTTP arrival order equals subscription lifecycle order.

Do RevenueCat webhook retries keep the same event ID?

Yes. RevenueCat says retries reuse the payload id and event_timestamp_ms. Use event.id as a durable idempotency key.

Can I sort RevenueCat events by signature timestamp?

No. RevenueCat recomputes the signature timestamp t for every delivery attempt. A retry of an old event can therefore have a newer signature timestamp than a later business event.

Should I trust event_timestamp_ms for entitlement access?

Use it to understand when RevenueCat generated the event and to help reject stale updates, but do not treat it as the only source of current entitlement truth. Read the customer's active entitlement or subscription state and reconcile local state.

How do I prevent duplicate wallet credits?

Record the RevenueCat event or virtual-currency transaction identity under a unique database constraint and apply the ledger entry in the same transaction. Replayed delivery should return success without adding the credit again.

Can FetchSandbox prove every out-of-order webhook bug automatically?

No. The relevant application state must be observable, and the probe must reproduce the order-dependent failure on the old code. If FetchSandbox cannot run or reproduce that invariant, it blocks green and reports the proof as unavailable or unproven.