> Fetch the index of every Tonbo docs page as Markdown at https://tonbo.dev/llms.txt

# Receive webhooks

Last updated 2 November 2026

> Machines are not open yet. These pages describe Machines as they will open; until then, a create answers [`region_not_offered`](https://tonbo.dev/docs/machines/troubleshooting/manage.md#region_not_offered) (422), “Machines are not offered in any region yet.”

Receive webhooks on a Machine when a service outside Tonbo must call it, such as a payment provider or a repository host: check each event, record it, do its work once and answer only when that is durable.

The Machine sleeps between deliveries and costs nothing for compute then; a delivery wakes it, and the first one after a sleep waits while it starts (see [While it is asleep](#asleep)).

## Create it with a public port

The sender cannot hold a key of your account, so the port is public, and the program checks each event itself:

```shell
tonbo machine create hooks --image registry.example.com/you/app:1 --shape s-1 --port 8080:public --env WEBHOOK_SECRET=whsec_... --region us-east-1 --wait
```

Give the sender `https://hooks-<account>.tonbo.sh/` as the webhook URL. The image is built as on [Run your own image](https://tonbo.dev/docs/machines/images.md).

## Check the signature

Most senders sign each event with an [HMAC](https://www.rfc-editor.org/rfc/rfc2104.html) of its raw body and a secret you share with them; the sender’s documentation names its header and format. Compare with [timingSafeEqual](https://nodejs.org/docs/latest-v22.x/api/crypto.html#cryptotimingsafeequala-b), and refuse an event that does not match. A Machine has no store for secrets: the signing secret is one of its environment variables, and every member of your account, and every Agent key of it, can read it, and so could forge events. Give each Machine a signing secret of its own, and replace it with the sender if someone who could read it leaves (see [the rule](https://tonbo.dev/docs/machines/rules/access.md#env)).

## Record and sync, do the work, then answer

Record the event in the Machine’s database and await `synced()` before its work starts, then mark the work done and sync again before you answer: a sender that hears `204` never sends the event again, so the event and its outcome must be durable by then (see [Answer after synced()](https://tonbo.dev/docs/machines/database.md#answer-after-synced)).

**server.js**

```js
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { open } from "@tonbo/sqlite-ursula";

// On a Machine the platform always sets these: the database, its cache
// directory and how much may wait to be sent. It also keeps the token that
// opens the database fresh.
const db = await open(process.env.TONBO_DATABASE_URL, {
  dir: process.env.TONBO_VFS_CACHE_DIR,
  maxPendingBytes: Number(process.env.TONBO_VFS_MAX_PENDING_BYTES),
});
db.exec(
  "CREATE TABLE IF NOT EXISTS events(id TEXT PRIMARY KEY, body TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0)",
);
const secret = process.env.WEBHOOK_SECRET;

const server = createServer(async (req, res) => {
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const body = Buffer.concat(chunks);

  // The sender's documentation names its header and format; this is a common one.
  const sent = Buffer.from(req.headers["x-signature"] ?? "", "hex");
  const expected = createHmac("sha256", secret).update(body).digest();
  if (sent.length !== expected.length || !timingSafeEqual(sent, expected)) {
    return res.writeHead(401).end();
  }

  const event = JSON.parse(body.toString("utf8"));
  db.prepare("INSERT INTO events(id, body) VALUES (?, ?) ON CONFLICT(id) DO NOTHING").run(
    event.id,
    body.toString("utf8"),
  );
  await db.synced(); // the event is durable before its work starts

  const { done } = db.prepare("SELECT done FROM events WHERE id = ?").get(event.id);
  if (!done) {
    await handle(event); // pass event.id on as the idempotency key of what it calls
    db.prepare("UPDATE events SET done = 1 WHERE id = ?").run(event.id);
    await db.synced();
  }
  res.writeHead(204).end();
});
server.listen(8080, "0.0.0.0");

process.on("SIGTERM", async () => {
  server.close(); // take no new requests
  await db.synced(); // every commit is durable from here
  db.close();
  process.exit(0);
});
```

## Handle a repeated delivery

Senders deliver an event again when they hear no answer, and some send it twice anyway. The event’s id is the table’s primary key, so a repeated delivery finds its row, and once its work is marked done it is answered `204` without doing it again. If the Machine stops after the work and before the mark is durable, the next delivery does the work again, so pass the event’s id to the services `handle()` calls as their idempotency key, as [Run side effects once](https://tonbo.dev/docs/databases/side-effects.md#ledger) shows.

## While it is asleep

A delivery that wakes the Machine waits while it starts, while its database’s local copy is rebuilt (a [cold start](https://tonbo.dev/docs/machines/database.md#cold-start)) and your program begins to listen, at most 25 seconds; if that is not enough, it is answered `503` with `Retry-After` (see [The first request](https://tonbo.dev/docs/machines/requests.md#first-request)). A sender that gives up sooner, or hears the `503`, counts the delivery as failed and delivers it again later, as it does for any failed delivery, and the code above recognizes the repeat. Read your sender’s retry policy: an event is lost only if the sender stops retrying.

Before you rely on it, check it against [Rules for Machines](https://tonbo.dev/docs/machines/rules.md).
