// Project brief
API Development — PayFlow
Node.js · Express · REST · API Standards
Join the platform team at PayFlow, a payments company. The API works — but it was built in a hurry. Bring it up to professional standards ticket by ticket: honest status codes, RFC 7807 errors, integer money, cursor pagination, idempotency keys, versioning, scoped API keys, rate limits, signed webhooks, and an enforced OpenAPI contract.

Work items
24
Story points
82
Est. effort
~41h
Top difficulty
6/10
// How it works
01
Enroll
Get your own private repo, generated from the project blueprint.
02
Ship work items
Pick up bugs, tasks, and stories — one PR each, run through CI and AI code review.
03
Earn story points
Merge to bank verified points and unlock the next item on the backlog.
// The backlog
24work items, unlocked in order as you ship. Open any of them to see exactly what you'd be doing — nothing here is hidden until you sign up.
TASK-001
XS · 1SPRename RPC routes to resources
Merchants integrating PayFlow keep asking why our API looks nothing like Stripe's. RPC-style routes (`POST /getCustomers`) confuse every client library and every new hire.
How to do this ▾
Why it matters
Merchants integrating PayFlow keep asking why our API looks nothing like Stripe's. RPC-style routes (
POST /getCustomers) confuse every client library and every new hire.What you do
Restructure the API around resources:
GET/POST /customers,GET/PATCH/DELETE /customers/:idGET/POST /payments,GET /payments/:id,POST /payments/:id/refunds
URLs identify things (plural nouns); HTTP methods express actions. Remove the old RPC routes entirely — they must 404.
Behavior stays exactly as it is (yes, including the sloppy parts — later tickets fix those). This ticket is about the route surface only.
Hints
Everything lives in
src/routes.js. Split it intosrc/routes/customers.jsandsrc/routes/payments.jswith Express routers, mounted insrc/app.js.Done when
- No verbs in any path; collection/item pattern used throughout
- Old RPC routes removed (404), new resource routes work
- Routes split sensibly (e.g. per-resource router files) rather than one pile
- No behavior changes beyond routing
- Tests pass
BUG-002
S · 2SPEverything returns 200 — even errors
A merchant's retry system melted down: our API told it `200 OK` while the body said `{"error": "customer not found"}`. HTTP status codes are the contract vocabulary that every client, retry library, and monitor branches on.
How to do this ▾
Why it matters
A merchant's retry system melted down: our API told it
200 OKwhile the body said{"error": "customer not found"}. HTTP status codes are the contract vocabulary that every client, retry library, and monitor branches on.What you do
Give every response its honest status code:
- create → 201 Created + a
Locationheader pointing at the new resource - delete → 204 No Content, empty body
- missing resource → 404
- rejected business rule (e.g. refund exceeds payment) → an error status, never a 200
Apply this to customers, payments, and refunds. An error message riding inside a 200 is the single most common API smell — remove every instance.
Hints
Touch
src/routes/customers.jsandsrc/routes/payments.js. Express:res.status(201).location(...),res.status(204).send().Done when
- 201 + Location on creates; 204 with no body on deletes; 404 on missing resources
- No error text inside any 2xx response, anywhere
- Refund-exceeds and unknown-customer paths return 4xx
- Tests pass
- create → 201 Created + a
STORY-003
S · 3SPInvoices resource with computed totals
Merchants want to bill customers with line items ("2 × Standard plan, 1 × setup fee") instead of raw payments. First revenue feature built by you end-to-end.
How to do this ▾
Why it matters
Merchants want to bill customers with line items ("2 × Standard plan, 1 × setup fee") instead of raw payments. First revenue feature built by you end-to-end.
What you do
Build
/invoices:POST /invoiceswith{ customerId, items: [{ description, quantity, unitPrice }] }→ 201 + Location; the server computestotal(never trust a client-sent total)GET /invoices/:id→ invoice with its items; unknown id → 404- unknown customer → 404; empty
items→ 4xx
Apply everything from TASK-001/BUG-002 without being told — that's the point of this ticket.
Hints
New tables in
src/db.js(invoices + invoice_items), newsrc/routes/invoices.js, mounted insrc/app.js.Done when
- Resource semantics consistent with the patterns already established (201/Location/404)
totalcomputed server-side as Σ quantity × unitPrice- Empty-items and unknown-customer rejections correct
- Line items modeled sensibly (nested in responses)
- Tests pass
TASK-004
S · 2SPPATCH means partial update
A merchant updated a customer's email and silently wiped the customer's name. Data loss from update semantics is a support-ticket factory.
How to do this ▾
Why it matters
A merchant updated a customer's email and silently wiped the customer's name. Data loss from update semantics is a support-ticket factory.
What you do
PATCH /customers/:idcurrently replaces every field with whatever arrived — absent fields become null.Fix it to be a true partial update: only fields present in the request body change; everything else keeps its value. An empty patch changes nothing and doesn't crash.
In your PR description, explain the difference between PUT (full replace) and PATCH (partial merge) and why the old behavior loses data.
Hints
The PATCH handler in
src/routes/customers.js. Checkbody.field === undefined, not falsiness — an explicitnull/empty string is a real value.Done when
- Only provided fields change; absent fields untouched
- Empty body: no-op, no crash
- PR explains PUT vs PATCH semantics in the student's own words
- Tests pass
STORY-005
M · 5SPRFC 7807 problem+json error contract
Every error we return has a different shape, so merchants write a special parser per endpoint. RFC 7807 (`application/problem+json`) is the industry standard for machine-readable errors — Stripe-class APIs treat the error response as a first-class contract.
How to do this ▾
Why it matters
Every error we return has a different shape, so merchants write a special parser per endpoint. RFC 7807 (
application/problem+json) is the industry standard for machine-readable errors — Stripe-class APIs treat the error response as a first-class contract.What you do
Introduce one error contract for the whole API:
- every error is
application/problem+json:{ type, title, status, detail? } typeis a stable URI per error category (not-found, validation, …)- handlers
throwa typedApiError; ONE error middleware turns it into the problem document - unknown routes also answer with a problem document, not the framework's HTML default
Convert every existing error path (404s, refund-exceeds, invoice rejections).
Hints
New
src/lib/problem.js(ApiError class +sendProblem+ notFound/error middleware). Express 5 forwards thrown/rejected handler errors to error middleware automatically.Done when
- Single error pathway: no scattered
res.status().json({error})left - Correct
Content-Type: application/problem+jsonand body shape everywhere - Stable
typeURIs;statusfield matches the HTTP status - Unknown routes covered
- Tests pass
- every error is
BUG-006
S · 2SP500s leak stack traces to clients
Security review finding, severity high: when our charge processor hiccups, the API sends the full stack trace and internal error message to the merchant. Information disclosure like this appears in virtually every real pentest report.
How to do this ▾
Why it matters
Security review finding, severity high: when our charge processor hiccups, the API sends the full stack trace and internal error message to the merchant. Information disclosure like this appears in virtually every real pentest report.
What you do
When an unexpected error occurs:
- the client gets a generic 500 problem document — no message, no stack, no internals
- the server logs the full error (message + stack) so we can still debug it
The boundary rule: observability inward, opacity outward. Expected errors (ApiError) keep their helpful details; unexpected ones tell the client nothing.
Hints
The error middleware in
src/lib/problem.js. The test crashes the charge processor seam and inspects both the response and the captured logs.Done when
- 500 problem contains no stack frames, no internal error message
- Full error still logged server-side via the injected logger
- Expected ApiError details still surface normally
- Tests pass
STORY-007
M · 5SPValidate every request at the boundary
A merchant sent `"amount": "lots"` and got a 500 with a stack trace from deep inside a handler. All input is hostile until validated — and the frontend team needs field-level errors to show users.
How to do this ▾
Why it matters
A merchant sent
"amount": "lots"and got a 500 with a stack trace from deep inside a handler. All input is hostile until validated — and the frontend team needs field-level errors to show users.What you do
Add a validation layer (zod) at the route boundary:
- schemas for creating customers, payments, refunds, invoices, and patching customers
- invalid input → 422 problem document with an
errorsarray of{ field, message }— reporting every invalid field at once, not just the first - handlers only ever see parsed, valid data
Validation is middleware, not code sprinkled inside handlers.
Hints
New
src/lib/validate.jsexporting avalidate(schema)middleware; zod is already in package.json. Mapissue.pathto thefieldname.Done when
- Every mutating route validated; no handler touches unvalidated input
- 422 problem with field-level
errors[], all invalid fields reported together - Unknown/absurd types (string customerId, string amount) rejected cleanly, never 500
- Valid requests unaffected
- Tests pass
BUG-008
S · 2SPMoney is stored as floats — refunds drift by paise
Finance reconciliation found payments where the refundable remainder was off by a paisa — customers literally cannot get their last paisa back. IEEE-754 floats cannot represent decimal money; this bug class has caused legendary production incidents industry-wide.
How to do this ▾
Why it matters
Finance reconciliation found payments where the refundable remainder was off by a paisa — customers literally cannot get their last paisa back. IEEE-754 floats cannot represent decimal money; this bug class has caused legendary production incidents industry-wide.
What you do
Reproduce: pay ₹19.99 (1999 paise), refund 666 paise three times, then try to refund the final 1 paisa — the API refuses, because internally amounts are floats and
6.66 × 3isn't19.98.Fix money end-to-end using integer minor units (paise):
- API accepts integer paise only —
19.99is rejected with 422 (that's what1999is for) - storage columns become INTEGER; all arithmetic is integer arithmetic
- money responses carry an explicit
currency: "INR" - invoices (unitPrice, total) follow the same rule
Hints
src/db.js(REAL → INTEGER),src/routes/payments.js(drop the ÷100 'rupees for readability' conversion — that's the bug),src/routes/invoices.js. This is the same convention Stripe and Razorpay use.Done when
- No floating-point arithmetic anywhere on the money path
- Fractional amounts rejected with 422
- Refund sequence exact to the last paisa (the reproduce case passes)
currencypresent on payment/refund responses- Tests pass
- API accepts integer paise only —
TASK-009
S · 2SPOne contract: camelCase fields, ISO-8601 UTC dates
Responses mix `created_at` and `createdAt`, and dates are locale strings like `4/8/2026, 1:43 pm` that no client can parse reliably. Consistency IS the contract — every inconsistency becomes a client-side workaround.
How to do this ▾
Why it matters
Responses mix
created_atandcreatedAt, and dates are locale strings like4/8/2026, 1:43 pmthat no client can parse reliably. Consistency IS the contract — every inconsistency becomes a client-side workaround.What you do
Standardize every response body:
- camelCase field names, everywhere, including nested objects
- timestamps are ISO-8601 UTC (
2026-08-04T10:30:00.000Z) - database rows never leak directly into responses — introduce serializers, the one place response shapes are defined
Store timestamps as ISO strings too (the clock seam's
toISOString()), so sorting works lexicographically.Hints
New
src/lib/serialize.jswithtoCustomer/toPayment/toRefund/toInvoice. Replace everytoLocaleString()insert withclock.now().toISOString().Done when
- Zero snake_case keys in any response, nested objects included
- All timestamps ISO-8601 UTC, parseable by
Date.parse - Serializer layer (
src/lib/serialize.js) used by every route; no raw rows in responses - Tests pass
STORY-010
M · 5SPCursor pagination on payments
`GET /payments` returns every payment ever made. Fine with 12 rows in the demo; a production merchant with 2M payments would take the API down with one request. Also: offset pagination (`?page=3`) breaks when rows are inserted mid-scroll — that's why Stripe, GitHub, and Slack all use cursors.
How to do this ▾
Why it matters
GET /paymentsreturns every payment ever made. Fine with 12 rows in the demo; a production merchant with 2M payments would take the API down with one request. Also: offset pagination (?page=3) breaks when rows are inserted mid-scroll — that's why Stripe, GitHub, and Slack all use cursors.What you do
Paginate
GET /payments:?limit=(default 10, hard cap 100) and?cursor=- response envelope
{ data: [...], nextCursor };nextCursorisnullon the last page - the cursor is opaque (e.g. base64 JSON) — clients must never parse it
- a garbage cursor → 422 problem
- order: newest first
Walking pages must visit every payment exactly once.
Hints
New
src/lib/pagination.js(encode/decode). Fetchlimit + 1rows to know whether another page exists. Remember that a tampered cursor can still decode as JSON — validate its shape, not just that it parses.Done when
- Envelope shape with data + nextCursor; default and cap enforced
- Cursor opaque and tamper-tolerant (malformed OR tampered-but-decodable cursor → 422, not 500)
- Walking all pages returns every payment exactly once, for payments created at distinct times
- Stable ordering (newest first)
- Tests pass
Scope note: pagination across payments that share an identical timestamp is a known edge case tracked separately as BUG-012. Do not solve it here.
STORY-011
S · 3SPFiltering and sorting — with a whitelist
Merchants need "refunded payments this month" without downloading everything. But arbitrary filter/sort fields are an injection surface and an unindexed-query performance trap — real APIs whitelist.
How to do this ▾
Why it matters
Merchants need "refunded payments this month" without downloading everything. But arbitrary filter/sort fields are an injection surface and an unindexed-query performance trap — real APIs whitelist.
What you do
Extend
GET /payments:?status=succeeded|refunded(a fully refunded payment now flips to statusrefunded)?created[gte]=/?created[lte]=ISO date-range filters?sort=createdAt|-createdAt|amount|-amount(default-createdAt); any other field → 422- filters and sorting compose with cursor pagination
Only whitelisted fields are ever interpolated into SQL — values always go through parameters.
Hints
src/routes/payments.jslist handler. Express 5's query parser may give youreq.query["created[gte]"]— handle both bracket forms.Done when
- Whitelist enforced for sort; unknown field → 422 problem
- Range filters work; full refund flips payment status to
refunded - Filters compose with pagination (a filtered walk returns exactly the matching payments)
- Field names whitelisted, values parameterized
- Tests pass
Scope note: as in STORY-010, pagination across rows that share the same sort value is BUG-012's job. Sorting by
amountmakes those ties more likely — that is expected here, and BUG-012 fixes it next.BUG-012
S · 2SPPagination silently skips payments that share a timestamp
A merchant's reconciliation job paged through payments and came up short — money "missing" with no error anywhere. Pagination bugs are silent: nothing fails, data just vanishes. This exact bug ships to production constantly.
How to do this ▾
Why it matters
A merchant's reconciliation job paged through payments and came up short — money "missing" with no error anywhere. Pagination bugs are silent: nothing fails, data just vanishes. This exact bug ships to production constantly.
What you do
Reproduce: several payments created in the same clock instant share a
createdAt. Walking pages with a cursor that compares onlycreatedAt < cursorskips the tied rows at each page boundary.Fix with a compound cursor
(createdAt, id)giving a total order:WHERE createdAt < ? OR (createdAt = ? AND id < ?).The exhaustive-walk test (freeze the clock, create ties, walk with limit 2) must show every payment exactly once.
Hints
src/routes/payments.js+ the cursor payload insrc/lib/pagination.jsusage. The test uses the clock seam'sfreeze()to force ties.Done when
- Compound cursor with id tie-break in both the WHERE clause and the cursor payload
- Tied-timestamp walk: complete, no duplicates, no skips
- Works for both sort directions
- Tests pass
STORY-013
L · 8SPIdempotency-Key: retry-safe payment creation
THE flagship ticket. A merchant's request times out mid-charge — did the charge happen? They cannot know, and blind retry double-charges the customer. Every serious payment API (Stripe, Razorpay, PayPal) solves this with idempotency keys; 'how do you prevent double-charging on retry?' is a canonical backend interview question.
How to do this ▾
Why it matters
THE flagship ticket. A merchant's request times out mid-charge — did the charge happen? They cannot know, and blind retry double-charges the customer. Every serious payment API (Stripe, Razorpay, PayPal) solves this with idempotency keys; 'how do you prevent double-charging on retry?' is a canonical backend interview question.
What you do
Support an
Idempotency-Keyheader onPOST /payments:- first request with a key: execute normally, store the response against the key
- replay (same key + same body): return the stored response — same status, same body,
Idempotent-Replay: trueheader — and execute nothing (no second charge) - same key + different body: 422 problem (a key identifies one logical request)
- no key: behaves as before
Fingerprint the body (hash) to detect mismatches.
Hints
New
idempotency_keystable (key PRIMARY KEY, request_hash, response), newsrc/lib/idempotency.jswrapper used by the payments POST handler. SHA-256 the JSON body for the fingerprint.Done when
- Replay returns the stored result byte-for-byte; charge processor invoked exactly once
Idempotent-Replay: trueon replays- Key+different-body → 422 problem
- Keyless requests unaffected
- Tests pass
BUG-014
S · 2SPIdempotency has a race — concurrent retries double-charge
A merchant's client retried while the original request was still in flight. Both saw 'no stored key yet', both charged. Check-then-act races are among the hardest production bugs — here you get one with a deterministic test.
How to do this ▾
Why it matters
A merchant's client retried while the original request was still in flight. Both saw 'no stored key yet', both charged. Check-then-act races are among the hardest production bugs — here you get one with a deterministic test.
What you do
The new concurrency test fires two simultaneous requests with the same key: exactly one charge must happen, and both callers must receive the same payment.
Fix by reserving the key before the side effect: insert the key row (status
pending) first — the primary-key constraint makes exactly one concurrent request win. The loser waits for the winner's stored response and replays it. If the winner fails, release the key so a retry can execute cleanly.The database constraint is the lock. An in-memory flag would not survive multiple processes.
Hints
src/lib/idempotency.js:INSERT OR IGNOREfirst, checkinfo.changes. Poll for the winner's completion via the delays seam (ctx.delays.wait), bounded.Done when
- Concurrent-duplicate test: exactly one charge, both responses 201 with the same payment id
- Guarantee comes from the DB unique/primary-key constraint, not an in-memory check
- Failure path releases the reservation
- Concurrent same-key different-body: one 201, one 422
- Tests pass
STORY-015
M · 5SPShip /v2 with breaking changes — /v1 stays frozen
Product wants `name` split into `firstName`/`lastName` and `desc` renamed to `description`. Both are breaking changes — and 40 merchants run against the current API. Old clients are a permanent obligation: this is how real APIs evolve without burning integrations.
How to do this ▾
Why it matters
Product wants
namesplit intofirstName/lastNameanddescrenamed todescription. Both are breaking changes — and 40 merchants run against the current API. Old clients are a permanent obligation: this is how real APIs evolve without burning integrations.What you do
Introduce versioned prefixes:
/v1/...= the current contract, byte-stable forever; bare unversioned paths remain an alias of v1/v2/customers: accepts/returnsfirstName+lastName(noname); v1-created customers readable through v2 and vice versa/v2/payments:descriptionreplacesdesc- no business-logic duplication: shared routers take the version differences (wire schema, input mapper, serializer) as options — versions are thin wrappers
Storage does not change.
Hints
Parametrize
customersRouter/paymentsRouterwith{ serialize, createSchema, mapInput }; addsrc/routes/v2/*.jswrappers; mount/v1,/v2, and the root alias insrc/app.js.Done when
- v1 responses unchanged (all earlier tests still green); unversioned = v1
- v2 shapes correct both directions; v2 rejects the v1 shape (422)
- One shared implementation; per-version serializers/schemas only
- Same records readable through both versions
- Tests pass
TASK-016
S · 2SPv1 announces its own retirement
Deprecation is a process, not a deletion. Merchants need machine-readable notice on every v1 call so their tooling can flag it long before the shutdown date.
How to do this ▾
Why it matters
Deprecation is a process, not a deletion. Merchants need machine-readable notice on every v1 call so their tooling can flag it long before the shutdown date.
What you do
Every
/v1(and unversioned) response carries:Deprecation: trueSunset: <HTTP date>— from configuration (option/env), never hardcodedLink: <migration-guide-url>; rel="deprecation"
/v2responses carry none of these.Hints
One middleware on the v1 router in
src/app.js. These are the emerging IETF standard headers for API lifecycle.Done when
- All three headers on every v1 + unversioned response; absent on v2
- Sunset date read from config (
sunsetV1option /SUNSET_V1env) - Implemented once as middleware, not per-route
- Tests pass
STORY-017
M · 5SPAPI keys with scopes — 401 vs 403 done right
The API is open to the world. Machine-to-machine auth for a payments API means API keys — designed the way Stripe/OpenAI/GitHub do it: shown once, stored hashed, scoped.
How to do this ▾
Why it matters
The API is open to the world. Machine-to-machine auth for a payments API means API keys — designed the way Stripe/OpenAI/GitHub do it: shown once, stored hashed, scoped.
What you do
Add key auth:
Authorization: Bearer <key>; missing/unknown key → 401 problem; landing page (and docs/health) stay public- scopes:
read(GET) andwrite(everything else); a key without the needed scope → 403 problem — know the difference: 401 = who are you, 403 = you may not POST /api-keyscreates a key: the raw key appears in that response once; only its SHA-256 hash is storedGET /api-keyslists metadata (name, prefix, scopes) — never key material- two demo keys ship seeded (see the test helpers)
Hints
api_keystable + seeds insrc/db.js,src/lib/auth.jsmiddleware,src/routes/api-keys.js. Mount auth after the landing route, before the resource routers.Done when
- 401 vs 403 used correctly; problem bodies
- Raw keys never stored — hash at rest, shown once at creation
- Scope check central (middleware), not per-handler
- Listing exposes no key material
- Tests pass
STORY-018
M · 5SPRate limiting with standard headers
One runaway integration loop can take the API down for everyone. Real APIs throttle per key AND tell clients where they stand — the X-RateLimit-* trio is a de-facto standard client libraries rely on.
How to do this ▾
Why it matters
One runaway integration loop can take the API down for everyone. Real APIs throttle per key AND tell clients where they stand — the X-RateLimit-* trio is a de-facto standard client libraries rely on.
What you do
Token-bucket rate limiting per API key:
- bucket of
limittokens refilling continuously overwindowMs(a fixed window allows 2× bursts across the boundary — the brief explains why bucket beats window) - every response carries
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset— successes too, not just rejections - over the limit → 429 problem +
Retry-Afterseconds - limits configurable (
rateLimitoption); time comes from the clock seam so tests can drive refills
Hints
New
src/lib/rate-limit.js, mounted after auth (it needsreq.apiKey). Never callDate.now()— usectx.clock.Done when
- Headers on successful responses as well as 429s
- Bucket math correct under the fake clock (refill test passes)
- Per-key isolation (one key exhausted ≠ all keys blocked)
- 429 problem + sensible Retry-After
- Tests pass
- bucket of
BUG-019
XS · 1SPAPI keys leak into logs; comparison isn't timing-safe
Security audit, two findings: (1) the auth middleware logs the full Authorization header — logs are one of the most common credential-leak vectors in real breaches; (2) key hashes are compared with `===`, which leaks timing information.
How to do this ▾
Why it matters
Security audit, two findings: (1) the auth middleware logs the full Authorization header — logs are one of the most common credential-leak vectors in real breaches; (2) key hashes are compared with
===, which leaks timing information.What you do
Two fixes in the auth middleware:
- Redaction: the Authorization header (or any key material) must never be written to any log line.
- Constant-time compare: use
crypto.timingSafeEqualon the hash buffers instead of===.
Small diff, big habits — these two details mark security-literate engineers.
Hints
src/lib/auth.js. Compare equal-length Buffers; guard the length check first.Done when
- No log line contains the raw key (test captures the logger)
timingSafeEqualused for the hash comparison- Auth still works for valid keys
- Tests pass
STORY-020
L · 8SPSigned webhooks with retry and a delivery log
Merchants currently poll `GET /payments` in a loop to learn about their own money. Push beats poll: every payments platform delivers signed webhook events, and 'verify the webhook signature' is a task every integrating developer meets.
How to do this ▾
Why it matters
Merchants currently poll
GET /paymentsin a loop to learn about their own money. Push beats poll: every payments platform delivers signed webhook events, and 'verify the webhook signature' is a task every integrating developer meets.What you do
Build the provider side of webhooks:
POST /webhook-endpoints { url }→ register; response contains the signingsecret(whsec_…) exactly once- on every successful payment, deliver a
payment.succeededevent (JSON) to each registered endpoint via the transport seam - sign each delivery:
PayFlow-Signature: t=<unix>,v1=<HMAC-SHA256(secret, "t.payload")>— the timestamp is inside the signed content - failures retry (3 attempts, backoff 1s → 5s via the delays seam); webhook failure never fails the payment
GET /webhook-endpoints/:id/deliveriesshows the attempt log- ship
src/webhook-verify.js, the merchant-side verification helper we'll publish in the docs
Hints
New tables
webhook_endpoints/webhook_deliveriesinsrc/db.js,src/lib/webhooks.js(sign + dispatch),src/routes/webhooks.js. Usectx.webhookTransportandctx.delays— never fetch/setTimeout directly.Done when
- Signature verifiable by an independent HMAC implementation
- Retries with backoff recorded; delivery log accurate (attempts, delivered flag)
- Payment 201 even when every delivery attempt fails
- Secrets never listed after creation
- Tests pass
STORY-021
M · 5SPOpenAPI 3.1: the contract, served and enforced
Merchants ask for our API docs; we send them a README. OpenAPI is the machine-readable contract the whole modern API toolchain consumes (codegen, gateways, validators) — and docs that aren't enforced by CI are fiction within a month.
How to do this ▾
Why it matters
Merchants ask for our API docs; we send them a README. OpenAPI is the machine-readable contract the whole modern API toolchain consumes (codegen, gateways, validators) — and docs that aren't enforced by CI are fiction within a month.
What you do
Author
openapi.json(OpenAPI 3.1) covering the core surface: customers (CRUD), payments (+pagination params, Idempotency-Key), refunds, invoices, webhook-endpoints, api-keys — including the Problem schema for errors.- serve it publicly at
GET /openapi.json - upgrade the landing page
/to list the documented endpoints (the deploy demo becomes an API console) - schemas use
$refreuse, and the response schemas are strict (additionalProperties: false) so drift between spec and code turns the build red — the test suite validates real responses against your schemas
Hints
openapi.jsonat the repo root, loaded and served insrc/app.js. The tests compile your schemas with ajv and check live responses against them.Done when
- Valid 3.1 document; core paths + Problem schema present
- Real customer/payment responses validate against the spec's schemas
- $ref reuse, no copy-pasted schema blocks
- Landing page lists endpoints from the spec
- Tests pass
- serve it publicly at
TASK-022
S · 2SPETags and optimistic concurrency
Two back-office admins edit the same customer; the slower save silently overwrites the faster one — the classic lost update. HTTP has a native cure: preconditions.
How to do this ▾
Why it matters
Two back-office admins edit the same customer; the slower save silently overwrites the faster one — the classic lost update. HTTP has a native cure: preconditions.
What you do
On customers:
GET /customers/:idreturns anETag(versioned);If-None-Matchwith the current tag → 304 with no body (caching)PATCHacceptsIf-Match: if the tag is stale → 412 Precondition Failed problem, and the record is untouched — the client must re-read and re-apply- every successful update changes the ETag exactly once
- requests without preconditions keep working
Hints
Add a
versioncolumn to customers insrc/db.js(bump on update); ETag ="v<version>"insrc/routes/customers.js.Done when
- ETag stable until the resource changes, then changes
- 304 path correct (empty body)
- Stale If-Match → 412 problem, no overwrite (test proves the other admin's write survives)
- Fresh If-Match succeeds
- Tests pass
BUG-023
S · 2SPWebhook verification accepts replayed deliveries
Red-team finding: an attacker who captures ONE legitimate webhook delivery can replay it to a merchant forever — the signature stays valid because nothing checks WHEN it was signed. This is exactly the check Stripe's docs insist on and integrators skip.
How to do this ▾
Why it matters
Red-team finding: an attacker who captures ONE legitimate webhook delivery can replay it to a merchant forever — the signature stays valid because nothing checks WHEN it was signed. This is exactly the check Stripe's docs insist on and integrators skip.
What you do
Fix
src/webhook-verify.js(the merchant-side helper we ship):- reject any delivery whose signed timestamp is outside a freshness window (default 300s) of the current time — the timestamp is inside the signed payload, so an attacker cannot forge a fresher one
- compare signatures with
timingSafeEqual, not=== - keep the API:
verifySignature({ secret, header, payload, now?, toleranceSeconds? })
In the PR, explain why the tolerance window closes the replay attack and what clock skew has to do with choosing its size.
Hints
src/webhook-verify.jsonly. The test signs payloads itself with a fixed 'now' — determinism via thenowparameter.Done when
- Old-but-validly-signed delivery rejected; fresh one accepted
- Constant-time signature comparison
- Tolerance configurable, sensible default
- PR explains the replay attack and the skew tradeoff
- Tests pass
STORY-024
S · 3SPRequest IDs and health checks
A merchant reports 'a payment failed around noon'. Which request, in which log line? Correlation IDs are how production debugging conversations actually work — 'send me the request id' — and health endpoints are the contract our own deploy system routes traffic on.
How to do this ▾
Why it matters
A merchant reports 'a payment failed around noon'. Which request, in which log line? Correlation IDs are how production debugging conversations actually work — 'send me the request id' — and health endpoints are the contract our own deploy system routes traffic on.
What you do
Two observability primitives:
- Request IDs: accept the caller's
X-Request-Idor generate one; echo it on every response; include it in every log line; problem documents carry it as theirinstance(RFC 7807), so an error response names the exact request to search for. GET /health(public): reports{ status, db }after actually probing the database — a health check that doesn't check anything is decoration. DB unreachable → 503.
Hints
Request-id middleware first in the chain in
src/app.js; extendsendProbleminsrc/lib/problem.js.crypto.randomUUID()for generated ids.Done when
- Provided id echoed; generated when absent
- Problem
instancecontains the request id - Log lines carry the id
- /health probes the DB for real and needs no API key
- Tests pass
- Request IDs: accept the caller's