Invoicing API design
Principles
- One version, extended additively. Everything lives under
/v1. New fields are optional and added to responses, and nothing is removed or retyped. A/v2is a last resort. - Old app versions must survive. Clients ignore unknown fields and treat enums as open: an unknown value falls back to a default. This is a documented rule for client authors. Server responses always include every field, using
nullrather than omitting. - Money is
{ amount: integer minor units, currency: ISO-4217 }. Never use floats. - Naming: plural nouns, camelCase fields, UPPER_SNAKE enum values, and IDs that are opaque prefixed strings (
inv_…,cus_…).
Endpoints
POST /v1/customers
GET /v1/customers ?limit&cursor&email&createdAfter
GET /v1/customers/{customerId}
PATCH /v1/customers/{customerId} (partial; JSON merge-patch)
POST /v1/invoices (creates DRAFT, line items inline)
GET /v1/invoices ?limit&cursor&customerId&status&issuedAfter
GET /v1/invoices/{invoiceId}
PATCH /v1/invoices/{invoiceId} (DRAFT only; If-Match required)
POST /v1/invoices/{invoiceId}/finalization (DRAFT → OPEN, immutable after)
POST /v1/invoices/{invoiceId}/void (OPEN → VOID)
POST /v1/invoices/{invoiceId}/payments (the pay action; Idempotency-Key required)
GET /v1/invoices/{invoiceId}/payments
GET /v1/payments/{paymentId} (poll target for pending/unknown outcomes)
- Pay is modeled as creating a payment attempt. That gives the client something to poll and gives us an audit trail.
- Line items are part of the invoice. They are replaced as a whole via
PATCHon a DRAFT, so there is no separate line-item endpoint to keep consistent. After finalization the invoice is immutable. - Concurrent edits use
ETag/If-Match. A mismatch returns412 PRECONDITION_FAILED.
Types
type Id<P extends string> = `${P}_${string}`;
type CustomerId = Id<'cus'>; type InvoiceId = Id<'inv'>; type PaymentId = Id<'pay'>;
interface Money { amount: number; currency: string } // amount: integer minor units
interface Customer {
id: CustomerId;
name: string;
email: string;
metadata: Record<string, string>; // escape hatch for future fields
createdAt: string; // RFC 3339 UTC
updatedAt: string;
}
interface CreateCustomerInput { name: string; email: string; metadata?: Record<string, string> }
type UpdateCustomerInput = Partial<CreateCustomerInput>;
type InvoiceStatus = 'DRAFT' | 'OPEN' | 'PAID' | 'VOID'; // clients: unknown → treat as OPEN-like/readonly
interface LineItem {
id: string;
description: string;
quantity: number; // integer
unitPrice: Money;
total: Money; // server-computed
}
interface LineItemInput { description: string; quantity: number; unitPrice: Money }
interface Invoice {
id: InvoiceId;
customerId: CustomerId;
status: InvoiceStatus;
lineItems: LineItem[];
total: Money; // server-computed
amountDue: Money;
dueDate: string | null;
createdAt: string;
updatedAt: string;
}
interface CreateInvoiceInput { customerId: CustomerId; lineItems: LineItemInput[]; dueDate?: string }
type UpdateInvoiceInput = Partial<Omit<CreateInvoiceInput, 'customerId'>>;
// Payment
interface PaymentMethodInput { token: string } // provider-issued token; raw card data never touches us
type PaymentStatus = 'PROCESSING' | 'SUCCEEDED' | 'FAILED' | 'REQUIRES_ACTION' | 'UNKNOWN';
interface Payment {
id: PaymentId;
invoiceId: InvoiceId;
status: PaymentStatus;
amount: Money;
failure: { code: string; message: string } | null; // e.g. CARD_DECLINED
nextAction: { type: string; url?: string } | null; // e.g. 3-D Secure; open-ended
createdAt: string;
updatedAt: string;
}
interface CreatePaymentInput { paymentMethod: PaymentMethodInput } // amount is NOT client-supplied: it's the invoice's amountDue
// Envelopes
interface Page<T> { data: T[]; page: { nextCursor: string | null; hasMore: boolean } }
The client doesn't send the amount to pay. The server charges amountDue, so a stale or tampered client can't over- or under-charge.
Error format
One shape for every non-2xx response, including 5xx from gateways where we control them:
interface ApiError {
error: {
code: string; // stable, machine-readable; clients switch on this, never on message
message: string; // human-readable, English, may change, not a contract
requestId: string; // also in the X-Request-Id header
retryable: boolean; // tells the client whether retrying the same request can help
details?: FieldViolation[] | Record<string, unknown>;
};
}
interface FieldViolation { field: string; code: string; message: string } // e.g. "lineItems[0].quantity"
| Status | Code examples | Notes |
|---|---|---|
| 400 | MALFORMED_REQUEST |
Unparseable body, bad header |
| 401 / 403 | UNAUTHENTICATED / FORBIDDEN |
|
| 404 | NOT_FOUND |
|
| 409 | INVOICE_NOT_PAYABLE, REQUEST_IN_PROGRESS |
State conflict, or in-flight duplicate |
| 412 | PRECONDITION_FAILED |
ETag mismatch |
| 422 | VALIDATION_FAILED, IDEMPOTENCY_KEY_REUSED |
Semantically invalid |
| 429 | RATE_LIMITED |
Retry-After header |
| 502/503/504 | PROVIDER_UNAVAILABLE, UNAVAILABLE |
retryable: true |
| 500 | INTERNAL |
No internals leaked |
A declined card is a business outcome, not an HTTP error. Pay returns 200 with a Payment whose status is FAILED and failure.code is CARD_DECLINED. A replay then returns exactly the same thing instead of an error that looks like a transport failure.
Pagination
Use cursor pagination, not page numbers. Mobile lists are infinite scroll, and offset pages duplicate or skip rows when invoices are created between fetches.
- Request:
GET /v1/invoices?limit=25&cursor=<opaque>. The default limit is 25 and the maximum is 100. - Response:
Page<Invoice>. Passpage.nextCursorback verbatim. - The cursor is opaque base64 that encodes the sort key
(createdAt, id)plus a filter hash. Clients must not parse it. That lets us change the internals without breaking old apps. - Sort is fixed at
createdAt desc, id desc. Add asortparam later if needed. - There is no
totalItems. Counts are expensive and drift. If needed later, add it as an opt-in?include=totalCount. - A cursor reused with different filters returns
400 INVALID_CURSOR.
Retry-safe payments
Contract
POST /v1/invoices/{id}/payments requires an Idempotency-Key header, and returns 400 without one.
- The client generates the key once per user intent, when the user taps "Pay". It persists the key (in memory plus local storage) and reuses it on every retry of that tap. A new tap after a definitive failure gets a new key.
- The key is never generated inside the HTTP retry layer. If it were, each retry would be a new charge.
Server algorithm
- Claim atomically. Insert into
idempotency_keys(scope, key, request_hash, payment_id, state, response, created_at)with a unique constraint on(accountId, key). The constraint picks the winner. Never doSELECTthenINSERT. - If the insert hits the unique violation, load the existing row:
request_hashdiffers (hash of invoiceId + body):422 IDEMPOTENCY_KEY_REUSED.- State is
in_progress:409 REQUEST_IN_PROGRESSwithRetry-After: 2andretryable: true. Never let a second caller through because the first "seems stuck". - State is completed: replay the stored status and body, plus the header
Idempotent-Replayed: true.
- Record intent before calling out. In the same transaction as the claim, create the
Paymentrow asPROCESSINGand lock the invoice. Enforce a partial unique index: at most onePROCESSINGorSUCCEEDEDpayment per invoice. This is a second line of defense if a client uses two different keys for the same invoice. - Call the provider with a derived provider-side idempotency key, for example
pay:v1:{paymentId}. It is stable across our own retries and crash recovery, so even our own re-attempts can't double charge. - Persist the outcome. Update the payment to
SUCCEEDEDorFAILED, mark the invoicePAIDin the same transaction, and store the response against the key. - Treat timeouts as a third outcome,
UNKNOWN. A provider timeout or a crash after the call tells us nothing about whether the charge applied.- The payment stays
PROCESSING/UNKNOWN, and the key stays claimed. - A reconciler resolves it by querying the provider with the same provider key, or via provider webhooks.
- Clients that get
409or anUNKNOWNpayment pollGET /v1/payments/{id}. They never re-pay with a new key until the status is definitive.
- The payment stays
- Validate the provider's responses (shape, amount, currency) before trusting them. They are untrusted input.
Client rules
| Response | Action |
|---|---|
2xx with SUCCEEDED |
Done |
2xx with FAILED |
Show the reason; a new attempt uses a new key |
2xx with REQUIRES_ACTION |
Run the next action, then poll |
| Network error, timeout, 5xx, 429 | Retry with the same key, with exponential backoff and jitter |
409 REQUEST_IN_PROGRESS |
Wait for Retry-After, then retry with the same key |
422 IDEMPOTENCY_KEY_REUSED |
Client bug; don't retry |
Retention
Keep keys at least as long as the longest path that can redeliver a request. Our recommendation is 30 days minimum, and never shorter than the provider's dispute window or any dead-letter replay horizon. The Payment rows themselves are kept permanently, so the partial unique index on invoices still guards after the key expires. Expiry is a retention job and is never an on-read check.
Evolving safely
- Add, don't change. New fields are optional on input and always present on output. Adding an enum value counts as a change, so the open-enum client rule is what makes it safe.
metadatamaps on customers and invoices absorb ad-hoc fields without a schema change.- Send
X-App-Version(or the User-Agent) from clients and log it. Before deprecating anything, you can then see which versions still use it. Deprecations are announced withDeprecationandSunsetheaders. - Minimum supported version: an unsupported client gets
426 UPGRADE_REQUIREDwith the standard error body, which gives a graceful "please update" path. - Publish an OpenAPI spec generated from these types, and run contract tests that replay recorded requests from old app versions on every release.
Open questions
- Partial payments and refunds: I assumed one full-amount payment per invoice. Partial payments would change the "one active payment" index into a balance check.
- Auth model: I assumed a per-account bearer token, with idempotency keys scoped to the account. Tell me if it's multi-tenant in a different way.
- Currency: one currency per invoice is assumed.
Test run: an invoicing API for a fictional service. The model produced endpoints, types, one error format, cursor pagination and a retry-safe payment design with an atomic idempotency key. Design only; nothing was implemented or run, and some choices (for example returning 200 with a FAILED payment) are opinions.
它做什麼
指導 REST 或 GraphQL 介面、模組邊界與元件屬性的設計。它從契約出發(實作之前先寫型別與端點),再套用一組原則:海勒姆定律(每一個可觀察的行為都會變成相依)、單一版本規則、用戶端可據以分支的統一錯誤回應、只在系統邊界做驗證,以及寧可新增選用欄位也不改動既有欄位。它補充了 REST 模式(用名詞表示資源、選對動詞、部分更新、游標分頁、篩選)與 TypeScript 模式(輸入輸出型別分開、用可辨識聯集表示狀態、品牌化的 ID、替舊用戶端留餘地的開放列舉)。對不能執行兩次的操作,它還談到冪等性。
輸出
一份用端點、型別、錯誤格式、分頁與演進計畫寫成的設計,並列出假設與未決問題。
適合什麼場景
設計新的對外或內部 API、審查現有 API,以及避免對無法立即升級的用戶端(例如手機 App)造成破壞性變更。
低風險:純指令檔,沒有腳本。它產出的是設計文字與程式草圖,不會呼叫任何服務。已用一個虛構的、付款可安全重試的開立發票 API 試用一次:給出端點、型別、統一的錯誤格式、游標分頁與冪等鍵設計。只是設計,沒有實作也沒有執行,其中有些取捨(例如信用卡被拒時回傳 200 加一筆 FAILED 的付款)屬於主張,你可以不同意。作者 Addy Osmani,MIT 授權。