Webhook ו-API להעלאת הקלטות: חיבור מרכזייה ל-VoiceBox

שתי נקודות קצה חתומות: אחת לקליטת מטא-דאטה של שיחה שהסתיימה, ואחת להעלאת קובץ ההקלטה עצמו. כאן מתועד בדיוק מה הן מקבלות ומה הן מחזירות, כדי שאפשר יהיה לבדוק התאמה לפני שמדברים עם מישהו.

כל בקשה נחתמת ב-HMAC-SHA256 ונבדקת מול חלון זמן. אין מצב שבו קליטה עובדת בלי חתימה.

חתימה
HMAC-SHA256
חלון זמן לבקשה
5 דקות
גוף Webhook
עד 256KiB
בקשת העלאה
עד 200MiB
ערכת בדיקה לחתימה (Node, ריצה יבשה כברירת מחדל)

למי המדריך הזה

למי שצריך להזרים שיחות מהמרכזייה, מה-CRM או משרת ההקלטות אל VoiceBox. אין SDK ואין ספרייה להתקין: שתי נקודות קצה ב-HTTPS, חתימה אחת ותשובת JSON. כל מערכת שיודעת לבצע בקשת POST חתומה יכולה להתחבר. מערכת שאינה יודעת לקרוא ל-HTTP או לחתום בעצמה תצטרך תוכנה שתעשה זאת עבורה לצד המרכזייה, ואת זה הדף הזה אינו מכסה.

מה שכתוב כאן הוא ההתנהגות בפועל של נקודות הקצה - שדות, קודים וגבולות - ולא תוכנית עתידית. מה שלא כתוב כאן, אל תניחו שקיים.

שתי נקודות הקצה

בשתיהן {connectorId} הוא מזהה מקור הקליטה שלכם, ושתיהן מקבלות POST בלבד:

נקודות הקצה לקליטת שיחות
נתיבסוג תוכןמה שולחיםמה נחתם
POST /api/ingest/{connectorId}application/jsonמטא-דאטה של שיחה שהסתיימהגוף הבקשה, בדיוק כפי שנשלח
POST /api/ingest/{connectorId}/uploadmultipart/form-dataמטא-דאטה יחד עם קובץ האודיושדה meta בלבד

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

אל תשמרו את המפתח בקוד המקור ואל תשלחו אותו בדוא״ל. כל הדוגמאות כאן קוראות אותו ממשתנה סביבה, בכוונה.

חתימה על כל בקשה

לכל בקשה מצרפים שתי כותרות:

  • x-voicebox-timestamp - הזמן הנוכחי בשניות מאז 1970, כמספר שלם.
  • x-voicebox-signature - HMAC-SHA256 בהקסדצימלי על המחרוזת <timestamp>.<signed-string>, עם מפתח החתימה של מקור הקליטה.

וכאן הפרט שמפיל את רוב האינטגרציות בפעם הראשונה: ב-Webhook נחתם גוף הבקשה כפי שנשלח, ובהעלאת קובץ נחתם שדה meta בלבד. לכן ב-Webhook יש לחתום על אותה מחרוזת JSON שיוצאת על הקו - לא לחתום על אובייקט ולסדר אותו מחדש לפני השליחה, כי הבייטים ישתנו והחתימה תיפסל.

בהעלאה חשוב להכיר את הגבול הזה במדויק: החתימה מאמתת את המטא-דאטה בלבד ואינה מאמתת את בייטים של האודיו. הקובץ מוגן בהעברה על ידי TLS כמו שאר הבקשה, אך אין לו חתימה משלו. אנחנו מחשבים SHA-256 על הבייטים שהגיעו ומחזירים אותו בתשובה - זו טביעת אצבע של מה שהתקבל, לא הוכחה שהוא זהה לקובץ שיצא מכם. מי שנדרש להוכחה כזו צריך לחשב SHA-256 מקומי לפני השליחה ולהשוות אותו לערך שבתשובה; דוגמת ה-Node להעלאה עושה בדיוק את זה.

