Research-based overview. This article synthesizes public documentation from Stripe, AWS, Vercel, Inngest, and the IETF's HTTP semantics specs. How we research.

One-sentence definition
Idempotency is a property of an operation in which performing it multiple times produces the same result as performing it once — meaning a duplicate retry of the same request never causes a duplicate charge, a duplicate record, or any other side effect that was not intended on the first call.

Idempotency is the unsexy plumbing that separates a SaaS that quietly handles network blips from one that calls its users in panic at 2am because the same person was charged for a $99 subscription four times. Networks fail. Webhooks get redelivered. Cron jobs retry. Mobile apps drop connections mid-POST. Without idempotency, every one of those failures has the potential to cause a duplicate side effect — another charge, another email, another record, another notification — that the user did not ask for. With idempotency, retries are safe by construction.

For solo SaaS founders the topic is more urgent than it sounds because the modern stack — Stripe, Supabase, Vercel cron, Inngest, Trigger.dev — ships at-least-once delivery semantics on almost every async surface. The systems retry on your behalf, which means your handlers must be idempotent or they will produce duplicates the moment a network packet drops.

Why idempotency matters

The argument is best made through the disasters that happen without it. Each of these is a real failure mode that has burned production SaaS apps:

The common thread: every one of these scenarios is a sane, reasonable behavior on the part of the calling system. Stripe is not buggy for delivering a webhook twice; it is being correct under the at-least-once delivery contract. The bug is on the receiving end — in code that assumed each invocation was unique.

The HTTP method semantics primer

The HTTP spec has had idempotency baked into method semantics for decades. RFC 9110 defines four of the standard HTTP methods as idempotent by specification:

Two methods are explicitly not idempotent by default:

This matters because HTTP infrastructure (proxies, CDNs, browsers) sometimes retries requests automatically, but only on methods that are spec-defined as idempotent. A browser will re-issue a GET on a connection error; it will not re-issue a POST without prompting the user. So the spec already does some of the work for you on the read side. The write side — POSTs that create resources, PATCHes that modify state — is where you have to add idempotency by construction.

The idempotency key pattern

