Build1 publisher3 min readPublished
GoVueKit's Stripe handler hands deduplication to the primary key
GoVueKit's Go handler records the event id, answers Stripe in a millisecond, and does the billing work in a background loop. The design closes a race that a SELECT-then-INSERT check leaves open, and it costs one table.
The Engineer · Build desk

What happened
- A dev.to write-up publishes the Stripe webhook handler GoVueKit ships, code first: about twenty lines of Go, one table called processed_events, and a background worker that does the billing writes.
- The handler's only write is an INSERT with ON CONFLICT (event_id) DO NOTHING annotated :execrows, so zero rows inserted tells it Stripe has delivered this event before, and it logs the duplicate and answers 200.
- The request body is read through io.LimitReader with a 1<<20 byte ceiling before anything hashes it, and a read failure returns 400 with "cannot read body".
- Verification hands the untouched bytes to Stripe's webhook.ConstructEventWithOptions with the endpoint secret, and the library checks the HMAC and rejects timestamps older than five minutes.
- A goroutine runs ProcessPending on an interval, taking pending rows oldest first, at most 20 per pass, and skipping any row that already has eight attempts.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- decision A 200 from this endpoint means the event was recorded, not that it was applied, so alerting has to move off HTTP status codes and onto the depth and age of pending rows in processed_events.
- cost Adoption buys you a migration, a generated query and a supervised goroutine, and it turns reconciliation speed into two tuning parameters: the loop interval and the 20-row page size.
- exposure A payment from a Stripe customer with no organization to credit makes processEvent return an error, so that row stays pending and retries while the money sits unapplied until someone maps the customer.
- capability Because the schema avoids engine-specific types, the duplicate-delivery path can be exercised on SQLite in a local test run without standing up PostgreSQL.
Two deliveries of one event can reach two instances in the same millisecond. A handler that checks with SELECT and then writes with INSERT lets both pass the check before either writes. The post is blunt about that: dedup "is the database's job, not a SELECT followed by an INSERT that two concurrent deliveries can both pass" [13]. With event_id as the primary key, one of the two inserts affects one row and the other affects zero, and neither raises an error [25].
The cap on the body read is not a rejection. io.LimitReader stops at 1,048,576 bytes and reports EOF, so an oversized POST is truncated, the HMAC over the truncated bytes does not match, and the caller gets the 400 for an invalid signature [23]. A 2 GB body is roughly 1,900 times the cap [22]. The write-up's reason for the line is that signature verification hashes whatever you read, and one mebibyte is an order of magnitude above any Stripe event, so the cap costs nothing you will notice [8].
IgnoreAPIVersionMismatch: true is the single option passed to ConstructEventWithOptions, and the post defends it on the grounds that the Stripe dashboard lets you pick any release train for an endpoint, so refusing a mismatch would turn a dashboard setting into a silent outage [11]. That defence rests on a property of this handler: nothing beyond identifiers is read out of the payload, and processEvent goes back to the provider with the event id, type and object id to resolve what changed [17]. A handler that reads amounts or nested objects straight from the event does not have that property, and for it the same option converts a version change into a parsing bug [26].
The background loop takes at most 20 rows per pass, oldest first, and skips rows with eight or more attempts [16]. The burst the post cares about is a hundred events arriving during an outage recovery [15]. At 20 a pass, draining a hundred takes five passes, so the worst case delay between Stripe's delivery and your ledger being right is five times the loop interval [21]. The excerpt breaks off inside processEvent, so what happens to a row on its eighth failure is not published [20].
Four failure modes are asserted for the forty-line tutorial handler: double charge on retry, lost payment on restart, a payload an attacker can shape, and a timeout under burst [2]. They describe your handler only if it does its business writes inside the request and can be restarted mid-handler. If you already insert the event id into a unique column before doing any work, you have the dedup property, and what is left is supervising the worker.
One smaller piece of craft: with no STRIPE_SECRET_KEY set, the route answers 501, and the unconfigured path is treated as a tested state instead of a panic [6].
What to watch
- Whether the full published code shows what happens to a processed_events row on its eighth failed attempt.
- Whether the portable migration is actually exercised against both PostgreSQL and SQLite in the kit's test suite.
- Whether IgnoreAPIVersionMismatch stays safe once handlers start reading fields beyond identifiers from the payload.