Skip to main content
APIs APIs Design

Write the API contract before the first endpoint

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.

LesterTechLabs Engineering Estimated 3 min read

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

  1. List each resource, its statuses, and who is allowed to change them.
  2. Choose the idempotency key for every POST that moves money or creates work.
  3. Write one error example a client developer can copy.

On the site

APIs

Need an API the rest of the product can trust?

We design contracts, webhooks, and payment integrations that survive real traffic and real retries.