ThunderPhone 2.0 kini resmi hadir.Gunakan langsung secara mandiri, mulai 2¢/menit.Baca pengumumannya

Operations

Verifikasi tanda tangan webhook

Setiap webhook dan permintaan alat yang dikirim ThunderPhone ditandatangani dengan stempel waktu. Verifikasi dengan resep atau helper SDK di sini dan tolak permintaan yang diputar ulang yang berusia lebih dari lima menit.

Setiap permintaan yang kami kirim ke server Anda (pengiriman webhook, webhook URL tunggal versi lama, dan panggilan endpoint alat) memuat dua header tanda tangan. Keduanya adalah HMAC-SHA256 dengan secret yang sama.

HeaderMenandatanganiPerlindungan replay
X-ThunderPhone-Signature-V2 (direkomendasikan)Stempel waktu, metode, path, kueri, dan bodyTolak permintaan yang berusia lebih dari 5 menit
X-ThunderPhone-SignatureHanya bodyTidak ada: permintaan yang ditangkap akan selalu terverifikasi

Verifikasi X-ThunderPhone-Signature-V2 pada integrasi baru dan pindahkan integrasi yang ada ke sana. X-ThunderPhone-Signature masih dikirim pada setiap permintaan, tanpa perubahan, sehingga verifier yang dibangun dengannya tetap berfungsi. Kami akan mengumumkan tanggal sebelum berhenti mengirimkannya.

Tanda Tangan V2

X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
  • t adalah waktu Unix, dalam detik, saat kami mengirim permintaan. Pengiriman yang dicoba ulang ditandatangani lagi saat dikirim, dengan t miliknya sendiri.
  • v1 adalah HMAC-SHA256 heksadesimal dari string yang ditandatangani di bawah, menggunakan secret Anda sebagai kunci.

Nilainya adalah daftar pasangan key=value yang dipisahkan koma. Abaikan kunci yang tidak Anda kenali, dan terima permintaan jika ada v1 yang cocok: kami mungkin menambahkan tanda tangan kedua pada perubahan di masa mendatang.

String yang ditandatangani

Empat bidang, masing-masing diikuti satu baris baru (\n, byte 0x0A) kecuali yang terakhir:

<t>\n<METHOD>\n<path and query>\n<body>
BidangNilai
tNilai t dari header
METHODMetode HTTP dalam huruf besar: POST, GET, PUT, PATCH, atau DELETE
path and queryTarget permintaan dari baris permintaan HTTP: path, lalu ? dan string kueri jika ada. Tanpa skema, host, atau fragmen. Gunakan persis seperti saat diterima, tanpa mendekodenya atau mengubah urutan parameter
bodyIsi mentah permintaan. Panggilan tool GET dan DELETE tidak memiliki isi, sehingga string yang ditandatangani berakhir dengan baris baru setelah path

Untuk memverifikasi:

  1. Sebelum melakukan parsing apa pun, baca header, metode, path dan kueri mentah, serta isi mentah.
  2. Ambil t dan setiap v1 dari header.
  3. Tolak permintaan jika t lebih dari 300 detik sebelum atau sesudah waktu Anda.
  4. Hitung HMAC-SHA256 heksadesimal dari string yang ditandatangani menggunakan secret Anda.
  5. Bandingkan dengan v1 dalam waktu konstan. Respons 401 jika tidak ada yang cocok.

Contoh lengkap

Secret whsec_example_4c1f2a9b. Kami mengirim permintaan ini pada 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"}

String yang ditandatangani adalah empat baris berikut, tanpa baris baru setelah isi:

1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}

HMAC-SHA256-nya adalah 03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e. Periksa implementasi Anda dengan nilai tersebut, atau buat ulang menggunakan openssl:

printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
  | openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'

Panggilan tool GET ke /tools/lookup?customer_id=cust_123 pada detik yang sama memiliki isi kosong. String yang ditandatanganinya adalah 1767225600\nGET\n/tools/lookup?customer_id=cust_123\n dan tanda tangannya adalah 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.

