---
title: "אמתו חתימות של webhook"
description: "כל בקשת webhook וכל בקשת כלי ש-ThunderPhone שולח נחתמות בחותמת זמן. אמתו אותן באמצעות המתכון או מסייע ה-SDK כאן, ודחו בקשות חוזרות שגילן עולה על חמש דקות."
---

כל בקשה שאנו שולחים לשרת שלכם (מסירות webhook, ה-webhook הישן עם כתובת URL יחידה וקריאות לנקודות קצה של כלים) כוללת שתי כותרות חתימה. שתיהן מסוג HMAC-SHA256 ומשתמשות באותו סוד.

| כותרת | חותמת על | הגנה מפני שידור חוזר |
|--------|-------|-------------------|
| `X-ThunderPhone-Signature-V2` (מומלצת) | חותמת זמן, שיטה, נתיב, שאילתה וגוף הבקשה | דחו בקשות שגילן עולה על 5 דקות |
| `X-ThunderPhone-Signature` | גוף הבקשה בלבד | אין: בקשה שנלכדה תאומת לעד |

אמתו את `X-ThunderPhone-Signature-V2` באינטגרציות חדשות והעבירו אליו אינטגרציות קיימות. `X-ThunderPhone-Signature` עדיין נשלחת בכל בקשה, ללא שינוי, ולכן מאמתים שנבנו עבורה ימשיכו לפעול. נודיע על תאריך לפני שנפסיק לשלוח אותה.

## חתימה V2

```http
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
```

- `t` הוא זמן Unix, בשניות, שבו שלחנו את הבקשה. משלוח שמנוסה מחדש
  נחתם שוב בעת שליחתו, עם `t` משלו.
- `v1` הוא ערך HMAC-SHA256 הקסדצימלי של המחרוזת החתומה שלהלן, באמצעות
  הסוד שלכם כמפתח.

הערך הוא רשימה מופרדת בפסיקים של זוגות `key=value`. התעלמו ממפתחות שאינכם
מזהים, וקבלו את הבקשה אם ערך `v1` כלשהו תואם: ייתכן שנוסיף חתימה שנייה
במהלך שינוי עתידי.

### המחרוזת החתומה

ארבעה שדות, שכל אחד מהם מלווה בירידת שורה אחת (`\n`, בייט `0x0A`) למעט
האחרון:

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

