Most WhatsApp bot tutorials stop at "the model replied." That is roughly the first fifteen percent of the work. The other eighty-five percent is signature verification, delivery guarantees, consent tracking, and the failure modes that only surface once real customers are messaging you at 2am.

This is a walkthrough of an agent we run in production: Meta Cloud API on the inbound edge, Next.js App Router route handlers in the middle, Google Gemini generating replies, PostgreSQL via Prisma holding conversation state. Every code sample below is the shape of what actually ships, including the parts we got wrong the first time.


The architecture

code
[ Customer on WhatsApp ]
          │  message
          ▼
[ Meta Cloud API ] ──POST──> [ Next.js route handler ]
                                      │
                          1. verify HMAC signature
                          2. check opt-out keywords
                          3. generate reply (Gemini)
                          4. send via Graph API
                          5. persist (best effort)
                                      │
                                      ▼
                          [ PostgreSQL / Prisma ]
                                      ▲
                                      │
[ Cron / worker ] ──drains──> [ Outbound queue ]

Two paths matter, and they have different reliability requirements:

  • Inbound — a customer messages you, and you answer. Latency-sensitive. The customer is sitting there watching for the typing indicator.
  • Outbound — you message a customer first. Not latency-sensitive at all, but heavily regulated by Meta, and the path that gets business numbers banned when done carelessly.

Conflating these two is the most common architectural mistake. They deserve different code paths, and in our system they get them.


Step 1: The verification handshake

Before Meta sends you a single message, it performs a GET handshake against your webhook URL. You echo back the challenge if the token matches.

typescript
// app/api/webhooks/whatsapp/route.ts
import { NextRequest, NextResponse } from "next/server";

export const runtime = "nodejs";

export async function GET(request: NextRequest) {
  const p = request.nextUrl.searchParams;

  if (
    p.get("hub.mode") === "subscribe" &&
    p.get("hub.verify_token") === process.env.WHATSAPP_VERIFY_TOKEN
  ) {
    return new NextResponse(p.get("hub.challenge") || "", { status: 200 });
  }

  return NextResponse.json({ error: "Verification failed" }, { status: 403 });
}

Note export const runtime = "nodejs". This matters: the signature verification in the next step needs Node's crypto module, which is not available on the Edge runtime. Getting this wrong produces a build that succeeds and a webhook that rejects every legitimate request in production.

WHATSAPP_VERIFY_TOKEN is a secret you invent — any long random string. It is only used during this handshake.


Step 2: Verify the signature on every request

This is the step most tutorials skip entirely, and skipping it means anyone who learns your webhook URL can inject fake customer messages into your CRM and burn your model quota. The URL is not a secret. It appears in logs, in browser history, in screenshots.

Meta signs every payload with HMAC-SHA256 using your app secret, delivered in the x-hub-signature-256 header.

typescript
import { createHmac, timingSafeEqual } from "crypto";

function signatureValid(raw: string, signature: string | null) {
  const secret = process.env.WHATSAPP_APP_SECRET;
  if (!secret || !signature) return false;

  const expected = `sha256=${createHmac("sha256", secret).update(raw).digest("hex")}`;

  // Length check FIRST — timingSafeEqual throws on mismatched buffer lengths,
  // and a thrown exception is a 500, which Meta treats as a retryable failure.
  return (
    expected.length === signature.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
  );
}

Three details worth internalising:

  1. Hash the raw body, not the parsed object. JSON.parse followed by JSON.stringify will not reproduce Meta's exact bytes — key order and whitespace differ — and your HMAC will never match. Read await request.text() first, verify, then parse.
  2. Use timingSafeEqual, not ===. String comparison short-circuits on the first differing character, which leaks signature bytes through response timing. It is a real attack, and the fix is one function call.
  3. Guard the length before calling it. timingSafeEqual throws a RangeError on unequal lengths. An attacker sending a short signature would crash the handler into a 500, and Meta retries 500s — so you have handed them an amplification lever.

Fail closed. If the secret is missing from the environment, reject everything. A webhook that accepts unsigned requests because someone forgot an environment variable is worse than one that is down.


Step 3: Generating the reply

