How we design REST APIs for workflow systems
Resources over actions, idempotent writes, one error shape, cursor pagination, and versioning that does not break the apps already in people's hands.
- Node.js
- TypeScript
- PostgreSQL
- React Native
Almost everything we automate talks over HTTP: a mobile app submitting a request, a web dashboard approving it, a worker updating Jira. The API is the contract between all of them, so we design it before we build the screens, and we keep it boring on purpose.
Where the API sits
Our APIs run on Node.js and are written in TypeScript, with PostgreSQL behind them by default. Clients are web front ends and React Native apps. The HTTP layer is a thin adapter: it authenticates, validates, and translates. Business rules live in the use cases behind it (see hexagonal architecture).
Resources, not actions
URLs name things; HTTP methods say what happens to them. Workflow steps that feel like verbs (“approve”, “escalate”) become resources of their own, which gives each one an ID, a timestamp, an author, and a place in the audit trail.
| Request | Meaning | Success |
|---|---|---|
GET /v1/requests?status=open | List open requests the caller may see | 200 |
POST /v1/requests | Submit a new request | 201 + Location |
GET /v1/requests/req_8f2c | Read one request | 200 |
PATCH /v1/requests/req_8f2c | Change editable fields | 200 |
POST /v1/requests/req_8f2c/approvals | Record an approval (a resource, not a flag) | 201 |
Every write can be retried safely
Phones lose signal mid-request and workers restart. So every POST that creates something accepts an Idempotency-Key header. We store the key with the result in PostgreSQL, under a unique constraint: a retry with the same key returns the original response instead of creating a duplicate.
POST /v1/requests HTTP/1.1
Authorization: Bearer <token>
Idempotency-Key: 6c1e2a90-3f4b-4f7e-9d0a-0b8f5d1c2e77
Content-Type: application/json
{ "type": "equipment", "summary": "Laptop for new starter", "dueDate": "2026-12-01" }
HTTP/1.1 201 Created
Location: /v1/requests/req_8f2c
{ "id": "req_8f2c", "status": "submitted", "createdAt": "2026-11-08T10:12:03Z" }One error shape, everywhere
Clients should never parse prose. Every error has the same JSON shape: a stable machine code, a human message, optional field errors, and the request ID that appears in our logs, so support can find the exact call.
{
"code": "validation_failed",
"message": "Some fields need attention.",
"fieldErrors": { "dueDate": "Must be a future date." },
"requestId": "rq_01HF3K9M2Z"
}We use status codes for what they mean: 400 malformed, 401 not signed in, 403 not allowed, 404 not found or not visible to you, 409 conflicts with the current state (such as approving something already closed), 422 invalid input, 429 slow down.
Validation at the edge, permissions in the core
The adapter checks that the input is well formed; the use case checks that this person may do this thing to this record right now. Keeping permissions in the core means the same rule protects the web app, the mobile app, and every automation.
const CreateRequest = z.object({
type: z.enum(["equipment", "access", "leave"]),
summary: z.string().trim().min(3).max(200),
dueDate: z.iso.date(),
});
router.post("/v1/requests", async (req, res) => {
const input = CreateRequest.safeParse(req.body);
if (!input.success) return res.status(422).json(validationError(input.error, req.id));
// The use case owns the rules: who may submit, defaults, the audit entry.
const result = await submitRequest(input.data, {
actor: req.user,
idempotencyKey: req.header("Idempotency-Key"),
});
res.status(201).location(`/v1/requests/${result.id}`).json(result);
});Lists that stay fast
We paginate with cursors, not page numbers: the response carries a nextCursor that encodes where the last page ended. It stays correct while new records arrive, and it maps to an indexed query in PostgreSQL or MySQL instead of a growing OFFSET.
Versioning that does not strand apps
Mobile apps stay installed for months, so we version in the path (/v1) and make changes additive: new fields and new endpoints are fine, renaming or removing is a new version. Clients ignore fields they do not know, and we keep the old version running until usage shows it is safe to retire.
Want this in your systems?
Tell us about the workflow; we reply with questions or a proposal for a discovery call.