Skip to content

Split View: 결제 멱등성: 이중 결제를 막는 법

|

결제 멱등성: 이중 결제를 막는 법

들어가며 — 두 번 청구되는 악몽

결제 시스템에서 가장 흔하고 가장 무서운 버그는 "사용자가 두 번 청구되는 것"입니다. 커피 한 잔 값이 두 번 빠져나가면 사용자는 화가 나고, 큰 금액이 두 번 빠지면 신뢰가 무너집니다. 그런데 이 버그는 코드에 명백한 실수가 없어도 발생합니다. 원인은 대개 네트워크의 불확실성과 그에 대응하는 재시도입니다.

분산 시스템의 근본 진실 하나는 "네트워크는 언젠가 반드시 실패한다"는 것입니다. 그리고 실패했을 때 클라이언트가 할 수 있는 합리적인 대응은 재시도입니다. 그런데 결제에서는 이 합리적인 재시도가 이중 결제라는 비합리적인 결과를 낳을 수 있습니다.

이 글은 이 문제를 정면으로 다룹니다. 왜 재시도가 이중 결제를 만드는지, 그것을 막는 **멱등성(idempotency)**이란 무엇인지, 멱등성 키와 중복 제거 윈도우와 유니크 제약을 어떻게 설계하는지, at-least-once 전달에 dedup을 어떻게 결합하는지, 결제를 상태 기계로 어떻게 모델링하는지, 그리고 Stripe 같은 실제 시스템이 어떻게 멱등성을 구현하는지까지 짚겠습니다. 재시도와 흐름 제어, 최소 한 번 전달 같은 개념을 눈으로 실험해 보고 싶다면 이 사이트의 메시지 큐 놀이터를 함께 열어 두면 좋습니다.

멱등성이란 무엇인가

**멱등성(idempotency)**은 수학과 컴퓨터 과학에서 온 개념입니다. 어떤 연산을 여러 번 적용해도 결과가 한 번 적용한 것과 같으면, 그 연산은 멱등하다고 합니다.

일상적인 예로 감을 잡을 수 있습니다. 엘리베이터의 층 버튼을 한 번 누르나 다섯 번 누르나 결과는 같습니다. 그 층으로 갈 뿐입니다. 이것이 멱등입니다. 반면 자판기에 동전을 넣는 것은 멱등이 아닙니다. 넣을 때마다 잔액이 늘어나기 때문입니다.

HTTP 메서드로 보면 더 분명합니다.

  • GET은 멱등입니다. 같은 자원을 몇 번 읽어도 서버 상태는 안 바뀝니다.
  • PUTDELETE도 멱등으로 설계됩니다. 같은 값을 여러 번 써도, 같은 자원을 여러 번 지워도 최종 상태는 같습니다.
  • POST는 기본적으로 멱등이 아닙니다. 보통 새 자원을 만들거나 어떤 동작을 일으키므로, 두 번 호출하면 두 번 일어납니다.

문제는 결제가 대개 POST라는 데 있습니다. "결제를 생성한다"는 것은 본질적으로 부수 효과(돈이 빠져나감)를 일으키는 비멱등 연산입니다. 우리가 하려는 일은 이 비멱등 연산을, 멱등하게 만드는 것입니다. 즉 "같은 결제 요청을 몇 번 보내도 결제는 딱 한 번만 일어나게" 하는 것입니다.

재시도와 타임아웃의 문제

멱등성이 왜 필요한지 이해하려면, 재시도가 어떻게 이중 결제를 만드는지 정확히 봐야 합니다. 핵심은 타임아웃의 모호함입니다.

클라이언트가 서버에 결제 요청을 보내고 응답을 기다립니다. 그런데 타임아웃이 났습니다. 이때 클라이언트가 확실히 아는 것은 단 하나, "정해진 시간 안에 응답이 오지 않았다"는 사실뿐입니다. 실제로 무슨 일이 일어났는지는 여러 가지일 수 있습니다.

  타임아웃이 났을 때 실제로 벌어졌을 수 있는 일들:

  경우 A: 요청이 서버에 도달하지 못함        -> 결제 안 됨.  재시도해야 함.
  경우 B: 서버가 처리했지만 응답이 유실됨     -> 결제 됨.    재시도하면 안 됨.
  경우 C: 서버가 처리 중이라 아직 안 끝남     -> 진행 중.    재시도하면 경합 위험.

