Most API design advice assumes one consumer: a web frontend built by the same team, in the same sprint, deployable at the same moment.

FitsSort has three consumers with almost nothing in common. A React dashboard used by gym owners. A React Native app in members’ pockets. And a ZKTeco biometric turnstile sitting on a gym’s local network, which cannot reach the internet, cannot be redeployed, and does not care about your API conventions.

The third one changed how I design APIs generally.

Three clients, three different assumptions

Dashboard Mobile app Device bridge
Network Reliable Intermittent Frequently offline
Update cadence Deploy any time Days to weeks (app stores) Effectively never
Payload tolerance Large Bandwidth-sensitive Tiny
Clock Trustworthy Mostly Untrusted
Auth lifetime Session Long-lived refresh Machine credential

You cannot satisfy all three by designing for the easiest one and hoping. What you can do is design for the hardest one and let the others benefit.

Versioning becomes real the day you ship a mobile app

On the web, a breaking API change is coordinated: deploy backend and frontend together, done. On mobile, a user running a four-month-old build is a normal, common, permanent condition. There is no “deploy the client.”

So the rules I now hold to:

  • Additive changes only, within a version. New fields are fine; renaming or removing a field is a new version.
  • Never repurpose a field. Changing status from a boolean to an enum breaks every old client silently - they get a truthy string and take the wrong branch.
  • Every response carries a shape the oldest supported client can parse. New capability goes in new fields the old client ignores.

The mobile app is what makes these rules non-negotiable, and the whole API is better for them.

The device: assume nothing

The turnstile speaks a proprietary protocol over the local network. It has no route to the cloud. The bridge is a small LAN agent that talks to the device locally and syncs upstream, and every assumption I would normally make had to be discarded.

The clock is untrusted. Device clocks drift, get reset by power cuts, and are occasionally set by someone in the building who thought they were helping. So attendance carries two timestamps: device_time, what the hardware claimed, and received_at, when the server got it. Payroll uses one of them and the audit trail keeps both - because “we computed overtime from a clock that was ninety minutes fast” is a conversation you only want to have once, and you want records when you do.

The connection is not there. The agent buffers locally and syncs when it can. Which means the API receives batches of events that are minutes or hours old, out of order, and possibly overlapping with a previous partial sync.

Retries are guaranteed, not possible. An agent that times out mid-upload will resend. It has no way of knowing whether the server processed the batch before the connection dropped.

That last point is the one that shaped the contract.

Idempotency as a first-class part of the contract

Every event carries a deterministic id derived from its content - device serial, user id, and the device timestamp:

event_id = sha256(f"{device_serial}:{user_id}:{device_time_iso}")

The write is an upsert keyed on that id. A batch delivered twice produces the same rows. A batch delivered half-way and then re-sent in full completes cleanly, with the already-written half absorbed.

The endpoint returns per-item results, not a single status:

{
  "accepted": 47,
  "duplicates": 12,
  "rejected": [
    { "event_id": "a3f…", "reason": "unknown_member" }
  ]
}

A partial-success response is what lets the agent clear exactly the events that landed and keep retrying the ones that didn’t. An all-or-nothing endpoint forces the agent to choose between resending everything forever or dropping data - and it will eventually choose wrong.

This is the design the mobile app quietly benefits from too. A member checking in on a train, in and out of signal, hits the same idempotent path.

Shaping responses without three codebases

Three clients want three payload shapes. The dashboard wants members with their plan, branch and last visit embedded. The mobile app wants a fraction of that. The device wants an id and a name.

Three separate endpoints means three implementations of the same permission logic, and eventually they disagree. Instead: one resource, explicit field selection.

GET /api/v1/members?fields=id,name,status&expand=plan

One queryset, one permission path, one place where branch scoping and RBAC are enforced. The serializer honours fields and expand, and the query layer uses the same information to decide what to select_related - so asking for less data also costs less, rather than fetching everything and discarding it at the edge.

Real-time where it is worth it

The dashboard shows check-ins as they happen. The first version polled every five seconds; with dozens of dashboards open across branches, that was a permanent query floor unrelated to whether anything had happened.

WebSocket fan-out replaced it. The event already existed - the device sync writes it - so pushing to subscribed dashboards costs nothing extra.

The mobile app deliberately does not hold a socket. Battery and background execution constraints make a persistent connection the wrong trade for a client that needs to know about a handful of events a day. It uses push notifications and refreshes on foreground. Same backend event, different transport per client, chosen by that client’s constraints.

What the hardware taught me

The device could not be updated, could not be trusted about time, and could not stay connected. Designing for it forced idempotent writes, partial-success responses, additive-only evolution and explicit payload control.

Every one of those made the dashboard and the mobile app more robust too. The hardest client is the best specification you will get.