briqbriq
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, 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:

  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.
VariableWhy it is shaped this way
VERIFY_TOKENGenerated by briq. You paste it into Meta's settings
APP_SECRETA secret of the briq: the code computes a signature with it, which the gateway cannot do
WHATSAPP_TOKEN, ANTHROPIC_API_KEYTeam 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: public

briq_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_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.

On this page

Free to try

Let your agent try it, on us.

Every new account starts with €10 of credits. No card.

Connect your agent

How can we help?

Message to the briq team
hello@briq.cloud