Skip to content
article

Idempotency keys in Go: the version that survives a double tap and a dead inverter

How we stopped charging customers twice when the network does what networks do

Emeka Nwosu@emeka_golang

July 30, 2026 · 4 min read

Customer taps Pay. The request goes out on a 3G connection in Onitsha, reaches our API, the charge succeeds, and the response dies somewhere on the way back. The app sees a timeout and retries. Without protection, that is two charges and one very angry customer calling the merchant, who calls us.

Same thing happens when NEPA takes light in the middle of a request and the client's inverter takes three seconds to kick in. Retries are not an edge case. Retries are Tuesday.

The contract

The client generates a UUID per payment attempt and sends it as Idempotency-Key. Same key means "this is the same request, do not do it again". We store the key with a hash of the request body, a status, and the response we sent.

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',
    status_code  int,
    response     jsonb,
    created_at   timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (merchant_id, key)
);

Claim the key first, then do the work

func (h *Handler) CreatePayment(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	key := r.Header.Get("Idempotency-Key")
	if key == "" {
		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)
	merchant := merchantID(ctx)

	tag, err := h.db.Exec(ctx,
		"INSERT INTO idempotency_keys (merchant_id, key, request_hash) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
		merchant, key, sum[:])
	if err != nil {
		http.Error(w, "db error", http.StatusInternalServerError)
		return
	}

	if tag.RowsAffected() == 0 {
		h.replay(w, ctx, merchant, key, sum[:])
		return
	}

	// we own this key now. do the charge exactly once.
	code, resp := h.charge(ctx, merchant, key, body)

	_, _ = h.db.Exec(ctx,
		"UPDATE idempotency_keys SET status = 'done', status_code = $3, response = $4 WHERE merchant_id = $1 AND key = $2",
		merchant, key, code, resp)

	w.WriteHeader(code)
	w.Write(resp)
}

The INSERT ... ON CONFLICT DO NOTHING plus RowsAffected() is the whole trick. Two identical requests arriving at the same millisecond: Postgres lets exactly one insert win. No SELECT-then-INSERT race.

The replay path

func (h *Handler) replay(w http.ResponseWriter, ctx context.Context, merchant int64, key string, hash []byte) {
	var rec struct {
		hash   []byte
		status string
		code   *int
		body   []byte
	}
	err := h.db.QueryRow(ctx,
		"SELECT request_hash, status, status_code, response FROM idempotency_keys WHERE merchant_id = $1 AND key = $2",
		merchant, key).Scan(&rec.hash, &rec.status, &rec.code, &rec.body)
	if err != nil {
		http.Error(w, "db error", http.StatusInternalServerError)
		return
	}

	switch {
	case !bytes.Equal(rec.hash, hash):
		http.Error(w, "key reused with a different request", http.StatusUnprocessableEntity)
	case rec.status == "in_progress":
		w.Header().Set("Retry-After", "1")
		http.Error(w, "request in progress", http.StatusConflict)
	default:
		w.WriteHeader(*rec.code) // replay the ORIGINAL status, not 200
		w.Write(rec.body)
	}
}

Same key, different body: 422, because that is a client bug and you want them to see it. Same key while the first request is still running: 409 with Retry-After, so a client retrying in a loop gets told to wait instead of getting a second charge.

The part every tutorial skips

What if the process dies after the charge succeeded but before the UPDATE? The key is stuck at in_progress forever and you do not know if money moved. Two things fix this for us:

  1. Pass the key down. The provider call uses our key as its reference, and most providers worth using accept their own idempotency key or reference. So even our own retries cannot double charge at the provider.
  2. A sweeper. Every two minutes, keys in in_progress older than 5 minutes get looked up at the provider by reference, and the record is finished with the real outcome.

Plus a nightly delete of keys older than 72 hours, so the table does not grow forever.

If you want the long version of this idea, Brandur Leach wrote the classic piece on Stripe-style idempotency keys in Postgres at brandur.org/idempotency-keys. Our version is smaller, because our team is smaller, and because our inverter is louder 😂

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

9

Sign in to join the conversation.

Sign in to comment
Arjun Mehta@arjunmehta#1 in Ledger Lines
Finally somebody wrote the crash-between-charge-and-update part. Everybody stops at the INSERT. We had exactly the stuck in_progress problem, our sweeper queries the bank by our reference every 2 minutes. Also very good that you replay the original status code, clients depend on it.
2 net votes
Emeka Nwosu@emeka_golang#2 in Ledger Lines
Yes boss. A 402 replayed as 200 is how you get angry merchants 😂
1 net votes
Vivek Chauhan@vivek_backend#8 in Ledger Lines
Same in UPI world, the reference number is effectively our idempotency key with the bank. Arre, the number of times a customer got debited twice because a PSP retried without it. Sharing with the team.
4 net votes
Lindiwe Mokoena@loadshedding_lindi5 rep in Ledger Lines
'dead inverter' made me laugh and then cry. We literally design for the lights going out mid-transaction. One thing: we hash canonical JSON, not raw bytes, because one client SDK reorders keys on retry. Learnt that the hard way, eish.
4 net votes
Emeka Nwosu@emeka_golang#2 in Ledger Lines
Ah that is a good catch. Our mobile SDK is ours so the bytes are stable, but for a public API you are right. Canonical JSON or hash only the fields that matter. Adding it to my notes
2 net votes
Bram Verhoeven@bram_on_a_bike2 rep in Ledger Lines
Clean. ON CONFLICT DO NOTHING plus RowsAffected is the right primitive. Most people do SELECT then INSERT and have a race they never see in tests.
3 net votes
Danny Cho@danny_cho_la2 rep in Ledger Lines
The 409 with Retry-After for in-flight requests is the detail I like most. Clients that hammer in a loop get told to wait instead of getting a second charge.
3 net votes
Oluwatobi Adeyemi@tobi_freelance2 rep in Ledger Lines
Na this kind post I go send to the next client who asks why their 'simple wallet app' quote is not 300 dollars
1 net votes
Brian Kiprono@kiprono_codes#4 in Ledger Lines
Wueh, this is Daraja survival 101. STK push callbacks come twice sometimes, and once in a while they come zero times. We use the CheckoutRequestID as the key. Saving this, maze.
4 net votes