בכל אפליקציה מגיע הרגע שבו צריך לוודא שמאחורי המסך יש בן אדם אמיתי עם מספר טלפון אמיתי: התחברות בלי סיסמה, אימות מספר בהרשמה, אישור פעולה רגישה. הפתרון הנפוץ בישראל הוא קוד חד-פעמי ב-SMS - ובמדריך הזה נבנה אותו מאפס: נבין איך הזרימה עובדת, נכתוב קוד מלא שאפשר להעתיק כמו שהוא, נעבור על צ'ק-ליסט אבטחה - ובסוף מחכה בונוס למי שכותב קוד עם AI: פרומפט אחד שמקים את כל המנגנון בשבילכם.
מה זה בעצם OTP, ולמה כמעט כל אפליקציה צריכה את זה
OTP (One-Time Password) הוא קוד חד-פעמי, בדרך כלל בן 6 ספרות, שנשלח למשתמש ומוכיח דבר אחד פשוט: האדם שמולכם באמת מחזיק בטלפון הזה, עכשיו. על ההוכחה הקטנה הזאת נשענים כמה מהתרחישים הנפוצים ביותר:
- התחברות בלי סיסמה - המשתמש מזין מספר טלפון, מקבל קוד, מקליד ונכנס. אין סיסמה לשכוח ואין סיסמה שתדלוף.
- אימות מספר בהרשמה - לפני שאתם שומרים מספר במערכת (ובטח לפני שאתם שולחים אליו הודעות), כדאי לוודא שהוא אמיתי ושייך לנרשם.
- אישור פעולה רגישה - שינוי פרטי חשבון, מחיקת נתונים, אישור תשלום או משלוח.
- שכבה שנייה (2FA) - קוד ב-SMS מעל סיסמה קיימת.
ולמה דווקא SMS ולא אפליקציית אימות? כי אין מה להתקין ואין מה להסביר. לכל לקוח שלכם יש מספר טלפון, וכולם יודעים לקרוא הודעה. בשביל קהל ישראלי רחב - זו הדרך עם הכי פחות חיכוך.
איך זרימת OTP עובדת - ארבעה שלבים
כשמפרקים את ה"קסם", נשארים עם ארבעה שלבים שכל מפתח יכול לבנות:
- יצירה - השרת שלכם מייצר קוד אקראי ושומר אותו אצלו, יחד עם זמן תפוגה.
- שליחה - השרת שולח את הקוד ב-SMS למספר שהמשתמש מסר.
- הזנה - המשתמש מקליד את הקוד במסך שלכם.
- אימות - השרת משווה בין מה שהוקלד למה שנשמר. תואם ובתוקף? המספר מאומת, והקוד נמחק.
וכלל הזהב שמחזיק את כל המבנה: הקוד חי אך ורק בצד השרת. הוא נשלח למשתמש ב-SMS - ולעולם, לעולם לא חוזר בתשובת ה-API אל הדפדפן או האפליקציה. מפתחים מתחילים לפעמים מחזירים את הקוד בתגובת ה-request "כדי לבדוק שזה עובד", וזו בדיוק הפרצה: כל מי שפותח DevTools מאמת את עצמו בלי טלפון.
כמה זה עולה?
הודעת OTP היא ההודעה הזולה ביותר שיש: ב-Sendy קרדיט אחד מכסה הודעה של עד 201 תווים - בעברית, באנגלית, לא משנה. הודעת אימות טיפוסית ("הקוד שלך: 482913") רחוקה מזה מאוד, כך שכל אימות עולה בדיוק קרדיט אחד, תמיד. במספרים: בחבילה של 10,000 קרדיטים (₪280) אימות בודד עולה פחות מ-3 אגורות, ו-1,000 התחברויות בחודש יעלו לכם בערך כמו שני קפה.
שלב 0: מה צריך לפני שכותבים קוד
שלושה דברים, חמש דקות:
- חשבון Sendy עם קרדיטים.
- שם שולח מאושר (Sender ID) - זה השם שיופיע אצל הלקוח במקום מספר (למשל שם המותג שלכם). מגישים לאישור מתוך המערכת.
- מפתח API - בעמוד מפתחים במערכת מפיקים מפתח
sk_live_…. הוא מוצג פעם אחת בלבד - שמרו אותו במשתני סביבה (SENDY_API_KEY), לעולם לא בקוד עצמו, לא בצד לקוח ולא בגיט.
שלב 1: לשלוח SMS בקריאת API אחת
הליבה של כל העניין היא בקשת POST אחת:
curl -X POST https://sendy.co.il/api/v1/messages \
-H "Authorization: Bearer $SENDY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: otp-0501234567-a1b2c3" \
-d '{
"to": "0501234567",
"from": "MyApp",
"body": "הקוד שלך: 482913",
"mode": "transactional"
}'מה יש כאן:
to- מספר נייד ישראלי, בפורמט מקומי (0501234567) או בינלאומי (+972501234567).from- שם השולח המאושר שלכם. שם לא מאושר יוחזר עם שגיאתSENDER_NOT_APPROVED.mode: "transactional"- נקודה שחשוב להבין: הודעת אימות היא הודעה תפעולית, לא שיווקית. ההצהרה הזאת אומרת למערכת לא להחיל את חסימת דיוור-שיווקי - לקוח שהסיר את עצמו מרשימת התפוצה עדיין צריך (ורוצה) לקבל קוד התחברות. השתמשו בזה רק להודעות שבאמת תפעוליות. להרחבה על ההבדל בין שני המצבים: מדריך מצבי השליחה ב-API.Idempotency-Key(מומלץ) - מזהה ייחודי לשליחה. אם החיבור נפל ואתם מנסים שוב עם אותו מפתח, לא יישלח SMS שני ולא תחויבו פעמיים.
תשובה מוצלחת חוזרת מיד (202), והמסירה עצמה מתבצעת ברקע:
{
"id": "…",
"status": "queued",
"to": "+972501234567",
"from": "MyApp",
"credits_charged": 1
}שגיאות מגיעות תמיד באותה מעטפה - { "error": { "code": "…", "message": "…" } } - עם קודים ברורים: INVALID_API_KEY (401), INVALID_RECIPIENT (400), SENDER_NOT_APPROVED (403), INSUFFICIENT_CREDITS (402) ו-RATE_LIMITED (429).
טיפ למתקדמים: בזרימת OTP בדרך כלל לא צריך לעקוב אחרי מסירה - ההוכחה שההודעה הגיעה היא שהמשתמש הקליד את הקוד. אבל אם תרצו, GET /api/v1/messages/{id} מחזיר את הסטטוס המלא (queued → sending → sent → delivered).
רוצים להעמיק דווקא בקריאת השליחה עצמה? יש לנו מדריך ממוקד לשליחת קוד אימות מה-API, כולל דוגמת Python ומעקב אחרי סטטוס המסירה.
שלב 2: מודול OTP שלם ב-TypeScript
עכשיו נעטוף את השליחה במנגנון אמיתי. שתי פונקציות - requestOtp ו-verifyOtp - שמממשות את כל מה שצריך: קוד אקראי קריפטוגרפי, תוקף, הגבלת ניסיונות, השוואה בטוחה וקוד חד-פעמי.
// otp.ts
import { createHash, randomInt, timingSafeEqual } from "node:crypto";
const OTP_TTL_MS = 5 * 60 * 1000; // תוקף הקוד: 5 דקות
const MAX_ATTEMPTS = 3; // ניסיונות אימות לכל קוד
const RESEND_COOLDOWN_MS = 60 * 1000; // המתנה מינימלית בין שליחות לאותו מספר
interface OtpEntry {
codeHash: Buffer;
expiresAt: number;
attempts: number;
lastSentAt: number;
}
// לפיתוח: Map בזיכרון. בפרודקשן החליפו ב-Redis (עם TTL) או בטבלת DB -
// הממשק של שתי הפונקציות נשאר זהה.
const store = new Map<string, OtpEntry>();
const hashCode = (code: string) => createHash("sha256").update(code).digest();
async function sendSms(to: string, body: string): Promise<void> {
const res = await fetch("https://sendy.co.il/api/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SENDY_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `otp:${to}:${Date.now()}`,
},
body: JSON.stringify({
to,
from: "MyApp", // שם השולח המאושר שלכם
body,
mode: "transactional",
}),
});
if (!res.ok) {
const payload = await res.json().catch(() => null);
throw new Error(`Sendy send failed: ${res.status} ${payload?.error?.code ?? ""}`);
}
}
export async function requestOtp(phone: string): Promise<void> {
const existing = store.get(phone);
if (existing && Date.now() - existing.lastSentAt < RESEND_COOLDOWN_MS) {
return; // מוקדם מדי לקוד נוסף - שקט, בלי להסגיר מידע
}
const code = randomInt(0, 1_000_000).toString().padStart(6, "0");
store.set(phone, {
codeHash: hashCode(code),
expiresAt: Date.now() + OTP_TTL_MS,
attempts: 0,
lastSentAt: Date.now(),
});
await sendSms(phone, `הקוד שלך: ${code}\nתקף ל-5 הדקות הקרובות.`);
}
export function verifyOtp(phone: string, code: string): boolean {
const entry = store.get(phone);
if (!entry) return false;
if (Date.now() > entry.expiresAt) {
store.delete(phone);
return false;
}
entry.attempts += 1;
if (entry.attempts > MAX_ATTEMPTS) {
store.delete(phone);
return false;
}
const ok = timingSafeEqual(hashCode(code), entry.codeHash);
if (ok) store.delete(phone); // קוד חד-פעמי: נמחק מיד אחרי אימות מוצלח
return ok;
}שימו לב לפרטים הקטנים - הם ההבדל בין "עובד לי" לבין מאובטח: הקוד נוצר עם crypto.randomInt (לא Math.random), נשמר כ-hash ולא כטקסט, מושווה עם timingSafeEqual, ונמחק ברגע שאומת.
שלב 3: חיבור לאפליקציה
שני endpoints והמנגנון באוויר (הדוגמה ב-Express, אבל התבנית זהה בכל framework):
// auth-routes.ts
import { requestOtp, verifyOtp } from "./otp";
app.post("/auth/otp/request", async (req, res) => {
const { phone } = req.body;
try {
await requestOtp(phone);
} catch (err) {
console.error("otp request failed", err); // בלי הקוד עצמו!
}
// תמיד אותה תשובה - לא מסגירים אם המספר מוכר למערכת או לא
res.json({ ok: true });
});
app.post("/auth/otp/verify", (req, res) => {
const { phone, code } = req.body;
if (typeof code !== "string" || !verifyOtp(phone, code)) {
return res.status(401).json({ ok: false });
}
// מכאן זה שלכם: יצירת session, הנפקת JWT, סימון המספר כמאומת
res.json({ ok: true });
});זהו. מסך שמבקש מספר, מסך שמבקש קוד, ושתי קריאות ל-endpoints האלה.
צ'ק-ליסט אבטחה - עשר הנקודות שמפרידות בין צעצוע למערכת
- קוד מ-
crypto.randomInt, לאMath.random- מחולל פסאודו-אקראי רגיל ניתן לחיזוי. - תוקף קצר - 5 דקות מספיקות לכל משתמש אמיתי, וסוגרות את חלון התקיפה.
- הגבלת ניסיונות (3) - לקוד בן 6 ספרות יש מיליון צירופים; בלי מגבלת ניסיונות, brute force הוא רק עניין של זמן. עם 3 ניסיונות - הסיכוי לנחש הוא 1 ל-333,333.
- Cooldown בין שליחות + תקרה יומית למספר - גם נגד הטרדת משתמשים, וגם נגד תוקף שמזרים אלפי בקשות ומרוקן את חשבון ה-SMS שלכם (SMS pumping).
- קוד חד-פעמי - נמחק מיד אחרי אימות מוצלח. קוד שאומת פעמיים הוא באג אבטחה.
- שומרים hash, לא את הקוד - אם מסד הנתונים דולף, אין שם קודים בטקסט גלוי.
- השוואה timing-safe - השוואת מחרוזות רגילה דולפת מידע דרך זמן הריצה.
- הקוד לא מופיע בלוגים - לא ב-
console.log, לא ב-APM, בשום מקום. - תשובה אחידה בבקשת קוד - אותו
{ ok: true }בין אם המספר קיים ובין אם לא, כדי שאי אפשר יהיה למפות אילו מספרים רשומים אצלכם. Idempotency-Keyבשליחה - ניסיון חוזר אחרי תקלת רשת לא ישלח SMS כפול ולא יחייב פעמיים.
עובדים עם AI? הנה כל המדריך הזה בפרומפט אחד
בואו נהיה כנים: רוב הסיכוי שאת הקוד הזה לא תקלידו ביד - Claude, Copilot או Cursor יכתבו אותו בשבילכם. הבעיה היחידה היא שהם לא מכירים את ה-API של Sendy לעומק. אז הכנו לכם פרומפט שכולל את כל מה שהם צריכים לדעת - המפרט המלא ודרישות האבטחה מהמדריך (באנגלית, השפה שבה המודלים הכי מדויקים). העתיקו, מלאו את שתי השורות בסוגריים, והדביקו:
Add SMS phone verification (one-time password / OTP) to my project, using Sendy's API.
My stack: [e.g. Node.js + Express + Postgres]
## Sendy API (sendy.co.il)
- Send an SMS: POST https://sendy.co.il/api/v1/messages
- Auth: an Authorization: Bearer <SENDY_API_KEY> header - read the key from an
environment variable, never hardcode it
- JSON body: { "to": "05XXXXXXXX", "from": "<approved sender name>",
"body": "<message text>", "mode": "transactional" }
- Israeli mobile numbers only: "05XXXXXXXX" or "+9725XXXXXXXX"
- Include a unique Idempotency-Key header on every send - a retry with the same
key will not send or charge twice
- Success response: 202 with { "id", "status": "queued", "credits_charged", ... }
- Errors use the envelope { "error": { "code", "message" } } with these codes:
INVALID_API_KEY (401), INVALID_RECIPIENT (400), SENDER_NOT_APPROVED (403),
INSUFFICIENT_CREDITS (402), RATE_LIMITED (429 - honor the Retry-After header)
## What to build
1. An OTP module with two functions: requestOtp(phone) and verifyOtp(phone, code)
2. Two endpoints: POST /auth/otp/request with { phone }, and
POST /auth/otp/verify with { phone, code }
3. On successful verification: [what to do - e.g. create a session / issue a JWT /
plug into my existing auth]
## Security requirements (mandatory, no exceptions)
- 6-digit code generated with crypto.randomInt - never Math.random
- 5-minute expiry; the code is single-use and deleted immediately after
successful verification
- Maximum 3 verification attempts per code; after that the code is invalidated
- 60-second cooldown between sends to the same number, and a cap of 5 sends
per number per day
- Store a SHA-256 hash of the code, not the code itself; compare with
crypto.timingSafeEqual
- The code must never be logged and never returned in any API response
- The request endpoint always returns the same response, without revealing
whether the number exists in the system
- SMS body in Hebrew: "הקוד שלך: {code}" plus how long it is valid
Write complete, working code for my stack, including handling for every
Sendy API error listed above.התוצאה: מנגנון OTP שלם, מאובטח לפי כל הכללים שלמעלה, שמדבר עם Sendy נכון כבר מהניסיון הראשון.
לסיכום
אימות ב-SMS הוא אחד מאותם פיצ'רים שנשמעים מסובכים ומתבררים כפשוטים להפתיע: קוד אקראי, קריאת API אחת, השוואה - וקומץ כללי אבטחה שעושים את ההבדל. ואם אתם עדיין מתלבטים דרך מי לשלוח - כך בוחרים ספק SMS API בישראל. עם Sendy כל אימות עולה קרדיט אחד, ההודעה יוצאת בעברית מלאה משם השולח של המותג שלכם, והמסירה בישראל מהירה ואמינה.
יש לכם כבר חשבון? כל מה שנשאר הוא להפיק מפתח API בעמוד המפתחים ולהעתיק את הקוד מהמדריך. אין עדיין? פתחו חשבון עם 30 הודעות חינם, בלי כרטיס אשראי, ותוך כמה דקות אתם שולחים את הקוד הראשון. נתקעתם באישור שם שולח או בהטמעה? דברו איתנו - אנחנו כאן.