החתימה מושווית בזמן קבוע, ובקשה שחותמת הזמן שלה רחוקה מהשעון שלנו ביותר מחמש דקות לכל כיוון נדחית. זה מצמצם את חלון השידור החוזר של בקשה שנלכדה ברשת, אך אינו מונע שידור חוזר בתוך אותן חמש דקות; מה שמונע שיחה כפולה הוא מזהה חיצוני יציב, שגורם לשידור חוזר להתמזג עם השיחה הקיימת במקום ליצור אחת נוספת. המשמעות המעשית הנוספת: השעון בשרת השולח חייב להיות מסונכרן. שעון שסוטה בעשר דקות נראה בדיוק כמו מפתח שגוי, ושתי התקלות מחזירות 401.

Webhook של מטא-דאטה

נשלח בסיום שיחה ומכיל את מה שהמרכזייה יודעת עליה. האודיו אינו נמשך בתוך הבקשה הזו: התשובה חוזרת מהר כדי שהמרכזייה לא תיכנס למעגל ניסיונות חוזרים, וההקלטה נמשכת אחר כך מהכתובת שנשלחה. גוף הבקשה מוגבל ל-256KiB, והגודל נספר על הזרם עצמו ולא לפי מה שהשולח הצהיר.

שדות והשמות המקובלים עליהם

אין צורך לשנות שמות שדות במרכזייה אם הם כבר אחד מהשמות המוכרים. לכל שדה נבדקת רשימת מועמדים לפי הסדר, והראשון שאינו ריק הוא שנקלט:

שדות ה-Webhook והשמות המקובלים
מה זהחובהשמות מקובלים
מזהה השיחה במרכזייהכןcall_id · callId · id · uniqueid
זמן תחילת השיחהכןstarted_at · startedAt · start_time · startTime · timestamp · date
מספר הצרכןכןcustomer_number · to · destination · dst · called_number · phone
כיוון השיחהלאdirection · call_direction · type
משך בשניותלאduration_sec · duration · billsec · talk_time
זמן סיוםלאended_at · endedAt · end_time · endTime
מספר או שלוחה של הנציגלאagent_number · from · source · src · extension · caller
שם הנציגלאagent_name · agentName · user_name
כתובת ההקלטהלאrecording_url · recordingUrl · record_url · file_url · url
סטטוס מהמרכזייהלאdisposition · status · result

אם השמות אצלכם שונים לחלוטין, מפו אותם בצד שלכם לאחד השמות שברשימה. מה שנשלח נשמר במלואו לצד השיחה, גם שדות שלא מופו, כך ששום מידע לא הולך לאיבוד.

שלושה פרטים שמונעים תקלות

  • זמנים. חותמת זמן בלי אזור זמן מפורשת מפוענחת כשעון ישראל (Asia/Jerusalem). מספר בן עשר ספרות נקרא כשניות, ובן שלוש-עשרה כמילישניות. הבטוח ביותר הוא ISO 8601 עם היסט, למשל 2027-04-01T09:14:22+03:00.
  • משך. אפשר לשלוח מספר שניות או מחרוזת בפורמט 00:03:34. כשאין משך אבל יש זמן סיום, המשך מחושב מההפרש.
  • מספר טלפון. בכל צורה שהמרכזייה מייצרת: 050-000-1234, +972500001234 או 972500001234. אנחנו שומרים את כל הצורות כדי שהצרכן ימצא את השיחה שלו לפי מה שהוא מקליד.

גוף בקשה לדוגמה

גוף הבקשה (JSON)
{
  "call_id": "pbx-2027-04-01-000871",
  "direction": "outbound",
  "start_time": "2027-04-01T09:14:22+03:00",
  "duration": 214,
  "customer_number": "050-000-1234",
  "agent_number": "101",
  "recording_url": "https://pbx.example/rec/000871.wav"
}

כאשר נשלחת recording_url, האודיו נמשך ממנה אחרי שהשיחה נקלטה. הכתובת חייבת להיות ב-HTTP או HTTPS ונגישה מהאינטרנט; כתובת שמפנה לרשת פנימית נדחית בכוונה, וזה בדיוק המקרה שבו יש להשתמש בנקודת הקצה להעלאת קובץ.

