Practical Idempotency for Solo SaaS APIs and Jobs
A practical pattern for solo founders: how to accept idempotency keys on mutating endpoints, store results safely, and make background jobs safe to retry without duplicate charges or emails.
Networks drop requests. Clients retry on timeouts. Background workers crash after a side effect but before they mark the job done. Without protection, the same logical action can charge a card twice, send the same email three times, or create duplicate records. Idempotency makes those retries harmless.
This guide covers a minimal implementation that works for early SaaS, AI tools, and developer products. It focuses on the endpoints you expose and the jobs you run, not on receiving webhooks (covered separately). The goal is enough safety that you can retry confidently without building a full distributed system.
Start with the operations that hurt when repeated
Not every endpoint needs an idempotency key on day one. Prioritize mutations that create money movement, send irreversible messages, or provision external resources:
- Creating a charge, subscription, or invoice.
- Sending a welcome or billing email.
- Provisioning a domain, API key, or external account.
- Recording usage that will be billed later.
Read-only GETs are already safe. Updates that are naturally idempotent (setting a flag to true) often need less machinery. Begin with the one or two flows where a duplicate would create a support ticket or a refund.
Illustrative example only: a founder adds an Idempotency-Key header requirement only to the “create subscription” endpoint. The rest of the API stays unchanged until a second painful duplicate appears.
Accept a client-supplied key and scope it tightly
The client (your own frontend, a mobile app, or an API consumer) generates a unique key before the first attempt and reuses the exact same key on every retry of that logical operation. A random UUID is the usual choice; the important properties are that it is unique per attempt and stable across retries.
On the server:
- Read the key from an
Idempotency-Keyheader (or a well-documented body field if headers are awkward). - Scope every stored record by the authenticated user or tenant plus the key. A unique constraint on
(user_id, idempotency_key)prevents one customer from replaying another’s key. - Store a hash of the request body (or the important fields) alongside the key. If a later request arrives with the same key but a different body, reject it rather than replaying the old result. This stops accidental key reuse.
Keep the key out of the business payload when possible so middleware can inspect it without parsing the whole body.
Store the result atomically before side effects
The safe sequence for a mutating request:
- Look up the key for this user.
- If a completed record exists and the body hash matches, return the stored status code and body immediately. Do not re-execute.
- If a record exists but is still in progress and recent, return 409 Conflict (or 425) and tell the client to retry later.
- If the key is new, insert a row marked “in progress” inside a transaction (or use an atomic “insert if not exists”).
- Perform the business work and any external calls that support their own idempotency keys.
- Update the row to “completed” with the final response, still inside the same transaction when possible.
- Return the response.
The unique constraint or the atomic insert is the gate. A plain “select then insert” can race: two concurrent requests both see the key missing and both proceed. Database-level uniqueness removes the race.
A simple table is enough:
CREATE TABLE idempotency_keys (
user_id BIGINT NOT NULL,
idempotency_key TEXT NOT NULL,
request_hash TEXT NOT NULL,
status TEXT NOT NULL, -- in_progress | completed | failed
response_status INT,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
PRIMARY KEY (user_id, idempotency_key)
);
Retention can be short. Many teams expire completed keys after 24 hours for payment-like operations and longer for slower workflows. Expired keys simply allow a new attempt; choose the window to cover normal client retry behavior.
Pass the same key to downstream systems
When your code calls Stripe, an email provider, or another service that supports idempotency, forward a stable key derived from your own. That way even if your process crashes after the external call succeeds but before you store the result, the next retry is safe at both layers.
Illustrative example only: the internal key sub_create:user_42:uuid-abc is passed as the Idempotency-Key to the payment processor and also used to mark the email job. A crash between the two steps does not create a second charge or a second email on retry.
Make background jobs safe the same way
Jobs that run under at-least-once delivery (most queues) need the same discipline. Generate or receive a stable key for the logical work before the job is enqueued. Inside the worker:
- Check the key first.
- If already completed, exit successfully.
- Otherwise claim it, do the work, mark completed.
Prefer keys that describe the business intent (invoice:inv_123:finalize) over the queue’s internal job ID, because a re-dispatch creates a new job ID while the business intent stays the same.
If the job calls an external API, pass the same key there. If the side effect is a database write, use a unique constraint or conditional update so a second run is a no-op.
Practical checklist before you ship the next mutating endpoint
- Client generates a fresh key per logical operation and reuses it unchanged on retries.
- Server requires the key on the high-risk POST/PATCH endpoints you chose.
- Storage uses a unique constraint on (user/tenant, key).
- Request body (or key fields) is hashed and compared; mismatched bodies are rejected.
- The “in progress” claim and the final result are written atomically or under a lock that prevents double execution.
- Completed responses are stored and returned verbatim on replay.
- Downstream calls that support idempotency receive a stable derived key.
- Background jobs check the same key before performing side effects.
- Keys expire after a window that covers normal retries but does not grow forever.
- Logs include the key and the outcome (new, replay, conflict, mismatch) so you can debug a customer report.
Common pitfalls
- Generating a new UUID on every retry instead of reusing the original key, which defeats the mechanism.
- Checking for the key and then doing the work without an atomic claim, allowing concurrent requests to both succeed.
- Returning an error status on a valid replay, which causes the client or queue to keep retrying.
- Storing the full response body when it contains secrets or large customer data; store only what you need to replay safely.
- Using the same hard-coded key for every user or every day, so the second customer receives the first customer’s stored response.
- Forgetting to forward the key to the payment or email provider, so the external side effect still happens twice.
When the simple pattern is no longer enough
Revisit the design when:
- You need exactly-once semantics across multiple services that cannot share a database.
- Key volume or retention requirements make a single table expensive or slow.
- Clients begin to reuse keys incorrectly at a scale that requires better validation or rate limiting on key creation.
- Compliance rules demand longer audit retention of the idempotency records themselves.
Until then, client-generated keys scoped to the user, an atomic store of the result, and the same key forwarded to external systems will prevent the majority of duplicate charges, emails, and records that solo founders encounter. The pattern should be small enough that you add it only where a duplicate would actually hurt.
Retries are normal. Idempotency makes them boring instead of expensive.
Sponsor this article · $29 / 30 days
More in Engineering & Architecture
Practical Structured Logging for Solo SaaS
A practical setup for solo founders: how to choose what to log, structure messages so they are searchable, avoid sens...
Practical Schema Migrations for Solo SaaS
A practical approach to database schema changes for solo founders: expand-contract patterns, safe deployment order, a...