כדי לקבל SMS בקוד צריך מספר שמקבל הודעות ודרך להעביר אותן לשרת שלך. ב-Sendy המספר הווירטואלי מקבל את התשובות, וכל הודעה נכנסת מגיעה אליך באחת משתי דרכים: webhook ברגע שהיא נוחתת, או קריאת GET /v1/inbound שאתה מריץ מתי שנוח לך.
שלחת ללקוחות הודעה, והם עונים: "כן, אני מגיע", "אשמח לפרטים", "8". אם שלחת משם עסק, אי אפשר להשיב לשם. אם שלחת מהמספר הנייד שלך, התשובות נוחתות אצלך בטלפון ונשארות שם. כדי שהן יגיעו למערכת שלך, צריך מספר וירטואלי: מספר נייד ישראלי שמשויך למרחב העבודה, שאפשר לשלוח ממנו ושמקבל את התשובות.
המאמר הזה מתחיל איפה שההפעלה נגמרת. הוא מראה איך הודעה נכנסת מגיעה לקוד שלך, איך נראה האובייקט שלה, ומה עושים איתה בדרך ל-CRM.
מה צריך מראש
- מספר וירטואלי פעיל. בעל מרחב העבודה מפעיל אותו מהתפריט "מספר וירטואלי". המספר מוקצה מתוך מאגר, אחד לכל מרחב עבודה. הצעדים: הפעלת מספר וירטואלי.
- טוקן API. יוצרים אותו בעמוד מפתחים, ושולחים אותו בכל קריאה ב-header
Authorization: Bearer. - לדרך של ה-webhook: כתובת HTTPS פומבית בשרת שלך, ובעלות על מרחב העבודה כדי להגדיר אותה.
שתי דרכים לקבל הודעה נכנסת
| Webhook | Polling | |
|---|---|---|
| איך זה עובד | Sendy שולחת POST חתום לשרת שלך על כל הודעה | הקוד שלך קורא ל-GET /v1/inbound כל כמה דקות |
| מהירות | שניות אחרי שההודעה נכנסה | לפי תדירות הבדיקה |
| מה צריך | כתובת HTTPS פומבית | רק טוקן API |
| מתי מתאים | בוט, תור שירות, כל מה שצריך תגובה מהירה | סקריפט לילי, סנכרון ל-CRM, סביבה בלי כתובת פומבית |
אפשר גם להריץ את שתיהן יחד: webhook לזמן אמת, ו-polling פעם ביום כרשת ביטחון. מכיוון שלכל הודעה יש id קבוע, הכפילות לא מזיקה.
איך נראית הודעה נכנסת
זה האובייקט ש-GET /v1/inbound מחזיר בתוך data, ובדיוק אותו אובייקט מגיע בשדה data של אירוע ה-webhook:
{
"id": "cmc0inbound0001",
"from": "+972501234567",
"to": "+972531234567",
"text": "אשמח לקבל פרטים נוספים",
"opt_out": "none",
"opt_out_applied_at": null,
"received_at": "2026-08-24T10:15:00.000Z"
}fromהוא השולח. כשמדובר בנייד ישראלי הוא מגיע בפורמט בינלאומי (+972...), אותו פורמט של אנשי הקשר, כך שאפשר לחפש אותו ישר עםGET /v1/contacts?phone=. כששולח הוא שם עסק או מספר מחו"ל, הערך מגיע כמו שהוא.toהוא המספר הווירטואלי שלך.opt_outהוא השדה שהכי חשוב לקרוא, וההסבר עליו מופיע בהמשך.
דרך 1: webhook, שלב אחר שלב
ההגדרה המלאה, כולל כל האירועים, מופיעה במדריך ה-Webhooks. בקצרה:
- נכנסים למפתחים ובוחרים בלשונית Webhooks.
- מוסיפים את כתובת ה-HTTPS שלך. יש נקודת קצה אחת לכל מרחב עבודה.
- מסמנים את האירוע
inbound.received. - מעתיקים את סוד החתימה למשתנה סביבה בשרת.
- לוחצים על שליחת אירוע בדיקה ומוודאים שהשרת מחזיר
200.
זה שרת Node.js מינימלי שמקבל את האירוע, מאמת שהוא באמת הגיע מ-Sendy ומעביר הלאה:
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
const SECRET = process.env.SENDY_WEBHOOK_SECRET;
function isFromSendy(rawBody, header = "") {
const t = header.match(/t=(\d+)/)?.[1];
const sigs = [...header.matchAll(/v1=([0-9a-f]{64})/g)].map((m) => m[1]);
if (!t || sigs.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac("sha256", SECRET).update(`${t}.${rawBody}`).digest("hex");
return sigs.some((s) => timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}
app.post("/sendy/webhook", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!isFromSendy(rawBody, req.get("Sendy-Signature"))) return res.sendStatus(401);
const event = JSON.parse(rawBody);
res.sendStatus(200); // עונים מיד, מעבדים אחרי
if (event.type === "inbound.received") {
handleInbound(event.data).catch(console.error);
}
});
app.listen(3000);שלושה דברים שהקוד הזה עושה בכוונה:
- מאמת על הגוף הגולמי. החתימה מחושבת על הבייטים שהתקבלו, ולכן
express.rawולאexpress.json. אחריJSON.parseכבר אי אפשר לאמת. - מקבל שתי חתימות. אחרי החלפת סוד, הסוד הקודם ממשיך לחתום במקביל במשך 24 שעות, ולכן הבקשה יכולה לשאת שני ערכי
v1. - עונה לפני שהוא מעבד. Sendy מחכה עד 10 שניות לתשובת
2xx. כתיבה ל-CRM יכולה לקחת יותר, אז היא קורית אחרי התשובה, או בתור עבודות.
דרך 2: polling ב-Python
אם אין לך כתובת פומבית, או שמספיק לך סנכרון כל כמה דקות, קוראים ל-GET /v1/inbound עם since. ההודעות חוזרות מהישנה לחדשה, ו-since כולל את הרגע עצמו, ולכן מסננים כפילויות לפי id:
import os
import time
from datetime import datetime, timedelta, timezone
import requests
API = "https://sendy.co.il/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SENDY_API_KEY']}"}
def fetch_inbound(since):
params = {"since": since, "limit": 100}
while True:
r = requests.get(f"{API}/inbound", headers=HEADERS, params=params, timeout=10)
r.raise_for_status()
page = r.json()
yield from page["data"]
if not page["has_more"]:
return
params["cursor"] = page["next_cursor"]
seen = set() # בפרודקשן: טבלה עם מפתח ייחודי על id
while True:
since = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
for msg in fetch_inbound(since):
if msg["id"] in seen:
continue
seen.add(msg["id"])
handle_inbound(msg)
time.sleep(300)למה since ולא רק cursor: בעמוד האחרון next_cursor חוזר null, כך שאין סמן להמשיך ממנו בסבב הבא. חלון של יום אחורה עם סינון לפי id הוא הדרך הפשוטה שלא מפספסת הודעות. בדיקה כל חמש דקות רחוקה מאוד ממגבלת הקצב של 60 קריאות לדקה לטוקן.
מההודעה ל-CRM: מה עושים עם כל הודעה
כאן handle_inbound נכנסת לתמונה. השדה opt_out אומר לך איזה סוג הודעה קיבלת, ולכל סוג יש טיפול אחר:
opt_out | מה זה אומר | מה עושים ב-CRM |
|---|---|---|
"detected" | ההודעה היא בקשת הסרה, למשל "הסר". Sendy כבר הסירה את השולח, ו-opt_out_applied_at אומר מתי | מסמנים את איש הקשר כלא מעוניין בדיוור, כדי שה-CRM לא יחזיר אותו לרשימה בייבוא הבא |
"suspected" | מילת הסרה בתוך הודעה ארוכה יותר, כמו "אשמח שתסירו את החיוב". Sendy לא מסירה, ומסמנת את ההודעה לבדיקה במערכת | פותחים משימה לאדם: זו יכולה להיות בקשת שירות ולא בקשת הסרה |
"none" | הודעה רגילה: שאלה, אישור, ליד, הצבעה | מוצאים את איש הקשר לפי from, ורושמים פעילות, מעדכנים שלב או פותחים ליד |
ככה זה נראה בקוד, עם פונקציות CRM כלליות שאתה מחליף בקריאות של המערכת שלך:
def handle_inbound(msg):
phone, text = msg["from"], msg["text"]
if msg["opt_out"] == "detected":
crm.mark_unsubscribed(phone, at=msg["opt_out_applied_at"])
elif msg["opt_out"] == "suspected":
crm.create_task(phone, f"בדיקת בקשה אפשרית להסרה: {text}")
else:
crm.log_reply(phone, text, received_at=msg["received_at"])לענות מהקוד. המספר הווירטואלי מאושר אוטומטית כשולח, כך שאפשר להשיב מאותו מספר שהלקוח כתב אליו. שולחים POST /v1/messages עם from שווה ל-to של ההודעה הנכנסת ו-to שווה ל-from שלה. תשובה לשאלה של לקוח היא הודעת שירות, ולכן כדאי לשלוח אותה במצב תפעולי.
טעויות נפוצות
- להניח שכל הודעה מגיעה פעם אחת. שרת שלא ענה בזמן יקבל את אותו אירוע שוב, עד 8 ניסיונות לאורך כ-22 שעות. בודקים את ה-
idלפני שכותבים, בדיוק כמו מפתח אידמפוטנטיות בשליחה. - להניח סדר. אירועים לא מגיעים בהכרח לפי הסדר. כשהסדר חשוב, ממיינים לפי
received_at. - לחשוב שכל שורה היא הודעה שלמה. הודעה ארוכה במיוחד יכולה להגיע בכמה שורות נפרדות מאותו שולח, אחת אחרי השנייה.
- לחכות להתראה מתיבת ההודעות. התיבה במערכת מקבלת בלבד ולא שולחת התראה על הודעה חדשה. ה-webhook הוא הדרך היחידה לקבל דחיפה.
- לשחרר את המספר בלי גיבוי. אחרי שחרור, התיבה כבר לא מוצגת. מייצאים CSV לפני.
בלי לכתוב שרת: n8n, Make ו-Zapier
כל אחד מהכלים האלה נותן כתובת webhook נכנסת. מדביקים אותה כנקודת הקצה של Sendy, מסמנים inbound.received, וכל הודעה נכנסת מפעילה את התרחיש שלך. דרך שנייה היא צעד HTTP מתוזמן שקורא ל-GET /v1/inbound. ההבדל בין שתי הדרכים, ומה כל כלי יודע לעשות מול Sendy: חיבור Sendy לכלי אוטומציה.
שאלות נפוצות
אפשר לקבל SMS בלי מספר וירטואלי? לא דרך Sendy. משם עסק אי אפשר להשיב בכלל, ותשובות למספר הנייד שלך מגיעות לטלפון שלך ולא למערכת. המספר הווירטואלי הוא מה שמכניס את התשובות ל-API.
תשובת "הסר" מגיעה גם לקוד שלי?
כן, עם opt_out שערכו "detected". ההסרה עצמה כבר קרתה ב-Sendy כשההודעה הגיעה, כך שהקוד שלך רק צריך לעדכן את ה-CRM.
אפשר לבחור את המספר? לא. המספר מוקצה מתוך מאגר, אחד לכל מרחב עבודה.
אפשר להשתמש ב-webhook וב-polling יחד?
כן. שתי הדרכים מחזירות את אותו אובייקט עם אותו id, ומסננים כפילויות לפיו.
כמה זה עולה? המספר והודעות שנכנסות מניידים מחויבים בקרדיטים מאותה יתרה של השליחות, בלי מנוי נפרד. הפירוט בעמוד המספר הווירטואלי.
רוצה לחבר את התשובות למערכת שלך? התיעוד המלא של GET /v1/inbound ושל ה-Webhooks כולל את כל השדות והשגיאות. 30 ההודעות הראשונות חינם, בלי כרטיס אשראי.