דוגמה: curl

שליחת שיחה חתומה עם curl ו-openssl
# All values here are synthetic. Replace them with your own.
# Read the signing key into the environment first, without leaving it on disk:
#   read -rs VOICEBOX_SIGNING_SECRET && export VOICEBOX_SIGNING_SECRET
: "${VOICEBOX_SIGNING_SECRET:?set the signing key before running this}"

CONNECTOR_ID='con_xxxxxxxxxxxxxxxxxxxxxx'
BODY='{"call_id":"pbx-2027-04-01-000871","direction":"outbound","start_time":"2027-04-01T09:14:22+03:00","duration":214,"customer_number":"050-000-1234","agent_number":"101","recording_url":"https://pbx.example/rec/000871.wav"}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$VOICEBOX_SIGNING_SECRET" | sed 's/^.*= //')

curl -sS -X POST "https://voicebox.co.il/api/ingest/$CONNECTOR_ID" \
  -H 'content-type: application/json' \
  -H "x-voicebox-timestamp: $TS" \
  -H "x-voicebox-signature: $SIG" \
  --data-raw "$BODY"

דוגמה: Node 20 ומעלה

שליחת שיחה מ-Node, ללא תלויות חיצוניות
import { createHmac } from 'node:crypto';

const connectorId = process.env.VOICEBOX_CONNECTOR_ID;
const secret = process.env.VOICEBOX_SIGNING_SECRET;
if (!connectorId || !secret) {
  throw new Error('Set VOICEBOX_CONNECTOR_ID and VOICEBOX_SIGNING_SECRET before running this.');
}

const endpoint = `https://voicebox.co.il/api/ingest/${connectorId}`;

// Serialise once, then sign and send that very same string. Signing an object
// and re-serialising it before sending produces different bytes, and the
// signature is checked against the bytes that arrive.
const body = JSON.stringify({
  call_id: 'pbx-2027-04-01-000871',
  direction: 'outbound',
  start_time: '2027-04-01T09:14:22+03:00',
  duration: 214,
  customer_number: '050-000-1234',
  agent_number: '101',
  recording_url: 'https://pbx.example/rec/000871.wav',
});

const timestamp = Math.floor(Date.now() / 1000);
const signature = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');

const response = await fetch(endpoint, {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'x-voicebox-timestamp': String(timestamp),
    'x-voicebox-signature': signature,
  },
  body,
});

const result = await response.json();
console.log(response.status, result);
if (!response.ok) {
  throw new Error(`ingest failed: ${response.status} ${result.code ?? ''}`.trim());
}

תשובת הצלחה

תשובה לשיחה חדשה
HTTP/1.1 201 Created

{
  "ok": true,
  "callId": "cal_7q4m2xk9vd3hbn5r8ts0jw",
  "created": true,
  "scope": "inScope",
  "recordingRequired": true,
  "audioPending": true
}
  • created - האם נוצרה שיחה חדשה. חדשה מוחזרת עם 201, שיחה שכבר נקלטה עם 200.
  • scope - סיווג ראשוני של השיחה מול חובת ההקלטה, אחד מ-inScope · outOfScope · indeterminate.
  • recordingRequired - האם לשיחה הזו נדרשת הקלטה שמורה.
  • audioPending - האם נשלחה כתובת הקלטה שעדיין ממתינה למשיכה.

העלאת קובץ אודיו

כשאין כתובת שאפשר למשוך ממנה - מרכזייה ברשת פנימית, שרת קבצים, סוכן שרץ לצד המרכזייה - שולחים את המטא-דאטה ואת הקובץ יחד ב-multipart/form-data עם שני חלקים: meta, מחרוזת JSON שהיא גם המחרוזת הנחתמת, ו-audio, הקובץ עצמו.

שדה meta נקרא בשמות מדויקים ואינו משתמש בשמות המקובלים של ה-Webhook. חובה: externalId, counterparty ו-startedAt תקין. רשות: agentRef, direction (אחד מ-outbound · inbound · unknown, ברירת המחדל unknown) ו-durationSeconds. שדות נוספים פשוט לא ייקראו.

