Stripe runs the parts of taking money that are genuinely hard: the card form, the PCI scope, the fraud checks, the retry schedule a bank expects. Buying that is the right call and volta made it. What it does not do is close the gap between how Stripe models a payment and how you assumed it did, and that gap has no SLA on it.

We know where the gap is because we built volta’s payment layer on Stripe Connect and walked into it three times. volta is a platform: a creator connects their Telegram bot, a buyer pays inside Telegram, Stripe takes the money on volta’s account and forwards the creator’s share. Standard marketplace shape, standard SDK. Here is what the SDK still left us holding.

The signature is over bytes your framework already ate

Stripe signs every event and expects you to verify it against the exact raw body of the request. Any change to those bytes before verification makes it fail, and the docs say so directly: if your framework touches the raw body, the check breaks.[1]

The trap is that “touches the raw body” includes a JSON body parser you did not think of as touching anything. Our API runs on Hono, where the request body is a stream you get to read once. A c.req.json() call, or any middleware that does one before the route runs, consumes the stream, and constructEvent then sees an empty body and throws on every single event. The webhook does not fail loudly in your logs first. It fails in the Stripe dashboard, as a wall of 400s that starts the minute you deploy.

webhooks.post('/stripe', async (c) => {
  // read the bytes before anything can parse them
  const raw = new Uint8Array(await c.req.arrayBuffer());
  const sig = c.req.header('stripe-signature');
  const event = stripe.webhooks.constructEvent(raw, sig, secret);
  // ...only now is it safe to look at the body
});
The order that works: raw bytes first, and the route kept off any body-parsing middleware.

The fix is boring once you see it. Finding it means knowing that the verifier needs the wire bytes, not the parsed object, and that your framework’s convenience layer is what stands between them.

The same event arrives twice, and that has to be a non-event

Stripe’s own guidance: an endpoint “might occasionally receive the same event more than once”, and you guard against it by logging the event IDs you have processed and skipping the ones you have seen.[1] The same page tells you not to lean on the created timestamp for this, because two events can share a second and delivery is not ordered. Track the IDs.[1]

So the first thing our handler does with a verified event is insert its ID into a table with a unique constraint and ON CONFLICT DO NOTHING. If the row was already there, the handler acknowledges with a 200 and stops. Everything past that point, the part that grants channel access and sends the buyer their invite, runs off a queue, because Stripe also asks you to return the 2xx before the slow work and to process events asynchronously so a renewal-day spike does not bury your endpoint.[1] The webhook’s job is to record that the event happened and get out.

That handles Stripe replaying an event at Stripe. It does nothing for the second delivery path, which is the one that actually caught us. A completed purchase in volta arrives from two systems: Stripe’s charge.succeeded webhook and, because the checkout runs through Telegram’s payment flow, a successful_payment message from Telegram. Deduplicating Stripe events against Stripe events sees those as unrelated, because they are. The guard that stops a buyer getting two invite links is keyed on the order, not on the event: deliver-once-for-order-X, checked before the delivery runs. Stripe hints at this exact case in the duplicate-events note, where it says two separate Event objects can be generated for one underlying thing and you tell them apart by the object ID plus the event type.[1]

There is a third mechanism underneath both, doing a different job. Every POST we send to Stripe that creates money, a PaymentIntent or a refund, carries an Idempotency-Key tied to the order. Stripe stores the outcome of the first call under that key and replays it on a retry instead of creating a second charge. The keys age out after about 24 hours and the result is only stored once the endpoint starts executing.[3] That covers a dropped connection on the request, not a duplicate that shows up the next day. Three layers, none of them a substitute for another: request idempotency on the way out, event-ID dedupe on the way in, business-key dedupe before any side effect.

Refunding a marketplace charge does not move the money back

This one costs real money if you miss it. A volta sale is a destination charge: Stripe puts the full amount on volta’s balance and immediately transfers the creator’s cut to their connected account.[2] Now the buyer asks for a refund. You call refunds.create, the buyer gets their money, and by default the creator keeps the share that was already transferred to them. volta’s balance goes negative to cover the difference.[2]

To pull the creator’s share back you pass reverse_transfer: true on the refund.[2] And refund_application_fee is not the parameter its name suggests: by default the platform keeps its fee even on a fully refunded sale, and setting the flag true refunds that fee as well. If you do refund the fee on a destination charge, Stripe requires you to reverse the transfer too.[2] So a clean full refund on a marketplace charge is three parameters, and leaving any of them at the default silently changes who is out of pocket.

The part that stays a cost

Even with every flag set correctly, a refund is not free to the platform. Stripe debits volta’s balance for Stripe fees, refunds and chargebacks.[2] On a full refund nobody keeps revenue, and volta still absorbs the card-processing fee on the original sale plus the fees on the refund itself. A chargeback is worse: the disputed amount and the dispute fee come off the platform balance regardless, and recovering it from the creator is a separate transfer reversal you have to write and reconcile.[2] Buying the payment processor does not move that liability; on a marketplace it concentrates it on you.

There is a smaller ongoing cost in the SDK itself. A Stripe SDK calls the API with whatever version was current when that SDK release shipped, unless you set apiVersion yourself, and changing the version changes the shape of the objects you get back.[4] We pin the version explicitly so a routine dependency bump does not quietly restructure the JSON our webhook handlers parse. The price of pinning is that moving it later is a deliberate step with the changelog open, instead of something that rides along with npm update.

What you actually sign up for

Stripe handlesStays yoursHow you find out it is not done
Card data, PCI SAQ-A scope, the payment sheetGetting the raw request bytes to the signature verifier intactEvery webhook returns 400 starting the minute you deploy
Delivering events, retrying failures for three daysMaking a repeated delivery change nothingA buyer gets access, or a receipt email, twice
Moving the buyer’s money to the connected accountMoving it back on a refund: reverse_transfer plus refund_application_feeThe platform balance drifts negative after refunds
Versioning the APIPinning the version your parsers are written againstA field your handler reads goes missing after a package bump
volta's Stripe Connect integration, the seam by line item.

None of this is an argument against Stripe. You do not want to be the entity holding card numbers or answering a card network’s retry schedule, and building that yourself is slower and more dangerous than any bug in this article. The point is narrower: the processor sells you the hard, regulated middle of taking money, and the seam on either side of it is still an integration you own, test and get paged about. Scope it as one.

Sources

  1. Receive Stripe events in your webhook endpoint - Stripe , accessed

    Supports: Stripe requires the unmodified raw request body to verify the Stripe-Signature header, may deliver the same event more than once, tells integrators to track event IDs rather than the created timestamp to identify duplicates, asks endpoints to return a 2xx before running complex logic and to process events on an asynchronous queue, and retries non-2xx deliveries for up to three days with exponential backoff in live mode.

  2. Create destination charges - Stripe , accessed

    Supports: On a refund of a charge carrying transfer_data[destination], the connected account keeps the transferred funds by default and the platform balance covers the refund; reverse_transfer=true pulls the funds back and refund_application_fee=true also refunds the platform fee, and refunding the fee on a destination charge requires reversing the transfer. The platform account is debited for Stripe fees, refunds and chargebacks.

  3. Idempotent requests - Stripe , accessed

    Supports: Every POST accepts an Idempotency-Key; Stripe saves the status and body of the first request under that key and returns the same result on retry, keys can be pruned after 24 hours, and a result is saved only once endpoint execution begins.

  4. Set a Stripe API version - Stripe , accessed

    Supports: A server-side SDK calls the API with the version that was current when that SDK release was published unless apiVersion is set explicitly, and overriding the version changes the shape of the response objects you get back.