04 Β· Software Engineering β 01
API design: REST done well, and when to reach for something else
API design questions are a proxy for "have you built things other people depend on?" You have β Sentinel exposes webhook endpoints, calls GitHub's API, and talks to MCP servers. Anchor every answer in that experience.
01First principles: what is an API contract?
An API is a promise about behavior between two systems that deploy independently. Everything in good API design follows from that one fact:
The caller can't see your code, only your responses β responses must carry their own meaning (honest status codes, structured errors).
The caller upgrades on their own schedule β you can't break the contract casually.
The network is unreliable β the same request may arrive twice, so retries must be safe.
The caller may be malicious or buggy β auth + rate limits + validation at the boundary.
02REST done well
Resources, not actions
REST models your domain as nouns (resources) manipulated by a small fixed set of verbs (HTTP methods).
Good β verb + noun
GET /incidents
GET /incidents/42
POST /incidents
PATCH /incidents/42
DELETE /incidents/42
Bad β actions in the path
GET /getIncidents
POST /incident/fetch
POST /createNewIncident
POST /incidents/42/update
GET /deleteIncident?id=42 β never mutate on GET
Why? Uniformity. If everything is verb + noun, a new consumer can guess 80% of your API. Caches, proxies, and browsers also rely on verb semantics (GET is cacheable and safe; DELETE is not).
Sub-resources express ownership: GET /incidents/42/comments. Don't nest more than ~2 levels β /orgs/1/repos/2/issues/3/comments/4 is misery; give comments their own top-level ID.
Actions that don't map to CRUD are fine as sub-resource verbs when honest nouns don't exist:
POST /incidents/42/resolve # state transition
POST /pull-requests/42/approve # Sentinel's approval gate is exactly this shape
Purists say model it as PATCH {"status": "resolved"}. Either is defensible in an interview β say you'd pick one convention and be consistent.
The verbs, precisely
| Verb | Meaning | Idempotent? | Safe? |
|---|---|---|---|
| GET | Read | Yes | Yes (no side effects) |
| POST | Create / process | No | No |
| PUT | Replace entire resource | Yes | No |
| PATCH | Partial update | Usually treated as yes | No |
| DELETE | Remove | Yes | No |
Idempotent = calling it N times has the same effect as once. This matters because retries are inevitable. PUT /users/42 with the same body twice β same end state. POST /payments twice β two charges. That's why POST needs idempotency keys (section 07).
03Status codes β the ones that matter
You don't need all 60. You need these, used honestly:
| Code | When |
|---|---|
| 200 OK | Here's your data. |
| 201 Created | POST succeeded; include a Location: /incidents/42 header and the created body. |
| 202 Accepted | "I've queued it, not done it." Perfect for async work (e.g. "generate a fix PR" β return 202 + a status URL; don't hold the connection open for an LLM call). |
| 204 No Content | Success, nothing to say (DELETE). |
| 400 Bad Request | The request itself is malformed (bad JSON, missing field). |
| 401 Unauthorized | Who are you? Missing/invalid credentials. |
| 403 Forbidden | I know who you are; you can't do this. |
| 404 Not Found | Also used to hide the existence of resources the caller can't see (returning 403 for a secret resource leaks that it exists). |
| 409 Conflict | State conflict (duplicate unique key, edit collision, in-flight idempotency key). |
| 422 Unprocessable | Syntactically fine, semantically wrong ("end_date before start_date"). FastAPI returns this by default for Pydantic validation failures β nice detail to drop. |
| 429 Too Many Requests | Rate limited; include Retry-After. |
| 500 / 502 / 503 / 504 | 500 = my bug. 502/503/504 = infrastructure trouble (bad upstream / overloaded / upstream timeout). Retry 5xx, don't retry most 4xx β this distinction drives client retry logic. |
200 {"error": "not found"}. It breaks every generic client, monitor, and cache. Status codes exist so machines can react without parsing your body.04Error format β pick one shape, everywhere
{
"error": {
"type": "validation_error",
"message": "end_date must be after start_date",
"field": "end_date",
"request_id": "req_8fa2c1"
}
}
- A machine-readable
type(stable, documented) so clients can branch on it. - A human message for logs and debugging.
- A
request_idyou also log server-side β turns "it failed" support tickets into a one-grep investigation. The single highest-leverage field. - RFC 9457 ("Problem Details",
application/problem+json) is the standardized version of this β worth name-dropping.
05Pagination, filtering, field selection
Never return unbounded lists. Two schools:
GET /incidents?limit=50&offset=100
Simple; supports "jump to page 7". Breaks under writes: an insert mid-pagination causes duplicates or skips. And OFFSET 1000000 makes the DB scan and discard a million rows β O(n) per page.
GET /incidents?limit=50&cursor=eyJpZCI6MTAwfQ
The cursor encodes "position after item X" (often base64 of the last item's sort key). Query becomes WHERE (created_at, id) < (?, ?) ORDER BY ... LIMIT 50 β index-friendly, stable under inserts. Can't jump to arbitrary pages; cursors stay opaque to the client.
{ "data": [...], "next_cursor": "eyJ...", "has_more": true }Filtering, sorting, field selection
GET /incidents?status=open&severity=high&sort=-created_at&fields=id,title,status
- Query params for filters;
-prefix (orsort=created_at:desc) for direction. - Reject unknown filter params loudly (400) rather than silently ignoring β silent ignoring means a typo'd filter returns everything, which has caused real incidents (imagine
?user_idmisspelled on a DELETE-adjacent list). - Field selection (
fields=) is a lightweight answer to over-fetching before you reach for GraphQL.
06Versioning
You version because you promised not to break callers. Options:
| Where | Example | Verdict |
|---|---|---|
| URL path | /v1/incidents | Explicit, cache-friendly, easy to route/deprecate. Most common; what Stripe/GitHub-adjacent APIs do in practice. |
| Header | Api-Version: 2026-07-01 or Accept: application/vnd.myapi.v2+json | "Purer" (URL identifies the resource, not the representation); Stripe pins date-based versions per API key. |
| Query param | ?version=2 | Meh; easy to forget. |
07Idempotency keys β the retry-safety mechanism
Problem: client POSTs "create payment", the network drops before the response arrives. Did it succeed? The client's only sane move is retry β but naive retry double-charges.
- Client generates a unique key per logical operation (UUID) and sends
Idempotency-Key: 3f2a.... - Server, atomically, checks a store (Redis/DB with a unique constraint) for the key.
- New key β record it (status: in-progress), do the work, store the response against the key.
- Seen key, finished β replay the stored response verbatim. No re-execution.
- Seen key, in-progress β return 409 or wait β prevents concurrent duplicates.
- Keys expire (24h is typical β Stripe's number).
Subtleties worth volunteering
- The key must be inserted with a unique constraint / atomic SET NX, or two racing requests both think they're first.
- Store the response and status code β the retry should be indistinguishable from the original.
- Same key, different body? That's a client bug β 422.
- This is exactly why GitHub webhook deliveries carry a delivery ID: Sentinel dedupes webhook redeliveries the same way β GitHub retries deliveries, and an issue-detection pipeline that fires twice per event creates duplicate fix PRs. Concrete production story; use it.
08Authentication and authorization
API keys
A random secret in a header (Authorization: Bearer sk_live_... or X-Api-Key).
- Best for: server-to-server, one org per key, machine callers.
- Store only a hash of the key server-side (they're passwords). Show a prefix (
sk_live_a1b2...) for identification. - Support rotation: allow two active keys per client so they can roll without downtime.
- Weakness: no user identity, no scoping by default, long-lived β damage if leaked. Mitigate with scopes and per-key rate limits.
OAuth2 β the intuition, not the RFC
OAuth2 solves delegation: "let app A act on user U's data at service B, without U giving A their password." Mental model: the user is escorted to the service they trust (GitHub), says "yes, this app may read my repos", and the app receives a scoped, revocable access token instead of credentials.
The browser redirect dance: app redirects user to provider β user consents β provider redirects back with a one-time code β app's backend exchanges code + client secret for tokens, server-to-server, so the token never rides through the browser URL. PKCE adds a per-request proof so a stolen code is useless β now recommended for every client, not just mobile.
No user at all; service A authenticates as itself to service B (client id + secret β token). Machine-to-machine auth; how backend services get tokens.
Access tokens are short-lived (minutesβhours); the refresh token is the long-lived credential used to mint new ones, so a leaked access token has a small blast radius.
JWT β structure and pitfalls
A JWT is three base64url segments: header.payload.signature.
header {"alg": "RS256", "typ": "JWT", "kid": "key-2026-01"}
payload {"sub": "user_42", "iss": "auth.myco.com", "aud": "api.myco.com",
"exp": 1767225600, "iat": 1767222000, "scope": "incidents:read"}
signature = sign(base64(header) + "." + base64(payload), key)
The point: the server can verify the token without a DB lookup β the signature proves the issuer minted it and nothing was altered. That's the whole value: stateless auth that scales horizontally.
| Pitfall | Why it bites / the fix |
|---|---|
| Signed β encrypted | Anyone can base64-decode the payload. Never put secrets/PII in claims. |
| alg: none / algorithm confusion | Verifiers must pin the expected algorithm, never trust the header's alg. Classic exploit: server verifies RS256 but attacker sends HS256 signed with the public key as the HMAC secret. |
| No revocation | Logout / user-banned doesn't take effect until exp. Mitigate: short expiry (5β15 min) + refresh tokens; a denylist for the rare emergency (which reintroduces state β acknowledge the tradeoff). |
| Skipping claim checks | Always validate exp, iss, aud. Skipping aud means a token minted for service A is accepted by service B. |
| Clock skew | Allow a small leeway (Β±60s) or freshly-minted tokens fail on a fast clock. |
| JWT-as-session cargo cult | Don't use JWTs as session storage for a classic web app just because they're trendy β a server-side session with an opaque cookie is simpler and revocable. Saying this shows judgment. |
Authorization models, briefly
- RBAC β roles β permissions (admin, member, viewer). Right default for most products.
- Scopes β per-token capability narrowing (
repo:read), orthogonal to roles. - Resource-level checks β "is this their incident?" Must be enforced in the handler/query, not just middleware β missing this is BOLA/IDOR, the #1 API vulnerability class (OWASP API Top 10). Test: user A requests
/incidents/{B's id}β must be 404/403.
09Rate limiting
Protects you from abuse, bugs (retry storms), and noisy neighbors. Algorithms in one line each:
"100 req/min counter, resets at :00". Simple; bursty at boundaries (200 requests in 2 seconds straddling the reset).
Smooths the boundary problem by weighting the previous window.
Bucket holds N tokens, refills at R/sec; each request spends one. Allows bursts up to N while enforcing average rate R. The usual production choice; typically Redis + Lua for atomicity across instances.
Like token bucket but enforces a smooth constant outflow.
Headers (tell clients how to behave):
RateLimit-Limit: 100
RateLimit-Remaining: 12
RateLimit-Reset: 1767225600 # or seconds-until-reset
Retry-After: 30 # on the 429 itself
Older convention: X-RateLimit-* β GitHub uses these; the IETF draft standardizes the unprefixed forms. Knowing both reads well.
Retry-After, and implement client-side exponential backoff with jitter β mention jitter; without it, all clients retry in sync and re-stampede. You've lived this on the other side: LLM provider 429s are exactly this, and your gateway's backoff/fallback logic is the client-side mirror.10Webhooks β designing the other direction
You're unusually strong here: Sentinel is a webhook consumer at its front door. Talk about both sides. A webhook = the server calls you on events, instead of you polling.
Signing (authenticity)
Anyone who finds your webhook URL can POST fake events. Fix: provider computes HMAC-SHA256(secret, raw_body) and sends it in a header (GitHub: X-Hub-Signature-256: sha256=...). Consumer recomputes and compares with a constant-time comparison.
Unsigned / naive handler
@app.post("/webhook")
def handle(payload: dict):
# anyone can POST fake events
# == compare leaks timing info
# parsed JSON won't re-serialize
# byte-identically anyway
process(payload) # slow, inline
return {"ok": True}
Signed, verified first
@app.post("/webhook")
def handle(req):
raw = req.body # raw bytes!
mac = hmac.new(SECRET, raw,
sha256).hexdigest()
if not hmac.compare_digest(
"sha256="+mac, req.headers[
"X-Hub-Signature-256"]):
return 401 # reject FIRST
queue.put(raw) # async
return 200 # fast ack
Gotchas that show real experience:
- Sign/verify the raw bytes, before JSON parsing β re-serialized JSON won't match (key order, whitespace).
- Use
hmac.compare_digest, never==β timing attacks. - Include a timestamp in the signed material and reject old deliveries (replay protection β Stripe does this, Β±5 min tolerance).
- Rejecting unsigned/bad-signature requests should be the first thing the handler does.
Retries and delivery semantics
Consumers are flaky, so providers retry β which means delivery is at-least-once, never exactly-once. Consequences for both sides:
As the provider: retry with exponential backoff (e.g. 1m, 5m, 30m, 2h... over ~24h); treat non-2xx or timeout (>10s) as failure; give each event a stable delivery/event ID; provide a dead-letter view + manual redelivery in the dashboard; disable endpoints that fail for days (and notify).
As the consumer (your production reality):
- Return 200 fast, process async. Validate signature β enqueue β 200. Never do slow work (an LLM call!) inline, or the provider times out and retries, and now you have duplicates and pressure.
- Dedupe by event ID β idempotent consumption (store processed IDs with a TTL).
- Don't assume ordering. Retries and parallel delivery reorder events; use the event's payload/timestamps or re-fetch current state from the API rather than trusting arrival order.
- Fetch, don't trust: for critical actions, treat the webhook as a hint and confirm state via the API ("webhook says PR merged" β GET the PR).
11When not REST: GraphQL and gRPC
Don't be a zealot; be a chooser of tradeoffs.
GraphQL β when the client's data needs vary a lot
- Problem it solves: over-fetching / under-fetching / N+1 round-trips for UI clients. One request describes exactly the shape needed; great when many differently-shaped frontends (web, iOS, partner apps) share one backend, or a screen aggregates 5 resources.
- Costs: caching gets harder (everything is POST
/graphqlβ no HTTP-level cache semantics); you must defend against expensive queries (depth/complexity limits, persisted queries); N+1 moves into your resolvers (dataloader pattern); authorization becomes per-field, which is easy to get wrong; it's an extra layer of infra. - Verdict to give: GraphQL for a client-facing aggregation layer with diverse UIs; REST for service-to-service and public APIs (simpler, cacheable, curl-able).
gRPC β when it's your own services talking
- What it is: RPC over HTTP/2 with Protobuf β a typed, compiled contract (
.proto) generating client/server stubs in any language. - Wins: compact binary encoding + HTTP/2 multiplexing β much lower latency/CPU than JSON; contract-first with codegen (type-safe clients for free); first-class streaming (client-, server-, and bidirectional) β natural fit for token streaming or long-lived feeds; deadlines and cancellation built in.
- Costs: not browser-native (need grpc-web or a gateway); binary payloads are not human-debuggable with curl; protobuf schema evolution has its own discipline (field numbers are forever, never reuse them).
- Verdict to give: gRPC for internal service-to-service at scale and for streaming; REST/JSON at the public edge.
12Rapid-fire interview Q&A
PUT vs PATCH?
How do you make a POST safe to retry?
How would you paginate a feed with heavy writes?
(created_at, id) β stable under inserts and O(1) per page, unlike OFFSET.Where do you check authorization?
How do you secure a webhook endpoint?
How do you evolve an API without breaking clients?
13Self-test
Derive REST best practices from the four contract pressures.
Independent deployment β self-describing responses (honest status codes, structured errors); independent upgrade schedules β versioning discipline and tolerant readers; unreliable network β idempotency (safe verbs, idempotency keys, dedupe); untrusted callers β auth, rate limits, validation at the boundary.
Walk through the idempotency-key lifecycle, including both race conditions.
Client sends a UUID key per logical op. Server inserts it atomically (unique constraint / SET NX). New β run and store response + status code. Seen & finished β replay verbatim. Seen & in-progress β 409 or wait. Races: two concurrent firsts (solved by the atomic insert) and same key with a different body (client bug β 422). Keys expire (~24h, Stripe's number).
Explain the RS256/HS256 algorithm-confusion attack.
The server expects RS256 (asymmetric) but trusts the token header's alg. Attacker crafts an HS256 token using the server's public key as the HMAC secret β the naive verifier "verifies" it with that same public key and accepts a forged token. Fix: pin the expected algorithm server-side; never read it from the token.
Say the webhook one-liner and unpack each clause.
"At-least-once, unordered event stream over HTTP." At-least-once β dedupe by delivery ID; unordered β don't trust arrival order, re-fetch state; over HTTP β sign with HMAC over raw bytes, constant-time compare, timestamp against replays; and the handler is fast (validate β enqueue β 200) so the provider never times out into a retry storm.
Give the REST vs GraphQL vs gRPC verdict in under 30 seconds.
REST at the boundary (universal, cacheable, debuggable); GraphQL as an aggregation layer when diverse UIs drown in round-trips; gRPC for internal high-volume or streaming service-to-service. The invariants β contracts, idempotency, versioned evolution, auth at the boundary β carry across all three.