הבקשה חייבת לכלול Content-Length: בקשה שאינה מצהירה על אורך נדחית בקוד 411. הגבול הוא 200MiB לכלל הבקשה ולא לקובץ בלבד - הוא נבדק על האורך המוצהר של גוף ה-multipart כולו, כולל שדה meta וגבולות החלקים. לכן קובץ האודיו צריך להיות מעט מתחת ל-200MiB, ולא בדיוק עליו.

פורמטים, ומה נחשב הקלטה תקינה

הפורמט מזוהה מהבייטים עצמם ולא מהסוג שהוצהר, כי המערכת שטעתה בהקלטה היא בדיוק זו שמצהירה על הסוג. מזוהים WAV, MP3, Ogg (Opus או Vorbis), MP4/M4A, FLAC, AMR-NB ו-WebM.

שתי בדיקות נוספות רצות על כל קובץ, ושתיהן מדווחות בתשובה במקום להיבלע בשקט:

  • קובץ ריק נדחה מיד בקוד 422.
  • קובץ שאינו אודיו מזוהה, או WAV מסוג PCM שהוא שקט לחלוטין, נשמרים אך מסומנים בבידוד, והתשובה חוזרת עם quarantined: true ועם הסבר בעברית בשדה reason. שניהם הסימפטום הקלאסי של ערוץ הקלטה שאינו מחובר כראוי, ועדיף לגלות אותם עכשיו ולא ביום שבו לקוח מבקש את השיחה.

שימו לב לגבול של בדיקת השקט: היא מבוססת על מדידת עוצמה בדגימות PCM, ולכן חלה על WAV לא דחוס בלבד. הקלטה שקטה שהגיעה כ-MP3, Ogg או פורמט דחוס אחר תיקלט כרגיל בשלב הקליטה ולא תסומן בבידוד בשל שקט. פורמט שאינו מזוהה מסומן בבידוד בכל מקרה.

דוגמה: curl

העלאת קובץ אודיו עם curl
: "${VOICEBOX_SIGNING_SECRET:?set the signing key before running this}"

CONNECTOR_ID='con_xxxxxxxxxxxxxxxxxxxxxx'
META='{"externalId":"pbx-2027-04-01-000871","startedAt":"2027-04-01T09:14:22+03:00","counterparty":"050-000-1234","agentRef":"101","direction":"outbound","durationSeconds":214}'
TS=$(date +%s)

# The signature covers the meta field only, never the multipart body.
SIG=$(printf '%s' "$TS.$META" | openssl dgst -sha256 -hmac "$VOICEBOX_SIGNING_SECRET" | sed 's/^.*= //')

curl -sS -X POST "https://voicebox.co.il/api/ingest/$CONNECTOR_ID/upload" \
  -H "x-voicebox-timestamp: $TS" \
  -H "x-voicebox-signature: $SIG" \
  -F "meta=$META" \
  -F "audio=@call-000871.wav;type=audio/wav"

דוגמה: Node 20 ומעלה

העלאת קובץ אודיו מ-Node
import { createHash, createHmac } from 'node:crypto';
import { readFile } from 'node:fs/promises';

const connectorId = process.env.VOICEBOX_CONNECTOR_ID;
const secret = process.env.VOICEBOX_SIGNING_SECRET;
if (!connectorId || !secret) {
  throw new Error('Set VOICEBOX_CONNECTOR_ID and VOICEBOX_SIGNING_SECRET before running this.');
}

const endpoint = `https://voicebox.co.il/api/ingest/${connectorId}/upload`;
const audio = await readFile('./call-000871.wav');

const meta = JSON.stringify({
  externalId: 'pbx-2027-04-01-000871',
  startedAt: '2027-04-01T09:14:22+03:00',
  counterparty: '050-000-1234',
  agentRef: '101',
  direction: 'outbound',
  durationSeconds: 214,
});

const form = new FormData();
form.append('meta', meta);
form.append('audio', new Blob([audio], { type: 'audio/wav' }), 'call-000871.wav');

