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.

Lesson 3410 min readintermediate#api#reliability

On this page

  1. The Problem
  2. The Core Idea
  3. How It Works
  4. Trade-offs
  5. In the Real World
  6. Interview Questions
  7. Key Takeaways
  8. Further Reading

The Problem

Three details decide whether an API works well in the real world.

  1. 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.
  2. 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.
  3. You rename a field from name to full_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.

step 1 / 7
Phone
Payments API
Card network
  1. POST /payments ₹2,499
  2. charge
  3. ✅ approved
  4. 201 Created
    ✖ lost in a tunnel
  5. POST /payments ₹2,499
    automatic retry
  6. charge AGAIN
  7. Without an idempotency key, the customer is charged twice
Why retries are unavoidable. The client cannot tell “failed” from “succeeded, but the reply was lost”. · sequence · step through

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.

HTTP
POST /paymentsIdempotency-Key: 7f3c9a2e-4b1d-4e8a-9f0c-2d6b1e5a8c33Content-Type: application/json{"amount": 249900, "currency": "INR", "source": "card_123"}

The server's steps:

  1. 1
    Look up the key
    scoped to this client or account
  2. 2
    INSERT (key, status = processing)
    a unique constraint makes this race-free
  3. 3
    Do the work
    pass the key downstream to the card network too
  4. 4
    Save the response with the key
    status = done, expires in ~24 h
  5. 5
    Return the saved response
    the charge is never repeated
  6. 6
    409 Conflict
    an identical request is still in flight
Server-side handling of an idempotency key. · flow
Diagram
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 = 7 or If-Match ETags (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.

HTTP
GET /orders?limit=20&offset=0      → orders 1–20GET /orders?limit=20&offset=20     → orders 21–40
SQL
SELECT * 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 1000000 reads 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.
  1. Page 1 (offset 0)
    shows orders A B C D
  2. Meanwhile
    a new order Z is inserted at the top
  3. Page 2 (offset 4)
    now starts at D again — D E F G, so D appears twice
  4. Cursor pagination
    “20 items after D” — immune to inserts and deletes
Why offset pagination shows duplicates on a changing list. · timeline

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":

HTTP
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:

SQL
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:

JavaScript
{  "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.

Safe — additive
  • +New endpoint
  • +New optional request field
  • +New response field (clients are tolerant readers)
  • +New enum value, if clients handle unknown values
Breaking
  • −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 you can change freely, and what breaks clients. · compare

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 (price in 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

ApproachExampleNotes
URI path/v1/orders, /v2/ordersMost common. It is easy to see, route and cache
HeaderApi-Version: 2027-01-25Clean URLs; used for date-based versions
Media typeAccept: application/vnd.shop.v2+jsonPrecise, but less convenient
Query parameter/orders?version=2Easy, 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

  1. Announce early, with a clear timeline (for example, 6–12 months).
  2. Mark it in responses. The Sunset HTTP header (RFC 8594) tells clients the date when an endpoint will stop working. A deprecation notice can point to the migration guide.
  3. Monitor usage. See which clients still call the old version, and contact them directly.
  4. Provide migration guides and, where possible, tools.
  5. 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.
  1. Month 0
    announce, with a timeline and a migration guide
  2. Months 0–6
    Sunset and deprecation headers on every response
  3. Months 3–9
    monitor who still calls it; contact them directly
  4. Month 10
    short planned “brownouts” so late clients notice
  5. Month 12
    switch it off
Deprecating an API version without surprising anyone. · timeline

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 Sunset headers, 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