# בול (Bul) — תיעוד API

שליחת מיילים טרנזקציוניים ושיווקיים. כל הבקשות והתשובות ב-JSON (UTF-8). גרסת HTML: https://bul.friman.app/docs

מדיניות פרטיות: https://bul.friman.app/privacy · תנאי שימוש: https://bul.friman.app/terms (טיוטה, עברית ואנגלית)

## אימות
- כתובת בסיס: `https://bul.friman.app/v1`
- כותרת בכל בקשה: `Authorization: Bearer <API key>` (המפתח נוצר בדשבורד ומוצג פעם אחת).
- סוגי מפתחות: `read` קריאה בלבד · `send` שליחה וקריאה · `full` הכול (כולל דומיינים ו-webhooks).
- `Idempotency-Key` (חובה ב-POST /v1/emails, /batch, /campaigns): מחרוזת ייחודית לבקשה (למשל UUID). בקשה חוזרת עם אותו מפתח מחזירה את התשובה המקורית ולא שולחת שוב. תקף 30 יום.
- **שמות שדות: snake_case הוא הצורה הראשית** בבקשות, בתשובות ובאירועים. בכמה תשובות מוחזרים גם שמות ישנים ב-camelCase — deprecated (ראו "שמות ישנים").
- שדה גוף לא מוכר → `422 unknown_fields` (עם `fields`, `allowed`). פרמטר שאילתה לא מוכר בבקשת GET → `422 unknown_parameters` (עם `parameters`, `allowed`).

## נקודות קצה
| 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 | אירועים לפי סדר קבלה, עם עימוד |
| POST | /v1/campaigns | send, full | קמפיין, עד 10,000 נמענים |
| POST | /v1/campaigns/{id}/recipients | send, full | הוספת נמענים לקמפיין קיים, עד 10,000 בבקשה |
| GET | /v1/campaigns | read, send, full | רשימת קמפיינים, עם ETA (?limit, ?status) |
| GET | /v1/campaigns/{id} | read, send, full | פרטי קמפיין, מונים ו-ETA (?include=html) |
| GET/POST | /v1/domains | read / full | רשימה / הוספת דומיין שליחה |
| GET/DELETE | /v1/domains/{id} | read / full | סטטוס אימות ורשומות / מחיקה |
| GET/POST | /v1/webhooks | read / full | רשימה / יצירה (הסוד חוזר פעם אחת) |
| GET/DELETE | /v1/webhooks/{id} | read / full | webhook בודד / מחיקה |
| POST | /v1/webhooks/{id}/test | full | אירוע בדיקה חתום (test: true) |
| GET/POST | /v1/suppressions | read / send | רשימת החסימה (limit, offset) / הוספה |
| POST | /v1/suppressions/import | send, full | ייבוא מרוכז, עד 10,000 כתובות |
| DELETE | /v1/suppressions/{id or email} | full | הסרה מרשימת החסימה |
| GET | /v1/stats | read, send, full | מונים + unique_opens / unique_clicks + human_opens / unique_human_opens |
| GET | /v1/usage | read, send, full | שימוש ועלות משוערת לפי חודש (?group=campaign) |

## GET /v1/status
```
{ "key": { "name": "...", "scope": "send" },
  "sending": { "status": "open", "reason": null, "reputation_warning": false },   // open | blocked
  "rate_limit": { "limit": 120, "remaining": 117, "reset_seconds": 42 },
  "domains": [ { "domain": "yourdomain.co.il", "status": "verified" } ],         // verified | pending | failed
  "queue_processing": true,
  "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 (בקמפיין ברירת המחדל marketing)
  "reply_to": ["support@yourdomain.co.il"], // עד 5
  "headers": { "X-Order-Id": "1234" },      // רק X-*, עד 20
  "tags": { "order_id": "1234" },           // עד 10; חוזרים בכל webhook, ב-GET וב-/v1/events
  "attachments": [ ... ]
}
202 { "id": "uuid", "recipient": "dana@example.com" }
```
- tags: מפתח — אותיות באנגלית, ספרות, `_`, `-`, עד 64 תווים; ערך — מחרוזת עד 256 תווים.
- marketing: מקבל אוטומטית List-Unsubscribe ו-List-Unsubscribe-Post (RFC 8058). ביטול הרשמה מכניס לרשימת החסימה ושולח webhook `unsubscribed`.
- headers: שם מתחיל ב-`X-`; כותרות סטנדרטיות לא ניתנות לדריסה.
- `track_opens` עוד לא נתמך (`422 not_supported`).

