Skip to content
AstroWay/api v2.325.0 · integrations
all systems operational

WhatsApp bot

Last updated View as Markdown Agent setup

A person sends the bot their sign, for example leo, and gets today’s horoscope back. Unlike Telegram, the WhatsApp Cloud API pushes messages to your webhook, so the server needs a public HTTPS address.

  1. An app on developers.facebook.com of type Business with the WhatsApp product added. The API Setup page has a test number, its Phone number ID and a temporary access token; to run longer than a day, create a permanent system user token.
  2. The App secret from App settings → Basic: Meta signs every webhook delivery with it.
  3. A Server only AstroWay key scoped to public/horoscope/*, as described in A key for an automation. The endpoint spends no credits.
Terminal window
export ASTROWAY_API_KEY="aw_live_..."
export WHATSAPP_TOKEN="EAAG..."
export WHATSAPP_PHONE_NUMBER_ID="1234567890"
export WHATSAPP_APP_SECRET="..."
export WHATSAPP_VERIFY_TOKEN="any-string-you-make-up"

Save it as whatsapp.mjs:

whatsapp.mjs
// Node 22 or newer, no dependencies.
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
const GRAPH = 'https://graph.facebook.com/v23.0';
const ASTROWAY = 'https://api.astroway.info/v1';
const LANG = 'en';
// One text per sign per day: at most 12 calls a day, whatever the traffic.
const cache = new Map();
const SIGNS = ['aries', 'taurus', 'gemini', 'cancer', 'leo', 'virgo',
'libra', 'scorpio', 'sagittarius', 'capricorn', 'aquarius', 'pisces'];
async function answer(text) {
const sign = text.trim().toLowerCase();
if (!SIGNS.includes(sign)) return `Send your sign, for example: leo\n${SIGNS.join(', ')}`;
const key = `${new Date().toISOString().slice(0, 10)} ${sign}`;
if (!cache.has(key)) {
const res = await fetch(`${ASTROWAY}/public/horoscope/daily?sign=${sign}&lang=${LANG}`, {
headers: { 'X-Api-Key': process.env.ASTROWAY_API_KEY },
});
const json = await res.json();
if (!json.ok) return `AstroWay: ${json.error.message}`;
cache.set(key, `${json.data.date}, ${sign}\n\n${json.data.horoscope}`.slice(0, 4000));
}
return cache.get(key);
}
function signedByMeta(raw, header) {
const expected = 'sha256=' + createHmac('sha256', process.env.WHATSAPP_APP_SECRET).update(raw).digest('hex');
return header?.length === expected.length && timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}
async function send(to, body) {
await fetch(`${GRAPH}/${process.env.WHATSAPP_PHONE_NUMBER_ID}/messages`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.WHATSAPP_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ messaging_product: 'whatsapp', to, type: 'text', text: { body } }),
});
}
createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
// Meta checks the webhook once, when you save its URL.
if (req.method === 'GET') {
const ok = url.searchParams.get('hub.mode') === 'subscribe'
&& url.searchParams.get('hub.verify_token') === process.env.WHATSAPP_VERIFY_TOKEN;
res.writeHead(ok ? 200 : 403).end(ok ? url.searchParams.get('hub.challenge') : '');
return;
}
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const raw = Buffer.concat(chunks);
if (!signedByMeta(raw, req.headers['x-hub-signature-256'])) {
res.writeHead(401).end();
return;
}
// Answer Meta first: a slow 200 makes it deliver the same message again.
res.writeHead(200).end();
const messages = JSON.parse(raw).entry?.[0]?.changes?.[0]?.value?.messages ?? [];
for (const message of messages) {
if (message.type !== 'text') continue;
await send(message.from, await answer(message.text.body));
}
}).listen(process.env.PORT ?? 3000);

Run node whatsapp.mjs behind an HTTPS proxy or on a host that gives it a public address.

  1. In the app open WhatsApp → Configuration → Webhook → Edit.
  2. Callback URL: your server’s address, for example https://bot.example.com/webhook. Verify token: the same value as WHATSAPP_VERIFY_TOKEN. Meta sends a GET with hub.challenge straight away, and the code echoes it back.
  3. Under Webhook fields subscribe to messages.
  4. Send leo to the test number from a phone added to the recipient list on the API Setup page.
  • The signature is not optional. The webhook address is public, so without the X-Hub-Signature-256 check anyone could make the bot message people. A request without a valid signature gets 401.
  • Free text only as a reply. The Cloud API allows a free-form message within 24 hours of the person’s last message. Sending a horoscope every morning to people who have not written for a while needs an approved message template.
  • The cache in the code keeps it to at most 12 API calls a day. A Free key on /v1/public/* counts against the same limit as a call with no key, 30 requests an hour from your server’s IP, and a wrong key quietly gets that anonymous limit rather than a 401.
  • The Graph API version in GRAPH can be any current version. Meta supports each for at least two years, so change the line when v23.0 is retired.