API Contracts You Can Trust: Versioning, Pagination, Error Model, Idempotency Keys
A strong API contract makes integrations peacefully uneventful — and that’s exactly what you want. It tells clients how versions change over time, how to handle large lists of data, what an error looks like, and how to make sure duplicate requests don’t mess things up.
Below is a straightforward, ready-to-use guide you can adapt for any service.
1) Versioning
What & why:
Change is inevitable, but your clients shouldn’t have to suffer because of it. Versioning isolates breaking changes and gives consumers breathing room to update their integrations safely.
Common strategies
-
URI versioning (simple and explicit):
/v1/...,/v2/... -
Header versioning (flexible):
Accept: application/vnd.myapi+json; version=2 -
Field-level evolution (non-breaking):
Always add new fields instead of repurposing existing ones. Don’t change the meaning or data type of existing fields. -
Deprecation policy:
Publish clear timelines, and whenever possible, support two adjacent major versions to ease transitions.
Rules of thumb
-
Minor, non-breaking updates (like adding fields or new endpoints) don’t need a major version bump.
-
Breaking changes (like removing or renaming fields, or changing types) require a new major version.
-
Always include Deprecation and Sunset headers when they apply.
2) Pagination
What & why:
As datasets grow, responses need to stay quick and manageable. Pagination keeps APIs fast and scalable.
Patterns
-
Offset/limit (easy but fragile at scale):
?offset=200&limit=50 -
Cursor (preferred for scalability):
?cursor=eyJpZCI6IjEyMyJ9&limit=50
(use an opaque token and a consistent ordering) -
HATEOAS-style links:
Includenext,prev, andselfreferences for navigation.
Example response shape
{
"data": [ /* items */ ],
"page": {
"limit": 50,
"next_cursor": "eyJpZCI6IjEyMyJ9",
"prev_cursor": null
}
}
3) Error Model
What & why:
When errors behave predictably, client code becomes simpler and safer.
Recommended structure (based on RFC 7807):
{
"type": "https://docs.myapi.com/errors/validation_failed",
"title": "Validation failed",
"status": 422,
"code": "validation_failed",
"detail": "email is invalid",
"correlation_id": "a4f1c8e9-2b59-4e3e-9f0f-2f7b8c7d2a31",
"errors": [
{ "field": "email", "message": "must be a valid email" }
]
}
HTTP status guidance
-
400 / 422 → Client validation errors
-
401 / 403 → Authentication or permission issues
-
404 → Resource not found
-
409 → Conflicts (e.g., duplicate keys)
-
429 → Too many requests (include
Retry-After) -
5xx → Server errors (always return
correlation_id)
4) Idempotency Keys
What & why:
They’re your insurance against duplicate writes when clients retry after timeouts or network hiccups.
Contract
-
Clients send:
Idempotency-Key: <uuid>on POST/PUT/PATCH/DELETE requests that modify data. -
The server stores a fingerprint
(key, method, path, body hash, user/tenant)and associates it with the original response. -
Any replay with the same key returns the exact same result — same status, headers, and body.
-
The TTL should at least cover the retry window (usually 24–72 hours).
-
If a reused key comes with a different body hash, respond with 409 Conflict.
Short Real-World Example (Payments API)
A) Versioned endpoint
POST /v1/payments
Accept: application/json
Content-Type: application/json
Idempotency-Key: 7b5d0e2e-7f2b-4a2c-8a3b-0e0e6a1d9d0f
{
"amount": 1999,
"currency": "USD",
"source": "tok_visa",
"metadata": { "orderId": "ORD-1001" }
}
Success (201 Created)
{
"id": "pay_01HXYZ",
"status": "succeeded",
"amount": 1999,
"currency": "USD",
"created": "2025-10-22T10:05:03Z"
}
Retrying with the same Idempotency-Key will return this exact same 201 response.
B) Cursor pagination on list
GET /v1/payments?limit=2&cursor=eyJpZCI6InBheV8wMUhYWSJ9
Response
{
"data": [
{ "id": "pay_01HXYZ", "status": "succeeded", "amount": 1999 },
{ "id": "pay_01HXYY", "status": "pending", "amount": 5000 }
],
"page": {
"limit": 2,
"next_cursor": "eyJpZCI6InBheV8wMUhYWVoifQ",
"prev_cursor": "eyJpZCI6InBheV8wMUhYWSJ9"
}
}
C) Error model in action (422 & 429)
Invalid request (422)
POST /v1/payments
...
{ "amount": -10, "currency": "USD" }
{
"type": "https://docs.myapi.com/errors/validation_failed",
"title": "Validation failed",
"status": 422,
"code": "validation_failed",
"detail": "amount must be >= 1",
"correlation_id": "18f2e2e1-3ad0-4e0b-9db9-8c8d5d8c1a12",
"errors": [{ "field": "amount", "message": "must be >= 1" }]
}
Rate limited (429)
GET /v1/payments Retry-After: 2
{
"type": "https://docs.myapi.com/errors/rate_limited",
"title": "Too Many Requests",
"status": 429,
"code": "rate_limited",
"detail": "Burst limit exceeded. Try again later.",
"correlation_id": "b1e0a9f3-7b28-4a35-9d10-0c11b7e1caa9"
}
D) Minimal server-side implementation snippets
Node.js (Express) — idempotency with Redis
import express from "express";
import crypto from "crypto";
import { createClient } from "redis";
const app = express();
app.use(express.json());
const redis = createClient(); await redis.connect();
function bodyHash(body) {
return crypto.createHash("sha256").update(JSON.stringify(body || {})).digest("hex");
}
app.post("/v1/payments", async (req, res) => {
const key = req.header("Idempotency-Key");
if (!key) return res.status(400).json({ code: "missing_idempotency_key" });
const fp = JSON.stringify({ method: "POST", path: "/v1/payments", hash: bodyHash(req.body), user: req.header("X-User") || "anon" });
const existing = await redis.get(key);
if (existing) {
const saved = JSON.parse(existing);
if (saved.fp !== fp) return res.status(409).json({ code: "conflicting_idempotency_key" });
res.set(saved.headers); return res.status(saved.status).send(saved.body);
}
// ...perform payment (call PSP)...
const result = { id: "pay_01HXYZ", status: "succeeded", amount: req.body.amount, currency: req.body.currency, created: new Date().toISOString() };
const snapshot = { status: 201, headers: { "Content-Type": "application/json" }, body: JSON.stringify(result), fp };
await redis.set(key, JSON.stringify(snapshot), { EX: 60 * 60 * 24 }); // 24h
res.status(201).json(result);
});
app.get("/v1/payments", async (req, res) => {
const limit = Math.min(Number(req.query.limit) || 50, 100);
const cursor = req.query.cursor; // decode & apply to query
// Keyset (cursor) query in SQL-ish:
// SELECT * FROM payments WHERE id > :cursor_id ORDER BY id LIMIT :limit;
// Build next_cursor from last row.
res.json({ data: [], page: { limit, next_cursor: null, prev_cursor: null } });
});
app.use((err, req, res, _next) => {
const correlationId = crypto.randomUUID();
console.error(correlationId, err);
res.status(500).json({
type: "https://docs.myapi.com/errors/internal",
title: "Internal Server Error",
status: 500,
code: "internal_error",
detail: "An unexpected error occurred.",
correlation_id: correlationId
});
});
app.listen(3000);
SQL keyset pagination (example)
-- Stable order by unique column to build cursors: SELECT id, amount, status FROM payments WHERE id > :cursor_id -- derive from decoded cursor ORDER BY id LIMIT :limit;
E) OpenAPI fragments (drop-in)
Headers & error schema
components:
parameters:
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
schema: { type: string, format: uuid }
responses:
Error:
description: Error response
content:
application/json:
schema:
$ref: '#/components/schemas/Problem'
schemas:
Problem:
type: object
required: [title, status, code, correlation_id]
properties:
type: { type: string, format: uri }
title: { type: string }
status: { type: integer }
code: { type: string }
detail: { type: string }
correlation_id: { type: string, format: uuid }
errors:
type: array
items:
type: object
properties:
field: { type: string }
message: { type: string }
List with cursor pagination
paths:
/v1/payments:
get:
parameters:
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
- name: cursor
in: query
schema: { type: string }
responses:
'200':
description: Paginated list
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/Payment' } }
page:
type: object
properties:
limit: { type: integer }
next_cursor: { type: string, nullable: true }
prev_cursor: { type: string, nullable: true }
'4XX': { $ref: '#/components/responses/Error' }
'5XX': { $ref: '#/components/responses/Error' }

Final Thoughts
Nailing these four fundamentals turns your API into a platform teams can trust.
-
Versioning lets you evolve safely.
-
Pagination keeps responses lightweight and efficient.
-
A consistent error model makes debugging easy and integrations stable.
-
Idempotency keys prevent accidental duplicates and protect your data integrity.
Implement them once, document them clearly, and every new endpoint you build will benefit automatically.
