Published 2026-06-1212 min readTutorial

WhatsApp webhook setup guide.

Receiving WhatsApp messages in your code is two steps: expose an HTTPS endpoint, point your provider’s webhook configuration at it. Doing it correctly takes a little more: verify signatures so spoofed requests can’t hit your business logic, handle retries idempotently so duplicate deliveries don’t double-charge anything downstream, and dispatch cleanly on the four lifecycle events you actually care about. This guide walks all of it, with ready-to-paste code in Node.js, Python, and Next.js.



01

The four events you'll receive

Mossmoon fires these four events to your webhook URL, plus a few others (such as message.failed and contact.first_seen) listed in the API docs. Every event shares one envelope: a top-level event field you dispatch on, the line_id, your agency_external_user_id, a ts, and an event-specific data object. The examples below are trimmed to the most useful fields.


02

Step 1: expose an HTTPS endpoint

Mossmoon (like every webhook provider) needs a public HTTPS URL it can POST to. Three common ways to set one up:

  • Production deployment: your existing backend at any HTTPS URL. Vercel, Fly, Railway, Render, Cloudflare Workers, AWS, GCP, your own VPS, anything that serves HTTPS.
  • Local development with a tunnel: run your backend locally on port 3000, expose it via ngrok http 3000 or cloudflared tunnel --url http://localhost:3000. Use the public URL as the webhook_url when you create the line.
  • Serverless functions: a single function handler on Vercel, AWS Lambda, Cloudflare Workers, or similar. Mossmoon’s webhook payloads are small enough that this works well for low-to-medium volume.

03

Step 2: verify HMAC signatures (don't skip this)

Every webhook Mossmoon sends includes an X-Mossmoon-Signature header and an X-Mossmoon-Timestamp header. The signature is sha256= followed by the hex HMAC-SHA256 of the timestamp, a literal dot, and the raw request body (<timestamp>.<raw body>), keyed with the line’s webhook_secret. Verifying it is the only way to be sure the request actually came from Mossmoon and not from somebody who guessed your endpoint URL.

Every line has its own secret. It is returned once, when you create the line, and creating a new line for the same phone number gives you a new one. Store each secret against its line_id and look it up from the line_id in the webhook body. A single secret in an environment variable only works while you have exactly one line.

Node.js (Express):

import express from "express";
import crypto from "crypto";

const app = express();

// CRITICAL: use raw body, not parsed JSON, for signature verification
app.post(
  "/webhooks/whatsapp",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const signature = req.header("X-Mossmoon-Signature") ?? "";
    const ts = req.header("X-Mossmoon-Timestamp") ?? "";

    // Reject anything older than 5 minutes (replay protection)
    const age = Math.abs(Date.now() / 1000 - Number(ts));
    if (!Number.isFinite(age) || age > 300) {
      return res.status(401).send("stale");
    }

    const raw = req.body.toString("utf8");
    const payload = JSON.parse(raw);

    // Each line has its own secret: look it up by line_id
    const secret = await getWebhookSecret(payload.line_id); // your storage
    if (!secret) return res.status(401).send("unknown line");

    const expected = "sha256=" + crypto
      .createHmac("sha256", secret)
      .update(`${ts}.${raw}`)  // timestamp + "." + raw body
      .digest("hex");

    const a = Buffer.from(signature);
    const b = Buffer.from(expected);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).send("invalid signature");
    }

    // ... dispatch on payload.event ...
    res.status(200).send("ok");
  }
);

Python (FastAPI):

import hashlib
import hmac
import json
import time
from fastapi import FastAPI, Header, HTTPException, Request

app = FastAPI()

