Stripe webhooks in Go: the complete idempotent handler, line by line
Every Stripe tutorial in Go shows the same forty lines: read the body, call
ConstructEvent, switch on the type, update the database. Those forty lines
double-charge on a retry, lose a payment on a restart, trust a payload an
attacker can shape, and time out under a burst. This is the handler
GoVueKit ships instead — the actual code, then each design choice and the
failure it closes. It is the pattern we described in
Idempotent billing webhooks; this
article is the implementation.
The shape: receive fast, process later, once
Two moving parts and one table.
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
object_id TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
attempts BIGINT NOT NULL DEFAULT 0,
received_at TIMESTAMP NOT NULL,
processed_at TIMESTAMP
);
CREATE INDEX processed_events_status_idx ON processed_events (status, received_at);
Portable SQL on purpose: TEXT ids, TIMESTAMP, no engine-specific type, so the same migration runs on PostgreSQL and SQLite.
Part one: the HTTP handler
func (h *Billing) Webhook(w http.ResponseWriter, r *http.Request) {
if !h.requireEnabled(w) {
return
}
payload, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
Error(w, http.StatusBadRequest, "cannot read body")
return
}
event, err := h.Svc.VerifyWebhook(payload, r.Header)
if err != nil {
h.Log.Warn("webhook signature rejected", "error", err)
Error(w, http.StatusBadRequest, "invalid signature")
return
}
fresh, err := h.Svc.Enqueue(r.Context(), event)
if err != nil {
h.Log.Error("webhook enqueue", "error", err)
Error(w, http.StatusInternalServerError, "internal error")
return
}
if !fresh {
h.Log.Info("webhook duplicate ignored", "event", event.ID)
}
JSON(w, http.StatusOK, map[string]string{"received": "ok"})
}
Five decisions in twenty lines.
requireEnabled. With no STRIPE_SECRET_KEY the route answers 501,
not a panic. Most deployments start without billing; the unconfigured path
is a first-class, tested state.
io.LimitReader(r.Body, 1<<20). Signature verification hashes the
body. Without a cap, a 2 GB POST is a free CPU bill. One mebibyte is an
order of magnitude above any Stripe event.
Signature over the raw bytes. VerifyWebhook hands the untouched body
to Stripe's webhook.ConstructEventWithOptions with the endpoint secret;
the library checks the HMAC and rejects timestamps older than five
minutes. Decode the JSON first and re-serialize it and the signature will
never match — a classic.
event, err := webhook.ConstructEventWithOptions(body, header.Get("Stripe-Signature"), p.WebhookSecret,
webhook.ConstructEventOptions{IgnoreAPIVersionMismatch: true})
IgnoreAPIVersionMismatch is deliberate: the dashboard lets you pick any
release train for the endpoint, and nothing in the payload is read beyond
identifiers, so the envelope version is irrelevant. Refusing it would turn
a dashboard setting into a silent outage.
Enqueue and the primary key. The handler writes one row and nothing
else:
-- name: InsertEvent :execrows
INSERT INTO processed_events (event_id, event_type, object_id, status, received_at)
VALUES ($1, $2, $3, 'pending', $4)
ON CONFLICT (event_id) DO NOTHING;
:execrows returns the number of rows inserted. Zero means Stripe
delivered this event before; the handler logs it and still answers 200,
because a duplicate is not an error, it is the retry policy working.
Dedup is the database's job, not a SELECT followed by an INSERT that
two concurrent deliveries can both pass.
200 in a millisecond. No Stripe API call, no email, no business write in the request. Stripe retries anything slower than its timeout, and a burst of a hundred events during an outage recovery must not take a hundred round-trips to survive.
Part two: the worker
A goroutine runs ProcessPending on an interval. It lists pending events
(oldest first, at most 20, fewer than 8 attempts) and hands each to
processEvent:
func (s *Service) processEvent(ctx context.Context, ev sqlcgen.ProcessedEvent) error {
change, err := s.Provider.Resolve(ctx, Event{
ID: ev.EventID, Type: ev.EventType, ObjectID: ev.ObjectID,
})
switch {
case errors.Is(err, ErrUnknownEvent):
change = Change{} // acknowledged, nothing to write
case err != nil:
return err
}
if change.Buyer != nil {
return fmt.Errorf("payment from unknown customer %s (%s): no organization to credit",
change.Buyer.CustomerID, change.Buyer.Email)
}
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
qtx := s.Q.WithTx(tx)
if change.Subscription != nil {
if err := qtx.UpdateSubscriptionState(ctx, *change.Subscription); err != nil {
return fmt.Errorf("apply subscription: %w", err)
}
}
if change.Purchase != nil {
if _, err := qtx.InsertPurchase(ctx, *change.Purchase); err != nil {
return fmt.Errorf("apply purchase: %w", err)
}
}
if change.RefundedPaymentRef != "" {
if _, err := qtx.MarkPurchaseRefunded(ctx, sqlcgen.MarkPurchaseRefundedParams{
RefundedAt: sql.NullTime{Time: now(), Valid: true},
PaymentRef: change.RefundedPaymentRef,
}); err != nil {
return fmt.Errorf("apply refund: %w", err)
}
}
if err := qtx.MarkEventDone(ctx, sqlcgen.MarkEventDoneParams{
EventID: ev.EventID, ProcessedAt: sql.NullTime{Time: now(), Valid: true},
}); err != nil {
return fmt.Errorf("mark done: %w", err)
}
if err := tx.Commit(); err != nil {
return err
}
if change.PaymentFailedCustomer != "" {
s.notifyPaymentFailed(ctx, change.PaymentFailedCustomer)
}
if change.DisputedCharge != "" {
s.Log.Error("billing: chargeback opened", "charge", change.DisputedCharge)
}
return nil
}
Resolve re-fetches by ID. The provider takes the event's object id
and asks Stripe for the current subscription or checkout session. The
payload is never the source of truth: it may be stale (two events about the
same subscription arrive out of order), and it is attacker-shaped input by
definition even when the signature is valid. What comes back is a small
Change struct — subscription state, a purchase, a refund reference, a
failed-payment customer, a disputed charge — translated from Stripe's
vocabulary into ours. That translation is the billing.Provider interface,
which is also the seam for a second processor.
Business write and MarkEventDone in one transaction. Either both
land or neither does. A crash between "wrote the purchase" and "marked the
event done" would otherwise replay the purchase on restart; here the replay
finds the event still pending, re-fetches, and the INSERT … ON CONFLICT (checkout_id) DO NOTHING on purchases makes the second application a
no-op anyway. Two layers of idempotency, each cheap.
Unknown customer stays pending. A payment from a customer this database has never seen has nowhere to land. Dropping it would be silent data loss about money; the event stays visible with its attempts count rising until a human decides. After eight attempts the worker stops retrying and the row remains for inspection.
Zero rows on refund is fine. A refund can reference a payment this deployment never recorded (a test-mode leftover, a migration). The event must complete rather than retry forever.
Side effects after the commit. The failed-payment email to the organization's owners goes out only once the state is durable; an email is not worth losing a fulfilled payment. A chargeback logs at error level on purpose, because that reaches the configured error reporter, and a dispute ignored until the deadline is lost money.
Two events every subscription integration forgets
Subscribe the endpoint to checkout.session.async_payment_succeeded and
checkout.session.async_payment_failed next to
checkout.session.completed. A card completes paid; a deferred method
(SEPA debit, which Stripe enables readily on euro prices) completes
unpaid and settles days later as the second event. Listen to the first
alone and every SEPA sale is lost in silence.
What the tests cover
The suite drives the service with a Stripe double and asserts the failure modes above, on PostgreSQL and SQLite in CI:
- a duplicate delivery is a no-op: the second
Enqueueinserts nothing and the object is fetched once; - a replayed fulfilment leaves exactly one purchase;
- a deferred payment (SEPA) is not fulfilled until it settles, and its failure emails the organization's owners;
- a payment from an unknown customer fails the event and leaves it pending, visible;
- a refund marks the purchase once and its replay is acknowledged; a refund of a payment this database never saw, a dispute and an unknown event type are acknowledged without a write;
- with no Stripe key the billing routes answer 501, because the unconfigured deployment is the one most people run.
Locally, stripe listen --forward-to localhost:8080/api/billing/webhook
against the labs stack replays real events against the real binary.
curl -fsSLO https://govuekit.dev/labs/docker-compose.yml
docker compose up -d
The labs runs stripe-mock, which answers the API but emits no webhook; put
real test keys in a .env next to the compose file and the path above
runs end to end.