Skip to content
article

Idempotent payment endpoints in Go and Postgres: the pattern that stopped our double charges

Idempotency keys, request hashes, the in-progress state everybody forgets, and the reconcile job for when the server dies in the middle

Emeka Nwosu@emeka_golang

July 29, 2026 · 4 min read

For six months we refunded somebody almost every Monday. Same story every time: customer on a bad connection taps Pay, the request reaches us, the PSP charges them, our response never reaches the phone, the app retries, we charge again. This is the pattern we use now. Zero double charges since February.

The rules

  1. Every POST that moves money requires an Idempotency-Key header. No key, 400. The client generates it once per payment attempt: when the payment screen opens, not when the button is tapped.
  2. Keys are scoped per merchant. Two merchants can send the same key and never see each other's responses.
  3. We store a hash of the request body with the key. Same key with a different body is a client bug and gets a 422, not a silent replay.
  4. A key has a state: in_progress or done. This is the part most blog posts skip.

The table

CREATE TABLE idempotency_keys (
  merchant_id   bigint      NOT NULL,
  key           text        NOT NULL,
  request_hash  bytea       NOT NULL,
  status        text        NOT NULL DEFAULT 'in_progress',
  response_code int,
  response_body jsonb,
  created_at    timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (merchant_id, key)
);

The handler (pgx, trimmed)

func (s *Server) CreatePayment(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	key := r.Header.Get("Idempotency-Key")
	if key == "" || len(key) > 255 {
		http.Error(w, "Idempotency-Key required", http.StatusBadRequest)
		return
	}
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		http.Error(w, "bad body", http.StatusBadRequest)
		return
	}
	sum := sha256.Sum256(body)
	merchantID := merchantFrom(ctx)

	// Claim the key. Exactly one request wins this insert.
	tag, err := s.db.Exec(ctx,
		"INSERT INTO idempotency_keys (merchant_id, key, request_hash) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
		merchantID, key, sum[:])
	if err != nil {
		http.Error(w, "db error", http.StatusInternalServerError)
		return
	}
	if tag.RowsAffected() == 0 {
		s.replay(ctx, w, merchantID, key, sum[:])
		return
	}

	code, resp := s.charge(ctx, merchantID, key, body)

	_, err = s.db.Exec(ctx,
		"UPDATE idempotency_keys SET status = 'done', response_code = $3, response_body = $4 WHERE merchant_id = $1 AND key = $2",
		merchantID, key, code, resp)
	if err != nil {
		log.Printf("idempotency: could not store response for %d/%s: %v", merchantID, key, err)
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(code)
	w.Write(resp)
}

