R1.4 — The API contract: requests, responses, and how the answer comes back later

Part of R1. The story's request was POST /v1/payments, it was answered with 202 Accepted, and the real outcome arrived later. This part covers both halves of that contract: how the API itself is designed, and how a service tells a client about something that happened after the response.

Designing the API

The API is the one part of the system every client sees and nobody can change cheaply. Interviewers use it to check whether you think about the contract before the boxes.

The anatomy of a request

POST /v1/payments HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOi...
Idempotency-Key: 5f1c2a9e-...
Content-Type: application/json

{ "debtor_account": "...", "creditor_account": "...", "amount": "250.00", "currency": "INR" }

A method, a path, headers for identity and metadata, a body. REST is the default style; the others you may be asked to place are SOAP, GraphQL, gRPC, WebSocket and MQTT. REST is weak for deeply nested data or screens that need many round trips — that is where GraphQL earns its keep. Between internal services, gRPC is common for its typed contracts and binary encoding.

Resources and methods

Resources are plural nouns: /payments, /payments/{id}, /accounts/{id}/payments. The method says what you are doing to them.

Method Meaning Safe? Idempotent? Payments example
GET Read Yes Yes GET /v1/payments/{id} — fetch status
POST Create No No POST /v1/payments — initiate a payment
PUT Replace (creates if missing) No Yes PUT /v1/beneficiaries/{id} — set a beneficiary's details
DELETE Remove No Yes DELETE /v1/mandates/{id} — cancel a standing mandate

The row that matters is POST. HTTP gives POST no idempotency of its own, and creating a payment is exactly the call a mobile client retries after a timeout. So the API requires an Idempotency-Key header on every POST that moves money, and the server deduplicates against a durable record. R3 is the whole mechanism; at the API level, the commitment is just "the header is required, and a retry with the same key returns the original response."

Status codes that carry meaning

A status code is part of the contract. The ones worth using deliberately:

Code Meaning When a payments API returns it
200 OK Done, here is the result A GET, or an idempotent replay of a finished request
201 Created A new resource exists now Creating something synchronously — a beneficiary, a mandate
202 Accepted Accepted for processing, outcome not known yet POST /v1/payments — the rail has not settled
400 Bad Request The request is malformed Missing fields, invalid IBAN
401 Unauthorized Who are you? Missing or invalid token
403 Forbidden I know who you are; you may not Token lacks the scope for this account
404 Not Found No such resource Unknown payment ID
409 Conflict Conflicts with current state Same idempotency key still in flight; an expired FX quote
422 Unprocessable Well-formed but semantically wrong Idempotency key reused with a different body
429 Too Many Requests Rate limited Over quota; send Retry-After
500 / 503 Server failed / unavailable Your fault; the client may retry with the same key

POST /payments returns 202, not 200 or 201, because anything else claims an outcome you do not have yet. R28 makes the same call in its walkthroughs. And 409 versus 422 on an idempotency key is a distinction R3 explains: one says "wait and retry", the other says "you have a bug".

Rate-limit responses carry headers so a well-behaved client can slow down before it is rejected: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. Rejected requests get 429. The algorithms behind the limit are R14.

Query patterns: sort, filter, paginate

Lists are where APIs fall over at scale.

GET /v1/payments?status=PENDING&sort=created_at&order=desc&limit=50
GET /v1/payments?limit=50&cursor=eyJpZCI6IjkxMjMifQ

Sorting and filtering are query parameters. Pagination comes in two shapes:

Offset (?limit=20&page=5) Cursor (?limit=20&cursor=...)
How it works Skip N rows, return the next page Return rows after an opaque marker of the last row seen
Jump to page 40 Yes No — only next and previous
Deep pages Slow: the database reads and discards every skipped row Constant: an index seek to the marker
Rows inserted while paging Pages shift; items are skipped or repeated Stable
Best for Small admin lists Transaction histories, feeds, anything large or live

For a payment history, use a cursor. New payments arrive while the customer scrolls, and an offset page would show a payment twice or skip one. The cursor encodes the last (created_at, id) seen; R11 shows what the database does underneath and why the offset version gets slower with every page.

Versioning

Clients you do not control will call the old contract for years. Put the version in the path — /v1/payments, /v2/payments — document deprecations with a date, and migrate gradually with canary or blue-green releases rather than a big-bang cutover. Additive changes (a new optional field) do not need a new version; removing or renaming a field does. R16 covers changing contracts without downtime.

The error model

A status code says which class of thing went wrong. The body has to say what, in a shape every endpoint shares:

{
  "error": {
    "code": "INSUFFICIENT_FUNDS",
    "message": "Debtor account balance is below the payment amount.",
    "request_id": "req_7f3a...",
    "retryable": false
  }
}

A stable machine-readable code for clients to branch on, a human message, the request_id so support can find the trace (R15), and whether a retry can help. Never leak stack traces or internal hostnames. And state security explicitly: TLS everywhere, the auth scheme documented, rate limits published.

The defensible position: the contract tells the truth about the outcome. 202 when you do not know yet, a required idempotency key on anything that moves money, cursors for lists, and one error shape.

Talking back to the client: polling, SSE, WebSocket, webhooks

Back to the end of R1's story. The payment was accepted with 202. The outcome arrives later. Two parties need to hear about it: your system needs to hear from the rail or PSP, and the phone needs to hear from your system. HTTP is request-response, so "tell me when it changes" needs one of five mechanisms.

Mechanism Direction Connection Use for
Short polling Client asks repeatedly A new request each time Job status, low-frequency updates
Long polling Server holds the request until there is data Repeated long-held requests Notifications, before SSE was common
SSE Server to client One long-lived HTTP stream (text/event-stream), auto-reconnect with the last event ID Scores, feeds, tickers, a payment status screen
WebSocket Both ways, full duplex HTTP Upgrade to 101 Switching Protocols, then a persistent TCP connection Chat, collaborative editing, trading screens
Webhook Server to server An HTTP POST to a callback URL registered in advance PSP and rail status, partner event notifications

Choosing between them

Short polling is the simplest and the most wasteful: most responses say "nothing yet". It is fine for a status check every few seconds on a low-traffic screen, and it works everywhere.

Long polling cuts the waste — the server holds the request open until something happens, then the client immediately asks again. It was the workaround before SSE.

SSE is one-directional, server to client, over plain HTTP. It is UTF-8 text only, but it is firewall-friendly, and the browser reconnects automatically and tells the server the last event ID it saw, so nothing is missed. For "show the customer their payment status as it changes", SSE is usually enough.

WebSocket opens with an HTTP handshake (Upgrade: websocket, Connection: Upgrade, Sec-WebSocket-Key, Sec-WebSocket-Version), gets 101 Switching Protocols, and becomes a persistent full-duplex connection. Use it when the client also sends a stream — chat, collaborative editing, a trader's order screen. The cost is that the server is now stateful in exactly the sense of the first half of this section: connections pinned to servers, draining on deploy, reconnect storms.

The rule: choose the lightest mechanism that fits. Polling before SSE, SSE before WebSocket.

Webhooks: how the outside world reports back

A webhook is the one server-to-server mechanism in the table, and it is how PSPs and rails tell you what happened: a PSP's payment-status callback, NPCI's UPI callback, a SWIFT pacs.002 status. R0 calls it what it is — an event delivered over HTTP.

Receiving one safely takes four things:

Concern What to do Why
Authenticity Verify the signature (an HMAC over the body with a shared secret, or mTLS) before parsing anything Your callback URL is public; anyone can POST to it
Duplicates Deduplicate on the sender's reference — the RRN for UPI, the UETR for SWIFT The sender retries until you acknowledge, so you will see the same event twice (R3, R4)
Acknowledgement Store the event durably, return 2xx fast, process asynchronously A slow handler times out, and the sender retries a request you already received
Ordering Do not assume events arrive in order; apply them against the payment's state machine Retries reorder events; a "settled" can arrive before its "accepted"

A missing webhook is silent.

If the sender gives up or your endpoint was down, nothing tells you. So the webhook is never the only path: a scheduled status query catches the ones that never arrived, and reconciliation (R6) catches whatever both missed. Sending webhooks is the mirror image — drive them off the outbox (R4), sign them, give each an event ID the receiver can deduplicate on, and retry with backoff.

Step through the walkthrough below. It shows each mechanism as a sequence diagram: count the empty responses in short polling, see the held request in long polling, the single stream in SSE, the upgrade handshake in WebSocket, and the callback in the webhook step — the only one where your system is the server being called.

The defensible position: your system hears from rails by webhook, verified, deduplicated and backstopped by status queries and reconciliation; the client hears from your system by SSE unless it genuinely needs to talk back.

What to say in the interview

"POST /payments requires an idempotency key and returns 202 Accepted with a payment ID, because the rail has not settled anything yet; lists use cursor pagination, and every error shares one shape with a retryable flag. The rail reports back by a signed webhook that I deduplicate on its reference and backstop with status queries and reconciliation, and the customer's screen gets the outcome over SSE — the lightest channel that fits."