Receive Webhooks
HookMyApp forwards Meta's payload signed with your VERIFY_TOKEN. Verify the HMAC, acknowledge fast, process async.
The Payload Shape
Meta sends WhatsApp events as nested entry then changes then value then messages JSON. HookMyApp forwards the payload byte-for-byte.
{
"object": "whatsapp_business_account",
"entry": [
{
"id": "1276334778010256",
"changes": [
{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": { "phone_number_id": "1080996501762047" },
"messages": [
{
"from": "15551234567",
"id": "wamid.abc123...",
"timestamp": "1716300000",
"type": "text",
"text": { "body": "hello" }
}
]
}
}
]
}
]
}The Verification GET
When you run hookmyapp webhook set or hookmyapp sandbox listen, HookMyApp calls your URL once with GET /webhook to prove you own it. Respond with the raw VERIFY_TOKEN string and HTTP 200.
app.get('/webhook', (req, res) => {
res.send(process.env.VERIFY_TOKEN);
});Signature Verification
Every POST arrives with X-HookMyApp-Signature-256: sha256=<hex>. Compute HMAC-SHA256 over the raw request body using VERIFY_TOKEN as the key. Compare against the header's hex digest.
The algorithm matches Meta's webhook verification scheme, adapted for the HookMyApp forwarding path. For the underlying scheme see Meta's payload validation docs.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const app = express();
const VERIFY_TOKEN = process.env.VERIFY_TOKEN;
// Capture the raw body. express.json() would re-serialize and
// break the HMAC comparison.
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.get('X-HookMyApp-Signature-256') || '';
const expected = 'sha256=' +
createHmac('sha256', VERIFY_TOKEN)
.update(req.body)
.digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const payload = JSON.parse(req.body.toString('utf8'));
// Process payload.entry[...].changes[...].value.messages[...]
res.json({ status: 'ok' });
},
);Acknowledge Fast
Return 200 immediately. Process asynchronously.
If your handler takes longer than 20 seconds, HookMyApp treats the delivery as failed and retries. Queue work to a background job before responding.
Sandbox Versus Production Delivery
- Sandbox:
hookmyapp sandbox listenopens a Cloudflare tunnel from a HookMyApp-managed hostname tolocalhost:3000/webhookby default. The tunnel lives as long as the CLI process runs. - Production: you set the URL with
hookmyapp webhook set <waba-id> --url <your-public-https-url>. HookMyApp writes it to Meta'soverride_callback_urifield via the Graph API. The URL must be public HTTPS with a valid certificate.
Next Steps
- Webhook Routing: Find and debug your sandbox and production URLs.
- Starter Kit: Skip the boilerplate: clone the reference receiver.