API Design When Money Moves
Prerequisite: API Design
Your payment API receives a POST /transfers request. The client sends $500 from account A to account B. Your server processes the transfer, debits A, credits B, and begins generating the 201 Created response. The TCP connection drops before the response reaches the client. The client sees a timeout. It retries. Your server processes the transfer again — debits A a second time, credits B a second time. Account A is now short $500, account B has $500 too many, and your ledger has two entries for a transfer that should have happened once.
Every API that moves money must answer this question before writing any business logic: what happens when the client retries a request that already succeeded?
Idempotency Keys
An idempotency key is a client-generated identifier that tells the server “this is the same request you may have already processed.” The server uses it to return the original response instead of executing the operation again.
POST /transfers
Idempotency-Key: txn_a1b2c3d4e5f6
Content-Type: application/json
{
"from_account": "acct_001",
"to_account": "acct_002",
"amount": 50000,
"currency": "USD"
}
The server stores the idempotency key alongside the request hash and the response. On retry, it looks up the key, verifies the request body matches, and returns the stored response without re-executing.
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
request_hash BYTEA NOT NULL,
response JSONB,
status TEXT NOT NULL DEFAULT 'processing',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '24 hours'
);Stripe stores idempotency keys for 24 hours. Modern Treasury stores them for the lifetime of the request. The TTL decision depends on your retry window — if clients retry within minutes, 24 hours is generous. If your system supports scheduled payments that may be retried days later, the window must be longer or the key must be derived from the payment’s own identity.
An idempotency key that only checks the key without verifying the request body is a footgun. If a client reuses a key with different parameters — different amount, different recipient — the server must reject the request, not silently return the old response. Stripe returns 422 Unprocessable Entity when the request body doesn’t match the stored key.
Key Generation
The client generates the key, not the server. This is not a style choice — it’s a correctness requirement. If the server generates the key and returns it in the response, a network failure before the response arrives means the client never receives the key and has no way to deduplicate on retry.
UUIDs (v4) are the common choice. The collision probability of two UUIDv4 values across 1 billion keys is approximately 1 in 10^18. For payment APIs, that margin is sufficient.
Some systems derive idempotency keys from business identifiers — payment:{order_id}:{attempt} — which makes keys predictable and debuggable. The tradeoff is that clients must maintain their own sequence counters, and key reuse across unrelated operations becomes a failure mode if the derivation scheme is ambiguous.
Request Deduplication at the Server
The idempotency key table is the deduplication layer. The implementation has three states:
| State | Meaning | On retry |
|---|---|---|
processing |
Request received, execution in progress | Return 409 Conflict or block until original completes |
completed |
Request executed, response stored | Return stored response |
failed |
Request failed, no side effects | Allow re-execution with same key |
The processing state handles the race condition where two instances of the same request arrive simultaneously. The first instance inserts the key with processing status using an INSERT ... ON CONFLICT DO NOTHING (Postgres) or a conditional put (DynamoDB). If the insert succeeds, this instance owns the key and proceeds with execution. If it fails, another instance already owns it.
INSERT INTO idempotency_keys (key, request_hash, status)
VALUES ($1, $2, 'processing')
ON CONFLICT (key) DO NOTHING
RETURNING key;If the RETURNING clause returns no rows, the key already exists. Read the existing row: if completed, return the stored response. If processing, either return 409 or poll until the original completes. If failed, allow re-execution — update the status back to processing and proceed.
The critical invariant: the idempotency key insert and the business operation must share a transaction boundary. If they don’t — if you insert the key first, then execute the transfer in a separate transaction — a crash between the two leaves a key in processing state with no way to complete or fail it. Stale processing keys require a reaper process that detects keys stuck in processing beyond a timeout (typically 60 seconds) and marks them failed.
Partial Failure Semantics
A transfer that debits account A and credits account B is two database writes. If the debit succeeds and the credit fails, you have an inconsistent ledger. The API must define what the client sees and what the server does.
Single-database transactions are the simplest solution. Both the debit and credit happen in one database transaction. Either both succeed or neither does. The idempotency key insert lives in the same transaction. This works when both accounts are in the same database — which is true for most ledger architectures.
BEGIN;
INSERT INTO idempotency_keys (key, request_hash, status) VALUES ($1, $2, 'processing');
INSERT INTO entries (account_id, amount, ...) VALUES ('acct_001', -50000, ...);
INSERT INTO entries (account_id, amount, ...) VALUES ('acct_002', 50000, ...);
UPDATE idempotency_keys SET status = 'completed', response = $3 WHERE key = $1;
COMMIT;When accounts span databases or services, you need a saga. The API returns 202 Accepted immediately with a transfer ID. The client polls for completion or receives a webhook when the saga resolves. The response contract changes from synchronous success/failure to asynchronous eventual resolution.
| Pattern | Response | Failure mode | Client experience |
|---|---|---|---|
| Single transaction | 201 Created |
Atomic rollback | Synchronous success/failure |
| Saga with polling | 202 Accepted |
Compensating actions | Poll until completed or failed |
| Saga with webhook | 202 Accepted |
Compensating actions | Receive async notification |
The API must never return 200 OK or 201 Created for a transfer that has not fully completed. If the debit succeeded but the credit is pending, the response is 202 Accepted with a status field — not 201 Created with a caveat in the body. The HTTP status code is the contract. A client that checks response.status_code == 201 and proceeds to ship an order must be able to trust that the money moved.
See API Design. The handbook rule — never return 200 with an error body — is non-negotiable for payment APIs. A 200 that means “we received your request but the payment failed” will cost someone money.
Response Contracts for Money
Payment API responses carry information that clients use to make financial decisions. The response contract must be unambiguous about the state of the money.
Every transfer response includes at minimum:
id— Server-generated, globally unique, immutable. The canonical reference for this transfer.status— One of a closed set:pending,posted,failed,returned. Never free-text.amountandcurrency— Echo back exactly what the server executed, not what the client requested. If the server applied fees or FX conversion, the response shows the actual amounts.created_at— Server timestamp in UTC. The client’s clock is irrelevant.
Stripe’s API returns the full object on every mutation — create, update, and retrieve all return the same shape. This means the client never needs to make a separate GET request after a POST to learn what happened. Modern Treasury follows the same pattern. The cost is larger response payloads (typically 1–3 KB per transfer object). The benefit is that every response is self-contained.
Status Transitions
Transfer statuses follow a state machine. The API documentation must publish the valid transitions, and the server must enforce them.
stateDiagram-v2
[*] --> pending
pending --> posted : settlement confirmed
pending --> failed : rejected by processor
posted --> returned : chargeback or reversal
failed --> [*]
returned --> [*]
A transfer in posted status cannot transition to pending. A transfer in failed status cannot transition to posted. These constraints are not suggestions — they are invariants that clients build automation around. If your API allows a transfer to move from failed to posted because a retry succeeded after the initial failure, clients that took action on the failed status (refunding a customer, canceling an order) now have inconsistent state.
Webhook Reliability
Webhooks notify clients about asynchronous state changes — a transfer that moved from pending to posted, a payment that was returned, an account that was frozen. The delivery semantics determine how much engineering the client must do.
At-least-once delivery is the only practical guarantee. Exactly-once delivery across a network boundary is impossible without cooperation from the receiver. Your system will deliver some webhooks more than once — after a timeout, after a deployment, after a retry — and the receiver must handle duplicates.
Each webhook carries an event_id that the receiver uses for deduplication, and an idempotency_key if the event corresponds to a specific API request. The receiver stores processed event IDs and skips duplicates.
Retry Strategy
Failed webhook deliveries (non-2xx response or timeout) retry with exponential backoff. Stripe retries up to 30 times over approximately 3 days. Modern Treasury retries with 1-minute, 5-minute, 30-minute, 2-hour, and 24-hour intervals.
| Attempt | Delay | Cumulative |
|---|---|---|
| 1 | Immediate | 0 |
| 2 | 1 min | 1 min |
| 3 | 5 min | 6 min |
| 4 | 30 min | 36 min |
| 5 | 2 hours | 2h 36m |
| 6 | 24 hours | 26h 36m |
After exhausting retries, the webhook is dead-lettered. The client must poll the API to discover events they missed, or your system surfaces dead-lettered webhooks in a dashboard for manual replay.
Ordering
Webhooks arrive out of order. A transfer.posted event may arrive before the transfer.created event if the first delivery attempt for created times out and retries while posted delivers on the first try. The receiver must handle this — either by ignoring events for unknown resources (and catching up on the next poll) or by buffering events and reordering by sequence number.
Stripe includes a created timestamp on each event. Modern Treasury includes a sequence counter. Neither guarantees delivery order matches creation order. The receiver’s deduplication logic must be order-independent.
Signature Verification
Every webhook request includes a cryptographic signature that proves it came from your system, not from an attacker who discovered the endpoint URL. HMAC-SHA256 over the raw request body with a shared secret is the standard.
import hmac, hashlib
def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)Use hmac.compare_digest (constant-time comparison), not ==. A timing side-channel on signature comparison leaks the secret one character at a time. At webhook-level traffic volumes the attack is impractical, but the fix is one function call, and the vulnerability is permanent if ignored.
The receiver must verify the signature against the raw request body, not a parsed-and-reserialized version. JSON serialization is not deterministic — key ordering, whitespace, and Unicode escaping vary across parsers. Parse after verification, never before.
Error Responses That Don’t Lie
Payment API errors must distinguish between “you made a mistake” (client error, do not retry) and “something went wrong on our end” (server error, retry is appropriate).
| Status | Meaning | Client action |
|---|---|---|
400 |
Malformed request — bad JSON, missing field, invalid type | Fix the request, do not retry as-is |
402 |
Insufficient funds or payment declined | Inform the user, do not retry without user action |
409 |
Idempotency key conflict — concurrent duplicate request | Wait and retry, or fetch the existing resource |
422 |
Valid JSON but business rule violation — amount exceeds limit, account frozen | Fix the input, do not retry as-is |
429 |
Rate limited | Retry after Retry-After header |
500 |
Server error — safe to retry with the same idempotency key | Retry with exponential backoff |
503 |
Service temporarily unavailable | Retry after Retry-After header |
The distinction between 400, 402, and 422 matters because retry logic is automated. A client that retries a 422 (account frozen) wastes resources and may trigger rate limiting. A client that does not retry a 500 (transient server error) leaves a payment unprocessed. The HTTP status code is the machine-readable signal. The response body provides the human-readable explanation.
Never return 200 with {"error": "insufficient funds"}. The client’s HTTP library sees 200 and reports success. The error handling code never runs. The payment appears to succeed in the client’s logs. The reconciliation break surfaces hours or days later.