mcpField
W
webhook-setup

webhook-setup

github.com/zavudevDevelopment1239 installsUpdated 2d ago
Repository

About webhook-setup

A skill for developers configuring Zavu webhooks to receive message, delivery, and template events. It explains webhook types, event payloads, sender setup, and how to handle inbound messages across channels.

Key features

  1. 01Configure sender and project webhooks
  2. 02Select events for message and delivery updates
  3. 03Interpret shared event envelopes and payloads
  4. 04Handle inbound messages across channels
  5. 05Regenerate sender webhook secrets
  6. 06Retrieve email attachment metadata and links

Use cases

  1. 01Process inbound customer messages
  2. 02Track delivery and read status
  3. 03React to template approvals or recategorization
  4. 04Retrieve attachments from inbound email

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/zavudev/zavu-skills --skill webhook-setup

After installing, type/webhook-setupin Claude Code to confirm it loaded.

Details

From the project README

Webhook Setup

When to Use

Use this skill when setting up webhook endpoints to receive inbound messages, delivery status updates, or template approval notifications from Zavu.

Senders vs accounts (one paragraph)

A Sender is the API handle you pass as Zavu-Sender; accounts (a WhatsApp Business Account, a Facebook Page, a Telegram bot, a phone number) are the connections it routes — and what bills. Senders are free. Connecting an account in the dashboard auto-creates its sender; find it with GET /v1/senders and trust its channels array for what it can send. See the channel-setup skill for the full model.

Webhook Types

  • Sender Webhooks: Message events (inbound, delivery status, templates) - configured per sender
  • Project Webhooks: Project-level events (partner invitations) - one per project. A parent project does not receive its sub-accounts' events: configure the webhook with each sub-account's own API key (POST /v1/invitations/webhook).

Available Events

EventCategoryDescription
message.inboundInboundCustomer sent you a message
conversation.newInboundFirst message from a new contact
message.unsupportedInboundUnsupported message type received
contact.identity_linkedContactsA WhatsApp contact known by BSUID was tied to a phone number, a contact known by phone received a BSUID, or a contact changed their number. data carries contactId, bsuid, phoneNumber, reason (provider_reported, contact_shared, phone_changed, bsuid_changed); previousPhoneNumber with phone_changed, where bsuid is null if WhatsApp never reported one; mergedContactId when two contacts were merged into contactId; previousBsuid when a BSUID you may hold no longer identifies the contact. Sent once per link
message.queuedOutboundMessage queued for delivery
message.sentOutboundMessage sent to carrier
message.deliveredOutboundMessage delivered to recipient
message.readOutboundMessage read by recipient
message.failedOutboundMessage delivery failed
broadcast.status_changedBroadcastsBroadcast status changed
template.status_changedTemplatesWhatsApp template approval status changed, or Meta recategorized it. data carries templateId, name, previousStatus, currentStatus, rejectionReason, category
invitation.status_changedInvitationsPartner invitation status changed (in_progress, completed, failed, cancelled, or back to pending on resend). Never sent for expiry
domain.verifiedDomainsCustom email domain passed verification
domain.failedDomainsCustom email domain failed verification

Configure Webhook via SDK

TypeScript - Create Sender with Webhook
const sender = await zavu.senders.create({
  name: "My Sender",
  phoneNumber: "+15551234567",
  webhookUrl: "https://your-app.com/webhooks/zavu",
  webhookEvents: ["message.inbound", "message.delivered", "message.failed"],
});
// Store sender.webhook.secret securely - only shown once!
Update Webhook
await zavu.senders.update({
  senderId: "snd_abc123",
  webhookUrl: "https://new-url.com/webhooks",
  webhookEvents: ["message.inbound"],
  webhookActive: true,
});
Regenerate Secret
const result = await zavu.senders.webhookSecret.regenerate({
  senderId: "snd_abc123",
});
console.log(result.secret); // whsec_new_secret...

Webhook Payload Structure

The top-level envelope is the same for every event; event-specific fields live in data. timestamp is when Zavu dispatched the webhook (Unix ms).

{
  "id": "evt_1705312200000_abc123",
  "type": "message.inbound",
  "timestamp": 1705312200000,
  "senderId": "snd_abc123",
  "projectId": "prj_xyz789",
  "data": { }
}

Inbound Message Data (message.inbound)

