Webhook İmza Doğrulama & Olaylar
Purvisor bir olay olduğunda (arama tamamlandı, kontak oluştu, DNC talebi geldi) senin targetUrl’ine HMAC-SHA256 ile imzalanmış bir JSON POST gönderir. İmza, gövdenin gerçekten Purvisor’dan geldiğini ve yolda değişmediğini kanıtlayan bir mühür gibidir: aynı gizli anahtarla ürettiğin imza gelen imzayla birebir tutuyorsa, o istek gerçektir. Bu sayfa hem zarfın şeklini hem de imzayı iki dakikada doğru doğrulamayı gösterir — internete açık bir uç noktan varsa, imzayı doğrulamadan içeriğe güvenme.
İmza doğrulamayan bir webhook uç noktası, targetUrl’ini bilen herkesin sahte “arama tamamlandı” olayı basmasına açıktır. Üretimde imza kontrolü zorunludur.
Zarf nasıl görünür?
Her teslimatın gövdesi her zaman üç alanlı aynı zarftır:
{
"event": "call.completed",
"timestamp": "2026-07-12T09:31:04.512Z",
"data": {
"callId": "8123456789",
"phoneNumber": "+905321234567",
"direction": "outbound",
"durationSeconds": 84,
"outcome": "answered",
"sentiment": "positive",
"summary": "Randevu talep etti, Salı 14:00 uygun.",
"contactId": "42198"
}
}event— olay adı (aşağıdaki tablo).timestamp— teslimat anı, ISO 8601 (UTC).data— olaya özel alanlar; şekli olaya göre değişir.
Yanında gönderilen başlıklar:
| Başlık | Değer |
|---|---|
Content-Type | application/json |
X-Purvisor-Event | olay adı (ör. call.completed) — hızlı yönlendirme için |
X-Purvisor-Signature | ham gövdenin abonelik secret’i ile HMAC-SHA256 imzası, hex kodlu |
Desteklenen olaylar
Abone olurken (POST /api/v1/hooks) event alanı aşağıdaki 6 addan biri olmalıdır. Ancak bugün kodda yalnızca 3 tanesi gerçekten tetikleniyor; diğer 3’ü tanımlı ve örnek yükü var ama henüz hiçbir yerden gönderilmiyor.
| Olay | Durum | data alanları |
|---|---|---|
call.completed | ✅ Yayınlanıyor | callId, phoneNumber, direction, durationSeconds, outcome, sentiment, summary, contactId |
contact.created | ✅ Yayınlanıyor | contactId, firstName, lastName, phoneNumber, email, company, leadSource |
dnc.requested | ✅ Yayınlanıyor | phoneNumber, reason, contactCount |
lead.hot | ✅ Yayınlanıyor | contactId, phoneNumber, leadScore, heat, outcome, sentiment, summary |
appointment.booked | ✅ Yayınlanıyor | callId, contactId, phoneNumber, date, time, bookingId, bookingLink |
campaign.finished | ✅ Yayınlanıyor | campaignId, name, totalContacts, completedCalls |
call.failed | ✅ Yayınlanıyor | callId, phoneNumber, contactId, direction, campaignId, durationSeconds, reason |
appointment.cancelled | ✅ Yayınlanıyor | callId, contactId, phoneNumber, date, time, bookingId, reason |
appointment.rescheduled | ✅ Yayınlanıyor | callId, contactId, phoneNumber, date, time, bookingId, previousBookingId, previousDate, previousTime, reason |
GET /api/v1/samples?event=<olay> her olay için örnek kaydı canlı teslimatla aynı zarfla döndürür — yani kurulumda gördüğün alan yapısı, canlıda geleceğin birebir aynısıdır. İmza doğrulamanı örnek yükle test edebilirsin.
Bir olay için alan haritasını çıkarmak istiyorsan
GET /api/v1/samples?event=call.completedsana statik, örnek bir yük döndürür (canlı veri değil) — Zapier/Make alan eşleştirmesi bunu kullanır.
İmza nasıl üretiliyor?
Formül tek satır: imza, ham (raw) gövdenin UTF-8 baytları üzerinden, o aboneliğin secret’iyle anahtarlanmış HMAC-SHA256’nın hex çıktısıdır.
X-Purvisor-Signature = hex( HMAC_SHA256( key = subscription.secret, message = ham_gövde ) )secret(32 hex karakter) yalnızcaPOST /api/v1/hooksyanıtında bir kez döner — sakla.GET /api/v1/hooksonu tekrar göstermez.- İmzalanan şey, JSON’u ayrıştırmadan önceki ham gövdedir. Gövdeyi parse edip yeniden
JSON.stringifyedersen anahtar sırası/boşluklar değişir ve imza tutmaz.
Doğrulamayı ham byte gövde üzerinde yap. Framework’ün (Express, Next.js, Flask…) gövdeyi otomatik parse ediyorsa, imzayı hesaplamadan önce ham gövdeyi yakalayacak şekilde ayarla (ör. Express’te express.raw()).
Doğrulama — dilden bağımsız adımlar
Ham gövdeyi ve imzayı al
İsteğin değiştirilmemiş gövde metnini (byte dizisi) ve X-Purvisor-Signature başlığını oku.
Kendi imzanı hesapla
Sakladığın abonelik secret’i ile ham gövdenin HMAC-SHA256’sını al, hex olarak kodla.
Sabit-zamanlı karşılaştır
Hesapladığın imza ile başlıktaki imzayı sabit-zamanlı (timing-safe) karşılaştır. Eşitse istek gerçektir; değilse 401/403 ile reddet. == gibi normal string karşılaştırması zamanlama sızıntısına açıktır — kripto güvenli karşılaştırma kullan.
(Önerilir) timestamp’i kontrol et
Zarftaki timestamp çok eskiyse (ör. > 5 dk) tekrar-oynatma (replay) şüphesiyle reddedebilirsin.
Node.js doğrulama örneği
import crypto from "crypto"
import express from "express"
const app = express()
// ÖNEMLİ: imza ham gövde üzerinden hesaplanır — bu route'ta JSON parse ETME.
app.post(
"/purvisor-webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const secret = process.env.PURVISOR_HOOK_SECRET // POST /hooks yanıtındaki 32 hex
const rawBody = req.body // Buffer — ham gövde
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody) // ham byte'lar
.digest("hex")
const received = req.get("X-Purvisor-Signature") || ""
// Sabit-zamanlı karşılaştırma (uzunluk farkı da güvenli ele alınır)
const a = Buffer.from(expected, "utf8")
const b = Buffer.from(received, "utf8")
const valid = a.length === b.length && crypto.timingSafeEqual(a, b)
if (!valid) {
return res.status(401).json({ error: "invalid_signature" })
}
const { event, timestamp, data } = JSON.parse(rawBody.toString("utf8"))
// İsteğe bağlı replay koruması: 5 dakikadan eski olayları reddet
if (Date.now() - new Date(timestamp).getTime() > 5 * 60 * 1000) {
return res.status(401).json({ error: "stale_event" })
}
switch (event) {
case "call.completed":
// data.callId, data.outcome, data.sentiment, data.summary ...
break
case "contact.created":
// data.contactId, data.phoneNumber, data.source ...
break
case "dnc.requested":
// data.phoneNumber, data.reason ...
break
}
// 2xx döndür — aksi halde Purvisor teslimatı yeniden dener (retry)
res.status(200).json({ ok: true })
}
)
X-Purvisor-Eventbaşlığını gövdeyi parse etmeden önce okuyup isteği hızlıca doğru işleyiciye yönlendirebilirsin — ama imza doğrulamasını yine de yap.
Teslimat, retry ve otomatik pasife alma
Teslimat BullMQ tabanlı bir kuyrukla, güvenilir şekilde yapılır:
Olay tetiklenir (emitEvent)
↓
Aktif abonelikler bulunur (event + isActive)
↓
Her abonelik için kuyruğa iş atılır ──(Redis yoksa)──▶ doğrudan tek seferlik POST (retry yok)
↓
Worker → targetUrl'e imzalı POST (10 sn timeout)
↓
2xx? ── evet ─▶ lastDeliveryAt güncellenir, failCount = 0
└─ hayır (non-2xx / timeout) ─▶ retry (5 deneme, exponential backoff, 15 sn taban)
↓
Tüm denemeler biterse failCount++
↓
failCount ≥ 10 ise abonelik otomatik pasife alınır| Parametre | Değer |
|---|---|
| Deneme sayısı | 5 |
| Backoff | Üstel (exponential), 15 sn taban |
| İstek zaman aşımı | 10 sn |
| Yeniden deneme tetiği | 2xx dışındaki her yanıt veya timeout |
| Başarı sonrası | lastDeliveryAt güncellenir, failCount sıfırlanır |
| Otomatik pasife alma | Ard arda 10 başarısız teslimattan sonra isActive=false |
| Redis erişilemezse | Tek seferlik “fire-and-forget” POST — retry yok, izlenmez |
Uç noktan bakıma girer veya kalıcı olarak 2xx dönmezse, abonelik ~10 başarısız teslimatta sessizce kapanır ve bir daha POST almazsın. Uç noktanı düzelttikten sonra GET /api/v1/hooks ile isActive durumunu kontrol et; kapandıysa yeniden abone ol (POST /api/v1/hooks). Aboneliğin sağlığını failCount ve lastDeliveryAt alanlarından takip edebilirsin.
Uç noktan olayı aldığında hızlıca
2xxdöndür, ağır işi arka plana at. Yanıt 10 saniyeyi aşarsa Purvisor timeout sayar ve gereksiz yere yeniden dener.
KVKK notu
Webhook data alanı kişisel veri içerir (telefon numarası, ad-soyad, görüşme özeti, duygu analizi). Bu veriyi kendi sistemine akıtırken:
- Yalnızca
https://uç noktalarına gönderilir —targetUrlhttp olamaz (kodda zorunlu). - Alıcı sistemin de KVKK uyumlu olmalı; veriyi işleme amacın ve saklama süren tanımlı olsun.
- İmza doğrulaması, kişisel verinin sahte kaynaktan gelmediğini garanti eden ilk savunma hattıdır — atlama.
Sorun giderme
| Belirti | Olası neden / çözüm |
|---|---|
| İmza hiç tutmuyor | Parse edilmiş gövdeyi yeniden serialize edip imzalıyorsun → ham gövde üzerinden hesapla (Express’te express.raw()). |
| İmza bazen tutmuyor | Yanlış secret; birden çok abonelik varsa her birinin ayrı secret’i vardır — doğru olanı eşleştir. |
| Hiç teslimat gelmiyor | Dokuz olay da canlıdır — olay henüz gerçekleşmemiş olabilir. Abonelik 10 ardışık başarısızlıktan sonra devre dışı kalmış olabilir (isActive:false). |
| Teslimat durdu | Abonelik ~10 başarısız denemeden sonra otomatik pasife alınmış olabilir → GET /api/v1/hooks ile isActive kontrol et, gerekirse yeniden abone ol. |
| Aynı olay birden çok kez geldi | Uç noktan zamanında 2xx dönmedi → retry oldu. İşleyicini idempotent yap (ör. callId/contactId ile tekilleştir). |
secret’i kaybettim | Secret yalnızca abonelik oluşturma yanıtında döner; kaybettiysen aboneliği silip (DELETE /api/v1/hooks/{id}) yeniden oluştur. |