Webhook imzalarını doğrulama
İmza, sisteminizin gerçek webhook'ları sahte isteklerden ayırmasını ve içeriğin yolda değiştirilmediğini kontrol etmesini sağlar. Örneğin CRM'de bir kişi oluşturmadan önce isteğin beklenen göndericiden geldiğini doğrulayabilirsiniz.
Alıcı, hesap ayarlarından kopyaladığınız bir metin olan açık anahtarla imzayı kontrol eder. Anahtarı kopyalayıp entegrasyonunuzu geliştiren kişiye iletin. Geliştiriciniz, webhook'ları alan sisteme doğrulama ekleyecektir.
İmzalamayı etkinleştirme#
- Yönetici olarak giriş yapın. Kanalın geliştirici ayarlarını veya CRM webhook ayarlarını açın.
- İmzalamayı etkinleştirin ve açık anahtarı kopyalayın.
- Anahtarı webhook'ları alan sisteme ekleyin ve orada imza doğrulamasını etkinleştirin.
Hesabın kanalları, CRM ve API abonelikleri için tek bir anahtar kullanılır. İmzalama etkinleştirilmezse webhook'lar eskisi gibi çalışmaya devam eder.
Alıcı neyi kontrol etmeli?#
Sisteminiz, işlem yapmadan önce:
- Hesap ayarlarındaki anahtarla imzayı doğrulamalı. İmza yoksa veya geçersizse isteği reddetmeli.
- Gönderim zamanını kontrol etmeli. Sunucu saatinizle fark her iki yönde de en fazla 5 dakika olmalı.
- Webhook'un daha önce işlenip işlenmediğini kontrol etmeli. Tekrarlanan bir istek, örneğin CRM'de ikinci bir kişi oluşturmamalı.
Anahtarı gelen istekten değil, hesap ayarlarından alın. Webhook'ları HTTPS üzerinden alın.
Anahtarı değiştirme#
Anahtarı değiştirmek istediğinizde yenileyin. Bu işlem hesaptaki tüm alıcıları etkiler.
Tüm entegrasyonlarda anahtarı güncellemek için 24 saatiniz vardır. Bu süre boyunca hem yeni hem önceki anahtar istekleri doğrulayabilir. Ardından önceki anahtar çalışmaz. 24 saat dolduktan sonra tekrar yenileyebilirsiniz.
Yenilemeden sonra doğrulama başarısız olursa güncel anahtarı hesap ayarlarından kopyalayın ve alıcıda güncelleyin.
Geliştiriciler için örnekler#
Aşağıdaki işlevler, imza ve zaman geçerliyse true döndürür. Olayı işlemeden önce çağırın. Anahtarları dizi olarak geçirin: genellikle tek anahtar, yenileme sırasında geçici olarak iki anahtar.
JSON'u ayrıştırmadan önceki özgün istek gövdesini kullanın. Boşlukların değişmesi bile doğrulamayı bozar.
| Başlık | İşleve geçirilecek değer |
|---|---|
Webhook-Timestamp | Unix saniyesi cinsinden gönderim zamanı |
Webhook-Id | Webhook kimliği |
Webhook-Signature | İstek imzası |
Tekrarları önlemek için kimlikleri hesapla birlikte ortak bir veritabanında saklayın ve aynı kimliğin eş zamanlı işlenmesini engelleyin. Kimlikleri en az 15 dakika saklayın. Olay kalıcı olarak kabul edildiğinde 2xx döndürün; daha önce kabul edilen tekrarlar için de 2xx kullanın. Örnekler bu adımı uygulamaz. Bir olay farklı kimlikle tekrar gönderilirse iş işleminin kendisinin tekrarlanmasını da önlemelisiniz.
TypeScript (Node.js 20+)#
Gövdeyi Buffer, üç başlık değerini metin ve ayarlardaki açık anahtarları dizi olarak geçirin. Node.js, request.headers içindeki başlık adlarını küçük harfle tutar. Express'te bu rota için express.json() öncesinde express.raw({type: 'application/json'}) kullanın.
import {createPublicKey, verify} from 'node:crypto';
export function verifyWebhook(
rawBody: Buffer,
timestamp: string,
webhookId: string,
signatureHeader: string,
trustedPublicKeys: string[],
now = Math.floor(Date.now() / 1000)
): boolean {
if (!/^[0-9]+$/.test(timestamp)) {
return false;
}
if (!/^[0-9]+$/.test(webhookId)) {
return false;
}
const sentAt = Number(timestamp);
if (!Number.isSafeInteger(sentAt)) {
return false;
}
const timeToleranceSeconds = 300;
if (Math.abs(now - sentAt) > timeToleranceSeconds) {
return false;
}
const messagePrefix = Buffer.from(`${webhookId}.${timestamp}.`);
const message = Buffer.concat([messagePrefix, rawBody]);
for (const encodedPublicKey of trustedPublicKeys) {
if (!encodedPublicKey.startsWith('whpk_')) {
throw new Error('Invalid configured public key');
}
const decodedPublicKey = Buffer.from(encodedPublicKey.slice(5), 'base64');
if (decodedPublicKey.length !== 32) {
throw new Error('Invalid configured public key');
}
// Node.js needs a key container rather than the raw public key bytes.
const verificationKey = createPublicKey({
key: {kty: 'OKP', crv: 'Ed25519', x: decodedPublicKey.toString('base64url')},
format: 'jwk'
});
for (const signatureEntry of signatureHeader.split(' ')) {
const parts = signatureEntry.split(',');
if (parts.length !== 2) {
continue;
}
const [version, encodedSignature] = parts;
if (version !== 'v1a') {
continue;
}
const decodedSignature = Buffer.from(encodedSignature, 'base64');
if (decodedSignature.length !== 64) {
continue;
}
// Buffer.from tolerates malformed Base64, so check the round trip too.
if (decodedSignature.toString('base64') !== encodedSignature) {
continue;
}
if (verify(null, message, verificationKey, decodedSignature)) {
return true;
}
}
}
return false;
}
Go#
Özgün gövdeyi io.ReadAll(r.Body) ile okuyun. Gövdeyi, r.Header.Get(...) ile alınan başlık değerlerini, açık anahtarları ve time.Now() değerini geçirin.
package webhooks
import (
"crypto/ed25519"
"encoding/base64"
"regexp"
"strconv"
"strings"
"time"
)
var decimalDigits = regexp.MustCompile(`^[0-9]+$`)
func VerifyWebhook(
rawBody []byte,
timestamp string,
webhookID string,
signatureHeader string,
trustedPublicKeys []string,
now time.Time,
) bool {
if !decimalDigits.MatchString(timestamp) {
return false
}
if !decimalDigits.MatchString(webhookID) {
return false
}
sentAt, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
const timeToleranceSeconds = 300
if sentAt < now.Unix()-timeToleranceSeconds {
return false
}
if sentAt > now.Unix()+timeToleranceSeconds {
return false
}
messagePrefix := []byte(webhookID + "." + timestamp + ".")
message := append(messagePrefix, rawBody...)
for _, encodedPublicKey := range trustedPublicKeys {
if !strings.HasPrefix(encodedPublicKey, "whpk_") {
panic("invalid configured public key")
}
decodedPublicKey, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(encodedPublicKey, "whpk_"))
if err != nil {
panic("invalid configured public key")
}
if len(decodedPublicKey) != ed25519.PublicKeySize {
panic("invalid configured public key")
}
for _, signatureEntry := range strings.Split(signatureHeader, " ") {
parts := strings.Split(signatureEntry, ",")
if len(parts) != 2 {
continue
}
version, encodedSignature := parts[0], parts[1]
if version != "v1a" {
continue
}
decodedSignature, err := base64.StdEncoding.DecodeString(encodedSignature)
if err != nil {
continue
}
if len(decodedSignature) != ed25519.SignatureSize {
continue
}
if ed25519.Verify(ed25519.PublicKey(decodedPublicKey), message, decodedSignature) {
return true
}
}
}
return false
}
PHP 8+ (Sodium)#
Sodium eklentisi gerekir. Gövdeyi file_get_contents('php://input') ile okuyun. Başlıklar $_SERVER['HTTP_WEBHOOK_TIMESTAMP'], $_SERVER['HTTP_WEBHOOK_ID'] ve $_SERVER['HTTP_WEBHOOK_SIGNATURE'] içindedir. Eksik başlıklar için boş metin geçirin.
<?php
declare(strict_types=1);
function verifyWebhook(
string $rawBody,
string $timestamp,
string $webhookId,
string $signatureHeader,
array $trustedPublicKeys,
?int $now = null,
): bool {
$now ??= time();
if (!preg_match('/^[0-9]+$/D', $timestamp)) {
return false;
}
if (!preg_match('/^[0-9]+$/D', $webhookId)) {
return false;
}
$timeToleranceSeconds = 300;
if (abs($now - (float) $timestamp) > $timeToleranceSeconds) {
return false;
}
$message = $webhookId . '.' . $timestamp . '.' . $rawBody;
foreach ($trustedPublicKeys as $encodedPublicKey) {
if (!str_starts_with($encodedPublicKey, 'whpk_')) {
throw new RuntimeException('Invalid configured public key');
}
$decodedPublicKey = base64_decode(substr($encodedPublicKey, 5), true);
if ($decodedPublicKey === false) {
throw new RuntimeException('Invalid configured public key');
}
if (strlen($decodedPublicKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
throw new RuntimeException('Invalid configured public key');
}
foreach (explode(' ', $signatureHeader) as $signatureEntry) {
$parts = explode(',', $signatureEntry);
if (count($parts) !== 2) {
continue;
}
[$version, $encodedSignature] = $parts;
if ($version !== 'v1a') {
continue;
}
$decodedSignature = base64_decode($encodedSignature, true);
if ($decodedSignature === false) {
continue;
}
if (strlen($decodedSignature) !== SODIUM_CRYPTO_SIGN_BYTES) {
continue;
}
if (sodium_crypto_sign_verify_detached($decodedSignature, $message, $decodedPublicKey)) {
return true;
}
}
}
return false;
}
İmza biçimi#
Kendi doğrulayıcınızı yazıyorsanız bu bölümü kullanın. Açık anahtarın biçimi whpk_<Base64> şeklindedir: 32 Ed25519 baytını çözmeden önce whpk_ önekini kaldırın. İmzalanan veri, başlık değerlerini ve özgün gövdeyi birleştirir:
Webhook-Id + "." + Webhook-Timestamp + "." + rawBody
Webhook-Signature, v1a,<Base64 imza> kaydını içerir. Yenileme sırasında boşlukla ayrılmış iki kayıt vardır; güvenilir bir anahtarla tek bir geçerli imza yeterlidir. Mesaja ayrıca özet uygulamayın.
İmzalanan baytlar, imza başlığı ve açık anahtar biçimi Standard Webhooks ile uyumludur.
Doğrulama başarısız olursa güncel anahtarı, sunucu saatini ve gövde ile başlıkların değişmeden ulaştığını kontrol edin. Başarısız bir isteği kabul etmek için doğrulamayı atlamayın.
Olay türleri ve içerikleri: Webhooks API.