문제의 본질이 여기 있습니다. 클라이언트는 A, B, C를 구분할 수 없습니다. 응답이 없다는 사실만으로는 결제가 됐는지 안 됐는지 알 길이 없습니다.

이때 클라이언트가 취할 수 있는 선택은 둘입니다. 재시도하지 않으면, 실제로는 경우 A였는데 포기해 버려 결제를 놓칠 수 있습니다(사용자는 결제하려 했는데 안 됨). 반대로 재시도하면, 실제로는 경우 B였는데 다시 보내 이중 결제가 됩니다.

그래서 멱등성이 없으면 이 딜레마에서 빠져나올 수 없습니다. 멱등성은 이 매듭을 풉니다. "재시도해도 안전하다"를 보장하면, 클라이언트는 안심하고 재시도할 수 있고, 경우 A는 정상 처리되며 경우 B는 중복이 걸러집니다. 즉 멱등성은 재시도를 안전하게 만드는 장치입니다.

멱등성 키 — 요청에 이름표를 붙이다

멱등성을 구현하는 표준 방법은 **멱등성 키(idempotency key)**입니다. 아이디어는 단순합니다. 클라이언트가 각 결제 요청에 고유한 식별자를 하나 붙여 보내고, 서버는 그 키를 기준으로 "이 요청을 전에 본 적 있는지"를 판단하는 것입니다.

  POST /v1/charges
  Idempotency-Key: 9f2c1a7e-...-b3   <- 클라이언트가 생성한 고유 키
  { "amount": 5000, "currency": "krw", "source": "tok_..." }

서버의 처리 규칙은 이렇습니다.

  요청 수신 시:
  1) 이 멱등성 키를 전에 본 적 있는가?
     - 없다 -> 결제를 실제로 처리하고, (키 -> 결과)를 저장한 뒤 결과 반환
     - 있다 -> 저장해 둔 그 결과를 그대로 반환 (결제를 다시 하지 않음)

이 단순한 규칙 덕분에, 클라이언트가 같은 키로 몇 번을 재시도하든 결제는 딱 한 번만 일어납니다. 첫 요청만 실제 결제를 실행하고, 이후의 재시도는 저장된 첫 결과의 "복사본"을 받습니다.

여기서 키를 누가, 어떻게 만드느냐가 중요합니다.

  • 키는 클라이언트가 생성합니다. 그래야 재시도할 때 같은 키를 다시 보낼 수 있습니다. 서버가 키를 만들면, 클라이언트는 재시도 때 어떤 키를 써야 할지 알 수 없습니다.
  • 키는 하나의 논리적 작업에 하나씩 대응해야 합니다. 예를 들어 "장바구니 X를 결제한다"는 하나의 작업에 키 하나를 부여하고, 그 작업의 모든 재시도에 같은 키를 씁니다. UUID 같은 충분히 무작위한 값을 쓰면 우연한 충돌 걱정이 없습니다.
  • 사용자가 결제 버튼을 다시 눌러 새 작업을 의도한 경우에는 새 키를 만들어야 합니다. 같은 키를 재사용하면, 시스템은 그것을 "재시도"로 보고 새 결제를 막아 버립니다. 이 점을 UX와 잘 맞추는 것이 중요합니다.

중복 제거 윈도우와 저장

멱등성 키를 저장하려면 "언제까지" 저장할지를 정해야 합니다. 이것이 **중복 제거 윈도우(dedup window)**입니다. 키-결과 기록을 영원히 보관할 수는 없으므로, 보통 일정 기간(예: 24시간)만 유지합니다.

윈도우 길이를 정하는 기준은 "재시도가 현실적으로 얼마의 시간 범위 안에서 일어나는가"입니다. 네트워크 오류로 인한 재시도는 대개 초·분 단위 안에서 일어납니다. 하지만 클라이언트가 잠시 오프라인이었다가 나중에 재시도하는 경우까지 감안해, 실무에서는 하루 정도를 넉넉히 잡는 경우가 많습니다. Stripe는 멱등성 키를 24시간 동안 유지합니다.

