Part 5 · APIs & Communication
Idempotency, Pagination & API Versioning
A retried payment that charges twice, a list endpoint that loads three million rows, a renamed field that crashes every old app. Idempotency keys, cursor pagination and careful versioning — the unglamorous details that separate a demo API from a production one.
On this page
The Problem
Three details decide whether an API works well in the real world.
- A customer taps "Pay ₹2,499". The request reaches your server and the card is charged, but the phone loses signal before the response arrives. The app retries automatically, and the customer is charged twice.
- A seller has 3 million orders. The app calls
GET /orders, and the server tries to load all of them into memory and crashes. Even with pages, page 50,000 takes 20 seconds, and some orders appear twice while others are skipped. - You rename a field from
nametofull_name. Thousands of installed mobile apps, which users have not updated, crash on launch.
Idempotency, pagination and versioning are the fixes. They are not exciting, but they separate a demo API from a production API (an API used by real users).
The Core Idea
- Idempotency means that doing the same request many times has the same effect as doing it once. It is like a lift button. Pressing it once or ten times still calls one lift. A good API makes "do it again" safe.
- Pagination means returning a long list in small parts (pages). It is like a book. You do not read all 900 pages at once. You read one page at a time, with a bookmark so you can continue exactly where you stopped, even if someone inserts new pages.
- Versioning means giving each form of your API a version label, so old clients keep working when you change it. It is like electrical sockets. A new appliance must still fit existing sockets, or come with an adapter. You do not rewire everyone's house overnight.
How It Works
Part 1: Idempotency
Why retries are unavoidable
Networks fail in a tricky way: the client often cannot tell whether the request succeeded. A retry means sending the same request again.
- POST /payments ₹2,499
- charge
- ✅ approved
- 201 Created✖ lost in a tunnel
- POST /payments ₹2,499automatic retry
- charge AGAIN
- Without an idempotency key, the customer is charged twice
Retries happen everywhere: mobile apps, SDKs, load balancers, queues (lesson 18) and users tapping twice. So every operation that changes something should be safe to repeat.
Naturally idempotent vs not
- GET, PUT, DELETE are idempotent by design (lesson 3). "Set the name to Asha" twice gives the same result. "Delete order 7" twice leaves it deleted.
- POST usually is not. "Create a payment" twice creates two payments.
- Relative updates are not either. "Add ₹100 to the balance" twice adds ₹200.
Idempotency keys
The standard fix, made popular by Stripe, is an idempotency key. The client creates a unique key (such as a UUID, a random unique ID) for each logical operation, and sends it in a header. A logical operation is one real action, like one payment, even if it is sent many times. Retries reuse the same key.
POST /paymentsIdempotency-Key: 7f3c9a2e-4b1d-4e8a-9f0c-2d6b1e5a8c33Content-Type: application/json{"amount": 249900, "currency": "INR", "source": "card_123"}The server's steps:
- 1Look up the keyscoped to this client or account
- 2INSERT (key, status = processing)a unique constraint makes this race-free
- 3Do the workpass the key downstream to the card network too
- 4Save the response with the keystatus = done, expires in ~24 h
- 5Return the saved responsethe charge is never repeated
- 6409 Conflictan identical request is still in flight
Idempotency table
┌──────────────────────────────┬──────────┬───────────────┬──────────────┬─────────────┐
│ key │ status │ request_hash │ response │ expires_at │
├──────────────────────────────┼──────────┼───────────────┼──────────────┼─────────────┤
│ 7f3c9a2e-4b1d-... │ done │ a91f... │ {201, {...}} │ +24 hours │
└──────────────────────────────┴──────────┴───────────────┴──────────────┴─────────────┘Important details:
- A unique constraint on the key makes the "check then insert" step race-free (lesson 21). A race happens when two requests run at the same time and clash. Two simultaneous retries cannot both win.
- Store a hash of the request body. A hash is a short fingerprint of data. If the same key arrives with a different body, it is a client bug, so reject it (for example with 422).
- Scope keys per client or account, so different customers' keys cannot clash.
- Expire keys after a reasonable window (for example, 24 hours).
- Save the result in the same transaction as the work where possible. A transaction is a group of database changes that all succeed or all fail together. If the work involves an external system (a card network), pass the idempotency key downstream too.
Other idempotency techniques
- Natural unique keys: a unique constraint on
(order_id)in the payments table means "one payment per order", however many retries arrive. - Upserts: "update or insert" in one step, for example
INSERT ... ON CONFLICT DO NOTHING/DO UPDATE(PostgreSQL). - Conditional updates:
UPDATE ... WHERE version = 7orIf-MatchETags (lesson 30). - Deduplicating consumers: queue workers write down the IDs of messages they already handled, and skip repeats (Part 6).
Webhooks need it too
A webhook is a call that your server makes to another system's URL when something happens. When your API sends webhooks ("payment succeeded"), you retry on failure, so receivers may get the same event twice. Good webhook design:
- include a unique event ID so receivers can deduplicate,
- sign each payload with an HMAC signature (a code made with a shared secret key) so receivers can check it is really from you,
- retry with backoff (wait longer after each failure) for hours or days, and
- expect receivers to reply quickly and do heavy work later, in the background (asynchronously).
Part 2: Pagination
Never return a list with no limit. Always paginate, with a default page size (for example 20) and a maximum (for example 100).
Offset pagination
Offset pagination says "skip this many items, then give me the next few". limit is the page size, and offset is how many items to skip.
GET /orders?limit=20&offset=0 → orders 1–20GET /orders?limit=20&offset=20 → orders 21–40SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 40;- ✅ Simple, and lets users jump to "page 7".
- ❌ Slow for deep pages. The database must read and throw away all the skipped rows.
OFFSET 1000000reads a million rows to return 20. - ❌ Unstable when data changes. If a new order is inserted while you page, everything shifts by one, and you see an item twice. If one is deleted, you skip one.
- Page 1 (offset 0)shows orders A B C D
- Meanwhilea new order Z is inserted at the top
- Page 2 (offset 4)now starts at D again — D E F G, so D appears twice
- Cursor pagination“20 items after D” — immune to inserts and deletes
Cursor (keyset) pagination
Cursor pagination (also called keyset pagination) uses a cursor. A cursor is a marker that points to the last item you received. Instead of "skip N rows", you say "give me the next 20 after this item":
GET /orders?limit=20→ { "data": [...], "has_more": true, "next_cursor": "eyJjcmVhdGVkX2F0Ijoi..." }GET /orders?limit=20&cursor=eyJjcmVhdGVkX2F0Ijoi...Inside, the cursor holds the last item's sort values (for example created_at + id). The server turns them into a query like this:
SELECT * FROM ordersWHERE (created_at, id) < ('2027-01-20 10:15:00', 88231) -- the last item seenORDER BY created_at DESC, id DESCLIMIT 20;- ✅ Fast at any depth, because it uses an index (a sorted lookup structure) to jump straight to the position (lesson 22).
- ✅ Stable. Inserts and deletes do not cause duplicates or gaps.
- ❌ You cannot jump to an arbitrary "page 537". You can only go next and previous (fine for feeds and infinite scroll).
- Include a unique tie-breaker (like
id) in the sort, so items with the same timestamp are not skipped. A tie-breaker is a second sort field that makes every position unique. - Make cursors opaque (for example, base64-encoded JSON). Opaque means clients cannot read the inside. They should not parse or build cursors, so you can change the format later.
Response shape example:
{ "data": [ { "id": "ord_88231", "total": 129900 } ], "has_more": true, "next_cursor": "eyJjIjoiMjAyNy0wMS0yMFQxMDoxNTowMFoiLCJpIjo4ODIzMX0="}Some APIs put next and previous links in an HTTP Link header instead.
Which to use:
- Offset: small datasets and admin tables that need page numbers.
- Cursor: feeds, timelines, large or frequently changing lists, and public APIs.
Avoid exact total counts on huge lists. COUNT(*) (count all rows) over millions of rows is slow. Show "has more", or an approximate count, instead.
Part 3: API versioning
A breaking change is a change that makes existing clients fail. Versioning lets you make such changes without hurting clients that still use the old form.
- +New endpoint
- +New optional request field
- +New response field (clients are tolerant readers)
- +New enum value, if clients handle unknown values
- −Remove or rename a field or endpoint
- −Change a type or meaning (rupees → paise)
- −Make an optional field required
- −Change status codes or defaults clients rely on
What counts as a breaking change?
Safe (non-breaking) changes:
- adding a new endpoint,
- adding a new optional request field,
- adding a new response field (clients should ignore fields they do not know. This is the "tolerant reader" rule),
- adding a new enum value, if clients are written to handle unknown values.
Breaking changes:
- removing or renaming a field or endpoint,
- changing a field's type or meaning (
pricein rupees → paise), - making an optional field required,
- changing error codes or status codes clients rely on,
- changing default behaviour (sort order, page size).
The best strategy is to avoid breaking changes. Add new fields next to old ones and keep the old ones working. Remove them only after a long deprecation period. Deprecation means announcing that something will be removed later.
Ways to version
| Approach | Example | Notes |
|---|---|---|
| URI path | /v1/orders, /v2/orders | Most common. It is easy to see, route and cache |
| Header | Api-Version: 2027-01-25 | Clean URLs; used for date-based versions |
| Media type | Accept: application/vnd.shop.v2+json | Precise, but less convenient |
| Query parameter | /orders?version=2 | Easy, but also easy to forget |
Date-based versions (popularised by Stripe) work like this:
- each account or client is pinned to the API version that existed when it started integrating,
- when a breaking change is released, it becomes a new dated version,
- clients upgrade when they choose,
- inside, the server uses small "version change" layers that translate between versions. Old versions keep working, and the code is not copied.
Deprecation done well
- Announce early, with a clear timeline (for example, 6–12 months).
- Mark it in responses. The
SunsetHTTP header (RFC 8594) tells clients the date when an endpoint will stop working. A deprecation notice can point to the migration guide. - Monitor usage. See which clients still call the old version, and contact them directly.
- Provide migration guides and, where possible, tools.
- Only then switch it off. You can first run short "brownouts" (planned, temporary shutdowns) so that clients who missed the news notice.
Mobile apps make this harder
A web frontend updates as soon as you deploy it. But mobile apps stay installed for months or years, and many users never update. So:
- keep old API versions alive longer for mobile clients,
- have the app send its app version so the server can adapt,
- build a "minimum supported version" / force-update mechanism for breaking changes that cannot be avoided.
- Month 0announce, with a timeline and a migration guide
- Months 0–6Sunset and deprecation headers on every response
- Months 3–9monitor who still calls it; contact them directly
- Month 10short planned “brownouts” so late clients notice
- Month 12switch it off
Trade-offs
- Idempotency keys: safe retries and no duplicate side effects, at the cost of extra storage, lookups and careful building (races, expiry, request hashing).
- Offset pagination: simple, with page numbers, but slow deep pages and unstable results.
- Cursor pagination: fast and stable, but no random page access, and the cursor design needs care.
- URI versioning: clear and easy to route, but it can tempt teams into big "v2 rewrites".
- Date-based header versioning: small steps and smooth upgrades, but the server-side version translation is more complex.
- Supporting old versions: happy clients, but ongoing maintenance and testing cost.
In the Real World
Stripe's idempotency keys. Stripe lets clients send an Idempotency-Key header on POST requests. Retries with the same key return the original result instead of creating a second charge. Stripe keeps keys for a limited time (at least 24 hours). Its engineering blog post on idempotency explains the design and is one of the best practical references on the topic.
Stripe's date-based versioning. Stripe pins each account to an API version and releases breaking changes as new dated versions, with internal compatibility layers so older integrations keep working for years. GitHub's REST API has also moved to date-based version headers (for example, X-GitHub-Api-Version: 2022-11-28).
Slack's move to cursor pagination. Slack wrote about evolving its API from offset-style pagination to cursor-based pagination for large, changing collections, for both performance and consistency.
Payment gateways and UPI apps. UPI is India's instant payment system. Payment flows everywhere depend on idempotent APIs and unique transaction references. When a payment "times out", systems check the transaction status using its reference rather than blindly retrying, because a duplicate debit is one of the worst possible bugs.
Infinite scroll feeds. Social apps and e-commerce "load more" lists use cursor pagination. This is why new posts at the top do not make you see the same post twice as you scroll.
Interview Questions
Q1How do you make a payment API safe to retry?
The client sends a unique Idempotency-Key for each payment and reuses it on retries. The server saves the key under a unique constraint before doing the work, and stores the response with it. On a repeat, it returns the stored response instead of charging again. Scope keys per account, store a request hash to reject a different body with the same key, expire keys after a day or so, and pass the key to downstream processors.
Q2Why is offset pagination a problem for large or changing lists?
OFFSET N makes the database read and throw away N rows, so deep pages get slower and slower. Inserts or deletes between requests shift everything, which causes duplicates and skipped items. Cursor pagination finds the position by the last item's sort key, using an index, so it is fast at any depth and stable.
Q3Why include a tie-breaker like id in a cursor?
Sort values like created_at are not unique. Without a unique tie-breaker, items that share the boundary timestamp can be skipped or repeated. Sort and seek by (created_at, id).
Q4What is a breaking API change? Give examples.
Anything that makes existing correct clients fail or misbehave: removing or renaming fields, changing types or units, making optional inputs required, or changing status codes or defaults. Adding optional fields and endpoints is safe if clients ignore what they do not recognise.
Q5How do you version an API used by mobile apps?
Avoid breaking changes as long as you can. When you must, publish a new version (URI or date-based header) and keep the old one running for a long time, because installed apps update slowly. Send the app version with requests, and keep a minimum-supported-version or force-update option for emergencies.
Q6How should webhooks be designed for reliability?
Give each event a unique ID so receivers can remove duplicates. Sign payloads with HMAC so receivers can check them. Retry with backoff for hours or days. Expect receivers to reply quickly and do the work later in the background.
Key Takeaways
- Retries are inevitable, so make every state-changing operation idempotent.
- Use idempotency keys (with a unique constraint, a stored response, a request hash and an expiry), natural unique keys, upserts or conditional updates.
- Always paginate. Prefer cursor (keyset) pagination with a unique tie-breaker and opaque cursors for large or changing lists. Use offset only for small lists.
- Avoid breaking changes: add, do not rename or remove. Clients should be tolerant readers.
- When you must break things, version (URI or date-based headers), deprecate slowly with clear timelines and
Sunsetheaders, and remember that mobile apps live for years.
Further Reading
- Stripe's blog post "Designing robust and predictable APIs with idempotency" and its API documentation on idempotent requests
- The AWS Builders' Library article "Making retries safe with idempotent APIs"
- Stripe's blog post "APIs as infrastructure: future-proofing Stripe with versioning"
- Use The Index, Luke! by Markus Winand (the chapter on paging without OFFSET)
- Google API Improvement Proposals on pagination (AIP-158) and versioning (AIP-185)
- Slack's engineering blog post "Evolving API Pagination at Slack"
- RFC 8594 (The Sunset HTTP Header Field)
This wraps up Part 5. Next up, Part 6: Async & Event-Driven Systems, starting with "Queues vs Pub/Sub".
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related series