---
title: "Verifikasi tanda tangan webhook"
description: "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.

| Header | Menandatangani | Perlindungan replay |
|--------|-------|-------------------|
| `X-ThunderPhone-Signature-V2` (direkomendasikan) | Stempel waktu, metode, path, kueri, dan body | Tolak permintaan yang berusia lebih dari 5 menit |
| `X-ThunderPhone-Signature` | Hanya body | Tidak 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

```http
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:

```text
<t>\n<METHOD>\n<path and query>\n<body>
```

| Bidang | Nilai |
|-------|-------|
| `t` | Nilai `t` dari header |
| `METHOD` | Metode HTTP dalam huruf besar: `POST`, `GET`, `PUT`, `PATCH`, atau `DELETE` |
| path and query | Target 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 |
| `body` | Isi 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):

```http
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:

```text
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`:

```bash
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](/api-reference/sdks) 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.

<CodeGroup>
```python 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
)
```

```javascript 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
});
```
</CodeGroup>

### Tanpa SDK

<CodeGroup>
```python 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)
```

```javascript 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)
    );
  });
}
```
</CodeGroup>

### Integrasi framework

Setiap contoh membaca path dan query mentah serta body mentah, lalu memanggil
helper SDK.

<CodeGroup>
```python 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}
```

```javascript 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);
  },
);
```

```python 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})
```
</CodeGroup>

`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.

| Sumber | Rahasia |
|--------|--------|
| [Endpoint webhook](/id/webhooks/endpoints) (`/v1/developer/webhook-endpoints`) | `secret` per endpoint (48 karakter heksadesimal) yang dikembalikan sekali saat pembuatan |
| [Webhook URL tunggal lama](/api-reference/organizations#legacy-single-url-webhook) | `secret` per organisasi yang dikembalikan pada `GET /v1/webhook` |
| [Pemanggilan endpoint alat](/id/tools/overview) (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](/id/tools/overview) 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`.

```python
@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](/id/tools/overview) 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:

```python
# 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.

<CodeGroup>
```python 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 "")
```

```javascript 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 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 Ruby
require "openssl"

def verify(body, signature, secret)
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
  Rack::Utils.secure_compare(expected, signature.to_s)
end
```
</CodeGroup>

## Kesalahan umum

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="Tidak mengembalikan 401 saat tidak cocok">
    Mengembalikan 200 saat verifikasi gagal menjadikan handler sebagai
    target replay. Selalu respons dengan non-2xx jika verifikasi gagal.
  </Accordion>
</AccordionGroup>

---

## Langkah berikutnya

<CardGroup cols={2}>
  <Card title="Ringkasan webhook" icon="bolt" href="/id/webhooks/overview">
    Semantik pengiriman, percobaan ulang, IP sumber.
  </Card>
  <Card title="Endpoint webhook" icon="plug" href="/id/webhooks/endpoints">
    Kelola beberapa URL, rotasi rahasia.
  </Card>
  <Card title="Tool Fungsi" icon="screwdriver-wrench" href="/id/tools/overview">
    Dua jalur pemanggilan tool dan bentuk permintaannya.
  </Card>
  <Card title="Integrasi tool" icon="wrench" href="/id/guides/build-tool-integration">
    Bangun integrasi lengkap yang didukung tool dari awal hingga akhir.
  </Card>
</CardGroup>