저장소 자체는 빠른 조회와 만료(TTL)를 지원하는 것이 좋습니다. 흔한 선택은 다음과 같습니다.

  • 관계형 DB의 전용 테이블: 결제의 원장 데이터와 같은 트랜잭션 안에서 다룰 수 있어 정합성이 좋습니다. 뒤에서 볼 유니크 제약과 궁합이 좋습니다.
  • Redis 같은 인메모리 저장소 + TTL: 매우 빠르고 만료가 자동입니다. 다만 원장 DB와 별개의 저장소이므로, 둘 사이의 정합성(예: 키는 기록됐는데 결제 트랜잭션은 실패)을 신중히 다뤄야 합니다.

무엇을 쓰든, 저장하는 값은 단순한 "이 키를 봤다" 플래그가 아니라 첫 요청의 결과 전체여야 한다는 점이 중요합니다. 재시도한 클라이언트에게 원래 응답과 똑같은 것(같은 결제 ID, 같은 상태, 같은 금액)을 돌려줘야 하기 때문입니다.

유니크 제약 — 데이터베이스에게 맡기는 안전장치

멱등성 키를 "먼저 조회하고, 없으면 삽입한다"는 방식으로만 구현하면 미묘한 경쟁 조건이 남습니다. 같은 키를 가진 두 요청이 거의 동시에 도착하면, 둘 다 "이 키는 없네"라고 판단한 뒤 둘 다 결제를 진행할 수 있습니다. 조회와 삽입 사이의 틈이 문제입니다.

이 틈을 근본적으로 막는 가장 견고한 도구가 데이터베이스의 **유니크 제약(unique constraint)**입니다. 멱등성 키 컬럼에 유니크 제약을 걸어 두면, 두 요청이 같은 키로 동시에 삽입을 시도해도 데이터베이스가 그중 하나만 성공시키고 나머지는 거부합니다. "동시에 딱 하나만"을 데이터베이스가 원자적으로 보장해 주는 것입니다.

CREATE TABLE payment_requests (
  idempotency_key TEXT PRIMARY KEY,   -- 유니크 제약: 같은 키 중복 삽입 불가
  status          TEXT NOT NULL,      -- 'in_progress' | 'succeeded' | 'failed'
  response_body   JSONB,              -- 첫 요청의 결과 전체를 저장
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

이 제약을 활용하는 흐름은 대략 이렇습니다.

  1) 이 키로 'in_progress' 행을 INSERT 시도
     - 성공하면 -> 내가 이 작업의 "소유자". 결제를 실제로 처리하고 결과로 행을 UPDATE
     - 유니크 위반으로 실패하면 -> 다른 요청이 이미 이 키를 잡았다는 뜻
         -> 기존 행을 조회:
            - 이미 완료됐으면 저장된 결과를 반환
            - 아직 in_progress면 잠깐 기다렸다 다시 보거나, "처리 중" 응답

핵심은 "먼저 자리를 맡는" 행위 자체를 유니크 제약이 있는 INSERT로 만드는 것입니다. 조회-후-삽입의 틈이 사라지므로, 동시에 들어온 재시도들 사이에서도 정확히 하나만 결제를 실행합니다. 원장 데이터와 같은 트랜잭션에서 이 행을 다루면, 결제 기록과 멱등성 기록이 항상 함께 커밋되어 정합성이 보장됩니다. SQL의 제약과 트랜잭션을 직접 실험해 보고 싶다면, 이런 종류의 스키마와 제약은 이 사이트의 SQL·Postgres 놀이터에서도 시험해 볼 수 있습니다.

결제를 상태 기계로 모델링하기

멱등성을 제대로 다루려면, 결제를 단순한 "성공/실패" 두 값이 아니라 **상태 기계(state machine)**로 모델링하는 것이 좋습니다. 결제는 여러 중간 상태를 거치고, 각 상태에서 재시도나 콜백이 어떻게 작용해야 하는지가 다르기 때문입니다.

전형적인 결제 상태의 흐름은 이렇습니다.

  created ──▶ authorizing ──▶ authorized ──▶ capturing ──▶ captured
     │            │                              │
     │            ▼                              ▼
     │         failed                          failed
  canceled
                (별도 흐름) captured ──▶ refunding ──▶ refunded

각 상태의 의미를 짧게 정리하면 이렇습니다.

  • created: 결제 의도가 만들어졌지만 아직 승인 전.
  • authorizing: 승인 요청을 PG/발급사로 보낸 상태(응답 대기).
  • authorized: 승인 완료, 한도 홀드됨. 아직 매입 전.
  • capturing / captured: 매입 진행/완료.
  • failed / canceled / refunded: 종료 상태들.

