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 listen opens a Cloudflare tunnel from a HookMyApp-managed hostname to localhost:3000/webhook by 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's override_callback_uri field 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.