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 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 handles | Stays yours | How you find out it is not done |
|---|---|---|
| Card data, PCI SAQ-A scope, the payment sheet | Getting the raw request bytes to the signature verifier intact | Every webhook returns 400 starting the minute you deploy |
| Delivering events, retrying failures for three days | Making a repeated delivery change nothing | A buyer gets access, or a receipt email, twice |
| Moving the buyer’s money to the connected account | Moving it back on a refund: reverse_transfer plus refund_application_fee | The platform balance drifts negative after refunds |
| Versioning the API | Pinning the version your parsers are written against | A field your handler reads goes missing after a package bump |
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
- Receive Stripe events in your webhook endpoint
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.
- Create destination charges
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.
- Idempotent requests
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.
- Set a Stripe API version
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.