Skip to content

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.

Tài liệu liên quan

Cấp phép theo điều khoản sử dụng của Zorio.