Dengan SDK

SDK Python dan TypeScript menyertakan helper (thunderphone 0.2.0 atau versi lebih baru, @thunderphone/sdk 0.2.0 atau versi lebih baru). Helper ini memeriksa rentang 5 menit, membandingkan dalam waktu konstan, dan menerima daftar secret sehingga Anda dapat merotasinya tanpa waktu henti.

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
});

Tanpa 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)
    );
  });
}

Integrasi framework

Setiap contoh membaca path dan query mentah serta body mentah, lalu memanggil helper 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) serta raw_path dan query_string ASGI (FastAPI) adalah target persis seperti yang diterima. get_full_path() Django dan request.path Flask mendekode path terlebih dahulu, sehingga menghasilkan string yang sama untuk path biasa. Jika path endpoint Anda berisi karakter yang dikodekan dalam persen atau karakter khusus seperti %2F, ;, atau =, baca target mentah yang disediakan server Anda sebagai gantinya (misalnya request.META["RAW_URI"] pada Gunicorn).

Rahasia yang mana?

Rahasia yang sama menandatangani kedua header.

SumberRahasia
Endpoint webhook (/v1/developer/webhook-endpoints)secret per endpoint (48 karakter heksadesimal) yang dikembalikan sekali saat pembuatan
Webhook URL tunggal lamasecret per organisasi yang dikembalikan pada GET /v1/webhook
Pemanggilan endpoint alat (panggilan langsung ke endpoint.url Anda)Rahasia webhook tingkat organisasi (sama dengan webhook URL tunggal lama) — bukan rahasia per endpoint

Simpan rahasia di pengelola rahasia atau variabel environment Anda — jangan pernah melakukan commit.

Memverifikasi panggilan alat

Saat agen memanggil salah satu alat fungsi Anda secara langsung (alat tersebut memiliki endpoint), permintaan membawa header ThunderPhone berikut bersama endpoint.headers yang Anda konfigurasi:

  • X-ThunderPhone-Call-ID — ID numerik panggilan yang sedang berlangsung.
  • X-ThunderPhone-Signature-V2 — tanda tangan bertimestamp di atas, dengan kunci rahasia webhook tingkat organisasi Anda.
  • X-ThunderPhone-Signature — tanda tangan legacy yang hanya mencakup body, dengan kunci yang sama.

Dua hal berbeda dari webhook:

  1. Alat GET / DELETE tidak memiliki body. Argumen dikirim sebagai parameter kueri. Tanda tangan V2 mencakupnya melalui path dan kueri, dan kolom body kosong. Verifikasi menggunakan string kueri mentah: mem-parsing parameter dan membuat string kueri baru dapat mengubah urutan atau encoding-nya. Header legacy menandatangani string byte kosong.
  2. Organisasi tanpa webhook legacy yang dikonfigurasi tidak memiliki rahasia organisasi. Dalam kasus tersebut, panggilan alat hanya membawa X-ThunderPhone-Call-ID dan tidak memiliki header tanda tangan. Konfigurasikan webhook legacy (PUT /v1/webhook) untuk mendapatkan rahasia penandatanganan, atau autentikasi panggilan alat menggunakan header Anda sendiri melalui 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)
    ...

Di Express, parser body meninggalkan req.body sebagai objek kosong pada GET, jadi teruskan Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).

Pengiriman alat dalam mode webhook (alat tanpa endpoint, dikirimkan ke webhook organisasi Anda sebagai telephony.tool / web.tool) adalah webhook bertanda tangan biasa — resep standar di atas berlaku. Lihat Alat Fungsi untuk kedua bentuk permintaan.

Tanda tangan lama: X-ThunderPhone-Signature