We call Gemini over plain REST rather than through an SDK. Two reasons: one less dependency to keep current, and full control over the request body — particularly responseMimeType, which is what makes the model return parseable JSON instead of prose wrapped in markdown fences.

typescript
const system = `You are a sales assistant on WhatsApp. Be concise, truthful, and
never claim to be human. Collect qualification details and offer to book a call.
Respond ONLY as JSON:
{"reply":string,"confidence":number,"shouldEscalate":boolean,"nextAction":string}
Escalate for legal questions, pricing exceptions, complaints, or anything ambiguous.`;

export async function generateSalesReply(userMessage: string) {
  const apiKey = process.env.GEMINI_API_KEY;
  if (!apiKey) throw new Error("Gemini API is not configured");

  const model = process.env.GEMINI_MODEL || "gemini-2.0-flash";

  const response = await fetch(
    `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        system_instruction: { parts: [{ text: system }] },
        contents: [{ role: "user", parts: [{ text: userMessage }] }],
        generationConfig: {
          responseMimeType: "application/json",
          maxOutputTokens: 500,
          temperature: 0.7,
        },
      }),
    },
  );

  if (!response.ok) throw new Error(`Gemini API error (${response.status})`);
  // ... parse, with a fallback for when the model ignores the JSON contract
}

Always structure the output. A bare string reply gives you no way to detect that the model is out of its depth. Asking for a shouldEscalate flag alongside the reply gives you a cheap, reliable escape hatch: when the model is unsure, a human gets the conversation instead of the customer getting a confident hallucination about your refund policy.

Always have a fallback. Models occasionally return malformed JSON no matter what you ask for. Wrap the parse in a try/catch and degrade to a fixed holding message — "Thanks for your message, someone will be with you shortly" — rather than throwing. Silence reads as broken; a holding message reads as busy.

maxOutputTokens is a UX control, not just a cost control. WhatsApp is a chat surface. A 400-word reply is wrong for the medium regardless of how good it is. Capping output forces brevity better than asking for it in the prompt.


Step 4: Sending the reply

typescript
export async function sendWhatsAppMessage(to: string, body: string) {
  const id = process.env.WHATSAPP_PHONE_NUMBER_ID;
  const token = process.env.WHATSAPP_ACCESS_TOKEN;
  if (!id || !token) throw new Error("WhatsApp Cloud API is not configured");

  const response = await fetch(`https://graph.facebook.com/v21.0/${id}/messages`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      messaging_product: "whatsapp",
      to,
      type: "text",
      text: { preview_url: false, body },
    }),
  });

  if (!response.ok) {
    const detail = await response.text();
    throw new Error(`WhatsApp API error (${response.status}): ${detail}`);
  }

  const result = (await response.json()) as { messages?: Array<{ id: string }> };
  return result.messages?.[0]?.id;
}

Keep the returned message ID. It is the join key for delivery and read receipts, which arrive later on the same webhook as statuses events rather than messages events. Without storing it you cannot tell a delivered message from one that silently failed.


Step 5: Delivery guarantees for outbound

Inbound replies happen inline — the customer is waiting, and a queue hop adds latency for no benefit. Outbound is the opposite: nobody is waiting, delivery must survive process restarts, and failures need retry with backoff.

So outbound messages get written to a queue table and drained by a worker. The critical part is claiming work atomically, because a cron invocation and a long-running worker will eventually overlap, and double-sending to a customer is an unrecoverable embarrassment.

typescript
// Only the invocation that flips PENDING -> PROCESSING owns this item.
const claim = await prisma.queueItem.updateMany({
  where: { id: item.id, status: "PENDING" },
  data: { status: "PROCESSING", lockedAt: new Date() },
});

if (claim.count === 0) {
  skipped++;   // someone else got there first
  continue;
}

updateMany with the status in the WHERE clause compiles to a single conditional UPDATE. The database resolves the race; you never hold a lock in application code. On failure, increment the attempt counter and push runAt out exponentially:

