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 four event types to your webhook URL. Every event has a stable JSON shape and a top-level event field you dispatch on.


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. Paste the public URL into Mossmoon’s webhook config.
  • 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 containing an HMAC-SHA256 of the raw request body, signed with your workspace webhook secret. Verifying this signature is the only way to be sure the request actually came from Mossmoon and not from somebody who guessed your endpoint URL.

Node.js (Express):

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

const app = express();
const WEBHOOK_SECRET = process.env.MOSSMOON_WEBHOOK_SECRET!;

// CRITICAL: use raw body, not parsed JSON, for signature verification
app.post(
  "/webhooks/whatsapp",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.header("X-Mossmoon-Signature");
    if (!signature) return res.status(401).send("missing signature");

    const expected = crypto
      .createHmac("sha256", WEBHOOK_SECRET)
      .update(req.body)
      .digest("hex");

    if (!crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    )) {
      return res.status(401).send("invalid signature");
    }

    const payload = JSON.parse(req.body.toString());
    // ... dispatch on payload.event ...
    res.status(200).send("ok");
  }
);

Python (FastAPI):

import hmac
import hashlib
import os
from fastapi import FastAPI, Header, HTTPException, Request

app = FastAPI()
WEBHOOK_SECRET = os.environ["MOSSMOON_WEBHOOK_SECRET"]

@app.post("/webhooks/whatsapp")
async def whatsapp_webhook(
    request: Request,
    x_mossmoon_signature: str = Header(...)
):
    body = await request.body()
    expected = hmac.new(
        WEBHOOK_SECRET.encode(),
        body,
        hashlib.sha256,
    ).hexdigest()

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

    payload = await request.json()
    # ... dispatch on payload["event"] ...
    return {"ok": True}

Next.js App Router (route handler):

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

const WEBHOOK_SECRET = process.env.MOSSMOON_WEBHOOK_SECRET!;

export async function POST(req: NextRequest) {
  const signature = req.headers.get("x-mossmoon-signature");
  if (!signature) {
    return new NextResponse("missing signature", { status: 401 });
  }

  const body = await req.text(); // raw body, not parsed
  const expected = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(body)
    .digest("hex");

  if (!crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  )) {
    return new NextResponse("invalid signature", { status: 401 });
  }

  const payload = JSON.parse(body);
  // ... 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 30 seconds, or returns a 5xx. Retries follow an exponential backoff with up to 8 attempts over 24 hours. This means your endpoint will receive duplicate events sometimes, especially during partial outages.

Idempotency is straightforward: every event has an id field (e.g. evt_aBcD1234). 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.

// Simple Postgres-backed idempotency check
async function processEvent(payload: any) {
  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`,
    [payload.id]
  );
  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 include a media object with a temporary signed URL you can fetch:

{
  "event": "message.received",
  "id": "evt_mediaImg",
  "line_id": "ln_8KQ9pP2nR",
  "message": {
    "id": "msg_PfQ8vR2nW",
    "from": "+15559876543",
    "type": "image",
    "caption": "What about this color?",
    "media": {
      "url": "https://media.mossmoon.app/...signed...",
      "mime_type": "image/jpeg",
      "sha256": "9f8e..."
    },
    "received_at": "2026-06-12T14:42:11Z"
  }
}

The signed URL is valid for 24 hours from when Mossmoon fires the webhook. Best practice: download the media into your own storage (S3, R2, GCS) inside your queue worker, then reference it from there. Don’t rely on the signed URL past 24 hours.

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 30 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}/messages. 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