상태 기계가 멱등성에 결정적으로 도움이 되는 이유는, 각 상태에서 허용되는 전이를 명확히 정의할 수 있기 때문입니다. 예를 들어 이미 captured인 결제에 매입 요청이 또 오면, 그것은 재시도이므로 다시 매입하지 않고 현재 상태를 그대로 돌려줍니다. authorizing 상태에서 같은 요청이 또 오면 "아직 처리 중"임을 알 수 있습니다. 즉 상태 기계는 "이 요청이 지금 상태에서 유효한가, 아니면 이미 처리된 재시도인가"를 판단하는 근거가 됩니다.

또한 상태를 명시적으로 저장하면 크래시 복구가 쉬워집니다. authorizing 상태로 멈춰 있는 결제가 있으면, 시스템은 PG에 그 거래의 실제 상태를 조회해 authorized인지 failed인지 확인하고 상태를 정정할 수 있습니다. 이런 조회를 흔히 정합성 맞추기(reconciliation) 또는 상태 동기화라 부릅니다.

At-least-once 전달에 dedup 더하기

멱등성은 결제 API뿐 아니라, 결제 시스템 곳곳의 비동기 메시지에서도 핵심입니다. 결제 이벤트는 흔히 메시지 큐를 통해 흐르는데(예: "결제 성공" 이벤트가 정산·알림·회계 서비스로 전파), 대부분의 큐는 at-least-once(최소 한 번) 전달을 보장합니다.

at-least-once의 의미를 정확히 봅시다. 큐는 메시지가 "적어도 한 번" 전달되는 것을 보장하지만, "정확히 한 번"은 보장하지 않습니다. 즉 같은 메시지가 두 번 이상 전달될 수 있습니다. 왜냐하면 소비자가 메시지를 처리한 뒤 "처리 완료(ack)"를 큐에 알리는 도중에 장애가 나면, 큐는 그 메시지가 처리됐는지 알 수 없어 안전하게 다시 보내기 때문입니다. 앞서 본 결제 API의 타임아웃 문제와 똑같은 구조입니다.

  소비자가 "결제 성공" 이벤트를 수신
     -> 처리(예: 잔액 적립, 영수증 발송)
     -> ack를 보내는 도중 장애
        -> 큐는 처리 여부를 모름 -> 재전달
           -> 같은 이벤트가 두 번 처리될 위험

여기서 "정확히 한 번(exactly-once)"을 전달 계층에서 진짜로 보장하기는 매우 어렵습니다. 실무의 표준 해법은 at-least-once 전달 + 소비자 측 dedup입니다. 즉 큐는 최소 한 번 전달만 보장하고, 소비자가 "이 메시지를 이미 처리했는지"를 스스로 확인해 중복을 걸러 내는 것입니다. 이것을 흔히 **멱등 소비자(idempotent consumer)**라 부릅니다.

구현은 결제 API의 멱등성과 같은 원리입니다. 각 메시지에 고유 ID를 부여하고, 소비자는 처리한 메시지 ID를 저장해 둡니다. 새 메시지가 오면 그 ID가 이미 처리 목록에 있는지 확인하고, 있으면 조용히 무시합니다. 여기서도 유니크 제약이 든든한 안전장치가 됩니다. "처리한 메시지 ID"를 유니크 컬럼에 삽입하려다 실패하면, 그것은 곧 "이미 처리했다"는 신호입니다. 큐의 at-least-once, ack, 재전달의 동작을 눈으로 확인하고 싶다면 메시지 큐 놀이터에서 직접 다뤄 볼 수 있습니다.

Stripe 스타일 멱등성

지금까지의 조각들이 실제 제품에서 어떻게 합쳐지는지, Stripe의 멱등성 설계를 예로 정리해 봅시다. Stripe는 멱등성을 API의 일급 기능으로 노출하며, 그 규칙은 이 글에서 다룬 원리를 거의 그대로 따릅니다.

