workflow
Version:
Workflow SDK - Build durable, resilient, and observable workflows
205 lines (152 loc) • 8.25 kB
text/mdx
---
title: hook-conflict
description: Hook tokens must be unique across all running workflows in your project.
type: troubleshooting
summary: Resolve hook token conflicts by using unique or auto-generated tokens.
prerequisites:
- /docs/foundations/hooks
related:
- /docs/api-reference/workflow/create-hook
- /docs/api-reference/workflow/define-hook
---
<CopyPrompt
text="Fix hook token conflicts. Find every `createHook({ token })` or typed hook creation site. If multiple waits can exist at the same time, include a unique stable discriminator in the token such as `${workflowRunId}:approval:${itemId}` or `${orderId}:${attempt}` instead of reusing one global token. If duplicate work should join an existing run, catch `HookConflictError` from `@workflow/errors`, read the conflicting run ID from the error/result if available, and use `getRun(runId)` plus `resumeHook()` from `workflow/api` to deliver the payload to the active run. Keep token generation deterministic across retries so replay does not create new hook identities. Verify two concurrent runs and a duplicate request no longer throw hook-conflict unexpectedly."
/>
This error occurs when you try to create a hook with a token that is already in use by another active workflow run. Hook tokens must be unique across all running workflows in your project.
## Error message
```text
Hook token "<token>" is already in use by another workflow
```
## Why this happens
Hooks use tokens to identify incoming webhook payloads. When you create a hook with `createHook({ token: "my-token" })`, the Workflow runtime reserves that token for your workflow run. If another workflow run is already using that token, a conflict occurs.
This typically happens when:
1. **Two workflows start simultaneously** with the same hardcoded token
2. **A previous workflow run is still waiting** for a hook when a new run tries to use the same token
## Common causes
### Hardcoded token values
{/* @skip-typecheck: incomplete code sample */}
```typescript lineNumbers
// Error - multiple concurrent runs will conflict
export async function processPayment() {
"use workflow";
const hook = createHook({ token: "payment-hook" }); // [!code highlight]
// If another run is already waiting on "payment-hook", this will fail
const payment = await hook;
}
```
**Solution:** Use unique tokens that include the run ID or other unique identifiers.
```typescript lineNumbers
import { createHook } from "workflow";
export async function processPayment(orderId: string) {
"use workflow";
// Include unique identifier in token
const hook = createHook({ token: `payment-${orderId}` }); // [!code highlight]
const payment = await hook;
}
```
### Omitting the token (auto-generated)
The safest approach is to let the Workflow runtime generate a unique token automatically:
```typescript lineNumbers
import { createHook } from "workflow";
export async function processPayment() {
"use workflow";
const hook = createHook(); // Auto-generated unique token // [!code highlight]
console.log(`Send webhook to token: ${hook.token}`);
const payment = await hook;
}
```
## Handling hook conflicts
When a hook conflict occurs, awaiting the hook will throw a `HookConflictError`. The error exposes the token that conflicted and, for current worlds, the run ID that currently owns it. `conflictingRunId` remains optional for compatibility with older persisted events and world implementations, so guard it before delegating:
```typescript lineNumbers
import { createHook } from "workflow";
import { HookConflictError } from "@workflow/errors";
export async function processPayment(orderId: string) {
"use workflow";
const hook = createHook({ token: `payment-${orderId}` });
try {
const payment = await hook; // [!code highlight]
return { success: true, payment };
} catch (error) {
if (HookConflictError.is(error)) { // [!code highlight]
// Another workflow is already processing this order
console.log(`Conflicting token: ${error.token}`);
if (error.conflictingRunId) {
console.log(`Active run: ${error.conflictingRunId}`);
}
return {
success: false,
reason: "duplicate-processing",
token: error.token,
runId: error.conflictingRunId
};
}
throw error; // Re-throw other errors
}
}
```
This pattern is useful when you want to detect duplicate processing inside the workflow. Runtime APIs such as `resumeHook()` and `getRun()` must be called outside workflow functions, for example from an API route or in a step.
### Delegate to the active Run
In idempotency flows, a conflict means another active run already owns the hook token. You can return the duplicate-processing payload from the workflow, resume the active hook to deliver the payload to the existing run, then use `getRun(result.runId)` to wait for, stream, or cancel the active run:
```typescript lineNumbers
import { getRun, resumeHook, start } from "workflow/api";
import { processPayment } from "@/workflows/process-payment";
type ProcessPaymentResult =
| { success: true; payment: unknown }
| {
success: false;
reason: "duplicate-processing";
token: string;
runId?: string;
};
export async function POST(request: Request) {
const { orderId, payment } = await request.json();
const run = await start(processPayment, [orderId]);
const result = (await run.returnValue) as ProcessPaymentResult;
if (
result.success === false &&
result.reason === "duplicate-processing" &&
result.runId
) {
await resumeHook(result.token, payment); // [!code highlight]
const activeRun = getRun(result.runId); // [!code highlight]
return Response.json({
delegatedToRunId: activeRun.runId,
result: await activeRun.returnValue
});
}
return Response.json(result);
}
```
If the caller needs live output instead of the final result, return `activeRun.getReadable()` from the same branch. If the duplicate request should replace the active work, call `await activeRun.cancel()` after inspecting the run.
### Take the token over
If the newest run should always own the token, create the hook with [`experimental_force: true`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds). The new run takes the token instead of getting `HookConflictError`, and the previous owner's `await hook` rejects with [`HookForceClaimedError`](/docs/errors/hook-force-claimed).
This enables zero-downtime transfers for hooks so one run can hand off a hook to another without dropping messages.
```typescript lineNumbers
import { createHook } from "workflow";
export async function processPayment(orderId: string) {
"use workflow";
const hook = createHook({
token: `payment-${orderId}`,
experimental_force: true, // [!code highlight]
});
const payment = await hook;
}
```
A run started at a Workflow spec version below 8, including runs started by older SDK releases, can't be taken from, so the forced hook still gets `HookConflictError` in that case.
## When hook tokens are released
Hook tokens are automatically released when:
- The workflow run **completes** (successfully or with an error)
- The workflow run is **canceled**
- The hook is explicitly **disposed**
After a workflow completes, its hook tokens become available for reuse by other workflows.
## Best practices
1. **Use auto-generated tokens** when possible - they are guaranteed to be unique
2. **Include unique identifiers** if you need custom tokens (order ID, user ID, etc.)
3. **Avoid reusing the same token** across multiple concurrent workflow runs
4. **Consider using webhooks** (`createWebhook`) if you need a fixed, predictable URL that can receive multiple payloads
5. **Use `experimental_force`** when a newer run should replace the run holding the token
## Related
- [Hooks](/docs/foundations/hooks) - Learn more about using hooks in workflows
- [getRun](/docs/api-reference/workflow-api/get-run) - Retrieve or control the active run
- [resumeHook](/docs/api-reference/workflow-api/resume-hook) - Deliver data to the active hook
- [createWebhook](/docs/api-reference/workflow/create-webhook) - Alternative for fixed webhook URLs