Webhooks
Incoming messages and delivery updates are sent to your server as signed HTTPS requests. Each request is one event.
Set up
- In the dashboard, Webhooks, add your server's https URL.
- Copy the signing secret shown (
whsec_…). It is shown once. Keep it on your server, for example in an environment variable. - Press Send test event. Your server should answer with a 2xx status.
What you receive
A POST with a JSON body. type is Meta's webhook field, and value is Meta's own payload, untouched, so Meta's webhook docs apply to it.
{
"id": "evt_EXAMPLE_ONLY",
"type": "messages",
"created_at": "2026-11-11T09:30:00.000Z",
"waba_id": "200000000000001",
"phone_number_id": "100000000000001",
"value": {
"messaging_product": "whatsapp",
"metadata": { "display_phone_number": "923000000000", "phone_number_id": "100000000000001" },
"contacts": [{ "profile": { "name": "Ayesha" }, "wa_id": "923000000000" }],
"messages": [{ "from": "923000000000", "id": "wamid.EXAMPLE_ONLY", "timestamp": "1762853400", "type": "text", "text": { "body": "Hi" } }]
}
}messages: messages from customers invalue.messages, and status updates for what you sent (sent, delivered, read, failed) invalue.statuses.- Other types you may see:
account_update,history,smb_message_echoes,smb_app_state_sync, template updates, andtestfrom the dashboard button. - A photo or file from a customer arrives as a media id. Fetch it with
GET /v1/media/{id}/downloadand a live key.
Headers on every request:
Content-Type: application/json
User-Agent: SociallySnap-Webhooks/1.0
X-SociallySnap-Event-Id: evt_EXAMPLE_ONLY
X-SociallySnap-Signature: t=1762853400,v1=5f2b…Verify the signature
Check every request before you trust it:
- Read the raw body, exactly as it arrived. Parsing the JSON and turning it back into text changes the bytes, and the check fails.
- From
X-SociallySnap-Signature, taket(Unix seconds) and everyv1. - Compute HMAC-SHA256 of
t + "." + raw bodywith your secret, as hex. - Accept the request if it matches any
v1, compared in constant time. - Refuse it if
tis more than 5 minutes from your clock. Keep your server's clock synced.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
// rawBody: the exact bytes received, before any JSON parsing.
export function verifySociallySnap(rawBody, header, secret) {
// Without a secret, anyone could sign. Fail loudly instead.
if (typeof secret !== "string" || secret.length < 24) throw new Error("SOCIALLYSNAP_WEBHOOK_SECRET is not set");
if (!header) return false;
let timestamp;
const signatures = [];
for (const part of header.split(",")) {
const [name, value] = part.trim().split("=", 2);
if (name === "t") timestamp = Number(value);
if (name === "v1" && /^[0-9a-f]{64}$/.test(value)) signatures.push(value);
}
if (!Number.isInteger(timestamp) || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
return signatures.some((signature) => timingSafeEqual(expected, Buffer.from(signature, "hex")));
}
// Next.js route handler
export async function POST(request) {
const rawBody = await request.text();
const ok = verifySociallySnap(rawBody, request.headers.get("x-sociallysnap-signature"), process.env.SOCIALLYSNAP_WEBHOOK_SECRET);
if (!ok) return new Response("Invalid signature", { status: 401 });
const event = JSON.parse(rawBody);
// Save event.id first and skip it if you've seen it before, then do slow work later.
return new Response("ok");
}<?php
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_SOCIALLYSNAP_SIGNATURE'] ?? '';
$secret = getenv('SOCIALLYSNAP_WEBHOOK_SECRET');
if (!is_string($secret) || strlen($secret) < 24) {
// Without a secret, anyone could sign. Refuse everything until it is set.
http_response_code(500);
exit('Webhook secret not set');
}
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$name, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($name === 't' && ctype_digit($value)) $timestamp = (int) $value;
if ($name === 'v1' && preg_match('/^[0-9a-f]{64}$/', $value)) $signatures[] = $value;
}
$valid = false;
if ($timestamp !== null && abs(time() - $timestamp) <= 300) {
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) $valid = true;
}
}
if (!$valid) {
http_response_code(401);
exit('Invalid signature');
}
$event = json_decode($rawBody, true);
// Save $event['id'] first and skip it if you've seen it before.
http_response_code(200);
echo 'ok';Answer quickly
- Answer with any 2xx status within 10 seconds. Save the event and do slow work afterwards.
- Redirects aren't followed and count as a failure. Use the final URL.
Retries and duplicates
A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, then every 12 hours, for up to 3 days.
- The same event can arrive more than once. It keeps its
idon every try, so store the ids you've handled and skip repeats. - If your endpoint keeps failing for 5 days, it is paused, and new events aren't kept while it is. Fix your server, then press Resume on the dashboard's Webhooks page.
- Events can arrive out of order. A
readstatus may come beforedelivered; use Meta'stimestamp.
Privacy
Events carry customers' numbers, names and message text. Socially Snap keeps only the metadata. Store what you receive with the same care, and keep it out of logs you share. Message IDs (wamids) contain the customer's number too.
See every endpoint in the API reference.