Stripe 멱등성의 핵심 규칙은 이렇습니다.

  • 클라이언트가 Idempotency-Key 헤더에 고유한 키를 넣어 요청합니다. 보통 UUID v4 같은 무작위 값을 씁니다.
  • 같은 키로 요청이 다시 오면, Stripe는 처음 요청의 결과를 그대로 재생(replay)해 돌려줍니다. 결제가 다시 일어나지 않습니다.
  • 멱등성 키는 24시간 동안 유지됩니다. 그 이후 같은 키는 새 요청으로 취급됩니다.
  • 첫 요청이 아직 처리 중일 때 같은 키로 동시 요청이 오면, 에러(예: 409 계열)를 반환해 경합을 막습니다. 클라이언트는 잠시 뒤 재시도하면 됩니다.
  • 같은 키를 다른 요청 본문으로 보내면, Stripe는 이를 오용으로 보고 에러를 냅니다. 하나의 키는 하나의 요청에만 대응해야 하기 때문입니다.
  1차 요청:  Idempotency-Key: K, body: {amount: 5000}
             -> 결제 실행, 결과를 K에 기록, 결과 반환

  재시도:    Idempotency-Key: K, body: {amount: 5000}   (같은 본문)
             -> 기록된 결과를 그대로 재생, 결제 안 일어남

  오용:      Idempotency-Key: K, body: {amount: 9999}   (다른 본문)
             -> 에러 (하나의 키에 서로 다른 요청은 허용 안 함)

이 설계에서 배울 실무 지침을 요약하면 이렇습니다. 클라이언트는 재시도 시 반드시 같은 키를 보낼 것, 하나의 논리적 작업에는 하나의 키만 쓸 것, 그리고 새 결제를 의도할 때만 새 키를 만들 것. 서버는 키를 결과와 함께 저장하고, 유니크 제약으로 동시성을 막고, 적절한 dedup 윈도우를 두는 것입니다.

실무 체크리스트

멱등성을 결제 시스템에 도입할 때 확인할 것들을 압축합니다.

  • 모든 상태 변경 요청은 멱등해야 한다. 결제 생성, 매입, 환불, 취소 모두 재시도 가능해야 합니다. 읽기(GET)는 원래 멱등이지만, 돈을 움직이는 모든 POST에 멱등성 키를 요구하세요.
  • 키는 클라이언트가 생성하고, 재시도 때 재사용한다. 서버가 키를 만들면 재시도의 의미가 사라집니다.
  • 유니크 제약으로 동시성을 막는다. 조회-후-삽입만으로는 경쟁 조건이 남습니다. 데이터베이스의 유니크 제약이 최후의 방어선입니다.
  • 키와 함께 결과를 저장한다. 플래그만 저장하면 재시도한 클라이언트에게 원래 응답을 돌려줄 수 없습니다.
  • 결제를 상태 기계로 모델링한다. 각 상태에서 유효한 전이를 정의하면, 재시도와 크래시 복구가 명확해집니다.
  • 비동기 이벤트에는 멱등 소비자를 둔다. at-least-once 큐를 가정하고, 소비자가 메시지 ID로 중복을 거릅니다.
  • 적절한 dedup 윈도우를 정한다. 너무 짧으면 늦은 재시도를 놓치고, 너무 길면 저장 비용과 정상적인 재사용 충돌 위험이 커집니다. 24시간이 흔한 출발점입니다.

마치며

이중 결제는 코드의 명백한 버그가 아니라, "네트워크는 실패하고, 실패하면 재시도한다"는 분산 시스템의 근본 성질에서 자연스럽게 흘러나오는 문제입니다. 타임아웃은 결제가 됐는지 안 됐는지를 모호하게 만들고, 그 모호함 속에서 재시도가 중복을 낳습니다.

멱등성은 이 매듭을 푸는 열쇠입니다. 클라이언트가 고유한 키로 요청에 이름표를 붙이고, 서버가 그 키로 중복을 걸러 결과를 재생하면, 몇 번을 재시도해도 결제는 딱 한 번만 일어납니다. 여기에 유니크 제약으로 동시성을 잠그고, 상태 기계로 흐름을 명확히 하고, 비동기 경로에는 멱등 소비자를 두면, "재시도해도 안전한" 결제 시스템이 완성됩니다.

핵심 문장 하나로 요약하면 이렇습니다. 재시도를 없애려 하지 말고, 재시도를 안전하게 만들어라. 분산 시스템에서 재시도는 피할 수 없습니다. 그러니 재시도가 해가 되지 않도록, 모든 결제를 멱등하게 설계하는 것이 정답입니다.

참고 자료

Payment Idempotency: Preventing Double Charges

Introduction — The Nightmare of Being Charged Twice

