Developers judge your API in the first five minutes. They’ll open the docs, try one request, look at the response, and decide whether this is something they want to build on or walk away from.
We know this because we’ve been on the other side. Thirty years of integrating with APIs that return 200 OK for errors, use raw integers as IDs across different entity types, and represent money as strings in some endpoints and floats in others.
When we built the SpeyBooks API, we had a clear benchmark: would Stripe be embarrassed by this? If yes, fix it.
Here’s what we learned.
Decision 1: Prefixed IDs
Every ID in the SpeyBooks API carries its entity type as a prefix:
inv_42 → Invoice #42
cont_3 → Contact #3
txn_891 → Transaction #891
acc_1200 → Account #1200
line_203 → Line item #203
Why? Because raw integers are ambiguous. If you see 42 in a log file, is that an invoice, a contact, or a transaction? With inv_42, you know instantly. The prefix acts as a type tag at the serialisation layer.
This also prevents a class of bug where you accidentally pass a contact ID to an invoice endpoint. The API validates the prefix on every request — cont_3 passed as an invoice ID returns a clear error, not a 404 or worse, a wrong record.
The implementation is lightweight. IDs are stored as integers in PostgreSQL and transformed at the API boundary:
// Outbound: DB → API
toApiId('invoice', 42); // "inv_42"
toApiIdOrNull('contact', null); // null
// Inbound: API → DB
validateApiId('inv_42', 'invoice'); // 42
validateApiId('cont_3', 'invoice'); // throws WrongIdTypeError
One library file. Two functions. Applied consistently across every route. The transformation adds negligible overhead and eliminates an entire category of cross-entity confusion.
Decision 2: Minor Units for Money
All monetary amounts are integers in pence. Not strings, not floats, not sometimes-one-sometimes-the-other:
{
"subtotal": 500000,
"vatAmount": 100000,
"total": 600000
}
That’s £5,000.00 + £1,000.00 VAT = £6,000.00. Integer arithmetic is exact. No floating-point rounding errors. No ambiguity about whether "1500" means £15.00 or £1,500.
This is the Stripe convention, and it’s the right one. For a deeper explanation, see Why We Store Money in Pence.
Decision 3: Consistent Response Envelope
Every response follows the same structure:
// Success
{
"success": true,
"data": { ... }
}
// Success with pagination
{
"success": true,
"data": { "invoices": [...] },
"meta": { "page": 1, "perPage": 50, "total": 47, "pages": 1 }
}
// Error
{
"success": false,
"error": {
"code": "validation_error",
"message": "Amount must be positive",
"field": "amount"
}
}
The success boolean means you can write one response handler for all endpoints. Check success, branch on it, done. No checking HTTP status codes AND response body format AND error shape for each endpoint.
Error responses always include a machine-readable code, a human-readable message, and optionally the field that caused the error. This is enough for client code to display useful error messages without parsing strings.
Decision 4: Idempotency Keys
Financial operations must be safe to retry. Network timeouts happen. Mobile connections drop. If a client sends a “create invoice” request and doesn’t get a response, they need to be able to retry without creating a duplicate.
SpeyBooks supports idempotency keys on all mutating endpoints:
curl -X POST https://api.speybooks.com/v1/invoices \
-H "Authorization: Bearer sk_live_..." \
-H "Idempotency-Key: inv-feb-acme-2026" \
-H "Content-Type: application/json" \
-d '{ "contactId": "cont_42", ... }'
If the same Idempotency-Key is sent twice, the second request returns the original response without creating a new resource. The key is scoped to the organisation and stored with a TTL.
This is table-stakes for any financial API. Without idempotency, your users risk double-charging clients, creating duplicate expenses, or submitting VAT figures twice.
Decision 5: Meaningful Error Codes
We defined a fixed set of error codes that map to specific HTTP status codes:
| Code | HTTP | When |
|---|---|---|
validation_error | 400 | Input doesn’t pass validation |
invalid_id | 400 | Malformed or wrong-type ID |
not_found | 404 | Resource doesn’t exist in this org |
unauthorized | 401 | Missing or expired token |
forbidden | 403 | Valid token, insufficient permissions |
conflict | 409 | Resource already exists (duplicate) |
unprocessable_entity | 422 | Valid input, but business rule violated |
rate_limited | 429 | Too many requests |
internal_error | 500 | Something broke on our end |
The distinction between validation_error (400) and unprocessable_entity (422) matters. A 400 means the input is structurally wrong — missing required field, wrong type, invalid format. A 422 means the input is valid JSON with correct types, but violates a business rule — trying to void an already-voided invoice, or creating a transaction that doesn’t balance.
Client code can handle these differently: 400 errors are input bugs (fix your request), 422 errors are state problems (check the current state of the resource).
Decision 6: No Nested Resource URLs
We avoided deep URL nesting. Instead of /contacts/cont_3/invoices/inv_42/lines/line_203, we use flat resources with query filters:
GET /v1/invoices?contactId=cont_3 # Invoices for a contact
GET /v1/invoices/inv_42 # Single invoice (includes lines)
GET /v1/transactions?from=2026-01-01 # Transactions in date range
Deep nesting creates inflexible URLs and forces clients to know the full hierarchy to access a resource. Flat URLs with filters let you query from any angle: by contact, by date, by status, or any combination.
The trade-off is discoverability — nested URLs make relationships explicit in the URL structure. We compensated by including related IDs in every response body, so the relationships are always visible.
The Pattern Behind the Patterns
Every decision above follows the same principle: make invalid states unrepresentable at the API level.
Prefixed IDs make cross-entity confusion unrepresentable. Minor units make rounding errors unrepresentable. The response envelope makes ambiguous success/failure unrepresentable. Idempotency keys make duplicate mutations unrepresentable. Double-entry constraints make unbalanced transactions unrepresentable.
Each constraint eliminates a category of bugs before they reach your code.
- Prefixed IDs (inv_42, cont_3) prevent cross-entity bugs and make logs readable
- Minor units (integer pence) eliminate floating-point rounding in financial calculations
- A consistent response envelope with success/error reduces client complexity
- Idempotency keys are essential for financial APIs — retries must be safe
- Flat resource URLs with query filters are more flexible than deep nesting