Webhooks tutorial

Receive WhatsApp Messages with a Webhook

Get incoming WhatsApp messages and delivery updates on your server: a verified webhook receiver in PHP, Node.js and Python, with retries explained.

Updated 6 October 2026 · by BriskBrain Technologies

Sending is only half of a conversation. With a webhook, InstaMessage calls a URL on your server the moment a WhatsApp message arrives on your number — text, photos, voice notes, documents — and again whenever a message you sent is delivered or read. No polling, no delay.

This guide builds a small, secure receiver in PHP, Node.js and Python, using only what comes built in. Pick the one you use.

How it works

  1. Someone sends a WhatsApp message to your connected number.
  2. InstaMessage sends an HTTP POST with a JSON body to your webhook URL.
  3. The request carries an X-Webhook-Signature header: sha256= followed by an HMAC-SHA256 of the raw body, made with your instance's signing secret. Checking it proves the request really came from InstaMessage.
  4. Your server checks the signature, replies 200 OK, and handles the message.

An incoming message looks like this:

message.received · JSON
{
    "event": "message.received",
    "instance_id": "YOUR_INSTANCE_ID",
    "from": "919876543210",
    "type": "text",
    "message": "Hi, is the blue one still in stock?",
    "message_id": "3EB0A1B2C3D4E5F6",
    "timestamp": "2026-10-06T10:15:03.000Z",
    "media": null
}

And when a message you sent is delivered or read:

message.status · JSON
{
    "event": "message.status",
    "instance_id": "YOUR_INSTANCE_ID",
    "message_id": "3EB0A1B2C3D4E5F6",
    "status": "read",
    "to": "919876543210",
    "timestamp": "2026-10-06T10:17:41+00:00"
}

type can be text, image, video, voice, audio, document, sticker, location, contact or unsupported. For photos, videos, audio and documents, message is the caption and media holds the file details (see media files below).

Before you start

  • A InstaMessage account with a connected instance. Sign up free if you don't have one yet.
  • Somewhere to run the receiver. To try it on your own computer first, keep reading — step 2 shows how to make it reachable.

Every example reads the signing secret from an environment variable, so it never ends up in your code:

Terminal (macOS / Linux)
export INSTAMESSAGE_WEBHOOK_SECRET="your-signing-secret"
PowerShell (Windows)
$env:INSTAMESSAGE_WEBHOOK_SECRET = "your-signing-secret"

You'll copy the real secret from your instance page in step 3.

Step 1: Write the receiver

All three versions do the same four things: read the raw body, check the signature in constant time, reply 200 straight away, then handle the event.

PHP

Save as webhook.php. It works on any PHP host, or locally with PHP's built-in server:

webhook.php
<?php

$secret = getenv('INSTAMESSAGE_WEBHOOK_SECRET');

// The signature is calculated over the exact bytes sent, so read the raw body.
$body = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $body, $secret);
$received = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

if (! hash_equals($expected, $received)) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode($body, true);

switch ($event['event'] ?? '') {
    case 'message.received':
        error_log("New {$event['type']} from {$event['from']}: {$event['message']}");
        break;

    case 'message.status':
        error_log("Message {$event['message_id']} is now {$event['status']}");
        break;
}

echo 'OK';
Terminal
php -S localhost:8080 webhook.php

Using Laravel? Put the same logic in a controller, read the body with $request->getContent(), and leave the route out of CSRF checks: in bootstrap/app.php add $middleware->validateCsrfTokens(except: ['whatsapp/webhook']);

Node.js

Save as webhook.mjs. It uses only Node's built-in modules, so there's nothing to install:

webhook.mjs
import crypto from "node:crypto";
import http from "node:http";

const SECRET = process.env.INSTAMESSAGE_WEBHOOK_SECRET;

function isGenuine(body, signature) {
  const expected = Buffer.from("sha256=" + crypto.createHmac("sha256", SECRET).update(body).digest("hex"));
  const received = Buffer.from(signature ?? "");
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

http.createServer((req, res) => {
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    const body = Buffer.concat(chunks); // the raw bytes, exactly as sent

    if (req.method !== "POST" || !isGenuine(body, req.headers["x-webhook-signature"])) {
      res.writeHead(401).end("Invalid signature");
      return;
    }

    res.writeHead(200).end("OK"); // answer fast, then do the work

    const event = JSON.parse(body.toString("utf8"));
    if (event.event === "message.received") {
      console.log(`New ${event.type} from ${event.from}: ${event.message}`);
    } else if (event.event === "message.status") {
      console.log(`Message ${event.message_id} is now ${event.status}`);
    }
  });
}).listen(8080, () => console.log("Waiting for webhooks on http://localhost:8080"));
Terminal
node webhook.mjs

