What idempotency means
An operation is idempotent when doing it once or doing it many times gives the same result.
In plain words: if the same request reaches your server twice, the second one should not do the work again. It should return the same result as the first.
Pressing a lift button five times is idempotent. The lift still comes once. Transferring money is not naturally idempotent. Sending the same transfer twice moves the money twice, unless the system is designed to recognise the repeat.
Why duplicate requests happen
Duplicates are not caused by careless users. They are a normal part of how networks work.
- Double clicks and double taps. The user presses the button again because the screen did not respond fast enough.
- Mobile network drops. The request reaches the server and the payment succeeds, but the response is lost on the way back. The app thinks it failed and retries.
- Automatic retries. HTTP clients, load balancers and SDKs retry on timeouts. That is usually good, but it means the server can receive the same request more than once.
- Webhooks. Payment gateways, messaging platforms and most third-party services deliver webhooks "at least once". If your server is slow to reply, they send the same event again.
- Queue workers. Background job systems may run the same job twice after a crash or a timeout.
Since you cannot prevent duplicates from arriving, the server has to be able to handle them safely.
Which HTTP methods are already idempotent
The HTTP standard defines some methods as idempotent by design:
| Method | Idempotent? | Example |
|---|---|---|
GET | Yes | Fetch an order. Fetching it twice changes nothing. |
PUT | Yes | Set an order's status to "shipped". Setting it twice leaves it "shipped". |
DELETE | Yes | Delete a booking. After the first call it is gone; the second changes nothing. |
POST | No | Create a payment. Two calls create two payments. |
PATCH | Not guaranteed | "Add 1 to stock" is not idempotent; "set stock to 10" is. |
The dangerous operations are almost always POST requests that create something or trigger a side effect: charging a card, placing an order, creating an invoice, sending a message. Those are the ones that need protection.
How an idempotency key works
The standard solution, used by most major payment APIs, is an idempotency key. It is a unique ID that the client creates once for each user action and sends with the request, usually in an Idempotency-Key header.
- The user taps "Pay". The app creates a unique key for this action, such as a UUID.
- The app sends the request with the key in the
Idempotency-Keyheader. - The server checks whether it has seen this key before.
- If the key is new, the server does the work, saves the result against the key and returns it.
- If the key has already been completed, the server skips the work and returns the saved result.
- If the app retries because of a timeout, it sends the same key, so the customer is charged only once.
The important rule for the client: create the key once per user action, and reuse it for every retry of that action. A new key on every retry defeats the purpose.
Example: an idempotent payment endpoint in Node.js and PostgreSQL
First, a table to remember keys and their results. The primary key on key is what guarantees that only one request can claim it, even if two arrive at the same moment.
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'processing',
response_code INT,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Then the endpoint, using Express and the pg library:
const crypto = require('crypto');
const hashBody = (body) =>
crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex');
app.post('/payments', async (req, res, next) => {
const key = req.get('Idempotency-Key');
if (!key) {
return res.status(400).json({ error: 'Idempotency-Key header is required' });
}
const requestHash = hashBody(req.body);
try {
// 1. Try to claim the key. Only one request can insert it.
const claim = await db.query(
`INSERT INTO idempotency_keys (key, request_hash)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
RETURNING key`,
[key, requestHash]
);
// 2. Key already exists: this is a repeat request.
if (claim.rowCount === 0) {
const { rows: [saved] } = await db.query(
`SELECT request_hash, status, response_code, response_body
FROM idempotency_keys WHERE key = $1`,
[key]
);
if (saved.request_hash !== requestHash) {
return res.status(422).json({ error: 'Key was already used for a different request' });
}
if (saved.status === 'processing') {
return res.status(409).json({ error: 'The original request is still being processed' });
}
return res.status(saved.response_code).json(saved.response_body);
}
// 3. New key: do the work once.
// Pass the same key to the payment provider if it supports idempotency.
const payment = await chargeCustomer(req.body, { idempotencyKey: key });
const body = { id: payment.id, status: payment.status };
// 4. Save the result so repeats get the same answer.
await db.query(
`UPDATE idempotency_keys
SET status = 'done', response_code = 201, response_body = $2
WHERE key = $1`,
[key, body]
);
return res.status(201).json(body);
} catch (err) {
return next(err);
}
});A few details that matter in production:
- Check the request body. If the same key arrives with a different amount, reject it. Otherwise a bug in the client could silently return the wrong payment.
- Handle "still processing". If two identical requests arrive at the same moment, the second should wait or get a 409, not start a second charge.
- Pass the key downstream. If your payment provider supports idempotency keys, send the same key to it. Then even a crash between charging and saving the result cannot cause a double charge.
- Expire old keys. Keep keys for a sensible window, such as 24 hours to a few days, then clean them up with a scheduled job.
Example: de-duplicating webhooks
For incoming webhooks, the provider usually gives each event a unique ID. Store the IDs you have processed, and do the insert and the work in one database transaction. If the work fails, the transaction rolls back, the event is not marked as processed, and the provider's retry can try again.
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);app.post('/webhooks/payments', async (req, res) => {
// Always verify the webhook signature first (provider-specific).
const event = req.body;
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rowCount } = await client.query(
`INSERT INTO processed_events (event_id)
VALUES ($1)
ON CONFLICT (event_id) DO NOTHING`,
[event.id]
);
if (rowCount === 1) {
// First time we see this event: do the work in the same transaction.
await handleEvent(event, client);
}
// rowCount === 0 means we already processed it: do nothing.
await client.query('COMMIT');
res.sendStatus(200);
} catch (err) {
await client.query('ROLLBACK');
res.sendStatus(500); // The provider will retry later.
} finally {
client.release();
}
});One caution: if handleEvent calls an outside service, such as sending a WhatsApp message or an email, that call cannot be rolled back. For those, save a "pending message" row inside the transaction, and let a separate worker send it and mark it as sent. This pattern is often called an outbox.
Idempotency on the client side
The server does the heavy lifting, but the app can help:
- Create the key when the user starts the action, for example when the checkout screen opens or the "Pay" button is first pressed, using
crypto.randomUUID(). - Keep the key for retries. Store it in memory, or in local storage for long flows, until the server confirms success.
- Disable the button after the first tap, and show a clear loading state. This reduces duplicates but never replaces server-side protection.
- Treat timeouts as "unknown", not "failed". Retry with the same key, or check the status, before asking the user to pay again.
Where small businesses feel this most
Idempotency sounds technical, but the problems it prevents are very visible to customers:
- A customer charged twice for one order, followed by a refund request and a bad review.
- Three identical WhatsApp booking confirmations, which look unprofessional.
- Duplicate invoices with different numbers, which confuse the accounts team.
- Stock reduced twice for one sale, so the system shows the wrong quantity.
- Loyalty points or discounts applied twice.
If your SaaS platform, booking system, online store or internal tool handles payments, orders, messages or stock, idempotency should be part of the design from the start. It is much cheaper to build in than to add after customers start complaining.
Building a SaaS, payment flow or integration?
Deviorin designs and builds reliable SaaS platforms, APIs, payment flows and WhatsApp integrations that handle retries, failures and scale from day one.
Quick checklist
- check_circleEvery POST endpoint that charges, creates or sends something accepts an idempotency key.
- check_circleKeys are stored with a unique constraint, the request hash and the saved response.
- check_circleRepeat requests return the saved response instead of doing the work again.
- check_circleThe same key is passed to payment providers that support it.
- check_circleWebhook event IDs are stored and checked inside a transaction.
- check_circleOutgoing messages and emails go through an outbox, not directly inside the webhook handler.
- check_circleThe client creates one key per user action and reuses it on retry.
- check_circleOld keys and event IDs are cleaned up on a schedule.
FAQ
Is idempotency only needed for payments?
expand_more
No. Any action with a side effect benefits from it: creating orders, bookings or invoices, sending messages or emails, adjusting stock, and calling third-party APIs.
Can I just disable the button after one click?
expand_more
It helps, but it is not enough. Network retries, webhooks and background jobs can still send the same request twice. The server must handle repeats safely.
What should I use as an idempotency key?
expand_more
A random unique value created by the client for each user action, such as a UUID v4. Do not use timestamps or values that two different actions could share.
How long should idempotency keys be stored?
expand_more
Long enough to cover realistic retries, commonly 24 hours to a few days. After that, remove them with a scheduled cleanup job.
What is the difference between idempotency and a unique constraint?
expand_more
A unique constraint stops duplicate rows in the database. Idempotency goes further: it recognises a repeated request and returns the original result, so the client gets a consistent answer and no work is repeated.
Building a SaaS, payment flow or integration?
Deviorin designs and builds reliable SaaS platforms, APIs, payment flows and WhatsApp integrations that handle retries, failures and scale from day one.