← All field notes
API Design

An API design checklist for anything that produces a physical object

Labels, text messages, payments, and robot moves all share one contract. Ten things the request and the state model need when the side effect cannot be rolled back, and the two where our own API does not yet pass.

A CRUD endpoint can be wrong twice and be fine. You fix the row. An endpoint that prints a shipping label, sends a text message, moves money, or moves a robot arm cannot take anything back, and the caller often cannot see whether it worked.

That shared situation produces a shared contract. Ten requirements, in the order they matter, each stated generally and then shown as it looks in our own print API, including the two we do not pass.

The worked example: RocketPrint is a remote printing API and desktop Station for software platforms: your backend POSTs a print job, and a small app beside the printer prints it, with no inbound networking, no drivers for raw label formats, and a durable job record. Printing tests this checklist well, because it has every property that makes physical APIs hard and none of the regulatory scaffolding payments get to borrow.

The request needs a business identity, not a retry identity

The caller must be able to say "this is the label for shipment 4471" rather than "this is my third attempt."

A fresh UUID per attempt defeats the mechanism, because every retry looks like new work. Derive the key from the business object and store it beside it, before the first send.

order-1042-shipping-label      good: stable across retries
return-882-rma-label           good: names the thing, not the attempt
9f3c1d...-attempt-3            bad: a new identity every retry

Payments call this an idempotency key and so do we. Messaging APIs often call it a client reference. The name does not matter. Two server-side rules do:

  1. The same key with the same content returns the original object, and says so.
  2. The same key with different content returns a conflict rather than guessing.

Rule 2 is the one people skip and the one that catches real bugs. If the address changed, that is not a retry, it is a different label. Our API returns the original job with an Idempotency-Replayed header for the first case and a 409 for the second. Worked through fully in how to stop retries from printing duplicate shipping labels.

Every physical request needs an explicit expiry

A queued instruction that executes eight hours late is usually worse than one that never executes.

A stale database write is merely wrong. A stale label is paper with yesterday's rate on it, stuck to a box that already shipped. A stale SMS wakes someone about an appointment they attended. So make expiry a first-class request field, bound it, and default it short enough to be safe. Ours is expiresInSeconds, accepting 10 to 86,400, defaulting to 3,600. A job not dispatched by its deadline becomes expired and is never sent.

The default matters more than the range, because most callers will never set the field. Whatever you pick, write down why, and pick a value that fails closed for the callers who never touch it.

State the cancel window as a boundary, not a best effort

Say exactly where cancellation stops working, in terms of your own state machine, and return an error past that point rather than accepting the request and hoping.

Ours: DELETE /v1/print-jobs/:id cancels a job still in pending. Once sent to a Station it cannot be canceled, and the API says so instead of returning 204 and a shrug.

The honest boundary is the last moment your control plane still holds the instruction. After that it is on a machine you do not control, and "cancel" would mean "please race the printer," which is not a promise an API can keep. In payments the equivalent boundary is capture; in messaging it is handoff to the carrier. Same shape every time: one moment where the thing leaves your custody.

Your states should name observations, not intentions

The highest-value idea here. Every state should correspond to something a specific component observed, and the docs should say what it proves.

State What was observed What it proves
pending We validated and stored the instruction Nothing has been attempted
dispatching One server leased the job It cannot be sent twice concurrently
sent The instruction was delivered to the agent It reached the building, not that anything was printed
printing The operating system print system accepted it The OS took the bytes
completed The print command returned success The command succeeded, not that paper emerged
failed Something returned an error, with a reason The reason is real
canceled The caller canceled before send Nothing was attempted
expired The deadline passed with no dispatch Nothing was attempted
unknown An interruption with an unconfirmed outcome Nothing, and that is the point

The column people want, "the label is on the box," does not exist and no API can provide it. What the table does provide is a precise answer to every support question of the form "how far did this get." Our reliability page carries the same model plus what completed cannot tell you, including an empty ribbon and a dirty printhead producing an unscannable barcode. Longer version in what print job statuses actually mean.

If your model has exactly two outcomes, you have encoded an assumption that every attempt resolves observably. Physical systems do not.

unknown is a state, and retrying it is the dangerous default

Model "we do not know" explicitly, make it terminal, and attach a policy that is not an automatic retry.