## מקבץ: POST /v1/emails/batch
- **צורה 1 (אובייקט):** כמו /v1/emails עם עד 100 נמענים. תשובה: `{ "queued": [{ "id", "recipient" }], "suppressed": [...], "invalid": [...] }` — כתובות לא תקינות לא נשלחות ומוחזרות ב-invalid, והשאר נשלחים (אותו דבר בקמפיין). ב-/v1/emails ובפריט במקבץ כתובת לא תקינה → 400 invalid_to (במקבץ: שגיאה בתוצאה של הפריט).
- **צורה 2 (מערך):** עד 100 הודעות שונות, נמען אחד לכל פריט, עם `idempotency_key` אופציונלי לכל הודעה.
```
POST /v1/emails/batch            // ?mode=partial (ברירת מחדל) | ?mode=atomic
Idempotency-Key: 6f1c...
[ { "from": "Acme <hi@yourdomain.co.il>", "to": "dana@example.com", "subject": "...", "html": "<p>...</p>",
    "idempotency_key": "invoice-1001", "tags": { "invoice": "1001" } },
  { "from": "Acme <hi@unknown-domain.com>", "to": "rina@example.com", "subject": "...", "text": "..." } ]

202 { "data": [
        { "index": 0, "id": "uuid", "recipient": "dana@example.com" },
        { "index": 1, "recipient": "rina@example.com", "error": { "code": "unverified_from_domain", "message": "..." } } ],
      "summary": { "queued": 1, "deduplicated": 0, "failed": 1 } }
```
- partial: תוצאה לכל הודעה; פריטים פסולים מקבלים error והשאר נשלחים. 202 אם התקבלה לפחות אחת, אחרת `422 no_items_accepted` (עם data).
- atomic: פריט פסול אחד → `422 invalid_batch_item` (עם index, item_error) ושום הודעה לא נשמרת.
- נמען ברשימת החסימה מסומן `error.code: "suppressed"` בשני המצבים, והשאר נשלחים.
- idempotency_key לכל הודעה (עד 256 תווים, 30 יום): הודעה שכבר התקבלה מוחזרת עם ה-id המקורי ו-`deduplicated: true` ולא נשלחת שוב — גם בבקשה אחרת.
- המקבץ נספר כבקשה אחת במכסת הקצב.

## מעבר מ-Resend לבול
רוב הקוד נשאר: JSON דומה, `Authorization: Bearer`, ו-202 עם `id`. ההבדלים:
| Resend | בול |
|---|---|
| `https://api.resend.com/emails` | `https://bul.friman.app/v1/emails` |
| Idempotency-Key — אופציונלי | **חובה** ב-POST /v1/emails, /batch, /campaigns |
| `tags: [{ name, value }]` | `tags: { name: value }` (אובייקט) |
| `reply_to` (`replyTo` ב-SDK) | `reply_to` — מערך, עד 5 |
| `cc`, `bcc`, `scheduled_at` | עוד לא נתמכים (`422 unknown_fields`) — נמען נפרד לכל אחד ב-/v1/emails/batch |
| `POST /emails/batch` (מערך) | `POST /v1/emails/batch` — אותו מערך, עם `idempotency_key` לכל הודעה |
| webhook: `email.delivered`, `email.bounced` ... | `delivered`, `bounced` ... — חתימה ב-`Bul-Signature` (ראו Webhooks) |
| שגיאה: `{ name, message }` | `{ "error": { "code", "message" } }` |
לפני:
```ts
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" }],
});
```
אחרי:
```ts
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.

## הודעה בודדת: GET /v1/emails/{id}
```
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": {}, "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 ...", "timestamp": "..." } }
```
?include=html מוסיף html ו-text. `content_purged_at` — מתי נמחק התוכן לפי מדיניות השמירה (null = שמור); אחרי המחיקה html/text = null.

## שמירת נתונים
- תוכן ההודעה (html, text, תוכן קבצים מוטבעים) — נמחק 30 יום אחרי השליחה. ההודעה נשארת (נמען, נושא, סטטוס, זמנים, תגיות, שמות הקבצים). אותו כלל ל-HTML של קמפיין.
- אירועים — נמחקים אחרי 90 יום, עם סיכום יומי לפי סוג; `GET /v1/stats` ממשיך לספור אותם.
- רשימת החסימה — לא נמחקת. קבצים מצורפים ששמורים ו-Idempotency-Key — 30 יום.
- צריכים לאורך זמן? שמרו אצלכם דרך webhooks או GET /v1/events. המחיקה רצה אוטומטית פעם ביום.

## רשימת הודעות: GET /v1/emails
פרמטרים: `recipient` (מדויק) · `status` (queued, sending, sent, delivered, opened, clicked, bounced, complained, unsubscribed, failed, deferred, withdrawn; כמה מופרדים בפסיק) · `since` / `until` (זמן יצירה, ISO 8601) · `campaign_id` · `limit` (1–100, ברירת מחדל 50) · `cursor`. מהחדשה לישנה.
```
200 { "data": [ { "id", "recipient", "from", "subject", "status", "attempts", "campaign_id", "created_at", "last_error", "tags" } ],
      "has_more": false, "next_cursor": null }
