Subscribe
← All articles
Sep 18, 2026

One sale, two events: the duplicate your buyer sees

By Built and shut down a subscription platform on Stripe Connect, Telegram Stars and crypto payouts

Two near-identical brass keys lying side by side on a dark grey surface

We built Volta between April and June 2026, a subscription platform for creators selling access to an audience, and retired it in August. The first bug that ever reached a real buyer was not a failed payment. It was a successful one that arrived twice, and sent the same person their download link twice, ninety seconds apart.

Nothing had gone wrong. Every system involved behaved exactly as documented, and that is why some platforms do this to your members and others never will.

One purchase is not one message

A card payment made inside Telegram told us about itself from two directions. Stripe sent a webhook saying the payment intent had succeeded. Telegram sent the bot a successful_payment message saying the same thing. Neither was wrong, neither was a retry of the other, and each one on its own was a perfectly good reason to deliver the product.

Even from a single provider, once is not the guarantee. Stripe's own documentation says plainly that "webhook endpoints might occasionally receive the same event more than once," and that "in some cases, two separate Event objects are generated and sent."[1] Delivery is retried "for up to three days with an exponential back off in live mode," and a human can resend an event by hand for fifteen days after the fact.[1] If your server hiccups for four seconds, the same payment notification will be back.

Order is not promised either. "Stripe doesn't guarantee the delivery of events in the order that they're generated," and the advice that follows is sharper than it first looks: "Don't use created to determine event order or whether you've already processed an event."[1] A cancellation can land before the payment it cancels.

None of this is a defect. A payment network that gave up after one delivery attempt would lose real payments. The duplicates are the price of never missing one, and every platform you might sell through is paying that price somewhere in its code.

What the buyer sees

The failure has three shapes, in ascending order of how much it costs you.

Delivery twice is the mild one. Two emails, two DMs, two identical invoices. Embarrassing, survivable, and the version we shipped.

The expensive one is a second charge. The buyer's card is debited twice for one purchase, and you find out from a support message written in capital letters. This happens often enough that the card networks gave it its own reason code: Stripe files these under a Duplicate category, covering Visa's 12.6.1 "Duplicate processing" and 12.6.2 "Paid by other means," and Mastercard's 4831, which is named "Cardholder Debited More than Once for the Same Goods or Services."[2] If the buyer goes to their bank rather than to you, that is a $15 dispute fee on top of a refund you were always going to owe.[3]

Then there is the one nobody reports: nothing arrives. A platform that guards against duplicates by discarding anything that looks like a repeat will eventually discard a payment that was not a repeat - the member's second month, the second seat they bought, the course they came back and bought again. Over-deduplicating fails silently, which makes it worse than the noisy version.

Checking is not the same as claiming

The instinct is to look before you act: ask the database whether this order has already been delivered, and deliver only if it has not. That is the version that fails, and it fails specifically when two copies of the event arrive close together, which is the only situation it exists for. Both workers ask, both are told no, both deliver.

The fix is to stop asking and start claiming. We put a unique constraint on the pair of (what kind of operation, which thing it is about) and inserted a row before doing any work. The insert either created the row or collided with one already there. Whoever created it owned the job; everyone else read the result the winner had stored and returned it. The database decided, in one statement, and two simultaneous attempts could not both win.

Stripe runs the same pattern from the other side of the wire. Send them an Idempotency-Key header and their system works "by saving the resulting status code and body of the first request made for any given idempotency key, regardless of whether it succeeds or fails. Subsequent requests with the same key return the same result, including 500 errors."[4] Two details on that page catch people out. Keys can be pruned once "they're at least 24 hours old," after which the same key starts a genuinely new request. And reusing a key with changed parameters raises an error instead of quietly overwriting anything.[4]

The key decides what counts as the same thing

Stripe tells you to track event IDs to spot duplicate deliveries,[1] which is exactly right for replays of one event and does nothing for the case we had: the Stripe webhook and the Telegram message were two different events, with two different identifiers, describing one sale. An event-ID check would have passed both and sent the link twice.

