# Webhooks

Add a webhook in Settings, check each notice's signature, answer in time, and catch up after one was missed.

A webhook tells your server when a task starts working or stops, so your program does not have to keep asking. You add your server's address in Settings. When a task changes, Little Worker posts a small signed notice to that address. The notice names the task and carries no task data, so your server reads the task with its API key to learn what happened.

This guide builds a small server that checks each notice, answers it, reads the task, and catches up if it missed any.

## Before you start

- A key in `LITTLE_WORKER_API_KEY`. [API keys](/get-started/api-keys) shows how to make one.
- A public `https://` address that reaches your server. Little Worker refuses an address that leads inside a private network, so to try the server on your own computer, use a tool that gives it a public `https://` address.
- For curl: curl and jq. A shell script is not a good place to receive notices, so the curl tabs show the checks and reads that go with the server.
- For TypeScript: Node 22.18 or later, which runs a `.ts` file as it is. Save the program as `webhooks.ts` and run `node webhooks.ts`.
- For Python: Python 3.9 or later and the requests library (`pip install requests`). Save the program as `webhooks.py` and run `python3 webhooks.py`.

## 1. Add the webhook

Open **Settings**, then **Webhooks**, and choose **Add Webhook**.

1. In **Address**, enter the `https://` address of your server, such as `https://example.com/little-worker`. It may be up to 2,048 bytes. An address that cannot be reached from the internet is refused when you add it.
2. In **Description**, add a note for yourself if you like, up to 200 characters.
3. Under **Send a Notice When**, choose **A task starts working**, **A task stops working**, or both. A task stops when it is done, needs you, or reaches its limit.
4. Choose **Add**. **Copy Your Signing Key** shows a key that starts with `whsec_`. Copy it.

The signing key is shown once. Your server uses it to check that each notice came from Little Worker. Keep it in your server's secrets, in `LITTLE_WORKER_SIGNING_KEY`:

```bash
export LITTLE_WORKER_SIGNING_KEY="whsec_..."
```

A workspace holds up to 10 webhooks. In the list, each webhook has a **Send Notices** switch, and **Details** shows its recent notices and has **Send Test Notice**, which posts a notice that is not about a task.

## 2. Receive a notice and check its signature

Every notice is a `POST` with a JSON body and these headers:

| Header | What it holds |
| --- | --- |
| `webhook-id` | The notice's id. It is the same on every try of the same notice. |
| `webhook-timestamp` | When this try was signed, in seconds since 1970. Each try has a new one. |
| `webhook-signature` | `v1,` and a base64 signature. More than one may come, separated by spaces. |

The body looks like this. `data.type` says what happened, and `data.task_id` names the task:

```json
{
  "type": "event",
  "id": "event_9d5c1f7e0b3a4c2d8e6f1a2b3c4d5e6f",
  "created_at": "2026-10-25T22:07:46.123Z",
  "data": {
    "type": "task.status_idle",
    "id": "task_01M5TTRJKWYAK3XHFX432K5MBV",
    "workspace_id": "wrk_01M5TTRH6V3N8QZ4YD7C2BKXPA",
    "task_id": "task_01M5TTRJKWYAK3XHFX432K5MBV",
    "sequence": "11"
  }
}
```

| `data.type` | When it is sent |
| --- | --- |
| `task.status_running` | A task begins running: its work was accepted, or it went on after it had stopped. |
| `task.status_idle` | The worker stops: it is done, it failed, you stopped it, it is out of budget, it reached a run limit, or it waits for you. |
| `webhook.test` | You chose **Send Test Notice**. It names no task. |

`sequence` is the number of the event that caused the notice, the same one the task's events carry.

