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:

Self-describing

The caller can't see your code, only your responses β†’ responses must carry their own meaning (honest status codes, structured errors).

Versioned

The caller upgrades on their own schedule β†’ you can't break the contract casually.

Idempotent

The network is unreliable β†’ the same request may arrive twice, so retries must be safe.

Guarded

The caller may be malicious or buggy β†’ auth + rate limits + validation at the boundary.

If you internalize these four pressures, you can derive most REST best practices from scratch in an interview instead of reciting them.

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

VerbMeaningIdempotent?Safe?
GETReadYesYes (no side effects)
POSTCreate / processNoNo
PUTReplace entire resourceYesNo
PATCHPartial updateUsually treated as yesNo
DELETERemoveYesNo

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:

CodeWhen
200 OKHere's your data.
201 CreatedPOST 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 ContentSuccess, nothing to say (DELETE).
400 Bad RequestThe request itself is malformed (bad JSON, missing field).
401 UnauthorizedWho are you? Missing/invalid credentials.
403 ForbiddenI know who you are; you can't do this.
404 Not FoundAlso used to hide the existence of resources the caller can't see (returning 403 for a secret resource leaks that it exists).
409 ConflictState conflict (duplicate unique key, edit collision, in-flight idempotency key).
422 UnprocessableSyntactically fine, semantically wrong ("end_date before start_date"). FastAPI returns this by default for Pydantic validation failures β€” nice detail to drop.
429 Too Many RequestsRate limited; include Retry-After.
500 / 502 / 503 / 504500 = 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.
Interview trap: returning 200 {"error": "not found"}. It breaks every generic client, monitor, and cache. Status codes exist so machines can react without parsing your body.

Status Code Picker β€” 8 rounds

Read the scenario, click the right status code. Instant verdict each round; score at the end.

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_id you 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:

Offset

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.

Cursor (keyset)

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.

Rule of thumb to state: cursor for infinite feeds and anything high-volume; offset is acceptable for small admin tables. Always return pagination metadata:
{ "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 (or sort=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_id misspelled 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:

WhereExampleVerdict
URL path/v1/incidentsExplicit, cache-friendly, easy to route/deprecate. Most common; what Stripe/GitHub-adjacent APIs do in practice.
HeaderApi-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=2Meh; easy to forget.
The stronger interview point: versioning is a last resort. Prefer additive, backward-compatible change β€” new optional fields, new endpoints β€” and only cut v2 for genuinely breaking changes. Breaking = removing/renaming fields, changing types or semantics, tightening validation. Adding a response field is not breaking (clients must ignore unknown fields β€” say "tolerant reader").

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.

The mechanism:
  1. Client generates a unique key per logical operation (UUID) and sends Idempotency-Key: 3f2a....
  2. Server, atomically, checks a store (Redis/DB with a unique constraint) for the key.
  3. New key β†’ record it (status: in-progress), do the work, store the response against the key.
  4. Seen key, finished β†’ replay the stored response verbatim. No re-execution.
  5. Seen key, in-progress β†’ return 409 or wait β€” prevents concurrent duplicates.
  6. 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

Authentication = who are you. Authorization = what may you do. Interviewers love hearing you separate them.

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.

Authorization Code (+ PKCE)

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.

Client Credentials

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.

Refresh 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.

If asked "how does 'Login with GitHub' work?" β€” describe the Authorization Code flow end to end. That's the expected answer.

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.

PitfallWhy it bites / the fix
Signed β‰  encryptedAnyone can base64-decode the payload. Never put secrets/PII in claims.
alg: none / algorithm confusionVerifiers 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 revocationLogout / 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 checksAlways validate exp, iss, aud. Skipping aud means a token minted for service A is accepted by service B.
Clock skewAllow a small leeway (Β±60s) or freshly-minted tokens fail on a fast clock.
JWT-as-session cargo cultDon'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:

Fixed window

"100 req/min counter, resets at :00". Simple; bursty at boundaries (200 requests in 2 seconds straddling the reset).

Sliding window

Smooths the boundary problem by weighting the previous window.

Token bucket

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.

Leaky bucket

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.

Design points: key limits by API key/user, not just IP (NAT, shared egress). Return 429 + 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):

  1. 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.
  2. Dedupe by event ID β€” idempotent consumption (store processed IDs with a TTL).
  3. 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.
  4. 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).
A crisp line for interviews: "Webhooks are an at-least-once, unordered event stream over HTTP β€” so my handler is fast, idempotent, and order-agnostic, and the queue behind it absorbs the rest."

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.
One-paragraph summary you can say aloud: "REST is my default at the boundary β€” universal, cacheable, debuggable. If frontend clients are drowning in round-trips and shaped views, I'd consider GraphQL as an aggregation layer. If internal services are chatting at high volume or need streaming, gRPC. The protocol is a tool choice; the invariants β€” clear contracts, idempotency, versioned evolution, auth at the boundary β€” carry across all three."

12Rapid-fire interview Q&A

PUT vs PATCH?

PUT replaces the whole resource (send everything); PATCH updates a subset. PUT is idempotent by definition; PATCH usually is in practice.

How do you make a POST safe to retry?

Idempotency keys β€” client-supplied unique key, server stores the result against it atomically, replays the stored response on duplicates.

How would you paginate a feed with heavy writes?

Cursor/keyset pagination on (created_at, id) β€” stable under inserts and O(1) per page, unlike OFFSET.

Where do you check authorization?

Every layer names the same answer: middleware handles authentication and coarse role checks, but resource ownership must be checked at the data-access level per request β€” otherwise IDOR.

How do you secure a webhook endpoint?

HMAC signature over the raw body with a constant-time compare, timestamp to block replays, fast 200 + async processing, dedupe by delivery ID. (Then tell the Sentinel version of this story.)

How do you evolve an API without breaking clients?

Additive changes only; clients as tolerant readers; deprecation headers + docs + sunset dates; version bump only for genuinely breaking changes.

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.