The Workflow SDK's start() function can now admit a run and claim a hook in one atomic step. Duplicate workflow starts fail safely. The atomic start replaces the previous getHookByToken()-then-start() pattern, whose gap between the lookup and the start left a documented duplicate-start race.
import { resumeHook, start } from "workflow/api";import { HookConflictError } from "workflow/errors";import { processOrder } from "./workflows/process-order";
export async function POST(request: Request) { const { orderId, note } = await request.json(); const token = `order:${orderId}`;
try { const run = await start(processOrder, [orderId, note], { experimental_hook: { token }, }); return Response.json({ runId: run.runId, started: true }); } catch (error) { if (!HookConflictError.is(error)) throw error; // Route the duplicate's payload to the run that owns the token const hook = await resumeHook(token, { note }); return Response.json({ runId: hook.runId, started: false }); }}Starting a run idempotently and routing a duplicate's payload to the run that already owns the token
Copy link to headingSubscribe to messages inside the run
The claimed Hook exists from the moment the run is admitted, so resumeHook works even before the run begins executing, and early payloads are buffered and delivered in order. Inside the workflow, createHook({ token }) with the same token binds to the already-claimed hook: guaranteed, registering nothing new, adding no step, and resolving getConflict() to null.
import { createHook } from "workflow";
type OrderUpdate = { note: string };
export async function processOrder(orderId: string, note: string) { "use workflow";
await recordNote(orderId, note);
// Binds to the hook claimed at start: no conflict possible, no extra step const updates = createHook<OrderUpdate>({ token: `order:${orderId}` }); for await (const update of updates) { await recordNote(orderId, update.note); }}Binding to the start Hook inside the run to consume payloads in order
Copy link to headingIdempotency with no extra code
The token is the idempotency key, and it comes from your own domain: an order ID, an invoice ID, an import ID, or a request ID. The token alone guarantees idempotency. Exactly one run owns it, and a duplicate start() throws HookConflictError carrying the owner's conflictingRunId and never becomes a run. The workflow doesn't have to open the hook at all, and if it never does, the token was only an idempotency key and the run completes normally.
Setting experimental_minRetention keeps the token unavailable after the run ends, counted from start(), so late duplicates are still refused.
Upgrade to workflow 5.4.0 and read the idempotency documentation to get started.