For message.inbound, data carries the message. On inbound, to is your own number (the message's destination) and from is the sender.

FieldDescription
messageIdZavu message ID.
conversationIdInbox thread id. Always set, because the webhook is sent once the message is in its thread; the first message of a new thread carries the same id as conversation.new. Absent on reactions. See "Deep-linking to the inbox" below.
contactIdThe Zavu contact. Unlike from, it does not change when a WhatsApp contact's identifier switches between BSUID and phone number: key on this, not on from. The exception is a merge of two contacts for one person; contact.identity_linked then carries the old id as mergedContactId. Absent on reactions.
whatsappWhatsApp only, when the BSUID is known: { bsuid, username? }.
fromSender: the contact for a 1:1, or the participant for a group message. Usually an E.164 phone number, but for WhatsApp contacts who adopted a username and hid their number it is their business-scoped user ID (BSUID, e.g. US.13491208655302741918) — treat it as opaque and pass it back as to when replying. It is the phone only when WhatsApp includes it in that message, so it can switch between phone and BSUID for the same person.
toYour own number (the message's destination).
channelDelivery channel (sms, whatsapp, telegram, email, instagram, messenger, voice).
messageTypetext, image, video, etc. A reply to a location_request arrives as location (not a new type) with content.replyToMessageId set to the request — match on that to correlate.
textText body or media caption, when present.
providerTimestampThe provider's original receive time (Unix ms) for WhatsApp, Telegram, Instagram, Messenger; null for SMS and email. Compare with the top-level timestamp to detect delayed deliveries.
referralClick-to-WhatsApp ad attribution. WhatsApp only, and only on the first message of an ad-originated thread. See "Click-to-WhatsApp attribution" below.
telegramChatIdTelegram only: the chat ID to send back to, without a prefix.
instagramSenderIdInstagram only: the sender's ID to send back to, without a prefix.
messengerSenderIdMessenger only: the sender's ID to send back to, without a prefix.
Replying from the same sender

Send the reply with POST /v1/messages, passing the payload's top-level senderId as the Zavu-Sender header so it leaves from the sender the contact wrote to. What goes in to depends on the channel:

  • SMS, WhatsApp and email: data.from.
  • Telegram: data.telegramChatId. Instagram: data.instagramSenderId. Messenger: data.messengerSenderId. On these channels data.from carries a telegram: / instagram: / messenger: prefix that to does not accept, and the send is refused with 400.
const { senderId, data } = event;
const to =
  data.channel === "telegram" ? data.telegramChatId :
  data.channel === "instagram" ? data.instagramSenderId :
  data.channel === "messenger" ? data.messengerSenderId :
  data.from;
await zavu.messages.send({ to, channel: data.channel, text: "Thanks, we got it.", "Zavu-Sender": senderId });
Deep-linking to the inbox

conversation.new carries a conversationId in data — the id of the inbox thread — and so does message.inbound. Build a direct link so your team can open the conversation in the Zavu dashboard:

https://dashboard.zavu.dev/{locale}/inbox?conv={conversationId}

{locale} is the dashboard UI language (e.g. en, es).

On message.inbound, conversationId is always set, and absent on reactions.

Reply / quote context

Present on message.inbound when the contact replied to (quoted) an earlier message, inside data.content.

FieldDescription
replyToMessageIdZavu message ID of the quoted message. Omitted if the quoted message is not stored in Zavu.
replyToProviderMessageIdProvider message ID (WhatsApp WAMID) of the quoted message. Present whenever it is a reply.
replyToFromSender of the quoted message (E.164).
replyToTextTruncated snippet of the quoted message's text (empty for media).
replyToMessageTypeType of the quoted message (text, image, ...).

Filled in for WhatsApp only: inbound Telegram messages do not report what the contact quoted. To answer with a quote of your own, send content.replyToMessageId: event.data.messageId from the sender in event.senderId (see the send-message skill).

Click-to-WhatsApp attribution

When a contact reaches you by tapping a Click-to-WhatsApp (CTWA) ad or a post, data.referral carries where they came from. It is WhatsApp only, and it arrives on the first message of that conversation and on no message after it — persist it when it arrives, because there is no second delivery. Organic conversations never carry it.

FieldDescription
ctwaClidClick-to-WhatsApp click identifier. This is what Meta's Conversions API needs to credit a conversion back to the ad. Present on ad sources; a post has none.
sourceIdIdentifier of the ad or post that produced the click.
sourceTypead or post.
sourceUrlMeta permalink to the ad or post.
headlineHeadline of the ad or post.
bodyBody copy of the ad or post.
mediaTypeimage or video, when the ad had media.
imageUrl / videoUrl / thumbnailUrlAd media. Only the one matching mediaType is present.
{
  "type": "message.inbound",
  "data": {
    "messageId": "jd7x2k3m4n5p6q7r8s9t0",
    "from": "+56912345678",
    "channel": "whatsapp",
    "messageType": "text",
    "text": "Hi, I saw your ad",
    "referral": {
      "sourceType": "ad",
      "sourceId": "120210000000000000",
      "headline": "Free first consultation",
      "mediaType": "image",
      "ctwaClid": "ARIzZm9vYmFyY3R3YWNsaWQ"
    }
  }
}

Fields that do not apply are omitted rather than sent empty, so check for presence before reading. GET /v1/messages/{messageId} returns the same object under content.referral.

Email attachments

Inbound emails arrive as message.inbound with channel: "email" and messageType: "text". The webhook payload carries only the body (text, and htmlBody on the fetched message) — it does not include attachment data. Attachments are stored separately and fetched on demand.

To retrieve them, call GET /v1/messages/{messageId}/attachments with the messageId from the webhook. It returns each attachment's metadata plus a short-lived signed downloadUrl (regenerated on every request — fetch promptly, don't cache the URL). This also works for outbound emails you sent with attachments. Messages without stored attachments return an empty list.

curl https://api.zavu.dev/v1/messages/MESSAGE_ID/attachments \
  -H "Authorization: Bearer $ZAVU_API_KEY"
{
  "items": [
    {
      "id": "att_abc123",
      "filename": "invoice.pdf",
      "mimeType": "application/pdf",
      "size": 102400,
      "contentId": null,
      "isInline": false,
      "downloadUrl": "https://...signed-url...",
      "createdAt": "2024-01-15T10:00:00.000Z"
    }
  ]
}

Inline images embedded in the HTML body have isInline: true and a contentId referenced in htmlBody as cid:<contentId>.

Partner Invitation Data (invitation.status_changed)

Fires on every stored status change of a partner invitation, delivered to the project webhook of the project that created it. A change to the same status sends nothing. Expiry is not a stored change: a pending invitation past expiresAt reads as expired but emits no event. data carries:

FieldDescription
invitationIdZavu invitation ID.
clientName / clientEmailWhat you passed when creating the invitation, or null.
connectionTypeWhat the client connects: whatsapp_waba or messenger.
previousStatus / currentStatusThe transition.
senderIdPresent on completed: the sender created in your project.
wabaAccountIdPresent on completed WhatsApp invitations: the WhatsApp Business Account ID.
connectedAccountPresent on completed: { channel, id, name } — the WhatsApp number or the Facebook Page that was linked.
failureReasonPresent on failed. Stable code: fb_cancelled, fb_not_authorized, signup_abandoned, meta_no_pages, internal_error, and others. Treat unknown codes as a generic failure.

A failed invitation is not terminal: the same link stays usable, and it moves back to in_progress when the client retries. Only act on completed to provision.

{
  "id": "evt_1736850000000_abc123",
  "type": "invitation.status_changed",
  "timestamp": 1736850000000,
  "projectId": "jx7xyz789ghi012",
  "data": {
    "invitationId": "jh7am5bng9p3v2x1k4r8",
    "clientName": "Acme Corp",
    "clientEmail": "[email protected]",
    "connectionType": "messenger",
    "previousStatus": "in_progress",
    "currentStatus": "completed",
    "senderId": "sender_12345",
    "connectedAccount": {
      "channel": "messenger",
      "id": "1077492835456839",
      "name": "Acme Store"
    }
  }
}

Signature Verification

Header: X-Zavu-Signature: t=<unix_seconds>[,v1=<hex>][,v2=<hex>]

PartWhat it covers
tUnix timestamp in seconds
v1HMAC_SHA256(secret, body)
v2HMAC_SHA256(secret, "{t}.{body}") — the current scheme

Hash the right payload or every delivery 401s. v1 covers the body alone. Only v2 covers {t}.{body}. Read webhook.signatureVersion on the sender (GET /v1/senders/{senderId}) to know which one that receiver gets. New senders default to v2; anything created earlier is on v1 until moved.

Prefer v2 when present and fall back to v1, so one implementation works before, during and after a migration.

The algorithm
1. read the RAW body (no JSON parser in front)
2. parse the header into { t, v1?, v2? }
3. reject if |now - t| > 300
4. signed = v2 present ? `${t}.${body}` : body
   expected = HMAC_SHA256(secret, signed)
5. constant-time compare against v2 ?? v1
TypeScript (Express)
import crypto from "crypto";
import express from "express";

const app = express();
// Raw body. NOT express.json() — the signature covers the exact bytes sent.
app.use("/webhooks/zavu", express.raw({ type: "application/json" }));

function verifyZavuSignature(rawBody: string, header: string, secret: string): boolean {
  if (!header) return false;

  const parts: Record<string, string> = {};
  for (const piece of header.split(",")) {
    const i = piece.indexOf("=");
    if (i > 0) parts[piece.slice(0, i)] = piece.slice(i + 1);
  }

  const t = Number(parts.t);
  if (!Number.isFinite(t)) return false;

  const age = Math.floor(Date.now() / 1000) - t;
  if (age > 300 || age < -60) return false;

  const received = parts.v2 ?? parts.v1;
  if (!received) return false;

  const signed = parts.v2 ? `${t}.${rawBody}` : rawBody;
  const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex");

  // Length first: timingSafeEqual throws on a mismatch.
  if (expected.length !== received.length) return false;
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}

app.post("/webhooks/zavu", (req, res) => {
  const rawBody = req.body.toString("utf8");
  if (!verifyZavuSignature(rawBody, req.headers["x-zavu-signature"] as string, process.env.ZAVU_WEBHOOK_SECRET!)) {
    return res.status(401).send("Invalid signature");
  }

  // Answer fast, then work. Zavu retries non-2xx, so a slow handler turns one
  // event into five.
  res.status(200).send("OK");
  processEvent(JSON.parse(rawBody)).catch(console.error);
});
Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request

app = Flask(__name__)

def verify_zavu_signature(raw_body: bytes, header: str, secret: str) -> bool:
    if not header:
        return False

    parts = {}
    for piece in header.split(","):
        key, sep, value = piece.partition("=")
        if sep:
            parts[key] = value

    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False

    age = int(time.time()) - t
    if age > 300 or age < -60:
        return False

    received = parts.get("v2") or parts.get("v1")
    if not received:
        return False

    signed = f"{t}.".encode() + raw_body if "v2" in parts else raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)