const timestamp = Math.floor(Date.now() / 1000);
// Only meta is signed. The signature does not authenticate the audio bytes;
// those are protected in transit by TLS like the rest of the request.
const signature = createHmac('sha256', secret).update(`${timestamp}.${meta}`).digest('hex');

const response = await fetch(endpoint, {
  method: 'POST',
  // No content-type header: fetch sets the multipart boundary and the
  // Content-Length, and this endpoint refuses a body that declares no length.
  headers: {
    'x-voicebox-timestamp': String(timestamp),
    'x-voicebox-signature': signature,
  },
  body: form,
});

const result = await response.json();
console.log(response.status, result);
if (!response.ok) {
  throw new Error(`upload failed: ${response.status} ${result.code ?? ''}`.trim());
}

// The response hash describes the bytes that arrived. Comparing it with a
// digest computed here is what makes it evidence about the file on this disk,
// so a response without one is a failed check rather than a skipped one.
if (typeof result.sha256 !== 'string') {
  throw new Error('no sha256 in the response: the upload could not be verified.');
}
const local = createHash('sha256').update(audio).digest('hex');
if (result.sha256 !== local) {
  throw new Error(`stored ${result.sha256} does not match local ${local}`);
}

תשובת הצלחה

תשובה להעלאה שנשמרה
HTTP/1.1 201 Created

{
  "ok": true,
  "callId": "cal_7q4m2xk9vd3hbn5r8ts0jw",
  "recordingId": "rec_3n8k5wq2ph7dtx4bs9mvj0",
  "sha256": "9f2c1d7a4b03e8115c6d29ab74f0e3d582c9147bb60ae3f21d84c5907ea6b31c",
  "quarantined": false,
  "reason": null
}

ההעלאה מחזירה 201 גם כשהשיחה כבר היתה קיימת, ואינה מחזירה את השדות created ו-scope שמופיעים בתשובת ה-Webhook.

sha256 הוא הגיבוב שחישבנו על הבייטים שהתקבלו אצלנו. הוא מזהה את מה שנשמר, אך לבדו אינו מעיד שזה הקובץ שיצא מכם - לשם כך חשבו SHA-256 על הקובץ המקומי לפני השליחה והשוו בין הערכים. התאמה בין השניים היא ראיה חזקה לכך שהבייטים שהתקבלו הם הבייטים שקראתם מהדיסק, ואי התאמה היא סימן ודאי שמשהו בדרך שינה או קטע את הקובץ.

מניעת כפילויות

שיחה מזוהה לפי צירוף של מקור הקליטה והמזהה החיצוני שנשלח - call_id ב-Webhook, externalId בהעלאה. שידור חוזר של אותו מזהה אינו יוצר שיחה שנייה, אבל התשובה נראית אחרת בכל אחת מנקודות הקצה:

  • Webhook: שיחה חדשה מוחזרת עם 201 ו-created: true, ושידור חוזר מוחזר עם 200 ו-created: false ואותו callId.
  • העלאת קובץ: התשובה היא 201 בכל מקרה ואין בה created. השיחה הקיימת נעשית בה שימוש חוזר, וההקלטה שלה מתעדכנת בקובץ החדש. כלומר אין דרך להסיק מקוד התשובה אם זו הפעם הראשונה - מי שצריך לדעת, ישווה את ה-callId שחזר למה שכבר רשום אצלו.

לכן המזהה חייב להיות יציב, כלומר אותו ערך בכל ניסיון חוזר של אותה שיחה, וייחודי בין שיחות שונות. מזהה שנוצר מחדש בכל ניסיון מייצר שיחות כפולות, ומזהה שחוזר על עצמו בין שיחות שונות גורם לשנייה להיבלע בראשונה. העלאה חוזרת של אותו מזהה עם קובץ אחר מחליפה את האודיו השמור ואינה מוסיפה שיחה.

קודי שגיאה

כל שגיאה חוזרת כ-JSON בצורה { "ok": false, "code": "...", "error": "..." }. השדה code יציב ומיועד לקוד שלכם, והשדה error הוא הסבר בעברית שנועד לאדם שיקרא את היומן.

