ზარების მოვლენების ვებჰუკები
CRM-ის ინტეგრატორისა და ბიზნესის მფლობელისთვის: ვებჰუკი თქვენს სისტემას ზარის შესახებ ატყობინებს, სანამ ის მიმდინარეობს. შეგიძლიათ თანამშრომელს კლიენტის ბარათი გაუხსნათ, გამოტოვებულის შემდეგ ამოცანა შექმნათ ან საკუთარი სტატისტიკა ააწყოთ. საჭიროა საჯარო HTTPS-მიმღები; მის გარეშე მოვლენების ნაკადის გამოკითხვა გამოიყენეთ. გასაღებები და უფლებები — საკუთარი PBX-ის API-სა და MCP-ის მიმოხილვაში.
სტატიაში: შექმნა, მოვლენები, ხელმოწერა, მიწოდება, გამოკითხვა, გამართვა.
ვებჰუკის შექმნა#
გახსენით ტელეფონია → API და MCP → ვებჰუკები, დაამატეთ სახელი და საჯარო HTTPS-მისამართი, აირჩიეთ ობიექტები და მოვლენები. შეინახეთ საიდუმლო whsec_…: ის ერთხელ ჩანს. ღილაკი „შემოწმება“ გაგზავნის webhook.ping-ს. ერთ ანგარიშზე 10-მდე ვებჰუკი; სახელი 60 სიმბოლომდე, URL — 500-მდე.
API-სთვის საჭიროა გასაღები ცალკე მინიჭებული webhooks:write-ით. ცარიელი events ზარების ხუთივე მოვლენას ნიშნავს; ცარიელი locations — ყველა ობიექტს. ობიექტებით შეზღუდულმა გასაღებმა ნებადართული locations აშკარად უნდა მიუთითოს და მთელი ანგარიშის ვებჰუკს ვერ მართავს.
curl -sS 'https://api.cenaly.ge/pbx-api/v1/webhooks' \
-H 'Authorization: Bearer cpx_…' -H 'Content-Type: application/json' \
-d '{"name":"CRM","url":"https://crm.example.com/cenaly/events","events":["call.started","call.ended"],"locations":["loc1"],"enabled":true}'
პასუხის მაგალითი (დრო, id და საიდუმლო გამოგონილია):
{
"ok": true,
"created": true,
"webhook": {
"id": "wh_0123456789ab",
"name": "CRM",
"url": "https://crm.example.com/cenaly/events",
"events": [
"call.started",
"call.ended"
],
"locations": [
"loc1"
],
"enabled": true,
"createdAt": "2026-10-24T08:00:00Z",
"secretHint": "whsec_exampl"
},
"secret": "whsec_…"
}
PATCH /v1/webhooks/{id} ცვლის გადაცემულ პარამეტრებს. POST …/{id}/rotate-secret გასცემს ახალ საიდუმლოს და ძველს მაშინვე აუქმებს; DELETE …/{id} შლის გამოწერას. როტაციისას საიდუმლოს შეცვლა მიმღებთან შეათანხმეთ.
თუ ობიექტს აქვს საკუთარი სატელეფონო სადგური პირდაპირ რეჟიმში, მოვლენებს სადგურის სერვერი აგზავნის; სხვა შემთხვევაში — Cenaly-ის ღრუბელი. თითოეული მოვლენა ერთი გზით იგზავნება, დუბლიკატების გარეშე; სათაურები, ხელმოწერა, User-Agent: Cenaly-Webhooks/1.0, ლოდინის ვადა და განმეორებითი ცდები ორივე გზაზე ერთნაირია. გამგზავნის მისამართი შეიძლება იყოს სადგურისაც და ღრუბლისაც, ამიტომ ნამდვილობა სხეულის ხელმოწერით შეამოწმეთ და არა IP-ით.
მოვლენების ტიპები და შიგთავსი#
| ტიპი | როდის ხდება | data-ს განსაკუთრებული ველები |
|---|---|---|
call.started |
ზარის დასაწყისი | მიმართულება, მხარეები და ხაზი, თუ ცნობილია |
call.ringing |
ტელეფონმა პირველად დარეკა | party: ვისთან; ერთ ზარზე შეიძლება რამდენიმე მოვლენა იყოს |
call.answered |
პირველმა ადამიანმა უპასუხა | party: ვინ უპასუხა |
call.transferred |
საუბრის გადართვა | party: ვისთან, by: ვინ გადართო |
call.ended |
ზარი დასრულდა | status, ხანგრძლივობები, answeredBy, hangupBy, თუ არსებობს |
webhook.ping |
დააჭირეთ „შემოწმებას“ | სამსახურებრივი შეტყობინება და არა ზარი |
გარსი ყოველთვის შეიცავს id, type, createdAt, locationId, webhookId, attempt, data ველებს. ზარის მოვლენებში data საწყისი მოვლენაა ველებით id, type, at, callId, seq. createdAt მოვლენის დროიდან აიღება; ეს განმეორებითი მიწოდების დრო არ არის. არასავალდებულო ველები შეიძლება არ იყოს, ნულოვანი ხანგრძლივობები ჩვეულებრივ არ გადაიცემა.
ქვემოთ ცალკეული შიგთავსის სრული მაგალითები გამოგონილ ზარს აღწერს. ხაზის, ტრანკისა და მხარეების ველები მარშრუტზეა დამოკიდებული, ამიტომ მათ არსებობას ყოველთვის ვერ მოითხოვთ.
call.started#
{
"id": "1792836000.1:1",
"type": "call.started",
"createdAt": "2026-10-24T10:00:00Z",
"locationId": "loc1",
"webhookId": "wh_0123456789ab",
"attempt": 1,
"data": {
"id": "1792836000.1:1",
"type": "call.started",
"at": "2026-10-24T10:00:00Z",
"callId": "1792836000.1",
"seq": 1,
"direction": "inbound",
"from": {
"kind": "caller",
"number": "+995555123456"
},
"to": {
"kind": "operator",
"extension": "101"
}
}
}
call.ringing#
{
"id": "1792836000.1:2",
"type": "call.ringing",
"createdAt": "2026-10-24T10:00:01Z",
"locationId": "loc1",
"webhookId": "wh_0123456789ab",
"attempt": 1,
"data": {
"id": "1792836000.1:2",
"type": "call.ringing",
"at": "2026-10-24T10:00:01Z",
"callId": "1792836000.1",
"seq": 2,
"direction": "inbound",
"from": {
"kind": "caller",
"number": "+995555123456"
},
"to": {
"kind": "operator",
"extension": "101"
},
"party": {
"kind": "operator",
"extension": "101",
"name": "Анна"
}
}
}
call.answered#
{
"id": "1792836000.1:3",
"type": "call.answered",
"createdAt": "2026-10-24T10:00:05Z",
"locationId": "loc1",
"webhookId": "wh_0123456789ab",
"attempt": 1,
"data": {
"id": "1792836000.1:3",
"type": "call.answered",
"at": "2026-10-24T10:00:05Z",
"callId": "1792836000.1",
"seq": 3,
"direction": "inbound",
"from": {
"kind": "caller",
"number": "+995555123456"
},
"to": {
"kind": "operator",
"extension": "101"
},
"party": {
"kind": "operator",
"extension": "101",
"name": "Анна"
}
}
}
call.transferred#
{
"id": "1792836000.1:4",
"type": "call.transferred",
"createdAt": "2026-10-24T10:00:30Z",
"locationId": "loc1",
"webhookId": "wh_0123456789ab",
"attempt": 1,
"data": {
"id": "1792836000.1:4",
"type": "call.transferred",
"at": "2026-10-24T10:00:30Z",
"callId": "1792836000.1",
"seq": 4,
"direction": "inbound",
"from": {
"kind": "caller",
"number": "+995555123456"
},
"to": {
"kind": "operator",
"extension": "101"
},
"party": {
"kind": "operator",
"extension": "102",
"name": "Иван"
},
"by": {
"kind": "operator",
"extension": "101",
"name": "Анна"
}
}
}
call.ended#
{
"id": "1792836000.1:5",
"type": "call.ended",
"createdAt": "2026-10-24T10:01:05Z",
"locationId": "loc1",
"webhookId": "wh_0123456789ab",
"attempt": 1,
"data": {
"id": "1792836000.1:5",
"type": "call.ended",
"at": "2026-10-24T10:01:05Z",
"callId": "1792836000.1",
"seq": 5,
"direction": "inbound",
"from": {
"kind": "caller",
"number": "+995555123456"
},
"to": {
"kind": "operator",
"extension": "101"
},
"status": "answered",
"answeredBy": {
"kind": "operator",
"extension": "101",
"name": "Анна"
},
"durationSec": 65,
"talkSec": 60,
"ringSec": 5
}
}
დამატებით შეიძლება მოვიდეს lineId, lineLabel, line (id, number, caption, resolution, source), trunk (id, name), redirectedFrom, transferOf, hangupBy. მხარეებს შეიძლება ჰქონდეთ operatorId და connectionId. უცნობი ველები შეინახეთ ან უგულებელყავით; სქემის გაფართოებისას მოვლენას ნუ უარყოფთ.
webhook.ping#
{
"id": "ping:example",
"type": "webhook.ping",
"createdAt": "2026-10-24T10:00:00Z",
"locationId": "",
"webhookId": "wh_0123456789ab",
"attempt": 1,
"data": {
"message": "Cenaly: проверка вебхука / webhook test"
}
}
ტესტის locationId შეიძლება ცარიელი იყოს. შეამოწმეთ ხელმოწერა, უპასუხეთ 2xx-ით, CRM-ში ზარს ან დავალებას ნუ შექმნით.
სათაურები#
| სათაური | მნიშვნელობა |
|---|---|
Content-Type |
application/json |
X-Cenaly-Signature |
t=<unix>,v1=<hex HMAC-SHA256> |
X-Cenaly-Event |
მოვლენის ტიპი |
X-Cenaly-Delivery |
მოვლენის Id, გამეორებისას იგივე |
X-Cenaly-Webhook |
გამოწერის Id |
User-Agent |
Cenaly-Webhooks/1.0 (+https://cenaly.com/docs/pbx-api) |
ხელმოწერის შემოწმება#
ხელმოწერილია სხეულის საწყისი ბაიტები: HMAC-SHA256(secret, t + "." + rawBody). საიდუმლო მთლიანად გამოიყენება, whsec_-ის ჩათვლით, base64-დეკოდირების გარეშე. შემოწმებამდე JSON ხელახლა არ დაშალოთ და არ დაასერიალიზოთ. მაგალითები უარყოფს timestamp-ს, თუ ის მიმღების საათს ნებისმიერი მიმართულებით 300 წამზე მეტად სცდება, და ხელმოწერას მუდმივ დროში ადარებს. საათები სინქრონიზებული გქონდეთ.
Node.js#
ფუნქცია Buffer-ს იღებს JSON middleware-მდე, მაგალითად HTTP-შეკითხვის ნაკადიდან აგებულს. საიდუმლო დაცული პარამეტრებიდან აიღეთ და არა შეკითხვის სათაურიდან.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(raw, header, secret, now = Date.now() / 1000) {
const m = /^t=(\d{1,12}),v1=([0-9a-f]{64})$/.exec(header || '');
if (!m || Math.abs(now - Number(m[1])) > 300) return false;
const expected = createHmac('sha256', secret)
.update(m[1] + '.').update(raw).digest();
return timingSafeEqual(expected, Buffer.from(m[2], 'hex'));
}
Python#
raw — bytes, მაგალითად request.get_data() JSON-ის გარჩევამდე.
import hashlib, hmac, re, time
def verify(raw, header, secret, now=None):
m = re.fullmatch(r"t=(\d{1,12}),v1=([0-9a-f]{64})", header or "")
if not m or abs((time.time() if now is None else now) - int(m[1])) > 300:
return False
expected = hmac.new(secret.encode(), m[1].encode() + b"." + raw,
hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, m[2])
PHP#
<?php
function verifySignature(string $raw, string $header, string $secret): bool {
if (!preg_match('/\At=(\d{1,12}),v1=([0-9a-f]{64})\z/', $header, $m)) return false;
if (abs(time() - (int)$m[1]) > 300) return false;
$expected = hash_hmac('sha256', $m[1] . '.' . $raw, $secret);
return hash_equals($expected, $m[2]);
}
$raw = file_get_contents('php://input');
if (!verifySignature($raw, $_SERVER['HTTP_X_CENALY_SIGNATURE'] ?? '', getenv('PBX_WEBHOOK_SECRET') ?: '')) {
http_response_code(401);
exit;
}
// Перед ответом надёжно сохраните событие в очередь; при сбое сохранения верните 503.
Go#
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"regexp"
"strconv"
"time"
)
var signatureRE = regexp.MustCompile(`^t=([0-9]{1,12}),v1=([0-9a-f]{64})$`)
func Verify(raw []byte, header, secret string, now time.Time) bool {
m := signatureRE.FindStringSubmatch(header)
if m == nil { return false }
ts, err := strconv.ParseInt(m[1], 10, 64)
if err != nil || ts < now.Unix()-300 || ts > now.Unix()+300 { return false }
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(m[1] + "."))
mac.Write(raw)
got, err := hex.DecodeString(m[2])
return err == nil && hmac.Equal(mac.Sum(nil), got)
}
წარმატებული შემოწმების შემდეგ დაშალეთ JSON, შეამოწმეთ საჭირო ველები და შეინახეთ მოვლენა. თქვენს სერვერზე შეზღუდეთ შემომავალი HTTP-სხეულის ზომა. არასწორი ხელმოწერისას — 401, შენახვის შეუძლებლობისას — 5xx, საიმედო მიღების შემდეგ — 200 ან 204.
მიწოდება, გამეორებები და რიგითობა#
2xx 10 წამში დააბრუნეთ. მძიმე სამუშაო პასუხის შემდეგ თქვენი რიგიდან შეასრულეთ, მოვლენა კი დადასტურებამდე შეინახეთ. 3xx არ გადამისამართდება; 4xx, 5xx, ტაიმაუტი და ქსელის შეცდომა წარუმატებლობაა.
| შემდეგი მცდელობა | პაუზა წინა მცდელობის შემდეგ |
|---|---|
| 2 | 10 წამი |
| 3 | 30 წამი |
| 4 | 2 წუთი |
| 5 | 5 წუთი |
| 6 | 10 წუთი |
| 7 | 15 წუთი |
| 8 | 15 წუთი |
სულ 8 მცდელობაა; პაუზები ჯამში 47 წუთი და 40 წამია, მოთხოვნებისა და რიგის დროის გარეშე. webhook.ping ერთხელ იგზავნება.
ზარის მოვლენის id არის <callId>:<seq>. გამეორებისას id უცვლელია, attempt იზრდება, ხელმოწერა და timestamp თავიდან ითვლება. საცავში უნიკალურობა (webhookId, id) გამოიყენეთ რამდენიმე გამოწერისას, ან id ერთი გამოწერისას. უკვე შენახულ გამეორებასაც 2xx დაუდასტურეთ. დავალების ჩასმა და id-ის ჩაწერა ერთ ტრანზაქციად გააკეთეთ.
HTTP-მიწოდებების რიგი გარანტირებული არაა: call.started-ის გამეორება შეიძლება call.ended-ის შემდეგ მოვიდეს. მდგომარეობა locationId, data.callId და data.seq-ის მიხედვით ააგეთ და დასრულებული ზარი «რეკავს» მდგომარეობაში არ დააბრუნოთ. seq-ის გამოტოვება აუცილებლად დაკარგვას არ ნიშნავს: გამოწერას შეიძლება შუალედური ტიპები გამორიცხული ჰქონდეს. საბოლოოს შესადარებლად მოვლენების ლენტი და ზარების ჟურნალი გამოიყენეთ; ვებჰუკები უვადო არქივს ვერ ცვლის.
მინიმუმ 72 საათის განმავლობაში რეალური მიწოდებების უწყვეტი წარუმატებლობის შემდეგ ვებჰუკი ავტომატურად გაითიშება და მფლობელი შეტყობინებას მიიღებს. წარმატებული რეალური მიწოდება სერიას ანულებს; სატესტო ping მასზე გავლენას არ ახდენს. მხოლოდ ზარების არქონა ვებჰუკს თავისით არ თიშავს.
ფანჯარაში გამოჩნდება «ავტომატურად გამორთულია» და «ჩართვა». მიმღების გამოსწორების შემდეგ იქ ჩართეთ ან გაგზავნეთ PATCH /v1/webhooks/{id} სხეულით {"enabled":true}. ძველი გამოტოვებები ცალკე შეამოწმეთ.
ჟურნალი გამოწერის ფანჯარასა და GET /v1/webhooks/{id}/deliveries-შია: ბოლო 100 მცდელობა, დრო, მოვლენა, მცდელობის ნომერი, HTTP-კოდი, შეცდომა და შედეგი. კოდი 0 ნიშნავს, რომ HTTP-პასუხი ვერ მივიღეთ. POST /v1/webhooks/{id}/test ping-ს რიგში აყენებს; ბრძანების მიღება წარმატებულ მიწოდებას არ ნიშნავს — ჟურნალი წაიკითხეთ.
საკუთარი სერვერის გარეშე#
calls:read უფლება იძლევა GET /v1/events-ს და MCP get_call_events-ს. ეს საწყისი მოვლენებია webhook-გარსის, attempt-ისა და ხელმოწერის გარეშე. ლენტი პუნქტის 7 დღის განმავლობაში 2000-მდე მოვლენას ინახავს. პარამეტრებია: since — id ან ISO-დრო, types — მძიმით გამოყოფილი ტიპები, limit — 500-მდე.
curl -sS -G 'https://api.cenaly.ge/pbx-api/v1/events' \
-H 'Authorization: Bearer cpx_…' \
--data-urlencode 'location=loc1' --data-urlencode 'since=1792836000.1:5' \
--data-urlencode 'limit=100'
{
"location": "loc1",
"events": [],
"more": false
}
მოვლენების არსებობისას პასუხი შეიცავს next-ს — ბოლო გაცემული id-ს. დამუშავების შემდეგ შეინახეთ და შემდეგ მოთხოვნაში since-ად გადასცით. more:true-ისას დაუყოვნებლივ გააგრძელეთ, false-ისას კი API-ის ლიმიტის ფარგლებში პაუზა დაიცავით. შემდეგი გვერდისთვის ძველი since არ გაიმეოროთ.
ლენტი შენახვის ფანჯრითაა შეზღუდული: თუ მიმღები 7 დღეზე მეტხანს ჩუმად იყო ან 2000-ზე მეტი მოვლენა გამოტოვა, ძველების დაბრუნება შეუძლებელია — შედეგი ზარების ჟურნალს შეადარეთ.
MCP-გამოძახება: get_call_events {"location":"loc1","since":"1792836000.1:5","types":["call.ended"],"limit":100}.
რეცეპტები#
კლიენტის ბარათი CRM-ში. call.started-ზე კონტაქტი ნორმალიზებული data.from.number-ით მოძებნეთ. call.ringing-ზე თანამშრომელს ბარათი data.party.extension-დან აჩვენეთ; call.answered-ზე მიმღები მონიშნეთ, გადამისამართებისას ადრესატი შეცვალეთ. დასრულებისას ზარის ინდიკატორი დახურეთ. დამატებითი ნომრისა და CRM-ის თანამშრომლის შესაბამისობა თქვენთან შეინახეთ.
გამოტოვებული → ამოცანა. call.ended-ზე direction:"inbound"-ით და status-ით missed, busy, cancelled, abandoned-დან შექმენით ერთი ამოცანა callId-ის მიხედვით. არასასურველი მოკლე ზარები საკუთარი პოლიტიკით გაფილტრეთ. ერთ call.ringing-ზე ამოცანა არ შექმნათ: პასუხი სხვა ტელეფონს შეეძლო.
სტატისტიკა. დასრულებული ზარები ერთხელ დაითვალეთ, მიმართულებები და შედეგები ცალ-ცალკე; ხანგრძლივობები აიღეთ durationSec, talkSec, ringSec-დან, ნულოვანი მნიშვნელობების არქონის გათვალისწინებით. Cenaly-ს ანალიტიკასთან შესადარებლად გამოიყენეთ /v1/metrics (REST API): SLA-ის წესები და მეტრიკების გაანგარიშება მოვლენების უბრალო დათვლაზე რთულია.
გამართვა#
- აამუშავეთ ლოკალური HTTP-მიმღები, მაგალითად 8080 პორტზე, და მიეცით მას საჯარო HTTPS-ტუნელი:
ngrok http 8080. ვებჰუკში მიუთითეთ მიმღების გაცემული HTTPS URL ბილიკით.localhost, კერძო მისამართები, HTTP და გადამისამართებები მიწოდებისთვის არ გამოდგება. - შეინახეთ საიდუმლო; დააჭირეთ „შემოწმებას“. ჟურნალში შეამოწმეთ HTTP-კოდი და შეცდომა. დარწმუნდით, რომ მარშრუტი POST-ს იღებს ბრაუზერში შესვლისა და ფორმის CSRF-შემოწმების გარეშე.
- ხელმოწერის შეუსაბამობისას შეადარეთ საწყისი ბაიტები, საიდუმლო, გამყოფი და საათები. საერთო ჟურნალში საიდუმლო ან ზარის სრული სხეული არ ჩაწეროთ.
- შეამოწმეთ, რომ ერთი id-ის გამეორება მეორე ჩანაწერს არ ქმნის; შემდეგ სატესტო სცენარის მოვლენები უკუღმა რიგითობით გააგზავნეთ.
- ping-ის შემდეგ განახორციელეთ ნებადართული სატესტო ზარი: ping მოვლენების ფილტრს, წერტილებსა და მოვლენების ჩართვას PBX-ში არ ამოწმებს.
- შეამოწმეთ საჯარო DNS/TLS-ის ხელმისაწვდომობა და 10 წამზე სწრაფი პასუხი. ტუნელის მისამართის შეცვლისას გამოწერა განაახლეთ.
აუდიოს დამუშავებისა და გაშიფვრის მზადყოფნის მოვლენები „ზარების“ განყოფილების „ვებჰუკის“ წყაროს ეკუთვნის. ამ ხუთი ტიპის მოვლენაში PBX-ის შეტყობინება „გაშიფვრა მზადაა“ არ არსებობს.