@app.post("/webhooks/zavu")
def zavu_webhook():
    raw_body = request.get_data()  # bytes, before parsing
    header = request.headers.get("X-Zavu-Signature", "")
    if not verify_zavu_signature(raw_body, header, os.environ["ZAVU_WEBHOOK_SECRET"]):
        return "Invalid signature", 401
    enqueue(request.get_json())
    return "OK", 200
Go
func verifyZavuSignature(rawBody []byte, header, secret string) bool {
	if header == "" {
		return false
	}

	parts := map[string]string{}
	for _, piece := range strings.Split(header, ",") {
		if k, v, ok := strings.Cut(piece, "="); ok {
			parts[k] = v
		}
	}

	t, err := strconv.ParseInt(parts["t"], 10, 64)
	if err != nil {
		return false
	}
	age := time.Now().Unix() - t
	if age > 300 || age < -60 {
		return false
	}

	received, hasV2 := parts["v2"]
	if !hasV2 {
		received = parts["v1"]
	}
	if received == "" {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	if hasV2 {
		mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
	}
	mac.Write(rawBody)

	return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(received))
}
Ruby (Sinatra)
def verify_zavu_signature(raw_body, header, secret)
  return false if header.nil? || header.empty?

  parts = {}
  header.split(',').each do |piece|
    key, _, value = piece.partition('=')
    parts[key] = value unless value.empty?
  end

  t = Integer(parts['t'], exception: false)
  return false if t.nil?

  age = Time.now.to_i - t
  return false if age > 300 || age < -60

  received = parts['v2'] || parts['v1']
  return false if received.nil?

  signed = parts['v2'] ? "#{t}.#{raw_body}" : raw_body
  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, signed)

  OpenSSL.secure_compare(expected, received)
