How to Implement Idempotency Keys in Go with PostgreSQL
Learn to implement idempotency keys in Go with PostgreSQL, step by step, so a retried payment charges the card once, even through races and crashes.
Payment requests travel over networks that drop things. A phone on a train loses signal for a second, or a mobile SDK stops waiting. The request can reach your server, your server can charge the card, and the answer can vanish on the way back. The client sees an error and does the sensible thing: it sends the request again.
Retrying is the right reaction: the client can't tell a request that never arrived from one whose answer got lost. The trouble is on the server, which without an idempotency key can't tell a retry from a new payment. A payment is a POST, and a server that treats every POST as new work charges the card once per attempt.
So the customer taps Pay once, sees one receipt, and finds two charges of 25.00 on their statement. Your logs show two successful payments a second apart, each correct on its own, and the fix is a refund, an apology and possibly a chargeback, a dispute raised through the customer's bank.
The general fix is an idempotency key: a unique value the client generates once per payment and sends with every attempt. The server does the work the first time it sees a key, records the result, and answers every repeat with that same result.
The key needs a home that every copy of your server can reach and that can settle a race between two requests carrying it. If your payments already live in PostgreSQL, a table with a primary key on the idempotency key does both.
In this tutorial, you'll implement idempotency keys in Go with PostgreSQL for a small payments API, break each version on purpose, and load-test the result against a fake card provider that keeps its own ledger. It's a sequel to Distributed Locks in Redis with Go. Everything was tested with Go 1.27.2, pgx v5.11.0 and PostgreSQL 18.6.
Prerequisites#
To follow along, you need:
- Go 1.25 or later, which pgx v5.11.0, the PostgreSQL driver, requires. The code also uses
crypto/rand.Text(Go 1.24) and method patterns such as"POST /payments"inhttp.HandleFunc, which arrived in Go 1.22. - Docker, to run PostgreSQL locally.
- Working knowledge of
net/http,context.Contextand SQL. No payments experience is needed. - Ports 5432, 8080 and 8090 free, and three terminal windows open in the project directory.
Step 1: Set Up a Fake Card Provider and a Checkout Client#
This step builds a fake card provider on port 8090 and a checkout client that retries like a phone. The provider and the API each keep a ledger, because the bug lives in the gap between what the shop believes it charged and what the card was actually charged.
Start PostgreSQL and wait until it reports accepting connections:
docker run -d --name pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:18
docker exec pg pg_isready -U postgres
/var/run/postgresql:5432 - accepting connections
The fallback password is for this container only
The API reads DATABASE_URL and the provider reads PROVIDER_DATABASE_URL. When they're unset, both fall back to this container's postgres:postgres URL, so every command here runs as shown. In a real deployment, set the variables from a secret store.
Create one database per ledger:
docker exec pg psql -U postgres -c "CREATE DATABASE shop" -c "CREATE DATABASE provider"
CREATE DATABASE
CREATE DATABASE
Create the Go module and add pgx:
mkdir payments && cd payments
go mod init example.com/payments
go get github.com/jackc/pgx/v5@v5.11.0
go: creating new go.mod: module example.com/payments
go: to add module requirements and sums:
go mod tidy
go: added github.com/jackc/pgpassfile v1.0.0
go: added github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761
go: added github.com/jackc/pgx/v5 v5.11.0
go: added golang.org/x/text v0.29.0
In the shop's schema, charges is the ledger and idempotency_keys holds one row per key. Its primary key is (customer_id, key), so two customers who send the same key never see each other's payments.
-- The shop's own ledger: one row per payment it believes it took.
CREATE TABLE charges (
id bigserial PRIMARY KEY,
customer_id text NOT NULL,
amount bigint NOT NULL CHECK (amount > 0),
provider_id text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- One row per idempotency key: who claimed it, for which request, and what we answered.
CREATE TABLE idempotency_keys (
customer_id text NOT NULL,
key text NOT NULL,
request_hash bytea NOT NULL,
status text NOT NULL DEFAULT 'started',
response_code int,
response_body bytea,
locked_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (customer_id, key)
);
The provider's table is its record of what it actually charged. Its idempotency_key column is UNIQUE, and because a UNIQUE column treats NULLs as distinct by default, a charge sent without a key never conflicts with anything:
-- The fake card processor's own records: what it actually charged.
CREATE TABLE card_charges (
id text PRIMARY KEY DEFAULT 'ch_' || substr(md5(random()::text), 1, 10),
idempotency_key text UNIQUE,
card text NOT NULL,
amount bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
Load both schemas:
docker exec -i pg psql -U postgres -d shop < schema.sql
docker exec -i pg psql -U postgres -d provider < provider.sql
CREATE TABLE
CREATE TABLE
CREATE TABLE
The fake provider takes one second per charge and declines the test card tok_declined with 402 Payment Required. With an Idempotency-Key, it behaves like Stripe: it takes a PostgreSQL advisory lock (a lock PostgreSQL holds on a number, here a hash of the key, until the transaction ends), answers 409 if another request with the same key is still running, and returns the original charge if the key was already used.
// Command provider is a stand-in for a card processor such as Stripe or Adyen.
// It charges slowly, declines one test card, and honors an Idempotency-Key.
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type chargeRequest struct {
Amount int64 `json:"amount"`
Card string `json:"card"`
}
func main() {
url := os.Getenv("PROVIDER_DATABASE_URL")
if url == "" {
url = "postgres://postgres:postgres@localhost:5432/provider?pool_max_conns=50" // the local Docker container; one connection per charge in flight
}
db, err := pgxpool.New(context.Background(), url)
if err != nil {
log.Fatal(err)
}
http.HandleFunc("POST /charges", func(w http.ResponseWriter, r *http.Request) {
var req chargeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
ctx := r.Context()
tx, err := db.Begin(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer tx.Rollback(ctx)
// Like Stripe, answer 409 while another request with the same key is running,
// and return the original charge once it has finished.
var key *string
if k := r.Header.Get("Idempotency-Key"); k != "" {
key = &k
var locked bool
tx.QueryRow(ctx, `SELECT pg_try_advisory_xact_lock(hashtext($1))`, k).Scan(&locked)
if !locked {
writeJSON(w, http.StatusConflict, map[string]string{"error": "request_in_progress"})
return
}
var id string
if tx.QueryRow(ctx, `SELECT id FROM card_charges WHERE idempotency_key = $1`, k).Scan(&id) == nil {
writeJSON(w, http.StatusCreated, map[string]string{"id": id})
return
}
}
time.Sleep(time.Second) // the card network is slow
if req.Card == "tok_declined" {
writeJSON(w, http.StatusPaymentRequired, map[string]string{"error": "card_declined"})
return
}
// Without a key (NULL), nothing conflicts: every call is a new charge.
var id string
err = tx.QueryRow(ctx, `
INSERT INTO card_charges (idempotency_key, card, amount)
VALUES ($1, $2, $3)
RETURNING id`, key, req.Card, req.Amount).Scan(&id)
if err == nil {
err = tx.Commit(ctx)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, map[string]string{"id": id})
})
log.Println("provider listening on :8090")
log.Fatal(http.ListenAndServe(":8090", nil))
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
payments/server.go holds the API's shared pieces. A payment carries an amount in cents and a card token, a string the provider's card form issues so your server never sees a card number. customerID reads a header as a stand-in for authentication; a real API answers 401 when there's no authenticated customer and caps the body with http.MaxBytesReader.
// Package payments is a small payments API that charges a card through a provider
// and records each payment in PostgreSQL.
package payments
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// PaymentRequest is what the checkout page sends. Amount is in cents; Card is a
// token from the provider's card form, never a card number.
type PaymentRequest struct {
Amount int64 `json:"amount"`
Card string `json:"card"`
}
// Payment is what the API returns for a successful charge.
type Payment struct {
ID int64 `json:"id"`
Amount int64 `json:"amount"`
ProviderID string `json:"provider_charge_id"`
}
// Server holds the API's dependencies.
type Server struct {
DB *pgxpool.Pool
Provider *Provider
}
// ErrDeclined means the provider refused the card. That is a final answer, not a fault.
var ErrDeclined = errors.New("card declined")
// ErrInProgress means the provider is still working on a charge with the same key.
var ErrInProgress = errors.New("charge in progress at the provider")
// Provider is a client for the card processor.
type Provider struct {
URL string
HTTP *http.Client
}
// Charge asks the provider to take amount from card. A non-empty key is sent as
// the provider's Idempotency-Key, so repeating the call cannot charge twice.
func (p *Provider) Charge(ctx context.Context, amount int64, card, key string) (string, error) {
body, _ := json.Marshal(PaymentRequest{Amount: amount, Card: card})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.URL+"/charges", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
if key != "" {
req.Header.Set("Idempotency-Key", key)
}
resp, err := p.HTTP.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusCreated:
var out struct{ ID string }
err := json.NewDecoder(resp.Body).Decode(&out)
return out.ID, err
case http.StatusPaymentRequired:
return "", ErrDeclined
case http.StatusConflict:
return "", ErrInProgress
default:
return "", fmt.Errorf("provider returned %s", resp.Status)
}
}
// NewProvider returns a client whose timeout bounds every provider call.
func NewProvider(url string) *Provider {
return &Provider{URL: url, HTTP: &http.Client{Timeout: 5 * time.Second}}
}
// customerID stands in for authentication: a real API takes it from the session or token.
func customerID(r *http.Request) string {
return r.Header.Get("Customer-ID")
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
func decode(r *http.Request) (PaymentRequest, error) {
var req PaymentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return req, err
}
if req.Amount <= 0 || req.Card == "" {
return req, errors.New("amount and card are required")
}
return req, nil
}
The checkout client plays the phone. Its first attempt gives up after 600 ms; later attempts wait 3 seconds, with a 300 ms backoff that doubles, for up to 4 attempts. -key sends one key per payment (the default), one per attempt (new) or none, and -parallel fires identical copies at once.
// Command pay is the checkout client: it sends one payment and retries the way a
// mobile app does when the network is slow.
package main
import (
"bytes"
"crypto/rand"
"flag"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
func main() {
amount := flag.Int("amount", 2500, "amount in cents")
card := flag.String("card", "tok_visa", "card token")
keyMode := flag.String("key", "once", "once: one key per payment; new: a new key per attempt; none: no key")
keyValue := flag.String("key-value", "", "use this key instead of a random one")
firstTimeout := flag.Duration("first-timeout", 600*time.Millisecond, "the first attempt gives up after this long (the phone loses signal)")
timeout := flag.Duration("timeout", 3*time.Second, "how long later attempts wait for an answer")
attempts := flag.Int("attempts", 4, "attempts before giving up")
parallel := flag.Int("parallel", 1, "send this many copies at the same moment")
flag.Parse()
key := *keyValue
if key == "" {
key = rand.Text() // 26 random characters from crypto/rand
}
body := fmt.Sprintf(`{"amount": %d, "card": %q}`, *amount, *card)
send := func(attempt int) (int, string, error) {
req, _ := http.NewRequest(http.MethodPost, "http://localhost:8080/payments", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Customer-ID", "cus_1001")
switch *keyMode {
case "once":
req.Header.Set("Idempotency-Key", key)
case "new":
req.Header.Set("Idempotency-Key", rand.Text())
}
wait := *timeout
if attempt == 1 {
wait = *firstTimeout
}
resp, err := (&http.Client{Timeout: wait}).Do(req)
if err != nil {
return 0, "", err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
extra := ""
if resp.Header.Get("Idempotent-Replayed") == "true" {
extra = " (replayed)"
}
return resp.StatusCode, string(bytes.TrimSpace(b)) + extra, nil
}
// One payment, retried with exponential backoff until it gets a final answer.
pay := func(id int) {
backoff := 300 * time.Millisecond
for attempt := 1; attempt <= *attempts; attempt++ {
code, msg, err := send(attempt)
prefix := fmt.Sprintf("attempt %d:", attempt)
if *parallel > 1 {
prefix = fmt.Sprintf("copy %d %s", id, prefix)
}
switch {
case err != nil:
fmt.Println(prefix, "no answer:", shorten(err))
case code == http.StatusConflict || code >= 500:
fmt.Println(prefix, code, msg)
default:
fmt.Println(prefix, code, msg)
return
}
time.Sleep(backoff)
backoff *= 2
}
}
var wg sync.WaitGroup
for i := 1; i <= *parallel; i++ {
wg.Add(1)
go func() { defer wg.Done(); pay(i) }()
}
wg.Wait()
}
func shorten(err error) string {
if strings.Contains(err.Error(), "Client.Timeout") {
return "timed out"
}
return err.Error()
}
pgx's connection pool needs two modules that go get didn't record, so let Go fill them in before the first build:
go mod tidy
In terminal 1, start the provider and leave it running. It logs provider listening on :8090:
go run ./cmd/provider
Between steps, you'll empty both ledgers with these two commands:
docker exec pg psql -U postgres -d shop -c "TRUNCATE charges, idempotency_keys RESTART IDENTITY"
docker exec pg psql -U postgres -d provider -c "TRUNCATE card_charges"
Step 2: Reproduce the Double Charge#
This step builds the handler most payment endpoints start as. Create it:
package payments
import (
"context"
"errors"
"net/http"
)
// CreatePaymentNaive charges the card and records the payment. Every call charges again.
func (s *Server) CreatePaymentNaive(w http.ResponseWriter, r *http.Request) {
req, err := decode(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Finish the payment even if the caller hangs up: a charge must not be half done.
ctx := context.WithoutCancel(r.Context())
providerID, err := s.Provider.Charge(ctx, req.Amount, req.Card, "")
if errors.Is(err, ErrDeclined) {
writeError(w, http.StatusPaymentRequired, "card declined")
return
}
if err != nil {
writeError(w, http.StatusBadGateway, "payment provider unavailable")
return
}
var p Payment
err = s.DB.QueryRow(ctx, `
INSERT INTO charges (customer_id, amount, provider_id)
VALUES ($1, $2, $3)
RETURNING id, amount, provider_id`,
customerID(r), req.Amount, providerID).Scan(&p.ID, &p.Amount, &p.ProviderID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not record payment")
return
}
writeJSON(w, http.StatusCreated, p)
}
One line matters before you run it. net/http cancels a request's context when the client's connection closes. A call cancelled mid-charge leaves you not knowing whether the card was charged, so context.WithoutCancel keeps the context's values but drops the cancellation, and the server finishes what it started. That's right for a payment, and it's also what makes the retry dangerous: the first attempt keeps going after the client has moved on.
The API command is a teaching rig: one binary that serves a version of the handler picked with -mode. This first version knows only the naive one; Steps 3 and 4 add the others.
// Command api serves POST /payments in one of three versions, chosen with -mode.
package main
import (
"context"
"flag"
"log"
"net/http"
"os"
"time"
"example.com/payments/payments"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
mode := flag.String("mode", "idempotent", "naive, checkfirst or idempotent")
flag.Parse()
db, err := pgxpool.New(context.Background(), env("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/shop"))
if err != nil {
log.Fatal(err)
}
s := &payments.Server{DB: db, Provider: payments.NewProvider("http://localhost:8090")}
var h http.HandlerFunc
switch *mode {
case "naive":
h = s.CreatePaymentNaive
default:
log.Fatalf("unknown -mode %q", *mode)
}
http.HandleFunc("POST /payments", h)
srv := &http.Server{Addr: ":8080", ReadHeaderTimeout: 5 * time.Second}
log.Printf("api (%s) listening on :8080", *mode)
log.Fatal(srv.ListenAndServe())
}
// env returns the variable's value, or a default that suits the local Docker container.
func env(name, fallback string) string {
if v := os.Getenv(name); v != "" {
return v
}
return fallback
}
In terminal 2, start the API in naive mode:
go run ./cmd/api -mode naive
In terminal 3, pay once with no idempotency key. Expect the first attempt to time out and the retry to succeed:
go run ./cmd/pay -key none
attempt 1: no answer: timed out
attempt 2: 201 {"id":2,"amount":2500,"provider_charge_id":"ch_abd3fed44e"}
From the customer's side, this is one payment: a spinner, then a receipt. Now look at both ledgers:
docker exec pg psql -U postgres -d shop -c "SELECT id, amount, provider_id FROM charges"
docker exec pg psql -U postgres -d provider -c "SELECT id, idempotency_key, amount FROM card_charges"
id | amount | provider_id
----+--------+---------------
1 | 2500 | ch_d03cc74dad
2 | 2500 | ch_abd3fed44e
id | idempotency_key | amount
---------------+-----------------+--------
ch_d03cc74dad | | 2500
ch_abd3fed44e | | 2500
The customer saw one success and was charged twice. The receipt gives it away: payment id 2, because row 1 came from the attempt the phone gave up on. Nothing in either ledger marks the rows as the same payment.
The server did exactly what HTTP allows. POST isn't idempotent, meaning sending it twice isn't guaranteed to have the same effect as sending it once, and RFC 9110 says so in as many words:
Of the request methods defined by this specification, PUT, DELETE, and safe request methods are idempotent.
A client SHOULD NOT automatically retry a request with a non-idempotent method unless it has some means to know that the request semantics are actually idempotent, regardless of the method, or some means to detect that the original request was never applied.
The client broke that rule, but refusing to retry would strand customers on every dropped connection. An idempotency key gives it "some means to know".
Step 3: Add an Idempotency Key and Check It First#
This step adds the fix most people write first: look the key up, and charge only if it's new. The client already sends a key by default, 26 random characters from rand.Text, generated once per payment and repeated on every attempt. Create the check-first handler:
package payments
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/jackc/pgx/v5"
)
// CreatePaymentCheckFirst looks the key up, and only charges if it has never seen it.
// The lookup and the save are separate steps, so two requests can both miss.
func (s *Server) CreatePaymentCheckFirst(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("Idempotency-Key")
if key == "" {
writeError(w, http.StatusBadRequest, "Idempotency-Key header is required")
return
}
req, err := decode(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
ctx := context.WithoutCancel(r.Context())
// 1. Have we answered this key before? If so, send the same answer again.
var code int
var body []byte
err = s.DB.QueryRow(ctx, `
SELECT response_code, response_body FROM idempotency_keys
WHERE customer_id = $1 AND key = $2`, customerID(r), key).Scan(&code, &body)
if err == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
w.Write(body)
return
}
if !errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusInternalServerError, "could not read idempotency key")
return
}
// 2. New key: charge and record the payment.
providerID, err := s.Provider.Charge(ctx, req.Amount, req.Card, "")
if err != nil {
writeError(w, http.StatusBadGateway, "payment provider unavailable")
return
}
var p Payment
err = s.DB.QueryRow(ctx, `
INSERT INTO charges (customer_id, amount, provider_id)
VALUES ($1, $2, $3)
RETURNING id, amount, provider_id`,
customerID(r), req.Amount, providerID).Scan(&p.ID, &p.Amount, &p.ProviderID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not record payment")
return
}
// 3. Remember the answer for next time.
body, _ = json.Marshal(p)
_, err = s.DB.Exec(ctx, `
INSERT INTO idempotency_keys (customer_id, key, request_hash, status, response_code, response_body)
VALUES ($1, $2, '', 'completed', 201, $3)
ON CONFLICT DO NOTHING`, customerID(r), key, body)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not save idempotency key")
return
}
writeJSON(w, http.StatusCreated, p)
}
It reads well, but the provider's one-second charge sits between step 1, the lookup, and step 3, the save. Add its case to the switch in cmd/api/main.go, under the naive case:
case "checkfirst":
h = s.CreatePaymentCheckFirst
In terminal 2, stop the API with Ctrl+C and start it in check-first mode. Reset both ledgers with the two TRUNCATE commands from Step 1, then pay with the default key behavior:
go run ./cmd/api -mode checkfirst
go run ./cmd/pay
attempt 1: no answer: timed out
attempt 2: 201 {"id":2,"amount":2500,"provider_charge_id":"ch_0ee40e9309"}
id 2 again, and both ledgers hold two rows, as in Step 2. The key table holds one row, because the second save hit ON CONFLICT DO NOTHING:
docker exec pg psql -U postgres -d shop -c "SELECT key, status, response_code FROM idempotency_keys"
key | status | response_code
----------------------------+-----------+---------------
G2D7ZUOEGVRORUF253FGPBOFKF | completed | 201
(1 row)
The retry arrived at about 0.9 s (600 ms timeout plus 300 ms backoff), while the first request was at the provider until about 1.0 s and hadn't saved the key, so the lookup found nothing:
The retry looks the key up while the first request is still at the provider. Both lookups miss, and both requests charge.
This is a check-then-act race: a decision based on a read that's stale by the time you act on it. The key is only checked, never claimed, and ownership has to come from the one component that sees every request in order: the database.
Step 4: Claim the Idempotency Key Atomically#
This step makes the first request to arrive own the key before it does any work. Create payments/idempotent.go from the collapsed block; this step explains the claim and the stored answer, Steps 5 and 6 the rest.
payments/idempotent.go (the full file)
package payments
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// Options switch on the protections one at a time, so each step of the tutorial
// can show what the previous version gets wrong.
type Options struct {
Lease time.Duration // 0: a started key is never taken over
PassKeyToCharge bool // send our key to the provider as its Idempotency-Key
CrashAfterCharge bool // exit after the provider charges, before we record it
}
type keyRecord struct {
status string
hash []byte
lockedAt time.Time // when this request claimed the key; proves it still owns it
}
// fingerprint identifies the request a key was first used with. Hashing the decoded
// struct, not the raw bytes, ignores whitespace and field order.
func fingerprint(r *http.Request, req PaymentRequest) []byte {
canonical, _ := json.Marshal(req)
sum := sha256.Sum256(append([]byte(r.Method+" "+r.URL.Path+"\n"), canonical...))
return sum[:]
}
// claim tries to make this request the owner of the key. It returns true, and the
// claim's locked_at, if this request should do the work. Otherwise it returns the
// key's current record.
func (s *Server) claim(ctx context.Context, customer, key string, hash []byte, opt Options) (bool, keyRecord, error) {
var rec keyRecord
var err error
if opt.Lease == 0 {
err = s.DB.QueryRow(ctx, `
INSERT INTO idempotency_keys (customer_id, key, request_hash)
VALUES ($1, $2, $3)
ON CONFLICT (customer_id, key) DO NOTHING
RETURNING locked_at`, customer, key, hash).Scan(&rec.lockedAt)
} else {
// Also take over a key whose owner started long ago and never finished.
err = s.DB.QueryRow(ctx, `
INSERT INTO idempotency_keys (customer_id, key, request_hash)
VALUES ($1, $2, $3)
ON CONFLICT (customer_id, key) DO UPDATE SET locked_at = now()
WHERE idempotency_keys.status = 'started'
AND idempotency_keys.request_hash = EXCLUDED.request_hash
AND idempotency_keys.locked_at < now() - make_interval(secs => $4)
RETURNING locked_at`, customer, key, hash, opt.Lease.Seconds()).Scan(&rec.lockedAt)
}
if err == nil {
return true, rec, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return false, keyRecord{}, err
}
// Someone else holds the key. Read what they have so far.
err = s.DB.QueryRow(ctx, `
SELECT status, request_hash FROM idempotency_keys
WHERE customer_id = $1 AND key = $2`,
customer, key).Scan(&rec.status, &rec.hash)
if errors.Is(err, pgx.ErrNoRows) {
// Released a moment ago: report it as in progress so the client retries.
rec.status, rec.hash = "started", hash
return false, rec, nil
}
return false, rec, err
}
// CreatePayment charges at most once per Idempotency-Key and replays the first answer.
func (s *Server) CreatePayment(opt Options) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
customer := customerID(r)
key := idempotencyKey(r)
if key == "" || len(key) > 255 {
writeError(w, http.StatusBadRequest, "Idempotency-Key header is required (at most 255 characters)")
return
}
req, err := decode(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
hash := fingerprint(r, req)
ctx := context.WithoutCancel(r.Context())
claimed, rec, err := s.claim(ctx, customer, key, hash, opt)
switch {
case err != nil:
writeError(w, http.StatusInternalServerError, "could not claim idempotency key")
return
case claimed:
// This request owns the key; carry on below.
case !bytes.Equal(rec.hash, hash):
writeError(w, http.StatusUnprocessableEntity, "Idempotency-Key was already used with a different request")
return
case rec.status == "completed":
s.replay(ctx, w, customer, key)
return
default:
w.Header().Set("Retry-After", "1")
writeError(w, http.StatusConflict, "a request with this Idempotency-Key is still in progress")
return
}
chargeKey := ""
if opt.PassKeyToCharge {
// Derived from our key, so every retry and takeover sends the same one.
// Hashed to 64 hex characters to fit providers' length limits.
sum := sha256.Sum256([]byte(customer + ":" + key))
chargeKey = hex.EncodeToString(sum[:])
}
providerID, err := s.Provider.Charge(ctx, req.Amount, req.Card, chargeKey)
if errors.Is(err, ErrDeclined) {
// A decline is a final answer: store it so a retry gets the same one.
body, _ := json.Marshal(map[string]string{"error": "card declined"})
err := s.finish(ctx, customer, key, http.StatusPaymentRequired, body, nil)
if errors.Is(err, errLostKey) {
s.replay(ctx, w, customer, key)
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "could not save result")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusPaymentRequired)
w.Write(body)
return
}
if errors.Is(err, ErrInProgress) {
// The provider is still charging under this key (an earlier claim we took
// over is mid-call). Not a fault: keep the key and let the client retry.
w.Header().Set("Retry-After", "1")
writeError(w, http.StatusConflict, "a request with this Idempotency-Key is still in progress")
return
}
if err != nil {
// A fault is not an answer. Release the key so a retry can run the charge
// again, but only if it is still our claim and not a later takeover's.
s.DB.Exec(ctx, `
DELETE FROM idempotency_keys
WHERE customer_id = $1 AND key = $2
AND status = 'started' AND locked_at = $3`, customer, key, rec.lockedAt)
writeError(w, http.StatusBadGateway, "payment provider unavailable")
return
}
if opt.CrashAfterCharge {
os.Exit(1) // simulate a deploy or OOM kill at the worst moment
}
var p Payment
record := func(tx pgx.Tx) ([]byte, error) {
err := tx.QueryRow(ctx, `
INSERT INTO charges (customer_id, amount, provider_id)
VALUES ($1, $2, $3)
RETURNING id, amount, provider_id`,
customer, req.Amount, providerID).Scan(&p.ID, &p.Amount, &p.ProviderID)
if err != nil {
return nil, err
}
return json.Marshal(p)
}
err = s.finish(ctx, customer, key, http.StatusCreated, nil, record)
if errors.Is(err, errLostKey) {
s.replay(ctx, w, customer, key) // a takeover finished first; answer as it did
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "could not record payment")
return
}
writeJSON(w, http.StatusCreated, p)
}
}
// errLostKey means another request finished this key first.
var errLostKey = errors.New("idempotency key already finished")
// finish saves the answer for a key, and the payment row when there is one, in one
// transaction. Only a request that still sees the key as started may finish it.
func (s *Server) finish(ctx context.Context, customer, key string, code int, body []byte, record func(pgx.Tx) ([]byte, error)) error {
tx, err := s.DB.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var status string
err = tx.QueryRow(ctx, `
SELECT status FROM idempotency_keys
WHERE customer_id = $1 AND key = $2
FOR UPDATE`, customer, key).Scan(&status)
if err != nil {
return err
}
if status != "started" {
return errLostKey
}
if record != nil {
if body, err = record(tx); err != nil {
return err
}
}
_, err = tx.Exec(ctx, `
UPDATE idempotency_keys
SET status = 'completed', response_code = $3, response_body = $4
WHERE customer_id = $1 AND key = $2`, customer, key, code, body)
if err != nil {
return err
}
return tx.Commit(ctx)
}
// replay sends the stored answer for a finished key.
func (s *Server) replay(ctx context.Context, w http.ResponseWriter, customer, key string) {
var code int
var body []byte
err := s.DB.QueryRow(ctx, `
SELECT response_code, response_body FROM idempotency_keys
WHERE customer_id = $1 AND key = $2`, customer, key).Scan(&code, &body)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not read stored result")
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Idempotent-Replayed", "true")
w.WriteHeader(code)
w.Write(body)
}
// idempotencyKey reads the header. The IETF draft sends it as a quoted string,
// most providers send it bare; accept both.
func idempotencyKey(r *http.Request) string {
k := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if len(k) >= 2 && k[0] == '"' && k[len(k)-1] == '"' {
k = k[1 : len(k)-1]
}
return k
}
Then replace cmd/api/main.go with its final version, which defaults to the idempotent handler and adds three flags for Step 6:
// Command api serves POST /payments in one of three versions, chosen with -mode.
package main
import (
"context"
"flag"
"log"
"net/http"
"os"
"time"
"example.com/payments/payments"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
mode := flag.String("mode", "idempotent", "naive, checkfirst or idempotent")
lease := flag.Duration("lease", 0, "take over a started key after this long (0 = never)")
passKey := flag.Bool("pass-key", false, "send the idempotency key to the provider")
crash := flag.Bool("crash-after-charge", false, "exit after the provider charges")
flag.Parse()
db, err := pgxpool.New(context.Background(), env("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/shop"))
if err != nil {
log.Fatal(err)
}
s := &payments.Server{DB: db, Provider: payments.NewProvider("http://localhost:8090")}
var h http.HandlerFunc
switch *mode {
case "naive":
h = s.CreatePaymentNaive
case "checkfirst":
h = s.CreatePaymentCheckFirst
case "idempotent":
h = s.CreatePayment(payments.Options{Lease: *lease, PassKeyToCharge: *passKey, CrashAfterCharge: *crash})
default:
log.Fatalf("unknown -mode %q", *mode)
}
http.HandleFunc("POST /payments", h)
srv := &http.Server{Addr: ":8080", ReadHeaderTimeout: 5 * time.Second}
log.Printf("api (%s) listening on :8080", *mode)
log.Fatal(srv.ListenAndServe())
}
// env returns the variable's value, or a default that suits the local Docker container.
func env(name, fallback string) string {
if v := os.Getenv(name); v != "" {
return v
}
return fallback
}
PostgreSQL's INSERT ... ON CONFLICT does the check and the claim in one statement, arbitrated by the primary key. With DO NOTHING, a conflicting row isn't inserted, and RETURNING reports only what changed:
Only rows that were successfully inserted or updated will be returned.
So the claim returns the new key's locked_at if this request inserted it, and zero rows if another request got there first. In pgx, zero rows shows up as pgx.ErrNoRows from Scan, the "lost the claim" signal:
// claim tries to make this request the owner of the key. It returns true, and the
// claim's locked_at, if this request should do the work. Otherwise it returns the
// key's current record.
func (s *Server) claim(ctx context.Context, customer, key string, hash []byte, opt Options) (bool, keyRecord, error) {
var rec keyRecord
var err error
if opt.Lease == 0 {
err = s.DB.QueryRow(ctx, `
INSERT INTO idempotency_keys (customer_id, key, request_hash)
VALUES ($1, $2, $3)
ON CONFLICT (customer_id, key) DO NOTHING
RETURNING locked_at`, customer, key, hash).Scan(&rec.lockedAt)
} else {
// Also take over a key whose owner started long ago and never finished.
err = s.DB.QueryRow(ctx, `
INSERT INTO idempotency_keys (customer_id, key, request_hash)
VALUES ($1, $2, $3)
ON CONFLICT (customer_id, key) DO UPDATE SET locked_at = now()
WHERE idempotency_keys.status = 'started'
AND idempotency_keys.request_hash = EXCLUDED.request_hash
AND idempotency_keys.locked_at < now() - make_interval(secs => $4)
RETURNING locked_at`, customer, key, hash, opt.Lease.Seconds()).Scan(&rec.lockedAt)
}
if err == nil {
return true, rec, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return false, keyRecord{}, err
}
// Someone else holds the key. Read what they have so far.
err = s.DB.QueryRow(ctx, `
SELECT status, request_hash FROM idempotency_keys
WHERE customer_id = $1 AND key = $2`,
customer, key).Scan(&rec.status, &rec.hash)
if errors.Is(err, pgx.ErrNoRows) {
// Released a moment ago: report it as in progress so the client retries.
rec.status, rec.hash = "started", hash
return false, rec, nil
}
return false, rec, err
}
The else branch adds a lease for Step 6. If the key vanishes between the two queries (released after a fault, as in Step 5), the request reports it as in progress so the client retries. The claim runs in its own implicit transaction, so it commits the moment the statement finishes.
That's deliberate. If the claim and the charge shared one transaction, a retry's INSERT would wait on the unique index for the whole provider call, holding a database connection, and never get its 409. Commit the claim on its own and keep the slow work outside any transaction.
The handler acts on what claim reports:
// CreatePayment charges at most once per Idempotency-Key and replays the first answer.
func (s *Server) CreatePayment(opt Options) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
customer := customerID(r)
key := idempotencyKey(r)
if key == "" || len(key) > 255 {
writeError(w, http.StatusBadRequest, "Idempotency-Key header is required (at most 255 characters)")
return
}
req, err := decode(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
hash := fingerprint(r, req)
ctx := context.WithoutCancel(r.Context())
claimed, rec, err := s.claim(ctx, customer, key, hash, opt)
switch {
case err != nil:
writeError(w, http.StatusInternalServerError, "could not claim idempotency key")
return
case claimed:
// This request owns the key; carry on below.
case !bytes.Equal(rec.hash, hash):
writeError(w, http.StatusUnprocessableEntity, "Idempotency-Key was already used with a different request")
return
case rec.status == "completed":
s.replay(ctx, w, customer, key)
return
default:
w.Header().Set("Retry-After", "1")
writeError(w, http.StatusConflict, "a request with this Idempotency-Key is still in progress")
return
}
What claim reports |
Response |
|---|---|
| This request owns the key | Charge the card and record the payment |
| Key exists with a different request | 422 Unprocessable Content (Step 5) |
Key exists and is completed |
Replay the stored status and body |
Key exists and is started |
409 Conflict with Retry-After: 1 |
Retry-After is a standard HTTP header that tells the client how many seconds to wait. The codes follow the Idempotency-Key header draft from the IETF, the body that publishes internet standards: 400 for a missing key, 422 for a key reused with a different payload, and 409 when "A request is outstanding for this Idempotency-Key". The draft expired in April 2026 without becoming an RFC, so treat it as a convention. It also says a retry of a completed request gets the previous result, success or error.
After a successful charge, finish saves the shop's ledger row and the key's stored answer in one transaction:
var p Payment
record := func(tx pgx.Tx) ([]byte, error) {
err := tx.QueryRow(ctx, `
INSERT INTO charges (customer_id, amount, provider_id)
VALUES ($1, $2, $3)
RETURNING id, amount, provider_id`,
customer, req.Amount, providerID).Scan(&p.ID, &p.Amount, &p.ProviderID)
if err != nil {
return nil, err
}
return json.Marshal(p)
}
err = s.finish(ctx, customer, key, http.StatusCreated, nil, record)
if errors.Is(err, errLostKey) {
s.replay(ctx, w, customer, key) // a takeover finished first; answer as it did
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "could not record payment")
return
}
writeJSON(w, http.StatusCreated, p)
// errLostKey means another request finished this key first.
var errLostKey = errors.New("idempotency key already finished")
// finish saves the answer for a key, and the payment row when there is one, in one
// transaction. Only a request that still sees the key as started may finish it.
func (s *Server) finish(ctx context.Context, customer, key string, code int, body []byte, record func(pgx.Tx) ([]byte, error)) error {
tx, err := s.DB.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var status string
err = tx.QueryRow(ctx, `
SELECT status FROM idempotency_keys
WHERE customer_id = $1 AND key = $2
FOR UPDATE`, customer, key).Scan(&status)
if err != nil {
return err
}
if status != "started" {
return errLostKey
}
if record != nil {
if body, err = record(tx); err != nil {
return err
}
}
_, err = tx.Exec(ctx, `
UPDATE idempotency_keys
SET status = 'completed', response_code = $3, response_body = $4
WHERE customer_id = $1 AND key = $2`, customer, key, code, body)
if err != nil {
return err
}
return tx.Commit(ctx)
}
The AWS Builders' Library article on making retries safe says recording the token and the mutations "needs to be an 'all or nothing' or atomic process"; otherwise a crash between the two writes leaves a payment no retry can see. The status check and FOR UPDATE matter in Step 6.
replay returns the stored answer with Idempotent-Replayed: true, a header Stripe uses so a client can tell a replay from fresh work. No standard defines it.
// replay sends the stored answer for a finished key.
func (s *Server) replay(ctx context.Context, w http.ResponseWriter, customer, key string) {
var code int
var body []byte
err := s.DB.QueryRow(ctx, `
SELECT response_code, response_body FROM idempotency_keys
WHERE customer_id = $1 AND key = $2`, customer, key).Scan(&code, &body)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not read stored result")
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Idempotent-Replayed", "true")
w.WriteHeader(code)
w.Write(body)
}
Restart the API in terminal 2 with no flags, reset both ledgers, and pay with a fixed key:
go run ./cmd/api
go run ./cmd/pay -key-value order-7731
docker exec pg psql -U postgres -d shop -c "SELECT key, status, response_code, convert_from(response_body, 'UTF8') AS response_body FROM idempotency_keys"
attempt 1: no answer: timed out
attempt 2: 409 {"error":"a request with this Idempotency-Key is still in progress"}
attempt 3: 201 {"id":1,"amount":2500,"provider_charge_id":"ch_b2d0620c57"} (replayed)
key | status | response_code | response_body
------------+-----------+---------------+-------------------------------------------------------------
order-7731 | completed | 201 | {"id":1,"amount":2500,"provider_charge_id":"ch_b2d0620c57"}
(1 row)
The retry at 0.9 s found the key started and got a 409; the third, at about 1.5 s, got the stored receipt, and each ledger holds one row. Now reset the ledgers and send 20 identical copies at the same instant, counting the distinct lines:
go run ./cmd/pay -parallel 20 -first-timeout 3s | sed -E 's/copy [0-9]+ //' | sort | uniq -c
1 attempt 1: 201 {"id":1,"amount":2500,"provider_charge_id":"ch_66e10aa679"}
19 attempt 1: 409 {"error":"a request with this Idempotency-Key is still in progress"}
19 attempt 2: 409 {"error":"a request with this Idempotency-Key is still in progress"}
19 attempt 3: 409 {"error":"a request with this Idempotency-Key is still in progress"}
19 attempt 4: 201 {"id":1,"amount":2500,"provider_charge_id":"ch_66e10aa679"} (replayed)
One copy charged; the other 19 got 409s, then its receipt, and each ledger holds one row. The losers needed four attempts because this client ignores Retry-After; a production client should read it.
Step 5: Fingerprint Requests and Store Only Final Answers#
The key still trusts the client never to reuse it for a different payment: a bug that sends order-7731 for a 99.00 payment after a 25.00 one would get the 25.00 receipt back. This step closes that hole, then settles which outcomes a key should remember.
A fingerprint is a hash of the request a key was first used with, stored in request_hash and compared on every repeat:
// fingerprint identifies the request a key was first used with. Hashing the decoded
// struct, not the raw bytes, ignores whitespace and field order.
func fingerprint(r *http.Request, req PaymentRequest) []byte {
canonical, _ := json.Marshal(req)
sum := sha256.Sum256(append([]byte(r.Method+" "+r.URL.Path+"\n"), canonical...))
return sum[:]
}
It hashes the decoded struct, not the raw body: {"amount": 2500} and {"amount":2500} are the same payment, and json.Marshal writes a struct's fields in a fixed order without extra whitespace. Unknown fields never reach the hash, which is fine because the handler ignores them too.
The header comes in two spellings. The IETF draft makes its value a structured-field String, HTTP's standard quoted-string type (Idempotency-Key: "8e03978e-..."), while providers such as Stripe take it bare. idempotencyKey accepts both:
// idempotencyKey reads the header. The IETF draft sends it as a quoted string,
// most providers send it bare; accept both.
func idempotencyKey(r *http.Request) string {
k := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if len(k) >= 2 && k[0] == '"' && k[len(k)-1] == '"' {
k = k[1 : len(k)-1]
}
return k
}
Reset the ledgers, pay with a fixed key, then reuse it for a different amount, then send it quoted:
go run ./cmd/pay -key-value order-7731
go run ./cmd/pay -key-value order-7731 -amount 9900 -first-timeout 3s
go run ./cmd/pay -key-value '"order-7731"' -first-timeout 3s
attempt 1: no answer: timed out
attempt 2: 409 {"error":"a request with this Idempotency-Key is still in progress"}
attempt 3: 201 {"id":1,"amount":2500,"provider_charge_id":"ch_dd32a4869f"} (replayed)
attempt 1: 422 {"error":"Idempotency-Key was already used with a different request"}
attempt 1: 201 {"id":1,"amount":2500,"provider_charge_id":"ch_dd32a4869f"} (replayed)
The first payment ran once. The 99.00 payment with the same key got a 422, and the client stopped retrying. The quoted key matched the bare one and replayed the original receipt.
A declined card and a provider that's down both end without a charge, and they need opposite treatment. (The ErrInProgress branch in between comes up in Step 6.)
providerID, err := s.Provider.Charge(ctx, req.Amount, req.Card, chargeKey)
if errors.Is(err, ErrDeclined) {
// A decline is a final answer: store it so a retry gets the same one.
body, _ := json.Marshal(map[string]string{"error": "card declined"})
err := s.finish(ctx, customer, key, http.StatusPaymentRequired, body, nil)
if errors.Is(err, errLostKey) {
s.replay(ctx, w, customer, key)
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "could not save result")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusPaymentRequired)
w.Write(body)
return
}
if errors.Is(err, ErrInProgress) {
// The provider is still charging under this key (an earlier claim we took
// over is mid-call). Not a fault: keep the key and let the client retry.
w.Header().Set("Retry-After", "1")
writeError(w, http.StatusConflict, "a request with this Idempotency-Key is still in progress")
return
}
if err != nil {
// A fault is not an answer. Release the key so a retry can run the charge
// again, but only if it is still our claim and not a later takeover's.
s.DB.Exec(ctx, `
DELETE FROM idempotency_keys
WHERE customer_id = $1 AND key = $2
AND status = 'started' AND locked_at = $3`, customer, key, rec.lockedAt)
writeError(w, http.StatusBadGateway, "payment provider unavailable")
return
}
A decline is a final answer from the card's bank, so the handler stores the 402 and replays it:
go run ./cmd/pay -card tok_declined -key-value order-7732
attempt 1: no answer: timed out
attempt 2: 409 {"error":"a request with this Idempotency-Key is still in progress"}
attempt 3: 402 {"error":"card declined"} (replayed)
A provider fault is different: it means "I don't know". The handler deletes its started claim so a later attempt can run the charge again (Step 6 explains the locked_at condition). Stop the provider in terminal 1 with Ctrl+C, pay with a new key, and look for the key:
go run ./cmd/pay -key-value order-7733 -attempts 2
docker exec pg psql -U postgres -d shop -c "SELECT key, status FROM idempotency_keys WHERE key = 'order-7733'"
attempt 1: 502 {"error":"payment provider unavailable"}
attempt 2: 502 {"error":"payment provider unavailable"}
key | status
-----+--------
(0 rows)
The key was released. Start the provider again in terminal 1 and retry the same key:
go run ./cmd/pay -key-value order-7733 -first-timeout 3s
attempt 1: 201 {"id":2,"amount":2500,"provider_charge_id":"ch_738dbd0b2f"}
A fresh charge, not a replay, and id 2 because the order-7731 payment is row 1. Reasonable systems disagree on faults:
| Policy | Who does it | Upside | Risk |
|---|---|---|---|
| Store every result, including 500s | Stripe API v1 | A retry never re-runs a half-finished request | A brief outage becomes a permanent failure for that key, and the client must resolve the original outcome before paying again |
| Re-execute after a failure | Stripe API v2 | Retries recover from transient faults | Safe only if a rerun can't repeat a side effect |
| Store final answers, release on faults | This tutorial | Declines stay declined; outages heal | Same as above |
I'd rather release on a fault, because the customer still wants to pay once the provider recovers. (Validation failures get a 400 before the claim and are never stored, as with Stripe.) But a 502 can hide a charge that went through moments before the connection broke, and a rerun would charge again. Releasing the key is only safe once the provider itself refuses to charge twice, which is the next step's job.
Never answer a 5xx with a new key
A 5xx doesn't tell the client whether the charge happened. Stripe's advice is plain: "Don't retry with a new idempotency key until you've resolved the original outcome." Retry with the same key, or look the payment up first; a new key turns an unknown outcome into a possible second charge.
Step 6: Survive a Crash with a Lease and a Provider Key#
This step kills the server after the provider charged the card and before the shop recorded it, as a deploy or an out-of-memory kill might. The -crash-after-charge flag exits at that point:
if opt.CrashAfterCharge {
os.Exit(1) // simulate a deploy or OOM kill at the worst moment
}
Reset the ledgers. In terminal 2, start the API with the crash flag; in terminal 3, pay once with a fixed key and a patient client:
go run ./cmd/api -crash-after-charge
go run ./cmd/pay -key-value order-8001 -first-timeout 3s -attempts 1
docker exec pg psql -U postgres -d shop -c "SELECT key, status, locked_at FROM idempotency_keys"
attempt 1: no answer: Post "http://localhost:8080/payments": EOF
key | status | locked_at
------------+---------+-------------------------------
order-8001 | started | 2026-10-12 11:39:00.616311+00
The API is gone and its claim is still started. Restart the API without flags and retry:
go run ./cmd/api
go run ./cmd/pay -key-value order-8001 -first-timeout 3s -attempts 2
attempt 1: 409 {"error":"a request with this Idempotency-Key is still in progress"}
attempt 2: 409 {"error":"a request with this Idempotency-Key is still in progress"}
Nothing will ever finish that key. The claim needs a lease, ownership that lapses after a set time unless the owner finishes first. locked_at records when the claim was taken, and with -lease set, claim runs its other branch:
} else {
// Also take over a key whose owner started long ago and never finished.
err = s.DB.QueryRow(ctx, `
INSERT INTO idempotency_keys (customer_id, key, request_hash)
VALUES ($1, $2, $3)
ON CONFLICT (customer_id, key) DO UPDATE SET locked_at = now()
WHERE idempotency_keys.status = 'started'
AND idempotency_keys.request_hash = EXCLUDED.request_hash
AND idempotency_keys.locked_at < now() - make_interval(secs => $4)
RETURNING locked_at`, customer, key, hash, opt.Lease.Seconds()).Scan(&rec.lockedAt)
}
The DO UPDATE takes over only a started key with the same fingerprint whose claim is older than the lease; otherwise RETURNING produces zero rows and the request loses the claim as before. The lease is one of the switches in Options:
// Options switch on the protections one at a time, so each step of the tutorial
// can show what the previous version gets wrong.
type Options struct {
Lease time.Duration // 0: a started key is never taken over
PassKeyToCharge bool // send our key to the provider as its Idempotency-Key
CrashAfterCharge bool // exit after the provider charges, before we record it
}
Restart the API with -lease 10s, wait until 10 seconds have passed since the crash, retry, and run the ledger queries from Step 2:
go run ./cmd/api -lease 10s
go run ./cmd/pay -key-value order-8001 -first-timeout 3s
attempt 1: 201 {"id":1,"amount":2500,"provider_charge_id":"ch_d711b185ca"}
id | amount | provider_id
----+--------+---------------
1 | 2500 | ch_d711b185ca
id | idempotency_key | amount
---------------+-----------------+--------
ch_c3f90b8aca | | 2500
ch_d711b185ca | | 2500
The card was charged twice, and the shop's ledger shows one payment: ch_c3f90b8aca, made by the crashed request, exists only at the provider. That's worse than Step 2, where the ledgers at least agreed. Only the provider knows whether the crashed request charged, so it has to be asked in a way it can deduplicate. With -pass-key, the handler sends the provider its own Idempotency-Key, derived from the customer and the client's key:
chargeKey := ""
if opt.PassKeyToCharge {
// Derived from our key, so every retry and takeover sends the same one.
// Hashed to 64 hex characters to fit providers' length limits.
sum := sha256.Sum256([]byte(customer + ":" + key))
chargeKey = hex.EncodeToString(sum[:])
}
providerID, err := s.Provider.Charge(ctx, req.Amount, req.Card, chargeKey)
The customer is part of the input because your table scopes keys per customer, while the provider has one key space for your whole account. The SHA-256 hash turns any input into 64 hex characters, which fits Stripe's 255-character limit and Adyen's 64. Square (45 characters), PayPal (38) and Razorpay payouts (36) need something shorter, such as the base64url encoding of the hash's first 24 bytes, which is 32 characters.
Reset the ledgers and repeat the crash with -pass-key on every API start, from terminals 2 and 3 as before:
go run ./cmd/api -crash-after-charge -pass-key
go run ./cmd/pay -key-value order-8001 -first-timeout 3s -attempts 1
go run ./cmd/api -lease 10s -pass-key
go run ./cmd/pay -key-value order-8001 -first-timeout 3s
The last command runs once 10 seconds have passed since the crash:
attempt 1: no answer: Post "http://localhost:8080/payments": EOF
attempt 1: 201 {"id":1,"amount":2500,"provider_charge_id":"ch_038668bc4c"}
id | amount | provider_id
----+--------+---------------
1 | 2500 | ch_038668bc4c
id | idempotency_key | amount
---------------+------------------------------------------------------------------+--------
ch_038668bc4c | 9115b225b4ef86fab14f1e43fc342407f9b1f20d5358ab1da88ef89ac96979ca | 2500
One charge in each ledger, with the same ID: the provider recognized the derived key and returned the crashed request's charge instead of making a second one.
A lease can also run out while its owner is still alive. Reset the ledgers, start the API with -lease 500ms and no -pass-key, pay with the default client, and run the ledger queries:
go run ./cmd/api -lease 500ms
go run ./cmd/pay
attempt 1: no answer: timed out
attempt 2: 201 {"id":1,"amount":2500,"provider_charge_id":"ch_47558d25dd"} (replayed)
id | amount | provider_id
----+--------+---------------
1 | 2500 | ch_47558d25dd
id | idempotency_key | amount
---------------+-----------------+--------
ch_47558d25dd | | 2500
ch_f5009699df | | 2500
The retry at 0.9 s took over a claim older than 500 ms while the original was still at the provider, so both charged. The shop recorded one payment because the original completed the key first and the takeover's finish replayed it. If both finish at the same instant, SELECT ... FOR UPDATE makes the second wait on the row lock. Under Read Committed, PostgreSQL's default isolation level where each statement sees what was committed before it began, it then reads the newest version and backs off.
Now run the same test with -lease 500ms -pass-key:
attempt 1: no answer: timed out
attempt 2: 409 {"error":"a request with this Idempotency-Key is still in progress"}
attempt 3: 201 {"id":1,"amount":2500,"provider_charge_id":"ch_7c98ee1cee"} (replayed)
id | amount | provider_id
----+--------+---------------
1 | 2500 | ch_7c98ee1cee
id | idempotency_key | amount
---------------+------------------------------------------------------------------+--------
ch_7c98ee1cee | 08079351b5a3a1c086fbc082c533c78e84569f2e49e00eef98447e607c5f4076 | 2500
The takeover reached the provider with the same derived key while the original was still charging, and the provider answered 409, as Stripe does for a concurrent request with the same key. The API passed it back without releasing the key (the ErrInProgress branch), and attempt 3 got the original's receipt: one charge, one payment.
One trap remains. Had the original's provider call failed after the takeover, Step 5's release would have deleted the takeover's live claim, which is why the DELETE also matches locked_at = $3, the value claim returned.
This is the lesson of the Redis distributed locks tutorial from the other side: a lease can't prove the previous owner has stopped, so each place where an effect happens checks for itself. There, PostgreSQL refused writes carrying a stale fencing token, a number that grows with every lock grant. Here, the provider refuses a second charge for the same key, and locked_at does a smaller version of the same job for the release: a stale owner's DELETE names a locked_at that no longer exists, so it removes nothing. Set the lease well above the slowest path through the handler, here the provider client's 5-second timeout (the load test uses 30 seconds), and treat it as a way to keep takeovers rare, never as the guarantee.
Card processors offer the same mechanism under different names and rules:
| Provider | Where the key goes | Retention | Limit |
|---|---|---|---|
| Stripe (API v1) | Idempotency-Key header |
At least 24 hours | 255 characters |
| Adyen | idempotency-key header on POST |
7 to 14 days | 64 characters |
| PayPal | PayPal-Request-Id header |
Varies by API | 38 characters |
| Square | idempotency_key in the request body |
See the API's docs | 1 to 45 characters |
| Razorpay (payouts) | X-Payout-Idempotency header |
See the API's docs | 4 to 36 characters |
Want this checked on your own payment path?
None of the failures in this step show up in a test that counts only successful responses: you find them by comparing your ledger with the provider's. I offer a focused review of payment and money-movement APIs that covers where retries enter, how keys are claimed and stored, what reaches the provider, and a duplicate-request load test like the one in Step 7, with written findings your team can act on. Ask for a payment API review or book a free 20-minute intro call.
Step 7: Load-Test the Finished API#
This step runs the finished configuration, a 30-second lease plus the provider key, against a burst of duplicates. load.sh starts its own provider and API, so stop terminals 1 and 2 first, then build:
go build -o bin/ ./cmd/...
The script sends 30 payments with distinct amounts, 1001 to 1030, each as 5 identical copies at the same instant, and counts the responses:
#!/usr/bin/env bash
# load.sh : 30 payments, each sent as 5 identical copies at once, against the final API.
cd "$(dirname "$0")"
docker exec pg psql -U postgres -d shop -qc "TRUNCATE charges, idempotency_keys RESTART IDENTITY"
docker exec pg psql -U postgres -d provider -qc "TRUNCATE card_charges"
pkill -f bin/provider; pkill -f bin/api; sleep 0.3
./bin/provider >/tmp/provider.log 2>&1 &
./bin/api -lease 30s -pass-key >/tmp/api.log 2>&1 &
until curl -s -o /dev/null localhost:8080 && curl -s -o /dev/null localhost:8090; do sleep 0.1; done
rm -f /tmp/pay-*.log
pids=()
for i in $(seq 1 30); do
./bin/pay -parallel 5 -first-timeout 3s -amount $((1000 + i)) >/tmp/pay-$i.log 2>&1 &
pids+=($!)
done
wait "${pids[@]}"
cat /tmp/pay-*.log | sed -E 's/copy [0-9]+ attempt [0-9]+: //; s/\{"id".*\}/{payment}/' | sort | uniq -c
pkill -f bin/provider; pkill -f bin/api
bash load.sh
30 201 {payment}
120 201 {payment} (replayed)
360 409 {"error":"a request with this Idempotency-Key is still in progress"}
Of 150 copies, one per group of 5 charged (30 originals); the other 120 each got three 409s while the winner was at the provider (360), then its receipt (120 replays). The run took about 3 seconds. Now count what was charged:
docker exec pg psql -U postgres -d shop -c "SELECT count(*) AS payments, count(DISTINCT amount) AS distinct_amounts FROM charges"
docker exec pg psql -U postgres -d provider -c "SELECT count(*) AS card_charges FROM card_charges"
docker exec pg psql -U postgres -d shop -c "SELECT status, response_code, count(*) FROM idempotency_keys GROUP BY 1, 2"
payments | distinct_amounts
----------+------------------
30 | 30
(1 row)
card_charges
--------------
30
(1 row)
status | response_code | count
-----------+---------------+-------
completed | 201 | 30
(1 row)
30 payments in the shop's ledger, each a distinct amount, 30 charges at the provider, and 30 keys completed with a 201. The ledgers agree, and that's the check that matters: a test counting only 201s would have passed in Step 2 too.
To check for data races as well, rebuild with Go's race detector and run the script again:
go build -race -o bin/ ./cmd/...
bash load.sh
The race-enabled run gave the same counts, took about 5 seconds, and the logs contained no DATA RACE report.
Step 8: Expire Old Keys#
The key table gains a row per payment and never shrinks. Keep each key as long as a retry can plausibly arrive, then delete it; Stripe keeps keys for at least 24 hours, and so does this tutorial. To test without waiting a day, backdate 12 of the load test's 30 keys (test only):
docker exec pg psql -U postgres -d shop -c "UPDATE idempotency_keys SET created_at = now() - interval '25 hours' WHERE key IN (SELECT key FROM idempotency_keys ORDER BY key LIMIT 12)"
UPDATE 12
The cleanup itself is one statement:
docker exec pg psql -U postgres -d shop -c "DELETE FROM idempotency_keys WHERE created_at < now() - interval '24 hours'"
docker exec pg psql -U postgres -d shop -c "SELECT count(*) FROM idempotency_keys"
DELETE 12
count
-------
18
(1 row)
The 12 old keys are gone and 18 remain. Run the DELETE from cron or a scheduled job, and at real volume add an index on created_at so it doesn't scan the whole table.
The IETF draft says the expiry policy SHOULD be documented, because after the window a retry with an old key is a new payment. Choose a window longer than any client's retry schedule, including apps that queue a payment offline.
When you're done, remove the container:
docker rm -f pg
Troubleshooting#
| Symptom | Cause | Fix |
|---|---|---|
missing go.sum entry for module providing package github.com/jackc/puddle/v2 |
go get didn't record the modules pgx's connection pool imports |
Run go mod tidy, as in Step 1 |
| Duplicate charges even though the API checks keys | The client generates a new key per attempt, so every retry looks new (-key new reproduces it: two charges with two different provider keys) |
Generate one key per operation, persist it with the pending payment, and send it on every attempt, as Stripe advises |
| Retries never return a 409; duplicates hang, then get a replay | The claim and the work share one transaction, so a duplicate waits on the uncommitted key row | Commit the claim in its own statement before the slow work, as in Step 4 |
| A retry keeps failing long after the provider recovered | A fault was stored as the key's final answer | Release the key on faults, as in Step 5. Never switch to a new key until the original outcome is known |
| One customer receives another customer's receipt | Keys are unique globally rather than per customer | Scope keys per customer, as the (customer_id, key) primary key does, and include the customer in the key you send to the provider |
| Every retry gets 409 after a crash or deploy | A started key whose owner died, with no lease |
Add a lease (Step 6) |
| Header rejected or never matched | The client sends the draft's quoted form and the server compares it raw | Strip one pair of surrounding quotes, as idempotencyKey does |
422 on a retry |
The key was reused with a different payload, or the client changed the body between attempts | Keep the body fixed for a key; a new payment needs a new key |
| Card charged but no payment in the shop's ledger | A takeover re-ran a charge the provider had already made | Pass a derived key to the provider (Step 6) |
Conclusion#
You built idempotency keys in Go on PostgreSQL and broke each version before replacing it. A check-first lookup still charged twice. An atomic claim fixed the race, a fingerprint caught a reused key, and a lease recovered from a crash, until its own double charge needed a provider key. The final load test sent 150 copies of 30 payments, and both ledgers recorded exactly 30.
Retries are normal on a real network. Handling them costs one table, one extra write per payment, a scheduled cleanup and a provider key on every charge; without them, duplicates show up as refunds, disputes and support tickets.
Next, read Brandur Leach's Implementing Stripe-like Idempotency Keys in Postgres, which extends this design to several side effects with atomic phases and recovery points, and the AWS article on making retries safe with idempotent APIs. Then point Step 7's load test at your own payment path and compare the two ledgers.
If your team is building payment flows, webhooks or any path where a retry can move money twice, I can help design and build it, including the load test that proves the ledgers agree. You can see how I approach distributed systems, book a free 20-minute intro call, or tell me about your system.
Frequently asked questions
What is an idempotency key?
A unique value the client generates once per operation, such as one payment, and sends with every attempt of it, usually in an Idempotency-Key header. The server does the work the first time it sees the key, stores the result, and answers every repeat with that stored result instead of doing the work again. A V4 UUID, or another random value of similar strength, makes a good key.
Is HTTP POST idempotent?
No. RFC 9110 defines PUT, DELETE and the safe methods such as GET as idempotent, and POST is none of those. The same section says a client should not retry a non-idempotent request automatically unless it has some means to know the request is actually idempotent. An idempotency key gives the client that means, so it can retry a POST safely.
How long should you keep idempotency keys?
Long enough to cover every retry a client could still send, and document the window. Stripe's v1 API keeps keys for at least 24 hours and its v2 API for 30 days, Adyen for 7 to 14 days, and PayPal's retention varies by API. Once a key expires, a request reusing it is treated as new. Delete expired keys with a scheduled job.
What status code should a duplicate request get?
A duplicate of a finished request gets the original status code and body again, success or error; Stripe adds an Idempotent-Replayed: true header. While the first request is still running, the IETF idempotency key draft suggests 409 Conflict, with 422 for a key reused with a different payload and 400 for a missing key. The draft expired without becoming an RFC, but its codes are a sensible default.
Should you cache 500 errors for an idempotency key?
It depends on whether running the request again can do harm. Stripe's v1 API stores the first result even when it is a 500, so a retry never re-runs a half-finished request; its v2 API re-executes failed requests. Store final answers such as a card decline. Release the key on a fault only when a rerun can't repeat a side effect, for example because the provider also deduplicates by key.
Shahid Yousuf
Senior Software Engineer
I build and consult on software across web, mobile, cloud and AI. Have something in mind? Let's talk.
Hire me Book a free intro call See case studies