Webhooks: אירועים מ-Sendy ישירות לשרת שלכם

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

עודכן לאחרונה:

Webhooks: אירועים מ-Sendy ישירות לשרת שלכם

אחרי שליחה דרך ה-API יש שתי דרכים לדעת מה קרה להודעה. אפשר לשאול שוב ושוב עם GET /api/v1/messages/{id}, ואפשר לתת ל-Sendy לספר לכם ברגע שזה קורה. הדרך השנייה נקראת webhook: אתם נותנים כתובת HTTPS, ו-Sendy שולחת אליה בקשת POST חתומה על כל אירוע שבחרתם.

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

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

מה צריך מראש

  1. כתובת HTTPS פומבית שמחזירה 2xx. HTTP רגיל לא מתקבל.
  2. בעלות על מרחב העבודה. רק בעלים יכול להגדיר, לערוך או למחוק את נקודת הקצה. חברי צוות רואים אותה לקריאה בלבד.
  3. גישה לגוף הבקשה הגולמי בשרת שלכם. אימות החתימה עובד על הבייטים שהתקבלו, לא על האובייקט אחרי JSON.parse.

ההגדרה

נכנסים למפתחים ובוחרים בלשונית Webhooks.

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

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

3. מעתיקים את סוד החתימה לשרת שלכם, למשתנה סביבה. הסוד משמש לאימות שהבקשה באמת הגיעה מ-Sendy.

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

האירועים

אירועמתי נשלח
message.sentההודעה נמסרה לספק ויצאה לדרך
message.deliveredאישור מסירה חזר מהרשת הסלולרית
message.undeliveredהרשת דיווחה שההודעה לא נמסרה
message.failedהשליחה נכשלה - דחייה של השער, או כשל לפני שההודעה יצאה
inbound.receivedהתקבלה הודעה במספר הווירטואלי של מרחב העבודה
contact.opted_outאיש קשר הוסר מרשימת התפוצה
contact.resubscribedאיש קשר חזר לרשימה
campaign.completedקמפיין סיים להישלח
campaign.cancelledקמפיין בוטל
credits.lowהיתרה ירדה מתחת לסף ההתראה. נשלח פעם אחת, ונדרך מחדש אחרי טעינת קרדיטים

שתי נקודות שכדאי להכיר מראש:

אירועי message.* נשלחים רק עבור הודעות שנשלחו דרך ה-API. קמפיין שנבנה במחולל לא מייצר אירוע לכל נמען - הוא מדווח כשהוא מסתיים, עם campaign.completed.

פעולה המונית על אנשי קשר שולחת אירוע נפרד לכל איש קשר. הסרה של 500 אנשי קשר בבת אחת היא 500 אירועי contact.opted_out.

איך נראה אירוע

{
  "id": "cmfz8k2p40001l504h3v9x2ab",
  "type": "message.delivered",
  "created_at": "2026-03-04T09:21:14.882Z",
  "workspace_id": "cmsgr14wb002mpc018m4hizyh",
  "workspace_name": "הדוגמה בע\"מ",
  "data": {
    "id": "cmfz8jx1n0000l504c8q2w7yd",
    "status": "delivered",
    "to": "+972501234567",
    "from": "SENDY",
    "credits_charged": 1,
    "credits_refunded": 0,
    "refunded": false,
    "error_code": null,
    "created_at": "2026-03-04T09:21:02.004Z",
    "sent_at": "2026-03-04T09:21:03.771Z",
    "delivered_at": "2026-03-04T09:21:14.331Z",
    "idempotency_key": "order-4471"
  }
}

באירועי message.* השדה data הוא בדיוק האובייקט ש-GET /api/v1/messages/{id} מחזיר, אותם שדות ואותם שמות. אם כבר כתבתם קוד שקורא את התשובה הזו, הוא עובד כאן כמו שהוא.

חלק מהאירועים נושאים גם שדה context עם פרטי רקע. ב-contact.opted_out, למשל, context.reason אומר מאיפה הגיעה ההסרה: תשובת הסר במסרון, לחיצה על קישור ההסרה, פעולה ידנית בממשק, או דיווח של המפעיל הסלולרי.

לצד הגוף מגיעים גם ארבעה headers:

Headerתוכן
Sendy-Signatureהחתימה, ראו למטה
Sendy-Event-Idמזהה האירוע, זהה ל-id שבגוף
Sendy-Event-Typeסוג האירוע, זהה ל-type שבגוף
Sendy-Delivery-Idמזהה האירוע ונקודה ומספר הניסיון, למשל ...x2ab.3