Header asli adalah HMAC-SHA256 heksadesimal dari body mentah saja, dengan secret yang sama. Header ini tidak memuat stempel waktu, sehingga siapa pun yang menangkap satu permintaan bertanda tangan dapat memutarnya ulang. Header ini masih dikirim pada setiap permintaan saat integrasi beralih ke V2.

  1. Baca body permintaan mentah — byte persis yang kami kirim kepada Anda.
  2. Hitung hmac_sha256(secret, body).hexdigest().
  3. Bandingkan dalam waktu konstan dengan X-ThunderPhone-Signature.

Kami menandatangani byte persis yang kami transmisikan. Byte tersebut juga merupakan serialisasi JSON kanonis dari payload — kunci diurutkan secara alfabetis, pemisah ringkas (, dan : tanpa spasi), UTF-8. Jika framework Anda hanya menyediakan JSON yang telah diurai, serialisasi ulang secara kanonis dan gunakan HMAC untuk itu:

# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")

Sebaiknya gunakan body mentah — satu langkah lebih sedikit dan kebal terhadap kejanggalan bolak-balik angka JSON dalam beberapa bahasa. Hal yang sama berlaku untuk field body 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

Kesalahan umum

Penyimpangan jam

Jendela 5 menit diukur berdasarkan jam server Anda. Pastikan jam tersebut tersinkronisasi dengan NTP; jam yang meleset lebih dari 5 menit akan menolak setiap permintaan.

Proxy menulis ulang path

Path yang ditandatangani adalah path yang kami minta: path URL yang Anda konfigurasi di ThunderPhone. Jika proxy balik atau gateway API menghapus atau menambahkan prefiks sebelum aplikasi Anda melihat permintaan, bangun ulang path yang kami kirim (misalnya, tambahkan kembali prefiks) sebelum memverifikasi.

Serialisasi ulang dengan pemformatan default

Mengurai isi lalu membuangnya kembali dengan pengaturan default pustaka JSON Anda (spasi setelah , / :, kunci berurutan berdasarkan penyisipan) menghasilkan byte yang berbeda dan merusak HMAC. Verifikasi isi mentah — atau jika Anda harus melakukan serialisasi ulang, samakan persis dengan bentuk kanonis kami: kunci terurut, pemisah ringkas, UTF-8.

Framework mengurai JSON secara otomatis

Middleware express.json() Express mengonsumsi stream body sehingga Anda kehilangan byte mentah. Gunakan express.raw() khusus pada rute webhook, atau buffer body mentah dalam middleware sebelumnya. Hal yang sama berlaku untuk NestJS / Koa — periksa dokumentasi "raw body" mereka.

Perbandingan yang tidak aman terhadap waktu

expected === signature di JS atau expected == signature di Python adalah perbandingan dengan waktu yang bervariasi. Gunakan crypto.timingSafeEqual atau hmac.compare_digest secara berurutan. Perbedaan performanya tidak ada.

Rahasia yang salah untuk endpoint tool

Panggilan endpoint tool langsung ditandatangani dengan rahasia webhook tingkat organisasi (GET /v1/webhook) — bukan dengan rahasia per-endpoint dari /v1/developer/webhook-endpoints. Gunakan kembali helper yang sama, tetapi pastikan Anda memberikan rahasia organisasi pada rute tool.

String kueri pada tool GET/DELETE

V2 menandatangani string kueri persis seperti yang dikirim. Header lama menandatangani isi kosong dan tidak pernah string kueri, sehingga melakukan hash pada URL untuk header lama tidak akan pernah cocok.

Duplikat dalam jendela

Timestamp menghentikan pemutaran ulang yang lebih lama dari 5 menit, bukan salinan kedua di dalam jendela tersebut. Pengiriman endpoint bersifat setidaknya sekali, sehingga percobaan ulang untuk event yang sama juga dapat tiba; lakukan deduplikasi berdasarkan event_id envelope.

Tidak mengembalikan 401 saat tidak cocok

Mengembalikan 200 saat verifikasi gagal menjadikan handler sebagai target replay. Selalu respons dengan non-2xx jika verifikasi gagal.


Langkah berikutnya