Cloudflare Workers
@hatchet-dev/serverless turns a Cloudflare Worker into a serverless endpoint: the Worker answers Hatchet's healthcheck with the workflows it serves, verifies the signature on every request, and runs each task it's assigned. Your task code is the same code you'd run on a worker, declared with the same hatchet.task and hatchet.durableTask calls.
You need Node 22, Wrangler, and a Cloudflare account. Durable tasks and tasks that wait for child runs hold a websocket open for the length of one invocation, so they need more CPU time than the Free plan's 10 ms; see platform notes.
Install
pnpm add @hatchet-dev/serverless @hatchet-dev/typescript-sdk zod
pnpm add -D wrangler @cloudflare/workers-types@hatchet-dev/typescript-sdk (1.41.0 or later) and zod are peer dependencies. The package's runtime entries carry no Node built-ins, so the Worker bundles without nodejs_compat.
Declare tasks
Declarations are runtime neutral. The same file compiles for a worker, a Cloudflare Worker or a Vercel function:
// src/tasks.ts
import { hatchet } from "@hatchet-dev/serverless";
// A function of its input that never talks to Hatchet. Delivered over a signed POST.
export const echo = hatchet.task({
name: "echo",
retries: 3,
hatchetClient: false,
fn: async (input: { message: string }) => ({ echo: input.message }),
});
// Talks to Hatchet (the default): it triggers a child run and waits for the result.
export const parentEcho = hatchet.task({
name: "parent-echo",
fn: async (input: { message: string }, ctx) => ({
child: await ctx.runChild(echo, {
message: `${input.message} (from parent)`,
}),
}),
});
// A durable task: sleeps, waits and child runs survive the invocation ending.
export const sleepThenEcho = hatchet.durableTask({
name: "sleep-then-echo",
fn: async (input: { message: string }, ctx) => {
await ctx.sleepFor("10s");
const child = await ctx.spawnChild(echo, { message: input.message });
return { echo: input.message, child };
},
});
export const workflows = [echo, parentEcho, sleepThenEcho];hatchetClient: false states that a task is a pure function of its input: it never triggers child runs, publishes streams, writes logs to Hatchet or cancels anything. Hatchet delivers such a task as one signed POST and reads the response as its output. Every other task is invoked over a websocket, so it can wait for child runs and read streams while it runs. The full list of what a task can do on an endpoint, and the declaration options that don't apply there, is in the package's LIMITATIONS.md.
Export the Worker
// src/index.ts
import { cloudflare } from "@hatchet-dev/serverless/cloudflare";
import { workflows } from "./tasks";
export default cloudflare({ workflows });The adapter serves POST /hatchet/healthcheck, POST /hatchet/trigger and the websocket upgrade on /hatchet/trigger. Every other path gets a 404 unless you pass a fetch option (see options).
# wrangler.toml
name = "hatchet-tasks"
main = "src/index.ts"
compatibility_date = "2025-09-01"
[limits]
# CPU time per invocation, in milliseconds. 300000 is the maximum (Workers Paid plan).
cpu_ms = 300000Secrets
The Worker holds two secrets. Neither is ever sent by Hatchet; you set both on the Worker and give the first one to Hatchet when you register the endpoint.
openssl rand -hex 32 # the signing secret; keep it for the registration
wrangler secret put HATCHET_SIGNING_SECRET # paste it at the prompt
wrangler secret put HATCHET_CLIENT_TOKEN # a tenant API tokenHATCHET_SIGNING_SECRET(at least 32 characters) is the key Hatchet signs every request with. The adapter rejects any request whose signature doesn't verify.HATCHET_CLIENT_TOKENis an API token for your tenant. The adapter builds the Hatchet client thatctx.runChild,ctx.putStream,ctx.logandctx.canceluse from it. Tasks declared withhatchetClient: falsedon't need it; if every task on the Worker is one of those, leave it out.HATCHET_ENDPOINT_ID(optional) is the endpoint's id from the dashboard. When set, the Worker refuses requests meant for another endpoint that shares the same secret.
Deploy and register
wrangler deploy # prints https://<name>.<account>.workers.devThen register the endpoint, from the dashboard's Endpoints page (choose Cloudflare Workers and enter the Worker's URL) or with the REST API:
curl -X POST \
-H "Authorization: Bearer $HATCHET_CLIENT_TOKEN" \
-H "Content-Type: application/json" \
"https://cloud.onhatchet.run/api/v1/stable/tenants/$TENANT_ID/serverless/endpoints" \
-d '{
"name": "hatchet-tasks",
"kind": "CLOUDFLARE_WORKERS",
"healthcheckUrl": "https://hatchet-tasks.my-account.workers.dev/hatchet/healthcheck",
"triggerUrl": "https://hatchet-tasks.my-account.workers.dev/hatchet/trigger",
"signingSecret": "'"$HATCHET_SIGNING_SECRET"'"
}'Within one poll interval (30 seconds by default) the endpoint turns healthy and its registeredActions list the tasks above. See Managing endpoints for the fields and the SDK calls.
Right after the first deploy, the workers.dev route can take a minute to go
live. Until then every request gets an edge 404 whose body is error code: 1042, and the first healthchecks fail. The endpoint recovers on its own once
the route is live.
From here on, the tasks run like any other: trigger them from the dashboard, with hatchet.run from any SDK, on a cron or from an event.
Local development
wrangler dev runs the Worker on http://localhost:8787 and reads secrets from .dev.vars (gitignored):
cat > .dev.vars <<EOF
HATCHET_SIGNING_SECRET=$(openssl rand -hex 32)
HATCHET_CLIENT_TOKEN=<api token>
EOF
wrangler devHatchet only delivers to public https URLs on port 443, so put a tunnel in front of the local Worker and register the tunnel's URL:
cloudflared tunnel --url http://localhost:8787 # prints https://<random>.trycloudflare.comRegister https://<random>.trycloudflare.com/hatchet/healthcheck and .../hatchet/trigger as a new endpoint, with a short pollIntervalSeconds so changes to your tasks are picked up quickly. The tunnel carries the websocket too, so durable tasks work locally.
For a locally running Hatchet served over plain HTTP, add HATCHET_CLIENT_HOST_PORT=localhost:7070 and HATCHET_CLIENT_TLS_STRATEGY=none to .dev.vars. These are the same variables the Node SDK reads.
Options
export default cloudflare({
workflows,
serve: [echo, parentEcho], // the tasks this Worker serves (default: all)
basePath: "/internal/hatchet", // default "/hatchet"
secret: (env) => env.ORDERS_SIGNING_SECRET, // default env.HATCHET_SIGNING_SECRET
client: (env) => ({ token: env.HATCHET_CLIENT_TOKEN }), // default: built from env
fetch: (request, env, ctx) => app.fetch(request, env, ctx), // everything outside basePath
});| Option | Description |
|---|---|
workflows | The declarations to serve. |
serve | A subset of the declared tasks to serve from this Worker, by declaration or by action id (workflow:task). The rest are still registered but answered 404 here. |
basePath | The path the healthcheck and trigger routes live under. |
secret | The signing secret, or a function of the Worker's env returning it. |
endpointId | The endpoint id, or a function of env returning it. Defaults to env.HATCHET_ENDPOINT_ID. |
client | The Hatchet client for the tasks: a client config, a HatchetCore, or a function of env returning either. Defaults to the client described by HATCHET_CLIENT_TOKEN (plus HATCHET_CLIENT_HOST_PORT and HATCHET_CLIENT_TLS_STRATEGY when set), or none. |
fetch | A handler for every request outside basePath, for a Worker that also serves your app. |
Platform notes
- CPU time. A task is bounded by the Worker's CPU limit per invocation (
limits.cpu_ms, 30 seconds by default on the Paid plan, 300 seconds at most) and by the endpoint's request timeout in Hatchet. A durable task spends its waits evicted, not on the socket, so the limit bounds the work the task does, not how long the run lasts. The Free plan's 10 ms is enough for trivial tasks only. - Access rules. Cloudflare Access, WAF rules and bot protection answer before your code runs. Hatchet's requests carry no browser headers, so an Access policy or a managed challenge on the Worker's hostname turns every healthcheck into a
401or403. Exclude the/hatchet/*paths, or rely on the signature, which the adapter already verifies. - Payload sizes. A task's input arrives in the request and its output is read from the response; both are capped at 4 MiB by Hatchet.
- Durable tasks are supported: the Worker accepts the websocket upgrade natively, and the invocation runs under
ctx.waitUntilso it outlives the101response. - Secrets per isolate. Secrets arrive with each request, which is why the
clientandsecretoptions take a function ofenv.
Last updated on October 27, 2026