Your checkout sends a payment request. The connection times out. The customer presses Pay again.
The useful question is what your system knows about the first instruction. It may be pending, its processor outcome may be unknown, or a terminal response may already be stored. Those states require different actions.
We will build a small reference state machine that makes those actions visible, implement it in Python and TypeScript, and step through a timeout in a guided lab. The model never calls a payment provider. It teaches the contract a real service must preserve around that call.
Give one instruction a stable identity
An idempotency key identifies an instruction within a namespace. In this tutorial the namespace is:
(merchant_id, operation, key)
That is a structured tuple. Concatenating fields with an unescaped separator can make different tuples collide. The implementations use a tuple in Python and a JSON-encoded tuple in TypeScript.
For one merchant's capture request, the payload signature is:
("payment-v1", payment_id, amount_minor, currency)
The first request binds this signature to the scoped key. A retry with the same signature refers to the same instruction. Changing the amount, currency, or payment resource while retaining that identity produces a conflict.
The related foundation tutorial Identifiers, Keys, Joins, and Data Grain explains why identity depends on context. Here, shop-a / capture / K1 and shop-b / capture / K1 are distinct contexts. Capture and refund also have distinct scopes.
The small signature is an explicit teaching choice. A real adapter must include every field capable of changing the operation, such as a destination or fee instruction. Keep integer money in upstream-defined minor units. Our USD example uses 2,500 cents, or USD 25.00; the reducer does not infer scales for other currencies.
Keep uncertainty visible
Look at the timeout branch in this diagram. It preserves the original attempt.
Three stored states are enough for the lesson:
| State | Meaning | Matching retry |
|---|---|---|
| Pending | Submission was reserved; no terminal response is stored | Wait: IN_PROGRESS |
| Unknown | The processor outcome is uncertain | Reconcile: RECONCILE |
| Completed | Trusted internal evidence supplied a terminal response | Return it: REPLAY |
A timeout does not establish a failed payment. A raw server error also cannot establish that by itself. If a processor may have acted, keep the outcome unknown and investigate the original attempt. A completed resolution is a trusted internal observation; it must never be accepted directly from an untrusted checkout client.
Observation time is when your system learns a result. It can be later than the underlying action. This is the same distinction introduced in Timestamps, Time Zones, Calendars, and Observation Time. Our event array is ordered by knowledge time, so a future response cannot repair an earlier decision retrospectively.
Decide at one atomic boundary
The request path is a lookup followed by one controlled transition:
Only creating a reservation permits a processor submission. The reference reducers serialize events as if each transition committed atomically. They are single-process educational code, with no durable storage or distributed lock.
In a real service, concurrent requests need a unique durable reservation rather than separate unlocked reads and inserts. PostgreSQL's unique conflict arbitration is one relevant database primitive. It does not make the database commit and a remote processor call one atomic action. Recovery across that gap still needs a durable workflow, processor-side identity, and reconciliation.
Walk through a synthetic timeout
Every amount, identifier, time, and outcome below is an invented teaching input. Expected decisions are author-derived, with a 100 ms completed-record retention interval. These milliseconds are selected to expose a boundary, not recommend a production duration.
| Time | What the service observes | Decision |
|---|---|---|
| 0 ms | shop-a requests capture K1 for P1, 2,500 USD cents | EXECUTE, attempt-1 |
| 1 ms | Another matching request arrives | IN_PROGRESS |
| 2 ms | A trusted worker reports an uncertain outcome | MARKED_UNKNOWN |
| 3 ms | The matching request arrives again | RECONCILE |
| 4 ms | Trusted terminal response: 201, CAPTURED, pay-01 | STORED |
| 5 ms | The matching request arrives | REPLAY of the saved response |
| 6 ms | Same scoped key asks for 2,600 cents | CONFLICT |
No additional submission permission is emitted between the first reservation and that replay. The client can receive the stored result even though its original network response was lost.
The full 12-event fixture then changes the boundaries: shop-b uses K1 in its own namespace; shop-a uses K2 for the same payment; and shop-a reuses K1 exactly at expiry. Each can create a separate reservation. Four reservations appear across the full trace, with three terminal results. That does not prove four completed charges.
Make retention part of the contract
Our application policy starts retention when a terminal response is stored:
Here is retention in milliseconds. The original result was stored at 4 ms, so the author-derived expiry is 4 + 100 = 104 ms. At 103 ms a matching retry replays. At exactly 104 ms the next request may remove the completed record and reserve a new generation, producing EXPIRED_KEY_EXECUTE.
Pending and unknown records never auto-expire in this model. A recovery process must resolve them; a clock advancing does not establish that no funds moved.
The narrow invariant for one scoped key and retained generation is:
The indicator counts permission to submit. It does not guarantee end-to-end exactly-once settlement, protection after losing the state, or deduplication across different keys. A permanent business constraint may be required when an instruction must never repeat.
Implement a reducer you can inspect
The Python and TypeScript implementations take a batch of events plus retention_ms and return one immutable output snapshot per event. No network or financial account is touched. This excerpt illustrates the request branch; complete runtime validation, resolutions, and stale-token checks are in the downloadable implementations.
if (!row) {
// Create one reservation at the atomic boundary.
decision = expired ? "EXPIRED_KEY_EXECUTE" : "EXECUTE";
} else if (row.signature !== signature) {
decision = "CONFLICT";
} else if (row.state === "pending") {
decision = "IN_PROGRESS";
} else if (row.state === "unknown") {
decision = "RECONCILE";
} else {
decision = "REPLAY";
}
Each output includes execute, an attempt token, current state, expiry, and a nullable cached response. Attempt tokens stop stale workers from replacing a newer local generation. They cannot undo a payment a remote worker already submitted.
The strict JSON schema rejects missing or extra fields, nonpositive or fractional amounts, booleans, nonfinite values, unsafe integers, malformed identifiers, unsupported operations, and reversed observation times. Currency is a three-letter uppercase label, not an assertion of ISO membership. All numeric operations stay within the JavaScript safe-integer range, and expiry addition is checked before processing.
Test meaning across languages
The same fixture file defines 13 valid and 23 invalid batches. Both implementations must match the expected decisions and error codes, and the parity check compares complete output objects. Additional checks cover:
- 500 matching pending retries with exactly one reservation.
- Exact expiry equality, and replay one millisecond before it.
- Pending and unknown states surviving the completed-record retention interval.
- Changed payloads, tenant/operation isolation, and collision-safe scope encoding.
- A stale completion after a newer generation was reserved.
- Immutable terminal outcomes, including declines and determinate error responses.
- Prefix causality: processing the first few events yields the same earlier outputs as the full trace.
- Input isolation and response snapshots that cannot mutate one another.
These checks prove the reference contract under serialized transitions. Database races, storage recovery, webhook authenticity, and real processor integration require additional implementation-specific tests.
Try the guided lab
The embedded Payment retry lab uses the same reference logic and synthetic scenarios. Start with the canonical timeout, then choose the expiry/stale-worker scenario or the failure/unknown scenario. Step through events, move back, play, pause, reset, or change completed-record retention. Notice that changing retention affects completed expiry while unknown attempts still require reconciliation.
The lab begins after the first reservation so the initial state is informative. Its audit table reveals results only through the current observation. Reduced-motion mode advances one step at a time without a playback timer.
The static diagrams and worked table above preserve the lesson without scripts. Open the standalone lab.
Compare actual provider contracts
The model is an application-owned policy. Provider adapters must preserve the actual provider's version and scope:
| Source | Difference to carry into an adapter |
|---|---|
| Stripe | API v1 uses saved-response replay and parameter comparison; v2 differs |
| Adyen | Account/regional scope and concurrent in-progress handling matter |
| PayPal | IDs depend on operation, while retries can return current status |
Sources checked 24 October 2026. No universal processor retention duration is asserted. Historical-example decision: not useful; these synthetic transitions teach the exact boundary without inventing an incident or observed financial loss.
Connect submission safety to accounting
You can now explain which requests may execute, preserve uncertainty after a timeout, replay a terminal response, and test where a key's protection ends. The next proposed tutorial is Double-Entry Posting Engine, followed by Idempotent Journal Posting and Three-Way Payment Reconciliation. Those catalog topics remain unbuilt; this lesson establishes the payment identity they can build on.
See the canonical README and REFERENCES.md in this package for the full schema, evidence roles, and reproducibility commands.
Rendered from the canonical Mermaid sources linked by this article.
The atomic request decision
This compact workflow locates the atomic decision between validation and output. The state/action table and guided lab expose its individual branches without shrinking a large decision tree on phones.
Only reservation creates submission permission. Completed-record expiry is evaluated before this workflow; pending and unknown records are retained. EXPIRED_KEY_EXECUTE identifies reservation after completed expiry.
References4 primary sources and evidence notesExpand the source trail, evidence role, and limitations behind the engineering choices.
Expand the source trail, evidence role, and limitations behind the engineering choices.
Opened 24 October 2026. Public API references are mutable; recheck exact versions before building an adapter. The state machine and fixtures are author choices, not observed processor behavior or payment-network rules.
R01 — Stripe, Idempotent requests
- Type: official API reference; v1 comparison with a v2 distinction.
- Date/version: mutable page accessed 2026-10-24; no fixed publication date.
- URL: Idempotent requests.
- Jurisdiction: platform contract, not a legal standard.
- Supports: saved response and parameter comparison as provider-specific examples.
- Limitations: does not define this application's scope, timing, storage, or recovery.
- Role: sourced platform facts; no provider dataset redistributed.
R02 — Adyen, API idempotency
- Type: official platform documentation.
- Date/version: mutable page accessed 2026-10-24.
- URL: API idempotency.
- Jurisdiction: account and regional API behavior.
- Supports: namespace/region boundaries and concurrent in-progress treatment.
- Limitations: not an Adyen adapter or a full reproduction of its retry policy.
- Role: sourced platform comparison.
R03 — PayPal, Idempotency
- Type: official REST API documentation.
- Date/version: page states last updated 2026-08-11; accessed 2026-10-24.
- URL: Idempotency.
- Jurisdiction: API-specific contract.
- Supports: operation-specific IDs and current-status behavior.
- Limitations: support/retention depend on the chosen API; no universal interval asserted.
- Role: sourced platform comparison.
R04 — PostgreSQL 18, INSERT
- Organization: PostgreSQL Global Development Group.
- Type/version: official PostgreSQL 18 documentation,
ON CONFLICTsection. - URL: INSERT.
- Accessed: 2026-10-24.
- Jurisdiction: database semantics, no financial regulation implied.
- Supports: unique conflict arbitration and atomic database insert/update outcomes.
- Limitations: cannot make a remote processor call atomic with a local transaction. This package is an in-memory reducer, not a database integration.
- Role: sourced design boundary.
Data and historical decision
Historical example: not useful. A famous outage would add narrative without proving these semantics. No real customer, merchant, or security is identified. Fixtures are author-created synthetic inputs with derived expected decisions. No market feed, processor payload, confidential data, credential, or licensed transaction dataset is used.
Full dependency-light reference implementations in both supported languages.
/** Pure reference reducer. Each event models one atomic committed transition.
* This module performs no I/O, durable reservation, or processor submission.
*/
export type TerminalResponse = { status_code: number; result_code: string; provider_reference: string | null };
type Base = { type: string; at_ms: number; merchant_id: string; operation: 'capture' | 'refund'; key: string };
export type RequestEvent = Base & { type: 'request'; payment_id: string; amount_minor: number; currency: string };
export type ResolveEvent = Base & { type: 'resolve'; attempt_id: string } &
({ resolution: 'unknown' } | { resolution: 'completed'; response: TerminalResponse });
export type PaymentEvent = RequestEvent | ResolveEvent;
export type Decision = {
decision: string; execute: boolean; attempt_id: string | null;
state: 'pending' | 'unknown' | 'completed' | null;
expires_at_ms: number | null; response: TerminalResponse | null;
};
type RecordState = {
signature: string; attempt_id: string; state: 'pending' | 'unknown' | 'completed';
expires_at_ms: number | null; response: TerminalResponse | null;
};
export class PaymentInputError extends Error {
constructor(public readonly code: string = 'INVALID_INPUT') { super(code); }
}
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
const BASE_FIELDS = ['type', 'at_ms', 'merchant_id', 'operation', 'key'];
function requireInput(condition: unknown, code = 'INVALID_INPUT'): asserts condition {
if (!condition) throw new PaymentInputError(code);
}
function object(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function integer(value: unknown, minimum = 0): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum;
}
function identifier(value: unknown): value is string {
// Compare the complete match: JS '$' can otherwise accept a trailing newline.
return typeof value === 'string' && value.match(IDENTIFIER)?.[0] === value;
}
function keys(value: Record<string, unknown>, expected: string[]): boolean {
return JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...expected].sort());
}
function copyResponse(response: TerminalResponse): TerminalResponse {
return { status_code: response.status_code, result_code: response.result_code, provider_reference: response.provider_reference };
}
function validate(events: unknown, retentionMs: unknown): asserts events is PaymentEvent[] {
requireInput(integer(retentionMs, 1));
requireInput(Array.isArray(events));
let previous = -1;
for (const event of events) {
requireInput(object(event));
requireInput(event.type === 'request' || event.type === 'resolve');
requireInput(event.operation === 'capture' || event.operation === 'refund', 'UNSUPPORTED_OPERATION');
requireInput(identifier(event.merchant_id) && identifier(event.key));
requireInput(integer(event.at_ms) && event.at_ms <= Number.MAX_SAFE_INTEGER - retentionMs);
requireInput(event.at_ms >= previous, 'OUT_OF_ORDER');
previous = event.at_ms;
if (event.type === 'request') {
requireInput(keys(event, [...BASE_FIELDS, 'payment_id', 'amount_minor', 'currency']));
requireInput(identifier(event.payment_id) && integer(event.amount_minor, 1));
requireInput(typeof event.currency === 'string' && /^[A-Z]{3}$/.test(event.currency) && event.currency.length === 3);
} else {
requireInput(event.resolution === 'unknown' || event.resolution === 'completed');
const expected = [...BASE_FIELDS, 'attempt_id', 'resolution'];
if (event.resolution === 'completed') expected.push('response');
requireInput(keys(event, expected) && identifier(event.attempt_id));
if (event.resolution === 'completed') {
const response = event.response;
requireInput(object(response) && keys(response, ['status_code', 'result_code', 'provider_reference']));
requireInput(integer(response.status_code, 200) && response.status_code <= 599);
requireInput(identifier(response.result_code));
requireInput(response.provider_reference === null || identifier(response.provider_reference));
}
}
}
}
export function simulate(events: unknown, retentionMs: unknown = 100): Decision[] {
validate(events, retentionMs);
const retention = retentionMs as number;
const records = new Map<string, RecordState>();
const outputs: Decision[] = [];
let sequence = 0;
for (const event of events) {
const scope = JSON.stringify([event.merchant_id, event.operation, event.key]);
let row = records.get(scope);
let expired = false;
if (event.type === 'request' && row?.state === 'completed' && event.at_ms >= row.expires_at_ms!) {
records.delete(scope);
row = undefined;
expired = true;
}
let decision: string;
let exposeResponse = false;
if (event.type === 'request') {
const signature = JSON.stringify(['payment-v1', event.payment_id, event.amount_minor, event.currency]);
if (!row) {
row = { signature, attempt_id: `attempt-${++sequence}`, state: 'pending', expires_at_ms: null, response: null };
records.set(scope, row);
decision = expired ? 'EXPIRED_KEY_EXECUTE' : 'EXECUTE';
} else if (row.signature !== signature) decision = 'CONFLICT';
else if (row.state === 'pending') decision = 'IN_PROGRESS';
else if (row.state === 'unknown') decision = 'RECONCILE';
else { decision = 'REPLAY'; exposeResponse = true; }
} else if (!row) decision = 'NO_RECORD';
else if (row.attempt_id !== event.attempt_id) decision = 'STALE_RESOLUTION';
else if (row.state === 'completed') {
if (event.resolution === 'completed' && JSON.stringify(row.response) !== JSON.stringify(copyResponse(event.response))) {
decision = 'FINAL_CONFLICT';
} else { decision = 'ALREADY_FINAL'; exposeResponse = true; }
} else if (event.resolution === 'unknown') { row.state = 'unknown'; decision = 'MARKED_UNKNOWN'; }
else {
row.state = 'completed'; row.response = copyResponse(event.response);
row.expires_at_ms = event.at_ms + retention;
decision = 'STORED'; exposeResponse = true;
}
outputs.push({
decision, execute: decision === 'EXECUTE' || decision === 'EXPIRED_KEY_EXECUTE',
attempt_id: row?.attempt_id ?? null, state: row?.state ?? null,
expires_at_ms: row?.expires_at_ms ?? null,
response: row?.response && exposeResponse ? copyResponse(row.response) : null,
});
}
return outputs;
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.