תשובת שגיאה
HTTP/1.1 401 Unauthorized

{
  "ok": false,
  "code": "connector.badSignature",
  "error": "..."
}
קודי שגיאה של נקודות הקליטה
HTTPcodeמתימה עושים
400connector.badJson · badJson · badRequestהגוף אינו JSON תקין, שדה meta אינו JSON, הבקשה אינה multipart תקין, חסר meta או audio, או Content-Length שאינו מספר תקיןלתקן את הבקשה; ניסיון חוזר יחזיר את אותה שגיאה
401connector.badSignatureחתימה חסרה או שגויה, חתימה במפתח של מקור אחר, או חותמת זמן מחוץ לחלון חמש הדקותלבדוק את המפתח, לוודא שנחתמה המחרוזת הנכונה, ולסנכרן שעון
403connector.disabledמקור הקליטה מושבתמקור מושבת אינו נפתח מהקונסולה - יש לפנות אלינו
404connector.notFoundמזהה מקור הקליטה שבנתיב אינו מוכרלהעתיק מחדש את המזהה מהקונסולה
409ingest.conflictהתנגשות נדירה בין שתי בקשות שהגיעו במקביל על אותה שיחהניסיון חוזר אחד אחרי השהיה קצרה
411lengthRequiredהעלאת קובץ בלי כותרת Content-Lengthלשלוח את אורך הגוף; רוב הלקוחות עושים זאת לבד
413tooLargeגוף Webhook מעל 256KiB, או בקשת העלאה שכולה מעל 200MiBלצמצם את המטא-דאטה, לשלוח קידוד קטן יותר (למשל MP3 במקום WAV לא דחוס), או לשלוח recording_url במקום הקובץ
422connector.unmappable · badMeta · recording.emptyחסר מזהה שיחה, זמן התחלה תקין או מספר צרכן, או שקובץ האודיו ריקלהשלים את מה שחסר לפני שליחה חוזרת
500internalתקלה זמנית אצלנולנסות שוב עם השהיה מדורגת

ניסיונות חוזרים

  • לנסות שוב: תקלות רשת ו-5xx, עם השהיה מדורגת. הקליטה אידמפוטנטית לפי המזהה החיצוני, ולכן ניסיון חוזר בטוח ולא ייצור שיחה כפולה.
  • ניסיון חוזר אחד, אחרי השהיה קצרה: 409 בלבד. זו התנגשות רגעית בין שתי בקשות על אותה שיחה, והיא נפתרת מעצמה.
  • לא לנסות שוב עם אותה בקשה: 400, 401, 403, 404, 411, 413 ו-422. כולן מתארות בקשה שצריכה להשתנות - כולל 411, שהוא צורת הבקשה ולא תקלה זמנית - וניסיון חוזר זהה רק יטביע את התקלה האמיתית מתחת ליומן מלא בשגיאות זהות.
  • לחתום מחדש בכל ניסיון. חותמת זמן ישנה נפסלת אחרי חמש דקות, ולכן אין לשמור חתימה ולשלוח אותה שוב מאוחר יותר.
  • לא לוותר על השיחה. אם כל הניסיונות נכשלו, עדיף להחזיק את הרשומה בתור מקומי ולשדר אותה שוב בהמשך: מזהה יציב מבטיח שהיא תיקלט פעם אחת בלבד, גם כעבור יומיים.

בשתי נקודות הקצה שמתועדות כאן, מונה הכשלים מתקדם על סוג מצומצם של תקלות בלבד: כשל באימות החתימה, וכשל בפענוח המטא-דאטה של ה-Webhook - כלומר connector.unmappable. אחרי חמישה כשלים רצופים כאלה מקור הקליטה מסומן כתקול. תקלות אחרות בנקודות הקצה - JSON לא תקין, חלקי multipart חסרים, meta לא תקין, קובץ ריק או שגיאת שרת - מוחזרות אליכם אך אינן מקדמות את המונה במסלול הזה. ל-recording_url יש מסלול נפרד: כשל בהורדת ההקלטה מסמן את המקור כמוגבל כבר בכשל הראשון, ואחרי שלושה כשלים רצופים הוא מסומן כתקול. הצלחה מאפסת את הרצף.