```

## אירועים שהוחמצו: GET /v1/events
פרמטרים: `since` (ISO; ברירת מחדל 24 שעות אחורה) · `cursor` · `limit` (1–500, ברירת מחדל 100) · `type` (delivered, opened, clicked, bounced, complained, unsubscribed, failed, deferred, withdrawn). לפי סדר קבלה אצלנו; כל אירוע באותו מבנה כמו ב-webhook, ו-`id` = `event_id` שב-webhook.
```
200 { "data": [ { "id": "uuid", "event": "clicked", "message_id": "uuid", "recipient": "...", "campaign_id": null,
                  "timestamp": "...", "received_at": "...", "clicked_url": "https://...", "tags": {} } ],
      "has_more": true, "next_cursor": "eyJ..." }
```
ממשיכים עם next_cursor כל עוד has_more. שמרו את האחרון כדי להמשיך ממנו בפעם הבאה.

## רשימת החסימה
```
GET  /v1/suppressions?limit=100&offset=0
200  { "data": [{ "id", "recipient", "reason", "source", "created_at" }], "total", "limit", "offset" }
POST /v1/suppressions            { "recipient": "dana@example.com", "reason": "manual" }
201  (נוסף) | 200 { "recipient", "already_suppressed": true }
POST /v1/suppressions/import     { "recipients": ["a@example.com", "b@example.com"], "reason": "manual" }
200  { "added": 2, "already_suppressed": 0, "invalid": [] }
DELETE /v1/suppressions/{id או כתובת}     // full בלבד → 204 | 404
```
reason: hard_bounce | complaint | manual | unsubscribe. כתובות נכנסות גם אוטומטית (החזרה קבועה, תלונה, ביטול הרשמה).

## סטטיסטיקה ושימוש
- `GET /v1/stats` → `{ "sent", "delivered", "bounced", "complained", "opened", "clicked", "unique_opens", "unique_clicks", "human_opens", "unique_human_opens" }`. opened/clicked סופרים אירועים; unique_* סופרים הודעות. human_* — בלי פתיחות שסומנו אוטומטיות (הערכה; ראו "פתיחות אוטומטיות"). כולל אירועים מעל 90 יום (מסיכום יומי; שם הודעה שנפתחה בשני ימים נספרת פעמיים ב-unique_*).

## פתיחות אוטומטיות: machine_open
באירוע `opened` (webhook ו-GET /v1/events): `machine_open` (true = נראית אוטומטית) ו-`machine_open_reason`:
- `apple_mpp` — Apple Mail Privacy Protection: טעינה מראש דרך שרתי Apple. IP בטווח 17.0.0.0/8, או User-Agent "Mozilla/5.0" חשוף.
- `google_prefetch` — GoogleImageProxy (Gmail) פחות מדקה אחרי המסירה. אחרי יותר מדקה — נחשב אנושי.
- `scanner` — User-Agent של סורק / כלי אוטומטי (Mimecast, Barracuda, Proofpoint, curl, python-requests...), או בלי User-Agent.
- `fast_open` — פחות מ-10 שניות אחרי המסירה.
ב-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 — ראו מדיניות הפרטיות).
הפיקסל מוגש בלי מטמון (Cache-Control: no-store, no-cache, max-age=0; Expires: 0) — פתיחה אמיתית אחרי טעינה מוקדמת נספרת שוב.
מגבלות: הערכה בלבד. משתמשי Apple Mail עם ההגנה נראים אוטומטיים גם אם פתחו בפועל; Gmail שומר את התמונה ולא מדווח פתיחות חוזרות; סורק שמתחזה לדפדפן ומחכה יותר מ-10 שניות ייחשב אנושי; בלי אירוע delivered כללי הזמן לא חלים; פתיחות מלפני 2026-10-06 סווגו בדיעבד לפי User-Agent וזמן בלבד. האירוע נשלח תמיד. בקהל עם הרבה Apple — clicked אמין יותר.

## קמפיין גדול: יותר מ-10,000 נמענים
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`.

