תיעוד/מדריכים

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
    }
  }
}
כללפירוט
כתובת URLHTTPS, בלי פרטי התחברות בתוך הכתובת, עד 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 החזיר. לפיו מסננים כפילויות.
directioninbound או outbound.
channelphone או 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. כדי לאמת:

  1. פצלו את הכותרת ל-t ול-v1.
  2. דחו את הבקשה אם t רחוק ביותר מ-5 דקות מהשעון שלכם (הגנה מפני שליחה חוזרת של בקשה ישנה).
  3. חשבו את ה-HMAC על הבייטים המדויקים שקיבלתם, לפני כל פענוח של ה-JSON.
  4. השוו את התוצאה ל-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

  1. הוסיפו צומת Webhook. שיטת HTTP: POST, תגובה: Immediately. העתיקו את כתובת ה-production. כתובת ה-test עובדת רק כשהעורך מאזין.
  2. רשמו את הכתובת הזו עם בקשת ה-PUT שלמעלה. הפעילו את התהליך (Activate), כדי שכתובת ה-production תעבוד.
  3. הוסיפו צומת 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 }];
n8n בהתקנה עצמיתצומת ה-Code חוסם מודולים מובנים כברירת מחדל. כדי לאפשר את 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 הוא nullShip Transcripts כבוי אצל הסוכן.

איזו שיחה הסוכן שלכם יקבל קודם?

ספרו לנו אילו בקשות הצוות שלכם מקבל שוב ושוב. נציע לכם מאיפה כדאי להתחיל.