API Design

Design Case Studies · Lesson 12

Design: Payments API

Money is the discipline case for API correctness. A payment cannot be applied twice, cannot be lost mid-flight, and cannot be silently wrong. Every design pattern in this lesson exists to defend one rule: charge exactly once, no matter what the network does.

⏱ 20 min Difficulty: advanced Prereq: idempotency, webhooks, idempotency in practice

By the end you'll be able to

1 — Requirements

Payments is a narrow domain with unusually strict correctness requirements. Start by being precise about what the system must do — and must never do.

Functional requirements

Non-functional requirements

2 — Design decisions

Decision 1: Idempotency keys are mandatory, not optional

Every POST /v1/charges call must include an Idempotency-Key header. This is the most important design decision in the entire API. Here is why it is mandatory and not merely recommended.

Imagine a client sends a charge request. The server processes the charge, debits the card, and then — before sending the HTTP response — the network drops the connection. From the client's perspective, the request timed out. Did the charge go through? The client cannot know. If it retries without an idempotency key, and the server processes the second request fresh, the customer is charged twice.

An idempotency key is a client-generated unique string (typically a UUID) that the server stores alongside the result. On a duplicate request with the same key, the server returns the stored result from the first attempt — not the result of a new operation. The key converts a non-idempotent write into an effectively idempotent one. See Lesson rel-02 for the theory and Lesson dbg-05 for debugging failures in real implementations.

⚠️ Common trap: the window between debit and response

The idempotency key must be recorded before the charge is sent to the card network, not after. If the server crashes after charging the card but before writing the key, a retry will charge again — and the server has no record of the first attempt. The correct order is: (1) write the key + "pending" state to the ledger in a durable transaction; (2) charge the card network; (3) update the ledger to "succeeded" or "failed." If the server crashes at step (2), the retry sees the key, finds the "pending" state, and can resume or report the outcome without re-initiating a charge.

Decision 2: Async settlement with an explicit state machine

Card networks do not respond synchronously. The issuing bank may take 200 ms or 30 seconds. The API must return before settlement is confirmed. The solution is an explicit charge state machine that both the API and the caller understand:

Charge states

pending → charge created, sent to card network
processing → card network acknowledged, awaiting issuer response
succeeded → issuer approved, funds will settle
failed → issuer declined or network error
refunded → succeeded charge fully reversed
partially_refunded → succeeded charge partially reversed

The API returns a pending charge immediately. Settlement transitions happen asynchronously and are communicated by webhook.

Decision 3: Webhooks for settlement events

Callers who need to know when a charge settles have two options: poll GET /v1/charges/:id or register a webhook URL and receive a push. Polling is reliable but wastes calls on charges that are still pending; at scale (millions of charges per day), constant polling creates significant load. Webhooks push the event to the caller when it happens, with no polling overhead.

The key webhook reliability requirements are: (1) deliver at least once (retry on non-200), (2) sign every delivery so the recipient can verify authenticity, (3) make the event envelope stable so old consumers can ignore new fields. See Lesson dbg-04 for patterns and failure modes.

Decision 4: Append-only ledger, never mutate amounts

The ledger records every money movement as an immutable row. A refund does not update the original charge row — it inserts a new refund row that references the charge. This design has three properties that matter for compliance and debugging: (1) any state can be reconstructed by replaying the ledger; (2) a bug that corrupts one row does not destroy the historical record; (3) auditors can verify that the sum of ledger entries equals the expected balance without trusting any computed field.

Decision 5: Strong consistency for all writes, and why

Payments is one of the few domains where eventual consistency is unacceptable on the write path. If a charge is written to a replica that then diverges from the primary, a subsequent GET might return stale state — "your $500 charge is pending" when the issuer has already declined it. The consequences (shipping goods before payment clears, double-applying a refund) are real financial harm. The charge creation path must write to the primary durably and read its own writes before returning 201.

POST /v1/charges pending 201 returned sent to network processing approved declined succeeded charge.succeeded webhook failed charge.failed webhook refunded partially refunded Idempotency-Key deduplication: same key + same parameters → replay stored result, no new charge
The charge state machine. Every transition emits a webhook event. The idempotency layer (bottom bar) intercepts duplicates before any state transition begins.

3 — The API model

Create a charge

POST /v1/charges
Authorization: Bearer <secret-key>
Idempotency-Key: idem_7f3a2c89-0e14-4b8f-a9d1-c3b4e5f60712
Content-Type: application/json

