Receive webhooks

Last updated 2 November 2026

View .md

Machines are not open yet. These pages describe Machines as they will open; until then, a create answers 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).

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:

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.

Check the signature

Most senders sign each event with an HMAC of its raw body and a secret you share with them; the sender’s documentation names its header and format. Compare with timingSafeEqual, 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).

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()).

server.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 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) 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). 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.