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:
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
v1değ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:
| Ortam | Ham gövde | Dikkat edin |
|---|---|---|
| PHP | file_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, Express | Webhook rotasında express.raw({ type: 'application/json' }) | Genel bir express.json() önce çalışıp gövdeyi tüketir |
| Next.js rota işleyicisi | Buffer.from(await request.arrayBuffer()) | Doğrulamadan önce request.json() çağırmak |
| Flask | request.get_data() | Önce request.json okumak |
| Django | request.body | Önce request.POST okumak |
| FastAPI, Starlette | await 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
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
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
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 FalseFlask'ta ham gövdeyi, onu ayrıştıran herhangi bir şeyden önce okuyun:
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 "", 204Deneme 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.
| Öğe | Değer |
|---|---|
| Sır | whsec_dXBwdS1leGFtcGxlLXNpZ25pbmcta2V5LTMyYnl0ZXM= |
| Eski sır | whsec_dXBwdS1leGFtcGxlLU9MRC1zaWduaW5nLWtleS0zMmI= |
webhook-id | 01jab3t6y8q7z0k5w9x2c4d6ef |
webhook-timestamp | 1760778902 (sınarken «şimdi» olarak kullanın) |
| Beklenen imza | v1,bkasCZjmHFOH+EYlzJiVUXX0aoEvGPmlqK69s6np8Ss= |
| Döndürme başlığı (iki sır da doğrular) | v1,bkasCZjmHFOH+EYlzJiVUXX0aoEvGPmlqK69s6np8Ss= v1,x8egiBglmqEXbprO/7Z87AKPhtamIkB1DSxLS6Y6JkA= |
| Reddedilmeli | Fazladan 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.
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)) # FalseSık yapılan hatalar
- Ayrıştırılmış JSON'u doğrulamak. Her zaman ham baytları doğrulayın.
- Sır metnini anahtar olarak kullanmak. Önce
whsec_önekini atın ve base64'ten çözün. - 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.
- Yalnızca ilk imzaya bakmak. Sır döndürülürken eski sırrın imzası ikinci sırada olabilir.
- İşi yanıttan önce yapmak. İşi kuyruğa alın; zaman aşımından önce yanıt verin.
- Tekrarı hata saymak. Daha önce işlediğiniz bir tekrara da 2xx yanıt verin; yoksa yeniden denenir.
- 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
- Standard Webhooks belirtimi (İngilizce) başlık adları, imzalanan içerik ve sır biçimi (erişim: 22.10.2026)
- UPPU webhook'ları: olaylar, teslim kuralları ve doğrulama olay türleri, yeniden deneme takvimi, olay yedeği
- UPPU API hata kodları (erişim: 22.10.2026)