The most common and most frightening bug in a payment system is "the user gets charged twice." If the price of a coffee is deducted twice, the user is annoyed; if a large amount is deducted twice, trust collapses. And this bug happens even without an obvious mistake in the code. The cause is usually the uncertainty of the network and the retries that respond to it.

One fundamental truth of distributed systems is that "the network will eventually fail." And when it fails, the reasonable response a client can take is to retry. But in payments, this reasonable retry can produce the unreasonable result of a double charge.

This post takes on that problem head-on. We will look at why a retry creates a double charge, what idempotency is that prevents it, how to design idempotency keys and dedup windows and unique constraints, how to combine at-least-once delivery with dedup, how to model a payment as a state machine, and how a real system like Stripe implements idempotency. If you want to experiment visually with retries, flow control, and at-least-once delivery, it helps to keep this site's Message Queue Playground open alongside.

What Is Idempotency

Idempotency is a concept from mathematics and computer science. If applying an operation multiple times produces the same result as applying it once, that operation is idempotent.

An everyday example gives you the feel. Whether you press an elevator's floor button once or five times, the result is the same: it goes to that floor. That is idempotent. By contrast, inserting a coin into a vending machine is not idempotent, because each insertion increases the balance.

It is even clearer through HTTP methods.

  • GET is idempotent. Reading the same resource any number of times does not change server state.
  • PUT and DELETE are designed to be idempotent. Writing the same value multiple times, or deleting the same resource multiple times, leaves the same final state.
  • POST is not idempotent by default. It usually creates a new resource or triggers an action, so calling it twice makes it happen twice.

The problem is that a payment is usually a POST. "Creating a payment" is essentially a non-idempotent operation with a side effect (money leaves). What we want to do is make this non-idempotent operation idempotent — that is, ensure that "no matter how many times the same payment request is sent, the payment happens exactly once."

The Retry-and-Timeout Problem

To understand why idempotency is needed, you must see precisely how a retry creates a double charge. The crux is the ambiguity of a timeout.

A client sends a payment request to the server and waits for a response. But a timeout occurs. At this point the only thing the client knows for sure is that "no response arrived within the allotted time." What actually happened could be several things.

  What might actually have happened when a timeout occurred:

  Case A: the request never reached the server    -> not charged.  Must retry.
  Case B: the server processed it but the response was lost  -> charged.  Must not retry.
  Case C: the server is still processing, not done yet  -> in progress.  Retrying risks a race.

Here lies the essence of the problem. The client cannot distinguish A, B, and C. The mere fact of "no response" gives no way to tell whether the payment happened.

At this point the client has two choices. If it does not retry, then it may abandon what was actually Case A and miss the payment (the user tried to pay but did not). If it does retry, then it resends what was actually Case B and creates a double charge.

So without idempotency, you cannot escape this dilemma. Idempotency unties the knot. By guaranteeing "it is safe to retry," the client can retry with confidence: Case A is handled normally, and Case B's duplicate is filtered out. In other words, idempotency is a mechanism that makes retries safe.

Idempotency Keys — Putting a Name Tag on a Request

The standard way to implement idempotency is the idempotency key. The idea is simple: the client attaches a unique identifier to each payment request, and the server uses that key to judge "have I seen this request before?"

  POST /v1/charges
  Idempotency-Key: 9f2c1a7e-...-b3   <- unique key generated by the client
  { "amount": 5000, "currency": "usd", "source": "tok_..." }

The server's processing rule is this.

  On receiving a request:
  1) Have I seen this idempotency key before?
     - No  -> actually process the payment, store (key -> result), then return the result
     - Yes -> return that stored result as-is (do not run the payment again)

Thanks to this simple rule, no matter how many times the client retries with the same key, the payment happens exactly once. Only the first request executes the actual payment; later retries receive a "copy" of the stored first result.

Here who creates the key, and how, matters.

  • The key is generated by the client. That way it can resend the same key on retry. If the server made the key, the client would not know which key to use on retry.
  • Each key should correspond to one logical operation. For example, assign one key to the single operation "pay for cart X," and use that same key for all retries of that operation. Using a sufficiently random value like a UUID removes any worry about accidental collision.
  • When the user presses pay again intending a new operation, a new key must be created. Reusing the same key makes the system treat it as a "retry" and block the new payment. Aligning this well with the UX is important.

Dedup Windows and Storage

To store an idempotency key, you must decide "until when" to store it. This is the dedup window. You cannot keep key-result records forever, so you usually retain them for a fixed period (for example, 24 hours).

