ארכיטקטורת תור להודעות WhatsApp בנפח: מפתח ייחודיות שמונע שליחה כפולה, קיזוז webhooks שמגיעים פעמיים ולא לפי הסדר, וקישור תגובה נכנסת להודעה שיצאה.
עיקרי הדברים
- מפתח ייחודיות במסד הנתונים הוא מה שמונע הודעה כפולה. בדיקה של "האם כבר שלחנו" לפני קריאת ה-API אינה מונעת כלום, כי שני עובדים יכולים לעבור אותה בו-זמנית.
- מטא מנסה לשלוח webhook מחדש בתדירות יורדת עד שבעה ימים כשהתשובה אינה 200, ומציינת במפורש שניסיונות אלה עשויים לגרום להתראות webhook כפולות.
- לכל הודעה יוצאת יכולים להגיע עד שלושה webhooks נפרדים - `sent`, `delivered` ו-`read` - ולכן סטטוס אינו שדה שמעדכנים אלא סדרה שמקדמים קדימה בלבד.
- תגובה נכנסת אובדת כשהיא נשמרת מול מספר טלפון במקום מול השיחה. הקישור הנכון הוא `context.id` שמצביע על ה-wamid של ההודעה שהלקוח הגיב עליה.
- מספר טלפון עסקי שולח כברירת מחדל עד 80 הודעות בשנייה, אך למשתמש בודד מותרת הודעה אחת כל 6 שניות. המגבלה השנייה היא זו ששוברת קמפיין קטן.
בנפח, הבעיה אינה הניסוח אלא החשבונאות. שתי שכבות עצמאיות מייצרות כפילויות: ניסיון שליחה חוזר מצד המערכת שלכם, ו-webhook שמטא שולחת שוב אחרי תשובה שאינה 200. תור נכון פותר את שתיהן באותו אמצעי בדיוק - אילוץ ייחודיות שנאכף ברגע הכתיבה למסד, ולא בדיקה שמתבצעת בקוד לפני קריאת ה-API.
שורת התור: מה בדיוק נשמר לפני שנוגעים ב-API
הודעה אינה נשלחת מקוד. היא נכתבת לתור, ותהליך נפרד שולח אותה. הפרדה זו היא מה שהופך ניסיון חוזר לבטוח, כי הרשומה קיימת עוד לפני שמטא יודעת עליה משהו.
| שדה | דוגמת ערך | למה הוא קיים |
|---|---|---|
dedupe_key | order-44812:shipped:972501234567 | מפתח ייחודי במסד. הכתיבה השנייה נכשלת, ולכן אין הודעה שנייה |
to | +972501234567 | בפורמט E.164 מנורמל פעם אחת בכניסה לתור |
purpose | order_shipped | מה ההודעה הזאת, ולא איזה טקסט יש בה |
trigger_ref | orders/44812 | הרשומה העסקית שגרמה לשליחה |
payload | שם תבנית ומשתנים, או טקסט חופשי | מה שיישלח בפועל |
state | queued | מצב במכונת המצבים, לא דגל בוליאני |
attempts | 0 | כמה פעמים כבר ניסינו לשלוח |
wamid | ריק עד לתשובת ה-API | המפתח שכל ה-webhooks יתלו עליו |
not_before | 2026-09-21T06:00:00Z | ויסות קצב וחלונות שעות, בלי לישון בקוד |
dedupe_key הוא הלב. הוא נבנה מהאירוע העסקי ולא מהזמן: אותה הזמנה, אותו שלב, אותו נמען. אם קוד המשלוחים ירוץ פעמיים - וברירת המחדל של כל תור עבודה היא "לפחות פעם אחת" - הכתיבה השנייה תיפול על אילוץ הייחודיות, והלקוח יקבל הודעה אחת.
למה אותו לקוח מקבל שתי הודעות זהות?
כמעט תמיד בגלל אחד משלושה תרחישים, ולכל אחד מהם תיקון אחר:
- התהליך שהפעיל את השליחה רץ פעמיים. משימה מתוזמנת שהופעלה מחדש, שרת שהתאושש, או משתמש שלחץ פעמיים. כאן
dedupe_keyפותר לגמרי. - קריאת ה-API הצליחה, אבל התשובה לא חזרה. הודעה נשלחה בפועל, אצלכם היא נראית ככישלון, וניסיון חוזר שולח אותה שוב. הפתרון היחיד כאן הוא לסמן את השורה כ-
sendingלפני הקריאה ולא לנסות שוב אוטומטית ממצב זה - שורה תקועה ב-sendingהיא פריט לבדיקה, לא פריט לשליחה. - מטא שלחה את אותו webhook שוב. התיעוד קובע שכאשר בקשת webhook מקבלת קוד שאינו 200, או שאינה ניתנת למסירה מסיבה אחרת, מטא מנסה שוב בתדירות יורדת עד שבעה ימים, ושניסיונות אלה עלולים להביא להתראות כפולות. אם מהתראה כזו אתם יוצרים רשומה חדשה במקום לעדכן קיימת, קיבלתם כפילות בצד הקליטה.
השלישי הוא המסוכן, כי הוא אינו מייצר הודעה כפולה אצל הלקוח אלא רשומה כפולה אצלכם - ואז אותה תגובה של לקוח נספרת פעמיים ומופיעה פעמיים בממשק.
סדר ההגעה: מה מובטח ומה לא
לכל הודעה יוצאת יכולים להגיע עד שלושה webhooks נפרדים: אחד עבור sent, אחד עבור delivered ואחד עבור read. התיעוד של מטא מגדיר גם failed ו-played. מה שהתיעוד אינו מבטיח הוא שהם יגיעו בסדר הזה. לעלויות בנייה ותפעול ראו כמה עולה צ'אטבוט.
לכן סטטוס אינו שדה שכותבים עליו, אלא דרגה שמקדמים:
| סטטוס | דרגה | פירוש לפי התיעוד |
|---|---|---|
queued | 0 | מצב פנימי שלכם, מטא אינה יודעת עליו |
sending | 1 | קריאת API יצאה, אין עדיין תשובה |
sent | 2 | ההודעה נשלחה בהצלחה משרתי מטא |
delivered | 3 | ההודעה נמסרה למכשיר של המשתמש |
read | 4 | ההודעה הוצגה בשיחה פתוחה במכשיר |
failed | סופי | כשל בשליחה או במסירה |
הכלל הוא: עדכן רק אם הדרגה החדשה גבוהה מהנוכחית. failed הוא היוצא מן הכלל היחיד ותמיד מנצח. שתי שורות הקוד האלה מונעות את התופעה שבה הודעה שנקראה חוזרת פתאום להיראות כ"נשלחה", כי webhook ישן הגיע באיחור. למדריך מעשי ראו איך בונים בוט וואטסאפ.
איך תגובה של לקוח הולכת לאיבוד?
לא מפני שהיא לא הגיעה, אלא מפני שנשמרה במקום שאיש אינו קורא. שלושה מקומות שבהם זה קורה:
- קישור לפי מספר טלפון בלבד. לקוח שקיבלו ממנו שלוש הודעות באותו יום השיב "כן". בלי הקשר, אין דרך לדעת על מה. WhatsApp מספק
context.idבהודעה נכנסת כשהמשתמש הגיב על הודעה מסוימת - זה ה-wamid של ההודעה המקורית, וזה מה שסוגר את הלולאה. - טיפול בכפתורים כטקסט. בהודעה עם עד שלושה כפתורי תשובה מוגדרים מראש, מה שחוזר הוא בחירה מזוהה ולא מחרוזת חופשית. קוד שמשווה מחרוזות ייכשל ברגע שמישהו ישנה ניסוח בתבנית.
- התראת webhook שנפלה בטיפול. אם המטפל שלכם החזיר 500 בגלל שדה חסר, מטא תנסה שוב - וזה טוב. אבל אם המטפל החזיר 200 ואז קרס פנימית, ההודעה אבדה לתמיד. לכן שומרים את גוף ה-webhook הגולמי מיד, מחזירים 200, ורק אחר כך מעבדים אותו בתהליך נפרד.
הכלל המעשי: תגובה נכנסת נשמרת תמיד, גם כשלא הצלחתם לשייך אותה. שיחה ללא שיוך היא פריט לטיפול אנושי. תגובה שנזרקה כי לא התאימה לדפוס היא לקוח שחושב שהתעלמתם ממנו.
קשור: אבחון הודעת WhatsApp שנכשלה, קודי שגיאה של WhatsApp Cloud API, הפיכת הזמנות שמגיעות ב-WhatsApp לתור עבודה.
200 הודעות ביום מול המגבלות בפועל
הנפח היומי הזה נשמע קטן ביחס למגבלת הקצב: מספר טלפון עסקי שולח כברירת מחדל עד 80 הודעות בשנייה. בפועל התקרה שנתקלים בה היא אחרת לגמרי.
- הודעה אחת כל 6 שניות לאותו משתמש. מי ששולח ללקוח אחד אישור ותזכורת ברצף חייב מרווח ביניהם. זה השדה
not_beforeבטבלה למעלה. - מספר לקוחות ייחודיים ב-24 שעות. המדרגות המתועדות הן 250, 2,000, 10,000, 100,000 וללא הגבלה. 200 הודעות ביום ל-200 אנשים שונים כבר קרובות למדרגה הראשונה, וקמפיין חד-פעמי אחד עלול לחצות אותה.
- חלון שירות הלקוח. כשמשתמש שולח הודעה או מתקשר, נפתח טיימר של 24 שעות; הודעה נוספת מהמשתמש מאפסת אותו. מחוץ לחלון ניתן לשלוח רק תבניות מאושרות.
המסקנה למבנה התור: לפני שליחה בודקים אם החלון פתוח, ואם לא - בוחרים במסלול התבנית או משהים. תור שאינו יודע מתי החלון של כל שיחה נסגר יצבור כישלונות שנראים אקראיים, כי הם תלויים בשעה שבה הלקוח כתב לאחרונה ולא בהודעה עצמה.
מה מודדים כדי לדעת שהתור בריא
- כמה שורות תקועות ב-
sendingמעל דקה. כל אחת מהן היא ספק כפילות. - כמה כתיבות נדחו על אילוץ הייחודיות. מספר גדול פירושו שיש תהליך שרץ פעמיים ואתם לא יודעים עליו.
- כמה webhooks התקבלו עם wamid שכבר מוכר. זה שיעור הכפילות שמטא שלחה, והוא לעולם אינו אפס.
- כמה הודעות נכנסות נשמרו בלי שיוך לשיחה או להודעה. זה תור עבודה, לא מדד.
- הפער בין
sentל-deliveredלאורך זמן. פער שגדל פתאום הוא בעיה בצד הנמענים, לא בקוד שלכם.
מקורות
שאלות נפוצות
האם מזהה ההודעה שמחזירה מטא מספיק כמפתח ייחודיות?
לא, כי הוא מתקבל רק אחרי הקריאה. המפתח שמונע כפילות חייב להיווצר לפני השליחה ומתוך האירוע העסקי. ה-wamid שמתקבל מהתשובה משמש למטרה אחרת לגמרי: לתלות עליו את עדכוני הסטטוס ואת ההקשר של תגובות נכנסות. שני התפקידים האלה אינם מתחלפים זה בזה לעולם.
מה מחזירים ל-webhook כשהעיבוד נכשל?
מחזירים 200 רק אם שמרתם את הגוף הגולמי. אם השמירה עצמה נכשלה, קוד שאינו 200 הוא הנכון, כי אז מטא תנסה שוב. הטעות הנפוצה היא להחזיר 200 ואז לעבד סינכרונית - כך כישלון בעיבוד מוחק את ההתראה בלי אפשרות שחזור.
כמה זמן שומרים שורות בתור אחרי שהסתיימו?
מספיק זמן כדי שאילוץ הייחודיות יעשה את עבודתו, וזה נקבע לפי האירוע העסקי: תזכורת חודשית דורשת חלון ארוך יותר מאישור הזמנה. מחיקה מוקדמת של שורות שהושלמו מחזירה בדיוק את הכפילויות שהמפתח נועד למנוע. שמירה ארוכה יותר עולה מעט מאוד באחסון ומונעת בעיה יקרה.
עדיף תור אחד לכל סוגי ההודעות או תור נפרד לכל סוג?
תור אחד עם שדה `purpose` ועם עדיפות. תורים נפרדים נשמעים מסודרים עד שצריך לאכוף מרווח של 6 שניות לאותו לקוח, ואז שני תורים שאינם מכירים זה את זה שולחים שתי הודעות באותה שנייה ואחת מהן נכשלת. תור אחד הוא גם המקום היחיד שבו אפשר לאכוף עדיפות.
להמשך קריאה
שירות רלוונטי
WhatsApp Cloud API
תבניות, אינבוקס דו-כיווני ותזכורות על ה-API הרשמי של Meta.
על הכותב
יהונתן סעדיה
מפתח פרילנסר לאוטומציה, אתרים ו-MVP
אני יהונתן סעדיה, מפתח בכיר שבונה אוטומציה עסקית, אתרים מותאמים ומוצרי MVP לעסקים קטנים ובינוניים בארה"ב, אירופה וישראל. המדריכים האלה נכתבים מתוך עבודה אמיתית עם לקוחות, לא מתיאוריה.
בוא נעבוד יחדיש לך פרויקט דומה?
ספר לי מה אתה רוצה לבנות או איזה תהליך להפוך לאוטומטי. אני חוזר תוך 24 שעות עסקים עם כמה שאלות ממוקדות, ואז עוברים על זה יחד בשיחת היכרות חינמית של 30 דקות, בלי התחייבות. בסוף יש לך היקף עבודה, לוח זמנים ומחיר קבוע - או תשובה כנה שלא שווה לבנות את זה.
