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 = 300000

Secrets

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 token
  • HATCHET_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_TOKEN is an API token for your tenant. The adapter builds the Hatchet client that ctx.runChild, ctx.putStream, ctx.log and ctx.cancel use from it. Tasks declared with hatchetClient: false don'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.dev

Then 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 dev

Hatchet 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.com

Register 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
});
OptionDescription
workflowsThe declarations to serve.
serveA 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.
basePathThe path the healthcheck and trigger routes live under.
secretThe signing secret, or a function of the Worker's env returning it.
endpointIdThe endpoint id, or a function of env returning it. Defaults to env.HATCHET_ENDPOINT_ID.
clientThe 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.
fetchA 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 401 or 403. 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.waitUntil so it outlives the 101 response.
  • Secrets per isolate. Secrets arrive with each request, which is why the client and secret options take a function of env.

Last updated on October 27, 2026

On this page