מפתחים

מניעת שליחת SMS כפולה עם Idempotency Key

9 בספטמבר 2026·13 דק' קריאה·מאת ניצן סויסה

Idempotency Key הוא מזהה שמצורף לבקשת שליחה כדי שריטריי לא ישלח פעמיים. ב-Sendy הוא נשלח כ-header בשם Idempotency-Key: שליחה חדשה מחזירה 202, וניסיון חוזר עם אותו מפתח מחזיר את ההודעה המקורית עם 200, בלי הודעה שנייה ובלי חיוב שני.

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

למה שליחה כפולה של SMS היא בעיה מיוחדת

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

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

מאיפה מגיעות שליחות כפולות בפועל

כמעט אף פעם לא מלולאה שכתבת בכוונה. הכפילויות מגיעות מהמקומות שבהם הקוד שלך מנסה להיות אמין:

  • Timeout בצד שלך אחרי שהשרת כבר קיבל. הבקשה הגיעה, ההודעה נכנסה לתור, והתשובה נתקעה בדרך חזרה. הקוד שלך רואה כישלון ומנסה שוב. זה התרחיש הקלאסי, והוא נדיר בדיוק במידה שגורמת לו לצוץ רק בפרודקשן.
  • ריטריי אוטומטי של ספריית ה-HTTP. הרבה ספריות מנסות שוב בשקט על שגיאת רשת. זה נהדר לקריאת GET, ומסוכן לקריאת POST ששולחת הודעה.
  • תור הודעות שמבטיח "לפחות פעם אחת". ה-worker שלח את ה-SMS, קרס לפני שאישר את המשימה, והתור מסר אותה שוב ל-worker הבא.
  • Webhook מהחנות שנשלח מחדש. חנות אונליין ששולחת לך אירוע "הזמנה נוצרה" תנסה שוב אם ה-endpoint שלך ענה לאט או לא ענה 2xx. הקוד שלך מקבל את אותה הזמנה פעמיים, ושולח אישור פעמיים.
  • Cron שרץ פעמיים. שני replicas, ריצה שנמשכה יותר מהמרווח, או deploy באמצע הריצה. תזכורות לתורים של מחר יוצאות פעמיים.
  • לחיצה כפולה. משתמש שלוחץ "שלח קוד שוב" פעמיים בשנייה.

המשותף לכולם: הבקשה השנייה היא לגיטימית מבחינת הקוד ששלח אותה. אי אפשר לפתור את זה בצד השולח בלי מזהה שאומר לשרת "כבר ראית אותי".

איך Idempotency Key עובד ב-API של Sendy

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

curl -X POST https://sendy.co.il/api/v1/messages \
  -H "Authorization: Bearer $SENDY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-58213-shipped" \
  -d '{
    "to": "+972501234567",
    "from": "MyShop",
    "body": "ההזמנה 58213 יצאה למשלוח. צפי הגעה: מחר.",
    "mode": "transactional"
  }'

שליחה ראשונה מחזירה 202 Accepted:

{
  "id": "cmqzkdkul0007p20mf0r4tz21",
  "status": "queued",
  "to": "+972501234567",
  "from": "MyShop",
  "credits_charged": 1,
  "credits_refunded": 0,
  "refunded": false,
  "created_at": "2026-09-09T06:41:32.877Z"
}

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

ארבעה פרטים שכדאי להכיר לפני שסומכים על זה:

  1. המפתח קשור לנמען ולגוף ההודעה. אם תשלח את אותו מפתח עם מספר אחר או טקסט אחר, תקבל 409 עם הקוד IDEMPOTENCY_CONFLICT. זה לא באג בשרת, זו הגנה: שחזור שקט היה מסתיר ממך שהקוד שלך ניסה לשלוח משהו אחר תחת מזהה ישן.
  2. גם בקשות מקבילות מסתיימות בהודעה אחת. שני workers ששלחו את אותו מפתח באותה מילישנייה מקבלים שניהם את אותה הודעה; אחד עם 202 והשני עם 200. אין חלון שבו שתיהן עוברות.
  3. המפתח ייחודי בתוך סביבת העבודה שלך. אין צורך להוסיף לו את שם החברה, ואין התנגשות עם לקוחות אחרים של המערכת.
  4. בלי header אין הגנה. ה-header אופציונלי. שליחה בלעדיו היא שליחה רגילה, וכל ריטריי הוא הודעה חדשה.

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