## קמפיינים: תור הוגן וזמן משוער לסיום (ETA)
התור של בול מחלק את הקיבולת בין לקוחות ובין קמפיינים לסירוגין, לא לפי סדר ההגעה — קמפיין גדול של לקוח אחד לא מעכב אחרים. החלוקה לפי היקף העבודה (גם הגודל של כל הודעה, לא רק מספר ההודעות). הודעות בודדות (POST /v1/emails, `kind` טרנזקציוני) לא מחכות לקמפיינים.
- `priority` ב-POST /v1/campaigns: `"normal"` (ברירת מחדל) או `"low"` — קמפיין בעדיפות נמוכה מקבל חלק קטן יותר מהקיבולת של החשבון, אבל תמיד מתקדם.
- `GET /v1/campaigns` — הקמפיינים האחרונים (`?limit=` עד 100, ברירת מחדל 20; `?status=sending|paused|halted|completed`).
- `eta` ב-GET /v1/campaigns ו-GET /v1/campaigns/{id}: `state` (sending / paused / done), `remaining_messages`, `remaining_bytes`, `rate_per_minute`, `estimated_completion_at`, וטווח: `earliest_completion_at`, `latest_completion_at`; `confidence` (high / medium / low), `basis` (`observed` — לפי הקצב בפועל; `fair_share` — בתחילת קמפיין, לפי החלק ההוגן), `reason` (campaign_paused, campaign_halted, account_paused, recipient_provider_slowdown, waiting_for_capacity, או null), `updated_at`.
- **ETA הוא הערכה שמתעדכנת, לא הבטחת זמן**: זמן השליחה מבול (לא זמן ההגעה לתיבה). הוא מחושב מהכמות שנשארה, מהגודל שלה, מהקצב בפועל ומהאטות אצל ספקי הדואר של הנמענים. כשאין מספיק נתונים — הטווח רחב יותר.

## אזהרות: warnings (קבצים כבדים)
קבצים מצורפים גדלים בכשליש בקידוד (base64): קובץ של 25MB הוא כ-36MB בהודעה. כשההודעה מעל כ-25MB אחרי הקידוד, התשובה (/v1/emails, ‏/v1/emails/batch, ‏/v1/campaigns, ‏/v1/campaigns/{id}/recipients) כוללת:
`"warnings": [{ "code": "attachment_delivery_risk", "message", "encoded_bytes", "recipients_at_risk", "recipients_total", "limits": { "measured_providers_bytes", "other_providers_bytes" } }]`
- Gmail ו-Outlook.com — קיבלו עד כ-36MB אחרי קידוד בבדיקות שלנו. שאר הספקים (Yahoo, ארגונים ב-Microsoft 365, ספקים ישראליים ושרתים אחרים) — כ-25MB לפי מה שהם מפרסמים.
- `recipients_at_risk` — כמה נמענים בבקשה אצל ספקים שעלולים לדחות הודעה בגודל הזה (מעל כ-36MB — כולם).
- **לא חוסם**: ההודעה נשלחת כרגיל. לקבצים כבדים לרשימה גדולה — עדיף קישור להורדה.

