INTERN360ALL PROJECTS →

// 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

● 12 storys● 5 tasks● 7 bugs

// What you'll learn, in order

Each layer builds on the one before it. You never meet a concept before the thing it depends on.

  1. 01HTTP and resource basics4
  2. 02Errors done right2
  3. 03Validation and contracts3
  4. 04Pagination and filtering3
  5. 05Idempotency and safe retries2
  6. 06Versioning2
  7. 07Auth, rate limits and security3
  8. 08Webhooks, contracts and concurrency5

// How it works

  1. 01

    Enroll

    Get your own private repo, generated from the project blueprint.

  2. 02

    Ship work items

    Pick up bugs, tasks, and stories — one PR each, run through CI and AI code review.

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

1 · HTTP and resource basics

4 items · 8 SP
  • TASK-001

    Rename 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 ▾

    XS · 1SP

    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/:id
    • GET/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 into src/routes/customers.js and src/routes/payments.js with Express routers, mounted in src/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

    Everything 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 ▾

    S · 2SP

    Why it matters

    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.

    What you do

    Give every response its honest status code:

    • create → 201 Created + a Location header 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.js and src/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
  • STORY-003

    Invoices 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 ▾

    S · 3SP

    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 /invoices with { customerId, items: [{ description, quantity, unitPrice }] } → 201 + Location; the server computes total (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), new src/routes/invoices.js, mounted in src/app.js.

    Done when

    • Resource semantics consistent with the patterns already established (201/Location/404)
    • total computed server-side as Σ quantity × unitPrice
    • Empty-items and unknown-customer rejections correct
    • Line items modeled sensibly (nested in responses)
    • Tests pass
  • TASK-004

    PATCH 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 ▾

    S · 2SP

    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/:id currently 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. Check body.field === undefined, not falsiness — an explicit null/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

2 · Errors done right

2 items · 7 SP
  • STORY-005

    RFC 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 ▾

    M · 5SP

    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? }
    • type is a stable URI per error category (not-found, validation, …)
    • handlers throw a typed ApiError; 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+json and body shape everywhere
    • Stable type URIs; status field matches the HTTP status
    • Unknown routes covered
    • Tests pass
  • BUG-006

    500s 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 ▾

    S · 2SP

    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

3 · Validation and contracts

3 items · 9 SP
  • STORY-007

    Validate 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 ▾

    M · 5SP

    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 errors array 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.js exporting a validate(schema) middleware; zod is already in package.json. Map issue.path to the field name.

    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

    Money 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 ▾

    S · 2SP

    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 × 3 isn't 19.98.

    Fix money end-to-end using integer minor units (paise):

    • API accepts integer paise only — 19.99 is rejected with 422 (that's what 1999 is 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)
    • currency present on payment/refund responses
    • Tests pass
  • TASK-009

    One 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 ▾

    S · 2SP

    Why it matters

    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.

    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.js with toCustomer/toPayment/toRefund/toInvoice. Replace every toLocaleString() insert with clock.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

4 · Pagination and filtering

3 items · 10 SP
  • STORY-010

    Cursor 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 ▾

    M · 5SP

    Why it matters

    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.

    What you do

    Paginate GET /payments:

    • ?limit= (default 10, hard cap 100) and ?cursor=
    • response envelope { data: [...], nextCursor }; nextCursor is null on 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). Fetch limit + 1 rows 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

    Filtering 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 ▾

    S · 3SP

    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 status refunded)
    • ?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.js list handler. Express 5's query parser may give you req.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 amount makes those ties more likely — that is expected here, and BUG-012 fixes it next.

  • BUG-012

    Pagination 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 ▾

    S · 2SP

    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 only createdAt < cursor skips 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 in src/lib/pagination.js usage. The test uses the clock seam's freeze() 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

5 · Idempotency and safe retries

2 items · 10 SP
  • STORY-013

    Idempotency-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 ▾

    L · 8SP

    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-Key header on POST /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: true header — 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_keys table (key PRIMARY KEY, request_hash, response), new src/lib/idempotency.js wrapper 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: true on replays
    • Key+different-body → 422 problem
    • Keyless requests unaffected
    • Tests pass
  • BUG-014

    Idempotency 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 ▾

    S · 2SP

    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 IGNORE first, check info.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

