Dev

WhatsApp webhooks in production: idempotency and retries

In production, a WhatsApp webhook has to do four things: verify the signature, return 2xx within a few seconds, handle the same delivery twice without duplicating side effects, and leave a trail you can debug. The ZapZap server tries to deliver every event, and if your endpoint fails it resends on a timed ladder. That makes duplicates a normal case, not a rare bug. This guide covers the event format, signature verification, deduplication and how to debug with the deliveries and errors endpoints.

Published on · 6 min read

This article is also available in: Português · Español

What lands on your server with each event?

Every event arrives as a POST with a JSON body inside an envelope. The top-level fields are delivery_id (identifies the delivery attempt), event_id (the message ID when the event is about a message, otherwise null), version, instance_id, instance_name, event (messages, messages_update, connection and so on) and data with the content. If you attached metadata to the instance, it comes in the body too. For an incoming text message, the content is in data.message.text, the contact in data.message.chatid and the own-message flag in data.message.fromMe.

The headers carry X-ZapZap-Id (the same delivery_id), X-ZapZap-Event (the event name) and, when the instance has a secret, X-ZapZap-Signature in the format t=<unix>,v1=<hmac>. The secret (it starts with whsec_) is generated the first time you configure the webhook and comes back in the webhook_secret field of the response from set webhook and from get webhook. For the basics of receiving messages, start with the guide on receiving messages by webhook and the webhooks docs page.

Events are off by default. Turn on only the ones you will consume: messages, messages_update and connection cover most integrations. High-volume events, like presence and history, are only worth it if you will actually handle them.

Configurar a URL, os eventos e pegar o segredo

curl -X PUT "https://api.zapzapapi.com/api/v1/instances/$INSTANCE_ID/webhook" \
  -H "x-api-key: $ZAPZAP_API_KEY" \
  -H "x-api-secret: $ZAPZAP_API_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook_url": "https://api.seudominio.com/webhooks/zapzap",
    "enabled": true,
    "events": ["messages", "messages_update", "connection"]
  }'
# A resposta traz "webhook_secret": "whsec_..." (guarde em variável de ambiente)

How do you verify the webhook HMAC signature?

The signature proves the event came from ZapZap and the body was not altered. The calculation is an HMAC SHA-256 over the string timestamp, dot and raw body (t + '.' + body), using the instance secret. The hex result must equal the v1 value in the header. The t timestamp also protects against replay: reject deliveries more than 5 minutes off from your clock.

The thing that breaks this check most often is computing the HMAC over already-parsed JSON. The body has to be the exact text received, byte for byte. In Express that means using express.raw on the webhook route and only then calling JSON.parse. Compare with timingSafeEqual (or hmac.compare_digest in Python) so you do not leak information through response timing.

One gotcha: if you return a 4xx for a legitimate delivery, ZapZap treats it as a failure and resends. Use 401 only for requests that truly failed verification, not for your own processing errors.

Node.js (Express): assinatura, deduplicação e resposta rápida

const crypto = require("crypto");
const express = require("express");
const app = express();

const SECRET = process.env.ZAPZAP_WEBHOOK_SECRET; // whsec_...
const seen = new Map(); // em produção: Redis SET NX EX ou índice único no banco