func (s *Server) replay(ctx context.Context, w http.ResponseWriter, merchantID int64, key string, sum []byte) {
	var status string
	var hash, body []byte
	var code *int
	err := s.db.QueryRow(ctx,
		"SELECT status, request_hash, response_code, response_body FROM idempotency_keys WHERE merchant_id = $1 AND key = $2",
		merchantID, key).Scan(&status, &hash, &code, &body)
	if err != nil {
		http.Error(w, "db error", http.StatusInternalServerError)
		return
	}
	if !bytes.Equal(hash, sum) {
		http.Error(w, "Idempotency-Key reused with a different request", http.StatusUnprocessableEntity)
		return
	}
	if status != "done" || code == nil {
		w.Header().Set("Retry-After", "2")
		http.Error(w, "a request with this key is still in progress", http.StatusConflict)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(*code)
	w.Write(body)
}

The parts people get wrong

  • The PSP call must be idempotent too. Pass your key, or something derived from it, downstream. Stripe accepts an Idempotency-Key header. With Paystack and most African PSPs, send your own unique transaction reference: they reject a duplicate reference.
  • The server can die between the charge and the UPDATE. Then the key sits in in_progress forever and the client gets 409s. We run a reconcile job every minute: any key in progress for more than 5 minutes, ask the PSP what happened to that reference, then write the real outcome.
  • Do not wrap the PSP call in the same DB transaction. You will hold a transaction open for 30 seconds while a bank thinks about life. Claim the key, commit, call out, then record.
  • Expire keys. We keep them 72 hours, then a nightly job deletes them. A retry after 3 days is not a retry, it is a new payment.
  • The ledger is still the source of truth. Idempotency stops duplicate requests. It does not replace double-entry bookkeeping and a daily reconciliation against the settlement file. That is another post.

Na so we stopped refunding people every Monday. If you write this in Rust it will take you three weeks and a lifetime of suffering, but the pattern is the same 😂

Written by

Emeka Nwosu

Go backend engineer, Enugu 🇳🇬 Remote for a UK fintech. NEPA took light again but the service is still up. Gophers rise.

Get the best of #ledger_lines weekly

One email each Monday with the most useful posts. No spam; unsubscribe in one click.

Comments

14

Sign in to join the conversation.

Sign in to comment
Arjun Mehta@arjunmehta#1 in Ledger Lines
Very clean. One addition from our side: we store the PSP reference in the idempotency row BEFORE calling out, so the reconcile job does not have to guess which reference was used. Saves one painful lookup when the PSP's query-by-reference is slow.
5 net votes
Emeka Nwosu@emeka_golang#2 in Ledger Lines
Good one my guy. We derive the reference from the key so it is deterministic, but storing it explicitly is cleaner. Adding it
2 net votes
Ondřej Hrubý@rewrite_it_in_rust2 rep in Ledger Lines
in Rust the in_progress state would be unrepresentable at compile time. typestate pattern. 🦀
1 net votes
Emeka Nwosu@emeka_golang#2 in Ledger Lines
My guy, the state is in POSTGRES. Your typestate cannot see my database row 😂
3 net votes
Ondřej Hrubý@rewrite_it_in_rust2 rep in Ledger Lines
sqlx checks the queries at compile time. checkmate
1 net votes
Emeka Nwosu@emeka_golang#2 in Ledger Lines
Checking that a query is valid is not the same as knowing what another server did 5 minutes ago. Go and rest 🤣
4 net votes
Danny Cho@danny_cho_la2 rep in Ledger Lines
Nice writeup. Did you try pg_advisory_xact_lock on a hash of the key instead of insert-as-lock? We started with advisory locks and ended up exactly where you are, because the row doubles as the record.
6 net votes
Emeka Nwosu@emeka_golang#2 in Ledger Lines
We tried advisory lock first. Problem: the lock disappears with the transaction, and we wanted in_progress to survive a crash so the reconcile job can see it. The row is the lock and the evidence
4 net votes
Lindiwe Mokoena@loadshedding_lindi5 rep in Ledger Lines
"The server can die between the charge and the UPDATE." Ja no, in Joburg the server, the office and the whole suburb can die between the charge and the update. The reconcile job is the most important code in this post
4 net votes
Walt Hendricks@walt_mainframe1 rep in Ledger Lines
We solved this in 1989 with a sequence number on every batch record and a control total. Different syntax, same idea. Good work, young man.
3 net votes
Achieng Owino@achieng_devrel#5 in Ledger Lines
Bookmarking. "Generate the key when the screen opens, not when the button is tapped" is the detail most mobile teams get wrong. A retry tap must reuse the key.
3 net votes
Vivek Chauhan@vivek_backend#8 in Ledger Lines
Good post bhai. UPI has the same idea with the transaction ref, and banks still get it wrong. One question: why bytea for the hash and not hex text?
2 net votes
Emeka Nwosu@emeka_golang#2 in Ledger Lines
Smaller and cheaper to compare. Honestly no big reason, text would also work 😄
4 net votes
Tunde Adebayo@tunde_ade#10 in Ledger Lines
omo this is the post i will send to every frontend dev who says "just disable the button". disable the button AND send a key abeg
2 net votes