The situation is unavoidable: the agent issued the command, then the process died or the machine lost power before it could report. Something may have happened. Nobody knows. Three responses, worst first:

  • Retry automatically. The default if you did not think about it, and the one that produces two labels, two charges, or two messages.
  • Mark it failed. Quietly wrong. Someone will see failed and reprint on top of a label that already exists.
  • Mark it unknown and require a human or a sensor. Correct, and unsatisfying, which is why people avoid it.

Ours is unknown, it is terminal, and the documented instruction is to inspect the printer before creating another job. A later Station acknowledgment can settle it, because the Station keeps its own durable record of finished jobs and reports the recorded outcome rather than printing again.

The general rule: when an outcome is genuinely unobservable, the API's job is to escalate, not to decide. A sensor beats a human, and a human beats a guess.

Content has a shorter life than the record

Separate the payload from the audit trail and set different retention for each, because they have different risk profiles.

The payload is usually the sensitive part. A shipping label carries a name and address. A receipt carries line items. The record that something happened is what you need for a year; the bytes are what you need for a few days of debugging.

We purge print content seven days after a job reaches a terminal state, keep the job record, and report contentPurgedAt so the absence is explicit. That timestamp saves a support conversation, because "the content is gone" and "the content was never there" look identical otherwise.

Rate limits should say when to come back

Return 429 with a Retry-After header. Do not silently drop, and do not return a generic error that a client library will retry immediately in a tight loop.

There is a second reason beyond politeness. Bursts usually mean a bug upstream, often a retry loop that lost its idempotency key. A limit that says when to come back gives a well-behaved client a way to comply and gives you a signal worth alerting on.

Credentials scope to the tenant, not the integration

If your API can act on hardware in multiple organizations, one credential must never be able to address a device in a different one.

The shortcut is one API key for the whole platform plus a customer identifier in the body. It works until a bug puts the wrong identifier there, and then somebody's label prints in a different company's warehouse. An apology does not recover that.

Ours is a platform account that provisions isolated customer workspaces, each with their own API keys, stations, printers, and jobs, and a Station that prints only jobs for printers it reported on the account it signed in to. Enrollment is device-bound rather than a shared secret, and stations can be revoked. Checklist version in the multi-tenant checklist for customer hardware.

The list endpoint is your reconciliation tool, so design it for that

Someone will eventually need to answer "what happened during those twenty minutes." That question is answered by your list endpoint, not your webhooks.

Minimum surface: filter by state, filter by device, newest first, a stated page limit, and a timestamp per transition. GET /v1/print-jobs does that with status and printer filters and per-job createdAt, sentAt, and completedAt. Every job is persisted before anything is attempted, which is what makes the endpoint trustworthy after an incident. State that property in your own docs, because callers will not assume it.

Give people a way to test without making an object

This is where we fail our own checklist.

A physical API needs a way to exercise the full request path without consuming paper, money, or a customer's attention. Two decent designs: a dryRun flag that validates and returns the job the server would have created, or a designated test target that accepts everything and prints nothing.

We have neither. What we have is a workaround: point at an offline printer, watch the job sit in pending, then cancel it. That exercises validation, authentication, idempotency, and the cancel path, and does not exercise delivery or rendering at all. It is a workaround, not a feature, and it is the item here we are least happy about.

The checklist, and the two rows we fail

The ten requirements above, plus the eleventh row every integrator asks about.

Requirement Our answer
Business identity in the request Idempotency-Key, replay header, 409 on conflicting content
Explicit expiry expiresInSeconds, 10 to 86,400, default 3,600
Stated cancel window Cancelable while pending, refused once sent
States that map to observations Nine states, documented by what each proves
An unknown state with a policy unknown, terminal, inspect before retrying
Content lifecycle separate from the record Content purged 7 days after terminal, contentPurgedAt reported
Rate limits that say when to retry 429 with Retry-After
Per-tenant credentials Per-workspace API keys, device-bound station enrollment
List endpoint good enough to reconcile Status and printer filters, newest first, transition timestamps
A way to test without side effects No. No dry run, no test target
Push notification of outcomes No. Polling or the Node client's waitFor helper

Nine green rows out of eleven is not a boast, it is a to-do list. If you are designing something in this shape, do the bottom two rows before launch rather than after. Both are far easier to add before anyone has built a client against the version without them.

The artifacts are in the API reference, described by an OpenAPI 3.1 document, and the Node client docs for the waitFor helper.

END / api-design-checklist-physical-objects

More field notes →