İçeriğe geç

Geliştirici

Webhook imzası nasıl doğrulanır? PHP, Node.js ve Python örnekleri

Standard Webhooks (HMAC-SHA256) imzasını adım adım doğrulayın: imzalanan içerik, ham gövde tuzakları, sabit zamanlı karşılaştırma, zaman damgası, sır döndürme.

Webhook uç noktası, sistemlerinizde veri değiştiren herkese açık bir adrestir; adresi bulan herkes oraya istek gönderebilir. İmza, isteğin gerçekten göndericiden geldiğini ve yolda değişmediğini kanıtlar. Bu yazı, UPPU'nun webhook'larında da kullanılan Standard Webhooks imzasının nasıl doğrulanacağını PHP, Node.js ve Python ile çalışan kodla anlatır.

Webhook'lar neden imzalanır?

İmzasız bir webhook taklit edilebilir: «message.status: delivered» ya da «contact.erased» gibi görünen bir istek herhangi birinden gelebilir. HTTPS bağlantıyı korur ama isteği kimin gönderdiğini söylemez. Ortak sırla atılan imza (HMAC) bunu söyler:

  • geçerli imzayı yalnızca sırrı bilen üretebilir;
  • gövdedeki tek bir baytın değişmesi imzayı bozar;
  • imzalanan içerikteki zaman damgası, eski isteklerin yeniden oynatılmasını sınırlar.

Kaynak IP adresini denetlemek ikinci bir katman olarak işe yarar (UPPU'nun webhook'ları 91.216.36.26 adresinden gelir, değişiklik 30 gün önce duyurulur); asıl denetim ise imzadır.

Şema: kimlik, zaman damgası ve gövde

Her istek üç başlık taşır. İmza; olay kimliğini, zaman damgasını ve gövdenin baytlarını, aralarına nokta koyarak kapsar:

İmzalanan içerik
webhook-id: 01jab3t6y8q7z0k5w9x2c4d6ef
webhook-timestamp: 1760778902
webhook-signature: v1,bkasCZjmHFOH+EYlzJiVUXX0aoEvGPmlqK69s6np8Ss=

imzalanan içerik = webhook-id + "." + webhook-timestamp + "." + ham gövde baytları
imza             = base64( HMAC-SHA256( anahtar = base64decode("whsec_" öneki olmadan sır), imzalanan içerik ) )
  • Anahtar, sırrın whsec_ öneki atılmış ve base64'ten çözülmüş ham baytlarıdır. Sır metnini doğrudan anahtar olarak kullanmak en sık yapılan hatadır.
  • İmza, imzalanan içeriğin HMAC-SHA256 değeridir; standart (dolgulu) base64 ile kodlanır ve v1, sürüm önekini alır.
  • Birden çok imza tek bir boşlukla ayrılarak aynı başlıkta gelebilir. Herhangi bir v1 değeri eşleşirse istek geçerlidir.

Çatıya göre ham gövde tuzakları

Aldığınız baytları ayrıştırmadan önce doğrulayın. Çatınız JSON'u önce ayrıştırır ve siz yeniden serileştirirseniz anahtar sırası, boşluklar ya da kaçış karakterleri değişebilir; geçerli bir imza böylece tutmaz. Ham gövdeyi şöyle alırsınız:

Ayrıştırmadan önce ham istek gövdesini okumak
OrtamHam gövdeDikkat edin
PHPfile_get_contents('php://input')Gövdeyi $_POST'tan yeniden kurmayın
Laravel, Symfony$request->getContent()Girdiyi kırpan ya da dönüştüren ara katmanlar
Node.js, ExpressWebhook rotasında express.raw({ type: 'application/json' })Genel bir express.json() önce çalışıp gövdeyi tüketir
Next.js rota işleyicisiBuffer.from(await request.arrayBuffer())Doğrulamadan önce request.json() çağırmak
Flaskrequest.get_data()Önce request.json okumak
Djangorequest.bodyÖnce request.POST okumak
FastAPI, Starletteawait request.body()Rotada Pydantic gövde modeli tanımlamak

Sabit zamanlı karşılaştırın