## אזהרות: warnings (קטיעה ב-Gmail)
HTML מעל 102,000 בייט (אחרי עטיפת הקישורים) → בתשובה ל-/v1/emails, ‏/v1/emails/batch (לכל פריט) ו-/v1/campaigns: `"warnings": [{ "code": "gmail_clipping", "message", "html_bytes", "limit_bytes": 102000 }]`. ההודעה נשלחת כרגיל. Gmail מציג "[ההודעה נקטעה]" ומסתיר את הסוף (בדרך כלל פוטר וקישור הסרה). פיקסל הפתיחה של בול בתחילת ה-<body>, כך שגם הודעה שנקטעה נספרת.
- `GET /v1/usage` → לפי חודש: `period_start`, `period_end`, `messages`, `cost_usd` (משוער), `tracked_messages`, `untracked_messages`, `message_bytes`, `attachment_bytes`. `?group=campaign` → לפי קמפיין. העלות היא הערכה (לכל הודעה + לפי נפח ההודעה כפי שנשלחה), לא חשבונית.

## קבצים מצורפים
```
"attachments": [
  { "filename": "invoice.pdf", "content_type": "application/pdf", "content": "<base64>" },
  { "filename": "report.pdf", "url": "https://files.example.com/report.pdf" },
  { "filename": "logo.png", "content_type": "image/png", "content": "<base64>", "content_id": "logo@acme" } ]
```
עד 10 קבצים ועד 25MB יחד להודעה. content או url — לא שניהם. content_type חובה עם content. תמונה בגוף: `<img src="cid:logo@acme">`.

## דומיינים: Cloudflare, הוספה אוטומטית ודומיין מעקב ממותג
- **חיבור Cloudflare** (דשבורד → דומיינים): הרשאות `zone.read dns.read dns.write` בלבד. בול מוסיפה רק את הרשומות שבכרטיס הדומיין, DNS only, בלי לדרוס קיימות. כמה חשבונות לטננט, ו-zone לכל דומיין. ניתוק מבטל את ההרשאה גם אצל Cloudflare.
- **Domain Connect**: אם ספק ה-DNS תומך ומכיר את התבנית של בול — הכפתור בכרטיס הדומיין שולח לאישור אצל הספק, והרשומות נוספות לבד.
- **דומיין מעקב ממותג** (אופציונלי; ברירת המחדל — `bulclick.friman.app`, בלי לעשות כלום): שם משלכם (למשל `click.example.co.il`) — CNAME אל `bulclick.friman.app` (DNS only). בול מציעה שם פנוי ולא נוגעת בשם תפוס. כשהסטטוס "פעיל" — קישורים ופיקסל עוברים דרכו (במיילים שיוצאים דרך Bul Delivery Network). **מומלץ לקהל מסונן** (NetFree): את הדומיין הממותג מבקשים לאשר בעצמכם; `bulclick.friman.app`, `bultrack.friman.app` ו-`bul.friman.app` — בול מבקשת.

## עצירת מסלול: withdrawn ו-route_paused
כשספק דואר (למשל Gmail) מאט או חוסם את Bul Delivery Network, השליחה דרכו נעצרת לחשבון אוטומטית עד אישור חידוש. ברירת המחדל — החזרה אליכם:
- הודעה שהתקבלה ולא נמסרה → אירוע `withdrawn` (reason.code = route_paused, safe_to_resend: true; attempted: false = לא נוסתה, true = נוסתה וכל ניסיון נדחה במפורש, למשל 421). בלי פקיעה ובלי מעבר שקט לספק אחר.
- בקשה חדשה שהייתה יוצאת דרך Bul Delivery Network → `409 route_paused` (safe_to_resend: true), כל הבקשה לא נקלטה (גם מקבץ וקמפיין). מה שיוצא ממילא דרך ספק אחר — לא נחסם.
- **נמען חדש** (reason.code = recipient_not_proven): המסירות הראשונות לנמען שעוד לא הוכח עוברות במסלול בדיקה. כשהמסלול בחשבון הוא החזרה אליכם — `withdrawn` עם safe_to_resend: true; בקשה שכל הנמענים בה (עד 50) חדשים → `409 route_paused` עם `reason_code: recipient_not_proven` ו-`recipients`. שלחו בספק שלכם; אחרי כמה מסירות נקיות הנמען מוכח.
- **חשבון בלי ספק חלופי** (reason.code = direct_unavailable): כל הודעה שלא יכולה לצאת דרך Bul Delivery Network חוזרת כ-`withdrawn` (safe_to_resend: true, הסיבה ב-detail); בקשה שכל ההודעות בה כאלה → `409 route_paused` עם `reason_code: direct_unavailable`.
- הודעה שתוצאתה לא ברורה (ייתכן שנמסרה) → לא חוזרת: `failed` עם ambiguous_delivery, safe_to_resend: false.

