An API is a promise about names, errors, and what happens when the same request arrives twice. The contract is cheaper to change than the clients already in production.
Name the resource, not the screen
Clients should ask for an order, a payment, or a document — not for “the dashboard payload.” A resource has an identifier, a status, and a small set of transitions. If the mobile app and the back office both need it, it is a resource. If only one screen needs a pile of joined fields, that pile is a query, and it should be labelled as one so it can change without pretending to be the system of record.
Errors are part of the design
Timeouts from a mobile-money provider are normal. So are duplicate callbacks. Document the status you store when the provider has not answered, the status you store when the customer cancelled, and the status that means “do not charge again.” A 200 with a vague message forces every client to guess. A stable error code lets the app show a sentence a person can act on.
Idempotency is the difference between a retry and a second sale
Phones drop. Jobs retry. If creating a payment uses a client reference, the second call with the same reference returns the first payment. Write that rule down before anyone implements the happy path. The same rule applies to webhooks: store the event id and ignore the duplicate.
Idempotency, written as code
The contract says: a client sends Idempotency-Key on every POST that creates money or work. The first request stores the response under that key. A retry with the same key and the same body returns the stored response and does not create a second row. A retry with the same key and a different body is a 409, because the client is confused, not because the server should guess.
Store the key, a hash of the body, the status code, and the response for at least as long as the client will retry. Twenty-four hours covers a phone that slept. The key is scoped to the account, so two customers can choose the same UUID without seeing each other’s payment.
Python · payments/idempotency.py
import hashlib
import json
from django.db import transaction
def body_hash(payload: dict) -> str:
raw = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(raw.encode()).hexdigest()
def begin_idempotent(account_id: int, key: str, payload: dict):
"""Return (replay, response) or reserve the key for the caller."""
digest = body_hash(payload)
with transaction.atomic():
row, created = IdempotencyKey.objects.select_for_update().get_or_create(
account_id=account_id,
key=key,
defaults={"body_hash": digest},
)
if not created and row.body_hash != digest:
return "conflict", None
if row.response is not None:
return "replay", row.response
return "new", row
The error a client can show
Document one JSON shape for every error. A code is stable. A message can be rewritten. The client switches on the code and shows its own sentence, which is how you keep the product’s language when the API is shared by a website and a phone.
Include a request id. When a customer says the payment failed, support can find the row without asking them to screenshot a stack trace.
Write the contract where the client will read it
A contract that lives only in a chat will drift the day the second client integrates. Put one page next to the API: the path, the method, the headers that matter, one success body, and one error body. Keep a real request in that page, copied from a test, so the example cannot rot without the test failing.
Say who may call it. A public quote and a customer’s invoice are different resources even when both are JSON. The invoice requires the caller’s own account, and the response must not include another account’s rows because a filter was forgotten. Write that rule as a test with two accounts, not as a sentence in a wiki.
When you change a field, add the new field and leave the old one until the clients you know about have moved. Note the date you will remove the old field. An unmarked rename is a Friday incident for someone who did not read your commit.
Before the first client integrates
- List each resource, its statuses, and who is allowed to change them.
- Choose the idempotency key for every POST that moves money or creates work.
- Write one error example a client developer can copy.