Sıradan metin karşılaştırması ilk farklı baytta durur. Kuramsal olarak bu zaman farkı, taklit bir imzanın ne kadarının doğru olduğunu sızdırır. Dilinizin sabit zamanlı karşılaştırmasını kullanın: PHP'de hash_equals, Node.js'te eşit uzunluktaki tamponlarla crypto.timingSafeEqual, Python'da hmac.compare_digest.

Zaman damgasını denetleyin

Zaman damgası tam sayı saniye değilse ya da saatinizden 5 dakikadan (300 saniye) fazla uzaksa isteği reddedin. Böylece ele geçirilmiş bir istek günler sonra yeniden oynatılamaz. Sunucu saatinizi eşitli tutun (NTP). webhook-timestamp olayın değil, bu teslim denemesinin zamanıdır: yeniden denenen olay yeni bir zaman damgası alır; olayın kendi zamanı gövdedeki created_at alanındadır.

Sırrı kesintisiz döndürün

Sır değişirken gönderici bir süre iki sırla da imzalar. UPPU'da eski sır 24 saat daha imza atmaya devam eder ve iki imza birlikte, yenisi önce gönderilir: v1,<yeni> v1,<eski>. Alıcınız, aşağıdaki işlevler gibi bir sır listesi kabul etmelidir: yeni sırrı eskisinin yanına ekleyin, döndürün, eski sır artık kullanılmayınca kaldırın.

Tekrarları webhook-id ile ayıklayın

Webhook'lar en az bir kez teslim edilir. Olayı işledikten sonra bağlantı zaman aşımına uğrarsa aynı olay yeniden denenir. webhook-id başlığı (gövdedeki id ile aynı) yeniden denemelerde ve elle yeniden gönderimlerde değişmez; onu tekrar anahtarı olarak kullanın: işlediğiniz kimlikleri saklayın, tekrarları atlayın. UPPU'da bir olay yaklaşık 76 saat boyunca yeniden denenebilir ve 30 gün içinde panelden yeniden gönderilebilir; kimlikleri en az 35 gün saklayın.

2xx yanıtı hızlı, 10 saniye içinde verin ve işi kuyrukta yapın. Yavaş bir işleyici zaman aşımına, zaman aşımı da yeniden denemelere yol açar.

Kod

PHP

PHP 8.0+bağımlılık yok
<?php
function uppu_verify_webhook(array $secrets, string $id, string $timestamp, string $body, string $signatureHeader, int $tolerance = 300): bool
{
    if ($id === '' || !ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $tolerance) {
        return false;
    }
    $signed = $id . '.' . $timestamp . '.' . $body;
    foreach ($secrets as $secret) {
        if (!is_string($secret) || strncmp($secret, 'whsec_', 6) !== 0) {
            continue;
        }
        $key = base64_decode(substr($secret, 6), true);          // metin değil, ham baytlar
        if ($key === false || $key === '') {
            continue;
        }
        $expected = base64_encode(hash_hmac('sha256', $signed, $key, true));
        foreach (explode(' ', $signatureHeader) as $part) {
            [$version, $signature] = array_pad(explode(',', $part, 2), 2, '');
            if ($version === 'v1' && hash_equals($expected, $signature)) {
                return true;
            }
        }
    }
    return false;
}

$body = file_get_contents('php://input');                        // ham gövde, değiştirmeden
$h = array_change_key_case(getallheaders(), CASE_LOWER);
$secrets = array_filter([getenv('UPPU_WEBHOOK_SECRET') ?: '', getenv('UPPU_WEBHOOK_SECRET_OLD') ?: '']);
if (!uppu_verify_webhook($secrets, $h['webhook-id'] ?? '', $h['webhook-timestamp'] ?? '', (string) $body, $h['webhook-signature'] ?? '')) {
    http_response_code(401);                                     // etkinleştirme denemesi burada 2xx dışı yanıt bekler
    exit;
}
$event = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
// 1) $event['id'] ile tekrarları ayıklayın  2) işi kuyruğa alın  3) hızlı yanıt verin
http_response_code(204);

Node.js