end
PHP
function verifyZavuSignature(string $rawBody, ?string $header, string $secret): bool {
    if (!$header) return false;

    $parts = [];
    foreach (explode(',', $header) as $piece) {
        $i = strpos($piece, '=');
        if ($i !== false) $parts[substr($piece, 0, $i)] = substr($piece, $i + 1);
    }

    if (!isset($parts['t']) || !ctype_digit($parts['t'])) return false;
    $t = (int) $parts['t'];

    $age = time() - $t;
    if ($age > 300 || $age < -60) return false;

    $received = $parts['v2'] ?? $parts['v1'] ?? null;
    if ($received === null) return false;

    $signed = isset($parts['v2']) ? "{$t}.{$rawBody}" : $rawBody;
    return hash_equals(hash_hmac('sha256', $signed, $secret), $received);
}

Which scheme is a receiver on?

Read it off the sender. webhook.signatureVersion is always present when a webhook is configured.

npx zavudev senders signature $SENDER_ID
endpoint   https://api.example.com/webhooks/zavu
signature  v1

Next step:
  npx zavudev senders update snd_abc --signature-version v1+v2

That sends both signatures at once, so your current receiver is unaffected.

npx zavudev senders list has a signature column, which is the fastest way to see which receivers still have to move.

Over the API it is GET /v1/senders/{senderId} -> webhook.signatureVersion.