The criterion for the window length is "over what time range do retries realistically happen?" Retries due to network errors usually happen within seconds or minutes. But to account for a client that was briefly offline and retries later, in practice many people set a generous window of about a day. Stripe retains idempotency keys for 24 hours.

The store itself should support fast lookups and expiration (TTL). Common choices are these.

  • A dedicated table in a relational DB: it can be handled within the same transaction as the payment's ledger data, which is good for consistency. It pairs well with the unique constraint we will see next.
  • An in-memory store like Redis + TTL: very fast, with automatic expiration. But since it is a separate store from the ledger DB, you must carefully handle consistency between the two (for example, the key was recorded but the payment transaction failed).

Whatever you use, it is important that the value you store is not a simple "I saw this key" flag but the entire result of the first request. You must return to the retrying client exactly the same thing as the original response (same payment ID, same status, same amount).

Unique Constraints — A Safety Net You Hand to the Database

If you implement idempotency keys only with "look up first, insert if absent," a subtle race condition remains. If two requests with the same key arrive almost simultaneously, both may judge "this key does not exist" and then both proceed with the payment. The gap between lookup and insert is the problem.

The most robust tool to fundamentally close this gap is the database's unique constraint. If you put a unique constraint on the idempotency key column, then even if two requests try to insert with the same key at the same time, the database lets exactly one succeed and rejects the rest. The database atomically guarantees "exactly one, concurrently."

CREATE TABLE payment_requests (
  idempotency_key TEXT PRIMARY KEY,   -- unique constraint: no duplicate key inserts
  status          TEXT NOT NULL,      -- 'in_progress' | 'succeeded' | 'failed'
  response_body   JSONB,              -- store the entire result of the first request
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

The flow that leverages this constraint looks roughly like this.

  1) Try to INSERT an 'in_progress' row with this key
     - Succeeds -> I am the "owner" of this operation. Actually process the payment and UPDATE the row with the result
     - Fails on unique violation -> means another request already claimed this key
         -> look up the existing row:
            - if already complete, return the stored result
            - if still in_progress, wait briefly and re-check, or return an "in progress" response

The key is to make the act of "claiming the slot first" itself an INSERT with a unique constraint. The look-up-then-insert gap disappears, so even among simultaneous retries exactly one executes the payment. If you handle this row in the same transaction as the ledger data, the payment record and the idempotency record always commit together, guaranteeing consistency. If you want to experiment with SQL constraints and transactions directly, this kind of schema and constraint can be tried in this site's SQL and Postgres playgrounds too.

Modeling a Payment as a State Machine

To handle idempotency properly, it helps to model a payment not as a simple two-value "success/failure" but as a state machine. A payment passes through several intermediate states, and how a retry or a callback should act differs by state.

A typical payment state flow looks like this.

  created ──▶ authorizing ──▶ authorized ──▶ capturing ──▶ captured
     │            │                              │
     │            ▼                              ▼
     │         failed                          failed
  canceled
                (separate flow) captured ──▶ refunding ──▶ refunded

Briefly, the meaning of each state.

  • created: the intent to pay is made but before authorization.
  • authorizing: the authorization request has been sent to the PSP/issuer (awaiting response).
  • authorized: authorization complete, hold placed. Before capture.
  • capturing / captured: capture in progress / complete.
  • failed / canceled / refunded: terminal states.

The reason a state machine helps decisively with idempotency is that it lets you clearly define the transitions allowed from each state. For example, if a capture request arrives again for a payment already in captured, that is a retry, so you do not capture again and instead return the current state as-is. If the same request arrives again while in authorizing, you can tell it is "still processing." In other words, the state machine becomes the basis for judging "is this request valid in the current state, or is it an already-handled retry?"

Storing state explicitly also makes crash recovery easier. If a payment is stuck in authorizing, the system can query the PSP for that transaction's actual status, confirm whether it is authorized or failed, and correct the state. Such a query is often called reconciliation or state synchronization.

Adding Dedup to At-Least-Once Delivery

Idempotency is central not only to the payment API but also to the asynchronous messages throughout a payment system. Payment events often flow through a message queue (for example, a "payment succeeded" event propagated to the settlement, notification, and accounting services), and most queues guarantee at-least-once delivery.