רשימת בדיקה לפני חיבור

  1. שעון השרת השולח מסונכרן ב-NTP. סטייה של יותר מחמש דקות נראית בדיוק כמו מפתח שגוי.
  2. מפתח החתימה נשמר במנהל סודות או במשתנה סביבה, לא בקוד המקור ולא בקובץ שנשמר בגיט.
  3. לכל שיחה יש מזהה יציב וייחודי, שנשאר זהה בכל ניסיון חוזר.
  4. זמן תחילת השיחה נשלח עם אזור זמן מפורש.
  5. מספר הצרכן נשלח תמיד, גם כשהוא מגיע חלקי מהמרכזייה.
  6. הרצתם את ערכת הבדיקה במצב יבש והשוויתם את המחרוזת החתומה למה שהמערכת שלכם שולחת.
  7. הבדיקה הראשונה מתבצעת מול מקור קליטה ייעודי לבדיקות שהוקצה לחשבון, ולא מול מקור הייצור.
  8. יש טיפול נפרד בשגיאות 4xx ובכשלים זמניים, ויומן שרושם את code שחזר.
  9. אם אתם שולחים recording_url, הכתובת נגישה מהאינטרנט ולא רק מהרשת הפנימית שלכם.

ערכת בדיקה

קובץ אחד ל-Node 20 ומעלה, בלי תלויות. הוא בונה בקשה סינתטית לשתי נקודות הקצה, מדפיס את הכותרות, את המחרוזת המדויקת שנחתמה ואת פקודת ה-curl המקבילה, ואינו שולח דבר אלא אם הוספתם --send. מפתח החתימה נקרא ממשתנה סביבה או מקובץ בלבד ולא מארגומנט בשורת הפקודה, כדי שלא ידלוף להיסטוריית הפקודות או לרשימת התהליכים במחשב משותף.

הורדת voicebox-webhook.mjs

הרצה
# Dry run: prints the signed headers, the exact signed string and an
# equivalent curl command. Sends nothing.
node voicebox-webhook.mjs --connector con_xxxxxxxxxxxxxxxxxxxxxx

# The same, for the upload endpoint.
node voicebox-webhook.mjs --mode upload --connector con_xxxxxxxxxxxxxxxxxxxxxx \
  --audio ./call-000871.wav

# Actually send, against a test connector. The key is read from the
# environment, never from an argument.
read -rs VOICEBOX_SIGNING_SECRET && export VOICEBOX_SIGNING_SECRET
node voicebox-webhook.mjs --connector con_xxxxxxxxxxxxxxxxxxxxxx --send

במצב העלאה הכלי מדפיס גם SHA-256 מקומי של הקובץ, וכששולחים בפועל הוא משווה אותו לערך שחזר בתשובה ומדווח על אי-התאמה. כשאין מפתח בסביבה הוא חותם במפתח אקראי חד-פעמי, כדי שאפשר יהיה לראות את מבנה הבקשה, ומסרב לשלוח. שליחה בפועל מתבצעת ב-HTTPS בלבד, למעט מול localhost, ואין דגל שמדלג על חתימה - נקודת קצה שמקבלת רשומות שיחה בלי אימות היא נקודת קצה שכל אחד יכול להזין אליה שיחה שלא היתה.

גרסה ושינויים

לנתיבים אין מספר גרסה, והמסמך הזה מתאר את ההתנהגות הנוכחית. שינוי שאינו תואם לאחור יופיע כאן, עם תאריך.

גרסה 1.0 · 28 ביולי 2026
פרסום ראשון: שתי נקודות הקצה, החתימה וגבולות מה שהיא מאמתת, חלון הזמן, שדות ושמות מקובלים, פורמטי אודיו ובידוד, מניעת כפילויות והבדלי התשובה בין נקודות הקצה, קודי שגיאה, ניסיונות חוזרים וערכת בדיקה.

