# Cloudflare Workers

`@hatchet-dev/serverless` turns a Cloudflare Worker into a [serverless endpoint](/v1/serverless): 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](/v1/workers), declared with the same `hatchet.task` and `hatchet.durableTask` calls.

You need Node 22, [Wrangler](https://developers.cloudflare.com/workers/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](#platform-notes).

## Install

```sh
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:

```ts
// 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](https://github.com/hatchet-dev/hatchet/blob/main/sdks/typescript-serverless/LIMITATIONS.md).

## Export the Worker

```ts
// 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](#options)).

```toml
# 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.

```sh
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](/v1/quickstart) 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

```sh
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:

```bash
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](/v1/serverless/managing-endpoints) for the fields and the SDK calls.

> **Info:** 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`](/v1/running-your-task) from any SDK, on a [cron](/v1/cron-runs) or from an [event](/v1/events).

## Local development

`wrangler dev` runs the Worker on `http://localhost:8787` and reads secrets from `.dev.vars` (gitignored):

```sh
cat > .dev.vars <
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:

```sh
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](/v1/running-locally) 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

```ts
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 `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`.
