Oct 30, 20266 min read

How to Handle Stripe Webhooks in a Go API

Verifying the Stripe-Signature header with stripe-go, avoiding the API version mismatch error, acknowledging fast and deduplicating retries, with tested code that deploys to your own AWS or GCP account.

Go makes the Stripe webhook itself short: read the body, call webhook.ConstructEvent, switch on the event type. The failures show up later. A dashboard endpoint on an older API version makes every event fail verification after a library upgrade. A handler that writes to the database inline times out during a spike and Stripe retries, so the same payment is recorded twice. And the retry from Stripe and the retry from your own queue can both deliver the same event.

Stripe's webhook guide gives the shape of the fix: verify against the raw body, return a 2xx before doing complex work, track event ids to catch duplicates and process events asynchronously. This guide implements it in Go for checkout.session.completed with stripe-go v87, Postgres for the records and a queue between the webhook and the work.

Quick start with Encore

Encore combines an open source infrastructure SDK for TypeScript and Go with a platform that provisions and deploys into your own AWS or GCP account. The database, the topic and the webhook secret are declared in the same Go package as the handler, run locally with encore run, and deploy to your own AWS or GCP account. Add the library with go get github.com/stripe/stripe-go/v87. The payments table uses the Stripe event id as its primary key:

-- payments/migrations/1_create_payments.up.sql CREATE TABLE payments ( stripe_event_id TEXT PRIMARY KEY, session_id TEXT NOT NULL, email TEXT, amount_total BIGINT NOT NULL, currency TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );

The webhook is a raw endpoint, so ConstructEvent sees the body exactly as Stripe sent it. It publishes to a Pub/Sub topic and returns 200, and the subscriber does the database work:

// payments/webhook.go package payments import ( "context" "encoding/json" "io" "net/http" "encore.dev/pubsub" "encore.dev/rlog" "encore.dev/storage/sqldb" "github.com/stripe/stripe-go/v87" "github.com/stripe/stripe-go/v87/webhook" ) var db = sqldb.NewDatabase("payments", sqldb.DatabaseConfig{Migrations: "./migrations"}) var secrets struct { StripeWebhookSecret string // whsec_... from the dashboard or stripe listen } type CheckoutCompleted struct { EventID string SessionID string Email string AmountTotal int64 Currency string } var CheckoutCompletedTopic = pubsub.NewTopic[*CheckoutCompleted]("checkout-completed", pubsub.TopicConfig{ DeliveryGuarantee: pubsub.AtLeastOnce, }) // A raw endpoint, because the signature is computed over the exact request bytes. // //encore:api public raw method=POST path=/webhooks/stripe func StripeWebhook(w http.ResponseWriter, req *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, req.Body, 65536)) if err != nil { http.Error(w, "body too large", http.StatusRequestEntityTooLarge) return } event, err := webhook.ConstructEvent(body, req.Header.Get("Stripe-Signature"), secrets.StripeWebhookSecret) if err != nil { rlog.Warn("invalid stripe webhook", "err", err) w.WriteHeader(http.StatusBadRequest) return } if event.Type == stripe.EventTypeCheckoutSessionCompleted || event.Type == stripe.EventTypeCheckoutSessionAsyncPaymentSucceeded { var s stripe.CheckoutSession if err := json.Unmarshal(event.Data.Raw, &s); err != nil { w.WriteHeader(http.StatusBadRequest) return } // Delayed payment methods complete the session before the money arrives. // Record only paid sessions; async_payment_succeeded arrives once the money does. if s.PaymentStatus != stripe.CheckoutSessionPaymentStatusPaid { w.WriteHeader(http.StatusOK) return } msg := &CheckoutCompleted{ EventID: event.ID, SessionID: s.ID, AmountTotal: s.AmountTotal, Currency: string(s.Currency), } if s.CustomerDetails != nil { msg.Email = s.CustomerDetails.Email } if _, err := CheckoutCompletedTopic.Publish(req.Context(), msg); err != nil { // A 500 makes Stripe retry the delivery later. w.WriteHeader(http.StatusInternalServerError) return } } // Acknowledge fast; the subscriber does the database work. w.WriteHeader(http.StatusOK) } var _ = pubsub.NewSubscription(CheckoutCompletedTopic, "record-payment", pubsub.SubscriptionConfig[*CheckoutCompleted]{ Handler: recordPayment, RetryPolicy: &pubsub.RetryPolicy{ MaxRetries: 5, }, }, ) func recordPayment(ctx context.Context, e *CheckoutCompleted) error { // Stripe can send the same event more than once, so dedupe on its id. res, err := db.Exec(ctx, ` INSERT INTO payments (stripe_event_id, session_id, email, amount_total, currency) VALUES ($1, $2, NULLIF($3, ''), $4, $5) ON CONFLICT (stripe_event_id) DO NOTHING`, e.EventID, e.SessionID, e.Email, e.AmountTotal, e.Currency) if err != nil { return err // retried with backoff, then dead-lettered } if res.RowsAffected() == 1 { rlog.Info("payment recorded", "session", e.SessionID, "amount", e.AmountTotal) } return nil }
Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

