Receive WhatsApp replies in your app: a signed webhook example
Build a Next.js webhook endpoint that receives WhatsApp replies, verifies the signature in constant time, ignores repeated deliveries, answers within 5 seconds, replies inside the 24-hour window and honours STOP.
October 17, 2026 · 8 min read
Sending a WhatsApp message is one POST. The part that takes care is the other direction: a customer replies, and your app has to notice, trust the request, and answer without doing anything twice. This guide builds that receiving side in Next.js (App Router), with the checks a production endpoint needs.
SendFromChat is an independent product and is not affiliated with Meta or WhatsApp. It runs on Meta's official WhatsApp Cloud API, and you keep your own WhatsApp Business account. The event names, headers and limits below come from our REST API reference, read on 17 Oct 2026.
Not run against a live API key. The code is written to match the documented API, and the signature function was tested locally against a signature we generated ourselves. We did not send or receive real WhatsApp traffic with this exact handler. Use a test key first, and adapt names such as markSeen, saveInboundMessage and removeFromMarketingLists, which stand in for your own storage.
What you need first
- A SendFromChat account (30-day free trial, no card) with a WhatsApp number connected (getting started).
- A public HTTPS URL for your endpoint. While building, a tunnel to your laptop works.
- A live key for sending replies. A test key stops before sending, so it checks your reply code without sending anything (REST API).
1. Register the endpoint
On the dashboard's Developers page, add your URL, or call the API with a live key:
POST https://sendfromchat.com/api/v1/webhook-endpoints
{ "url": "https://app.example.com/api/webhooks/sendfromchat",
"events": ["message.received", "message.status", "contact.opted_out"] }
The response includes a signing secret, shown once. Save it as SENDFROMCHAT_WEBHOOK_SECRET in your server's environment. Never put it in browser code.
The events used here:
| Event | When |
|---|---|
message.received |
A customer wrote in |
message.status |
A message you sent changed status (sent, delivered, read, failed) |
contact.opted_out |
Someone replied STOP, or was opted out |
Every request body looks like { "id": "evt_…", "type": "…", "created": "…", "data": { … } }.
2. Verify the signature, on the raw body
Each request carries a header like SendFromChat-Signature: t=1790300000,v1=5f2b…. v1 is the hex HMAC-SHA256 of <t>.<raw request body>, keyed with your endpoint's secret. Three rules matter:
- Use the raw body. If you parse the JSON first and stringify it again, spacing or key order can change and a valid signature fails. In a Next.js route handler, read the body with
await request.text()and parse it yourself afterwards (route handler docs). - Compare in constant time. A plain
===on secrets can leak timing. Usecrypto.timingSafeEqual, after checking the lengths match (it throws on different lengths). - Reject old timestamps. Dropping anything older than five minutes stops someone replaying a captured request.
// lib/verify-sendfromchat.ts
import crypto from "node:crypto";
export function verifySendFromChat(
rawBody: string,
header: string | null,
secret: string,
): boolean {
if (!header) return false;
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(t + "." + rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
This is the same function as in the REST API reference, in TypeScript. Return 401 when it fails and do nothing else with the request.
3. Answer fast, then do the work
Answer with any 2xx within 5 seconds. If you don't, the delivery counts as failed and is retried with backoff for about a day. So the endpoint should check, record, acknowledge, and push anything slow (your database writes beyond the first, an AI call, a reply) to after the response.
In Next.js, after from next/server runs code once the response has been sent (after docs, read on 17 Oct 2026 for Next.js 16.3). On other hosts, put a job on a queue instead.
// app/api/webhooks/sendfromchat/route.ts
import { after } from "next/server";
import { verifySendFromChat } from "@/lib/verify-sendfromchat";
export async function POST(request: Request) {
const rawBody = await request.text(); // raw, before any JSON.parse
const ok = verifySendFromChat(
rawBody,
request.headers.get("SendFromChat-Signature"),
process.env.SENDFROMCHAT_WEBHOOK_SECRET!,
);
if (!ok) return new Response("invalid signature", { status: 401 });
const event = JSON.parse(rawBody);
// Deliveries can repeat: skip event ids you've already handled.
if (!(await markSeen(event.id))) return new Response("ok");
after(() => handleEvent(event).catch((err) => console.error("webhook", event.id, err)));
return new Response("ok"); // 200 well inside 5 seconds
}
4. Don't handle the same event twice
Deliveries can repeat, and retries after a slow answer repeat them too. Use the event id (evt_…) as your dedupe key. The safest version is one atomic write, not "check, then insert":
// Stand-in for your database. With Postgres, for example:
// INSERT INTO webhook_events (id) VALUES ($1) ON CONFLICT DO NOTHING
// and treat "0 rows inserted" as "already seen".
async function markSeen(eventId: string): Promise<boolean> {
const inserted = await db.insertIgnore("webhook_events", { id: eventId });
return inserted === 1;
}
One trade-off: if you mark an event as seen before handleEvent succeeds and the job then fails, a retry will be skipped. For work that must not be lost, write the event to a queue table first and process it from there.
5. Handle the events
// lib/handle-event.ts
type SfcEvent = { id: string; type: string; data: Record<string, any> };
async function handleEvent(event: SfcEvent) {
switch (event.type) {
case "message.received": {
const { from, text, conversationId } = event.data;
await saveInboundMessage({ eventId: event.id, from, text, conversationId });
if (typeof text === "string" && text.trim().toLowerCase() === "status") {
await replyWithinWindow(event.id, from, "Thanks — your order is on its way. We'll message you when it arrives.");
}
break;
}
case "message.status": {
// data: messageId, to, status, error, whatsappAccountId, at
await updateDeliveryStatus(event.data.messageId, event.data.status, event.data.error);
break;
}
case "contact.opted_out": {
// data: phone, at
await removeFromMarketingLists(event.data.phone, event.data.at);
break;
}
}
}
message.received carries messageId, from, name, type, text, receivedAt, whatsappAccountId and conversationId. text is only there for text messages, so check type before you rely on it. Images, voice notes and buttons arrive as other types.
6. Reply inside the 24-hour window
When a customer writes to you, a 24-hour window opens in which you can send free-form text without a template (how the window works). Outside it, only an approved template is allowed. A reply to a message you have just received is inside the window.
async function replyWithinWindow(eventId: string, to: string, text: string) {
const res = await fetch("https://sendfromchat.com/api/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SENDFROMCHAT_KEY}`,
"Content-Type": "application/json",
// Derived from the event, so a retried job can't send twice.
"Idempotency-Key": `reply-${eventId}`,
},
body: JSON.stringify({ to, text }),
});
const body = await res.json();
if (!res.ok) {
// window_closed and opted_out are WhatsApp's rules, not bugs.
console.warn("reply failed:", body.error?.type);
}
}
The key comes from the event id, so a re-run of the same job returns the first response (with an Idempotent-Replayed: true header) instead of sending again (retries without double-sending). Don't use a random value here: it gives you no protection.
If the key is the same but the body differs, the API returns idempotency_mismatch. If type is window_closed, the 24 hours have passed and you need an approved template (template rules).
7. Honour STOP
When someone replies STOP, SendFromChat marks them as opted out and sends your endpoint a contact.opted_out event. After that, a send to that number is refused with opted_out (HTTP 422), so nothing reaches them from us.
Your side of the job is the lists in your own app. In the handler above, removeFromMarketingLists should stop your own campaigns, cancel queued sends and record the time of the opt-out. contact.opted_in tells you when someone opts back in. Read our opt-in guide for how to collect and record consent.
8. Test it
- Generate a signature yourself and send it to your local route, before touching WhatsApp:
BODY='{"id":"evt_test_1","type":"message.received","created":"2026-10-17T09:00:00Z","data":{"from":"+4915112345678","type":"text","text":"status"}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SENDFROMCHAT_WEBHOOK_SECRET" | sed 's/^.* //')
curl -i http://localhost:3000/api/webhooks/sendfromchat \
-H "SendFromChat-Signature: t=$T,v1=$SIG" \
-d "$BODY"
- Run it twice. The second call should return
200and do nothing, because the event id is already stored. - Change one character of the body, or use a timestamp from ten minutes ago. Both should return
401. - Then send a real WhatsApp message to your connected number and watch the event arrive.
Checklist before you go live
- Raw body read with
request.text(), signature checked before parsing. timingSafeEqualwith a length check, and a five-minute timestamp limit.- A
2xxreturned within 5 seconds; slow work runs after the response. - Events deduplicated on
id. contact.opted_outremoves the person from your own lists.- Replies use text only inside the 24-hour window, and a template otherwise.
- Signing secret and API key only on the server.
Things to keep in mind
- Only message people who gave you their number for this purpose. Keep a record of consent.
- Costs are two separate things: SendFromChat's flat monthly plan (from $19, 30-day free trial, no card) and Meta's per-message charge, billed to your own WhatsApp Business account at Meta's published rates with no markup from us (pricing guide).
A note for EU and UK customers
SendFromChat stores data in Mumbai, India. Transfers from the EU and UK are covered by the Standard Contractual Clauses and the UK Addendum in our Data Processing Agreement, and the sub-processors are listed publicly. Customer messages that reach your endpoint become data in your own systems too, so read our GDPR guide for consent, opt-out and records. This is not legal advice.
Try it
Start the 30-day free trial (no card), connect a number, create a test key and register an endpoint. The same pattern powers the order notifications guide, the OTP login guide and the n8n workflow.
Sources
- SendFromChat REST API reference, webhook section: events, header format, 5-second answer, retries, repeated deliveries. Read 17 Oct 2026.
- Next.js docs shipped with version 16.3.5: route handlers and
after. Read 17 Oct 2026. - The 24-hour window and template rules: our own explainer and template guide. We did not re-check Meta's pages for this guide.
Do this from your AI assistant
SendFromChat connects Claude, ChatGPT, Cursor or any MCP client to the WhatsApp Business API — templates, bulk sends, inbox, chatbots and sequences, just by asking. 30 days free, no card.
Start freeMore guides
Send OTP login codes over WhatsApp: a REST API example
Send one-time passcodes over WhatsApp from your own backend with an Authentication template, a test key and idempotent retries. Node example, error handling and what to build around it.
Send WhatsApp messages from n8n with an HTTP Request node
Build an n8n workflow that sends WhatsApp order updates and sign-in codes through a REST API, with a test key, idempotent retries and a webhook for delivery status and replies.
How to send a WhatsApp broadcast from a spreadsheet, safely
A step-by-step walkthrough for small businesses — turn a spreadsheet into a personalised WhatsApp broadcast, check the preview, respect opt-out and warm up a new number. Do it by asking Claude or ChatGPT, or in the dashboard.
WhatsApp order notifications from your app: a working example
Send order confirmations and shipping updates over WhatsApp from Node or Next.js with a REST API — test keys, templates, idempotent retries and signed delivery webhooks.