webhook-setup

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Webhook Setup

Webhook 配置

When to Use

使用场景

Use this skill when setting up webhook endpoints to receive inbound messages, delivery status updates, or template approval notifications from Zavu.
当你需要配置Webhook端点以接收来自Zavu的入站消息、送达状态更新或模板审批通知时,可使用此技能。

Webhook Types

Webhook 类型

  • Sender Webhooks: Message events (inbound, delivery status, templates) - configured per sender
  • Project Webhooks: Project-level events (partner invitations) - one per project
  • Sender Webhooks: 消息事件(入站、送达状态、模板)- 按发送方配置
  • Project Webhooks: 项目级事件(合作伙伴邀请)- 每个项目一个

Available Events

可用事件

EventCategoryDescription
message.inbound
InboundCustomer sent you a message
conversation.new
InboundFirst message from a new contact
message.unsupported
InboundUnsupported message type received
message.queued
OutboundMessage queued for delivery
message.sent
OutboundMessage sent to carrier
message.delivered
OutboundMessage delivered to recipient
message.read
OutboundMessage read by recipient
message.failed
OutboundMessage delivery failed
broadcast.status_changed
BroadcastsBroadcast status changed
template.status_changed
TemplatesWhatsApp template approval status changed
invitation.status_changed
InvitationsPartner invitation status changed
事件分类描述
message.inbound
入站客户向你发送了一条消息
conversation.new
入站收到新联系人的第一条消息
message.unsupported
入站收到不支持的消息类型
message.queued
出站消息已进入送达队列
message.sent
出站消息已发送至运营商
message.delivered
出站消息已送达收件人
message.read
出站消息已被收件人读取
message.failed
出站消息送达失败
broadcast.status_changed
广播广播状态已变更
template.status_changed
模板WhatsApp模板审批状态已变更
invitation.status_changed
邀请合作伙伴邀请状态已变更

Configure Webhook via SDK

通过SDK配置Webhook

TypeScript - Create Sender with Webhook

TypeScript - 创建带Webhook的发送方

typescript
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!
typescript
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

更新Webhook

typescript
await zavu.senders.update({
  senderId: "snd_abc123",
  webhookUrl: "https://new-url.com/webhooks",
  webhookEvents: ["message.inbound"],
  webhookActive: true,
});
typescript
await zavu.senders.update({
  senderId: "snd_abc123",
  webhookUrl: "https://new-url.com/webhooks",
  webhookEvents: ["message.inbound"],
  webhookActive: true,
});

Regenerate Secret

重新生成密钥

typescript
const result = await zavu.senders.webhookSecret.regenerate({
  senderId: "snd_abc123",
});
console.log(result.secret); // whsec_new_secret...
typescript
const result = await zavu.senders.webhookSecret.regenerate({
  senderId: "snd_abc123",
});
console.log(result.secret); // whsec_new_secret...

Webhook Payload Structure

Webhook 负载结构

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

Signature Verification

签名验证

Header:
X-Zavu-Signature: t=<timestamp>,v1=<hmac_sha256>
请求头:
X-Zavu-Signature: t=<timestamp>,v1=<hmac_sha256>

TypeScript (Express)

TypeScript (Express)

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

const app = express();
app.use("/webhooks/zavu", express.raw({ type: "application/json" }));

