← Back to home
← Back to the blog
7 min read· DirtFleet team

The five API conventions that stuck across 37 iterations (and three we let drift)

From one endpoint to 22 across 14 API iterations. The patterns that hardened by the third call: { ok } envelope, cursor pagination, cross-tenant 404, forgiving enums, idempotency keys. And what we deliberately let drift.

The DirtFleet public API grew from one endpoint (GET /api/v1/assets) to 22 endpoints across 14 iterations. No big-bang design doc; each iteration shipped one resource at a time, made the same calls a real integrator would make, and the patterns hardened by the third or fourth addition. Here's what we kept consistent — and what we let drift on purpose.

The five conventions that stuck

1. { ok: boolean, ... } envelope on every response

Every successful response starts with ok: true and a domain-specific second key (assets, workOrder, flagId). Every error response starts with ok: false and a stable string error code (missing_bearer, missing_scope:assets:read, not_found, validation_failed, rate_limited). Integrators can branch on response.ok from the body — the HTTP status code carries the same information, but a Zapier-class user with a 4xx error log finds the body easier to read.

2. Cursor pagination, never offset

Every list endpoint takes ?limit=N&cursor=X and returns nextCursor. Default 100, max 500 (50 / 200 on webhook deliveries). No ?page=2 — that pattern breaks under any concurrent insert and tempts integrators to deep-paginate. The cursor is the last row's id; the next page is "everything after this id, in the same order."

3. Cross-tenant 404, never 403

If an integrator passes an id from another org — by mistake or by probing — they get 404 not_found, not 403 forbidden. The 403 would confirm that an id exists somewhere, which is information leakage. The 404 matches the "no such resource in your org" mental model perfectly.

4. Forgiving on enums, strict on required fields

Unknown enum values (status: "WAITING" when the enum is OPEN/ASSIGNED/IN_PROGRESS/DONE) silently drop to undefined rather than 422. Reason: every first-time integrator typos an enum at least once; the 422 forces them to write defensive code that normalizes their input before sending. The drop-to-default behavior is what most APIs converge on after one round of integrator feedback, so we just started there.

On the other hand: missing required fields return 422 with fieldErrors mapping the missing field name to a human message. We report all missing fields in one response, not just the first, so an integrator gets the full picture on the first try.

5. Idempotency-Key on every write that creates rows

POST /hours, POST /flags, POST /work-orders, POST /tools, POST /projects, POST /assets — each accepts an Idempotency-Key header. A retry with the same key returns the original row id without a second insert. The binding lives on the row itself (a unique clientMutationId per org), not a separate dedupe table, so there's no GC and no race window. More on the design here.

What we deliberately let drift

Naming inconsistency on path params

The asset detail endpoint is /api/v1/assets/{id} — generic id. The work-order endpoint is the same. We considered {assetId} / {workOrderId} but the only reader of the path param is the route handler, which sees the typed param anyway. The cost of consistency was not worth the marginal clarity. (Honest scope.)

Different response keys per resource

List endpoints return assets / flags/ hoursLogs / workOrders — domain plurals. We considered a single items key everywhere (uniform shape, easier generic clients) but the explicit name is what every integrator scribbles into their client. The TypeScript types come from OpenAPI; the JSON shape prioritizes the human reading the response in a debug pane.

Sparse partial responses

We don't support GraphQL-style field selection (?fields=id,nickname). Most clients want every column anyway; the bandwidth saving is marginal and the cache story gets messy. Maybe later, when an integrator asks. The pattern that wins in practice is "ship the rich shape; let receivers ignore what they don't care about."

What we got right by happy accident

The startApiRequest helper

Around iteration 27 we factored the bearer auth + rate-limit + CORS-headers dance into a single startApiRequest helper. Routes went from a 12-line auth gate to two lines. When CORS support shipped (iteration 30) it required zero changes to the routes — the helper just merged the headers in. Same for the rate-limiter (iteration 27) and the per-key attribution (iteration 23). Each new cross-cutting feature landed in one place.

We didn't design this on day one. The first three routes each had their own copy of the auth dance. The helper emerged when the third copy felt obviously wrong. That's the right time — earlier and the abstraction would have locked before the shape was known; later and the duplication would have leaked into more files.

Documenting what we don't do

Every blog post on the API design has a "what we deliberately don't do" section. Token buckets, per-endpoint rate caps, body-hash idempotency binding, GraphQL, field selection. Spelling out the non-choices is at least as valuable as the choices — it tells future-us why the constraint exists, and it tells integrators why their favorite feature isn't in the spec.

Where it goes next

The next API iteration is probably bulk endpoints — POST an array of hours logs, get back a parallel array of results. After that, optional per-key rate-limit overrides for enterprise customers. Eventually a small TypeScript SDK package against the OpenAPI spec, although the spec itself plus openapi-typescript already gets integrators 90% of the way there. None of it is urgent; the surface is already comprehensive enough to do real work.

Build the smallest thing that ships honest behavior on every response. The patterns will harden by the third or fourth iteration without anyone designing them up front. Document what you didn't do at least as carefully as what you did.

→ API reference · → Copy-paste examples · → Integration-first design