@app.post("/webhooks/whatsapp")
async def whatsapp_webhook(
    request: Request,
    x_mossmoon_signature: str = Header(""),
    x_mossmoon_timestamp: str = Header(""),
):
    # Reject anything older than 5 minutes (replay protection)
    try:
        if abs(time.time() - int(x_mossmoon_timestamp)) > 300:
            raise HTTPException(401, "stale")
    except ValueError:
        raise HTTPException(401, "bad timestamp")

    body = await request.body()  # raw bytes
    payload = json.loads(body)

    # Each line has its own secret: look it up by line_id
    secret = get_webhook_secret(payload["line_id"])  # your storage
    if secret is None:
        raise HTTPException(401, "unknown line")

    expected = "sha256=" + hmac.new(
        secret.encode(),
        x_mossmoon_timestamp.encode() + b"." + body,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(x_mossmoon_signature, expected):
        raise HTTPException(401, "invalid signature")

    # ... dispatch on payload["event"] ...
    return {"ok": True}

Next.js App Router (route handler):

import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";

export async function POST(req: NextRequest) {
  const signature = req.headers.get("x-mossmoon-signature") ?? "";
  const ts = req.headers.get("x-mossmoon-timestamp") ?? "";

  // Reject anything older than 5 minutes (replay protection)
  const age = Math.abs(Date.now() / 1000 - Number(ts));
  if (!Number.isFinite(age) || age > 300) {
    return new NextResponse("stale", { status: 401 });
  }

  const body = await req.text(); // raw body, not parsed
  const payload = JSON.parse(body);

  // Each line has its own secret: look it up by line_id
  const secret = await getWebhookSecret(payload.line_id); // your storage
  if (!secret) {
    return new NextResponse("unknown line", { status: 401 });
  }

  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${body}`)  // timestamp + "." + raw body
    .digest("hex");

  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return new NextResponse("invalid signature", { status: 401 });
  }

  // ... dispatch on payload.event ...
  return NextResponse.json({ ok: true });
}

04

Step 3: dispatch on the event type

After verifying the signature, dispatch on the top-level event field. A simple dispatcher in Node:

switch (payload.event) {
  case "line.ready":
    await onLineReady(payload);
    break;
  case "line.disconnected":
    await onLineDisconnected(payload);
    break;
  case "message.received":
    await onMessageReceived(payload);
    break;
  case "message.delivered":
    await onMessageDelivered(payload);
    break;
  default:
    console.warn("unknown event", payload.event);
}
return res.status(200).send("ok");

The pattern most teams converge on: a thin webhook handler that verifies the signature, dispatches the event onto a queue (BullMQ, SQS, Inngest, Trigger.dev, whatever fits your stack), and acknowledges the webhook fast. The actual business logic runs from the queue worker, where you have proper retries, observability, and can take as long as needed without Mossmoon timing out.


05

Step 4: handle retries idempotently

Mossmoon retries webhook delivery if your endpoint doesn’t return a 2xx within 10 seconds. Any other response counts as a failure, including a 4xx such as a 401 from a signature check. Retries run 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, and 24 hours after the first attempt: 7 attempts over about 32 hours, after which the event is dropped. This means your endpoint will receive duplicate events sometimes, especially during partial outages.

Idempotency is straightforward: every delivery carries an X-Mossmoon-Delivery-Id header that stays the same across retries of that event. Before processing, check if you’ve seen that ID. If yes, return 200 immediately and skip processing. If no, mark it as seen (in Redis, Postgres, a KV store, whatever) and process. For messages you can also dedupe on data.message_id.

// Simple Postgres-backed idempotency check
async function processEvent(deliveryId: string, payload: any) {
  // deliveryId = the X-Mossmoon-Delivery-Id request header
  const result = await db.query(
    `INSERT INTO processed_events (event_id, processed_at)
     VALUES ($1, NOW())
     ON CONFLICT (event_id) DO NOTHING
     RETURNING event_id`,
    [deliveryId]
  );
  if (result.rowCount === 0) {
    // Already processed
    return;
  }
  // Do the actual work
  await dispatch(payload);
}

06

Step 5: media handling

Inbound message.received events for images, videos, voice notes, and documents set has_media and include a signed media_url you can fetch, plus the type, size, and expiry:

{
  "event": "message.received",
  "line_id": "wa_8f3c2e1a",
  "agency_external_user_id": "customer_42",
  "ts": "2026-06-12T14:42:12.551Z",
  "data": {
    "message_id": "false_20770646417423@lid_3A4818C915AE8B3D5917",
    "from": "+15559876543",
    "type": "image",
    "text": "What about this color?",
    "has_media": true,
    "media_url": "https://files.mossmoon.app/wa_8f3c2e1a/de0493e0.jpg?exp=...&sig=...",
    "media_kind": "image",
    "media_mime": "image/jpeg",
    "media_size": 184320,
    "media_filename": null,
    "media_expires_at": "2026-07-12T14:42:12.000Z",
    "media_error": null
  }
}

The signed URL stops working at media_expires_at. Best practice: download the media into your own storage (S3, R2, GCS) inside your queue worker, then reference it from there instead of the signed URL.

For voice notes specifically, pipe the audio into Whisper (OpenAI) or Deepgram for transcription. The transcribed text becomes another input to your AI agent or your conversation log. Critical for any conversational pattern where customers prefer to send voice notes (common in WhatsApp-dominant markets).


07

Step 6: respond fast, work async

Mossmoon expects a 2xx response within 10 seconds. Slower and we’ll retry, which leads to duplicate processing. The right pattern for any handler that does meaningful work:

  1. Verify signature.
  2. Check idempotency (skip if duplicate).
  3. Enqueue the event to your background job system.
  4. Return HTTP 200.

The expensive work (AI inference, third-party API calls, database writes, downstream webhook fanout) happens in the queue worker where you have all the time in the world. Vercel function logs full of “webhook timed out” are usually from doing real work inline; the fix is moving the work to a queue.


08

Where this fits in the bigger picture

Webhooks are the inbound half of your WhatsApp integration. The outbound half is one POST to /api/v1/wa/lines/{line_id}/send. Together they form the complete loop: customer messages your WhatsApp line, webhook fires into your code, your code processes and sends a reply back through the API, customer gets the response in the same WhatsApp thread.

If you’re building this on n8n / Make instead of code, see the orchestrator-specific tutorials: WhatsApp + n8n and WhatsApp + Make.com. Both abstract the webhook verification in their own way but the underlying patterns (signature verify, idempotency, dispatch) are the same.

For the full API surface, including outbound message types and all field references, see the Mossmoon API docs.


Ship the webhook today. Receive your first message in 30 minutes.

First line free for 7 days. No Meta business verification. One QR scan to connect. Webhooks firing immediately.

WhatsApp webhook setup guide: receiving messages in your code · Mossmoon