typescript
await prisma.queueItem.update({
  where: { id: job.id },
  data: {
    status: attempts >= job.maxAttempts ? "FAILED" : "PENDING",
    attempts,
    lastError: String(error),
    runAt: new Date(Date.now() + Math.pow(2, attempts) * 30_000),
    lockedAt: null,
  },
});
One hard-won lesson: your queue is only as good as the thing that drains it. We shipped a correct queue with exponential backoff and atomic claims, then scheduled the drain on a daily cron because that is what the hosting tier allowed. Messages that were supposed to go out in ninety seconds went out the next morning. The queue was flawless; the schedule made it useless. Check what cron frequency your platform actually permits before you design around it.

Everything above is engineering. This part is policy, and it carries more business risk than any bug in this article.

WhatsApp draws a hard line between user-initiated conversations (a customer messages you first; you may reply freely within a service window) and business-initiated ones (you message first; requires prior opt-in and an approved template). Violating this does not produce a warning email. It produces a restricted number.

Three mechanisms, all mandatory:

Handle opt-out keywords before anything else. Before you spend a model call, before you write to the database:

typescript
const OPT_OUT = new Set([
  "stop", "unsubscribe", "cancel", "optout", "opt out", "opt-out", "end", "quit",
]);

if (OPT_OUT.has(text.trim().toLowerCase())) {
  await recordOptOut(from);
  continue;
}

Gate outbound on recorded opt-in, at enqueue time. Not at send time — at the moment work is created, so nothing that lacks consent can ever enter the queue:

typescript
export async function validateOutreach(leadId: string) {
  const lead = await prisma.lead.findUnique({ where: { id: leadId } });
  if (!lead) throw new Error("Lead not found");

  if (!lead.messagingOptIn || lead.optOutAt) {
    throw new Error("Cannot send outreach without recorded WhatsApp opt-in");
  }
  return lead;
}

Cap daily volume and jitter the send times. A business number that sends two hundred identical messages in ninety seconds looks exactly like the spam it is being screened for. We cap per-day volume and randomise each send between 90 and 240 seconds after the last.

Store the consent record itself — a boolean messagingOptIn, plus optInAt and optOutAt timestamps. If Meta asks you to substantiate an opt-in, "the customer filled in a form once" is not an answer; a timestamp is.


Production considerations

ConcernNaive approachWhat holds up
Signature checkSkip it, or compare with ===HMAC-SHA256, timingSafeEqual, length-guarded, fail closed
Body parsingawait request.json()await request.text(), verify, then parse
RuntimeDefault (Edge)runtime = "nodejs"crypto is unavailable on Edge
Model outputFree-form stringStructured JSON with a shouldEscalate flag
Model failureThrow, return 500Catch, fall back to a holding message
Outbound sendFire-and-forget in the handlerQueue with atomic claim and exponential backoff
RetriesNone, or a naive loopPENDING -> PROCESSING conditional update
Opt-outHandle it eventuallyFirst check in the handler, before any spend
Delivery statusIgnore statuses eventsStore the message ID, reconcile receipts

What we would do differently

Three things this design gets wrong, stated plainly, because a walkthrough that only lists wins is not much use to anyone building the same thing.

  1. Single-turn context. Passing only the latest message to the model means the agent has no memory. It answers every message as if it were the first. Fixing this requires persisting inbound messages and replaying the last N turns into contents — cheap to add, and it should have been there from the start.
  1. Inline generation inside the webhook. Meta expects a prompt HTTP 200 and retries when it does not get one. Generating and sending inside the handler puts a model call and an outbound API call inside that window. It works, and the latency is good, but a slow model turn risks a retry and therefore a duplicate reply. The safer pattern is to 200 immediately and hand off to a background task — at the cost of infrastructure you may not want on day one.
  1. Persisting only outbound messages. Writing the reply but not the customer's message halves your CRM. Every analytics question — reply rate, common objections, where conversations stall — needs the inbound side. Best-effort persistence for both, after the customer has been answered, is the right shape.

The pattern underneath all three: answer the customer first, then do the bookkeeping, and never let a bookkeeping failure break the reply. Wrap persistence in try/catch, log the failure, move on. A database hiccup should never cost you a conversation.


Build this properly

We build WhatsApp AI agents on the Meta Cloud API — signature-verified, consent-gated, with delivery queues and a CRM behind them. If you are evaluating whether to build in-house or hand it over, talk to us about AI chatbot development.