So we keyed the delivery on the order instead. One delivery per order, whatever woke the system up, with event-ID deduplication kept underneath as a second layer for genuine replays. The refund command was keyed per buyer per order, so a member tapping it four times raised one request. Each key was a sentence about what "already done" meant for that operation, and we had to write that sentence separately every time, because there is no general answer.

That is the question to put to a platform you sell through, and it is answerable in support chat: is fulfilment keyed to the payment or to the notification? A platform that gets this right can still be told about your sale five times and will deliver once.

The hold on the other side

Deduplication stops the same sale being counted twice. The opposite problem is one sale being counted for too long.

While a payment is pending you are usually holding something for the buyer - a seat, a spot in a cohort, a limited run. We gave that hold a deadline that varied by rail: fifteen minutes for cards and for Telegram Stars, a full hour for crypto. That hour was not caution. A Bitcoin payment can sit unconfirmed for the better part of an hour, and a fifteen-minute hold would have released the seat while the buyer's money was already moving, which is the worst of both outcomes: they paid, and there was nothing left to give them.

Telegram applies a clock in the opposite direction. Its documentation is exact: the bot "must reply using answerPrecheckoutQuery within 10 seconds after receiving this update or the transaction is canceled."[5] Ten seconds is not long enough to do anything careful, so every slow check has to move to after the payment lands, which is precisely when duplicate handling has to be sound. Two smaller things from the same page are worth knowing if you sell on Stars: an invoice sent to a single chat "can only be paid once," though the bot decides whether to accept new payments for an invoice, and the telegram_payment_charge_id from the successful payment has to be stored at the time or you cannot refund later.[5]

When a member says they paid twice

Establish which of the three shapes you have before you do anything. Two charges on the statement is a different incident from one charge and two emails, and the member cannot usually tell you which they have. Ask for the amounts and the times.

If there are genuinely two charges, refund the duplicate yourself, immediately, and say so in the reply. The alternative is that they call their bank and you pay $15 for the same outcome,[3] plus a dispute on your record. Speed also puts you closer to the fee-free reversal window we wrote about in the piece on undoing a sale.

If it was one charge and two deliveries, say that clearly, because a member who thinks they were double-charged and is told only "it's fine" will check their statement anyway and may not believe you the second time. Send them the single charge amount and date.

Then keep the identifiers - both event IDs, the charge ID, the timestamps. If this turns into a dispute in three weeks, those are the only things that will still be true.

Disclosure. The two-sources-one-sale bug, the claim-before-you-work pattern, the per-order delivery key and the per-rail hold durations are from our own build of Volta rather than anyone's documentation, so they carry no citation below. Volta was our product, it is retired, and none of this is a recommendation to use it. Every external quote is cited to the provider's own documentation or pricing page.

Sources

Every external figure in this article is cited to the provider's own published terms, checked 20 September 2026.

  1. 1.Stripe, Receive Stripe events in your webhook endpoint - webhook endpoints might occasionally receive the same event more than once, and in some cases two separate Event objects are generated and sent; delivery is retried for up to three days with exponential backoff in live mode, and an event can be resent by hand for 15 days from the Dashboard or 30 from the CLI; event order is not guaranteed and the created timestamp must not be used to decide whether an event was already processed
  2. 2.Stripe, Dispute reason code categories - the Duplicate category exists across networks - Visa 12.6.1 Duplicate processing and 12.6.2 Paid by other means, Mastercard 4831 Cardholder Debited More than Once for the Same Goods or Services
  3. 3.Stripe, Pricing - $15 dispute received fee, charged whether or not the dispute is contested
  4. 4.Stripe, Idempotent requests - the first result for a key is saved and replayed to later requests with the same key, including 500 errors; keys may be pruned after 24 hours and a reused key then creates a new request; reusing a key with different parameters is an error; POST only
  5. 5.Telegram, Bot Payments API for Digital Goods and Services - the bot must answer pre_checkout_query within 10 seconds or the transaction is cancelled; the telegram_payment_charge_id from SuccessfulPayment must be stored to refund later; an invoice sent to a single chat can only be paid once, but the bot decides whether to accept new payments for an invoice