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, keeps each conversation in Postgres, and never holds its API keys.
No code to write?
Automate with n8n does the same with n8n's WhatsApp nodes.
First, add two team secrets on the dashboard's Secrets
page: WHATSAPP_TOKEN for graph.facebook.com, and ANTHROPIC_API_KEY for api.anthropic.com.
Then:
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=…{ "type": "module", "dependencies": { "@anthropic-ai/sdk": "*", "pg": "^8" } }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:
- 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_TOKENfrom the outputs. - Each message arrives as a POST. The bot checks Meta's signature with
APP_SECRET, answers200, then writes its reply. - The conversation is kept in Postgres, so the bot remembers each customer across messages.
WHATSAPP_TOKENandANTHROPIC_API_KEYare 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 to the stack: the webhook adds a job, a worker in the same process sends the reply.
As a stack file
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: publicbriq_up with secrets: { APP_SECRET: "…" }. See 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_SECRETis not the Meta app's secret. 401from Meta or Anthropic: the team secret's hosts do not include the host called, or the secret is missing.briq_secretslists names and hosts.- No reply, nothing in the logs: the reply failed after the
200.briq_logsshows 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 withbriq_destroy({ stack }). - Many businesses in one bot: route on
value.metadata.phone_number_idin the webhook body.