Public APIİmza Doğrulama

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ıkDeğer
Content-Typeapplication/json
X-Purvisor-Eventolay adı (ör. call.completed) — hızlı yönlendirme için
X-Purvisor-Signatureham 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.

OlayDurumdata alanları
call.completed✅ YayınlanıyorcallId, phoneNumber, direction, durationSeconds, outcome, sentiment, summary, contactId
contact.created✅ YayınlanıyorcontactId, firstName, lastName, phoneNumber, email, company, leadSource
dnc.requested✅ YayınlanıyorphoneNumber, reason, contactCount
lead.hot✅ YayınlanıyorcontactId, phoneNumber, leadScore, heat, outcome, sentiment, summary
appointment.booked✅ YayınlanıyorcallId, contactId, phoneNumber, date, time, bookingId, bookingLink
campaign.finished✅ YayınlanıyorcampaignId, name, totalContacts, completedCalls
call.failed✅ YayınlanıyorcallId, phoneNumber, contactId, direction, campaignId, durationSeconds, reason
appointment.cancelled✅ YayınlanıyorcallId, contactId, phoneNumber, date, time, bookingId, reason
appointment.rescheduled✅ YayınlanıyorcallId, 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.completed sana 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ızca POST /api/v1/hooks yanıtında bir kez döner — sakla. GET /api/v1/hooks onu tekrar göstermez.
  • İmzalanan şey, JSON’u ayrıştırmadan önceki ham gövdedir. Gövdeyi parse edip yeniden JSON.stringify edersen 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-Event baş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
ParametreDeğer
Deneme sayısı5
BackoffÜstel (exponential), 15 sn taban
İstek zaman aşımı10 sn
Yeniden deneme tetiği2xx dışındaki her yanıt veya timeout
Başarı sonrasılastDeliveryAt güncellenir, failCount sıfırlanır
Otomatik pasife almaArd arda 10 başarısız teslimattan sonra isActive=false
Redis erişilemezseTek 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 2xx dö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 — targetUrl http 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

BelirtiOlası neden / çözüm
İmza hiç tutmuyorParse edilmiş gövdeyi yeniden serialize edip imzalıyorsun → ham gövde üzerinden hesapla (Express’te express.raw()).
İmza bazen tutmuyorYanlış secret; birden çok abonelik varsa her birinin ayrı secret’i vardır — doğru olanı eşleştir.
Hiç teslimat gelmiyorDokuz 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 durduAbonelik ~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 geldiUç noktan zamanında 2xx dönmedi → retry oldu. İşleyicini idempotent yap (ör. callId/contactId ile tekilleştir).
secret’i kaybettimSecret yalnızca abonelik oluşturma yanıtında döner; kaybettiysen aboneliği silip (DELETE /api/v1/hooks/{id}) yeniden oluştur.

İlgili