Node.js 18+yalnızca yerleşik modüller
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyUppuWebhook(secrets, id, timestamp, rawBody, signatureHeader, toleranceSec = 300) {
  if (!id || !/^[0-9]+$/.test(timestamp ?? '')) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSec) return false;
  const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const given = (signatureHeader ?? '').split(' ').flatMap((part) => {
    const comma = part.indexOf(',');
    return comma > 0 && part.slice(0, comma) === 'v1' ? [Buffer.from(part.slice(comma + 1), 'utf8')] : [];
  });
  for (const secret of secrets) {
    if (typeof secret !== 'string' || !secret.startsWith('whsec_')) continue;
    const key = Buffer.from(secret.slice(6), 'base64');
    if (key.length === 0) continue;
    const expected = Buffer.from(createHmac('sha256', key).update(signed).digest('base64'), 'utf8');
    if (given.some((sig) => sig.length === expected.length && timingSafeEqual(sig, expected))) return true;
  }
  return false;
}

createServer((req, res) => {
  const chunks = [];
  req.on('data', (chunk) => chunks.push(chunk));
  req.on('end', () => {
    const raw = Buffer.concat(chunks);                            // ham baytlar; yeniden serileştirmeyin
    const ok = verifyUppuWebhook([process.env.UPPU_WEBHOOK_SECRET], req.headers['webhook-id'],
      req.headers['webhook-timestamp'], raw, req.headers['webhook-signature']);
    if (!ok) { res.writeHead(401).end(); return; }
    const event = JSON.parse(raw.toString('utf8'));
    // event.id ile tekrarları ayıklayın, işi kuyruğa alın, sonra:
    res.writeHead(204).end();
  });
}).listen(8080);

Python

Python 3.9+standart kitaplık; deneme vektörüyle sınandı
import base64
import hashlib
import hmac
import re
import time


def verify_uppu_webhook(secrets, msg_id, timestamp, raw_body, signature_header, tolerance=300, now=None):
    if not msg_id or not timestamp or not re.fullmatch(r"[0-9]+", timestamp):
        return False
    if abs((time.time() if now is None else now) - int(timestamp)) > tolerance:
        return False
    signed = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
    given = []
    for part in (signature_header or "").split(" "):
        version, _, signature = part.partition(",")
        if version == "v1" and signature:
            given.append(signature.encode("utf-8"))
    for secret in secrets:
        if not secret or not secret.startswith("whsec_"):
            continue
        key = base64.b64decode(secret[len("whsec_"):])
        expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
        if any(hmac.compare_digest(expected, signature) for signature in given):
            return True
    return False

Flask'ta ham gövdeyi, onu ayrıştıran herhangi bir şeyden önce okuyun:

Python · Flaskham gövdeyi kullanın
import json
import os

from flask import Flask, request

app = Flask(__name__)


@app.post("/uppu/webhook")
def uppu_webhook():
    raw = request.get_data()  # ham baytlar, JSON ayrıştırılmadan önce
    ok = verify_uppu_webhook(
        [os.environ["UPPU_WEBHOOK_SECRET"], os.environ.get("UPPU_WEBHOOK_SECRET_OLD", "")],
        request.headers.get("webhook-id", ""),
        request.headers.get("webhook-timestamp", ""),
        raw,
        request.headers.get("webhook-signature", ""),
    )
    if not ok:
        return "", 401
    event = json.loads(raw)
    # event["id"] ile tekrarları ayıklayın, işi kuyruğa alın, hızlı yanıt verin
    return "", 204

Deneme vektörü

Gerçek bir uç nokta bağlamadan önce işlevinizi sabit değerlerle sınayın. Aşağıdaki sırlar belgeleme için uydurulmuştur; gerçek sır değildir.

Webhook imza doğrulaması için deneme vektörü
ÖğeDeğer
Sırwhsec_dXBwdS1leGFtcGxlLXNpZ25pbmcta2V5LTMyYnl0ZXM=
Eski sırwhsec_dXBwdS1leGFtcGxlLU9MRC1zaWduaW5nLWtleS0zMmI=
webhook-id01jab3t6y8q7z0k5w9x2c4d6ef
webhook-timestamp1760778902 (sınarken «şimdi» olarak kullanın)
Beklenen imzav1,bkasCZjmHFOH+EYlzJiVUXX0aoEvGPmlqK69s6np8Ss=
Döndürme başlığı (iki sır da doğrular)v1,bkasCZjmHFOH+EYlzJiVUXX0aoEvGPmlqK69s6np8Ss= v1,x8egiBglmqEXbprO/7Z87AKPhtamIkB1DSxLS6Y6JkA=
ReddedilmeliFazladan bir boşluk eklenmiş gövde; zaman damgası artı 301 saniye; boş webhook-id

Gövde 277 baytlık UTF-8 metindir. "UPPU test olayı" içindeki Türkçe harfler, kodunuzun karakterleri değil baytları imzaladığından emin olmanızı sağlar.

Python · deneme vektörübeklenen sonuç: True, sonra False
SECRET = "whsec_dXBwdS1leGFtcGxlLXNpZ25pbmcta2V5LTMyYnl0ZXM="
BODY = ('{"id":"01jab3t6y8q7z0k5w9x2c4d6ef","object":"event","type":"webhook.test","api_version":"v1",'
        '"created_at":"2026-10-18T09:15:02.481Z","team_id":"01j8x7tm5k2m4n6p8q0r2s4t6v","channel":null,'
        '"test":true,"data":{"note":"UPPU test olayı","endpoint_id":"01jab3ep5k2m4n6p8q0r2s4t6v"}}').encode("utf-8")

print(verify_uppu_webhook([SECRET], "01jab3t6y8q7z0k5w9x2c4d6ef", "1760778902", BODY,
                          "v1,bkasCZjmHFOH+EYlzJiVUXX0aoEvGPmlqK69s6np8Ss=", now=1760778902))   # True
print(verify_uppu_webhook([SECRET], "01jab3t6y8q7z0k5w9x2c4d6ef", "1760778902", BODY + b" ",
                          "v1,bkasCZjmHFOH+EYlzJiVUXX0aoEvGPmlqK69s6np8Ss=", now=1760778902))   # False

Sık yapılan hatalar

  1. Ayrıştırılmış JSON'u doğrulamak. Her zaman ham baytları doğrulayın.
  2. Sır metnini anahtar olarak kullanmak. Önce whsec_ önekini atın ve base64'ten çözün.
  3. Geçersiz imzaya 2xx yanıt vermek. 401 ya da 403 döndürün. UPPU bir uç noktayı ancak bilerek yanlış imzalanmış bir deneme olayına 2xx dışı yanıt alınca etkinleştirir.
  4. Yalnızca ilk imzaya bakmak. Sır döndürülürken eski sırrın imzası ikinci sırada olabilir.
  5. İşi yanıttan önce yapmak. İşi kuyruğa alın; zaman aşımından önce yanıt verin.
  6. Tekrarı hata saymak. Daha önce işlediğiniz bir tekrara da 2xx yanıt verin; yoksa yeniden denenir.
  7. Yönlendirmeye güvenmek. Son HTTPS adresini girin; UPPU yönlendirme izlemez, 3xx yanıt başarısız sayılır.

UPPU'da webhook'lar

UPPU'nun webhook'ları Standard Webhooks (HMAC-SHA256) ile imzalıdır. Olaylar en az bir kez ve kişi başına sıralı teslim edilir; başarısız teslim yaklaşık 76 saat boyunca 12 kez denenir ve olayları 30 gün içinde panelden yeniden gönderebilirsiniz. Her istek 91.216.36.26 adresinden gelir. Olay türleri, yeniden deneme takvimi ve olay yedeği webhook rehberinde.

Kaynaklar

  1. Standard Webhooks belirtimi (İngilizce) başlık adları, imzalanan içerik ve sır biçimi (erişim: 22.10.2026)
  2. UPPU webhook'ları: olaylar, teslim kuralları ve doğrulama olay türleri, yeniden deneme takvimi, olay yedeği
  3. UPPU API hata kodları (erişim: 22.10.2026)