Idempotency Keys skill for Codex
Retries that never double charge. Codex first looks for natural idempotency such as client IDs or unique constraints, and only then adds an Idempotency-Key table that commits with the business write. Requests are fingerprinted, concurrent duplicates get 409, reused keys with new payloads get 422, completed results replay exactly, and provider calls receive derived keys so a lost response cannot cause a second charge.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- idempotency-keys Installs to ~/.agents/skills/idempotency-keys/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install idempotency-keys The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project idempotency-keys Installs to .agents/skills/idempotency-keys/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/idempotency-keys.zipunzip -o idempotency-keys.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) idempotency-keys PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $idempotency-keys, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Makes unsafe API operations safe to retry using Idempotency-Key headers, a key store in the same database as the business write, request fingerprinting, in-progress locking, stored-response replay and expiry, plus natural idempotency via unique constraints and client-generated IDs. Use when building payment, order, transfer or provisioning endpoints, when clients or gateways retry POSTs, when duplicate charges or double-created records appear, or when a downstream provider needs idempotency keys passed through.
Use it when
- Building payment, order, transfer or provisioning endpoints
- Clients, SDKs or gateways retry POST requests
- Duplicate records or double charges have appeared
- Calling providers that accept idempotency keys
Use something else when
- Deduplicating incoming third-party events: use webhook-receivers
- Making queue consumers safe to redeliver: use background-jobs
How to check the result
Twenty parallel requests with one key create exactly one record, later retries replay the same status and body, and the same key with a different payload returns 422.
The mistake it prevents
Storing idempotency keys in Redis while the order is written to Postgres, so a crash between the two leaves the key and the record out of sync.
An operation is idempotent when a client can send the same request any number of times, concurrently or after a timeout, and the system performs the side effect once and returns the same answer each time. Duplicates are prevented by the database, not by hoping retries do not overlap.
Choose the mechanism
| Situation | Mechanism |
|---|---|
| Resource identity can come from the client | Client-generated ID + PUT /orders/{id} or a unique constraint; no key table needed |
| Business has a natural unique key (invoice number, external event ID) | Unique constraint on it; on conflict return the existing record |
| POST that creates or charges, client cannot name the resource | Idempotency-Key header with a stored-response table |
| Multi-step operation calling external providers | Idempotency key plus recovery points per step, and pass a derived key to each provider |
| Message or webhook consumer | Dedupe table keyed by message/event ID (see webhook-receivers, background-jobs) |
Prefer the simplest row that fits; the header-and-table approach is for when nothing natural exists.
Workflow
- [ ] 1. Find the unsafe operations and current retry sources
- [ ] 2. Define the key contract
- [ ] 3. Create the key store
- [ ] 4. Implement the claim / execute / record flow
- [ ] 5. Propagate keys to downstream providers
- [ ] 6. Test concurrency, replay and mismatch
1. Find unsafe operations
Read AGENTS.md, then list POST/PATCH handlers that create money movements, orders,
emails, external resources or quota consumption. Note who retries them: mobile clients
on flaky networks, SDK retry policies, API gateways, queue redelivery, users
double-clicking.
2. Key contract
- Header:
Idempotency-Key, client-generated, 1-255 characters; recommend UUIDv4/v7. - Scope keys by caller (account or API key) so two customers cannot collide.
- The client generates one key per logical operation and reuses it on every retry of that operation. A new key per HTTP attempt defeats the mechanism.
- Retention: 24 hours minimum; match the longest client retry horizon.
- Responses, aligned with the IETF idempotency-key draft and common practice:
| Case | Response |
|---|---|
| First request | Execute, store, return the result |
| Same key, same payload, completed | Replay stored status and body; add Idempotent-Replayed: true |
| Same key, still in progress | 409 Conflict with Retry-After: 1 |
| Same key, different payload | 422 Unprocessable Content (key reuse error) |
| Key missing on an endpoint that requires it | 400 with a clear error |
3. Key store
Store keys in the same database and transaction as the business write whenever possible, so “record created” and “key completed” commit together.
CREATE TABLE idempotency_keys (
account_id bigint NOT NULL,
idem_key text NOT NULL,
request_hash bytea NOT NULL,
method text NOT NULL,
path text NOT NULL,
state text NOT NULL DEFAULT 'started', -- started | completed
recovery_point text NOT NULL DEFAULT 'started',
locked_until timestamptz,
response_code int,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, idem_key)
);
CREATE INDEX idempotency_keys_created_at ON idempotency_keys (created_at);
Use Redis (SET key value NX PX ttl) only when the protected side effect also lives
outside the relational database; otherwise you lose atomicity.
4. Claim, execute, record
const hash = sha256(canonicalJson({ method: req.method, path: req.path, body: req.body }));
await db.tx(async (tx) => {
const claimed = await tx.query(
`INSERT INTO idempotency_keys (account_id, idem_key, request_hash, method, path, locked_until)
VALUES ($1, $2, $3, $4, $5, now() + interval '60 seconds')
ON CONFLICT (account_id, idem_key) DO NOTHING
RETURNING idem_key`,
[accountId, key, hash, req.method, req.path],
);
if (claimed.rowCount === 0) {
const { rows: [row] } = await tx.query(
`SELECT * FROM idempotency_keys WHERE account_id = $1 AND idem_key = $2 FOR UPDATE`,
[accountId, key],
);
if (!row.request_hash.equals(hash)) return reply(422, problem('idempotency_key_reused'));
if (row.state === 'completed') return replay(row.response_code, row.response_body);
if (row.locked_until > new Date()) return reply(409, problem('request_in_progress'), { 'Retry-After': '1' });
// lease expired: previous attempt crashed; take over and resume from row.recovery_point
}
const result = await createOrder(tx, req.body); // business write, same tx
await tx.query(
`UPDATE idempotency_keys
SET state = 'completed', response_code = $3, response_body = $4, locked_until = NULL
WHERE account_id = $1 AND idem_key = $2`,
[accountId, key, 201, result],
);
return reply(201, result);
});
Rules:
- In this single-transaction form, a concurrent duplicate blocks on the unique index
until the first transaction commits, then replays. Set a
lock_timeoutso it fails fast instead of queueing. The 409 and lease-takeover branches matter in the multi-phase form below, where the claim commits in its own transaction first. - Fingerprint the canonicalized request (sorted JSON keys, method, path), not raw bytes; harmless whitespace differences should not trigger 422.
- Store the final response for completed business outcomes, including deterministic 4xx results (for example “card declined”), so retries see the same answer.
- On transient failures before any side effect committed (database timeout, 503 from a dependency), roll back so the key is not stored and the client can retry the same key.
- With external calls in the middle, a single transaction cannot cover everything:
split into phases, update
recovery_pointafter each committed phase, and on takeover resume from the recorded point instead of starting over.
5. Downstream providers
- Pass a derived key to providers that support it (Stripe and many payment APIs accept
Idempotency-Key): for example${idem_key}:charge. A retry then cannot double charge even if your record of the first call was lost. - Emails and messages sent as side effects go through an outbox written in the same transaction (see outbox-pattern), so replays do not resend them.
6. Expiry
Delete expired keys with a scheduled job in batches:
DELETE FROM idempotency_keys
WHERE ctid IN (
SELECT ctid FROM idempotency_keys
WHERE created_at < now() - interval '24 hours'
LIMIT 5000
);
(Postgres-specific ctid batching; on other databases batch by primary key.)
Gotchas
- Checking for an existing key and inserting it in two statements without a unique constraint lets two concurrent requests both proceed.
- Keys scoped globally rather than per account let one customer’s key block another’s.
- Middleware that caches responses in memory per instance does nothing for retries that land on a different instance.
- Replaying stored responses must also replay the status code and relevant headers
(
Location), not just the body. - Retrying with the same key but a different amount is a client bug; answering 422 surfaces it instead of silently returning the old result.
- Idempotency does not make the operation safe to run twice with different keys: double-clicks in the UI need the client to reuse one key per form submission.
Verification
- Concurrency test: send the same keyed request 20 times in parallel; exactly one business record exists, one response is the original, others are replays or 409s.
- Replay test: repeat after completion; identical status, body and
Location, withIdempotent-Replayed: true. - Mismatch test: same key, different body returns 422.
- Crash test: kill the handler after the first phase (or simulate with a thrown error) and confirm a retry after the lease completes the operation exactly once.
- Check the expiry job deletes old keys and that the table has an index supporting it.
Output template
## Idempotency for <endpoint>
- Mechanism: <client ID | unique constraint | Idempotency-Key table>
- Key scope and retention: <account, 24h>
- Fingerprint: <fields hashed>
- Responses: replay / 409 in-progress / 422 mismatch / 400 missing
- Downstream keys: <provider: derived key format>
- Tests: concurrency, replay, mismatch, crash-resume What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Idempotency Keys"
short_description: "Retries that never double charge"
default_prompt: "$idempotency-keys Make POST /payments safe to retry with an Idempotency-Key header" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.