Moving a webhook to v2

# 1. Both signatures, one shared t. Your v1 receiver notices nothing.
npx zavudev senders update $SENDER_ID --signature-version v1+v2

# 2. Deploy the verifier above. Confirm real deliveries land in YOUR logs.

# 3. Drop v1.
npx zavudev senders update $SENDER_ID --signature-version v2

Same thing over the API:

curl -X PATCH https://api.zavu.dev/v1/senders/$SENDER_ID \
  -H "Authorization: Bearer $ZAVUDEV_API_KEY" \
  -d '{"webhookSignatureVersion": "v1+v2"}'

A brand-new sender defaults to v2. If it points at an endpoint that already serves an older sender still reading v1, create it on both:

npx zavudev senders create --name Support --phone +15551234567 \
  --webhook-url https://api.example.com/webhooks/zavu \
  --webhook-events message.inbound \
  --signature-version v1+v2

v1 straight to v2 returns 400; set v1+v2 first. Step 2 is the one that matters: a receiver that answers 200 before verifying looks identical to a working one from Zavu's side, so a passing test request proves nothing. Confirm in the receiver's own logs.

Full guide: https://docs.zavu.dev/guides/receiving-messages/signature-migration

Tool webhooks are a different format

When an agent calls a tool that has a webhookUrl, Zavu POSTs with the same header name and a different shape:

X-Zavu-Signature: 2120e306a4ce...     bare hex, no t=, no v1=/v2=
X-Zavu-Timestamp: 1786113454812       separate header, MILLISECONDS
X-Zavu-Tool:      get_order_status

The digest is HMAC_SHA256(secret, body), same as v1, but the envelope differs, so the verifier above returns false on one of these. Write a second one, or branch on whether the header contains =.

Two things to know:

  • Every tool call is signed. Zavu generates a secret when the tool is created and returns it on that response only. Supply your own with --secret if you already have one. Lost it? Rotate: POST /v1/senders/{senderId}/agent/tools/{toolId}/webhook/secret. Reject when the header is missing; never "skip verification if unsigned".
  • Its payload carries its own timestamp, inside the signed body. Do the freshness check against that one rather than the X-Zavu-Timestamp header.

A tool declared in a Zavu Function with defineTool needs none of this: the handler runs inside the function, with no HTTP hop.

Retry Policy

AttemptDelay
1st retry1 minute
2nd retry5 minutes
3rd retry15 minutes
4th retry1 hour
5th retry4 hours

After 5 retries, delivery is marked as failed.

Best Practices

  1. Return 200 quickly - respond within 30 seconds, process async
  2. Verify signatures - always verify in production
  3. Idempotent handlers - check event.id to skip duplicates
  4. Use raw body - signature is computed on raw body, not parsed JSON
  5. Test with ngrok - expose local server for development

Comments

Markdown supported