6 · Versioning

2 items · 7 SP
  • STORY-015

    Ship /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 ▾

    M · 5SP

    Why it matters

    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.

    What you do

    Introduce versioned prefixes:

    • /v1/... = the current contract, byte-stable forever; bare unversioned paths remain an alias of v1
    • /v2/customers: accepts/returns firstName + lastName (no name); v1-created customers readable through v2 and vice versa
    • /v2/payments: description replaces desc
    • 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/paymentsRouter with { serialize, createSchema, mapInput }; add src/routes/v2/*.js wrappers; mount /v1, /v2, and the root alias in src/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

    v1 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 ▾

    S · 2SP

    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: true
    • Sunset: <HTTP date> — from configuration (option/env), never hardcoded
    • Link: <migration-guide-url>; rel="deprecation"

    /v2 responses 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 (sunsetV1 option / SUNSET_V1 env)
    • Implemented once as middleware, not per-route
    • Tests pass

7 · Auth, rate limits and security

3 items · 11 SP
  • STORY-017

    API 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 ▾

    M · 5SP

    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) and write (everything else); a key without the needed scope → 403 problem — know the difference: 401 = who are you, 403 = you may not
    • POST /api-keys creates a key: the raw key appears in that response once; only its SHA-256 hash is stored
    • GET /api-keys lists metadata (name, prefix, scopes) — never key material
    • two demo keys ship seeded (see the test helpers)

    Hints

    api_keys table + seeds in src/db.js, src/lib/auth.js middleware, 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

    Rate 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 ▾

    M · 5SP

    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 limit tokens refilling continuously over windowMs (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-After seconds
    • limits configurable (rateLimit option); time comes from the clock seam so tests can drive refills

    Hints

    New src/lib/rate-limit.js, mounted after auth (it needs req.apiKey). Never call Date.now() — use ctx.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
  • BUG-019

    API 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 ▾

    XS · 1SP

    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:

    1. Redaction: the Authorization header (or any key material) must never be written to any log line.
    2. Constant-time compare: use crypto.timingSafeEqual on 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)
    • timingSafeEqual used for the hash comparison
    • Auth still works for valid keys
    • Tests pass

8 · Webhooks, contracts and concurrency

5 items · 20 SP
  • STORY-020

    Signed 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 ▾

    L · 8SP

    Why it matters

    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.

    What you do

    Build the provider side of webhooks:

    • POST /webhook-endpoints { url } → register; response contains the signing secret (whsec_…) exactly once
    • on every successful payment, deliver a payment.succeeded event (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/deliveries shows 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_deliveries in src/db.js, src/lib/webhooks.js (sign + dispatch), src/routes/webhooks.js. Use ctx.webhookTransport and ctx.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

    OpenAPI 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 ▾

    M · 5SP

    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 $ref reuse, 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.json at the repo root, loaded and served in src/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
  • TASK-022

    ETags 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 ▾

    S · 2SP

    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/:id returns an ETag (versioned); If-None-Match with the current tag → 304 with no body (caching)
    • PATCH accepts If-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 version column to customers in src/db.js (bump on update); ETag = "v<version>" in src/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

    Webhook 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 ▾

    S · 2SP

    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.js only. The test signs payloads itself with a fixed 'now' — determinism via the now parameter.

    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

    Request 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 ▾

    S · 3SP

    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:

    1. Request IDs: accept the caller's X-Request-Id or generate one; echo it on every response; include it in every log line; problem documents carry it as their instance (RFC 7807), so an error response names the exact request to search for.
    2. 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; extend sendProblem in src/lib/problem.js. crypto.randomUUID() for generated ids.

    Done when

    • Provided id echoed; generated when absent
    • Problem instance contains the request id
    • Log lines carry the id
    • /health probes the DB for real and needs no API key
    • Tests pass
Sign in to join this project