# Run a webhook bot

URL: https://briq.cloud/docs/guides/run-a-webhook-bot

> A WhatsApp or Messenger bot as your own code, with a public HTTPS URL for Meta's webhooks, its memory in Postgres and its API keys held by the credential gateway.

A chat bot is a small server: Meta sends each message to a webhook, the bot answers through Meta's
API. Here it runs on the [node recipe](https://briq.cloud/cookbook/node), keeps each conversation in
[Postgres](https://briq.cloud/cookbook/postgres), and never holds its API keys.

<Callout title="No code to write?">
  [Automate with n8n](https://briq.cloud/docs/guides/automate-with-n8n) does the same with n8n's WhatsApp nodes.
</Callout>

First, add two [team secrets](https://briq.cloud/docs/concepts/secrets#team-secrets) on the dashboard's **Secrets**
page: `WHATSAPP_TOKEN` for `graph.facebook.com`, and `ANTHROPIC_API_KEY` for `api.anthropic.com`.
Then:

```text
briq_run({ recipe: "postgres", stack: "bot" })

briq_run({
  recipe: "node",
  stack: "bot",
  files: { "server.js": "<below>", "package.json": "<below>" },
  env: {
    DATABASE_URL: "postgres://postgres:{{secret:POSTGRES_PASSWORD}}@{{briq:db}}:5432/app",
    PHONE_NUMBER_ID: "1234567890",
    VERIFY_TOKEN: { secret: "VERIFY_TOKEN" },
    APP_SECRET: { secret: "APP_SECRET", value: "…" },
    WHATSAPP_TOKEN: { secret: "WHATSAPP_TOKEN", scope: "team" },
    ANTHROPIC_API_KEY: { secret: "ANTHROPIC_API_KEY", scope: "team" }
  },
  expose: "public"
})
→ … url: https://app-m4qa.briq.run/ · outputs: VERIFY_TOKEN=…
```

```json
{ "type": "module", "dependencies": { "@anthropic-ai/sdk": "*", "pg": "^8" } }
```

```js
import crypto from 'node:crypto';
import http from 'node:http';
import Anthropic from '@anthropic-ai/sdk';
import pg from 'pg';

const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
await db.query('create table if not exists messages (id serial primary key, phone text, role text, text text)');
const claude = new Anthropic();
const graph = `https://graph.facebook.com/v23.0/${process.env.PHONE_NUMBER_ID}/messages`;

http.createServer(async (req, res) => {
  const url = new URL(req.url, 'http://bot');
  if (req.method === 'GET') {
    // Meta checks the URL once, when you save it in the app's settings.
    const ok = url.searchParams.get('hub.verify_token') === process.env.VERIFY_TOKEN;
    return res.writeHead(ok ? 200 : 403).end(ok ? url.searchParams.get('hub.challenge') : '');
  }
  let body = '';
  for await (const chunk of req) body += chunk;
  const signature = 'sha256=' + crypto.createHmac('sha256', process.env.APP_SECRET).update(body).digest('hex');
  if (req.headers['x-hub-signature-256'] !== signature) return res.writeHead(401).end();
  res.writeHead(200).end(); // answer at once: Meta sends again what it does not see acknowledged
  const msg = JSON.parse(body).entry?.[0]?.changes?.[0]?.value?.messages?.[0];
  if (msg?.type === 'text') await reply(msg.from, msg.text.body).catch(console.error);
}).listen(Number(process.env.PORT ?? 8080), '::');

async function reply(phone, text) {
  await db.query('insert into messages (phone, role, text) values ($1, $2, $3)', [phone, 'user', text]);
  const { rows } = await db.query('select role, text from messages where phone = $1 order by id desc limit 20', [phone]);
  const history = rows.reverse().map((m) => ({ role: m.role, content: m.text }));
  while (history[0]?.role !== 'user') history.shift();
  const answer = await claude.messages.create({
    model: 'claude-opus-5-5',
    max_tokens: 1000,
    system: 'You answer the customers of a small shop on WhatsApp. Be brief.',
    messages: history,
  });
  const out = answer.content.find((b) => b.type === 'text')?.text ?? '';
  await db.query('insert into messages (phone, role, text) values ($1, $2, $3)', [phone, 'assistant', out]);
  await fetch(graph, {
    method: 'POST',
    headers: { authorization: `Bearer ${process.env.WHATSAPP_TOKEN}`, 'content-type': 'application/json' },
    body: JSON.stringify({ messaging_product: 'whatsapp', to: phone, type: 'text', text: { body: out } }),
  });
}
```

What happens:

1. The bot gets a public URL. In the Meta app's WhatsApp settings, set the callback URL to it and
   the verify token to `VERIFY_TOKEN` from the outputs.
2. Each message arrives as a POST. The bot checks Meta's signature with `APP_SECRET`, answers
   `200`, then writes its reply.
3. The conversation is kept in Postgres, so the bot remembers each customer across messages.
4. `WHATSAPP_TOKEN` and `ANTHROPIC_API_KEY` are placeholders in the bot's environment. The team's
   credential gateway puts the real values into requests to Meta and Anthropic only.

| Variable                              | Why it is shaped this way                                                                |
| ------------------------------------- | ---------------------------------------------------------------------------------------- |
| `VERIFY_TOKEN`                        | Generated by briq. You paste it into Meta's settings                                     |
| `APP_SECRET`                          | A secret of the briq: the code computes a signature with it, which the gateway cannot do |
| `WHATSAPP_TOKEN`, `ANTHROPIC_API_KEY` | Team secrets: the code sends them and never holds them                                   |

## Messenger

The same server, with two changes: Meta sends Messenger events with `entry[0].messaging[0]`, and
the bot replies with `POST https://graph.facebook.com/v23.0/me/messages`, using the Page's access
token as a team secret.

## Slow work in a queue

A reply that takes more than a few seconds belongs in a job queue. Add
[redis-bullmq](https://briq.cloud/cookbook/redis-bullmq) to the stack: the webhook adds a job, a worker in the same
process sends the reply.

## As a stack file

```yaml
stack: bot
services:
  db: { recipe: postgres }
  app:
    recipe: node
    files:
      server.js: "…"
      package.json: "…"
    env:
      DATABASE_URL: postgres://postgres:{{secret:POSTGRES_PASSWORD}}@{{briq:db}}:5432/app
      PHONE_NUMBER_ID: "1234567890"
      VERIFY_TOKEN: { secret: VERIFY_TOKEN }
      APP_SECRET: { secret: APP_SECRET }
      WHATSAPP_TOKEN: { secret: WHATSAPP_TOKEN, scope: team }
      ANTHROPIC_API_KEY: { secret: ANTHROPIC_API_KEY, scope: team }
    expose: public
```

`briq_up` with `secrets: { APP_SECRET: "…" }`. See [Stack file](https://briq.cloud/docs/stack-file).

## When it does not work

* **Meta says the callback URL could not be validated**: the verify token differs, or the briq was
  run without `expose: "public"`. A private URL's token cannot be sent by Meta.
* **Every POST gets `401`**: `APP_SECRET` is not the Meta app's secret.
* **`401` from Meta or Anthropic**: the team secret's hosts do not include the host called, or the
  secret is missing. `briq_secrets` lists names and hosts.
* **No reply, nothing in the logs**: the reply failed after the `200`. `briq_logs` shows the
  error printed by `.catch(console.error)`.

## Good to know

* **The bot can sleep.** Each webhook is a visit to its URL: it wakes the bot and keeps it awake.
* **A visit also wakes the stack**, so Postgres is up when the bot needs it.
* **One bot per business**: one stack each (`stack: "bot-bakery"`), destroyed with
  `briq_destroy({ stack })`.
* **Many businesses in one bot**: route on `value.metadata.phone_number_id` in the webhook body.
