תיעוד/מדריכים
Webhooks ו-n8n
כל שיחה שהסתיימה נשלחת לשרת או לכלי האוטומציה שלכם: התמלול, משך השיחה, התוצאה והמשימה ששלחתם. הכול חתום, כך שאפשר להוכיח שזה הגיע מאיתנו.
מה מקבלים
כששיחה של הסוכן שלכם מסתיימת, נכנסת או יוצאת, הפלטפורמה שולחת אירוע call.ended לכתובת ה-HTTPS שרשמתם. בגוף הבקשה נמצא אותו אובייקט שיחה ש-GET /v1/voice/calls/{id} מחזיר, כולל התמלול. בעזרתו אפשר לתעד שיחות, להתריע לצוות, לעדכן רשומה ב-CRM או להפעיל כל פעולת המשך, בלי לתשאל את ה-API שוב ושוב.
רושמים את ה-endpoint
בקשת PUT אחת, עם מפתח שיש לו הרשאת voice:call. השדה from הוא מספר הטלפון של הסוכן, ולפיו נבחר הסוכן. כמו כל בקשה שאינה GET, גם בקשה זו צריכה Idempotency-Key.
curl -X PUT https://api.talktomyagent.io/v1/voice/webhooks \
-H "Authorization: Bearer $TTMA_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"from": "+16475550100",
"url": "https://hooks.example.com/ttma/call-ended",
"secret": "REPLACE_WITH_A_RANDOM_SECRET_OF_32_OR_MORE_CHARACTERS",
"enabled": true
}'
צרו את הסוד עם פקודה כמו openssl rand -hex 32 ושמרו עותק, כי הוא לא מוחזר אף פעם. בתשובה מופיעים רק ארבעת התווים האחרונים שלו:
{
"data": {
"callEnded": {
"url": "https://hooks.example.com/ttma/call-ended",
"secretLastFour": "a9f2",
"enabled": true
}
}
}
| כלל | פירוט |
|---|---|
| כתובת URL | HTTPS, בלי פרטי התחברות בתוך הכתובת, עד 2048 תווים. שם המארח חייב להתפענח, ולא לכתובת פרטית, loopback או שמורה. זה נבדק בשמירה, ושוב לפני כל משלוח. |
| סוד | 32 תווים לפחות. |
enabled | לא חובה, ברירת המחדל true. שלחו false כדי להשהות את המשלוחים. |
| כתובת אחת לכל סוכן | כל PUT מחליף את הכתובת והסוד הקודמים של הסוכן. |
את ההגדרות הנוכחיות קוראים עם מפתח voice:read: GET /v1/voice/webhooks?from=%2B16475550100 (עם + בקידוד URL).
גוף האירוע
POST /ttma/call-ended
Content-Type: application/json
X-TTMA-Event: call.ended
X-TTMA-Delivery-Id: 6f1c2a9e-3b7d-4c55-9f0e-1d2a3b4c5d6e
X-TTMA-Signature: t=1762096512,v1=5c0f...e9a1
{
"event": "call.ended",
"deliveryId": "6f1c2a9e-3b7d-4c55-9f0e-1d2a3b4c5d6e",
"timestamp": "2026-11-02T15:15:12.000Z",
"data": {
"id": "v3:abcDef123...",
"direction": "outbound",
"status": "completed",
"endedReason": "botHangup",
"callerNumber": "+16475550100",
"calledNumber": "+12025550134",
"startedAt": "2026-11-02T15:12:40.000Z",
"endedAt": "2026-11-02T15:14:52.000Z",
"durationSeconds": 132,
"recordingAvailable": true,
"transcript": "...",
"hasTranscript": true,
"task": {
"goal": "Confirm the cleaning for Maria on Thursday at 10:30 AM, or note the day and time she would prefer instead.",
"script": "appointment_confirmation",
"facts": { "patient_name": "Maria", "appointment_time": "10:30 AM" },
"escalation": null
},
"usage": { "turnCount": 9, "inputTokens": 18234, "outputTokens": 2210 },
"quality": null,
"channel": "phone",
"outcome": {
"status": "completed",
"reason": "goal_met",
"summary": "Maria confirmed her Thursday 10:30 AM cleaning.",
"message": null,
"evaluatedAt": "2026-11-02T15:15:05.000Z"
}
}
}
שדה (תחת data) | משמעות |
|---|---|
id | מזהה השיחה. בשיחה יוצאת, זה ה-callControlId שה-Voice API החזיר. לפיו מסננים כפילויות. |
direction | inbound או outbound. |
channel | phone או browser. |
callerNumber | המספר שממנו יצאה השיחה. בשיחה יוצאת, זה הסוכן שלכם. |
calledNumber | המספר שקיבל את השיחה. |
endedReason | איך הקו נסגר: callerHangup, botHangup, callTimeout, silenceTimeout, idleTimeout, gatewayShutdown, aiConnectionFailed, aiSessionLost, mediaStreamDropped, setupFailed או unknown. השדה מציין מי ניתק, לא אם השיחה הצליחה. |
transcript | טקסט השיחה, או null כש-Ship Transcripts (שליחת תמלולים) כבוי. hasTranscript מציין אם יש תמלול. |
recordingAvailable | האם ה-endpoint של ההקלטה יחזיר קישור הורדה. |
task | המשימה ששלחתם, בשיחות יוצאות שנשלחו עם task. בכל מקרה אחר null, גם בשיחות purpose. |
outcome | ההערכה שנעשית אחרי השיחה: status (completed, not_completed, dropped, unknown), reason, summary, ו-message כשהמתקשר השאיר הודעה (השם, המספר לחזרה שמסר, הבקשה ומידת הדחיפות). ב-webhook הוא הרבה פעמים null, כי ההערכה יכולה להסתיים אחרי המשלוח. אם אתם צריכים אותו, שלפו את השיחה מאוחר יותר. |
usage, quality | מספר התגובות והטוקנים, וציון האיכות של השיחה, אם נרשמו. אחרת null. |
בדיקת החתימה
מבנה הכותרת X-TTMA-Signature הוא t=<unix seconds>,v1=<hex>, ו-v1 הוא HMAC-SHA256 בסוד שלכם על המחרוזת t + "." + rawBody. כדי לאמת:
- פצלו את הכותרת ל-
tול-v1. - דחו את הבקשה אם
tרחוק ביותר מ-5 דקות מהשעון שלכם (הגנה מפני שליחה חוזרת של בקשה ישנה). - חשבו את ה-HMAC על הבייטים המדויקים שקיבלתם, לפני כל פענוח של ה-JSON.
- השוו את התוצאה ל-
v1בהשוואה בזמן קבוע (constant time).
Node.js ו-Express
const crypto = require("crypto");
const express = require("express");
function verifyTtmaSignature(rawBody, header, secret) {
if (typeof header !== "string") return false;
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(t + "." + rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// express.raw keeps the body as the exact bytes we sent.
app.post("/ttma/call-ended", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verifyTtmaSignature(raw, req.get("X-TTMA-Signature"), process.env.TTMA_WEBHOOK_SECRET)) {
return res.status(401).send("bad signature");
}
const event = JSON.parse(raw);
res.sendStatus(200); // acknowledge first, then do the slow work
console.log("call ended", event.data.id, event.data.durationSeconds, event.data.outcome?.status);
});
app.listen(3000);
express.json() גלובלי לפני הנתיב הזה יפענח את הגוף קודם וישבור את האימות. חברו את express.raw לנתיב ה-webhook עצמו, כמו בדוגמה.
Python
import hashlib, hmac, time
def verify_ttma_signature(raw_body: bytes, header: str, secret: str) -> bool:
try:
parts = dict(kv.split("=", 1) for kv in header.split(","))
t = int(parts["t"])
except (ValueError, KeyError, AttributeError):
return False
if abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
חיבור ל-n8n
- הוסיפו צומת Webhook. שיטת HTTP:
POST, תגובה: Immediately. העתיקו את כתובת ה-production. כתובת ה-test עובדת רק כשהעורך מאזין. - רשמו את הכתובת הזו עם בקשת ה-
PUTשלמעלה. הפעילו את התהליך (Activate), כדי שכתובת ה-production תעבוד. - הוסיפו צומת Code אחרי צומת ה-Webhook כדי לבדוק את החתימה. צומת ה-Webhook שם את הכותרות תחת
json.headers(באותיות קטנות) ואת הגוף המפוענח תחתjson.body. הסוד יושב בתוך הצומת, אז הגבילו את מי שיכול לפתוח את התהליך.
// n8n Code node, mode "Run Once for All Items"
const crypto = require("crypto");
const SECRET = "REPLACE_WITH_YOUR_WEBHOOK_SECRET"; // the secret you sent in the PUT
const item = $input.first().json;
const header = item.headers["x-ttma-signature"] || "";
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) {
throw new Error("TTMA webhook: missing or stale timestamp");
}
// n8n has already parsed the JSON. Re-serialising it gives back the exact
// bytes we sent, because we serialise with JSON.stringify too.
const raw = JSON.stringify(item.body);
const expected = crypto.createHmac("sha256", SECRET).update(t + "." + raw).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || "");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error("TTMA webhook: bad signature");
}
return [{ json: item.body }];
crypto, הפעילו את n8n עם NODE_FUNCTION_ALLOW_BUILTIN=crypto.אחרי הצומת הזה האירוע הוא הפריט עצמו, כך שהצמתים הבאים קוראים {{ $json.data.transcript }}, {{ $json.data.durationSeconds }} או {{ $json.data.task.facts.invoice_id }}.
מתכונים
- כל שיחה בשורה בגיליון. ב-Google Sheets, פעולת "Append row" עם
$json.timestamp,$json.data.direction,$json.data.callerNumber,$json.data.durationSeconds,$json.data.outcome.statusו-$json.data.transcript. - התראה כשצריך בן אדם. צומת IF על
$json.data.outcome.statusששווה ל-not_completed, או על$json.data.outcome.messageכשיש בו ערך, ואחריו צומת Slack או מייל עם הסיכום. זכרו ש-outcomeעדיין יכול להיותnullברגע המשלוח. אם כך, המתינו כמה דקות ושלפו את השיחה עם צומת HTTP Request (GET /v1/voice/calls/{{ $json.data.id }}, מפתחvoice:read). - עדכון ה-CRM אחרי שיחות יוצאות. סננו לפי
$json.data.directionששווה ל-outbound, אתרו את הרשומה לפי אחד הנתונים ששלחתם (למשל$json.data.task.facts.invoice_id), ושמרו את הסיכום כהערה.
משלוח וניסיונות חוזרים
- ענו בקוד 2xx כלשהו תוך 10 שניות. קודם מאשרים קבלה, ורק אחר כך עושים את העבודה האיטית.
- ניסיונות חוזרים. משלוח שנכשל (קוד שאינו 2xx, חריגת זמן, שגיאת חיבור) נשלח שוב פעמיים בתוך כמה שניות. אם גם אלה נכשלים, הוא נכנס לתור ונשלח שוב כעבור דקה עד שתיים, עד 5 סבבים בסך הכול. אחרי זה הוא מבוטל.
- 410 Gone מכבה את ה-webhook. החזרת
410משביתה אותו מיד. כדי להפעיל אותו מחדש, שלחוPUTנוסף. - המתנה להקלטה. כשההקלטה פועלת, המשלוח ממתין שההקלטה תהיה מוכנה, דקה עד שתיים לכל היותר, ואז יוצא בכל מקרה. לכן
recordingAvailableעדיין יכול להיותfalse. אם כך, נסו שוב את ה-endpoint של ההקלטה מאוחר יותר. - סננו כפילויות לפי
data.id. ה-deliveryIdמתחלף בכל סבב ניסיונות, ולפעמים אותה שיחה מגיעה פעמיים. - רק שיחות חדשות. הפעלת webhook לא שולחת שיחות שהסתיימו לפני כן. כדי להשלים אותן, השתמשו ב-
GET /v1/voice/calls.
פתרון תקלות
| תסמין | מה לבדוק |
|---|---|
| לא מגיע כלום | GET /v1/voice/webhooks?from=...: האם callEnded מוגדר, והאם enabled הוא true? תשובת 410 שהחזרתם בעבר משביתה אותו. ודאו שהכתובת ציבורית ומתפענחת. |
PUT מחזיר 400 | השדה detail מציין איזה כלל נכשל: HTTPS, כתובת פרטית, אורך הסוד, או Idempotency-Key חסר. |
PUT מחזיר 403 | למפתח אין הרשאת voice:call. |
| החתימה אף פעם לא תואמת | ה-hash מחושב על גוף שפוענח ועוצב מחדש, עם סוד לא נכון, או על גוף בקידוד אחר. חשבו אותו על הבייטים הגולמיים כפי שהתקבלו, כמו בדוגמאות. |
| נדחה כישן מדי | השעון בשרת שלכם לא מדויק. סנכרנו אותו עם NTP. החלון הוא 5 דקות. |
transcript הוא null | Ship Transcripts כבוי אצל הסוכן. |