| שדה | ערך |
|-------|-------|
| `t` | ערך `t` מהכותרת |
| `METHOD` | שיטת HTTP באותיות גדולות: `POST`, `GET`, `PUT`, `PATCH` או `DELETE` |
| נתיב ושאילתה | יעד הבקשה משורת בקשת HTTP: הנתיב, ולאחריו `?` ומחרוזת השאילתה כאשר קיימת כזו. ללא סכימה, מארח או מקטע. השתמשו בו בדיוק כפי שהתקבל, ללא פענוח או שינוי סדר הפרמטרים |
| `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):

```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"}
```

המחרוזת החתומה היא ארבע השורות הבאות, ללא ירידת שורה אחרי הגוף:

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

ערך ה-HMAC-SHA256 שלה הוא
`03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e`.
בדקו את המימוש שלכם מולו, או שחזרו אותו באמצעות `openssl`:

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

לקריאת כלי `GET` אל `/tools/lookup?customer_id=cust_123` באותה
שנייה יש גוף ריק. המחרוזת החתומה שלה היא
`1767225600\nGET\n/tools/lookup?customer_id=cust_123\n` והחתימה שלה
היא `3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280`.

### עם ערכות ה-SDK

[ערכות ה-SDK של Python ו-TypeScript](/api-reference/sdks) כוללות פונקציית עזר
(`thunderphone` בגרסה 0.2.0 ואילך, `@thunderphone/sdk` בגרסה 0.2.0 ואילך). היא
בודקת את חלון חמש הדקות, משווה בזמן קבוע ומקבלת רשימת סודות, כך שתוכלו
לבצע רוטציה ללא השבתה.

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

### ללא 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>

### שילוב עם מסגרות עבודה

כל דוגמה קוראת את הנתיב והמחרוזת של השאילתה הגולמיים ואת גוף הבקשה הגולמי, ולאחר מכן קוראת למסייע של ה-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) וכן `raw_path` ו-`query_string` של ASGI
‏(FastAPI) הם יעד הבקשה בדיוק כפי שהתקבל. `get_full_path()` של Django
ו-`request.path` של Flask מפענחים תחילה את הנתיב, ולכן מחזירים את אותה
מחרוזת עבור נתיבים רגילים. אם נתיב נקודת הקצה שלכם מכיל תווים המקודדים
באחוזים או תווים שמורים כגון `%2F`, ‏`;` או `=`, קראו במקום זאת את יעד
הבקשה הגולמי שהשרת שלכם מספק (לדוגמה `request.META["RAW_URI"]` ב-Gunicorn).

## איזה סוד?

אותו סוד חותם על שתי הכותרות.

| מקור | סוד |
|--------|--------|
| [נקודת קצה של webhook](/he/webhooks/endpoints) (`/v1/developer/webhook-endpoints`) | `secret` לכל נקודת קצה (48 תווי הקסדצימליים), שמוחזר פעם אחת בעת היצירה |
| [webhook מדור קודם עם כתובת URL יחידה](/api-reference/organizations#legacy-single-url-webhook) | `secret` לכל ארגון, שמוחזר ב-`GET /v1/webhook` |
| [הפעלה של נקודת קצה לכלי](/he/tools/overview) (קריאה ישירה אל `endpoint.url` שלכם) | **סוד ה-webhook ברמת הארגון** (אותו סוד כמו ב-webhook המדור קודם עם כתובת URL יחידה) — לא סוד לכל נקודת קצה |

שמרו את הסוד במנהל הסודות או במשתנה הסביבה שלכם — לעולם אל תבצעו לו commit.

## אימות קריאות לכלים

כאשר הסוכן מפעיל ישירות אחד
מ[כלי הפונקציה](/he/tools/overview) שלכם (לכלי יש
`endpoint`), הבקשה כוללת את כותרות ThunderPhone הבאות לצד
`endpoint.headers` שהגדרתם:

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

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

ב-Express, מנתח גוף מותיר את `req.body` כאובייקט ריק ב-`GET`,
לכן העבירו `Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0)`.

הפצת כלים ב**מצב** webhook (כלים ללא `endpoint`, הנשלחים ל-webhook
הארגוני שלכם כ-`telephony.tool` / `web.tool`) היא webhook רגיל עם
חתימה — המתכון הסטנדרטי שלמעלה חל. ראו [כלי פונקציות](/he/tools/overview)
לשתי צורות הבקשה.

## חתימה מדור קודם: `X-ThunderPhone-Signature`

הכותרת המקורית היא HMAC-SHA256 הקסדצימלי של הגוף הגולמי בלבד, עם
אותו סוד. היא אינה כוללת חותמת זמן, ולכן כל מי שלוכד בקשה חתומה אחת
יכול להפעיל אותה מחדש. היא עדיין נשלחת בכל בקשה בזמן שהאינטגרציות עוברות ל-V2.

1. קראו את גוף הבקשה **הגולמי** — הבתים המדויקים ששלחנו לכם.
2. חשבו את `hmac_sha256(secret, body).hexdigest()`.
3. השוו ב**זמן קבוע** מול `X-ThunderPhone-Signature`.

אנו חותמים על הבתים המדויקים שאנו משדרים. בתים אלה הם גם
**סידור ה-JSON הקנוני** של המטען — מפתחות ממוינים בסדר אלפביתי,
מפרידים קומפקטיים (`,` ו-`:` ללא רווחים), UTF-8.
אם המסגרת שלכם חושפת רק JSON מפוענח, סדרו אותו מחדש באופן קנוני והחילו עליו HMAC:

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

העדיפו את הגוף הגולמי — זהו שלב אחד פחות והוא חסין בפני בעיות בהמרה הלוך ושוב של מספרי JSON בשפות מסוימות. הדבר נכון גם לשדה הגוף של 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>

## מלכודות נפוצות

<AccordionGroup>
  <Accordion title="סטיית שעון">
    חלון הזמן של 5 דקות נמדד מול שעון השרת שלכם. שמרו אותו
    מסונכרן עם NTP; שעון שסוטה ביותר מ-5 דקות ידחה כל
    בקשה.
  </Accordion>

  <Accordion title="Proxy משכתב את הנתיב">
    הנתיב החתום הוא הנתיב שביקשנו: נתיב ה-URL שהגדרתם
    ב-ThunderPhone. אם proxy הפוך או שער API מסיר
    או מוסיף קידומת לפני שהיישום שלכם רואה את הבקשה, בנו מחדש
    את הנתיב ששלחנו (לדוגמה, הוסיפו את הקידומת בחזרה) לפני האימות.
  </Accordion>

  <Accordion title="סידור טורי מחדש עם עיצוב ברירת המחדל">
    פענוח הגוף והמרתו מחדש באמצעות הגדרות ברירת המחדל של
    ספריית ה-JSON שלכם (רווחים אחרי `,` / `:`, מפתחות לפי סדר הכנסה) יוצר
    בתים שונים ושובר את ה-HMAC. אמתו את הגוף הגולמי — או אם
    עליכם לבצע סידור טורי מחדש, התאימו בדיוק לצורה הקנונית שלנו: מפתחות
    ממוינים, מפרידים קומפקטיים, UTF-8.
  </Accordion>

  <Accordion title="המסגרת מנתחת JSON באופן אוטומטי">
    התווכה `express.json()` של Express צורכת את זרם הגוף
    ואתם מאבדים את הבתים הגולמיים. השתמשו ב-`express.raw()` במיוחד בנתיב
    ה-webhook, או אחסנו את הגוף הגולמי בחוצץ בתווכה מקדימה.
    אותו הדבר נכון עבור NestJS / Koa — עיינו בתיעוד שלהם בנושא "גוף גולמי".
  </Accordion>

  <Accordion title="השוואה שאינה בטוחה מבחינת זמן">
    `expected === signature` ב-JS או `expected == signature` ב-
    Python הן השוואות שמשך הזמן שלהן משתנה. השתמשו ב-`crypto.timingSafeEqual`
    או ב-`hmac.compare_digest` בהתאמה. ההבדל בביצועים
    זניח.
  </Accordion>

  <Accordion title="סוד שגוי עבור נקודות קצה של כלים">
    קריאות ישירות לנקודות קצה של כלים נחתמות באמצעות **סוד ה-webhook
    ברמת הארגון** (`GET /v1/webhook`) — ולא באמצעות סוד כלשהו לכל נקודת קצה
    מתוך `/v1/developer/webhook-endpoints`. השתמשו מחדש באותו מסייע,
    אך ודאו שאתם מעבירים לו את סוד הארגון בנתיבי כלים.
  </Accordion>

  <Accordion title="מחרוזות שאילתה בכלי GET/DELETE">
    V2 חותם על מחרוזת השאילתה בדיוק כפי שנשלחה. הכותרת הישנה חותמת
    על הגוף הריק ולעולם לא על מחרוזת השאילתה, לכן גיבוב ה-URL עבור
    הכותרת הישנה לעולם לא יתאים.
  </Accordion>

  <Accordion title="כפילויות בתוך חלון הזמן">
    חותמת הזמן מונעת הפעלות חוזרות שגילן עולה על 5 דקות, ולא עותק שני
    בתוך חלון זה. מסירות לנקודות קצה מתבצעות לפחות פעם אחת, לכן גם
    ניסיון חוזר של אותו אירוע עשוי להגיע; הסירו כפילויות לפי
    `event_id` של המעטפה.
  </Accordion>

  <Accordion title="אי-החזרת 401 במקרה של אי-התאמה">
    החזרת 200 כאשר האימות נכשל הופכת את המטפל ליעד להפעלה חוזרת.
    השיבו תמיד בקוד שאינו 2xx אם האימות נכשל.
  </Accordion>
</AccordionGroup>

---

## השלבים הבאים

<CardGroup cols={2}>
  <Card title="סקירת webhooks" icon="bolt" href="/he/webhooks/overview">
    סמנטיקת מסירה, ניסיונות חוזרים, כתובות IP של המקור.
  </Card>
  <Card title="נקודות קצה של webhook" icon="plug" href="/he/webhooks/endpoints">
    נהלו כתובות URL מרובות, החליפו סודות.
  </Card>
  <Card title="כלי פונקציה" icon="screwdriver-wrench" href="/he/tools/overview">
    שני נתיבי הפעלת הכלים ומבני הבקשות שלהם.
  </Card>
  <Card title="אינטגרציות כלים" icon="wrench" href="/he/guides/build-tool-integration">
    בנו אינטגרציה מלאה המבוססת על כלים מקצה לקצה.
  </Card>
</CardGroup>