The handler only verifies and enqueues, so it answers Stripe quickly even when a burst of renewals arrives at once. If publishing fails, it returns a 500 and Stripe retries the delivery. If the database is briefly unavailable, recordPayment returns an error and the message is retried with backoff, then moved to a dead-letter queue, instead of making Stripe retry the whole webhook. The topic is at-least-once and Stripe can also deliver an event twice, so the ON CONFLICT insert keyed on the event id makes both kinds of duplicates harmless.

The webhook secret is a Go secret loaded into the secrets struct. To test locally, set it and forward test events with the Stripe CLI. stripe listen prints the whsec_ secret to use for the local environment:

~/shop
$ encore secret set --type local StripeWebhookSecret$ encore run$ stripe listen --forward-to localhost:4000/webhooks/stripe$ stripe trigger checkout.session.completed

encore run starts Postgres locally and runs the migration, and the subscriber runs in the same process, so a triggered event ends up as a row in payments.

On deploy, Encore provisions the topic as SNS and SQS on AWS or Pub/Sub on GCP, the database as RDS or Cloud SQL, and the secret in the cloud's secret manager, in your own account. Register the production endpoint in the Stripe dashboard and set its signing secret with encore secret set --type prod StripeWebhookSecret.

The API version mismatch error

This is the Go-specific failure. stripe-go pins an API version in stripe.APIVersion, which is 2026-10-28.endive in v87.1.0, and ConstructEvent rejects events rendered with a version from a different release train, even when the signature is valid. The error reads "received event with API version ..., but stripe-go ... expects API version ...".

The fix is to create the webhook endpoint with the API version your stripe-go release expects, and to upgrade the endpoint and the library together. webhook.ConstructEventWithOptions with IgnoreAPIVersionMismatch: true turns the check off, but the library then decodes objects whose shape may differ from its structs, which is the bug the check exists to catch.

Without Encore: net/http, chi or Gin

The handler code carries over to any Go server, since ConstructEvent only needs the bytes, the header and the secret. In plain net/http or chi, the handler above works as it is. In Gin, read c.Request.Body with io.ReadAll before any binding, because c.ShouldBindJSON consumes the body.

The rest is infrastructure: a queue and a dead-letter queue (SQS, or EventBridge, which Stripe can send events to directly), a worker deployment that consumes it, IAM permissions for both, the database and the signing secret in Secrets Manager. That is usually a Terraform module and a second deployable next to the API.

Good to know

  • Each endpoint has its own signing secret, and the test and live versions differ. Store one per environment.
  • ConstructEvent rejects signatures older than webhook.DefaultTolerance, five minutes, so keep server clocks synced.
  • Stripe does not guarantee event order. If you handle several event types for the same object, fetch the current object from the API instead of relying on arrival order.
  • Subscribe the endpoint only to the event types you handle.

How to choose

Use Encore when you want the webhook, queue, database and secret in one Go codebase, with local testing through the Stripe CLI and deploys to your own AWS or GCP account. Wire it up by hand in net/http, chi or Gin when the webhook has to live in an existing server and you already run a queue and workers you can reuse.

Frequently asked questions

Why does webhook.ConstructEvent return an API version error in Go?

stripe-go checks that the event was rendered with an API version on the same release train as the library, which is stripe.APIVersion (2026-10-28.endive in stripe-go v87). If your webhook endpoint in the Stripe dashboard uses an older version, ConstructEvent returns an error even when the signature is valid. Create the endpoint with the API version your stripe-go release expects, or upgrade stripe-go together with the endpoint version.

How do I read the raw request body for Stripe signature verification in Go?

Read r.Body with io.ReadAll before anything decodes it, ideally through http.MaxBytesReader to cap the size, and pass the bytes to webhook.ConstructEvent with the Stripe-Signature header and the endpoint's whsec_ secret. Stripe signs the exact bytes it sent, so a body that has been decoded and re-encoded will not verify.

How do I stop processing the same Stripe event twice in Go?

Store the event id in a table with a unique constraint, for example INSERT ... ON CONFLICT (stripe_event_id) DO NOTHING, and treat a conflict as already processed. Stripe can deliver the same event more than once and retries failed deliveries for up to three days in live mode.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders