Open in
वेबहुक सिग्नेचर सत्यापित करें
ThunderPhone द्वारा भेजे जाने वाले हर वेबहुक और टूल अनुरोध पर टाइमस्टैम्प के साथ सिग्नेचर होता है। यहां दिए गए रेसिपी या SDK हेल्पर से इसे सत्यापित करें और पांच मिनट से पुराने दोहराए गए अनुरोधों को अस्वीकार करें।
हम आपके सर्वर को भेजने वाली हर रिक्वेस्ट (वेबहुक डिलीवरी, लेगेसी सिंगल-URL वेबहुक और टूल-एंडपॉइंट कॉल) में दो सिग्नेचर हेडर होते हैं। दोनों एक ही सीक्रेट के साथ HMAC-SHA256 हैं।
| हेडर | किन पर साइन करता है | रीप्ले प्रोटेक्शन |
|---|---|---|
X-ThunderPhone-Signature-V2 (अनुशंसित) | टाइमस्टैम्प, मेथड, पाथ, क्वेरी और बॉडी | 5 मिनट से अधिक पुराने रिक्वेस्ट अस्वीकार करें |
X-ThunderPhone-Signature | केवल बॉडी | कोई नहीं: कैप्चर की गई रिक्वेस्ट हमेशा वेरिफ़ाई होती है |
नए इंटीग्रेशन में X-ThunderPhone-Signature-V2 वेरिफ़ाई करें और
मौजूदा इंटीग्रेशन को इस पर माइग्रेट करें। X-ThunderPhone-Signature अब भी हर
रिक्वेस्ट पर बिना किसी बदलाव के भेजा जाता है, इसलिए इस पर बने वेरिफ़ायर काम करते रहते हैं। हम इसे भेजना बंद करने से पहले एक तारीख की घोषणा करेंगे।
हस्ताक्षर V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4etवह Unix समय है, सेकंड में, जब हमने अनुरोध भेजा था। दोबारा की गई डिलीवरी भेजे जाने पर फिर से साइन की जाती है, उसके अपनेtके साथ।v1नीचे दी गई हस्ताक्षरित स्ट्रिंग का हेक्स HMAC-SHA256 है, जिसे आपके सीक्रेट से की किया गया है।
वैल्यू, कॉमा से अलग किए गए key=value पेयर की सूची है। जिन कीज़ को आप
नहीं पहचानते उन्हें अनदेखा करें, और यदि कोई v1 मेल खाता है तो अनुरोध स्वीकार करें: हम
भविष्य के किसी बदलाव के दौरान दूसरा हस्ताक्षर जोड़ सकते हैं।
हस्ताक्षरित स्ट्रिंग
चार फ़ील्ड, प्रत्येक के बाद एक न्यूलाइन (\n, बाइट 0x0A), अंतिम फ़ील्ड को
छोड़कर:
<t>\n<METHOD>\n<path and query>\n<body>| फ़ील्ड | वैल्यू |
|---|---|
t | हेडर से t वैल्यू |
METHOD | अपर केस में HTTP मेथड: POST, GET, PUT, PATCH या DELETE |
| path and query | HTTP अनुरोध लाइन से अनुरोध टारगेट: पाथ, फिर ? और मौजूद होने पर क्वेरी स्ट्रिंग। कोई स्कीम, होस्ट या फ़्रैगमेंट नहीं। इसे डीकोड किए बिना या पैरामीटर का क्रम बदले बिना, ठीक उसी तरह उपयोग करें जैसे यह प्राप्त हुआ था |
body | रॉ अनुरोध बॉडी। GET और DELETE टूल कॉल में कोई बॉडी नहीं होती, इसलिए उनकी हस्ताक्षरित स्ट्रिंग पाथ के बाद न्यूलाइन पर समाप्त होती है |
वेरिफ़ाई करने के लिए:
- किसी भी पार्सिंग से पहले, हेडर, मेथड, रॉ पाथ और क्वेरी, तथा रॉ बॉडी पढ़ें।
- हेडर से
tऔर प्रत्येकv1लें। - यदि
tआपके क्लॉक से पहले या बाद में 300 सेकंड से अधिक है, तो अनुरोध अस्वीकार करें। - अपने सीक्रेट के साथ हस्ताक्षरित स्ट्रिंग का हेक्स HMAC-SHA256 कंप्यूट करें।
- इसे कॉन्स्टेंट टाइम में
v1से कंपेयर करें। कुछ भी मेल न खाए तो401रिस्पॉन्ड करें।
कार्य किया गया उदाहरण
सीक्रेट whsec_example_4c1f2a9b। हम यह अनुरोध
t = 1767225600 (2026-01-01 00:00:00 UTC) पर भेजते हैं:
POST /tools/lookup?region=us HTTP/1.1
Host: api.example.com
Content-Type: application/json
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
{"customer_id":"cust_123"}हस्ताक्षरित स्ट्रिंग ये चार लाइनें हैं, बॉडी के बाद कोई न्यूलाइन नहीं है:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}इसका HMAC-SHA256
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e है।
इसके विरुद्ध अपने इम्प्लीमेंटेशन की जांच करें, या इसे openssl से दोबारा बनाएं:
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'उसी सेकंड में /tools/lookup?customer_id=cust_123 के लिए GET टूल कॉल की
बॉडी खाली होती है। इसकी हस्ताक्षरित स्ट्रिंग
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n है और इसका हस्ताक्षर
3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280 है।
SDKs के साथ
Python और TypeScript SDKs में एक हेल्पर शामिल है
(thunderphone 0.2.0 या उसके बाद का, @thunderphone/sdk 0.2.0 या उसके बाद का)। यह
5-मिनट की विंडो जांचता है, कॉन्स्टेंट टाइम में कंपेयर करता है, और सीक्रेट्स की सूची
स्वीकार करता है ताकि आप बिना डाउनटाइम के रोटेट कर सकें।
from thunderphone import verify_signature
ok = verify_signature(
header, # the X-ThunderPhone-Signature-V2 value
secret=SECRET, # or [NEW_SECRET, OLD_SECRET] while rotating
method=method, # "POST"
path=path, # "/tools/lookup?region=us", exactly as received
body=raw_body, # bytes, before any JSON parsing
tolerance=300, # optional; seconds
)import { verifySignature } from "@thunderphone/sdk";
const ok = verifySignature({
header, // the X-ThunderPhone-Signature-V2 value
secret: SECRET, // or [NEW_SECRET, OLD_SECRET] while rotating
method, // "POST"
path, // "/tools/lookup?region=us", exactly as received
body: rawBody, // Buffer or string, before any JSON parsing
toleranceSeconds: 300, // optional
});SDK के बिना
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_v2(header: str, secret: str, method: str, path: str, body: bytes) -> bool:
"""path is the path plus ?query exactly as received; body is the raw body."""
timestamp, signatures = None, []
for item in (header or "").split(","):
key, _, value = item.strip().partition("=")
if key == "t" and value.isdigit() and len(value) <= 15:
timestamp = int(value)
elif key == "v1":
signatures.append(value)
if timestamp is None or abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
signed = f"{timestamp}\n{method.upper()}\n{path}\n".encode() + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected.encode(), s.encode()) for s in signatures)import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
// path is the path plus ?query exactly as received; body is the raw Buffer.
export function verifyV2(header, secret, method, path, body) {
let timestamp;
const signatures = [];
for (const item of (header || "").split(",")) {
const [key, value = ""] = item.trim().split("=", 2);
if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === "v1") signatures.push(value);
}
if (timestamp === undefined) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = Buffer.from(
crypto
.createHmac("sha256", secret)
.update(`${timestamp}\n${method.toUpperCase()}\n${path}\n`)
.update(body)
.digest("hex"),
);
return signatures.some((sig) => {
const candidate = Buffer.from(sig);
return (
candidate.length === expected.length &&
crypto.timingSafeEqual(candidate, expected)
);
});
}फ्रेमवर्क वायरिंग
हर उदाहरण रॉ पाथ और क्वेरी तथा रॉ बॉडी पढ़ता है, फिर SDK हेल्पर को कॉल करता है।
import json
from fastapi import FastAPI, HTTPException, Request
from thunderphone import verify_signature
app = FastAPI()
def raw_path_and_query(request: Request) -> str:
path = (request.scope.get("raw_path") or request.url.path.encode()).decode("latin-1")
query = request.scope.get("query_string", b"").decode("latin-1")
return f"{path}?{query}" if query else path
@app.post("/thunderphone-webhook")
async def hook(request: Request):
body = await request.body() # raw bytes, NOT request.json()
if not verify_signature(
request.headers.get("X-ThunderPhone-Signature-V2"),
secret=SECRET,
method=request.method,
path=raw_path_and_query(request),
body=body,
):
raise HTTPException(status_code=401)
event = json.loads(body)
# … dispatch on event["type"] …
return {"ok": True}import express from "express";
import { verifySignature } from "@thunderphone/sdk";
const app = express();
app.post(
"/thunderphone-webhook",
// IMPORTANT: parse as raw; do NOT use express.json() here.
express.raw({ type: "application/json" }),
(req, res) => {
const ok = verifySignature({
header: req.header("X-ThunderPhone-Signature-V2"),
secret: process.env.WEBHOOK_SECRET,
method: req.method,
path: req.originalUrl, // path and query as received
body: req.body,
});
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
// … dispatch on event.type …
res.sendStatus(204);
},
);import json
from django.http import HttpResponseForbidden, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from thunderphone import verify_signature
@csrf_exempt
@require_POST
def hook(request):
if not verify_signature(
request.headers.get("X-ThunderPhone-Signature-V2"),
secret=SECRET,
method=request.method,
path=request.get_full_path(),
body=request.body, # raw bytes
):
return HttpResponseForbidden("invalid signature")
event = json.loads(request.body)
# … dispatch on event["type"] …
return JsonResponse({"ok": True})req.originalUrl (Express) और ASGI के raw_path और query_string
(FastAPI) टारगेट को ठीक वैसे ही रखते हैं जैसा वह प्राप्त हुआ था। Django का
get_full_path() और Flask का request.path पहले पाथ को डिकोड करते हैं, जिससे
सामान्य पाथ के लिए वही स्ट्रिंग मिलती है। यदि आपके एंडपॉइंट पाथ में
प्रतिशत-एन्कोडेड या रिज़र्व्ड कैरेक्टर, जैसे %2F, ; या =, शामिल हैं, तो
इसके बजाय आपके सर्वर द्वारा उपलब्ध कराया गया रॉ टारगेट पढ़ें (उदाहरण के लिए,
Gunicorn पर request.META["RAW_URI"])।
कौन सा सीक्रेट?
एक ही सीक्रेट दोनों हेडर्स पर साइन करता है।
| स्रोत | सीक्रेट |
|---|---|
Webhook एंडपॉइंट (/v1/developer/webhook-endpoints) | प्रति-एंडपॉइंट secret (48 हेक्स कैरेक्टर), जो क्रिएट करते समय एक बार रिटर्न होता है |
| लेगेसी सिंगल-URL webhook | प्रति-संगठन secret, जो GET /v1/webhook पर रिटर्न होता है |
टूल-एंडपॉइंट इनवोकेशन (आपके endpoint.url पर डायरेक्ट कॉल) | संगठन-स्तरीय webhook सीक्रेट (लेगेसी सिंगल-URL webhook वाला ही) — प्रति-एंडपॉइंट सीक्रेट नहीं |
सीक्रेट को अपने सीक्रेट मैनेजर या env var में स्टोर करें — इसे कभी कमिट न करें।
टूल कॉल सत्यापित करना
जब एजेंट आपके
फ़ंक्शन टूल्स में से किसी एक को सीधे इनवोक करता है (टूल में
endpoint होता है), तो रिक्वेस्ट आपके कॉन्फ़िगर किए गए endpoint.headers के साथ
ये ThunderPhone हेडर भेजती है:
X-ThunderPhone-Call-ID— लाइव कॉल की संख्यात्मक आईडी।X-ThunderPhone-Signature-V2— ऊपर दिया गया टाइमस्टैम्प वाला सिग्नेचर, जो आपके संगठन-स्तरीय वेबहुक सीक्रेट से की किया गया है।X-ThunderPhone-Signature— लेगसी, केवल-बॉडी सिग्नेचर, उसी की के साथ।
वेबहुक से दो बातें अलग हैं:
GET/DELETEटूल्स में कोई बॉडी नहीं होती। आर्ग्युमेंट क्वेरी पैरामीटर के रूप में जाते हैं। V2 सिग्नेचर उन्हें पाथ और क्वेरी के माध्यम से कवर करता है, और बॉडी फ़ील्ड खाली होता है। रॉ क्वेरी स्ट्रिंग से वेरिफ़ाई करें: पैरामीटर पार्स करने और नई क्वेरी स्ट्रिंग बनाने से उनका क्रम या एन्कोडिंग बदल सकता है। लेगसी हेडर खाली बाइट स्ट्रिंग पर साइन करता है।- जिन संगठनों में लेगसी वेबहुक कॉन्फ़िगर नहीं है, उनके पास संगठन सीक्रेट नहीं होता। उस
स्थिति में टूल कॉल्स में केवल
X-ThunderPhone-Call-IDहोता है और कोई सिग्नेचर हेडर नहीं होता। साइनिंग सीक्रेट पाने के लिए लेगसी वेबहुक (PUT /v1/webhook) कॉन्फ़िगर करें, याendpoint.headersके माध्यम से अपने हेडर के साथ टूल कॉल्स को ऑथेंटिकेट करें।
@app.api_route("/tools/search-appointments", methods=["GET", "POST"])
async def tool(request: Request):
body = await request.body() # b"" for GET/DELETE tools
if not verify_signature(
request.headers.get("X-ThunderPhone-Signature-V2"),
secret=ORG_WEBHOOK_SECRET,
method=request.method,
path=raw_path_and_query(request),
body=body,
):
raise HTTPException(status_code=401)
call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
args = json.loads(body) if body else dict(request.query_params)
...Express में, बॉडी पार्सर GET पर req.body को खाली ऑब्जेक्ट के रूप में छोड़ देता है,
इसलिए Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0) पास करें।
वेबहुक-मोड टूल डिस्पैच (वे टूल जिनमें endpoint नहीं होता और जिन्हें
आपके संगठन वेबहुक पर telephony.tool / web.tool के रूप में डिलीवर किया जाता है) एक सामान्य
साइन्ड वेबहुक है — ऊपर दिया गया मानक तरीका लागू होता है। दोनों रिक्वेस्ट स्वरूपों के लिए
फंक्शन टूल देखें।
लेगेसी सिग्नेचर: X-ThunderPhone-Signature
मूल हेडर केवल रॉ बॉडी का हेक्स HMAC-SHA256 है, उसी सीक्रेट के साथ। इसमें कोई टाइमस्टैम्प नहीं होता, इसलिए जो भी एक साइन की गई रिक्वेस्ट को कैप्चर करता है, वह उसे रीप्ले कर सकता है। इंटीग्रेशन के V2 पर माइग्रेट होने तक इसे अब भी हर रिक्वेस्ट पर भेजा जाता है।
- रॉ रिक्वेस्ट बॉडी पढ़ें — ठीक वही बाइट्स जो हमने आपको भेजी हैं।
hmac_sha256(secret, body).hexdigest()कैलकुलेट करें।X-ThunderPhone-Signatureसे कॉन्स्टेंट टाइम में तुलना करें।
हम ठीक उन्हीं बाइट्स पर साइन करते हैं जिन्हें हम ट्रांसमिट करते हैं। वे बाइट्स पेलोड का
कैनॉनिकल JSON सीरियलाइज़ेशन भी हैं — कीज़ वर्णानुक्रम में सॉर्ट की गई हैं,
कॉम्पैक्ट सेपरेटर (बिना स्पेस के , और :), UTF-8।
अगर आपका फ्रेमवर्क केवल पार्स किया गया JSON एक्सपोज़ करता है, तो उसे कैनॉनिकली री-सीरियलाइज़ करें और
उसका HMAC बनाएं:
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")रॉ बॉडी को प्राथमिकता दें — इसमें एक स्टेप कम है और यह कुछ भाषाओं में JSON नंबर राउंड-ट्रिपिंग की विचित्रताओं से सुरक्षित है। यही बात V2 के बॉडी फ़ील्ड पर भी लागू होती है।
import hashlib
import hmac
def verify(body: bytes, signature: str, secret: str) -> bool:
"""Constant-time HMAC-SHA256 verification."""
expected = hmac.new(
secret.encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature or "")import crypto from "node:crypto";
export function verify(body, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
if (!signature || expected.length !== signature.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
);
}package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func Verify(body []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}require "openssl"
def verify(body, signature, secret)
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
Rack::Utils.secure_compare(expected, signature.to_s)
endसामान्य समस्याएँ
क्लॉक स्क्यू
5-मिनट की विंडो आपके सर्वर की घड़ी के आधार पर मापी जाती है। इसे NTP के साथ सिंक्रोनाइज़ रखें; 5 मिनट से अधिक गलत घड़ी हर रिक्वेस्ट को अस्वीकार कर देती है।
एक प्रॉक्सी पाथ को फिर से लिखती है
साइन किया गया पाथ वही है जिसका हमने अनुरोध किया था: उस URL का पाथ जिसे आपने ThunderPhone में कॉन्फ़िगर किया है। यदि आपकी एप्लिकेशन के रिक्वेस्ट देखने से पहले कोई रिवर्स प्रॉक्सी या API गेटवे प्रीफ़िक्स हटाता या जोड़ता है, तो वेरिफ़ाई करने से पहले हमारे भेजे गए पाथ को फिर से बनाएँ (उदाहरण के लिए, प्रीफ़िक्स वापस जोड़ें)।
डिफ़ॉल्ट फ़ॉर्मैटिंग के साथ फिर से सीरियलाइज़ करना
बॉडी को पार्स करके अपनी JSON लाइब्रेरी के
डिफ़ॉल्ट विकल्पों (, / : के बाद स्पेस, इंसर्शन-ऑर्डर वाली कीज़) के साथ फिर से डंप करने पर
अलग बाइट्स बनते हैं और HMAC टूट जाता है। रॉ बॉडी को वेरिफ़ाई करें — या यदि
आपको फिर से सीरियलाइज़ करना ही है, तो हमारे कैनॉनिकल फॉर्म से बिल्कुल मिलान करें: सॉर्ट की गई
कीज़, कॉम्पैक्ट सेपरेटर, UTF-8।
फ़्रेमवर्क JSON को ऑटो-पार्स करता है
Express का express.json() मिडलवेयर बॉडी स्ट्रीम को
कंज़्यूम कर लेता है और आप रॉ बाइट्स खो देते हैं। खास तौर पर वेबहुक
रूट पर express.raw() इस्तेमाल करें, या प्री-मिडलवेयर में रॉ बॉडी को बफ़र करें।
NestJS / Koa के लिए भी यही बात लागू होती है — उनके "रॉ बॉडी" डॉक्यूमेंटेशन देखें।
टाइमिंग-असुरक्षित तुलना
JS में expected === signature या Python में expected == signature
टाइमिंग-वेरिएबल तुलनाएँ हैं। क्रमशः crypto.timingSafeEqual
या hmac.compare_digest इस्तेमाल करें। परफ़ॉर्मेंस में
कोई अंतर नहीं है।
टूल एंडपॉइंट्स के लिए गलत सीक्रेट
डायरेक्ट टूल-एंडपॉइंट कॉल्स पर संगठन-स्तरीय वेबहुक
सीक्रेट (GET /v1/webhook) से साइन किया जाता है — न कि
/v1/developer/webhook-endpoints के किसी प्रति-एंडपॉइंट सीक्रेट से। उसी हेल्पर का पुनः उपयोग करें,
लेकिन सुनिश्चित करें कि टूल रूट्स पर आप उसे संगठन का सीक्रेट देते हैं।
GET/DELETE टूल्स पर क्वेरी स्ट्रिंग्स
V2 क्वेरी स्ट्रिंग को ठीक उसी तरह साइन करता है जैसे वह भेजी गई है। लेगेसी हेडर खाली बॉडी पर साइन करता है और कभी क्वेरी स्ट्रिंग पर नहीं, इसलिए लेगेसी हेडर के लिए URL को हैश करना कभी मेल नहीं खाएगा।
विंडो के भीतर डुप्लिकेट
टाइमस्टैम्प 5 मिनट से पुराने रीप्ले को रोकता है, उस विंडो के भीतर आने वाली दूसरी कॉपी को नहीं।
एंडपॉइंट डिलीवरी कम-से-कम-एक-बार होती हैं, इसलिए उसी इवेंट का
रिट्राई भी आ सकता है; एनवेलप के event_id पर डी-डुप्लिकेट करें।
मिसमैच पर 401 रिटर्न न करना
वेरिफ़िकेशन विफल होने पर 200 रिटर्न करना हैंडलर को रीप्ले टारगेट बना देता है। वेरिफ़िकेशन विफल होने पर हमेशा नॉन-2xx रिस्पॉन्स दें।