## ספריית JavaScript / TypeScript
ספרייה רשמית (עוד לא פורסמה ב-npm — לפי בקשה; השם הצפוי: `bul-email`): כל נקודות הקצה, טיפוס לכל אירוע, אימות חתימת webhook (Web Crypto), ניסיונות חוזרים בטוחים (Idempotency-Key אחד לכל קריאה, נשמר בניסיונות; 429 לפי Retry-After, 5xx ורשת). `409 route_paused` → `RoutePausedError` עם `returns`, בלי ניסיון חוזר.
```ts
import { Bul, RoutePausedError, constructWebhookEvent } from "bul-email";
const bul = new Bul({ apiKey: process.env.BUL_API_KEY! });
await bul.emails.send({ from: "Acme <hi@acme.co.il>", to: ["dana@example.com"], subject: "שלום", html: "<p>…</p>" }, { idempotencyKey: "order-1234" });
for await (const ev of bul.events.iterate({ since: "2026-10-01T00:00:00Z" })) { /* … */ }
const event = await constructWebhookEvent({ rawBody, headers, secret });   // WebhookVerificationError אם לא תקין
```
שגיאות: BulError (status, code, details) → AuthenticationError 401, PermissionError 403, RoutePausedError 409, AccountBlockedError 423, RateLimitError 429; ConnectionError — רשת.

## סטטוס השירות
https://bul.friman.app/status (JSON: /status.json) — זמינות API, שליחה ואירועים, נמדדת כל דקה; תקלות ותחזוקה מתוכננת. הדף זמין גם כשהשרת למטה (מוגש אז מ-Cloudflare, עם "לא מגיב"), וגם בכתובת נפרדת: https://status.friman.app

## החזרה ודיווח: return_ref ו-POST /v1/recipients/proof
כל הודעה שבול מחזירה — `409 route_paused` (גם כל פריט במקבץ) ואירוע `withdrawn` — כוללת `return_ref` (ב-409: `returns: [{index, recipient, return_ref}]`; ב-withdrawn: שדה באירוע). אחרי השליחה בספק שלכם — דווחו:
`POST /v1/recipients/proof` (מפתח send / full; החשבון צריך אישור לייבוא היסטוריה) עם `{"return_ref","recipient","provider","outcome":"delivered"|"bounced"|"complained","occurred_at":"ISO","provider_message_id"}` → `202 {"accepted":true,"duplicate":false}`.
- תוצאה אחת לכל return_ref: אותה תוצאה שוב → 202 duplicate: true. bounced / complained אחרי delivered → גוברים (202, superseded: "delivered"). delivered אחרי bounced / complained, או bounced מול complained → נרשם ונדחה: 409 conflicting_outcome (current_outcome).
- delivered — מסירה נקייה (אחרי 2 — הנמען מוכח ויוצא דרך Bul Delivery Network). bounced / complained — ביטול ההוכחה וחסימה מ-Bul Delivery Network.
- 403 — מפתח read או חשבון בלי אישור. 422 invalid_return_ref — מזהה שלא קיים בחשבון או של נמען אחר. 422 validation_error — שדה לא תקין.