Using Express? Read the body with express.raw({ type: "application/json" }) on this route, not express.json() — the signature needs the original bytes.

Python

Save as webhook.py. It uses only Python's standard library:

webhook.py
import hashlib
import hmac
import json
import os
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ["INSTAMESSAGE_WEBHOOK_SECRET"].encode()

# WhatsApp messages are full of emoji; this stops print() failing on them on Windows.
sys.stdout.reconfigure(encoding="utf-8", errors="replace")


class WebhookHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        # The raw bytes, exactly as sent: the signature is calculated over them.
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))

        expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
        received = self.headers.get("X-Webhook-Signature", "")
        if not hmac.compare_digest(expected.encode(), received.encode()):
            self.send_response(401)
            self.end_headers()
            return

        self.send_response(200)  # answer fast, then do the work
        self.end_headers()
        self.wfile.write(b"OK")

        event = json.loads(body)
        if event["event"] == "message.received":
            print(f"New {event['type']} from {event['from']}: {event['message']}")
        elif event["event"] == "message.status":
            print(f"Message {event['message_id']} is now {event['status']}")


print("Waiting for webhooks on http://localhost:8080")
HTTPServer(("", 8080), WebhookHandler).serve_forever()
Terminal
python webhook.py

Using Flask or Django? Read the body with request.get_data() (Flask) or request.body (Django) and check the signature exactly the same way.

Step 2: Make it reachable

InstaMessage has to reach your receiver over the internet, so the URL must be public — use https://. On a real server, that's simply your domain, for example https://your-app.example.com/whatsapp/webhook.

To test on your own computer, use a tunnelling tool such as ngrok or Cloudflare Tunnel: it gives you a temporary public https address that forwards to localhost:8080. Use the final address directly: redirects are not followed.

Step 3: Connect it to your instance

  1. Open your instance in the dashboard and find the Webhook section.
  2. Paste your URL into Webhook URL and click Save.
  3. Copy the Signing secret that appears, put it in INSTAMESSAGE_WEBHOOK_SECRET, and restart your receiver.
  4. Click Send test webhook. Your receiver gets a webhook.test event, and the delivery log on the instance page shows whether your server answered 200.

Then send a WhatsApp message to your connected number from another phone — it appears in your receiver's output.

Media files

For photos, videos, voice notes and documents, the event includes a media object:

media · JSON
"media": {
    "status": "stored",
    "mime_type": "image/jpeg",
    "file_name": null,
    "size": 184223,
    "url": "https://www.instamessage.in/media/123?expires=...&signature=..."
}

url is a download link that works for 24 hours with no login, so download the file straight away if you want to keep it. It's only set when status is stored; it's too_large for files over 100 MB and failed if the download from WhatsApp didn't work. file_name is only set for documents.

Delivery rules to build for

  • Answer within 5 seconds with any 2xx status. Do slow work (database writes to other systems, sending replies) after answering, or in a background job.
  • Failed deliveries are retried up to 5 times: after 10 seconds, 1 minute, 5 minutes, 15 minutes and 30 minutes. A timeout, an error status or a redirect counts as a failure.
  • Expect the occasional duplicate. A retry can deliver an event you already handled, so skip any message_id you've seen before.
  • Reject anything with a wrong signature, as the examples do. Never trust the body before the check.

Reply automatically

To answer a message from your code, call the send API with the sender's from number as to — see the PHP, Python or Node.js guide. For simple keyword answers like prices and opening hours, you don't need code at all: switch on the built-in Chatbot in your dashboard.

Protect your WhatsApp number

WhatsApp can restrict or ban a number that looks like it's sending spam. Only message people who expect to hear from you, keep your volume steady, and avoid sending the same text to large lists. Read the full do's and don'ts before you go live.

Next steps

More guides