Session Recap — API Concepts¶
Starting Point¶
Concepts introduced progressively during the three problem walkthroughs (URL shortener, hotel booking, food delivery). Covered: pagination, deduplication vs idempotency, error contracts (RFC 7807), API versioning, and rate limiting (conceptual).
Pagination¶
The problem¶
GET /api/links with 100,000 records in an org. You cannot return all of them. You need pagination.
Two approaches¶
Offset pagination
GET /api/links?page=3&limit=20
SQL: LIMIT 20 OFFSET 60
- Allows random access — jump to page 47
- Gets slow at scale — database still scans all preceding rows
- Consistency problem: if a row is deleted between page fetches, the next page shifts and you skip a row. If a row is inserted, you see a duplicate. No clean fix.
- Use only when random access is a hard requirement.
Keyset pagination (cursor-based)
GET /api/links?afterCursor=<opaque>&limit=20
GET /api/links?beforeCursor=<opaque>&limit=20
- No consistency problem — anchored to a specific record, not a position
- Cannot jump to arbitrary pages — forward/backward only
- Fast at any scale — uses an index seek, not a row count
- Default choice for feeds, large datasets, anything without a random access requirement
Cursor design¶
The cursor is not a random UUID. It contains:
{
"sortColumnValue": "2026-04-01T10:00:00Z",
"id": "abc123", // tiebreaker — sort columns are rarely unique
"sortColumn": "created_at",
"sortDirection": "asc",
"filters": { "startDate": "...", "endDate": "..." } // optional, Shopify/Google standard
}
Encoding filters in the cursor makes it self-contained — client gets consistent results even if they change filters mid-pagination (server detects mismatch and resets).
The cursor is base64 encoded — not for security (base64 is trivially decodable), but for accidental tamper protection. The cursor is opaque to the client — they save it and send it back. They don't construct or modify it. If you need actual tamper protection, HMAC sign it.
Two separate params for bidirectional navigation:
- afterCursor → go forward (load next page)
- beforeCursor → go backward (load previous page)
Not a single cursor + direction param — makes client intent explicit.
Response envelope¶
{
"data": [...],
"nextCursor": "base64encodedvalue",
"prevCursor": "base64encodedvalue"
}
Deduplication vs Idempotency¶
Deduplication¶
Problem: same long URL posted twice creates two short codes.
Solution: hash the long URL, store as long_url_hash, check before inserting.
This is deduplication — not idempotency. Deduplication is about content uniqueness. Idempotency is about request retry safety.
Response on duplicate: 200 with "deduplicated": true flag. Not 201 (nothing was created). Not 409 (not an error).
Idempotency¶
Problem: customer taps "Place Order," network drops before receiving a response. App retries. Server has no way to know if the second request is a retry or a genuine new order. Creates duplicate.
Solution — idempotency key:
POST /orders
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
{ "items": [...], "deliveryAddress": {...} }
Server behaviour: 1. First request with key → process, create order, store response against the key (Redis, 24hr TTL) 2. Second request with same key → return stored response, do not process again
Critical principle:
An idempotency key represents a user intent, not a request. One intent = one key. How many HTTP requests it takes to complete is irrelevant.
Key generation is a client concern. The server only cares that same key = same response.
Generate the key at intent time, not at request time: - Wrong: generate UUID when user taps "Place Order" button - Right: generate UUID when user opens the order screen / creates the cart
This is the E-POS pattern — localId generated when cart is created, persisted in IndexedDB. Survives network drops, page refreshes, app restarts. A new key is only generated when the user expresses a new intent (creates a new cart).
The payment edge case¶
Customer places order. Payment goes through. DB goes down. Order never saved. Customer sees an error. Card was charged. They navigate away, try again — new session, new key. Duplicate payment risk.
Fix: use orderId as the idempotency key for payment, not a random UUID.
POST /payments
Idempotency-Key: order-{orderId}
orderId exists before payment starts. It survives navigation and app restarts. One order = one payment = natural idempotency key.
The deeper principle:
The best idempotency keys are natural business keys, not random UUIDs. Random UUIDs are correct only when no natural key exists.
What idempotency does NOT solve¶
Client state loss — crash, app restart, fresh session. A new session generates a new key. Idempotency keys only protect same-session retries.
Production approach (combined): 1. Intent-based key generation (survives session within same device) 2. Content hash with short window (60-90 seconds) as last-resort safety net 3. Accept that genuine duplicate intent within that window is a support problem, not a code problem
Error Contracts (RFC 7807)¶
The problem¶
{ "message": "Something went wrong" } is useless. Client cannot act on it programmatically.
RFC 7807 Problem Details — standard shape¶
{
"type": "https://domain.com/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "One or more fields are invalid",
"traceId": "abc-123-xyz",
"errors": [
{ "field": "deliveryAddress.phone", "message": "Phone number must be 10 digits" },
{ "field": "items", "message": "Cannot be empty" }
]
}
type— URI pointing to documentation for this error. This is the machine-readable part. Client switches on this, not on thedetailstring. No string parsing.title— human readable categorystatus— HTTP status code (redundant with header but useful for logging)detail— human readable explanation for developerstraceId— correlates to server logs. Critical for production debugging.errors— field-level detail for validation failures
Status codes — know these cold¶
| Code | Meaning |
|---|---|
| 400 | Bad request — client sent malformed or invalid data |
| 401 | Not authenticated |
| 403 | Authenticated but not authorised |
| 404 | Resource not found |
| 409 | Conflict — two resources collide (e.g. duplicate creation) |
| 422 | Valid request, invalid for current state (e.g. cancel approved booking) |
| 429 | Rate limited |
| 500 | Server error — not the client's fault |
409 vs 422¶
409— collision between resources. "This short code already exists."422— valid request, wrong state. "You cannot cancel an approved booking."
Business logic errors¶
Restaurant closed when order is placed — valid request, fails business rule:
{
"type": "https://domain.com/errors/restaurant-closed",
"title": "Restaurant Unavailable",
"status": 422,
"detail": "The restaurant is not accepting orders at this time",
"traceId": "abc-123-xyz"
}
Payment state in 500 errors¶
Server crashes mid-transaction after payment succeeds but before order is saved:
{
"type": "https://domain.com/errors/order-creation-failed",
"status": 500,
"detail": "Your order could not be completed due to a server error",
"traceId": "abc-123-xyz",
"paymentStatus": "charged_pending_refund",
"supportReference": "abc-123-xyz"
}
paymentStatus — tells the client what happened to the money. Client shows "You have been charged, a refund will be issued within 3-5 days."
supportReference — customer-facing version of traceId. Customer reads this to support, support pulls the exact log.
Error contracts are not just for developers. For financial operations they carry customer-facing state.
Multi-step process receipt¶
For operations with multiple steps (PEPPOL: invoice created → validated → relay logged), include step state in the error response:
{
"type": "https://domain.com/errors/order-creation-failed",
"status": 500,
"traceId": "abc-123-xyz",
"steps": [
{ "step": "validation", "status": "success" },
{ "step": "payment", "status": "success", "reference": "pay-xyz" },
{ "step": "order-creation", "status": "failed" }
],
"paymentStatus": "charged_pending_refund"
}
API Versioning¶
Why it exists¶
Clients break when you change response shapes. Versioning lets you evolve the API without breaking existing clients.
Three approaches¶
| Approach | Routing clean | Visible in logs | Browser testable | Use |
|---|---|---|---|---|
URL (/v1/orders) |
Yes | Yes | Yes | Default |
Header (API-Version: 2) |
Yes | No | No | Purist choice, harder for integrators |
Query param (?version=2) |
No | Yes | Yes | Avoid |
URL versioning is the default. GitHub, Stripe, Twitter all use it. Version is in the route — routing itself separates concerns. No branching inside handlers.
Query param version: the version check bleeds into the controller, return types become ambiguous, routing is messy. Avoid.
ASP.NET Core structure¶
/Controllers
/V1
OrdersController.cs [Route("api/v1/orders")]
/V2
OrdersController.cs [Route("api/v2/orders")]
/Models
/V1
OrderResponse.cs → estimatedDeliveryTime: string
/V2
OrderResponse.cs → eta: { min, max }
Business logic lives in a shared service layer — both controllers call the same services. Only response mapping differs. When v1 is retired, delete the folder.
Deprecation strategy¶
- Monitor traffic on v1
- When traffic drops below 10% of peak, append deprecation headers:
Deprecated: true Sunset: Sat, 01 Jan 2027 00:00:00 GMT - Notify clients via email (covers those not parsing headers)
- Delete v1 after sunset date
Deprecated and Sunset are actual HTTP standard headers. Clients that parse headers can detect and alert their own developers automatically.
Rate Limiting (Conceptual)¶
Why it exists¶
- Protection — buggy client, scraper, or malicious actor hammers your API and takes it down for everyone
- Fair usage — in multi-tenant systems, one org shouldn't consume all capacity and starve others
What to limit on¶
| Endpoint type | Limit on |
|---|---|
| Public (no auth) | IP address |
| Authenticated | User ID or Org ID |
IP can be spoofed — but the bar for spoofing IPs at scale is high enough to be an acceptable tradeoff for public endpoints. If an attacker is sophisticated enough to spoof IPs at scale, rate limiting isn't your only problem.
For authenticated endpoints: User ID or Org ID cannot be spoofed.
Which endpoints to rate limit¶
List endpoints and any endpoint that is computationally expensive or abusable. Not every endpoint needs rate limiting — focus on the ones that could be hammered.
Response on rate limit¶
HTTP 429 Too Many Requests
Retry-After: 60
Implementation (needs dedicated session)¶
Not covered in depth. Primitives to know: - Fixed window — count requests in a time bucket (simple, has burst problem at window boundary) - Sliding window — smoother, no boundary burst - Token bucket — most common, allows controlled bursting - Redis is the standard backing store for distributed rate limiting
In ASP.NET Core: built-in rate limiting middleware available from .NET 7 (AddRateLimiter).
Key Principles to Carry Forward¶
Clients never construct URLs. Server returns ready-to-use URLs.
userId comes from JWT, not request body. Always.
PATCH for simple field updates. Action endpoints for cross-aggregate state transitions.
Idempotency key = user intent, not a request. Generate at intent time. Natural business keys are better than random UUIDs.
type in error responses is machine-readable. Client switches on it programmatically — never on the detail string.
URL versioning is the default. Separate controllers, separate models, shared services.
Deduplication ≠ idempotency. Know the difference. Use the right word in interviews.
Things to Investigate in Dedicated Sessions¶
- Rate limiting implementation — token bucket, sliding window, fixed window, Redis sorted sets, ASP.NET Core
AddRateLimiter - PostGIS —
ST_DWithin,ST_MakePoint, GIST index internals, updating driver location - Class/object design (Pillar 1) — parking lot, chess game, LRU cache style problems