איך בונים מפתח שבאמת מגן

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

האירועמפתח טובלמה
הזמנה יצאה למשלוחorder-58213-shippedמזהה ההזמנה + השלב. אישור ההזמנה של אותה הזמנה מקבל מפתח אחר (order-58213-confirmed)
קוד אימותotp-8412-a1b2c3מזהה המשתמש + מזהה הניסיון שנוצר פעם אחת בצד שלך. "שלח שוב" יוצר ניסיון חדש ומפתח חדש
תזכורת לתורappt-77120-reminder-24hמזהה התור + סוג התזכורת. התזכורת של שעה לפני היא אירוע אחר
ריצת cron יומיתdaily-digest-2026-09-09-c-4471תאריך הריצה + מזהה איש הקשר. ריצה שנייה באותו יום מייצרת את אותם מפתחות ולא שולחת כלום
Webhook מהחנותwh-{event_id}מזהה האירוע שהחנות שלחה. משלוח חוזר של אותו אירוע נושא את אותו מזהה

ושלוש דרכים לבנות מפתח שלא מגן על כלום:

  • חותמת זמן. otp-8412-1757404892 הוא מפתח שונה בכל ניסיון, כי הניסיון השני קורה בשנייה אחרת. הוא נראה כמו idempotency ומתנהג כמו כלום.
  • מזהה אקראי שנוצר בכל קריאה. UUID שמיוצר בתוך פונקציית השליחה הוא בדיוק כמו חותמת זמן. UUID שנוצר פעם אחת כשהאירוע נוצר, נשמר, ומועבר לכל ניסיון, הוא מפתח מצוין.
  • מזהה הנמען בלבד. phone-0501234567 יחסום את ההודעה השנייה הלגיטימית לאותו לקוח, ויחזיר 409 כי הגוף שונה. המפתח מזהה הודעה, לא אדם.

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

ריטריי נכון: אותו מפתח בכל ניסיון

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

