← Back to blog

· by

Diagnosing duplicate Apple IAP transactions in production

PaymentsiOSEntitlements
Code editor showing the upsert-based fix to the entitlement resolver

A support ticket came in from a Protect-A-Plate user who'd been charged twice for the same monthly plan. Apple's side showed one transaction. Our database showed two entitlement records, both active, both pointing at the same originalTransactionId. That mismatch — one purchase, two rows — is the whole story of this post.

The setup

Entitlements get created in two places that are supposed to agree with each other:

  • The client calls our /entitlements/verify endpoint right after SKPaymentQueue reports a successful transaction, sending the receipt for validation.
  • Apple's App Store Server Notifications hit a webhook independently, on its own schedule, sometimes seconds later and sometimes minutes later.

Both paths ended in the same resolver function, which was supposed to be idempotent:

async function resolveEntitlement(receipt: VerifiedReceipt) {
  const existing = await db.entitlements.findOne({
    originalTransactionId: receipt.originalTransactionId,
  });

  if (existing) {
    return db.entitlements.updateOne(existing._id, receipt);
  }

  return db.entitlements.insertOne(receipt);
}

Read-then-write, no lock. Fine under normal load. Not fine when both paths call it within milliseconds of each other and the read happens before either write commits.

Why it only showed up on bad connections

The race existed from day one, but it took months to surface, and only from users on flaky mobile connections. The reason: our client-side receipt verification had a retry loop with token refresh baked in —

async function verifyWithRetry(receipt: string, attempt = 0): Promise<void> {
  try {
    await api.post("/entitlements/verify", { receipt });
  } catch (err) {
    if (isAuthError(err) && attempt < 3) {
      await refreshToken();
      return verifyWithRetry(receipt, attempt + 1);
    }
    throw err;
  }
}

On a slow or dropping connection, the first request would time out client-side after it had already reached the server and started processing — the response was lost, not the request. The retry then fired a second call with the same receipt while the first was still in flight. That's the twin request that raced the webhook: not client vs. Apple, but client vs. itself, occasionally joined by Apple's notification landing in the same window.

Good connections never produced the overlap. Bad ones produced it reliably enough that a handful of tickets a week eventually made the pattern visible.

The fix

Two changes, neither of which involved touching the retry logic:

  1. A unique index on originalTransactionId in the entitlements collection, so the losing write of any race fails loudly instead of silently creating a duplicate.
  2. An upsert instead of read-then-write, using the index as the concurrency guard:
async function resolveEntitlement(receipt: VerifiedReceipt) {
  return db.entitlements.updateOne(
    { originalTransactionId: receipt.originalTransactionId },
    { $set: receipt },
    { upsert: true }
  );
}

The database resolves the race instead of the application trying to. The retry loop stayed exactly as flaky-network-hostile as before — it didn't need fixing, because the assumption it violated (that the resolver was safe to call twice) is now actually true.

The takeaway

The bug report described a billing problem. The root cause was a concurrency assumption written months earlier, in a different file, that nobody thought to revisit when the retry logic was added. Grep for every findOne followed by a conditional insert in code that also has a retry path anywhere upstream — that combination is worth auditing on its own, before a support ticket forces the question. Neither Apple's notifications nor Stripe's webhooks promise exactly-once delivery, and no major provider's do — treat every handler as something that will be called twice.

FAQ

What causes duplicate Apple IAP transactions?

Almost always a race between two code paths that are supposed to agree — typically the client's own receipt-verification call and Apple's independent server-to-server notification — hitting a non-idempotent write at close to the same moment. Retried requests from a flaky client connection make the race far more likely, not less.

How do App Store Server Notifications relate to receipt validation?

They're two independent ways the same fact reaches your server. Client-triggered receipt validation happens right after purchase; App Store Server Notifications arrive on Apple's own schedule for the whole subscription lifecycle — renewals, refunds, cancellations. A correct system treats both as untrusted input to the same resolver, not as separate sources of truth.

What is an idempotent webhook handler?

One where processing the same event twice produces the same end state as processing it once — no duplicate records, no double-counted revenue. The standard technique is keying incoming events on the provider's own transaction or event ID and using that key as a database constraint, not just an in-memory check.

Why does a unique index prevent duplicate entitlements?

Because it moves the guarantee from application code — which can race — into the database, which can't. A read-then-write check can pass for two concurrent requests before either has committed. A unique index rejects the second write outright, so the upsert either updates the existing row or fails cleanly; it never creates a second one.

Does this race condition affect Google Play Billing and Stripe too?

Yes — it's a pattern, not an Apple-specific bug. Any system with more than one path to the same write (client callback plus server-to-server notification, or webhook plus polling) is exposed to it, regardless of provider.

How do I test for this kind of race condition before it reaches production?

Fire the same webhook payload and the same client request concurrently against a staging resolver and assert exactly one row exists afterward. It rarely shows up in manual testing — it needs genuine concurrency, not two sequential clicks — which is why a unique index as a backstop matters even when your test suite says the code is fine.

Further reading


Related on this site: starting the post-quantum migration covers another Protect-A-Plate production decision; AI writes the code now is about exactly the kind of judgment that catches races like this one before they ship; leaving the cloud is the same build-vs-buy thinking applied to infrastructure. Full context in the Protect-A-Plate case study, or get in touch if your entitlement system has a similar crack in it.