API reference

Helmsway public API.

Two surfaces. One is the inbound endpoint operators use to file a request into the Control Room; the other is the outbound webhook stream that carries every status change an agent makes on a request back to the systems that need to react.

The examples below are copy-pasteable. They target the deployed app origin — setNEXT_PUBLIC_APP_URLto the same value your running instance exposes.

01 — Request intake

POST /api/intake

Accepts a single request from an authenticated operator and lands it in the Control Room with status: "incoming". The handler shares the sameWorkflowCreatezod contract as the operator UI at /intake, so changing a field shape is a server-side ZodError, not silent breakage.

Authentication

A signed-in operator session is required. Sign in at the installed auth module endpoints (e.g.POST /api/auth/sign-in/email) and pass the returned session cookie asCookieon the request. No API token yet; presence-of-session is the gate.

Headers

HeaderRequiredNotes
Content-Type`application/json`. Required — anything else returns 400.
CookieSession cookie issued by the installed auth module. Obtain one by signing in at `/api/auth/sign-in/email` with the operator credentials.

Request body

FieldTypeRequiredDescription
titlestringShort, scannable name for the request. 1–160 characters.
ownerstringOwning team or person the agent will route to. 1–80 characters.
topicstringTopic the request falls under (e.g. Vendor management). 1–80 characters.
descriptionstringPlain context the agent can read. Up to 280 characters.

Example request

bashcurl — POST /api/intake
curl -X POST "${NEXT_PUBLIC_APP_URL}/api/intake" \
  -H "Content-Type: application/json" \
  -H "Cookie: <session cookie from /api/auth/get-session>" \
  -d '{
    "title": "Vendor contract renewal — Northwind Logistics",
    "owner": "Procurement",
    "topic": "Vendor management",
    "description": "Renewal packet from counterparty received this morning."
  }'

Response

On success the server replies with 201 Createdand a single Workflow object.

jsonResponse · 201 Created
{
  "id": "CR-601",
  "title": "Vendor contract renewal — Northwind Logistics",
  "owner": "Procurement",
  "topic": "Vendor management",
  "status": "incoming",
  "createdAt": "2026-08-17T09:14:22.134Z",
  "description": "Renewal packet from counterparty received this morning.",
  "dependsOn": []
}

Error codes

StatusWhenBody shape
200 / 201The request was accepted.A single `Workflow` object on 201 (POST), or an envelope `{ items: [...] }` on 200 (GET).
400Request body failed validation.`{ "errors": { "<field>": "<message>" } }` — keyed by the offending field; `_root` keys a top-level parsing issue (malformed JSON).
401No active session, or the session cookie is expired.Empty body. Sign in again via `/api/auth/...` (handled by the installed auth module) and retry.
500Unexpected server error.`{ "errors": { "_root": "<message>" } }` — retry with backoff; the in-memory store does not persist across restarts.
jsonResponse · 400 field errors
{
  "errors": {
    "title": "Title is required",
    "topic": "Topic is required"
  }
}
02 — Outbound webhooks

Streaming agent progress to your systems.

Outbound delivery is forthcoming. The contract below is the wire format the delivery system ships against; the public subscription endpoint is not yet live, so wiring a receiver today is preparatory only. Until delivery ships, subscribers can poll GET /api/workflows on the same session for the same lifecycle.

Status — forthcoming. Webhook delivery is planned and the contract below is authoritative for the payload shape, signature scheme, and retry semantics. The subscription endpoint and replay log will be added under /api/webhooks in a follow-up release.

Event types

EventTrigger`data` payload
workflow.requestedA new request lands through `/api/intake` (or `/api/workflows`). `addControlRoomWorkflow` produces `status: "incoming"`.A full `Workflow` record.
workflow.in_flightAn agent picks up a request and transitions it to `status: "in_flight"`.A full `Workflow` record.
workflow.blockedA blocker trips a tolerance; status moves to `"blocked"` with a `blockerNote` populated.A full `Workflow` record with `blockerNote`.
workflow.resolvedA previously in-flight or blocked request finishes.A full `Workflow` record.
exception.createdThe escalation agent opens an exception for a request that needs human review.An `ExceptionItem` record.
exception.resolvedAn operator resolves the exception (`status` → `approved`, `reassigned`, or `snoozed`).The updated `ExceptionItem` record.

Envelope

Every event — regardless of type — uses the same envelope. id is stable across retries so receivers can dedupe; attempt starts at1 and increments on each retry.

jsonEnvelope · workflow.requested
{
  "id": "evt_01HV0ABCDEF",
  "type": "workflow.requested",
  "createdAt": "2026-08-17T09:14:22Z",
  "attempt": 1,
  "data": {
    "id": "CR-601",
    "title": "Vendor contract renewal — Northwind Logistics",
    "owner": "Procurement",
    "topic": "Vendor management",
    "status": "incoming",
    "createdAt": "2026-08-17T09:14:22.134Z",
    "description": "Renewal packet from counterparty received this morning.",
    "dependsOn": []
  }
}

Sample payloads

A workflow event carries the full Workflow shape; an exception event carries the ExceptionItem shape. Both ride the same envelope above; only the typediscriminator and the data body change.

jsonEnvelope · exception.created
{
  "id": "evt_01HV0GHIJKL",
  "type": "exception.created",
  "createdAt": "2026-08-17T11:02:51Z",
  "attempt": 1,
  "data": {
    "id": "ex_01HV0MNOPQR",
    "userId": "user_01HV0UVWXYZ",
    "title": "Stale SLA breach loop — vendor onboarding",
    "description": "Blocker trip; needs human review.",
    "sourceType": "workflow",
    "sourceId": "CR-530",
    "agentRuleId": null,
    "status": "pending",
    "assignedTo": null,
    "snoozedUntil": null,
    "resolvedAt": null,
    "createdAt": "2026-08-17T11:02:51.412Z",
    "updatedAt": "2026-08-17T11:02:51.412Z"
  }
}

Signature — X-Helmsway-Signature

Every delivered POST carries a header of the formt=<unix_seconds>,v1=<hex_hmac>wherev1 = HMAC_SHA256(secret, "<t>." + raw_body)Verify in constant time on the receiver and reject anything more than five minutes outside the current time — that is the replay window.

tsVerifier · Node
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export function verifyHelmswaySignature({
  rawBody,
  header,
  secret,
  now = Math.floor(Date.now() / 1000),
}) {
  if (!header) throw new Error("missing X-Helmsway-Signature");

  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=")),
  );
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!Number.isFinite(t) || !v1) {
    throw new Error("malformed X-Helmsway-Signature");
  }

  if (Math.abs(now - t) > TOLERANCE_SECONDS) {
    throw new Error("timestamp outside tolerance window");
  }

  const signed = `${t}.${rawBody}`;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signed, "utf8")
    .digest("hex");

  // timingSafeEqual throws if the buffers differ in length — length-prefix
  // matches by construction (HMAC-SHA256 is 64 lowercase hex chars).
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(v1, "hex");
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    throw new Error("signature mismatch");
  }
  return true;
}

Retry semantics

  • Delivery retries with exponential backoff starting at 30 seconds, doubling per attempt, with ±20 % jitter to spread a thundering herd.
  • Up to 8 attempts across roughly 24 hours before the event is dead-lettered.
  • Every retry increments the envelope attempt field so receivers can spot a redelivery.
  • Receivers should dedupe on the envelope id; the id is stable across every attempt of a given event.
  • A receiver returns 2xx within the connection window to acknowledge; anything else (including a network timeout) is treated as a failed attempt and retried.