מה קורה אחרי הקליטה

שיחה שנקלטה נכנסת לניהול ההקלטות: שמירה לפי מדיניות, חיפוש לפי לקוח, עובד או תאריך, ומסירה מאובטחת ללקוח שמבקש את השיחה שלו. כך נראה ניהול ההקלטות, וכאן ההסבר על החובה עצמה. המפרט הזה פתוח לקריאה בלי חשבון, והמחיר של השירות שהוא מתחבר אליו מוצג לפני ההרשמה ולא אחריה.

שאלות שחוזרות

איזה מידע חייב להישלח על כל שיחה?
מזהה שיחה שנשאר יציב בין ניסיונות, זמן תחילת שיחה שניתן לפענוח, ומספר הטלפון של הצרכן. בלי מזהה אי אפשר למנוע כפילויות, בלי זמן התחלה אי אפשר לחשב את תקופת השמירה, ובלי מספר הצרכן אי אפשר למסור לו את ההקלטה כשיבקש. כל השאר רשות.
איך חותמים על בקשה?
מחשבים HMAC-SHA256 בהקסדצימלי על המחרוזת timestamp.body עם מפתח החתימה של מקור הקליטה, ושולחים אותה בכותרת x-voicebox-signature לצד x-voicebox-timestamp שמכילה את הזמן בשניות. בקשה שהחתימה בה אינה תואמת, או שחותמת הזמן שלה רחוקה ביותר מחמש דקות, נדחית בקוד 401.
מה בדיוק נחתם בהעלאת קובץ אודיו?
שדה meta בלבד. החתימה מאמתת את המטא-דאטה, ולא את בייטים של האודיו: אלה מוגנים בהעברה על ידי TLS. השרת מחשב SHA-256 על הבייטים שהתקבלו ומחזיר אותו בתשובה, וכדי לוודא שהם זהים לקובץ המקורי אצלכם יש לחשב SHA-256 מקומי ולהשוות בין השניים.
מה קורה כששולחים את אותה שיחה פעמיים?
לא נוצרת שיחה כפולה. שיחה מזוהה לפי צירוף של מקור הקליטה והמזהה החיצוני. ב-Webhook שידור חוזר מחזיר את אותו callId עם created=false ובקוד 200 במקום 201; בהעלאת קובץ התשובה היא 201 בכל מקרה ואינה כוללת את השדות created ו-scope, והקובץ החדש מחליף את ההקלטה של אותה שיחה.
אילו פורמטים של אודיו נקלטים?
WAV, MP3, Ogg עם Opus או Vorbis, MP4 או M4A, FLAC, AMR-NB ו-WebM. מגבלת הגודל היא 200MiB לכלל בקשת ההעלאה, כולל המטא-דאטה וגבולות ה-multipart, ולכן על הקובץ להיות מעט מתחת לגבול הזה. קובץ בפורמט שאינו מזוהה נשמר אך מסומן בבידוד, וכך גם WAV מסוג PCM שהוא שקט לחלוטין; בדיקת השקט מתבצעת על PCM בלבד, ולכן פורמט דחוס אינו נבדק לשקט בשלב הקליטה. בכל מקרה של בידוד התשובה חוזרת עם quarantined=true והסבר בשדה reason.
מתי כדאי לנסות לשלוח שוב?
בשגיאות 5xx ובתקלות רשת, עם השהיה מדורגת וחתימה חדשה בכל ניסיון. בשגיאות 400, 401 ו-422 ניסיון חוזר יחזיר בדיוק את אותה תשובה, כי הבעיה היא בבקשה עצמה.

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

פתחו חשבון ובקשו מאיתנו מקור קליטה ייעודי לבדיקות. אחרי שהוקצה, הציגו את פרטי החיבור שלו בקונסולה והריצו מולו את ערכת הבדיקה. לשאלות טכניות על התיעוד הזה, או אם מקור קליטה מופיע כמושבת: hello@voicebox.co.il

המפרט ברור. מזהה המקור והמפתח שהבקשות נחתמות בו נוצרים בקונסולה, אחרי פתיחת חשבון.

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