async function sendSms({ to, body, idempotencyKey }) {
  for (let attempt = 0; attempt < 4; attempt++) {
    let res;
    try {
      res = await fetch("https://sendy.co.il/api/v1/messages", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.SENDY_API_TOKEN}`,
          "Content-Type": "application/json",
          "Idempotency-Key": idempotencyKey,
        },
        body: JSON.stringify({ to, from: "MyShop", body, mode: "transactional" }),
        signal: AbortSignal.timeout(10_000),
      });
    } catch (err) {
      // שגיאת רשת או timeout: לא ידוע אם השרת קיבל. מנסים שוב עם אותו מפתח.
      await sleep(500 * 2 ** attempt);
      continue;
    }

    if (res.status === 202 || res.status === 200) {
      return res.json(); // 200 = שחזור של שליחה קודמת, אותו id
    }
    if (res.status === 429) {
      const wait = Number(res.headers.get("Retry-After") ?? 5);
      await sleep(wait * 1000);
      continue;
    }
    if (res.status >= 500) {
      await sleep(500 * 2 ** attempt);
      continue;
    }

    // 4xx אחר: הבעיה בבקשה, ריטריי לא יעזור
    const { error } = await res.json();
    throw new Error(`${error.code}: ${error.message}`);
  }
  throw new Error("sms send failed after retries");
}

// המפתח נגזר מהאירוע, מחוץ ללולאה
await sendSms({
  to: order.phone,
  body: `ההזמנה ${order.number} יצאה למשלוח. צפי הגעה: מחר.`,
  idempotencyKey: `order-${order.id}-shipped`,
});

מה שהלולאה הזו עושה נכון: היא מנסה שוב רק כשלא ידוע אם השרת קיבל (רשת, timeout, 5xx) או כשהשרת ביקש במפורש (429 עם Retry-After). היא לא מנסה שוב על 400, 402 או 403, כי הבקשה עצמה לא תקינה, אין יתרה, או שהנמען הוסר. ריטריי על אלה רק ייצר רעש ויאכל מהמכסה של 60 בקשות לדקה.

אותו דבר ב-Python:

import os, time, requests

def send_sms(to: str, body: str, idempotency_key: str) -> dict:
    for attempt in range(4):
        try:
            res = requests.post(
                "https://sendy.co.il/api/v1/messages",
                headers={
                    "Authorization": f"Bearer {os.environ['SENDY_API_TOKEN']}",
                    "Idempotency-Key": idempotency_key,
                },
                json={"to": to, "from": "MyShop", "body": body, "mode": "transactional"},
                timeout=10,
            )
        except requests.RequestException:
            time.sleep(0.5 * 2 ** attempt)
            continue

        if res.status_code in (200, 202):
            return res.json()
        if res.status_code == 429:
            time.sleep(int(res.headers.get("Retry-After", "5")))
            continue
        if res.status_code >= 500:
            time.sleep(0.5 * 2 ** attempt)
            continue

        err = res.json()["error"]
        raise RuntimeError(f"{err['code']}: {err['message']}")
    raise RuntimeError("sms send failed after retries")

send_sms(order.phone, f"ההזמנה {order.number} יצאה למשלוח.", f"order-{order.id}-shipped")

מה עושים כשמקבלים 409

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

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

אחרי 200: בודקים סטטוס, לא שולחים שוב

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

curl https://sendy.co.il/api/v1/messages/cmqzkdkul0007p20mf0r4tz21 \
  -H "Authorization: Bearer $SENDY_API_TOKEN"

הסטטוסים הציבוריים הם queued, sending, sent, delivered, undelivered ו-failed. שים לב ש-sent ו-delivered הם שני מצבים שונים: הראשון אומר שהרשת קיבלה, השני שהמכשיר אישר קבלה. הודעה שנכשלה היא סיבה לשליחה חדשה עם מפתח חדש, כי זה אירוע חדש ("ניסיון מסירה שני"), לא ריטריי של הבקשה.

אם ההודעה המקורית זוכתה בינתיים, כי מעולם לא התקבלה ברשת, השחזור יראה את זה ב-credits_refunded. הקרדיט חזר ליתרה בלי שביקשת.

אנשי קשר לא צריכים מפתח

POST /v1/contacts הוא כבר idempotent מטבעו: מספר הטלפון הוא המזהה. קריאה חוזרת עם אותו מספר מעדכנת את אותו איש קשר במקום ליצור כפול, ומחזירה 200 במקום 201 כדי שתדע. בעדכון אנשי קשר לא צריך Idempotency-Key, ולמעשה הוא לא נקרא שם. המפתח קיים במקום היחיד שבו כפילות עולה כסף: שליחת הודעה.

צ'ק-ליסט לפני פרודקשן

  1. כל קריאה ל-POST /v1/messages שנוצרת מקוד שולחת Idempotency-Key. בלי יוצאים מן הכלל, גם בסקריפט "חד-פעמי".
  2. המפתח נגזר מרשומה או מאירוע, ונשמר לפני הניסיון הראשון. אין Date.now() ואין UUID בתוך פונקציית השליחה.
  3. אותו מפתח עובר לכל ניסיון בלולאת הריטריי.
  4. ריטריי רק על רשת, timeout, 5xx ו-429. אף פעם על 400, 402, 403.
  5. 409 נרשם כשגיאה ונחקר, לא נעטף בתפיסה שקטה.
  6. אחרי 200 בודקים סטטוס ב-GET /v1/messages/{id}, לא שולחים שוב.
  7. הודעות תפעוליות נשלחות עם mode: "transactional"; דיוור שיווקי בלי ה-mode הזה, כדי שרשימת ההסרה תיאכף. ההבדל מוסבר במדריך מצבי השליחה.

שאלות נפוצות

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

מה קורה אם אותו מפתח נשלח עם טקסט אחר? 409 עם IDEMPOTENCY_CONFLICT, ושום דבר לא נשלח. המערכת מסרבת לנחש איזו משתי ההודעות התכוונת לשלוח.

האם השחזור מחייב שוב? לא. תשובת 200 מחזירה את credits_charged של השליחה המקורית; קרדיט אחד להודעה של עד 201 תווים, פעם אחת.

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

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


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

רוצים לראות את זה בפעולה?

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

התחילו בחינם
→ חזרה לבלוג

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