Let us look precisely at what at-least-once means. The queue guarantees a message is delivered "at least once," but not "exactly once." That is, the same message can be delivered more than once. Because if a failure occurs while the consumer, after processing a message, is telling the queue "done (ack)," the queue cannot know whether the message was processed and safely resends it. It is exactly the same structure as the payment API's timeout problem we saw earlier.

  Consumer receives a "payment succeeded" event
     -> processes it (e.g. credit balance, send receipt)
     -> failure while sending the ack
        -> the queue does not know whether it was processed -> redelivers
           -> risk of the same event being processed twice

Truly guaranteeing "exactly-once" at the delivery layer is very hard. The standard solution in practice is at-least-once delivery + dedup on the consumer side. That is, the queue guarantees only at-least-once delivery, and the consumer checks for itself whether "I already processed this message" and filters out duplicates. This is commonly called an idempotent consumer.

The implementation follows the same principle as the payment API's idempotency. Give each message a unique ID, and have the consumer store the IDs of processed messages. When a new message arrives, check whether its ID is already in the processed list, and if so, silently ignore it. Here too a unique constraint becomes a sturdy safety net. If inserting the "processed message ID" into a unique column fails, that failure is itself the signal that "it was already processed." If you want to observe the queue's at-least-once, ack, and redelivery behavior, you can work with it directly in the Message Queue Playground.

Stripe-Style Idempotency

Let us summarize how these pieces come together in a real product, using Stripe's idempotency design as an example. Stripe exposes idempotency as a first-class feature of its API, and its rules follow the principles covered in this post almost exactly.

The core rules of Stripe's idempotency are these.

  • The client makes the request with a unique key in the Idempotency-Key header. It is usually a random value like a UUID v4.
  • If a request comes again with the same key, Stripe replays and returns the result of the first request as-is. The payment does not happen again.
  • The idempotency key is retained for 24 hours. After that, the same key is treated as a new request.
  • If a concurrent request comes with the same key while the first is still processing, it returns an error (a 409-family response) to prevent a race. The client can just retry a moment later.
  • If you send the same key with a different request body, Stripe treats it as misuse and returns an error. One key must correspond to only one request.
  1st request: Idempotency-Key: K, body: {amount: 5000}
               -> run payment, record result under K, return result

  retry:       Idempotency-Key: K, body: {amount: 5000}   (same body)
               -> replay the recorded result, payment does not happen

  misuse:      Idempotency-Key: K, body: {amount: 9999}   (different body)
               -> error (different requests under one key are not allowed)

To summarize the practical guidance to learn from this design: the client must send the same key on retry, use only one key per logical operation, and create a new key only when intending a new payment. The server stores the key together with the result, prevents concurrency with a unique constraint, and sets an appropriate dedup window.

Practical Checklist

Here is a compressed list of things to verify when introducing idempotency into a payment system.

  • Every state-changing request must be idempotent. Payment creation, capture, refund, and cancel must all be retryable. Reads (GET) are inherently idempotent, but require an idempotency key on every POST that moves money.
  • The client generates the key and reuses it on retry. If the server makes the key, the meaning of a retry disappears.
  • Prevent concurrency with a unique constraint. Look-up-then-insert alone leaves a race condition. The database's unique constraint is the last line of defense.
  • Store the result together with the key. If you store only a flag, you cannot return the original response to a retrying client.
  • Model the payment as a state machine. Defining valid transitions from each state makes retries and crash recovery clear.
  • Put an idempotent consumer on asynchronous events. Assume an at-least-once queue and have the consumer filter duplicates by message ID.
  • Set an appropriate dedup window. Too short and you miss late retries; too long and storage cost and the risk of colliding with a legitimate reuse grow. 24 hours is a common starting point.

Conclusion

A double charge is not an obvious bug in the code but a problem that flows naturally from the fundamental nature of distributed systems: "the network fails, and when it fails, clients retry." A timeout makes it ambiguous whether the payment happened, and within that ambiguity a retry produces a duplicate.

Idempotency is the key that unties this knot. When the client tags a request with a unique key and the server filters duplicates by that key and replays the result, the payment happens exactly once no matter how many times you retry. Add a unique constraint to lock down concurrency, a state machine to clarify the flow, and an idempotent consumer on the asynchronous paths, and you have a payment system that is "safe to retry."

To summarize in a single sentence: do not try to eliminate retries; make retries safe. In distributed systems retries are unavoidable. So the right answer is to design every payment to be idempotent, so that retries do no harm.

References