## סיבות כישלון (failed)
לכל failed (webhook ו-/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.

## Webhooks
אירועים: delivered, opened, clicked, bounced (קבועה), deferred (זמנית), complained, unsubscribed, failed, withdrawn (הוחזרה לפני ניסיון — ראו "עצירת מסלול"), stuck. אירועי חשבון: reputation.warning, reputation.paused, compliance.unsubscribe_missing (בהרשמה מפורשת). אירוע בדיקה: test.
```
POST <your-url>
Content-Type: application/json
Bul-Event: bounced
Bul-Event-Id: 6167e99d-...     // מזהה המסירה
Bul-Timestamp: 1790000000
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": {},
  "bounce_type": "hard", "bounce_sub_type": "General", "smtp_status": "5.1.1", "smtp_diagnostic": "..." }
```
**מבנה לכל סוג:** משותף לכולם — event, event_id, message_id, recipient, campaign_id, timestamp, tags. opened + machine_open, machine_open_reason. clicked + clicked_url. failed + reason {code, description} + safe_to_resend (ו-error, טקסט, לתאימות). withdrawn + reason (route_paused), 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. reputation.* — אירוע חשבון בלי message_id. test — test: true, message_id: null.

**סינון כפילויות — לפי event_id.** event_id = מזהה האירוע: זהה בכל מסירה של אותו אירוע וזהה ל-id ב-GET /v1/events (ל-stuck — נגזר באופן יציב מההודעה; ל-reputation.* ולבדיקה — חדש לכל התראה). Bul-Event-Id = מזהה המסירה: קבוע בין ניסיונות חוזרים וגם ב"שלח שוב" מהדשבורד, אבל אותו אירוע שנמסר בדרך אחרת יגיע עם Bul-Event-Id אחר ואותו event_id.

**חתימה:** Bul-Signature = HMAC-SHA256 (hex) של `${Bul-Timestamp}.${body}` עם הסוד מהיצירה. לאמת על הגוף הגולמי, בזמן קבוע, ולדחות timestamp ישן מ-5 דקות.
```js
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 שעות: מיד, ואחרי 1, 5, 30 דקות, 1, 3, 6, 12 שעות. Bul-Timestamp והחתימה מחושבים מחדש בכל ניסיון.
**קצב:** עד 10 בקשות בו-זמנית לכל כתובת. 429 משהה את הכתובת לפי Retry-After (שניות או תאריך; בלעדיו 30 שניות) בלי לספור ניסיון; 503 עם Retry-After קובע את הניסיון הבא.
**אירוע בדיקה:** `POST /v1/webhooks/{id}/test` (full) → `200 { "delivery_id", "event_id", "delivered", "response_code", "duration_ms", "error" }`. נשלח מיד, חתום כמו אירוע אמיתי, Bul-Event: test, בגוף test: true, בלי ניסיונות חוזרים. גוף אופציונלי לבחירת סוג ותוכן (2026-10-06): 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, attempted, recipient, message_id, tags — אותו payload כמו אירוע אמיתי + test: true; Bul-Event = הסוג. למשל { "event": "bounced", "smtp_status": "5.7.1" }.
**ניהול:** `POST /v1/webhooks { "url", "events" }` → 201 עם `signing_secret` (פעם אחת). GET מחזיר id, url, events, active, created_at.

## שמות ישנים (deprecated)
מוחזרים לתאימות לאחור, אותו ערך בשם הישן; הכותרת `Bul-Deprecated-Fields` מפרטת אותם. יוסרו בעתיד.
- GET /v1/emails/{id}: fromAddress, campaignId, lastError, createdAt, replyTo, trackClicks, attachments[].contentType, attachments[].contentId
- GET /v1/campaigns/{id}: sender, haltReason, createdAt
- GET /v1/domains/{id}: row, verification
- POST /v1/webhooks: subscribedEvents, signingSecret, createdAt

## שגיאות
מבנה: `{ "error": { "code": "...", "message": "..." } }` — ההודעה בעברית, הקוד קבוע.
| Status | Code | מתי |
|---|---|---|
| 400 | invalid_json, missing_idempotency_key, invalid_to, too_many_recipients, invalid_from, unverified_from_domain, invalid_subject, invalid_html, invalid_kind, invalid_reply_to, invalid_headers, invalid_text, invalid_track_clicks, invalid_tags, invalid_idempotency_key, too_many_items, invalid_batch, invalid_mode, invalid_limit, invalid_cursor, invalid_since, invalid_until, invalid_status, invalid_type, invalid_attachment, too_many_attachments | קלט לא תקין |
| 401 | unauthorized | מפתח חסר/שגוי/מבוטל |
| 403 | forbidden | סוג המפתח לא מורשה (כולל scope, required_scopes) |
| 404 | not_found | לא קיים או לא שייך לחשבון |
| 405 | method_not_allowed | שיטה לא נתמכת (כותרת Allow) |
| 409 | already_processing | אותו Idempotency-Key עדיין בעיבוד |
| 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 | חשבון חדש: הגעתם למספר דומייני השליחה המותר בשלב הזה |
| 409 | route_paused | השליחה דרך Bul Delivery Network עצורה לחשבון; שום הודעה מהבקשה לא נקלטה — safe_to_resend: true |
| 413 | attachment_too_large | קבצים מעל 25MB להודעה |
| 422 | unknown_fields / unknown_parameters | שדה גוף / פרמטר שאילתה לא מוכר |
| 422 | suppressed | הנמען ברשימת החסימה (/v1/emails) |
| 422 | invalid_batch_item / no_items_accepted | מקבץ atomic פסול / partial שבו אף הודעה לא התקבלה |
| 422 | not_supported | track_opens |
| 423 | sending_blocked | השליחה חסומה זמנית (הסיבה: GET /v1/status) |
| 409 | conflicting_outcome | POST /v1/recipients/proof: ל-return_ref כבר נרשמה תוצאה אחרת (current_outcome); הדיווח נרשם ונדחה |
| 423 | account_frozen | החשבון מוקפא זמנית: המפתח תקין וקריאה (GET) עובדת, אבל כל שליחה וכל פעולת כתיבה נדחות עד שחרור (`sending.frozen` ב-/v1/status) |
| 429 | rate_limited | יותר מדי בקשות (Retry-After) |
| 500 | internal_error | אפשר לנסות שוב עם אותו Idempotency-Key |

## מכסת קצב ומגבלות
- 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 בשנייה ב-20MB).

## מוניטין ועצירה אוטומטית
חלון נע של 24 שעות. החזרות קבועות: אזהרה 2%, עצירה 3% (מ-100 הודעות בחלון). תלונות: אזהרה 0.04% (גם תלונה בודדת), עצירה 0.08% ולפחות 2 תלונות.
באזהרה: webhook reputation.warning, באנר, מייל לבעל החשבון, `sending.reputation_warning: true` ב-/v1/status (לכל היותר פעם ב-6 שעות). בעצירה: reputation.paused + מייל; שליחות חדשות מקבלות 423 sending_blocked; חידוש ידני.
```
{ "event": "reputation.warning", "event_id": "uuid", "timestamp": "...", "reasons": ["bounce_rate"],
  "sending_status": "active", "window_hours": 24, "volume": 1200, "bounced": 26, "complained": 0,
  "bounce_rate": 0.021667, "complaint_rate": 0, "thresholds": { ... } }
```

## דיוור בלי אפשרות הסרה
Gmail ו-Yahoo דורשים בדיוור כותרת ביטול הרשמה בלחיצה אחת (מעל 5,000 ביום ל-Gmail — חובה). בול מוסיפה אותה רק ל-kind: "marketing" ולקמפיינים.
התראה (אזהרה): מקבץ/קמפיין של 20+ נמענים עם אותו תוכן כטרנזקציוני ובלי קישור הסרה בגוף; או אותה הודעה ל-200+ נמענים ב-24 שעות. התראה חזקה: מעל 5,000 הודעות ל-Gmail ב-24 שעות בלי כותרת הסרה.
איפה: באנר בדשבורד, `compliance.unsubscribe` ב-/v1/status, webhook compliance.unsubscribe_missing (פעם ב-24 שעות לכל רמה). יורדת לבד אחרי 24 שעות. השליחה לא נחסמת.
הגדרות חשבון (כבויות כברירת מחדל): "שיווקי כברירת מחדל" (בלי kind → marketing), "שורת הסרה אוטומטית" (קישור הסרה בתחתית הודעה שיווקית שאין בה).
```
{ "event": "compliance.unsubscribe_missing", "event_id": "uuid", "timestamp": "...", "level": "warning",
  "reason": "...", "source": "batch", "recipients": 300, "subject": "..." }
```
