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 הוא הדרך של הקוד שלך לדעת אם זו שליחה או שחזור, בלי לפרסר כלום.
ארבעה פרטים שכדאי להכיר לפני שסומכים על זה:
- המפתח קשור לנמען ולגוף ההודעה. אם תשלח את אותו מפתח עם מספר אחר או טקסט אחר, תקבל
409עם הקודIDEMPOTENCY_CONFLICT. זה לא באג בשרת, זו הגנה: שחזור שקט היה מסתיר ממך שהקוד שלך ניסה לשלוח משהו אחר תחת מזהה ישן. - גם בקשות מקבילות מסתיימות בהודעה אחת. שני workers ששלחו את אותו מפתח באותה מילישנייה מקבלים שניהם את אותה הודעה; אחד עם
202והשני עם200. אין חלון שבו שתיהן עוברות. - המפתח ייחודי בתוך סביבת העבודה שלך. אין צורך להוסיף לו את שם החברה, ואין התנגשות עם לקוחות אחרים של המערכת.
- בלי 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, ולמעשה הוא לא נקרא שם. המפתח קיים במקום היחיד שבו כפילות עולה כסף: שליחת הודעה.
צ'ק-ליסט לפני פרודקשן
- כל קריאה ל-
POST /v1/messagesשנוצרת מקוד שולחתIdempotency-Key. בלי יוצאים מן הכלל, גם בסקריפט "חד-פעמי". - המפתח נגזר מרשומה או מאירוע, ונשמר לפני הניסיון הראשון. אין
Date.now()ואין UUID בתוך פונקציית השליחה. - אותו מפתח עובר לכל ניסיון בלולאת הריטריי.
- ריטריי רק על רשת, timeout,
5xxו-429. אף פעם על400,402,403. 409נרשם כשגיאה ונחקר, לא נעטף בתפיסה שקטה.- אחרי
200בודקים סטטוס ב-GET /v1/messages/{id}, לא שולחים שוב. - הודעות תפעוליות נשלחות עם
mode: "transactional"; דיוור שיווקי בלי ה-mode הזה, כדי שרשימת ההסרה תיאכף. ההבדל מוסבר במדריך מצבי השליחה.
שאלות נפוצות
האם חייבים לשלוח Idempotency-Key? לא. ה-header אופציונלי, ושליחה בלעדיו עובדת רגיל. אבל כל שליחה שיוצאת מקוד, ולא מלחיצה ידנית במסך, צריכה אותו, כי לקוד יש ריטריי ולאדם אין.
מה קורה אם אותו מפתח נשלח עם טקסט אחר?
409 עם IDEMPOTENCY_CONFLICT, ושום דבר לא נשלח. המערכת מסרבת לנחש איזו משתי ההודעות התכוונת לשלוח.
האם השחזור מחייב שוב?
לא. תשובת 200 מחזירה את credits_charged של השליחה המקורית; קרדיט אחד להודעה של עד 201 תווים, פעם אחת.
איך זה מתחבר לקוד אימות? המפתח הוא מזהה המשתמש ומזהה הניסיון, כך ש"שלח שוב" יוצר קוד חדש ומפתח חדש, וריטריי על אותו ניסיון לא שולח קוד שני. הפרטים במדריך שליחת קוד אימות ב-SMS ובמדריך המלא לאימות למפתחים.
זה מפתח שרק Sendy מציעה? לא, זה דפוס מקובל ב-API של תשלומים ושל הודעות. כשאתה בוחר ספק SMS API, השאלה היא לא אם יש idempotency אלא איך הוא נשלח, מה קורה בהתנגשות, והאם התשובה מבדילה בין שליחה לשחזור.
רוצה לבדוק את זה על הקוד שלך? 30 ההודעות הראשונות חינם, בלי כרטיס אשראי. השליחה הראשונה לוקחת דקות, והתיעוד המלא, כולל idempotency וקודי השגיאה, פתוח בעמוד ה-Docs. כשהחנות שולחת את אישור ההזמנה ואת עדכון המשלוח דרך אחת האינטגרציות, אותו לוג ואותו מפתח עובדים גם שם.