Arama olayları webhook'ları
CRM entegratörleri ve işletme sahipleri için: webhook, arama sürerken sisteminize haber verir. Çalışanın ekranında müşteri kartını açabilir, cevapsız aramadan sonra görev oluşturabilir veya kendi istatistiklerinizi toplayabilirsiniz. Herkese açık bir HTTPS alıcısı gerekir; yoksa olay akışını düzenli sorgulayın. Anahtarlar ve yetkiler Kendi santralinizin API ve MCP'si genel bakışında açıklanır.
Bu makalede: oluşturma, olaylar, imza, teslimat, sorgulama, hata ayıklama.
Webhook oluşturma#
Telefon sistemi → API ve MCP → Webhook’lar yolunu açın, ad ve herkese açık HTTPS adresi ekleyin, şubeleri ve olayları seçin. whsec_… sırrını kaydedin: yalnızca bir kez gösterilir. “Test et” düğmesi webhook.ping gönderir. Hesap başına en fazla 10 webhook; ad en fazla 60, URL en fazla 500 karakter olabilir.
API için ayrı olarak webhooks:write yetkisi verilmiş anahtar gerekir. Boş events, beş arama olayının tümünü; boş locations, tüm şubeleri ifade eder. Şubelerle sınırlandırılmış anahtar, izin verilen locations değerlerini açıkça belirtmelidir ve hesap genelindeki webhook'u yönetemez.
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}'
Yanıt örneği (zaman, kimlik ve sır kurgusaldır):
{
"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}, gönderilen ayarları değiştirir. POST …/{id}/rotate-secret yeni sır verir ve eskisini hemen iptal eder; DELETE …/{id} aboneliği siler. Sır yenilenirken değişikliği alıcıyla eşgüdümlü yapın.
Şubenin doğrudan modda kendi santrali varsa olayları santral sunucusu gönderir; diğer durumlarda Cenaly bulutu. Her olay tek bir yoldan, yinelenmeden gider; başlıklar, imza, User-Agent: Cenaly-Webhooks/1.0, zaman aşımı ve yeniden denemeler iki yolda da aynıdır. Gönderenin adresi santralin ya da bulutun adresi olabilir; bu yüzden özgünlüğü IP'ye göre değil, gövde imzasıyla doğrulayın.
Olay türleri ve gövdeleri#
| Tür | Ne zaman oluşur? | Özel data alanları |
|---|---|---|
call.started |
Arama başlar | Yön, taraflar ve biliniyorsa hat |
call.ringing |
Telefon ilk kez çalar | party: kimin telefonu; arama başına birden fazla olay olabilir |
call.answered |
İlk kişi yanıtlar | party: yanıtlayan |
call.transferred |
Görüşme aktarılır | party: kime, by: aktaran |
call.ended |
Arama sona erer | status, süreler, varsa answeredBy, hangupBy |
webhook.ping |
“Test et” düğmesine basılır | Arama değil, servis mesajı |
Dış nesne her zaman id, type, createdAt, locationId, webhookId, attempt, data içerir. Arama olaylarında data, id, type, at, callId, seq içeren özgün olaydır. createdAt olayın zamanından alınır; yeniden teslimat zamanı değildir. İsteğe bağlı alanlar bulunmayabilir; sıfır süreler genellikle gönderilmez.
Aşağıdaki tam gövde örnekleri kurgusal bir aramaya aittir. Hat, trunk ve taraf alanları rotaya bağlıdır; her zaman bulunmaları zorunlu tutulmamalıdır.
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
}
}
Ek olarak lineId, lineLabel, line (id, number, caption, resolution, source), trunk (id, name), redirectedFrom, transferOf, hangupBy gelebilir. Taraflarda operatorId ve connectionId bulunabilir. Bilinmeyen alanları saklayın veya yok sayın; şema genişlediğinde olayı reddetmeyin.
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"
}
}
Testin locationId değeri boş olabilir. İmzayı doğrulayın, 2xx yanıtı verin; CRM'de arama veya görev oluşturmayın.
Başlıklar#
| Başlık | Değer |
|---|---|
Content-Type |
application/json |
X-Cenaly-Signature |
t=<unix>,v1=<hex HMAC-SHA256> |
X-Cenaly-Event |
Olay türü |
X-Cenaly-Delivery |
Olay kimliği; yeniden denemede aynı kalır |
X-Cenaly-Webhook |
Abonelik kimliği |
User-Agent |
Cenaly-Webhooks/1.0 (+https://cenaly.com/docs/pbx-api) |
İmza doğrulama#
Gövdenin özgün baytları imzalanır: HMAC-SHA256(secret, t + "." + rawBody). Sır, whsec_ dahil bütünüyle, base64 çözümlemesi yapılmadan kullanılır. Doğrulamadan önce JSON'u ayrıştırıp yeniden serileştirmeyin. Örnekler, alıcının saatinden her iki yönde 300 saniyeden fazla uzak zaman damgasını reddeder ve imzayı sabit sürede karşılaştırır. Saati eşitleyin.
Node.js#
İşlev, JSON ara katmanından önce bir Buffer alır; örneğin HTTP istek akışından toplanmış olabilir. Sırrı istek başlığından değil, korumalı ayarlardan alın.
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 türündedir; örneğin JSON ayrıştırılmadan önce request.get_data().
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)
}
Doğrulama başarılı olduktan sonra JSON'u ayrıştırın, gereken alanları kontrol edin ve olayı saklayın. Sunucunuzda gelen HTTP gövdesinin boyutunu sınırlayın. İmza yanlışsa 401, kaydedilemiyorsa 5xx, güvenilir biçimde alındıktan sonra 200 veya 204 döndürün.
Teslimat, yeniden denemeler ve sıralama#
10 saniye içinde 2xx yanıtı verin. Ağır işleri yanıt verdikten sonra kendi kuyruğunuzdan yürütün; ancak onaylamadan önce olayı kaydedin. 3xx yönlendirmeleri izlenmez; 4xx, 5xx, zaman aşımı ve ağ hatası başarısızlık sayılır.
| Sonraki deneme | Önceki denemeden sonra bekleme |
|---|---|
| 2 | 10 saniye |
| 3 | 30 saniye |
| 4 | 2 dakika |
| 5 | 5 dakika |
| 6 | 10 dakika |
| 7 | 15 dakika |
| 8 | 15 dakika |
Toplam 8 deneme; beklemelerin toplamı 47 dakika 40 saniye, buna istek ve kuyruk süresi eklenir. Bu, saate göre kesin bir zamanlama değildir. webhook.ping yeniden denenmeden bir kez gönderilir.
Arama olayının id değeri <callId>:<seq> biçimindedir. Yeniden denemede id aynı kalır, attempt artar; imza ve zaman damgası yeniden hesaplanır. Alıcının deposunda birden fazla abonelik kabul ediyorsanız (webhookId, id) çiftini, tek abonelik içinde ise id değerini benzersiz yapın. Daha önce kaydedilmiş olayın tekrarını da 2xx ile onaylayın. Görevi kuyruğa ekleme ve kimliği kaydetme işlemlerini tek veritabanı işlemi içinde yapın; aksi halde kayıp veya çift görev oluşabilir.
HTTP teslimatlarının sırası garanti edilmez: yeniden gönderilen call.started, call.ended sonrasında gelebilir. Durumu locationId, data.callId ve data.seq üzerinden oluşturun; bitmiş aramayı tekrar “çalıyor” durumuna döndürmeyin. seq boşluğu mutlaka kayıp demek değildir: abonelik ara türleri kapsamıyor olabilir. Olay akışı ve arama günlüğü sonucu karşılaştırmaya yardımcı olur; webhook'lar süresiz arşivin yerini tutmaz.
En az 72 saat süren başarısız gerçek teslimat serisinden sonra webhook otomatik kapatılır ve hesap sahibine bildirim gider. Başarılı gerçek teslimat seriyi sıfırlar; test ping'i etkilemez. Hiç arama olmaması tek başına webhook'u kapatmaz.
Pencerede “Otomatik olarak kapatıldı” ve “Etkinleştir” görünür. Alıcıyı düzelttikten sonra buradan açın veya {"enabled":true} ile PATCH /v1/webhooks/{id} gönderin. Geçmişteki eksikleri ayrıca karşılaştırın.
Günlük, abonelik penceresinde ve GET /v1/webhooks/{id}/deliveries adresindedir: son 100 denemeye kadar zaman, olay, deneme numarası, HTTP kodu, hata ve sonuç. Kod 0, HTTP yanıtı alınmadığı anlamına gelir. POST /v1/webhooks/{id}/test ping'i kuyruğa koyar; komutun kabulü başarılı teslimat demek değildir, günlüğü okuyun.
Kendi sunucunuz olmadan#
calls:read yetkisi, GET /v1/events ve MCP get_call_events erişimi verir. Bunlar webhook dış nesnesi, attempt ve imza içermeyen özgün olaylardır. Akış, şube başına 7 gün içindeki en fazla 2.000 olayı saklar. Parametreler: since kimlik veya ISO zamanı, types virgülle ayrılmış türler, limit en fazla 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
}
Olay varsa yanıtta next, yani son döndürülen olayın kimliği bulunur. İşledikten sonra kaydedin ve sonraki istekte since olarak gönderin. more:true ise hemen devam edin; false ise API limitine uygun süre bekleyin. Sonraki sayfa için eski since değerini tekrar kullanmayın.
Akış saklama penceresiyle sınırlıdır: alıcı 7 günden uzun süre sessiz kaldıysa veya 2.000'den fazla olay kaçırdıysa eskiler geri alınamaz; sonucu arama günlüğüyle karşılaştırın.
MCP çağrısı: {"location":"loc1","since":"1792836000.1:5","types":["call.ended"],"limit":100} ile get_call_events.
Uygulama örnekleri#
CRM'de müşteri kartı. call.started olayında kişiyi normalize edilmiş data.from.number ile arayın. call.ringing olayında kartı data.party.extension dahilisindeki çalışana gösterin; call.answered olayında yanıtlayanı işaretleyin, aktarmada alıcıyı değiştirin. Arama sona erdiğinde arama göstergesini kapatın. Dahili ile CRM çalışanı arasındaki eşleştirmeyi kendi tarafınızda saklayın.
Cevapsız arama → görev. direction:"inbound" ve missed, busy, cancelled, abandoned değerlerinden birini taşıyan status ile gelen call.ended için callId başına tek görev oluşturun. İstenmeyen kısa aramaları kendi politikanızla filtreleyin. Yalnızca call.ringing üzerine görev oluşturmayın; başka telefon yanıtlamış olabilir.
İstatistik. Bitmiş aramaları bir kez sayın; yönleri ve sonuçları ayrı tutun. Süreleri, sıfır değerlerin gönderilmeyebileceğini dikkate alarak durationSec, talkSec, ringSec alanlarından alın. Cenaly analiziyle karşılaştırmak için /v1/metrics kullanın (REST API): SLA kuralları ve metrik hesaplaması, basit olay sayımından daha karmaşıktır.
Hata ayıklama#
- Örneğin 8080 portunda yerel HTTP alıcısı çalıştırın ve herkese açık HTTPS tüneli sağlayın:
ngrok http 8080. Verilen HTTPS URL'sini alıcının yoluyla birlikte webhook'a yazın.localhost, özel adresler, HTTP ve yönlendirmeler teslimata uygun değildir. - Sırrı kaydedin; “Test et” düğmesine basın. Günlükte HTTP kodunu ve hatayı kontrol edin. Rotanın tarayıcıdan giriş veya form CSRF kontrolü gerektirmeden POST kabul ettiğinden emin olun.
- İmza eşleşmezse özgün baytları, sırrı, nokta ayırıcısını ve saati karşılaştırın. Sırrı veya aramanın tam gövdesini ortak günlüğe yazmayın.
- Aynı kimliğin tekrarının ikinci kayıt oluşturmadığını kontrol edin; ardından test senaryosunun olaylarını ters sırayla gönderin.
- Ping'den sonra izin verilen bir test araması yapın: ping olay filtresini, şubeleri ve santralde olayların açık olmasını sınamaz.
- Herkese açık DNS/TLS erişimini ve yanıtın 10 saniyeden kısa sürdüğünü kontrol edin. Tünel adresi değişirse aboneliği güncelleyin.
Ses işleme ve dökümün hazır olma olayları “Aramalar” bölümünün “Webhook” kaynağına aittir. Santralin bu beş olay türünde “döküm hazır” bildirimi yoktur.