{
  "amount":      4999,         // cents — never floats for money
  "currency":    "usd",
  "payment_method": "pm_1Nz8kG...", // tokenised; no raw card data
  "description": "Annual subscription — Pro plan",
  "metadata": {
    "order_id": "ord_8821",
    "user_id":  "usr_2209"
  }
}

# First request — charge created
HTTP/1.1 201 Created
{
  "id":         "ch_9pXwL4",
  "status":     "pending",
  "amount":     4999,
  "currency":   "usd",
  "created_at": "2025-09-01T10:04:22Z",
  "livemode":   true
}

# Retry with the same Idempotency-Key (e.g. after a network timeout)
HTTP/1.1 201 Created          // same status code
{
  "id":     "ch_9pXwL4",   // same charge id — no new charge created
  "status": "pending"
}
# Replay-Response: true header may be included to signal dedup path was taken

Retrieve a charge

GET /v1/charges/ch_9pXwL4
Authorization: Bearer <secret-key>

HTTP/1.1 200 OK
{
  "id":          "ch_9pXwL4",
  "status":      "succeeded",
  "amount":      4999,
  "amount_refunded": 0,
  "currency":    "usd",
  "description": "Annual subscription — Pro plan",
  "created_at":  "2025-09-01T10:04:22Z",
  "succeeded_at": "2025-09-01T10:04:24Z",
  "payment_method": { "id": "pm_1Nz8kG...", "last4": "4242", "brand": "visa" },
  "metadata":    { "order_id": "ord_8821" }
}

Webhook event — charge settled

# Stripe-style: Payments API POSTs to the caller's registered URL
POST https://yourapp.example.com/webhooks/payments
Stripe-Signature: t=1693562662,v1=abc123...,v0=oldsig...
Content-Type: application/json

{
  "id":         "evt_1OBj3k",
  "type":       "charge.succeeded",
  "created":    1693562662,
  "data": {
    "object": {
      "id":      "ch_9pXwL4",
      "status":  "succeeded",
      "amount":  4999,
      "currency": "usd"
    }
  }
}

# Your handler must: (1) verify the signature, (2) respond 200 quickly,
# (3) process idempotently — the same event may be delivered more than once.
First request (key: idem_7f3a) Client Key lookup: not found write key + pending Card network Ledger: succeeded 201 { id: ch_9pXwL4, status: pending } — network drops before response arrives; client retries — Retry (same key: idem_7f3a) Client Key lookup: found → return Card network 201 { id: ch_9pXwL4, status: pending } — same response, no new charge
On the first request, the server writes the idempotency key, charges the card, and returns a result. On the retry, it finds the key and returns the stored result — the card is never charged again. The client cannot distinguish retry from first call.

4 — Evaluation & consistency analysis

Idempotency + consistency: why both are required

