وبهوک نسخهٔ ۲
رویدادهای تماس با شناسهٔ ثابت، امضا، تلاش مجدد و تاریخچهٔ تحویل.
تنظیم اتصال#
در ماژول وبهوک، آدرس HTTPS دریافتکننده را ثبت کنید. در «تنظیمات نسخهٔ ۲» قالب را انتخاب کنید: «سازگار با اتصال فعلی» یا «نسخهٔ ۲؛ رویدادهای کامل تماس». قالب سازگار بهصورت پیشفرض فعال است. انتخاب خالی رویدادها یعنی همهٔ رویدادهای همان قالب.
مقصد باید HTTPS عمومی با گواهی معتبر باشد. تغییر مسیر HTTP دنبال نمیشود. آدرس محلی، خصوصی، نام کاربری داخل URL و پورت غیر از ۴۴۳ پذیرفته نمیشود. مقصد نسخهٔ ۱ با همین تغییرات محدود نمیشود؛ قرارداد قبلی نسخهٔ ۱ حفظ شده است.
ساختار پیام کامل#
{
"schema_version": 2,
"event_id": "UUID-EVENT",
"tenant_id": "YOUR-TENANT",
"call_id": "UUID-CALL",
"leg_id": "UUID-LEG",
"event": "call.connected",
"sequence": 12345,
"occurred_at": "2026-09-15T12:30:00+00:00",
"data": {
"caller": "9120000001",
"destination": "200",
"direction": "in",
"other_leg_id": "UUID-OTHER-LEG"
}
}event_id در تلاش مجدد ثابت است. call_id شناسهٔ مشترک تماس و leg_id شناسهٔ کانال است. sequence ترتیب ثبت در موتور را مشخص میکند؛ بهدلیل ارسال همزمان یا تلاش مجدد، ترتیب دریافت تضمین نمیشود. occurred_at زمان رویداد با منطقهٔ زمانی است. شناسهها را رشته نگه دارید.
رویدادها#
| رویداد | معنا |
|---|---|
call.started / call.ringing | شروع مسیر / زنگخوردن کانال مقصد |
call.answered | پاسخ کانال؛ answer_phase مشخص میکند PBX، داخلی مبدأ API (source) یا کانال مقصد پاسخ داده است. پاسخ IVR بهتنهایی اتصال مکالمه نیست. |
call.connected | برقراری پل مکالمه میان کانالها |
call.ended | پایان کانال اصلی؛ دادهٔ حسابداری ممکن است هنوز تکمیل شود |
call.cdr / call.cdr.updated | گزارش نهایی / تکمیل دادههای گزارش با revision؛ شامل answered، bill_sec، مقصد نهایی و اطلاعات مسیر |
recording.ready | آمادهشدن ضبط اصلی، ضبط مشترک کنفرانس یا ضبط عضو؛ دریافت از API با recording_id |
voicemail.created | ثبت پیام صندوق صوتی؛ دریافت با track=voicemail |
queue.entered / queue.answered / queue.abandoned | ورود، پاسخ داخلی صف یا خروج بدون پاسخ از صف |
transfer.started / transfer.completed / transfer.failed | شروع، اتصال مقصد یا خطای انتقال |
conference.created / conference.ended | ایجاد یا پایان کنفرانس |
conference.member_joined / conference.member_left | ورود و خروج عضو همراه شماره، شناسهٔ عضو و اتاق |
webhook.test | تست دستی از پنل فقط برای همان اتصال |
گزارش نهایی حداقل یک دقیقه برای جمعشدن اطلاعات کانالها صبر میکند؛ اگر اطلاعات مسیر یا کنفرانس دیر برسد، تکمیل گزارش دیرتر است. پردازش قطعشدهٔ رویدادهای زنده ممکن است برخی رویدادهای گذرا را از دست بدهد؛ حسابداری نهایی از CDR مستقل بازیابی میشود. برای نتیجهٔ قطعی کسبوکار از call.cdr و گزارش API استفاده کنید.
امضای درخواست#
هر ارسال این هدرها را دارد؛ کلید امضا را از تنظیمات همان وبهوک ایجاد یا تعویض کنید و فقط روی سرور دریافتکننده نگه دارید.
X-Hamkaran-Event-Id: UUID-EVENT X-Hamkaran-Timestamp: UNIX_SECONDS X-Hamkaran-Signature: sha256=HEX_SIGNATURE signature = HMAC_SHA256(secret, timestamp + "." + raw_request_body)
امضا را با بدنهٔ خام درخواست، پیش از تبدیل JSON، بررسی کنید. نمونهٔ PHP:
$raw = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_HAMKARAN_TIMESTAMP'] ?? '';
$received = $_SERVER['HTTP_X_HAMKARAN_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $raw, $secret);
if (!ctype_digit($timestamp) || abs(time() - (int)$timestamp) > 300
|| !hash_equals($expected, $received)) {
http_response_code(403);
exit;
}
// Persist event_id uniquely before returning 2xx.
// Handle business work asynchronously.در هر تلاش، timestamp و امضا تازه است؛ event_id همان شناسهٔ قبلی باقی میماند. از event_id برای جلوگیری از پردازش تکراری استفاده کنید. تعویض کلید امضا روی تلاشهای بعدی هم اثر دارد.
تحویل و تلاش مجدد#
ارسال از مسیر تماس جداست. دریافتکننده باید پس از ذخیرهٔ پایدار رویداد سریع پاسخ 2xx بدهد و کارهای طولانی را در صف خودش اجرا کند. مهلت هر درخواست ۵ ثانیه است. خطای شبکه، 408، 425، 429 و پاسخهای 5xx تا حداکثر ۸ تلاش با فاصلهٔ افزایشی و کمی تأخیر تصادفی دوباره ارسال میشوند. پاسخهای دیگر 4xx ناموفق نهایی هستند.
حداکثر چهار ارسال همزمان برای هر مقصد انجام میشود. تحویل میتواند تکراری باشد؛ پذیرش رویداد بدون ثبت شناسهٔ یکتا باعث اجرای تکراری در نرمافزار شما میشود. پس از پایان تلاشها، پنل امکان بررسی و ارسال مجدد با همان event_id را دارد. تاریخچهٔ تحویل و لاگ تلاشها بهطور پیشفرض ۳۰ روز نگهداری میشود؛ رویدادهای در انتظار بهدلیل رسیدن این مهلت حذف نمیشوند.
در لاگ همان اتصال، شناسهٔ رویداد و تحویل، شمارهٔ تلاش، وضعیت HTTP و مدت درخواست ثبت میشود. بدنهٔ پاسخ نرمافزار شما و کلید امضا ذخیره نمیشود.
قالب سازگار#
در قالب سازگار، call.ringing و call.answered به Newstate، call.ended به Hangup، call.cdr به Cdr و پیام صوتی به voicemail نگاشت میشود. فیلدهای event_name، unique_id، uniq، uuid، Puuid، CallerIDNum، source، destination، channel_id و engine_version در دسترس هستند. شناسهها همچنان UUID نسخهٔ ۲ هستند. برای رویدادهای کامل انتقال، صف و کنفرانس قالب v2 را انتخاب کنید.
قالب سازگار برای اتصال مرکز نوع ۲ به مصرفکنندهٔ قدیمی است؛ مراکز نوع ۱ از فرستنده و قرارداد فعلی خود استفاده میکنند.
Node.js؛ بررسی امن امضا#
بدنه را بهصورت Buffer خام بگیرید؛ امضا روی همان بایتهای دریافتشده محاسبه میشود. خروجی JSON.stringify دوبارهسازیشده جای بدنه خام نیست.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyHamkaran(rawBody, headers, secret) {
if (!secret || !Buffer.isBuffer(rawBody)) throw new Error('Invalid verifier configuration');
const timestamp = String(headers['x-hamkaran-timestamp'] || '');
const signature = String(headers['x-hamkaran-signature'] || '');
const eventId = String(headers['x-hamkaran-event-id'] || '');
if (!/^\d{1,12}$/.test(timestamp)) throw new Error('Invalid timestamp');
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
throw new Error('Expired signature');
}
if (!/^sha256=[a-f0-9]{64}$/i.test(signature)) throw new Error('Invalid signature');
const expected = createHmac('sha256', secret)
.update(timestamp + '.')
.update(rawBody)
.digest();
const received = Buffer.from(signature.slice(7), 'hex');
if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
throw new Error('Signature mismatch');
}
const event = JSON.parse(rawBody.toString('utf8'));
if (!eventId || event.event_id !== eventId) throw new Error('Event ID mismatch');
return event;
}
// In your POST handler:
// 1. Collect the raw body with a size limit before any JSON middleware.
// 2. Call verifyHamkaran(rawBody, request.headers, process.env.HAMKARAN_WEBHOOK_SECRET).
// 3. Persist event.event_id with a UNIQUE constraint and the payload in one transaction.
// 4. Return 204 after commit; your worker processes saved pending rows.
// Duplicate IDs are acknowledged but must not repeat business side effects.این تابع بخش اعتبارسنجی است؛ مسیر HTTP و ذخیره پایدار باید در برنامه شما اضافه شوند. در Express، مسیر دریافت raw body را پیش از middleware عمومی JSON تعریف کنید. ساعت سرور را همگام نگه دارید.
Laravel؛ دریافت و ذخیره پایدار رویداد#
در دیتابیس برنامه خود یک جدول inbox با شناسه یکتا بسازید. نمونه زیر برای MySQL است؛ مهاجرت آن را طبق ساختار پروژه خود تعریف کنید.
CREATE TABLE hamkaran_webhook_inbox ( event_id VARCHAR(64) PRIMARY KEY, payload JSON NOT NULL, received_at TIMESTAMP NOT NULL, processed_at TIMESTAMP NULL );
secret همان کلید امضای اتصال وبهوک است؛ با کلید وبسرویس تفاوت دارد. آن را در متغیر محیطی ذخیره و با config('services.hamkaran.webhook_secret') بخوانید.
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
Route::post('/hamkaran/webhook', function (Request $request) {
$secret = config('services.hamkaran.webhook_secret');
abort_unless(is_string($secret) && $secret !== '', 503);
$raw = $request->getContent();
abort_if(strlen($raw) > 1048576, 413);
$timestamp = (string) $request->header('X-Hamkaran-Timestamp', '');
$received = (string) $request->header('X-Hamkaran-Signature', '');
$eventId = (string) $request->header('X-Hamkaran-Event-Id', '');
abort_unless(preg_match('/^[0-9]{1,12}$/D', $timestamp), 403);
abort_if(abs(time() - (int) $timestamp) > 300, 403);
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $raw, $secret);
abort_unless(hash_equals($expected, $received), 403);
$event = json_decode($raw, true);
abort_unless(is_array($event) && $eventId !== ''
&& strlen($eventId) <= 64
&& ($event['event_id'] ?? null) === $eventId, 422);
// The primary key makes repeated delivery harmless.
// A database failure throws: no successful acknowledgement is sent.
DB::table('hamkaran_webhook_inbox')->insertOrIgnore([
'event_id' => $eventId,
'payload' => $raw,
'received_at' => now(),
]);
// A separate worker reads unprocessed inbox rows.
// Return quickly; do not call the CRM or process audio here.
return response('', 204);
});با prefix معمول Laravel، نشانی این مسیر https://your-domain.example/api/hamkaran/webhook است. مقدار واقعی دامنه عمومی خود را در پنل ثبت کنید. worker شما باید پردازش رویداد را هم idempotent طراحی کند؛ ذخیره یکتای ورودی بهتنهایی اثر جانبی worker را دقیقاً یکبار نمیکند.
تست اتصال و عیبیابی تحویل#
- آدرس عمومی HTTPS را ثبت کنید.گواهی معتبر، پورت ۴۴۳ و مسیر بدون redirect لازم است؛ localhost و IP خصوصی مقصد معتبر نیستند.
- قالب PBX V2 و secret را تنظیم کنید.کلید امضا فقط سمت دریافتکننده ذخیره شود. قالب سازگار فقط رویدادهای نگاشتشده قرارداد قدیمی را میفرستد.
- یک رویداد آزمایشی بفرستید.از تست اتصال در پنل استفاده کنید؛ رویداد webhook.test باید در inbox شما ثبت شود و HTTP 2xx برگردد.
- تلاش مجدد را بررسی کنید.در محیط آزمایش یک بار ۵۰۳ بدهید؛ ارسال بعدی همان event_id و امضای تازه دارد. اثر تجاری باید فقط یک بار اعمال شود.
| خطا / نشانه | راه بررسی |
|---|---|
private_destination | DNS مقصد روی سرور فرستنده به آدرس خصوصی اشاره میکند؛ مقصد باید عمومی باشد. |
dns_pending | آمادهسازی DNS هنوز انجام نشده؛ سیستم تلاش مجدد میکند. |
| امضا در برنامه من نامعتبر است | secret درست، بدنه خام، هدر timestamp و ساعت سرور را بررسی کنید؛ JSON را دوباره serialize نکنید. |
| تحویل delivered است ولی پردازش CRM انجام نشده | delivered فقط پاسخ موفق گیرنده است؛ inbox و worker برنامه خود را بررسی کنید. |
| رویدادهای انتقال یا کنفرانس نمیآیند | قالب کامل v2 و فیلتر رویدادهای انتخابشده را بررسی کنید. |
| یک رویداد چند بار میرسد | تحویل حداقل یکبار است؛ deduplication را براساس event_id انجام دهید. |
