GoVueKit

Stripe webhooks in Go: the complete idempotent handler, line by line

September 11, 2026

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:

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.