Idempotency prevents double-charges on retry. Strong consistency prevents a different class of bug: a read that returns stale state after a write. If a charge succeeds but a subsequent GET (from a replica that hasn't caught up) returns "status": "pending", the caller might send a second charge attempt with a fresh idempotency key — logically, they think the first one didn't go through. These are different failure modes that require different defences:

Failure scenarioDefence
Network drops after charge, before responseIdempotency key: retry returns same result
Client reads stale state after a writeStrong consistency: write to primary, read-your-writes
Webhook delivery fails temporarilyAt-least-once retry with exponential backoff
Webhook handler processes event twiceConsumer idempotency: deduplicate on event id
Amount computed as float → rounding errorAlways represent money as integers (cents/pence)

Latency budget for a charge

StepTypical latencyDominant factor
API validation + idempotency key write5–15 msOne DB write (primary, synchronous)
Card network authorisation100–500 msIssuing bank processing time, variable
Ledger update (succeeded/failed)5–10 msOne DB write
Webhook enqueue1–5 msMessage queue write
Webhook delivery to caller50–300 msCaller's HTTPS response time
Total: caller sees 201110–530 msCard network dominates

The append-only ledger as audit record

A compliant payment system must be able to answer: "what exactly happened to charge ch_9pXwL4, in what order, and at what time?" An append-only ledger makes this straightforward. Every event — creation, authorisation, capture, refund, dispute — is a new immutable row. To reconstruct the balance for any account at any point in time, sum the relevant rows. No row is ever updated or deleted. This is not just good engineering; it is what regulators and card network rules require.

🎯 Interview angle

When interviewers ask "how do you prevent double charges?", surface three layers, not just idempotency keys: (1) the idempotency key prevents duplicate API calls from creating duplicate operations; (2) the database uses a unique constraint on the idempotency key so concurrent retries that race to write both find one succeeds and one fails gracefully; (3) the card network authorisation is a separate, bank-side check that prevents a payment method from being debited beyond its limit regardless of what the API layer does. One layer could fail; three layers together make double-charging effectively impossible. Also mention the Stripe debugging interview at Lesson dbg-03 for real-world failure patterns.

✅ Do this, not that

Do represent all monetary amounts as integers in the smallest currency unit (499 cents = $4.99). Don't use floats. IEEE 754 double-precision floating point cannot represent many decimal fractions exactly — 0.1 + 0.2 !== 0.3 in most languages. Rounding a floating-point amount to two decimal places before storing it can silently under- or over-charge by a cent per transaction. At a million transactions per day, "a cent here and there" becomes a systemic discrepancy that fails financial reconciliation.

Under the hood: the core mechanism

Three internal mechanisms are what make a payments API trustworthy at scale: the idempotency-key store that prevents double charges, the append-only ledger that makes every money movement auditable, and the charge state machine that sequences authorization from capture and settlement. Seeing how they interact makes the design constraints — strong consistency, integer amounts, write-before-charge ordering — feel inevitable rather than arbitrary.

The idempotency-key store

The key store is a separate table (or Redis hash with TTL) keyed on the idempotency key string. It stores the key, the associated charge ID, the HTTP status code, and the serialized response body from the first successful request:

idempotency_keys table:
  key        TEXT PRIMARY KEY          -- client-supplied UUID
  charge_id  TEXT                      -- references charges.id
  status     INT                       -- HTTP status from first attempt (201, 400, 402...)
  response   JSONB                     -- serialized response body
  created_at TIMESTAMPTZ DEFAULT now()
  expires_at TIMESTAMPTZ               -- typically now() + 24 h; after this a fresh charge is allowed

The critical constraint — enforced by the DB, not by application code:
  UNIQUE (key)  -- concurrent retries both INSERT; one wins, one gets a unique violation → returns stored result

The write-before-charge ordering is non-negotiable. The correct sequence:

Step 1 — Write key + pending state to ledger (durable, in one transaction)
  BEGIN;
    INSERT INTO idempotency_keys (key, status) VALUES ($key, 0);   -- 0 = in-flight
    INSERT INTO ledger (type, charge_id, amount, status)
      VALUES ('charge', $charge_id, $amount, 'pending');
  COMMIT;                                                            -- crash-safe from here

Step 2 — Send authorisation to card network (can take 100–500 ms)
  result = card_network.authorize(payment_method, amount, currency)

Step 3 — Update key + ledger with final result
  BEGIN;
    UPDATE idempotency_keys SET status=201, response=$body WHERE key=$key;
    UPDATE ledger SET status=$result.status WHERE charge_id=$charge_id;
  COMMIT;

If the process crashes between step 1 and step 2, the key row exists with status=0. A retry sees the key, finds status 0 (in-flight), and can either wait briefly and retry the lookup or treat it as "a charge may already be in progress — do not initiate another." If the process crashes between step 2 and step 3, the card was charged but the key status is still 0 — a reconciliation job scans for in-flight keys older than N minutes and resolves them by querying the card network for their status.

The append-only ledger

The ledger is a single table where every money movement is an immutable insert. No row is ever updated or deleted after it is committed. A refund, a dispute chargeback, and a settlement confirmation are all new rows:

ledger table (simplified):
  id         BIGSERIAL PRIMARY KEY
  type       TEXT      -- 'charge' | 'capture' | 'refund' | 'refund_settled' | 'dispute' | 'dispute_won'
  charge_id  TEXT      -- references the originating charge
  amount     INT       -- cents; positive = debit, negative = credit
  currency   CHAR(3)
  status     TEXT
  occurred_at TIMESTAMPTZ DEFAULT now()
  metadata   JSONB

To compute the current balance for account acc_123: SELECT SUM(amount) FROM ledger WHERE account_id = 'acc_123'. To reconstruct what the balance was on September 1st at noon: add AND occurred_at <= '2025-09-01T12:00:00Z'. No snapshot, no cached field needed — the ledger is the truth.

The charge state machine

A charge does not go directly from "created" to "money moved." Card networks use a two-step model: authorization (a hold on funds) followed by capture (actual movement of money). For most e-commerce flows these happen together in milliseconds, but understanding them separately is essential for partial captures, split shipments, and pre-authorization patterns:

StateMeaningLedger entryWebhook emitted
requires_payment_methodPaymentIntent created, no card attached yetNoneNone
requires_confirmationCard attached, awaiting explicit confirm callNoneNone
processingConfirmation sent to card network; awaiting issuercharge, amount=+4999, status=pendingNone
succeededIssuer authorized; funds captured (or will be)capture, amount=+4999, status=succeededcharge.succeeded
failedIssuer declined or network errorcharge, amount=0, status=failedcharge.failed

Worked trace: a charge with one network retry

The client's server times out waiting for the 201 and retries once. Watch what the idempotency store prevents:

T=0 ms    Client sends POST /v1/charges
           Idempotency-Key: idem_7f3a2c89
           { amount:4999, currency:"usd", payment_method:"pm_1Nz8kG..." }

T=5 ms    Payments API: idempotency key NOT found → write key (status=0) + pending ledger row
           idempotency_keys: { key:"idem_7f3a2c89", status:0 }
           ledger:           { type:"charge", charge_id:"ch_9pXwL4", amount:4999, status:"pending" }

T=8 ms    API sends authorisation to card network (Visa/Mastercard acquirer)

T=310 ms  Card network responds: approved  (issuer: Chase, card ending 4242)

T=315 ms  API updates ledger + idempotency key
           ledger:           { type:"capture", charge_id:"ch_9pXwL4", amount:4999, status:"succeeded" }
           idempotency_keys: { key:"idem_7f3a2c89", status:201, response:'{"id":"ch_9pXwL4","status":"pending"}' }

T=316 ms  API enqueues charge.succeeded webhook

T=316 ms  ← network drop occurs before response reaches client →

T=5316 ms Client times out (5 s), retries POST /v1/charges with same Idempotency-Key

T=5320 ms Payments API: idempotency key FOUND (status=201)
           Returns stored response: 201 { id:"ch_9pXwL4", status:"pending" }
           Card network is NOT called.
           Ledger has exactly ONE charge entry.

T=5600 ms Webhook delivery: POST client-webhook-url
           { event:"charge.succeeded", data:{ id:"ch_9pXwL4", amount:4999, status:"succeeded" } }
           Client sees 201 on retry AND then receives the webhook → exactly one charge.

One charge, one ledger entry, one webhook — regardless of how many times the client retried. The idempotency key is the single mechanism that makes this guarantee hold across network failures.

Operating & debugging it

Payment failures fall into three buckets: card declines (the issuer said no — nothing wrong with the API), integration errors (the API returned 4xx — the caller sent something invalid), and infrastructure failures (the API returned 5xx, or the webhook never arrived). Triaging which bucket you're in is step zero.

Inspecting a charge

# Fetch a charge and check its status + failure message curl -s https://api.stripe.com/v1/charges/ch_9pXwL4 \ -u sk_live_...: | jq '{status, failure_code, failure_message}' { "status": "succeeded", "failure_code": null, "failure_message": null } { "status": "failed", "failure_code": "card_declined", "failure_message": "Your card was declined." } # Check if an idempotency key was a replay (Stripe sets Idempotent-Replayed: true) curl -si https://api.stripe.com/v1/charges \ -u sk_live_...: \ -H "Idempotency-Key: idem_7f3a2c89" \ -d amount=4999 -d currency=usd -d payment_method=pm_1Nz8kG | grep -i idempotent Idempotent-Replayed: true ← server confirmed this was a duplicate; no new charge # Absence of this header on a retry means a NEW charge was created — key may have expired # List charges with a specific metadata field (order_id) curl -s "https://api.stripe.com/v1/charges?metadata[order_id]=ord_8821" \ -u sk_live_...: | jq '.data[] | {id, status, amount}' # Check webhook delivery attempts for an event curl -s "https://api.stripe.com/v1/events/evt_1OBj3k" \ -u sk_live_...: | jq '.request, .pending_webhooks' pending_webhooks: 0 ← delivered successfully pending_webhooks: 1 ← still trying; check your endpoint is returning 200

Symptom → cause → fix

SymptomLikely causeFix
Customer charged twice; two charge IDs in DB Client retried without idempotency key, or key expired before retry (keys typically TTL at 24 h) Always send idempotency key; increase TTL for long-running retry loops; deduplicate on metadata.order_id as a secondary check
402 Payment Required with card_declined Issuer declined — insufficient funds, fraud flag, or 3DS required Surface the decline reason to the user; do not retry automatically (retrying a hard decline burns the customer's trust); prompt for a different card
400 Bad Request — "amount must be at least 50 cents" Minimum charge amount not met (card networks have per-transaction minimums) Validate amount >= minimum before calling the API; consider batching micro-transactions
Webhook not arriving; pending_webhooks > 0 Endpoint returning non-200 or timing out (>30 s); or endpoint behind a VPN/firewall blocking Stripe IPs Return 200 immediately and process asynchronously; whitelist Stripe webhook IPs; check the Stripe dashboard for delivery attempt errors
Webhook handler applies refund twice Webhook delivered twice (at-least-once); handler not deduplicating on event id Store processed event IDs; check before executing business logic; return 200 on duplicates without re-processing
Balance reconciliation off by a few cents Monetary amounts stored as floats somewhere in the pipeline (client, database, or reporting layer) Audit every code path that touches amounts; convert all to integers (cents); fix the storage column type
Charge shows status: pending indefinitely Card network authorisation timed out and no reconciliation job resolved it; or the API server crashed between charge and ledger update Run a reconciliation job that queries the card network for charges stuck in pending > 10 min; update ledger accordingly
⚠️ The concurrent retry race condition

Two clients can race to submit the same idempotency key simultaneously (e.g. both sent the request at the same time and neither has seen a response yet). If the idempotency store uses only application-level deduplication (SELECT … if not exists … INSERT), both reads can see "not exists" before either write commits, and both proceed to charge the card. The only safe fix is a database-level unique constraint on the idempotency key column. With the constraint, one INSERT succeeds and the other gets a unique violation; the loser retries the SELECT and finds the winner's result. No application-level locking or Redis SETNX is sufficient on its own because they have their own race windows.

In production: how the real Stripe API does it

Every design decision in this case study maps directly to a documented Stripe behaviour. The table below is a reality check: the patterns here are not hypothetical — they are the patterns Stripe has been running at scale for over a decade.

ConcernHow Stripe does it
Idempotency Clients supply an Idempotency-Key header (typically a UUID) on every POST. Stripe stores the request fingerprint and the full response body. For roughly 24 hours, any repeat request bearing the same key receives the exact stored response — no new object is created and no side-effect is re-executed. Stripe also returns an Idempotent-Replayed: true response header so callers can confirm deduplication occurred. See Stripe — Idempotent Requests.
Versioning Stripe uses calendar date strings as version identifiers (e.g. 2024-06-20). Each Stripe account is pinned to the API version that was current on the day it was created; that version is what every request sees by default, so Stripe can ship breaking changes under a new date without breaking existing integrations. A caller can override the version for a single request by sending the Stripe-Version header, and can permanently upgrade the account version from the dashboard. Breaking changes are only ever introduced under a new date version. See Stripe — API Versioning.
Authentication Stripe issues three key types. Secret keys (sk_live_… / sk_test_…) carry full account access and must never be exposed client-side. Publishable keys (pk_live_… / pk_test_…) are safe for browsers and mobile SDKs; they can only tokenise card data, not read or mutate objects. Restricted keys carry per-resource permission scopes (e.g. read-only on charges, write on refunds) and are used to give third-party services the minimum access they need. All keys are passed via HTTP Basic Auth — the key as the username, an empty password — over TLS. See Stripe — API Keys.
Webhooks Every webhook delivery includes a Stripe-Signature header of the form t=<unix-timestamp>,v1=<hex-digest>. The signature is HMAC-SHA256 over the string <timestamp>.<raw-request-body> using the endpoint's signing secret. Recipients reconstruct the signed payload, recompute the HMAC, and compare it in constant time. They also check that the timestamp is within a configurable tolerance window (default 300 s) to prevent replay attacks. Stripe retries failed deliveries with exponential backoff for up to 72 hours. See Stripe — Webhook Signatures.
Rate limiting Stripe enforces per-account token-bucket limits: approximately 100 read requests per second and 100 write requests per second in live mode (lower in test mode). When a bucket is exhausted Stripe returns 429 Too Many Requests with a Retry-After header indicating the number of seconds to wait. Stripe also runs separate load-shedding mechanisms that protect its critical charge path under traffic spikes, so that authorisation latency stays predictable even when other endpoints are throttled. See Stripe — Rate Limits.
Pagination Stripe uses cursor-based pagination across all list endpoints. Each request accepts a limit parameter (1–100, default 10) and a starting_after parameter that is the ID of the last object seen. The response includes a has_more boolean; when it is true the caller passes the ID of the last object in the current page as starting_after on the next request. This avoids the "phantom row" and "skipped row" problems that offset-based pagination suffers when rows are inserted or deleted between pages. See Stripe — Pagination.

Why a payments API needs all of these at once

Each mechanism above is individually defensible, but the real insight is that they form a mutually reinforcing system. Removing any one of them creates a gap that the others cannot fill:

A payments API is correct only when all of these properties hold simultaneously: idempotency ensures money moves exactly once, auth ensures only authorised parties can move it, webhook signatures ensure settlement events can be trusted, versioning ensures the contract never breaks unexpectedly under the caller's feet, rate limiting ensures the charge path stays healthy under load, and pagination ensures full audit visibility without missed or duplicated records.

✅ Further reading

Explore the full reference at Stripe API Docs, then see how these patterns generalise across the industry in How leading APIs do it.

🧠 Quick check

1. A client sends POST /v1/charges with Idempotency-Key: k1. The server charges the card and is about to respond 201 when the connection drops. The client retries with the same key. What must the server return?

The server stored the result the first time. The retry finds the stored result and returns it verbatim — same status code, same charge ID. From the client's perspective, the retry is indistinguishable from a successful first call. No new charge is initiated.

2. Why must money amounts be stored as integers (cents) rather than floating-point numbers?

0.1 + 0.2 evaluates to 0.30000000000000004 in IEEE 754 double precision. At financial precision requirements, a cent rounding error per transaction compounds into unacceptable discrepancies. The standard practice — used by every major payments processor — is to store amounts as integers in the smallest denomination (cents, pence, paisa).

3. A charge.succeeded webhook is delivered twice (at-least-once semantics). What must the webhook handler do to stay correct?

HMAC verification tells you the event is authentic; it does not tell you it hasn't been processed before. The handler must store the event ID and check before executing business logic. Answering 200 without processing is the correct dedup response — the 200 tells the sender "received, no need to retry," and nothing is executed twice.

4. A refund does not mutate the original charge row — it inserts a new row referencing the charge. What property does this give the audit log?

An append-only ledger is immutable by design. Replaying all entries up to any timestamp gives the exact historical balance. This makes the ledger auditable, debuggable, and tamper-evident — properties that a mutable row-update design cannot provide, because updates destroy prior state.

5. Why must the idempotency key be written to the ledger before the card network is called, not after?

The sequence matters: write key (durable) → charge card → update result. If the server crashes between steps 2 and 3, the next request finds the key, sees the pending state, and can recover without re-charging. If the key is written only after charging, a crash between charge and key-write leaves no trace — the next retry has no key to find, and charges the card again.

✍️ Exercise: design the refund endpoint and its ledger impact

A customer wants to return a product. The merchant calls your API to refund a $49.99 charge that succeeded last week. Design the endpoint, the request/response, and describe exactly what rows are written to the ledger. The original charge must remain unchanged.

Model answer:

POST /v1/charges/ch_9pXwL4/refunds
Authorization: Bearer <secret-key>
Idempotency-Key: ref-idem-9d23e7a1
Content-Type: application/json

{
  "amount": 4999,     // partial refund — omit for full refund
  "reason": "customer_request"
}

HTTP/1.1 201 Created
{
  "id":         "re_3xNpQ2",
  "charge":     "ch_9pXwL4",
  "amount":     4999,
  "currency":   "usd",
  "status":     "pending",
  "created_at": "2025-09-08T09:12:01Z"
}

Ledger entries written:

When the refund settles, a third row is added: { type: refund_settled, refund_id: re_3xNpQ2, amount: -4999, status: succeeded }. The original charge row is never touched. The amount_refunded field on the charge is a computed aggregate over refund rows, not a stored value.

Rubric: ✓ Uses POST, not PATCH/DELETE on the charge ✓ Includes idempotency key ✓ Described append-only ledger impact ✓ Original charge row unchanged ✓ Refund has its own state machine (pending → succeeded).

Key takeaways

Sources & further reading