TheLunarEngine
Journal

APIs9 min read

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).

Lifecycle of a REST requestA client, such as a React Native app, sends POST /v1/requests with an Idempotency-Key header. The Node.js HTTP adapter authenticates and validates it, the TypeScript use case applies the business rules, and the PostgreSQL repository stores the change and an audit entry in one transaction. The response 201 Created with a Location header travels back the same way.POST /v1/requests · Idempotency-KeyClientweb or mobileHTTP adapterauth, validationUse caserules, permissionsRepositoryone transaction201 Created · Location: /v1/requests/req_8f2cchange + audit entry, committed together
One request, end to end. The HTTP adapter never touches the database directly; the use case decides, the repository stores the change and its audit entry together.

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.

RequestMeaningSuccess
GET /v1/requests?status=openList open requests the caller may see200
POST /v1/requestsSubmit a new request201 + Location
GET /v1/requests/req_8f2cRead one request200
PATCH /v1/requests/req_8f2cChange editable fields200
POST /v1/requests/req_8f2c/approvalsRecord 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
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.

422 Unprocessable Content
{
  "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.

routes/requests.ts
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.

Discuss a project

More from the journal