function verifyZavuSignature(req: express.Request, secret: string): boolean {
  const header = req.headers["x-zavu-signature"] as string;
  if (!header) return false;

  const parts = header.split(",");
  const timestamp = parseInt(parts.find(p => p.startsWith("t="))!.slice(2));
  const signature = parts.find(p => p.startsWith("v1="))!.slice(3);

  // Reject if older than 5 minutes (replay protection)
  if (Math.floor(Date.now() / 1000) - timestamp > 300) return false;

  const signedPayload = `${timestamp}.${req.body.toString()}`;
  const expected = crypto.createHmac("sha256", secret).update(signedPayload).digest("hex");

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

app.post("/webhooks/zavu", (req, res) => {
  if (!verifyZavuSignature(req, process.env.ZAVU_WEBHOOK_SECRET!)) {
    return res.status(401).send("Invalid signature");
  }

  const event = JSON.parse(req.body.toString());
  res.status(200).send("OK");

  // Process async
  processEvent(event).catch(console.error);
});

async function processEvent(event: any) {
  switch (event.type) {
    case "message.inbound":
      console.log("Inbound from:", event.data.from, event.data.text);
      break;
    case "message.delivered":
      console.log("Delivered:", event.data.messageId);
      break;
    case "message.failed":
      console.log("Failed:", event.data.messageId, event.data.errorMessage);
      break;
  }
}
typescript
import crypto from "crypto";
import express from "express";

const app = express();
app.use("/webhooks/zavu", express.raw({ type: "application/json" }));

function verifyZavuSignature(req: express.Request, secret: string): boolean {
  const header = req.headers["x-zavu-signature"] as string;
  if (!header) return false;

  const parts = header.split(",");
  const timestamp = parseInt(parts.find(p => p.startsWith("t="))!.slice(2));
  const signature = parts.find(p => p.startsWith("v1="))!.slice(3);

  // Reject if older than 5 minutes (replay protection)
  if (Math.floor(Date.now() / 1000) - timestamp > 300) return false;

  const signedPayload = `${timestamp}.${req.body.toString()}`;
  const expected = crypto.createHmac("sha256", secret).update(signedPayload).digest("hex");

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

app.post("/webhooks/zavu", (req, res) => {
  if (!verifyZavuSignature(req, process.env.ZAVU_WEBHOOK_SECRET!)) {
    return res.status(401).send("Invalid signature");
  }

  const event = JSON.parse(req.body.toString());
  res.status(200).send("OK");

  // Process async
  processEvent(event).catch(console.error);
});

async function processEvent(event: any) {
  switch (event.type) {
    case "message.inbound":
      console.log("Inbound from:", event.data.from, event.data.text);
      break;
    case "message.delivered":
      console.log("Delivered:", event.data.messageId);
      break;
    case "message.failed":
      console.log("Failed:", event.data.messageId, event.data.errorMessage);
      break;
  }
}

Python (Flask)

Python (Flask)

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

app = Flask(__name__)

def verify_zavu_signature(req, secret):
    header = req.headers.get("X-Zavu-Signature")
    if not header:
        return False

    parts = header.split(",")
    timestamp = int(next(p for p in parts if p.startswith("t="))[2:])
    signature = next(p for p in parts if p.startswith("v1="))[3:]

    # Reject if older than 5 minutes
    if int(time.time()) - timestamp > 300:
        return False

    signed_payload = f"{timestamp}.{req.data.decode('utf-8')}"
    expected = hmac.new(
        secret.encode(), signed_payload.encode(), hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

@app.route("/webhooks/zavu", methods=["POST"])
def handle_webhook():
    if not verify_zavu_signature(request, WEBHOOK_SECRET):
        return "Invalid signature", 401

    event = request.json
    # Process event...
    return "OK", 200
python
import hmac, hashlib, time
from flask import Flask, request

app = Flask(__name__)

def verify_zavu_signature(req, secret):
    header = req.headers.get("X-Zavu-Signature")
    if not header:
        return False

    parts = header.split(",")
    timestamp = int(next(p for p in parts if p.startswith("t="))[2:])
    signature = next(p for p in parts if p.startswith("v1="))[3:]

    # Reject if older than 5 minutes
    if int(time.time()) - timestamp > 300:
        return False

    signed_payload = f"{timestamp}.{req.data.decode('utf-8')}"
    expected = hmac.new(
        secret.encode(), signed_payload.encode(), hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

@app.route("/webhooks/zavu", methods=["POST"])
def handle_webhook():
    if not verify_zavu_signature(request, WEBHOOK_SECRET):
        return "Invalid signature", 401

    event = request.json
    # Process event...
    return "OK", 200

Go

Go

go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

func verifyZavuSignature(r *http.Request, secret string) ([]byte, bool) {
	header := r.Header.Get("X-Zavu-Signature")
	if header == "" {
		return nil, false
	}

	parts := strings.Split(header, ",")
	var timestamp int64
	var signature string
	for _, part := range parts {
		if strings.HasPrefix(part, "t=") {
			timestamp, _ = strconv.ParseInt(part[2:], 10, 64)
		} else if strings.HasPrefix(part, "v1=") {
			signature = part[3:]
		}
	}

	if time.Now().Unix()-timestamp > 300 {
		return nil, false
	}

	body, _ := io.ReadAll(r.Body)
	signedPayload := strconv.FormatInt(timestamp, 10) + "." + string(body)
	h := hmac.New(sha256.New, []byte(secret))
	h.Write([]byte(signedPayload))
	expected := hex.EncodeToString(h.Sum(nil))

	return body, hmac.Equal([]byte(expected), []byte(signature))
}

func main() {
	secret := os.Getenv("ZAVU_WEBHOOK_SECRET")
	http.HandleFunc("/webhooks/zavu", func(w http.ResponseWriter, r *http.Request) {
		body, valid := verifyZavuSignature(r, secret)
		if !valid {
			http.Error(w, "Invalid signature", http.StatusUnauthorized)
			return
		}

		var event map[string]interface{}
		json.Unmarshal(body, &event)
		// Process event...
		w.WriteHeader(http.StatusOK)
	})
	http.ListenAndServe(":3000", nil)
}
go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

func verifyZavuSignature(r *http.Request, secret string) ([]byte, bool) {
	header := r.Header.Get("X-Zavu-Signature")
	if header == "" {
		return nil, false
	}

	parts := strings.Split(header, ",")
	var timestamp int64
	var signature string
	for _, part := range parts {
		if strings.HasPrefix(part, "t=") {
			timestamp, _ = strconv.ParseInt(part[2:], 10, 64)
		} else if strings.HasPrefix(part, "v1=") {
			signature = part[3:]
		}
	}

	if time.Now().Unix()-timestamp > 300 {
		return nil, false
	}

	body, _ := io.ReadAll(r.Body)
	signedPayload := strconv.FormatInt(timestamp, 10) + "." + string(body)
	h := hmac.New(sha256.New, []byte(secret))
	h.Write([]byte(signedPayload))
	expected := hex.EncodeToString(h.Sum(nil))

	return body, hmac.Equal([]byte(expected), []byte(signature))
}

func main() {
	secret := os.Getenv("ZAVU_WEBHOOK_SECRET")
	http.HandleFunc("/webhooks/zavu", func(w http.ResponseWriter, r *http.Request) {
		body, valid := verifyZavuSignature(r, secret)
		if !valid {
			http.Error(w, "Invalid signature", http.StatusUnauthorized)
			return
		}

		var event map[string]interface{}
		json.Unmarshal(body, &event)
		// Process event...
		w.WriteHeader(http.StatusOK)
	})
	http.ListenAndServe(":3000", nil)
}

Ruby (Sinatra)

Ruby (Sinatra)

ruby
require "sinatra"
require "openssl"
require "json"

def verify_zavu_signature(request, secret)
  header = request.env["HTTP_X_ZAVU_SIGNATURE"]
  return false unless header

  parts = header.split(",")
  timestamp = parts.find { |p| p.start_with?("t=") }&.[](2..)&.to_i
  signature = parts.find { |p| p.start_with?("v1=") }&.[](3..)

  return false unless timestamp && signature
  return false if Time.now.to_i - timestamp > 300

  raw_body = request.body.read
  request.body.rewind
  signed_payload = "#{timestamp}.#{raw_body}"
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)

  Rack::Utils.secure_compare(expected, signature)
end

post "/webhooks/zavu" do
  halt 401, "Invalid signature" unless verify_zavu_signature(request, ENV["ZAVU_WEBHOOK_SECRET"])

  event = JSON.parse(request.body.read)
  # Process event...
  status 200
end
ruby
require "sinatra"
require "openssl"
require "json"

def verify_zavu_signature(request, secret)
  header = request.env["HTTP_X_ZAVU_SIGNATURE"]
  return false unless header

  parts = header.split(",")
  timestamp = parts.find { |p| p.start_with?("t=") }&.[](2..)&.to_i
  signature = parts.find { |p| p.start_with?("v1=") }&.[](3..)

  return false unless timestamp && signature
  return false if Time.now.to_i - timestamp > 300

  raw_body = request.body.read
  request.body.rewind
  signed_payload = "#{timestamp}.#{raw_body}"
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)

  Rack::Utils.secure_compare(expected, signature)
end

post "/webhooks/zavu" do
  halt 401, "Invalid signature" unless verify_zavu_signature(request, ENV["ZAVU_WEBHOOK_SECRET"])

  event = JSON.parse(request.body.read)
  # Process event...
  status 200
end

PHP

PHP

php
<?php
function verifyZavuSignature(string $secret): bool {
    $header = $_SERVER['HTTP_X_ZAVU_SIGNATURE'] ?? '';
    if (empty($header)) return false;

    $parts = explode(',', $header);
    $timestamp = $signature = null;
    foreach ($parts as $part) {
        if (str_starts_with($part, 't=')) $timestamp = (int) substr($part, 2);
        elseif (str_starts_with($part, 'v1=')) $signature = substr($part, 3);
    }

    if (!$timestamp || !$signature) return false;
    if (time() - $timestamp > 300) return false;

    $rawBody = file_get_contents('php://input');
    $expected = hash_hmac('sha256', "{$timestamp}.{$rawBody}", $secret);

    return hash_equals($expected, $signature);
}

if (!verifyZavuSignature(getenv('ZAVU_WEBHOOK_SECRET'))) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode(file_get_contents('php://input'), true);
// Process event...
http_response_code(200);
php
<?php
function verifyZavuSignature(string $secret): bool {
    $header = $_SERVER['HTTP_X_ZAVU_SIGNATURE'] ?? '';
    if (empty($header)) return false;

    $parts = explode(',', $header);
    $timestamp = $signature = null;
    foreach ($parts as $part) {
        if (str_starts_with($part, 't=')) $timestamp = (int) substr($part, 2);
        elseif (str_starts_with($part, 'v1=')) $signature = substr($part, 3);
    }

    if (!$timestamp || !$signature) return false;
    if (time() - $timestamp > 300) return false;

    $rawBody = file_get_contents('php://input');
    $expected = hash_hmac('sha256', "{$timestamp}.{$rawBody}", $secret);

    return hash_equals($expected, $signature);
}

if (!verifyZavuSignature(getenv('ZAVU_WEBHOOK_SECRET'))) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode(file_get_contents('php://input'), true);
// Process event...
http_response_code(200);

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.
尝试次数延迟时间
第1次重试1分钟
第2次重试5分钟
第3次重试15分钟
第4次重试1小时
第5次重试4小时
5次重试后,送达将标记为失败。

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
  1. 快速返回200状态码 - 30秒内响应,异步处理
  2. 验证签名 - 生产环境中务必验证
  3. 幂等处理程序 - 检查
    event.id
    以跳过重复事件
  4. 使用原始请求体 - 签名基于原始请求体计算,而非解析后的JSON
  5. 使用ngrok测试 - 在开发阶段暴露本地服务器