ThunderPhone 2.0 अब लाइव है।सेल्फ़-सर्व, 2¢/मिनट से।घोषणा पढ़ें

Operations

वेबहुक सिग्नेचर सत्यापित करें

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=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
  • t वह 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 queryHTTP अनुरोध लाइन से अनुरोध टारगेट: पाथ, फिर ? और मौजूद होने पर क्वेरी स्ट्रिंग। कोई स्कीम, होस्ट या फ़्रैगमेंट नहीं। इसे डीकोड किए बिना या पैरामीटर का क्रम बदले बिना, ठीक उसी तरह उपयोग करें जैसे यह प्राप्त हुआ था
bodyरॉ अनुरोध बॉडी। GET और DELETE टूल कॉल में कोई बॉडी नहीं होती, इसलिए उनकी हस्ताक्षरित स्ट्रिंग पाथ के बाद न्यूलाइन पर समाप्त होती है

वेरिफ़ाई करने के लिए:

  1. किसी भी पार्सिंग से पहले, हेडर, मेथड, रॉ पाथ और क्वेरी, तथा रॉ बॉडी पढ़ें।
  2. हेडर से t और प्रत्येक v1 लें।
  3. यदि t आपके क्लॉक से पहले या बाद में 300 सेकंड से अधिक है, तो अनुरोध अस्वीकार करें।
  4. अपने सीक्रेट के साथ हस्ताक्षरित स्ट्रिंग का हेक्स HMAC-SHA256 कंप्यूट करें।
  5. इसे कॉन्स्टेंट टाइम में 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-मिनट की विंडो जांचता है, कॉन्स्टेंट टाइम में कंपेयर करता है, और सीक्रेट्स की सूची स्वीकार करता है ताकि आप बिना डाउनटाइम के रोटेट कर सकें।

Python
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
)
Node.js
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 के बिना

Python
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)
Node.js
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 हेल्पर को कॉल करता है।

FastAPI
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}
Express
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);
  },
);
Django
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 — लेगसी, केवल-बॉडी सिग्नेचर, उसी की के साथ।

वेबहुक से दो बातें अलग हैं:

  1. GET / DELETE टूल्स में कोई बॉडी नहीं होती। आर्ग्युमेंट क्वेरी पैरामीटर के रूप में जाते हैं। V2 सिग्नेचर उन्हें पाथ और क्वेरी के माध्यम से कवर करता है, और बॉडी फ़ील्ड खाली होता है। रॉ क्वेरी स्ट्रिंग से वेरिफ़ाई करें: पैरामीटर पार्स करने और नई क्वेरी स्ट्रिंग बनाने से उनका क्रम या एन्कोडिंग बदल सकता है। लेगसी हेडर खाली बाइट स्ट्रिंग पर साइन करता है।
  2. जिन संगठनों में लेगसी वेबहुक कॉन्फ़िगर नहीं है, उनके पास संगठन सीक्रेट नहीं होता। उस स्थिति में टूल कॉल्स में केवल 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 पर माइग्रेट होने तक इसे अब भी हर रिक्वेस्ट पर भेजा जाता है।

  1. रॉ रिक्वेस्ट बॉडी पढ़ें — ठीक वही बाइट्स जो हमने आपको भेजी हैं।
  2. hmac_sha256(secret, body).hexdigest() कैलकुलेट करें।
  3. 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 के बॉडी फ़ील्ड पर भी लागू होती है।

Python
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 "")
Node.js
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),
  );
}
Go
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))
}
Ruby
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 रिस्पॉन्स दें।


अगले चरण