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.
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
- Someone sends a WhatsApp message to your connected number.
- InstaMessage sends an HTTP
POSTwith a JSON body to your webhook URL. -
The request carries an
X-Webhook-Signatureheader: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. - Your server checks the signature, replies
200 OK, and handles the message.
An incoming message looks like this:
{
"event": "message.received",
"instance_id": "YOUR_INSTANCE_ID",
"from": "27821234567",
"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:
{
"event": "message.status",
"instance_id": "YOUR_INSTANCE_ID",
"message_id": "3EB0A1B2C3D4E5F6",
"status": "read",
"to": "27821234567",
"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:
export INSTAMESSAGE_WEBHOOK_SECRET="your-signing-secret"
$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:
<?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';
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:
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"));
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:
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()
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
- Open your instance in the dashboard and find the Webhook section.
- Paste your URL into Webhook URL and click Save.
-
Copy the Signing secret that appears, put it in
INSTAMESSAGE_WEBHOOK_SECRET, and restart your receiver. -
Click Send test webhook. Your receiver gets a
webhook.testevent, and the delivery log on the instance page shows whether your server answered200.
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": {
"status": "stored",
"mime_type": "image/jpeg",
"file_name": null,
"size": 184223,
"url": "https://instamessage.co.za/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
2xxstatus. 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_idyou'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
- See every field, error and limit in the API documentation.
- Compare plans and message limits.