Stripe popularized the modern solution: every mutating request includes an Idempotency-Key header with a unique value (typically a UUID). The server stores the key alongside the resulting response for some retention period (24 hours in Stripe's case). On retry, the server sees the key, looks up the stored response, and returns it verbatim — without re-executing the operation.

The Stripe pattern in pseudocode:

// First call — processed normally, response stored POST /v1/charges Idempotency-Key: 7e3a8c2d-... { "amount": 9900, "customer": "cus_..." } → 200 OK { "id": "ch_abc", ... } // Retry — same key, server returns stored response without charging again POST /v1/charges Idempotency-Key: 7e3a8c2d-... { "amount": 9900, "customer": "cus_..." } → 200 OK { "id": "ch_abc", ... }

The mechanics matter:

Stripe's implementation is documented in their idempotent requests guide. The 24-hour TTL is long enough to cover any reasonable retry window; the constraint is that very late retries (a day after the original) are not protected.

Implementing idempotency keys yourself

For your own API endpoints, the simplest implementation is a dedicated table:

CREATE TABLE idempotency_keys ( key uuid PRIMARY KEY, request_hash text NOT NULL, response_status int NOT NULL, response_body jsonb NOT NULL, created_at timestamptz DEFAULT now() );

The handler logic:

  1. Read the Idempotency-Key header from the request.
  2. Look up the row in idempotency_keys.
  3. If found and the request hash matches, return the stored response.
  4. If found but the request hash differs, return a 422 error (key reused incorrectly).
  5. If not found, execute the operation inside a transaction. Insert the row with the resulting response.
  6. Return the response.

The trickiest part is step five: the operation and the idempotency-row insert must be atomic. If the operation succeeds but the row insert fails, the next retry will re-execute the operation. The fix is to wrap both in the same database transaction (when the operation is itself a database write) or to use a two-phase pattern (claim the key first, do the work, mark complete) when the operation involves an external system like a payment processor.

Idempotency by design vs idempotency via key

There are two fundamental approaches, and the right answer depends on the operation.

Idempotency by design

The operation is structured so that running it multiple times produces the same final state without needing a key. Examples:

Idempotency via key

The operation is unavoidably stateful and has external side effects that cannot be made idempotent through SQL alone. Examples:

The rule of thumb: when the side effect is internal (your own database), prefer idempotency by design. When the side effect is external (a third-party API call), use an idempotency key. Mix both when the operation does both.

Webhook receivers should always be idempotent

Stripe sends the same event multiple times by design. The Stripe webhook documentation is unambiguous: handlers must be idempotent because retries happen on the order of seconds (after a non-2xx response) or minutes/hours (during outage recovery). The same applies to webhooks from any provider that takes reliability seriously — Resend, Twilio, Slack, GitHub, Linear all retry.

The idempotency mechanism for webhooks is usually the event ID. Stripe events have a unique id field (e.g., evt_1NxYz...). The handler stores processed event IDs in a database table; on each invocation, the handler checks whether the event ID is already in the table and short-circuits if so. The two-row schema is enough:

CREATE TABLE processed_webhook_events ( event_id text PRIMARY KEY, processed_at timestamptz DEFAULT now() );

The full handler pattern is in webhook security best practices. The short version: verify the signature, look up the event ID, return early if already processed, otherwise process and insert.

Cron jobs and background jobs

Vercel Cron documents at-least-once delivery: a cron job may be invoked more than once for the same scheduled time if the function times out or fails to acknowledge. The same applies to scheduled tasks on most platforms. The implication: every cron handler must be idempotent.

The natural keying strategy for cron jobs is the scheduled run timestamp. A daily-digest job that runs at 09:00 UTC can store a row keyed by ('daily-digest', '2026-05-08') when it begins, fail to insert if the row already exists, and short-circuit. Background job systems like Inngest and Trigger.dev expose the run ID directly, which can serve as the idempotency key.

Without this guard, a cron job that times out at minute four of a five-minute execution will be retried by the platform, re-execute from scratch, and either send duplicate notifications or perform duplicate writes. The fix is the same key + check pattern; the only thing that changes is the source of the key. See the cron-specific notes in how to set up cron jobs on Vercel.

The four common idempotency patterns

PatternHow it worksBest for
UPSERTINSERT ... ON CONFLICT DO UPDATE in SQL. Multiple calls converge to the same final row.Sync operations from an external source of truth, idempotent by SQL semantics.
Lock + checkSELECT ... FOR UPDATE the row, check its state, conditionally update. Concurrency-safe.State machines where transitions must happen exactly once.
Idempotency key tableSeparate idempotency_keys table. Insert key + response on first call; return stored response on retry.External API calls (payments, emails) where the side effect cannot be undone.
Outbox patternWrite a record to an outbox table inside the same transaction as the business write. A separate worker reads the outbox and dispatches the side effect with retry-safe semantics.Decoupling business logic from external dispatch, distributed-systems consistency.

Most solo SaaS apps end up using all four at different points in their architecture. UPSERTs for syncing data from third-party APIs, lock + check for in-app state machines, idempotency keys for payment endpoints, the outbox pattern for any piece of the system that needs guaranteed-once eventual delivery to an external system. The Stripe + Supabase subscription guide walks through several of these together in a working example.

Common idempotency mistakes

The same handful of mistakes show up over and over:

The takeaway

Idempotency is the property that lets you safely retry. Networks fail, webhooks redeliver, cron jobs reschedule, and modern infrastructure ships at-least-once delivery semantics on every async surface. Without idempotency, every one of those becomes a duplicate-charge bug or a duplicate-record bug waiting to happen. With it, retries are a non-event.

The implementation patterns are straightforward: design state changes around UPSERTs and conditional updates when you can; use idempotency keys for anything with external side effects; never trust a webhook to arrive exactly once; treat every cron and background job as if it might run twice. Build the table, store the keys, return the cached response on retry. The plumbing is unsexy but the absence of it is what turns a 2am pager alert into a refund flood.

Related patterns and primitives are covered in what is a webhook, webhook security best practices, and what is API rate limiting — the three companion concepts that show up in the same code paths as idempotency on most SaaS backends.

Get one SaaS build breakdown every week

The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.