Skip to content
API Design Learn/Capstone Case Studies
Browsing as a guest. Sign in to save your progress and earn XP as you complete chapters.

Design a Payment API

12 min read

You'll learn to

  • -Combine idempotency, webhooks, and precise error handling into one coherent payment API design
  • -Prioritize correctness and auditability appropriately for a domain where a design mistake means real financial impact

Payment APIs are a favorite system design prompt precisely because correctness genuinely matters more here than almost anywhere else - a subtle mistake doesn't just produce a wrong API response, it can mean a customer charged twice or a merchant never credited for a real sale.

Step 1-2: Clients and Resources

Clients: a merchant's backend integrating to charge customers, and internally, a reconciliation/reporting system. Core resources: `Payment` (a single charge attempt), `Refund`, and `Customer` (for stored payment methods on returning customers).

Step 3: Protocol - REST, Deliberately

REST fits well here: payments are naturally resource-shaped (create a payment, look one up, issue a refund against one), the clients are primarily server-to-server integrations that don't need GraphQL's flexible querying, and REST's status code taxonomy maps cleanly onto payment outcomes.

Step 4: The Contract, Combining Techniques From Across This Course

  • -POST /v1/payments - charge a customer (idempotency-key required)
  • -GET /v1/payments/{id} - look up a single payment's current status
  • -POST /v1/payments/{id}/refunds - issue a full or partial refund against a payment
  • -GET /v1/payments/{id}/refunds - list refunds issued against a payment
  • -POST /v1/customers - create a customer record for a returning payer
  • -POST /v1/customers/{id}/payment_methods - attach a stored payment method to a customer
  • -GET /v1/customers/{id}/payment_methods - list a customer's stored payment methods
Idempotency is not optional here - it is the single most important detail
POST /v1/payments
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
{
  "amount": 4999,
  "currency": "usd",
  "customer_id": "cus_42",
  "payment_method_id": "pm_501"
}

HTTP/1.1 201 Created
{
  "id": "pay_501",
  "status": "succeeded",
  "amount": 4999,
  "currency": "usd"
}

A retried payment request - from a network timeout, a client-side crash, anything - must never double-charge. The idempotency-key discipline from earlier in this course isn't a nice-to-have here, it's the single design detail most likely to actually come up as a pointed interview follow-up, precisely because the consequence of getting it wrong is a real, quantifiable financial mistake.

Refunds are their own sub-resource, not a field mutated on the payment
POST /v1/payments/pay_501/refunds
Idempotency-Key: a1b2c3d4-1111-2222-3333-444455556666
{
  "amount": 1999,
  "reason": "requested_by_customer"
}

HTTP/1.1 201 Created
{
  "id": "re_88",
  "payment_id": "pay_501",
  "amount": 1999,
  "status": "succeeded"
}

A refund gets its own idempotency key (retrying the exact refund request must not refund twice) and its own resource under the payment, since a single payment can have multiple partial refunds - modeling refund as a mutable status field on the payment itself would lose that history and make "how much has actually been refunded so far" an error-prone sum instead of a simple query.

Specific, actionable error responses for payment failures
HTTP/1.1 402 Payment Required
{
  "type": "https://api.example.com/errors/card-declined",
  "title": "Card Declined",
  "status": 402,
  "detail": "The card was declined by the issuing bank.",
  "decline_code": "insufficient_funds"
}

A declined card is not a 500 (nothing went wrong on the server) or a generic 400 (the request was perfectly well-formed) - it is a legitimate business outcome that needs its own specific, actionable status and error detail, following the RFC 7807 error-design discipline, including a `decline_code` the client can use to give the end customer a genuinely useful message ("insufficient funds" versus a generic "payment failed").

Webhooks for asynchronous status changes after the initial request
POST https://merchant.example.com/webhooks/payments
X-Webhook-Signature: sha256=...
{"event": "payment.succeeded", "payment_id": "pay_501"}

Some payment methods (bank transfers, certain fraud-review holds) don't resolve synchronously within the original request - the initial response might be `status: "pending"`, with a signed webhook (from the webhooks chapter) notifying the merchant asynchronously once the payment actually settles or fails. This is also exactly the kind of long-running-operation shape covered earlier in the course, applied to payment settlement specifically.

Step 5: Evolution and Auditability

Payment records should be treated as append-only / event-sourced for audit purposes, mirroring LLD Fundamentals' financial ledger chapter - a payment's full history (attempted, succeeded, later refunded) needs to be reconstructable, not just its current state. On versioning: this is a domain where a breaking change deployed carelessly has real financial consequences, making the deprecation-with-a-real-timeline discipline from the versioning chapter especially non-negotiable rather than a nice-to-have.

When this prompt comes up in a real interview, naming the idempotency requirement unprompted, before the interviewer has to ask about retries or duplicate charges, is one of the strongest single signals available in this entire course's case-study set - it directly demonstrates the money-handling instinct the prompt is actually testing for.

Interview Signal is part of Pro

See a real weak answer next to a real strong one for this exact topic.

Quiz is part of Pro

Test what you just read with a short quiz, and bank the XP.

Ready to Build This?

Design a Payment API in the API Design Lab's Full System Design act.

ScaleDojo Logo
Initializing ScaleDojo