---
title: createHook
description: Create a low-level hook to resume workflows with arbitrary payloads.
type: reference
summary: Use createHook to pause a workflow and resume it with an arbitrary payload from an external system.
prerequisites:
  - /docs/foundations/hooks
related:
  - /docs/api-reference/workflow/define-hook
  - /docs/api-reference/workflow/create-webhook
  - /docs/foundations/idempotency
---

# createHook



Creates a low-level hook primitive that can be used to resume a workflow run with arbitrary payloads.

Hooks allow external systems to send data to a paused workflow without the HTTP-specific constraints of webhooks. They're identified by a token and can receive any serializable payload.

<Callout type="warn">
  A hook token routes a payload to the right hook; it does not authorize the sender. Generated tokens are hard to guess but are not secrets, and custom tokens are usually easy to reconstruct. Authorize callers in the route that calls [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook). See [Hook and webhook security](/docs/foundations/hooks#security).
</Callout>

```ts lineNumbers
import { createHook } from "workflow"

export async function hookWorkflow() {
  "use workflow";
  // `using` automatically disposes the hook when it goes out of scope
  using hook = createHook();  // [!code highlight]
  const result = await hook; // Suspends the workflow until the hook is resumed
}
```

## API signature

### Parameters

<TSDoc
  definition={`
import { createHook } from "workflow";
export default createHook;`
}
  showSections={['parameters']}
/>

#### HookOptions

<TSDoc
  definition={`
import type { HookOptions } from "workflow";
export default HookOptions;`
}
/>

### Returns

<TSDoc
  definition={`
import { createHook } from "workflow";
export default createHook;`}
  showSections={['returns']}
/>

#### Hook

<TSDoc
  definition={`
import type { Hook } from "workflow";
export default Hook;`}
/>

The returned `Hook` object also implements `AsyncIterable<T>`, which allows you to iterate over incoming payloads using `for await...of` syntax.

Use `hook.getConflict()` to check whether the hook token is already claimed by another hook, including one kept reserved after its run ends, without waiting for hook payload data. Calling `createHook()` on its own does not register the hook: registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the registration, then resolves with `null` once `hook_created` is recorded, or with the conflicting [`Run`](/docs/api-reference/workflow-api/get-run).

## Examples

### Basic usage

When creating a hook, you can specify a payload type for automatic type safety:

```typescript lineNumbers
import { createHook } from "workflow"

export async function approvalWorkflow() {
  "use workflow";

  using hook = createHook<{ approved: boolean; comment: string }>(); // [!code highlight]
  console.log("Send approval to token:", hook.token);

  const result = await hook;

  if (result.approved) {
    console.log("Approved with comment:", result.comment);
  }
}
```

### Customizing tokens

Tokens are used to identify a specific hook. You can customize the token to be more specific to a use case.

```typescript lineNumbers
import { createHook } from "workflow";

export async function slackBotWorkflow(channelId: string) {
  "use workflow";

  // Token constructed from channel ID
  using hook = createHook<SlackMessage>({ // [!code highlight]
    token: `slack_messages:${channelId}`, // [!code highlight]
  }); // [!code highlight]

  for await (const message of hook) {
    if (message.text === "/stop") {
      break;
    }
    await processMessage(message);
  }
}
```

### Detecting token conflicts

Use `hook.getConflict()` when the workflow needs to claim a hook token before doing other work, but does not need a payload yet:

```typescript lineNumbers
import { createHook } from "workflow";

declare function chargeOrder(orderId: string): Promise<void>; // @setup

async function processOrder(orderId: string) {
  "use workflow";

  using hook = createHook({ // [!code highlight]
    token: `order:${orderId}` // [!code highlight]
  }); // [!code highlight]

  const conflict = await hook.getConflict(); // [!code highlight]
  if (conflict) { // [!code highlight]
    // Another active workflow run already owns this token.
    return { dedupedTo: conflict.runId };
  }

  await chargeOrder(orderId);
}
```

Because `createHook()` alone does not suspend the workflow, awaiting `hook.getConflict()` is what actually suspends the run and commits the hook registration. It only waits for registration. To receive payload data from a future `resumeHook()` call, await the hook itself or iterate it with `for await...of`.

### Registering a hook before a step uses it

A hook's registration is committed alongside everything else the workflow started before it suspended, not ahead of it. When a workflow creates a hook and calls a step without awaiting anything in between, the step can start running before the hook is registered, and it can run even if the registration turns out to conflict. That matters in two cases:

* The step hands the token to something that may call `resumeHook()` right away, which throws `HookNotFoundError` until the hook exists.
* The hook guards against duplicate runs. A run that only learns of the conflict after calling the step, for example by awaiting the hook and letting `HookConflictError` end the run, may already have started that step.

In either case, await `hook.getConflict()` before calling the step:

```typescript lineNumbers
import { createHook } from "workflow";

declare function requestApproval(token: string): Promise<void>; // @setup

async function approvalWorkflow() {
  "use workflow";

  using hook = createHook<{ approved: boolean }>();
  await hook.getConflict(); // [!code highlight]

  // The hook is registered, so an approver that resumes it immediately
  // finds it.
  await requestApproval(hook.token);

  const { approved } = await hook;
  return approved;
}
```

On a conflict, the resolved value is a `Run` handle for the run that owns the token, with durable step-backed accessors. The duplicate run can decide in code how to handle it: return or log `conflict.runId`, inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` and continue in the current run. See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies in context.

<Callout type="info">
  Custom hook tokens are the recommended way to coordinate active workflow runs. Use a deterministic token from your domain, such as an order ID or conversation ID, create the hook near the beginning of the workflow, and check `await hook.getConflict()` before work that depends on owning the token. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
</Callout>

### Keep a token unavailable after the run ends

By default, another Hook can use the token after its workflow ends. Set `experimental_minRetention` to keep the token unavailable for at least a specific time after `createHook()` runs:

```typescript lineNumbers
import { createHook } from "workflow";

declare function processOwnedOrder(orderId: string): Promise<void>; // @setup

export async function processOrder(orderId: string) {
  "use workflow";

  const hook = createHook({ // [!code highlight]
    token: `order:${orderId}`, // [!code highlight]
    experimental_minRetention: "30d", // [!code highlight]
  }); // [!code highlight]

  const conflict = await hook.getConflict();
  if (conflict) {
    return { status: "duplicate" as const, runId: conflict.runId };
  }

  await processOwnedOrder(orderId);
  return { status: "processed" as const };
}
```

`experimental_minRetention` accepts the same values as [`sleep()`](/docs/api-reference/workflow/sleep): a duration string such as `"30d"`, a number of milliseconds, or an absolute `Date`. Durations start when `createHook()` runs.

The Hook remains active until the workflow ends, even if the configured time passes first. Another Hook can use the token only after both the workflow has ended and the configured time has passed. For example, `"30d"` keeps the token unavailable for 29 more days if the workflow ends after 1 day. A workflow that runs for more than 30 days releases the token when it ends.

After the workflow ends, [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) can still find the Hook until retention ends, but the Hook cannot be resumed.

<Callout type="warn">
  `using` auto-disposes the Hook at scope exit, which releases the token immediately and defeats `experimental_minRetention`. Declare retained Hooks with `const` and let the runtime clean them up when the run ends.
</Callout>

<Callout type="warn">
  This option is experimental. Worlds can limit how long tokens are retained; see [World configuration](/docs/configuration/worlds) for each World's limit. If the configured World does not support minimum retention, the workflow fails when registering the Hook. `createWebhook()` does not accept this option.
</Callout>

### Take over a token another run holds

By default, a token that another active run already registered makes the new Hook reject with [`HookConflictError`](/docs/api-reference/workflow-errors/hook-conflict-error). Set `experimental_force` when the newest run should own the token instead, for example when a fresh deployment or a restarted conversation must replace a run that is still waiting:

```typescript lineNumbers
import { createHook } from "workflow";

declare function processMessage(message: SlackMessage): Promise<void>; // @setup
type SlackMessage = { text: string }; // @setup

export async function slackChannelWorkflow(channelId: string) {
  "use workflow";

  // Whichever run for this channel started most recently owns the token.
  const hook = createHook<SlackMessage>({ // [!code highlight]
    token: `slack_messages:${channelId}`, // [!code highlight]
    experimental_force: true, // [!code highlight]
  }); // [!code highlight]

  for await (const message of hook) {
    await processMessage(message);
  }
}
```

With `experimental_force`, this run always ends up owning the token:

* The previous owner's Hook is disposed, recorded in that run's event log, and the previous owner is woken. If it was awaiting the Hook, that `await` rejects with [`HookForceClaimedError`](/docs/api-reference/workflow-errors/hook-force-claimed-error), which names the run that took the token. Payloads it received before the takeover stay with it; a `for await...of` loop drains them before it throws.
* Every [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) for the token from then on reaches this run, including a call that was already in flight when the takeover happened. Callers never see the token move; a delivery aimed at the previous owner is redirected to this run inside `resumeHook()`.
* Any number of runs forcing the same token at the same time converge on a single owner. The takeovers form a chain: each run that loses the token gets `HookForceClaimedError`, exactly one run ends up owning it, and none of them can get stuck. Which run wins among simultaneous claimers is not defined; if the order matters, start them in order.
* A finished run that still holds the token under [`experimental_minRetention`](#keep-a-token-unavailable-after-the-run-ends) is taken over silently, since there is nothing left to wake. A run can also take over a token held by its own earlier Hook.

The takeover is durable. If either run's compute fails partway through, the next request for the token completes it, so the token never ends up held by nobody or by both runs. The previous owner's wake is durable too: if the new owner's compute fails between registering the Hook and waking the previous owner, the new owner's next invocation republishes the wake, whatever else the new owner has recorded since (a step it started alongside the Hook, for example). Every invocation of the new owner within 24 hours of the takeover republishes it under the same idempotency key, which collapses the repeats into one wake; a repeat that does get through only replays the previous owner, which finds nothing new.

<Callout type="info">
  A token can only be taken from a run whose runtime understands being taken from. Runs started at a Workflow spec version below 8, which includes every run started by an older SDK release, a Python SDK run, or a deployment with `WORKFLOW_SEALED_LOG=0`, would never learn that their Hook was disposed. The World declines to take their token and the forced Hook rejects with the ordinary [`HookConflictError`](/docs/api-reference/workflow-errors/hook-conflict-error) instead, exactly as if `experimental_force` had not been set. Finished runs holding a retained token are taken over at any version.
</Callout>

`hook.getConflict()` on a forced Hook resolves with `null` once the takeover succeeds: the token is this run's by construction. If the World declines the takeover because the current owner predates spec version 8, `getConflict()` behaves as it does for an ordinary conflict and resolves with that owner's `Run`. Read `hook.claimedFrom` on the value returned by [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) to find out which run, if any, the token was taken from.

<Callout type="warn">
  This option is experimental. It requires an explicit `token` (a generated token can never conflict) and is not accepted by `createWebhook()`. If the configured World does not support force-claiming at all, the workflow fails when registering the Hook; a World that declines a specific takeover because the current owner cannot be woken answers with `HookConflictError` instead.

  Senders on an older SDK release are not redirected. A `resumeHook()` from a deployment that predates this option and that looked the token up inside the short handoff window gets an error (`EntityConflictError`) instead of following the token; the payload is refused, never delivered to the wrong run, and a retry resolves the new owner. Upgrade the sending deployment for the transparent redirect.

  Webhook requests are not redirected either. [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook) streams the request body once, and buffering a copy of every webhook body on the chance that its token is being taken over at that moment would be a cost paid by everyone who never uses this option. A webhook delivery that lands inside the handoff window fails with a retryable error naming the takeover; nothing is delivered anywhere, and the sender's retry reaches the new owner. A forced `createHook()` can take over a token that a webhook holds, but the token then belongs to a Hook that is not a webhook, so `resumeWebhook()` answers every later request for it as not found, exactly as it does for any `createHook()` token, and never redirects one into it.
</Callout>

### Waiting for multiple payloads

You can also wait for multiple payloads by using the `for await...of` syntax.

```typescript lineNumbers
import { createHook } from "workflow"

export async function collectHookWorkflow() {
  "use workflow";

  using hook = createHook<{ message: string; done?: boolean }>();

  const payloads = [];
  for await (const payload of hook) { // [!code highlight]
    payloads.push(payload);

    if (payload.done) break;
  }

  return payloads;
}
```

### Disposing hooks early

You can dispose a hook early to release its token for reuse by another workflow. This is useful for handoff patterns where one workflow needs to transfer a hook token to another workflow while still running.

```typescript lineNumbers
import { createHook } from "workflow"

export async function handoffWorkflow(channelId: string) {
  "use workflow";

  const hook = createHook<{ message: string; handoff?: boolean }>({
    token: `channel:${channelId}`
  });

  for await (const payload of hook) {
    console.log("Received:", payload.message);

    if (payload.handoff) {
      hook.dispose(); // [!code highlight] Release the token for another workflow
      break;
    }
  }

  // Continue with other work while another workflow uses the token
}
```

After calling `dispose()`, the hook will no longer receive events and its token becomes available for other workflows to use.

### Automatic disposal with `using`

Hooks implement the [TC39 Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management) proposal, allowing automatic disposal with the `using` keyword:

```typescript lineNumbers
import { createHook } from "workflow"

export async function scopedHookWorkflow(channelId: string) {
  "use workflow";

  {
    using hook = createHook<{ message: string }>({ // [!code highlight]
      token: `channel:${channelId}`
    });

    const payload = await hook;
    console.log("Received:", payload.message);
  } // hook is automatically disposed here // [!code highlight]

  // Token is now available for other workflows to use
  console.log("Hook disposed, continuing with other work...");
}
```

This is equivalent to manually calling `dispose()` but ensures the hook is always cleaned up, even if an error occurs.

## Related functions

* [`defineHook()`](/docs/api-reference/workflow/define-hook): Type-safe hook helper
* [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook): Resume a hook with a payload
* [`createWebhook()`](/docs/api-reference/workflow/create-webhook): Higher-level HTTP webhook abstraction
* [Idempotency](/docs/foundations/idempotency): Deduplicate step side effects and workflow starts


---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)