שליחת מיילים טרנזקציוניים ושיווקיים. כל הבקשות והתשובות ב-JSON (UTF-8).
גרסת Markdown של התיעוד — לעוזרי AI ולכלים (טקסט נקי, אותו תוכן). · סטטוס השירות (גם כשהשרת למטה: status.friman.app) · ספריית JavaScript
שאלות נפוצות · מעבר מ-Resend לבול · מדיניות פרטיות · תנאי שימוש · Privacy Policy · Terms of Service
כתובת הבסיס: https://bul.friman.app/v1. כל בקשה צריכה כותרת Authorization: Bearer <API key>. את המפתח יוצרים בדשבורד, והוא מוצג פעם אחת בלבד.
סוגי מפתחות: read קריאה בלבד · send שליחה וקריאה · full הכול, כולל ניהול דומיינים ו-webhooks.
Idempotency-Key (חובה ב-POST /v1/emails, /batch, /campaigns): מחרוזת ייחודית לכל בקשה, למשל UUID. בקשה חוזרת עם אותו מפתח מחזירה את התשובה המקורית ולא שולחת שוב. מפתח תקף 30 יום; אחרי זה הוא נמחק, ובקשה עם אותו ערך תיחשב בקשה חדשה ותישלח.
שמות שדות: הצורה הראשית היא snake_case — בבקשות, בתשובות ובאירועים (message_id, reply_to, created_at). בכמה תשובות ישנות מוחזר גם השם הקודם ב-camelCase, לתאימות לאחור בלבד.
| Method | Path | Scope | מה זה עושה |
|---|---|---|---|
| GET | /v1/status | read, send, full | בדיקת בריאות: המפתח, מצב השליחה, המכסה, הדומיינים |
| POST | /v1/emails | send, full | מייל לנמען אחד |
| POST | /v1/emails/batch | send, full | אותו מייל לעד 100 נמענים, או מערך של עד 100 הודעות שונות (תוצאה לכל הודעה) |
| GET | /v1/emails | read, send, full | רשימת הודעות: סינון לפי נמען, סטטוס, זמן וקמפיין, עם עימוד |
| GET | /v1/emails/{id} | read, send, full | סטטוס ומטא-דאטה של הודעה (?include=html לגוף ההודעה) |
| GET | /v1/events | read, send, full | אירועים לפי סדר קבלתם, עם עימוד — לשחזור מה שהוחמץ ב-webhooks |
| POST | /v1/campaigns | send, full | קמפיין, עד 10,000 נמענים בקריאה אחת |
| POST | /v1/campaigns/{id}/recipients | send, full | הוספת נמענים לקמפיין קיים, עד 10,000 בבקשה |
| GET | /v1/campaigns | read, send, full | רשימת קמפיינים, עם זמן משוער לסיום (?limit, ?status) |
| GET | /v1/campaigns/{id} | read, send, full | פרטי קמפיין, מונים וזמן משוער לסיום (?include=html לגוף) |
| GET | /v1/domains | read, send, full | רשימת הדומיינים |
| POST | /v1/domains | full | הוספת דומיין שליחה (מחזיר את רשומות ה-DNS) |
| GET | /v1/domains/{id} | read, send, full | סטטוס אימות ורשומות |
| DELETE | /v1/domains/{id} | full | מחיקת דומיין |
| GET | /v1/webhooks | read, send, full | רשימת ה-webhooks (בלי הסוד) |
| POST | /v1/webhooks | full | יצירת webhook (הסוד חוזר פעם אחת בלבד) |
| GET | /v1/webhooks/{id} | read, send, full | webhook בודד |
| DELETE | /v1/webhooks/{id} | full | מחיקת webhook |
| POST | /v1/webhooks/{id}/test | full | אירוע בדיקה חתום (test: true) — התשובה כוללת את התוצאה |
| GET | /v1/suppressions | read, send, full | רשימת החסימה (limit, offset) |
| POST | /v1/suppressions | send, full | הוספת כתובת לרשימת החסימה |
| POST | /v1/suppressions/import | send, full | ייבוא מרוכז — עד 10,000 כתובות בבקשה |
| DELETE | /v1/suppressions/{id|email} | full | הסרה מרשימת החסימה |
| GET | /v1/stats | read, send, full | מונים כלליים (אירועים) + unique_opens / unique_clicks + פתיחות אנושיות משוערות |
| GET | /v1/usage | read, send, full | שימוש ועלות משוערת לפי חודש (?group=campaign — לפי קמפיין) |
עובד עם כל סוג מפתח, ונספר במכסת הקצב כמו כל בקשה.
GET /v1/status
{
"key": { "name": "שרת הייצור", "scope": "send" },
"sending": { "status": "open", "reason": null, // open | blocked, ו-reason בעברית כשיש סיבה
"reputation_warning": false }, // true = עבר סף אזהרה, השליחה עדיין פתוחה
"rate_limit": { "limit": 120, "remaining": 117, "reset_seconds": 42 },
"domains": [ { "domain": "yourdomain.co.il", "status": "verified" } ], // verified | pending | failed
"queue_processing": true, // false = הודעות מתקבלות אבל השליחה או עדכוני הסטטוס מתעכבים אצלנו
"compliance": { "unsubscribe": null } // או { "level": "warning" | "strong", "reason": "...", "detected_at": "..." }
}POST /v1/emails
{
"from": "Acme <news@yourdomain.co.il>", // חובה. מדומיין מאומת בחשבון (או תת-דומיין שלו)
"to": ["dana@example.com"], // חובה. מערך. /emails: 1, /batch: עד 100, /campaigns: עד 10,000
"subject": "שלום", // חובה
"html": "<p>...</p>", // html או text (או שניהם) — חובה לפחות אחד
"text": "גרסת טקסט רגיל", // אופציונלי
"track_clicks": true, // אופציונלי, ברירת מחדל true. false = קישורים ישירים, בלי עטיפת מעקב
"kind": "transactional", // transactional | marketing. ברירת מחדל: transactional (בקמפיין: marketing)
"reply_to": ["support@yourdomain.co.il"], // אופציונלי. מחרוזת או מערך, עד 5 כתובות
"headers": { "X-Order-Id": "1234" }, // אופציונלי. רק כותרות X-*, עד 20
"tags": { "order_id": "1234" }, // אופציונלי. חוזרים בכל webhook, ב-GET וב-/v1/events
"attachments": [ ... ] // אופציונלי. ראו למטה
}
202 Accepted
{ "id": "uuid", "recipient": "dana@example.com" }tags: עד 10 זוגות של מפתח וערך. מפתח: אותיות באנגלית, ספרות, _ ו--, עד 64 תווים. ערך: מחרוזת עד 256 תווים. הם לא משפיעים על השליחה — רק חוזרים אליכם עם כל אירוע של ההודעה.
track_clicks: כש-false, הקישורים במייל נשלחים כמו שהם — בלי עטיפה בדומיין המעקב (ולכן גם בלי אירועי clicked להודעה הזו). track_opens עוד לא נתמך — שליחתו מחזירה 422 not_supported.
שמות שדות: מתקבלים גם ב-snake_case וגם ב-camelCase: reply_to / replyTo, track_clicks / trackClicks, content_type / contentType, content_id / contentId. שדה לא מוכר (למשל cc, bcc, שעוד לא נתמכים) מחזיר 422 unknown_fields עם רשימת השדות — הוא לא מתעלם בשקט.
marketing: כל מייל שיווקי מקבל אוטומטית את הכותרות List-Unsubscribe ו-List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058). ביטול הרשמה מכניס את הנמען לרשימת החסימה ושולח webhook בשם unsubscribed.
headers: שם הכותרת חייב להתחיל ב-X- (אותיות, ספרות ומקף, עד 64 תווים). כותרות סטנדרטיות (From, To, Subject, Reply-To, List-Unsubscribe וכו') נקבעות על ידינו ואי אפשר לדרוס אותן. כמה קידומות X- שמורות למערכת — שם כזה יוחזר עם 400 invalid_headers והסבר. ערך: מחרוזת עד 998 תווים, בלי ירידות שורה.
צורה 1 — אותו מייל לכמה נמענים (אובייקט): כמו /v1/emails, עם to של עד 100 כתובות. התשובה: { "queued": [{ "id", "recipient" }], "suppressed": [...], "invalid": [...] } — כתובות לא תקינות (למשל בלי @ או בלי דומיין) לא נשלחות ומוחזרות ב-invalid, והשאר נשלחים. אותו דבר בקמפיין.
צורה 2 — עד 100 הודעות שונות (מערך): כל פריט הוא הודעה מלאה עם נמען אחד — from, to, subject, html/text, reply_to, headers, attachments, kind, track_clicks, tags, ו-idempotency_key אופציונלי לכל הודעה.
POST /v1/emails/batch // ברירת מחדל: mode=partial
Idempotency-Key: 6f1c... // אחד לכל הבקשה (חובה)
[
{ "from": "Acme <hi@yourdomain.co.il>", "to": "dana@example.com",
"subject": "החשבונית שלך", "html": "<p>שלום דנה</p>",
"idempotency_key": "invoice-1001", // אופציונלי — מונע שליחה כפולה של ההודעה הזו גם בבקשה אחרת
"tags": { "invoice": "1001" },
"attachments": [{ "filename": "terms.pdf", "url": "https://files.example.com/terms.pdf" }] },
{ "from": "Acme <hi@yourdomain.co.il>", "to": "yossi@example.com",
"subject": "החשבונית שלך", "text": "שלום יוסי", "idempotency_key": "invoice-1002" },
{ "from": "Acme <hi@unknown-domain.com>", "to": "rina@example.com",
"subject": "החשבונית שלך", "text": "שלום רינה" }
]
202 Accepted
{ "data": [
{ "index": 0, "id": "uuid", "recipient": "dana@example.com" },
{ "index": 1, "id": "uuid", "recipient": "yossi@example.com", "deduplicated": true },
{ "index": 2, "recipient": "rina@example.com",
"error": { "code": "unverified_from_domain", "message": "..." } } ],
"summary": { "queued": 1, "deduplicated": 1, "failed": 1 } }idempotency_key באותה בקשה — מקבלים error, וכל השאר נשלחים. 202 אם התקבלה לפחות הודעה אחת; 422 no_items_accepted (עם data) אם אף אחת.?mode=atomic): הכל או כלום — פריט פסול אחד דוחה את כל הבקשה ושום הודעה לא נשמרת: 422 { "error": { "code": "invalid_batch_item", "index": 3, "item_error": { "code", "message" } } }. נמען ברשימת החסימה אינו "פסול" גם כאן — הוא מסומן ב-error.code: "suppressed" והשאר נשלחים.Idempotency-Key אחר — מוחזרת עם ה-id המקורי ו-deduplicated: true, ולא נשלחת שוב. כך אפשר לשלוח מחדש מקבץ שנכשל חלקית בלי לשלוח פעמיים את מה שכבר יצא.Idempotency-Key של הבקשה: בקשה חוזרת עם אותו מפתח מחזירה את אותה תשובה בדיוק (כולל השגיאות).| recipient | כתובת המייל (מנורמלת לאותיות קטנות) |
| reason | hard_bounce | complaint | manual | unsubscribe |
| source | מאיפה הגיעה החסימה (למשל "API ציבורי") |
| created_at | מתי נוספה (ISO 8601) |
GET /v1/suppressions?limit=100&offset=0
200 { "data": [{ "id": "uuid", "recipient": "dana@example.com", "reason": "unsubscribe",
"source": "...", "created_at": "2026-10-04T12:00:00.000Z" }],
"total": 1, "limit": 100, "offset": 0 }
POST /v1/suppressions
{ "recipient": "dana@example.com", "reason": "manual" } // reason אופציונלי, ברירת מחדל manual
201 { "id": "uuid", "recipient": "dana@example.com", "reason": "manual", ... }
200 { "recipient": "dana@example.com", "already_suppressed": true } // כבר ברשימה
POST /v1/suppressions/import // ייבוא מרוכז, עד 10,000 כתובות
{ "recipients": ["a@example.com", "b@example.com"], "reason": "manual" }
200 { "added": 2, "already_suppressed": 0, "invalid": [] } // לא תקינות לא עוצרות את הייבוא
DELETE /v1/suppressions/{id או כתובת} // מפתח full בלבד
204 (נמחק) | 404 not_foundכתובות נכנסות לרשימה גם אוטומטית: החזרה קבועה (hard_bounce), תלונת ספאם (complaint) וביטול הרשמה (unsubscribe).
200 { "sent": 120, "delivered": 118, "bounced": 1, "complained": 0,
"opened": 95, "clicked": 30,
"unique_opens": 61, "unique_clicks": 22,
"human_opens": 52, "unique_human_opens": 34 }opened ו-clicked סופרים אירועים, לא נמענים: נמען שפתח מייל שלוש פעמים נספר שלוש פעמים. unique_opens ו-unique_clicks סופרים הודעות שנפתחו / שנלחץ בהן לפחות פעם אחת.
המונים כוללים גם אירועים מעל 90 יום, שנשמרים כסיכום יומי (ראו שמירת נתונים). בחלק המסוכם, הודעה שנפתחה בשני ימים שונים נספרת ב-unique_opens פעמיים.
human_opens / unique_human_opens — אותו דבר בלי פתיחות שסומנו אוטומטיות (ראו פתיחות אוטומטיות). זו הערכה; opened נשאר הסך הכולל.
חלק מאירועי opened הם לא אדם שפתח את המייל, אלא מכונה שטענה את התמונות. בול מסמן כל פתיחה כזו — ב-webhook וב-GET /v1/events:
{ "event": "opened", ..., "machine_open": true, "machine_open_reason": "apple_mpp" }| apple_mpp | Apple Mail Privacy Protection — Apple טוענת מראש את התמונות דרך השרתים שלה, בלי קשר לפתיחה. מזוהה לפי IP של Apple (17.0.0.0/8) או User-Agent Mozilla/5.0 בלי שום פרט נוסף |
| google_prefetch | הפרוקסי של Gmail (GoogleImageProxy) טען את התמונה פחות מדקה אחרי המסירה. אחרי יותר מדקה — נחשב פתיחה אמיתית (Gmail טוען דרך הפרוקסי גם כשאדם פותח) |
| scanner | User-Agent של סורק אבטחה או כלי אוטומטי (למשל Mimecast, Barracuda, Proofpoint, curl, python-requests), או בלי User-Agent |
| fast_open | פתיחה פחות מ-10 שניות אחרי המסירה — בדרך כלל סורק בשער הדואר של הארגון |
בעלי ה-IP: ב-fast_open וב-scanner נוסף אחרי נקודתיים מי מחזיק בכתובת ה-IP שטענה את הפיקסל, כשהוא מזוהה (לפי ASN): fast_open:google, fast_open:netfree, fast_open:israeli_filter, fast_open:israeli_isp, scanner:security_vendor, fast_open:cloud, microsoft, yahoo. החלק שלפני הנקודתיים הוא תמיד הכלל. הזיהוי לפי ASN (נשלחת רק כתובת ה-IP — ראו מדיניות הפרטיות).
מגבלות: זו הערכה, לא עובדה. (1) כל משתמשי Apple Mail עם ההגנה נראים "אוטומטיים" — גם אם בנוסף פתחו בפועל; הפתיחה האמיתית שלהם עוברת דרך אותו פרוקסי ולא ניתנת להבחנה. (2) Gmail שומר את התמונה אחרי הטעינה הראשונה, כך שפתיחות חוזרות ב-Gmail לא נספרות בכלל. (3) סורק שמתחזה לדפדפן רגיל ומחכה יותר מ-10 שניות ייחשב אנושי. (4) זמן המסירה נלקח מאירוע delivered; כשהוא עוד לא הגיע, כללי הזמן לא חלים. (5) פתיחות מלפני 2026-10-06 סווגו בדיעבד לפי User-Agent וזמן בלבד. האירוע עצמו נשלח תמיד — machine_open רק מסמן אותו. לקהל עם הרבה משתמשי iPhone/Mac, clicked הוא מדד אמין יותר.
POST /v1/campaigns מקבל עד 10,000 נמענים. לקמפיין גדול יותר — יוצרים אותו עם הקבוצה הראשונה, ומוסיפים את השאר בבקשות של עד 10,000:
POST /v1/campaigns/{id}/recipients
Idempotency-Key: <מפתח ייחודי לכל בקשה>
{ "to": ["dana@example.com", "..."] }
202 { "id": "...", "status": "sending", "added": 9998, "duplicates": ["avi@example.com"],
"suppressed": ["x@example.com"], "invalid": [], "total": 19999 }התוכן (שולח, נושא, HTML, טקסט, קבצים, Reply-To, כותרות, tags) — של הקמפיין; בבקשה הזו רק to. בלי כפילויות: נמען שכבר בקמפיין (מבקשה קודמת או פעמיים באותה בקשה) לא מקבל שוב ומופיע ב-duplicates — גם כששתי בקשות רצות במקביל. Idempotency-Key לכל בקשה: ניסיון חוזר עם אותו מפתח מחזיר את אותה תשובה ולא מוסיף שוב. קמפיין שסיים לשלוח חוזר ל-sending; halted → 409 campaign_halted; תוכן שנמחק (30 יום) → 409 campaign_content_unavailable.
התור של בול מחלק את הקיבולת בין לקוחות ובין קמפיינים לסירוגין, לא לפי סדר ההגעה — קמפיין גדול של לקוח אחד לא מעכב אחרים. החלוקה לפי היקף העבודה: גם הגודל של כל הודעה, לא רק מספר ההודעות. הודעות בודדות (POST /v1/emails, טרנזקציוני) לא מחכות לקמפיינים.
priority ב-POST /v1/campaigns: "normal" (ברירת מחדל) או "low" — קמפיין בעדיפות נמוכה מקבל חלק קטן יותר מהקיבולת של החשבון, אבל תמיד מתקדם. GET /v1/campaigns מחזיר את הקמפיינים האחרונים (?limit= עד 100, ?status=).
"eta": {
"state": "sending", // sending | paused | done
"remaining_messages": 8400, "remaining_bytes": 512000000,
"rate_per_minute": 410,
"estimated_completion_at": "2026-10-08T09:40:00Z",
"earliest_completion_at": "2026-10-08T09:31:00Z",
"latest_completion_at": "2026-10-08T09:55:00Z",
"confidence": "medium", // high | medium | low
"basis": "observed", // observed (קצב בפועל) | fair_share (תחילת קמפיין)
"reason": null, // campaign_paused | campaign_halted | account_paused | recipient_provider_slowdown | waiting_for_capacity
"updated_at": "2026-10-08T09:12:03Z"
}ETA הוא הערכה שמתעדכנת, לא הבטחת זמן: זמן השליחה מבול, לא זמן ההגעה לתיבה. הוא מחושב מהכמות שנשארה, מהגודל שלה, מהקצב בפועל ומהאטות אצל ספקי הדואר של הנמענים. כשאין מספיק נתונים — הטווח רחב יותר.
קבצים מצורפים גדלים בכשליש בקידוד (base64): קובץ של 25MB הוא כ-36MB בהודעה. כשההודעה מעל כ-25MB אחרי הקידוד, התשובה ל-/v1/emails, /v1/emails/batch, /v1/campaigns ו-/v1/campaigns/{id}/recipients כוללת אזהרה. Gmail ו-Outlook.com קיבלו עד כ-36MB אחרי קידוד בבדיקות שלנו; שאר הספקים (Yahoo, ארגונים ב-Microsoft 365, ספקים ישראליים ושרתים אחרים) — כ-25MB לפי מה שהם מפרסמים. לא חוסם — ההודעה נשלחת כרגיל.
"warnings": [ { "code": "attachment_delivery_risk", "message": "...",
"encoded_bytes": 29360128, "recipients_at_risk": 120, "recipients_total": 4000,
"limits": { "measured_providers_bytes": 37748736, "other_providers_bytes": 26214400 } } ]recipients_at_risk — כמה נמענים בבקשה אצל ספקים שעלולים לדחות הודעה בגודל הזה (מעל כ-36MB — כולם). לקבצים כבדים לרשימה גדולה — עדיף קישור להורדה.
Gmail קוטע הודעה שה-HTML שלה מעל כ-102KB: מציג "[ההודעה נקטעה]", ומה שבסוף — בדרך כלל הפוטר וקישור ההסרה — לא מוצג בלי לחיצה. כשה-HTML מעל 102,000 בייט (אחרי עטיפת הקישורים למעקב), התשובה ל-POST /v1/emails, ל-/v1/emails/batch (לכל פריט) ול-/v1/campaigns כוללת אזהרה. ההודעה נשלחת כרגיל — זו לא חסימה.
202 { "id": "...", "recipient": "dana@example.com",
"warnings": [ { "code": "gmail_clipping", "message": "...", "html_bytes": 118342, "limit_bytes": 102000 } ] }פיקסל הפתיחה של בול ממוקם בתחילת ה-<body>, כך שגם הודעה שנקטעה נספרת כשהיא נפתחת. הוא מוגש בלי מטמון (Cache-Control: no-store, no-cache, max-age=0, Expires: 0), כך שפתיחה אמיתית אחרי טעינה מוקדמת נספרת שוב. כדי לקצר: להסיר CSS ותגובות שלא בשימוש, ולהקטין טבלאות מקוננות. עברית היא 2 בייט לאות.
"attachments": [
{ "filename": "invoice.pdf", "content_type": "application/pdf",
"content": "<base64>" }, // תוכן ב-base64
{ "filename": "report.pdf",
"url": "https://files.example.com/report.pdf" }, // או: נוריד אותו בעצמנו (https בלבד)
{ "filename": "logo.png", "content_type": "image/png",
"content": "<base64>", "content_id": "logo@acme" } // תמונה בגוף ההודעה: <img src="cid:logo@acme">
]עד 10 קבצים ועד 25MB יחד להודעה (קבצים אמיתיים, לא קישורים). בכל קובץ: content או url, לא שניהם. content_type חובה עם content. ב-url הוא נלקח מהשרת אם לא נשלח. בקמפיין הקבצים נשמרים אצלנו פעם אחת ומשותפים לכל הנמענים. הצורה הישנה "attachment": { ... } (קובץ אחד) עדיין נתמכת.
כל שגיאה מוחזרת באותו מבנה: { "error": { "code": "...", "message": "..." } }. ההודעה בעברית; הקוד קבוע — כדאי להסתמך עליו.
| 400 | invalid_json | הגוף אינו אובייקט JSON תקין |
| 400 | missing_idempotency_key | חסרה הכותרת Idempotency-Key |
| 400 | invalid_to / too_many_recipients | to אינו מערך כתובות, כתובת לא תקינה (ב-/v1/emails ובפריט במקבץ), או יותר מהמותר |
| 400 | invalid_from | from אינו כתובת תקינה (כולל ירידות שורה) |
| 400 | unverified_from_domain | הדומיין של from לא מאומת בחשבון |
| 400 | invalid_subject / invalid_html / invalid_kind | שדה חובה חסר או בערך לא חוקי |
| 400 | invalid_reply_to / invalid_headers | reply_to או headers לא תקינים |
| 400 | invalid_text / invalid_track_clicks | text אינו מחרוזת, או track_clicks אינו true/false |
| 400 | too_many_items / invalid_batch / invalid_mode | מקבץ ריק, מעל 100 הודעות, או mode שאינו partial/atomic |
| 400 | invalid_tags / invalid_idempotency_key | tags לא תקינים, או idempotency_key של פריט לא תקין |
| 400 | invalid_limit / invalid_cursor / invalid_since / invalid_until / invalid_status / invalid_type | פרמטר שאילתה לא תקין ברשימות (/v1/emails, /v1/events) |
| 400 | invalid_attachment / too_many_attachments / invalid_attachment_url / attachment_fetch_failed | בעיה בקבצים המצורפים |
| 401 | unauthorized | מפתח חסר, שגוי או מבוטל |
| 403 | forbidden | סוג המפתח לא מורשה. התשובה כוללת scope ו-required_scopes |
| 404 | not_found | המשאב לא קיים (או לא שייך לחשבון) |
| 405 | method_not_allowed | שיטה לא נתמכת בנתיב קיים. הכותרת Allow מפרטת מה נתמך |
| 409 | already_processing | בקשה עם אותו Idempotency-Key עדיין בעיבוד |
| 409 | route_paused | השליחה דרך Bul Delivery Network עצורה לחשבון; שום הודעה מהבקשה לא נקלטה — safe_to_resend: true |
| 429 | plan_daily_limit / plan_monthly_limit | החבילה החינמית: המכסה היומית / החודשית הושגה (limit, used, requested, resets_at) |
| 429 | tier_daily_limit | חשבון חדש: מכסת 24 שעות הושגה (limit, used). המכסה גדלה אוטומטית כשהשליחה נקייה |
| 422 | tier_recipients_limit | חשבון חדש: יותר נמענים מהמותר בבקשה אחת (limit) |
| 422 | tier_attachments_not_allowed / tier_attachment_too_large | חשבון חדש: קבצים מצורפים עוד לא זמינים, או גדולים מהמותר |
| 422 | content_rejected | חשבון חדש: הבקשה נדחתה בבדיקת התוכן והרשימה (score, findings) — שום הודעה לא נשלחה |
| 403 | tier_domain_limit | חשבון חדש: הגעתם למספר דומייני השליחה המותר בשלב הזה |
| 413 | attachment_too_large | הקבצים גדולים מ-25MB יחד להודעה |
| 422 | unknown_fields | שדות שלא מוכרים לנו. התשובה כוללת fields ו-allowed |
| 422 | unknown_parameters | פרמטר שאילתה לא מוכר בבקשת GET. התשובה כוללת parameters ו-allowed |
| 422 | conflicting_fields | אותו שדה נשלח ב-snake_case וב-camelCase עם ערכים שונים |
| 422 | suppressed | הנמען ברשימת החסימה (ב-/v1/emails) |
| 422 | invalid_batch_item | רק ב-mode=atomic: פריט במקבץ לא תקין — כולל index ו-item_error; שום הודעה לא נשמרה |
| 422 | no_items_accepted | מקבץ (partial) שבו אף הודעה לא התקבלה — הסיבה לכל פריט ב-data |
| 422 | not_supported | שדה שעוד לא נתמך (track_opens) |
| 423 | sending_blocked | השליחה בחשבון חסומה זמנית (מוניטין) |
| 409 | conflicting_outcome | POST /v1/recipients/proof: ל-return_ref כבר נרשמה תוצאה אחרת (current_outcome); הדיווח נרשם ונדחה |
| 423 | account_frozen | החשבון מוקפא זמנית: המפתח תקין וקריאה (GET) עובדת, אבל כל שליחה וכל פעולת כתיבה נדחות עד שחרור |
| 429 | rate_limited | יותר מדי בקשות. ראו Retry-After |
| 500 | internal_error | תקלה אצלנו. אפשר לנסות שוב עם אותו Idempotency-Key |
אירועים: delivered, opened, clicked, bounced (החזרה קבועה), deferred (החזרה זמנית), complained, unsubscribed, failed, withdrawn (הודעה שהוחזרה אליכם לפני ניסיון מסירה — ראו עצירת מסלול), stuck (הודעה שנתקעה בשליחה). queued ו-sent מתקבלים בהרשמה, אבל כרגע לא נשלחים.
אירועי חשבון (לא של הודעה בודדת, ולכן בלי message_id): reputation.warning — שיעור ההחזרות או התלונות עבר את סף האזהרה, השליחה ממשיכה; reputation.paused — השליחה נעצרה אוטומטית; compliance.unsubscribe_missing — נראה שנשלח דיוור בלי אפשרות הסרה. צריך להירשם אליהם במפורש. פרטים במוניטין ועצירה אוטומטית ובדיוור בלי הסרה.
ניהול endpoints (יצירה ומחיקה — מפתח full; רשימה ושליפה — כל מפתח). אותם שדות בכל התשובות; signing_secret מוחזר רק ביצירה, פעם אחת.
| id | מזהה ה-endpoint |
| url | כתובת https שאליה נשלח |
| events | האירועים שנרשמתם אליהם |
| active | true / false |
| created_at | ISO 8601 |
| signing_secret | רק בתשובת היצירה — לשמור אצלכם |
POST /v1/webhooks
{ "url": "https://example.com/bul-webhook", "events": ["delivered", "bounced"] }
201 { "id": "uuid", "url": "...", "events": ["delivered", "bounced"], "active": true,
"created_at": "...", "signing_secret": "whsec_..." }
// לתאימות לאחור מוחזרים גם subscribedEvents, signingSecret, createdAt — אותו ערך, שם ישן
GET /v1/webhooks 200 { "data": [{ "id", "url", "events", "active", "created_at" }] }
GET /v1/webhooks/{id} 200 { "id", "url", "events", "active", "created_at" }
DELETE /v1/webhooks/{id} 204POST <your-url>
Content-Type: application/json
Bul-Event: bounced
Bul-Event-Id: 6167e99d-... // קבוע בין ניסיונות חוזרים — לזיהוי כפילויות
Bul-Timestamp: 1790000000 // שניות מאז 1970
Bul-Signature: <hex>
{ "event": "bounced", "event_id": "uuid", "message_id": "...", "recipient": "dana@example.com",
"campaign_id": null, "timestamp": "2026-10-04T12:00:00.000Z", "tags": { "invoice": "1001" },
"bounce_type": "hard", "bounce_sub_type": "General",
"smtp_status": "5.1.1", "smtp_diagnostic": "smtp; 550 5.1.1 user unknown" }מבנה האירוע לכל סוג: כל אירוע של הודעה כולל תמיד את השדות המשותפים, ועוד שדות לפי הסוג.
| משותף לכולם | event, event_id, message_id, recipient, campaign_id, timestamp, tags |
| delivered · complained · unsubscribed | רק השדות המשותפים |
| opened | + machine_open (true — נראית אוטומטית), machine_open_reason — ראו פתיחות אוטומטיות |
| clicked | + clicked_url — הקישור שנלחץ |
| failed | + reason — { code, description } (ראו סיבות כישלון), safe_to_resend; ו-error (טקסט, לתאימות) כשהכישלון שלנו |
| withdrawn | + reason (route_paused, recipient_not_proven או direct_unavailable), attempted (false — לא נוסתה; true — נוסתה ונדחתה במפורש), safe_to_resend: true |
| bounced · deferred · failed | + bounce_type (hard | soft), bounce_sub_type, smtp_status, smtp_diagnostic — הקוד והתשובה של שרת הדואר של הנמען. bounced רק לכתובת שלא קיימת (5.1.x / NoEmail) — רק אז הנמען נחסם; דחייה קבועה אחרת (מדיניות 5.7.x, רשת 5.4.x) היא failed |
| stuck | השדות המשותפים; event_id נגזר באופן יציב מההודעה (אירוע תפעולי שלנו, לא ב-/v1/events) |
| test | אירוע בדיקה: "test": true, message_id: null — ראו למטה |
| reputation.* | אירוע חשבון — בלי message_id; ראו "מוניטין" |
timestamp הוא זמן האירוע. tags הוא תמיד אובייקט ({} כשלא נשלחו).
סינון כפילויות — לפי event_id. יש שני מזהים:
event_id (בגוף) — מזהה האירוע. זהה בכל מסירה של אותו אירוע, וזהה ל-id שמחזיר GET /v1/events. קיים בכל סוגי האירועים: לאירוע stuck הוא נגזר באופן יציב מההודעה (ולא מופיע ב-/v1/events, כי זה אירוע תפעולי שלנו); לאירועי reputation.* ולאירוע בדיקה — מזהה חדש לכל התראה.Bul-Event-Id (בכותרת) — מזהה המסירה. קבוע בין הניסיונות החוזרים, וגם ב"שלח שוב" מהדשבורד (זו אותה מסירה). אבל אם אותו אירוע נמסר בדרך אחרת (למשל שחזור שלנו אחרי תקלה) — הוא יגיע עם Bul-Event-Id אחר ואותו event_id.ההמלצה: לשמור event_id שכבר טופל ולהתעלם מכפילות. כך מכוסים גם ניסיונות חוזרים, גם "שלח שוב", וגם אירוע שמגיע פעם ב-webhook ופעם דרך /v1/events.
אירוע בדיקה: POST /v1/webhooks/{id}/test (מפתח full), או הכפתור "שלח אירוע בדיקה" בדשבורד. נשלח מיד, חתום וכותרות כמו אירוע אמיתי, עם Bul-Event: test ובגוף "test": true. בלי ניסיונות חוזרים — התשובה מחזירה את התוצאה:
POST /v1/webhooks/{id}/test
200 { "delivery_id": "uuid", "event_id": "uuid", "delivered": true,
"response_code": 200, "duration_ms": 184, "error": null }
// מה שהשרת שלכם מקבל:
Bul-Event: test
{ "event": "test", "test": true, "event_id": "uuid", "message_id": null, "recipient": null,
"campaign_id": null, "timestamp": "2026-10-05T12:00:00.000Z", "tags": {}, "message": "אירוע בדיקה מבול — אפשר להתעלם..." }אירוע בדיקה לבחירה (2026-10-06): גוף אופציונלי — סוג אירוע ותוכן, כדי לבדוק את הכללים שלכם (חסימה, שליחה חוזרת) בלי החזרות אמיתיות. ה-payload באותו מבנה כמו אירוע אמיתי, עם "test": true, והכותרת Bul-Event היא הסוג שבחרתם. שדות: event (test, delivered, opened, clicked, bounced, deferred, complained, unsubscribed, failed, withdrawn, stuck, reputation.warning, reputation.paused, compliance.unsubscribe_missing), smtp_status, smtp_diagnostic, bounce_sub_type, reason_code (ל-failed/withdrawn), attempted (ל-withdrawn), recipient, message_id, tags.
POST /v1/webhooks/{id}/test { "event": "bounced", "smtp_status": "5.1.1" }
POST /v1/webhooks/{id}/test { "event": "bounced", "smtp_status": "5.7.1" } // לבדיקת כללים; אצלנו בפועל 5.7.x מגיע כ-failed
POST /v1/webhooks/{id}/test { "event": "failed", "reason_code": "rejected_policy" }
POST /v1/webhooks/{id}/test { "event": "withdrawn", "attempted": false }חתימה: Bul-Signature = HMAC-SHA256 (hex) של `${Bul-Timestamp}.${body}`, עם הסוד שקיבלתם ביצירת ה-webhook. לאמת על הגוף הגולמי (לפני JSON.parse), להשוות בזמן קבוע, ולדחות timestamp ישן מ-5 דקות.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, headers, secret) {
const ts = headers["bul-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
const got = headers["bul-signature"] ?? "";
return got.length === expected.length && timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}ניסיונות חוזרים: תשובה שאינה 2xx (או זמן תגובה מעל 5 שניות) — עד 8 ניסיונות לאורך כ-22.5 שעות: מיד, ואחר כך אחרי דקה, 5 דקות, 30 דקות, שעה, 3 שעות, 6 שעות ו-12 שעות (כל מרווח נמדד מהניסיון הקודם). שרת שנפל לכמה שעות יקבל את האירועים כשיחזור. Bul-Event-Id זהה בכל הניסיונות — השתמשו בו לסינון כפילויות; Bul-Timestamp והחתימה מחושבים מחדש בכל ניסיון.
קצב: עד 10 בקשות בו-זמנית לכל כתובת יעד. תשובת 429 משהה את המסירה לכתובת הזו לפי Retry-After (שניות או תאריך; בלעדיו — 30 שניות), בלי לספור את הניסיון. גם ב-503 עם Retry-After הניסיון הבא נקבע לפיו.
אם השרת שלכם היה למטה מעבר ללוח הניסיונות, או שאתם רוצים לוודא שלא פספסתם כלום — שולפים את האירועים לפי סדר קבלתם אצלנו. כל אירוע באותו מבנה כמו ב-webhook, ו-id שלו = event_id שב-webhook.
GET /v1/events?since=2026-10-04T00:00:00Z&limit=100 // since: ברירת מחדל 24 שעות אחורה
GET /v1/events?cursor=<next_cursor> // העמוד הבא
GET /v1/events?type=bounced,complained // סינון לפי סוג
200 { "data": [
{ "id": "uuid", "event": "clicked", "message_id": "uuid", "recipient": "dana@example.com",
"campaign_id": null, "timestamp": "2026-10-04T12:00:00.000Z",
"received_at": "2026-10-04T12:00:00.642Z", "clicked_url": "https://...", "tags": {} } ],
"has_more": true, "next_cursor": "eyJ..." }limit עד 500 (ברירת מחדל 100). ממשיכים עם next_cursor כל עוד has_more. שמרו את ה-next_cursor האחרון — בפעם הבאה אפשר להמשיך ממנו, בלי חפיפה ובלי לפספס.
GET /v1/emails/{id} // ?include=html — גם html ו-text
200 { "id": "uuid", "recipient": "dana@example.com", "from": "Acme <hi@yourdomain.co.il>",
"subject": "...", "status": "delivered", "attempts": 1, "campaign_id": null,
"last_error": null, "created_at": "2026-10-04T12:00:00.000Z",
"reply_to": [], "headers": {}, "track_clicks": true, "tags": { "invoice": "1001" },
"attachments": [{ "filename": "terms.pdf", "content_type": "application/pdf", "size": 52311 }],
"content_purged_at": null,
"bounce": { "event": "bounced", "bounce_type": "hard", "bounce_sub_type": "NoEmail",
"smtp_status": "5.1.1", "smtp_diagnostic": "550 The email account ... does not exist", "timestamp": "..." } }bounce — ההחזרה/הדחייה האחרונה עם פרטי SMTP (null כשאין).
content_purged_at — מתי נמחק התוכן לפי מדיניות השמירה (null = התוכן עדיין שמור). אחרי המחיקה html ו-text חוזרים null, ושאר השדות נשארים.
שמות ישנים (deprecated): לתאימות לאחור מוחזרים גם fromAddress, campaignId, lastError, createdAt, replyTo, trackClicks, ובקבצים contentType/contentId — אותו ערך, שם ישן. הכותרת Bul-Deprecated-Fields מפרטת אותם. הם יוסרו בעתיד — השתמשו בשמות ה-snake_case. אותו כלל ב-GET /v1/campaigns/{id} (sender, haltReason, createdAt), ב-GET /v1/domains/{id} (row, verification) וב-POST /v1/webhooks.
חיבור Cloudflare. בדשבורד ← דומיינים ← "התחבר לקלאודפלייר". בול מבקשת רק קריאת zones וקריאה ועריכה של רשומות DNS (zone.read dns.read dns.write) — בלי גישה להגדרות אחרות, לחיוב או למשתמשים. בול מוסיפה רק את הרשומות שבכרטיס הדומיין, תמיד DNS only, ולא דורסת רשומות קיימות. אפשר לחבר כמה חשבונות, ולבחור לכל דומיין חשבון ו-zone. ניתוק בדשבורד מבטל את ההרשאה גם אצל Cloudflare.
הוספה אוטומטית (Domain Connect). אם ספק ה-DNS שלכם תומך בתקן Domain Connect ומכיר את התבנית של בול, הכפתור בכרטיס הדומיין שולח אתכם לאישור אצל הספק, והרשומות נוספות בלי העתקה. אחרת — העתקה ידנית או חיבור Cloudflare.
דומיין מעקב ממותג (אופציונלי). ברירת המחדל לכל החשבונות היא bulclick.friman.app, ולא צריך לעשות כלום. אפשר להעביר את הקישורים דרך שם משלכם, למשל click.example.co.il: בוחרים שם בכרטיס הדומיין (בול מציעה שם פנוי ולא נוגעת בשם תפוס, למשל links של ספק אחר), ומוסיפים CNAME אל bulclick.friman.app (DNS only). כשהסטטוס "פעיל" — הקישורים והפיקסל עוברים דרכו. כרגע חל על מיילים שיוצאים דרך Bul Delivery Network. מומלץ לקהל מסונן (למשל לקוחות NetFree): דומיין משלכם שאושר בסינון לא תלוי באישור של דומיין משותף. את הבקשה לאשר את הדומיין הממותג מגישים בעצמכם; bulclick.friman.app, bultrack.friman.app ו-bul.friman.app — בול מבקשת לאשר.
חלק מהדואר יוצא דרך Bul Delivery Network. אם ספק דואר (למשל Gmail) מאט או חוסם אותו, השליחה דרכו נעצרת לחשבון שלכם אוטומטית, עד שבול מאשרים חידוש. מה שקורה בזמן העצירה (לפי הגדרת החשבון; ברירת המחדל — החזרה אליכם):
withdrawn, ובטוח לשלוח אותה שוב (גם דרך ספק אחר). attempted: false — לא נוסתה בכלל; attempted: true — נוסתה וכל ניסיון נדחה במפורש (למשל 421). בלי פקיעה ובלי מעבר שקט לספק אחר.409 route_paused, וכל הבקשה לא נקלטה (גם מקבץ וקמפיין). הודעות שיוצאות ממילא דרך ספק אחר — לא נחסמות.failed עם ambiguous_delivery ו-safe_to_resend: false.// webhook
{ "event": "withdrawn", "event_id": "uuid", "message_id": "uuid", "recipient": "dana@gmail.com",
"campaign_id": null, "timestamp": "2026-10-06T12:00:00.000Z", "tags": {},
"reason": { "code": "route_paused", "description": "השליחה דרך Bul Delivery Network נעצרה..." },
"attempted": false, "safe_to_resend": true } // attempted: true — נוסתה ונדחתה במפורש
// POST /v1/emails בזמן עצירה
409 { "error": { "code": "route_paused", "message": "...", "safe_to_resend": true } }נמען חדש (recipient_not_proven): המסירות הראשונות לנמען שעוד לא קיבל מכם דואר דרך Bul Delivery Network עוברות במסלול בדיקה. אם בחשבון שלכם המסלול הוא החזרה אליכם — ההודעה חוזרת כ-withdrawn עם reason.code: recipient_not_proven ו-safe_to_resend: true, ובקשה שכל הנמענים בה (עד 50) חדשים נדחית ב-409 route_paused עם reason_code: recipient_not_proven ורשימת recipients. שלחו אותה בספק שלכם. אחרי כמה מסירות נקיות הנמען מוכח וכבר לא חוזר.
חשבון בלי ספק חלופי (direct_unavailable): בחשבון שמוגדר כך, כל הודעה שלא יכולה לצאת דרך Bul Delivery Network (למשל נמען שאינו אצל ספק ישיר, או מעבר לתקרה היומית) חוזרת כ-withdrawn עם safe_to_resend: true והסיבה ב-detail; בקשה שכל ההודעות בה כאלה — 409 route_paused עם reason_code: direct_unavailable.
רוב הקוד נשאר: JSON דומה, Authorization: Bearer, ו-202 עם id. ההבדלים:
https://bul.friman.app/v1/emails במקום https://api.resend.com/emails.Idempotency-Key — חובה ב-POST /v1/emails, /batch, /campaigns (ב-Resend אופציונלי).tags — אובייקט { name: value } במקום מערך [{ name, value }]. reply_to — מערך, עד 5.cc, bcc, scheduled_at — עוד לא נתמכים (422 unknown_fields); נמען נפרד לכל אחד ב-/v1/emails/batch.POST /v1/emails/batch, עם idempotency_key לכל הודעה.delivered, bounced... במקום email.delivered, email.bounced; חתימה ב-Bul-Signature (ראו Webhooks). שגיאה: { "error": { "code", "message" } }.לפני:
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
const { data, error } = await resend.emails.send({
from: "Acme <hi@acme.co.il>", to: ["dana@example.com"], subject: "שלום", html: "<p>ההזמנה התקבלה</p>",
tags: [{ name: "order_id", value: "1234" }],
});אחרי:
const res = await fetch("https://bul.friman.app/v1/emails", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.BUL_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "order-1234" },
body: JSON.stringify({
from: "Acme <hi@acme.co.il>", to: ["dana@example.com"], subject: "שלום", html: "<p>ההזמנה התקבלה</p>",
tags: { order_id: "1234" },
}),
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
const { id } = body;הרבה נמענים: עד 100 בבקשה ב-/v1/emails/batch, עד 10,000 ב-/v1/campaigns (ועוד 10,000 בכל בקשה ב-/v1/campaigns/{id}/recipients). לפני המעבר: הוסיפו את הדומיין בדשבורד ואמתו את רשומות ה-DNS.
ספרייה רשמית (TypeScript, Node 18+ / Bun / Deno / edge): כל נקודות הקצה, טיפוס לכל אירוע, אימות חתימת webhook, וניסיונות חוזרים בטוחים — שליחה מקבלת Idempotency-Key אחד לכל קריאה, שנשמר בכל ניסיון חוזר, כך שניסיון חוזר לא שולח פעמיים. 429 (לפי Retry-After), 5xx ושגיאות רשת — ניסיון חוזר; 409 route_paused — לא, אלא RoutePausedError עם returns.הספרייה עוד לא פורסמה ב-npm — זמינה לפי בקשה; השם הצפוי בפרסום: bul-email (בדוגמאות למטה).
import { Bul, RoutePausedError } from "bul-email";
const bul = new Bul({ apiKey: process.env.BUL_API_KEY! });
try {
const { id } = await bul.emails.send({
from: "Acme <hi@acme.co.il>", to: ["dana@example.com"],
subject: "שלום", html: "<p>ההזמנה התקבלה</p>", tags: { order_id: "1234" },
}, { idempotencyKey: "order-1234" }); // אופציונלי; בלי — UUID חדש לכל קריאה
} catch (e) {
if (e instanceof RoutePausedError) {
// שום הודעה לא נשלחה — שלחו בספק שלכם, ודווחו את התוצאה:
for (const r of e.returns) {
// ...send with your provider...
await bul.recipients.proof({ return_ref: r.return_ref, recipient: r.recipient, provider: "resend",
outcome: "delivered", occurred_at: new Date().toISOString() });
}
} else throw e;
}
// אירועים שהוחמצו
for await (const ev of bul.events.iterate({ since: "2026-10-01T00:00:00Z" })) {
if (ev.event === "failed" && !ev.safe_to_resend) continue; // ambiguous_delivery
}// webhook (Express / Next.js / Bun) — על הגוף הגולמי
import { constructWebhookEvent, WebhookVerificationError } from "bul-email";
const raw = await request.text();
try {
const event = await constructWebhookEvent({ rawBody: raw, headers: request.headers, secret: process.env.BUL_WEBHOOK_SECRET! });
switch (event.event) {
case "bounced": /* event.smtp_status */ break;
case "withdrawn": /* event.reason.code, event.return_ref */ break;
}
} catch (e) {
if (e instanceof WebhookVerificationError) return new Response("bad signature", { status: 400 });
throw e;
}שגיאות: BulError (status, code, details), ומתוכה AuthenticationError (401), PermissionError (403), RoutePausedError (409), AccountBlockedError (423), RateLimitError (429); ConnectionError — רשת / timeout אחרי כל הניסיונות.
כל הודעה שבול מחזירה אליכם — בתשובת 409 route_paused (גם כל פריט במקבץ) ובאירוע withdrawn — כוללת return_ref: מזהה ייחודי שבול שומרת יחד עם הנמען. ב-409 הוא ב-returns, פריט לכל נמען: { index, recipient, return_ref } (index — מיקום הפריט במקבץ, או הנמען בבקשה). ב-withdrawn — שדה return_ref באירוע. אחרי ששלחתם את ההודעה בספק שלכם — דווחו את התוצאה, וכך נמען חדש נהיה מוכח (אחרי 2 מסירות נקיות) ויוצא בהמשך דרך Bul Delivery Network.
POST /v1/recipients/proof // מפתח send או full; החשבון צריך אישור לייבוא היסטוריה
{ "return_ref": "rr_…", "recipient": "dana@gmail.com", "provider": "resend",
"outcome": "delivered", // delivered | bounced | complained
"occurred_at": "2026-10-06T12:00:00Z", "provider_message_id": "re_…" }
202 { "accepted": true, "return_ref": "rr_…", "outcome": "delivered", "duplicate": false }duplicate: true. bounced / complained אחרי delivered — גוברים (202 עם superseded: "delivered"; ה-delivered כבר לא נספר). delivered אחרי bounced / complained, או bounced מול complained — סותר: נרשם אצלנו ונדחה ב-409 conflicting_outcome (עם current_outcome).delivered — נספר כמסירה נקייה. bounced / complained — מבטל את ההוכחה של הנמען וחוסם אותו מהשרת של בול (ההודעות אליו ימשיכו לחזור אליכם).403 forbidden — מפתח read, או חשבון בלי אישור לייבוא היסטוריה. 422 invalid_return_ref — המזהה לא קיים בחשבון, או שאינו של הנמען הזה. 422 validation_error — שדה חסר או לא תקין (field).לכל אירוע failed (webhook ו-GET /v1/events) יש reason: { code, description } ו-safe_to_resend. הקודים:
| code | משמעות | בטוח לשלוח שוב? |
|---|---|---|
| invalid_recipient | כתובת הנמען לא תקינה | כן (אחרי תיקון הכתובת) |
| rejected_by_provider | ספק השליחה דחה את ההודעה עצמה (תוכן, כותרות, מבנה) | כן (אחרי תיקון) |
| rendering_failed | בניית ההודעה אצל הספק נכשלה | כן |
| retries_exhausted | השליחה נכשלה שוב ושוב ומוצו הניסיונות | כן |
| expired | לא נמסרה בזמן המותר ופקעה | כן |
| admin_bounced | הוסרה מהתור ידנית | כן |
| rejected_policy | השרת המקבל דחה ממדיניות/מוניטין (5.7.x) — לא בעיה בכתובת, הנמען לא נחסם | כן |
| network_failure | כשל רשת או ניתוב (5.4.x) — הנמען לא נחסם | כן |
| rejected_by_recipient | דחייה קבועה אחרת של השרת המקבל — הנמען לא נחסם | כן |
| ambiguous_delivery | תוצאת המסירה לא ברורה — ייתכן שיצאה | לא (סכנת כפילות) |
קוד שלא מופיע כאן — unknown. אחרי ambiguous_delivery ייתכן שיגיע מאוחר יותר גם delivered.
html, text, ותוכן קבצים שנשלחו מוטבעים) — נמחק 30 יום אחרי השליחה. ההודעה עצמה נשארת: נמען, נושא, סטטוס, זמנים, תגיות ורשימת הקבצים (שם וגודל). ב-GET /v1/emails/{id} מופיע content_purged_at. אותו כלל ל-HTML של קמפיין, כשאין בו הודעות שעוד ממתינות.GET /v1/events, ציר הזמן של הודעה) — נמחקים אחרי 90 יום. לפני המחיקה נשמר סיכום יומי לפי סוג אירוע, ו-GET /v1/stats ממשיך לספור אותם.Idempotency-Key — 30 יום, כמו קודם.צריכים את התוכן או את האירועים לאורך זמן? שמרו אותם אצלכם — דרך webhooks או GET /v1/events. המחיקה רצה אוטומטית פעם ביום. פרטים: מדיניות הפרטיות.
GET /v1/emails?recipient=dana@example.com
GET /v1/emails?status=bounced,failed&since=2026-10-01T00:00:00Z&until=2026-10-05T00:00:00Z
GET /v1/emails?campaign_id=uuid&limit=100&cursor=<next_cursor>
200 { "data": [
{ "id": "uuid", "recipient": "dana@example.com", "from": "Acme <hi@yourdomain.co.il>",
"subject": "...", "status": "delivered", "attempts": 1, "campaign_id": null,
"created_at": "2026-10-04T12:00:00.000Z", "last_error": null, "tags": {} } ],
"has_more": false, "next_cursor": null }מהחדשה לישנה. status: queued, sending, sent, delivered, opened, clicked, bounced, complained, unsubscribed, failed, deferred, withdrawn (אפשר כמה, מופרדים בפסיק). since/until — זמן יצירת ההודעה. limit עד 100 (ברירת מחדל 50).
GET /v1/usage
200 [ { "period_start": "2026-10-01T00:00:00.000Z", "period_end": "2026-11-01T00:00:00.000Z",
"messages": 9614, "cost_usd": 24.31, "estimated": true,
"tracked_messages": 9614, "untracked_messages": 0,
"message_bytes": 199412345678, "attachment_bytes": 145000000000 } ]
GET /v1/usage?group=campaign
200 [ { "campaign_id": "uuid", "name": "...", "messages": 9614, "message_bytes": ..., "attachment_bytes": ...,
"cost_usd": 24.31, "estimated": true } ]עלות משוערת (לא חשבונית): לכל הודעה, ועוד לפי נפח ההודעה כפי שנשלחה — כולל קבצים בקידוד base64. הודעות מלפני תחילת מעקב העלויות (untracked_messages) מוערכות לפי הודעה בלבד.
120 בקשות בדקה לכל מפתח. בכל תשובה: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (שניות עד איפוס). בחריגה: 429 עם Retry-After.
מגבלות נוספות: batch עד 100 נמענים · קמפיין עד 10,000 נמענים (יותר — לפצל) · קבצים: 10 / 25MB להודעה · tags: 10 · reply_to: 5 · headers: 20.
קצב היציאה בפועל: הבקשה חוזרת מיד (202), וההודעות יוצאות מהתור בקצב של כ-12 הודעות בשנייה לכל המערכת. קמפיין של 1,000 נמענים יוצא תוך כדקה וחצי, ושל 7,700 נמענים תוך כ-10 דקות. מיילים טרנזקציוניים (kind: "transactional", ברירת המחדל ב-/v1/emails) מקבלים עדיפות, ויוצאים מיד גם כשקמפיין גדול באמצע שליחה.
בול מודדת על חלון נע של 24 שעות את שיעור ההחזרות הקבועות (hard bounce) ואת שיעור התלונות (סימון כספאם), מתוך ההודעות שנמסרו או חזרו. הבדיקה רצה עם כל החזרה או תלונה, וגם כל 10 דקות, כך שאזהרה נוצרת ונעלמת גם בלי אירוע חדש.
| מדד | אזהרה | עצירה אוטומטית |
|---|---|---|
| החזרות (hard bounce) | 2% | 3% |
| תלונות | 0.04% (גם תלונה בודדת נחשבת) | 0.08%, וגם לפחות 2 תלונות בחלון |
מדד החזרות ועצירה מופעלים רק מ-100 הודעות בחלון; מתחת לזה שיעור הוא רעש סטטיסטי. תלונה בודדת מזהירה גם בנפח קטן, אבל אף פעם לא עוצרת לבד.
באזהרה השליחה ממשיכה, ונשלחים: webhook reputation.warning, באנר בדשבורד, ומייל לבעל החשבון. גם GET /v1/status מחזיר sending.reputation_warning: true. התראה חוזרת נשלחת לכל היותר פעם ב-6 שעות. בעצירה נשלח webhook reputation.paused ומייל; בקשות שליחה חדשות מקבלות 423 sending_blocked, והודעות שכבר בתור נשארות בתור ולא יוצאות. חידוש השליחה ידני, אחרי בדיקה.
POST <your-url>
Bul-Event: reputation.warning
{ "event": "reputation.warning", "timestamp": "2026-10-04T12:00:00.000Z",
"tenant_id": "...", "reasons": ["bounce_rate"], // bounce_rate | complaint_rate
"sending_status": "active", // active (warning) | paused (reputation.paused)
"window_hours": 24, "volume": 1200, "bounced": 26, "complained": 0,
"bounce_rate": 0.021667, "complaint_rate": 0,
"thresholds": { "bounce_warning": 0.02, "bounce_pause": 0.03,
"complaint_warning": 0.0004, "complaint_pause": 0.0008,
"complaint_pause_min_count": 2 } }Gmail ו-Yahoo דורשים מדיוור כותרת ביטול הרשמה בלחיצה אחת, ומעל 5,000 הודעות ביום ל-Gmail זו חובה. בול מוסיפה את הכותרת להודעות שיווקיות בלבד (kind: "marketing" וקמפיינים). הודעה שנשלחת כטרנזקציונית (ברירת המחדל ב-/v1/emails) יוצאת בלי הכותרת, וזה נכון לאיפוס סיסמה או קבלה — לא לדיוור.
מתי מתריעים: מקבץ או קמפיין של 20 נמענים ומעלה עם אותו תוכן, כטרנזקציוני ובלי קישור הסרה בגוף; אותה הודעה ל-200 נמענים ומעלה ב-24 שעות כטרנזקציונית ובלי קישור הסרה (אזהרה). מעל 5,000 הודעות ל-Gmail ב-24 שעות בלי כותרת הסרה — התראה חזקה. ההתראה מופיעה כבאנר בדשבורד, ב-GET /v1/status תחת compliance.unsubscribe, וב-webhook compliance.unsubscribe_missing (לכל היותר פעם ב-24 שעות לכל רמה). היא יורדת לבד אחרי 24 שעות בלי זיהוי חדש. השליחה עצמה לא נחסמת.
מה לעשות: לשלוח דיוור עם kind: "marketing", או להוסיף קישור הסרה לגוף. בהגדרות החשבון אפשר להפעיל "שיווקי כברירת מחדל" (הודעה בלי kind תיחשב דיוור, חוץ ממה שמסומן "transactional"), ו"שורת הסרה אוטומטית" (שורה קטנה עם קישור הסרה בתחתית הודעה שיווקית שאין בה קישור כזה). שתיהן כבויות כברירת מחדל.
POST <your-url>
Bul-Event: compliance.unsubscribe_missing
{ "event": "compliance.unsubscribe_missing", "event_id": "uuid", "timestamp": "2026-10-05T12:00:00.000Z",
"level": "warning", // warning | strong
"reason": "מקבץ של 300 נמענים עם אותו תוכן נשלח כטרנזקציוני, בלי קישור הסרה...",
"source": "batch", "recipients": 300, "subject": "מבצע סוף העונה" }