Notices are signed by the [Standard Webhooks](https://www.standardwebhooks.com/) scheme, so any library for it checks them. By hand, the signature is an HMAC-SHA256, in base64, of the three parts `webhook-id`, `webhook-timestamp` and the raw body, joined by dots. The key is what follows `whsec_` in your signing key, decoded from base64. Check the raw body exactly as it came, before you parse it, and refuse a notice signed more than 5 minutes ago.

```bash title="curl"
# A post with no signature must be refused: this answers 401.
curl -i -X POST http://127.0.0.1:8080 \
  -H "content-type: application/json" \
  -d '{"type":"event"}'
```

```ts title="TypeScript"
import { createHmac, timingSafeEqual } from "node:crypto"
import { createServer, type IncomingHttpHeaders } from "node:http"

const api = "https://core.thelittleworker.com/api/v1"
const headers = {
  "x-api-key": process.env.LITTLE_WORKER_API_KEY ?? "",
  "Little-Worker-Version": "2026-10-19",
}
const signingKey = process.env.LITTLE_WORKER_SIGNING_KEY ?? ""

type Notice = {
  type: string
  id: string
  created_at: string
  data: { type: string; task_id?: string; sequence?: string }
}

function verified(received: IncomingHttpHeaders, body: string): boolean {
  const id = String(received["webhook-id"] ?? "")
  const timestamp = String(received["webhook-timestamp"] ?? "")
  // Refuse a notice signed more than 5 minutes ago.
  if (!(Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300)) return false
  const secret = Buffer.from(signingKey.replace(/^whsec_/, ""), "base64")
  const expected = createHmac("sha256", secret)
    .update(`${id}.${timestamp}.${body}`)
    .digest()
  return String(received["webhook-signature"] ?? "")
    .split(" ")
    .some((part) => {
      const given = Buffer.from(part.replace(/^v1,/, ""), "base64")
      return given.length === expected.length && timingSafeEqual(given, expected)
    })
}

async function handle(notice: Notice) {
  console.log(notice.data.type, notice.data.task_id ?? "")
}

const handled = new Set<string>()

createServer((request, response) => {
  const chunks: Buffer[] = []
  request.on("data", (chunk: Buffer) => chunks.push(chunk))
  request.on("end", () => {
    const body = Buffer.concat(chunks).toString("utf8")
    if (!verified(request.headers, body)) {
      response.writeHead(401).end()
      return
    }
    // Answer first. The work comes after, so the answer is never late.
    response.writeHead(200).end()
    const id = String(request.headers["webhook-id"])
    if (handled.has(id)) return
    handled.add(id)
    handle(JSON.parse(body)).catch(console.error)
  })
}).listen(8080)
console.log("Listening on http://127.0.0.1:8080")
```

```python title="Python"
import base64
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

import requests

api = "https://core.thelittleworker.com/api/v1"
headers = {
    "x-api-key": os.environ["LITTLE_WORKER_API_KEY"],
    "Little-Worker-Version": "2026-10-19",
}
secret = base64.b64decode(os.environ["LITTLE_WORKER_SIGNING_KEY"].removeprefix("whsec_"))


def verified(received, body):
    message_id = received.get("webhook-id", "")
    timestamp = received.get("webhook-timestamp", "")
    # Refuse a notice signed more than 5 minutes ago.
    try:
        if abs(time.time() - int(timestamp)) > 300:
            return False
    except ValueError:
        return False
    signed = f"{message_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(secret, signed, hashlib.sha256).digest())
    return any(
        hmac.compare_digest(part.removeprefix("v1,").encode(), expected)
        for part in received.get("webhook-signature", "").split(" ")
    )


def handle(notice):
    print(notice["data"]["type"], notice["data"].get("task_id", ""))


handled = set()


class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("content-length", 0)))
        if not verified(self.headers, body):
            self.send_response(401)
            self.send_header("content-length", "0")
            self.end_headers()
            return
        # Answer first. The work comes after, so the answer is never late.
        self.send_response(200)
        self.send_header("content-length", "0")
        self.end_headers()
        message_id = self.headers["webhook-id"]
        if message_id in handled:
            return
        handled.add(message_id)
        handle(json.loads(body))


print("Listening on http://127.0.0.1:8080")
ThreadingHTTPServer(("127.0.0.1", 8080), Receiver).serve_forever()
```

Start the program. In Settings, open the webhook's **Details** and choose **Send Test Notice**. The program prints `webhook.test`, and **Recent Notices** shows the notice as delivered. Your own post with no signature, as in the curl tab, is refused with `401`.

## 3. Read what changed

A notice says that a task changed, not how. Read the task, and its events ([List events](/api/events#list-events)) if you need more. Replace the `handle` function, in the same place in the file, with these two functions. They read the task when it stops:

```bash title="curl"
# The task named by the notice:
curl -s https://core.thelittleworker.com/api/v1/tasks/$TASK_ID \
  -H "x-api-key: $LITTLE_WORKER_API_KEY" \
  -H "Little-Worker-Version: 2026-10-19" \
  | jq '{id, state, stop_reason, result_summary}'

# The event that caused the notice: send its sequence, minus one, as after.
curl -s "https://core.thelittleworker.com/api/v1/tasks/$TASK_ID/events?after=10&limit=1" \
  -H "x-api-key: $LITTLE_WORKER_API_KEY" \
  -H "Little-Worker-Version: 2026-10-19" \
  | jq '.data[0]'
```

```ts title="TypeScript"
type Task = {
  id: string
  status: string
  state: { status: string; needs: string | null }
  stop_reason: { type: string } | null
  result_summary: string | null
}

function report(task: Task) {
  console.log(
    task.id,
    task.state.status,
    task.state.needs ?? "",
    task.stop_reason?.type ?? "",
    task.result_summary ?? "",
  )
}

async function handle(notice: Notice) {
  const { type, task_id: taskId } = notice.data
  if (type !== "task.status_idle" || taskId === undefined) return
  const response = await fetch(`${api}/tasks/${taskId}`, { headers })
  // A task that was taken back or deleted is gone, and reading it is a 404.
  if (response.ok) report((await response.json()) as Task)
}
```

```python title="Python"
def report(task):
    print(
        task["id"],
        task["state"]["status"],
        task["state"]["needs"] or "",
        (task["stop_reason"] or {}).get("type", ""),
        task["result_summary"] or "",
    )


def handle(notice):
    data = notice["data"]
    if data["type"] != "task.status_idle" or "task_id" not in data:
        return
    response = requests.get(f"{api}/tasks/{data['task_id']}", headers=headers)
    # A task that was taken back or deleted is gone, and reading it is a 404.
    if response.ok:
        report(response.json())
```

The task's `state.status` says whether the work is `done`, or `needs_you` and why in `state.needs`. [Follow a task as it works](/guides/follow-a-task) says what each `stop_reason` means.

## 4. Answer in time

Answer a notice with any `2xx` status within 10 seconds. A slower answer, or any other status, counts as a failure, and Little Worker tries the notice again. Reply first and do the work after, as the server above does, so a slow task read never makes an answer late.

| Try | When it is made |
| --- | --- |
| 1 | When the notice is ready. |
| 2 to 5 | After about 10 seconds, 1 minute, 10 minutes and 1 hour. Each wait may be up to 20 percent longer or shorter. |

After the fifth failure Little Worker stops trying that notice. Every try carries the same `webhook-id`, so keep the ids you handled and drop a repeat, as the server does with `handled`. A notice that reaches you twice is normal. A set in memory is enough for a small program, and a database keeps ids across restarts.

Do not rely on the order. Notices are sent side by side and a retry arrives late, so a `task.status_running` can arrive after the `task.status_idle` that followed it. Read the task to learn where it stands now.

## 5. Catch up after a missed notice

A notice can be missed: your server was down, an answer was late for every try, or the webhook was off. Nothing is queued for a webhook that is off, and turning it on again does not send what it missed. So have your program look, now and then, and after every restart.

List the idle tasks made since a time you choose, and run each one through the same code as a notice. Pick a time earlier than your longest task, because the list goes by when a task was made:

```bash title="curl"
curl -s -g "https://core.thelittleworker.com/api/v1/tasks?statuses[]=idle&created_at[gte]=2026-10-24T00:00:00Z&limit=100" \
  -H "x-api-key: $LITTLE_WORKER_API_KEY" \
  -H "Little-Worker-Version: 2026-10-19" \
  | jq '.data[] | {id, state, stop_reason}'
```

```ts title="TypeScript"
async function catchUp(since: string) {
  const url = `${api}/tasks?statuses[]=idle&created_at[gte]=${encodeURIComponent(since)}&limit=100`
  const response = await fetch(url, { headers })
  const list = (await response.json()) as { data: Task[] }
  for (const task of list.data) report(task)
}

await catchUp(new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString())
```

```python title="Python"
def catch_up(since):
    tasks = requests.get(
        f"{api}/tasks",
        headers=headers,
        params={"statuses[]": "idle", "created_at[gte]": since, "limit": 100},
    ).json()
    for task in tasks["data"]:
        report(task)


catch_up(time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - 24 * 60 * 60)))
```

Run it once when the program starts. In Python, put the call above the line that starts the server, since that line does not return. One page holds up to 100 tasks. `next_page` is the `page` to send for more. For a single task, list its events with `after` set to the last `sequence` you handled, and you get everything since.

## When Little Worker turns a webhook off

A webhook that cannot be reached is turned off, so your server is not hammered. Settings shows **Off**, and for the first three causes below it says why. Fix the cause, then turn the **Send Notices** switch back on.

| Cause | What to do |
| --- | --- |
| Your server answered with a redirect. Little Worker never follows one. | Use the address that answers directly. |
| The address leads inside a private network. | Use a public `https://` address. |
| Every notice failed for 24 hours. One `2xx` answer resets that clock. | Fix your server. |
| The person who added the webhook left the workspace. They saw its signing key, so it cannot be turned on again. | Add a new webhook. |

## Next steps

- [Follow a task as it works](/guides/follow-a-task): stream a task's events when you want every step, not only its start and stop.
- [Answer approvals from your program](/guides/answer-approvals): answer a task that a `task.status_idle` notice says waits for you.
- [Review finished tasks](/guides/review-finished-tasks): check each task's result when its notice arrives.
- [Tasks](/concepts/tasks): what each `state` and `stop_reason` of the task you read means.