function assinaturaValida(raw, header) {
  const partes = Object.fromEntries(
    String(header || "").split(",").map((p) => p.trim().split("="))
  );
  const t = Number(partes.t);
  if (!t || !partes.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;
  const esperado = crypto
    .createHmac("sha256", SECRET)
    .update(t + "." + raw)
    .digest("hex");
  const a = Buffer.from(esperado);
  const b = Buffer.from(partes.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post(
  "/webhooks/zapzap",
  express.raw({ type: "application/json", limit: "2mb" }),
  (req, res) => {
    const raw = req.body.toString("utf8");
    if (!assinaturaValida(raw, req.get("X-ZapZap-Signature"))) {
      return res.sendStatus(401);
    }
    const evento = JSON.parse(raw);
    const chave = req.get("X-ZapZap-Id") || evento.delivery_id;
    if (seen.has(chave)) return res.sendStatus(200); // reentrega: já tratada
    seen.set(chave, Date.now());

    res.sendStatus(200); // confirma primeiro
    setImmediate(() => processar(evento).catch(console.error)); // trabalha depois
  }
);

async function processar(evento) {
  const msg = evento.data && evento.data.message;
  if (evento.event !== "messages" || !msg || msg.fromMe) return;
  console.log("mensagem de", msg.chatid, ":", msg.text);
}

app.listen(3000);

Python (Flask): a mesma validação

import hashlib, hmac, os, time
from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["ZAPZAP_WEBHOOK_SECRET"].encode()

def assinatura_valida(raw: bytes, header: str) -> bool:
    try:
        partes = dict(p.strip().split("=", 1) for p in header.split(","))
        t = int(partes["t"])
    except (KeyError, ValueError, AttributeError):
        return False
    if abs(time.time() - t) > 300:
        return False
    esperado = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(esperado, partes.get("v1", ""))

@app.post("/webhooks/zapzap")
def webhook():
    raw = request.get_data()  # corpo bruto, antes de qualquer parse
    if not assinatura_valida(raw, request.headers.get("X-ZapZap-Signature", "")):
        return "", 401
    evento = request.get_json(force=True)
    # deduplique por request.headers["X-ZapZap-Id"] e enfileire o trabalho
    return "", 200

How does retry work and what counts as delivered?

Values checked in the system on 10/27/2026 and subject to change. The first attempt happens right away, with a 10-second timeout. A status below 400 counts as delivered. Any 4xx, 5xx, timeout or network error goes into a retry queue: after the first attempt, the queue tries again after 30 seconds, 2 minutes, 10 minutes and 1 hour. Each retry carries the same delivery_id and is signed again with a fresh timestamp.

There are two safety brakes. If the endpoint fails many times in a row, the system stops queuing new retries for a few minutes so it does not fill the queue with a dead destination. And if the failures are permanent and consecutive, you get a warning and, after a few more, the webhook is disabled until you fix it and turn it back on. That is why monitoring endpoint health is not optional.

The URL must be HTTPS and public. Private addresses, localhost and internal IPs are rejected at configuration time, and the server runs a reachability check when you save. That is the most common reason behind 'I saved it and nothing arrives': see the guide on webhooks that never arrive.

Idempotency: how do you avoid processing twice?

Idempotent means processing the same delivery twice gives the same result as processing it once. With retries, duplicates will happen: your server processes, the response gets lost, ZapZap resends. The defense is to write a unique key before acting and refuse the second one.

Use two layers. For retries, the key is X-ZapZap-Id (delivery_id), which repeats on redelivery. For duplicates at the source, the key is the combination of event and event_id: the same message can produce different events (messages, then messages_update for delivered and for read), so do not use event_id alone. Store the key with an expiry (a few days, for example) in Redis with SET NX EX, or in a table with a unique index.

Also filter fromMe right at the top. When your own system sends a message, the event comes back to the webhook, and replying to it creates a loop. The rule: if data.message.fromMe is true, return 200 and stop.

Finally, separate 'acknowledge' from 'process'. The endpoint only verifies, stores the key, puts the event on a queue and returns 200. Whoever consumes the queue does the heavy work (AI, database, sending a reply). If processing fails after the 200, the event is already in your queue and you reprocess from there, without depending on ZapZap's retry.

How do you debug when something does not arrive?

The API has three diagnostic endpoints. The webhook test sends a signed test event to the configured URL (or to an alternate URL) and returns the HTTP status and latency. Recent deliveries list, per event, whether it was delivered, the status, the latency and the attempt number. The webhook errors show the latest failures with the reason, such as a timeout.

The practical sequence: run the test, check the returned status, open the deliveries to see whether real events arrive with 2xx and, if anything failed, read the errors. A 200 on the test with real events failing usually points to slow response time under load. A constant timeout means processing is inside the response path.

Testar, ver entregas e ver erros

BASE="https://api.zapzapapi.com/api/v1"
H1="x-api-key: $ZAPZAP_API_KEY"
H2="x-api-secret: $ZAPZAP_API_SECRET"

# 1) evento de teste assinado
curl -X POST "$BASE/instances/$INSTANCE_ID/webhook/test" -H "$H1" -H "$H2" \
  -H "Content-Type: application/json" -d '{}'

# 2) últimas entregas (máximo 100)
curl "$BASE/instances/$INSTANCE_ID/webhook/deliveries?limit=20" -H "$H1" -H "$H2"

# 3) últimos erros de entrega
curl "$BASE/$INSTANCE_ID/webhook/errors" -H "$H1" -H "$H2"

Production checklist

[ ] Public HTTPS URL with a valid certificate, on a domain you own

[ ] Signature verified over the raw body, with a 5-minute tolerance

[ ] 2xx response within a few seconds, with heavy work on a queue

[ ] Deduplication key (X-ZapZap-Id, and event plus event_id) with an expiry

[ ] fromMe filtered so you never reply to yourself

[ ] Only the events you consume turned on in the webhook

[ ] Alert when recent deliveries show consecutive failures

For a no-code flow, the n8n node and templates already receive the webhook, and the webhook integration describes the format. The overview of features is on the WhatsApp API page.

Quick summary

  • Verify the X-ZapZap-Signature header over the raw body (HMAC SHA-256 of t + dot + body) and reject timestamps more than 5 minutes off.
  • Return 2xx fast and push heavy work to a queue. The delivery timeout is 10 seconds.
  • Failed deliveries are resent after 30 seconds, 2 minutes, 10 minutes and 1 hour, with the same delivery_id. Dedupe on it.
  • Filter fromMe and combine event with event_id: the same message produces more than one event.
  • Debug with the webhook test, recent deliveries and webhook errors.

Also read

Frequently asked questions

Does the ZapZap webhook retry automatically?

Yes. The first attempt is immediate, with a 10-second timeout. If it fails (status 400 or higher, timeout or network error), the queue tries again after 30 seconds, 2 minutes, 10 minutes and 1 hour. Values checked on 10/27/2026.

How do I know the webhook really came from ZapZap?

From the X-ZapZap-Signature header, in the format t=<unix>,v1=<hmac>. Recompute the HMAC SHA-256 of t, a dot and the raw body with the instance's webhook_secret and compare. Reject old timestamps to prevent replay.

Can I receive the same event twice?

Yes, especially after a network failure or a slow response. Store the X-ZapZap-Id and refuse anything already handled. For duplicates at the source, use the combination of event and event_id.

Why was my webhook disabled?

Permanent, consecutive endpoint failures trigger a warning and, after a few more, the webhook is disabled. Fix the endpoint, run the webhook test and set the URL again.

Does the webhook work over HTTP or on localhost?

No. The URL must be HTTPS and public. Local and private addresses are rejected at configuration time. To test in development, use an HTTPS tunnel or a staging server.

Set up the webhook and test it in minutes

Create your account, verify your phone and get the welcome credit, no deposit needed (one per phone, subject to the current campaign). Connect a number and use the test endpoint to see your first signed delivery.