אימות החתימה

אל תדלגו על השלב הזה. כתובת ה-webhook שלכם פומבית, וכל אחד יכול לשלוח אליה בקשה שנראית כמו אירוע של Sendy. החתימה היא מה שמבדיל.

ה-header נראה כך:

Sendy-Signature: t=1772614874,v1=5f3a...c1

t הוא חותמת זמן ביוניקס, ו-v1 הוא HMAC-SHA256 של המחרוזת "<t>." + גוף הבקשה הגולמי, עם סוד החתימה כמפתח.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifySendySignature(rawBody: string, header: string, secret: string, toleranceSec = 300): boolean {
  const pairs = header.split(",").map((p) => p.trim().split("=")).filter((p): p is [string, string] => p.length === 2);
  const t = pairs.find(([k]) => k === "t")?.[1];
  const sigs = pairs.filter(([k, v]) => k === "v1" && /^[0-9a-f]{64}$/i.test(v)).map(([, v]) => v.toLowerCase());
  if (!t || !/^\d{1,12}$/.test(t) || sigs.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return sigs.some((s) => timingSafeEqual(Buffer.from(s, "ascii"), Buffer.from(expected, "ascii")));
}
import hmac, hashlib, re, time

def verify_sendy_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    pairs = [p.strip().split("=", 1) for p in header.split(",")]
    pairs = [p for p in pairs if len(p) == 2]
    t = next((v for k, v in pairs if k == "t"), None)
    sigs = [v.lower() for k, v in pairs if k == "v1" and re.fullmatch(r"[0-9a-fA-F]{64}", v)]
    if t is None or not re.fullmatch(r"[0-9]{1,12}", t) or not sigs or abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(s, expected) for s in sigs)

שלוש נקודות שכדאי לשים לב אליהן בשתי הדוגמאות:

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

יכולות להגיע שתי חתימות v1. זה קורה במשך 24 שעות אחרי החלפת סוד. מספיק שאחת מהן מאמתת.

השוו בזמן קבוע עם timingSafeEqual או compare_digest, לא עם ===.

מה נחשב תשובה תקינה

כל קוד 2xx, תוך 10 שניות. גוף התשובה לא נקרא.

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

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

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

אירוע שלא קיבל 2xx נשלח שוב, עד שמונה ניסיונות, בהמתנות עולות:

ניסיוןמתי
1מיד
2אחרי דקה
3אחרי 5 דקות
4אחרי 15 דקות
5אחרי שעה
6אחרי 3 שעות
7אחרי 6 שעות
8אחרי 12 שעות

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

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

יומן המשלוחים

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

החלפת סוד החתימה

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

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

לחבר ל-n8n, Make או Zapier

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

החיבורים בלי קוד מרחיב על כל כלי בנפרד.

טעויות נפוצות

אימות על הגוף אחרי JSON.parse. סריאליזציה מחדש משנה רווחים וסדר מפתחות, והחתימה לא תתאים. ב-Express צריך express.raw, ב-Flask request.get_data().

ציפייה ל-message.* מקמפיין במחולל. הם נשלחים רק להודעות שיצאו דרך ה-API.

עיבוד לפני התשובה. קריאה לשירות חיצוני בתוך הבקשה שוברת את חלון 10 השניות.

הנחה שכל אירוע מגיע פעם אחת. שמרו את Sendy-Event-Id.

הנחה שהסדר קבוע. הודעה שסומנה ככישלון ואחר כך התקבל עליה אישור מסירה מאוחר תייצר message.failed ואחריו message.delivered. הסתמכו על ה-type של האירוע האחרון שקיבלתם עבור אותו message_id, לא על סדר ההגעה.

לפרטי כל אירוע, שדה אחר שדה, ראו את פרק ה-Webhooks בתיעוד ה-API. כדי לשלוח את ההודעה הראשונה מלכתחילה, התחילו מהשליחה הראשונה דרך ה-API.

מדריכים נוספים במפתחים ואינטגרציות

רוצים לנסות את זה בעצמכם?

30 הודעות חינם בפתיחת חשבון עסקי. בלי כרטיס אשראי, בלי התחייבות.

התחילו בחינם

אתר זה עושה שימוש בעוגיות (Cookies) ובכלים טכנולוגיים נוספים כדי לשפר את חוויית המשתמש, לנתח תנועה ולהתאים פרסומות. המשך הגלישה באתר מהווה את הסכמתך לכך ולמדיניות הפרטיות שלנו.