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.
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
| Header | Required | Notes |
|---|---|---|
| Content-Type | `application/json`. Required — anything else returns 400. | |
| Cookie | Session cookie issued by the installed auth module. Obtain one by signing in at `/api/auth/sign-in/email` with the operator credentials. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| title | string | Short, scannable name for the request. 1–160 characters. | |
| owner | string | Owning team or person the agent will route to. 1–80 characters. | |
| topic | string | Topic the request falls under (e.g. Vendor management). 1–80 characters. | |
| description | string | Plain context the agent can read. Up to 280 characters. |
Example request
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.
{
"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
| Status | When | Body shape |
|---|---|---|
| 200 / 201 | The request was accepted. | A single `Workflow` object on 201 (POST), or an envelope `{ items: [...] }` on 200 (GET). |
| 400 | Request body failed validation. | `{ "errors": { "<field>": "<message>" } }` — keyed by the offending field; `_root` keys a top-level parsing issue (malformed JSON). |
| 401 | No active session, or the session cookie is expired. | Empty body. Sign in again via `/api/auth/...` (handled by the installed auth module) and retry. |
| 500 | Unexpected server error. | `{ "errors": { "_root": "<message>" } }` — retry with backoff; the in-memory store does not persist across restarts. |
{
"errors": {
"title": "Title is required",
"topic": "Topic is required"
}
}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.
/api/webhooks in a follow-up release.Event types
| Event | Trigger | `data` payload |
|---|---|---|
| workflow.requested | A new request lands through `/api/intake` (or `/api/workflows`). `addControlRoomWorkflow` produces `status: "incoming"`. | A full `Workflow` record. |
| workflow.in_flight | An agent picks up a request and transitions it to `status: "in_flight"`. | A full `Workflow` record. |
| workflow.blocked | A blocker trips a tolerance; status moves to `"blocked"` with a `blockerNote` populated. | A full `Workflow` record with `blockerNote`. |
| workflow.resolved | A previously in-flight or blocked request finishes. | A full `Workflow` record. |
| exception.created | The escalation agent opens an exception for a request that needs human review. | An `ExceptionItem` record. |
| exception.resolved | An 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.
{
"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.
{
"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.
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
attemptfield 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.