Mẫu receiver
Code mẫu deploy receiver verify HMAC + dedupe + xử lý event Zorio. Copy-paste làm điểm khởi đầu, customize phần "Xử lý event" theo nghiệp vụ của bạn.
Node.js (Express)
js
const crypto = require('crypto');
const express = require('express');
const Redis = require('ioredis');
const SECRET = process.env.ZORIO_WEBHOOK_SECRET;
const redis = new Redis(process.env.REDIS_URL);
const app = express();
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const ts = parseInt(req.headers['x-zorio-timestamp'], 10);
if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) {
return res.status(400).send('Stale timestamp');
}
const sig = req.headers['x-zorio-signature'] || '';
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
if (sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}
const deliveryId = req.headers['x-zorio-delivery'];
const seen = await redis.set(`zorio:delivery:${deliveryId}`, '1', 'NX', 'EX', 86400);
if (seen === null) {
return res.status(200).send('Duplicate');
}
const event = JSON.parse(req.body.toString('utf8'));
res.status(200).send('OK');
await handleEvent(event).catch((err) => {
console.error('Process error:', err);
});
});
async function handleEvent(event) {
switch (event.event) {
case 'pbx.call.hangup':
console.log('Call ended:', event.data.call_uuid, event.data.hangup_cause);
break;
case 'telesales.lead.updated':
console.log('Lead updated:', event.data.id);
break;
}
}
app.listen(3000, () => console.log('Receiver on :3000'));PHP (hệ thống)
php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
class WebhookController extends Controller
{
public function handle(Request $request)
{
$secret = env('ZORIO_WEBHOOK_SECRET');
$ts = (int) $request->header('X-Zorio-Timestamp');
if (!$ts || abs(time() - $ts) > 300) {
return response('Stale timestamp', 400);
}
$raw = $request->getContent();
$signature = $request->header('X-Zorio-Signature', '');
$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);
if (!hash_equals($expected, $signature)) {
return response('Invalid signature', 401);
}
$deliveryId = $request->header('X-Zorio-Delivery');
$key = "zorio:delivery:{$deliveryId}";
$isNew = Redis::set($key, '1', 'NX', 'EX', 86400);
if (!$isNew) {
return response('Duplicate', 200);
}
$event = json_decode($raw, true);
dispatch(function () use ($event) {
$this->processEvent($event);
})->afterResponse();
return response('OK', 200);
}
protected function processEvent(array $event): void
{
match ($event['event']) {
'pbx.call.hangup' => logger()->info('Call ended', $event['data']),
'telesales.lead.updated' => logger()->info('Lead updated', $event['data']),
default => null,
};
}
}Đăng ký route ở routes/api.php:
php
Route::post('/webhook', [WebhookController::class, 'handle']);Python (FastAPI)
python
import hmac
import hashlib
import os
import time
import json
import redis.asyncio as redis
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
SECRET = os.environ["ZORIO_WEBHOOK_SECRET"].encode()
redis_client = redis.from_url(os.environ["REDIS_URL"])
app = FastAPI()
@app.post("/webhook")
async def webhook(request: Request, bg: BackgroundTasks):
ts = int(request.headers.get("x-zorio-timestamp", "0"))
if not ts or abs(time.time() - ts) > 300:
raise HTTPException(400, "Stale timestamp")
raw = await request.body()
signature = request.headers.get("x-zorio-signature", "")
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(401, "Invalid signature")
delivery_id = request.headers.get("x-zorio-delivery")
is_new = await redis_client.set(
f"zorio:delivery:{delivery_id}", "1", nx=True, ex=86400
)
if not is_new:
return {"status": "duplicate"}
event = json.loads(raw)
bg.add_task(process_event, event)
return {"status": "ok"}
async def process_event(event: dict) -> None:
match event["event"]:
case "pbx.call.hangup":
print(f"Call ended: {event['data']['call_uuid']}")
case "telesales.lead.updated":
print(f"Lead updated: {event['data']['id']}")Go (net/http)
go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strconv"
"time"
)
var secret = []byte(os.Getenv("ZORIO_WEBHOOK_SECRET"))
func handler(w http.ResponseWriter, r *http.Request) {
ts, _ := strconv.ParseInt(r.Header.Get("X-Zorio-Timestamp"), 10, 64)
if ts == 0 || abs(time.Now().Unix()-ts) > 300 {
http.Error(w, "Stale timestamp", 400)
return
}
raw, _ := io.ReadAll(r.Body)
sig := r.Header.Get("X-Zorio-Signature")
mac := hmac.New(sha256.New, secret)
mac.Write(raw)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(sig), []byte(expected)) {
http.Error(w, "Invalid signature", 401)
return
}
w.WriteHeader(200)
w.Write([]byte("OK"))
var event struct {
Event string `json:"event"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(raw, &event); err == nil {
go processEvent(event.Event, event.Data)
}
}
func processEvent(name string, data map[string]interface{}) {
log.Printf("event=%s data=%+v", name, data)
}
func abs(x int64) int64 { if x < 0 { return -x }; return x }
func main() {
http.HandleFunc("/webhook", handler)
log.Fatal(http.ListenAndServe(":3000", nil))
}Checklist trước khi go-live
- [ ] HTTPS (TLS valid, không self-signed).
- [ ] Verify HMAC + timestamp ở mọi request.
- [ ] Dedupe theo
X-Zorio-Delivery. - [ ] Xử lý event async, trả 200 nhanh trong < 5s.
- [ ] Log đầy đủ payload + delivery_id để debug.
- [ ] Test bằng nút Send test event ở Admin Console.
- [ ] Stress test ≥ 200 req/s nếu volume lớn.
