# WeHub API למפתחים · WeHub Developer Guide

ה-API הציבורי של WeHub: אנשי-קשר, שיחות, שליחת הודעות **חכמה** (הערוץ, המופע
והתוכן נבחרים בשרת), ערוצים, **ניהול תבניות** (יצירה, סטטוס, מחיקה),
מצב-מסירה ו-webhooks יוצאים. המסמך
דו-לשוני — עברית לתיאור, אנגלית לקוד ולשמות-שדות.

*The WeHub public API: contacts, conversations, smart message sending, channels,
WhatsApp template management, delivery status and outgoing webhooks. Prose in Hebrew, code and field
names in English.*

* **Base URL** — `https://<your-wehub-host>/api/v1/public`
* **OpenAPI 3.1** — `GET /api/v1/public/openapi.json` (ללא מפתח; כולל דוגמאות לכל נתיב)
* **המסמך הזה** — `GET /api/v1/public/docs.md` (ללא מפתח)
* **במוצר** — הגדרות → **API ומפתחים**: מפתחות, webhooks, בונה-בקשות והמדריך הזה

---

## 1. אימות · Authentication

מפתח נוצר במסך **הגדרות → API ומפתחים** ומוצג **פעם אחת**. אין דרך לשחזר
אותו; מי שאיבד — מסובב.

```http
POST /api/v1/public/messages HTTP/1.1
Host: api.wehub.co.il
Authorization: Bearer wh_live_3fA9kQ2mZx7LpR4vT8sN1wY6bC0dE5gH
Content-Type: application/json
```

* קידומת `wh_live_` לסביבת-אמת, `wh_test_` לבדיקות (שולח תמיד במצב-הדמיה,
  ר' `mock`).
* **אין כותרת-עסק.** ה-`Authorization` לבדו קובע את העסק — לקוח אינו יכול
  להצהיר על עסק שאינו שלו.
* מפתח חסר, מפתח לא-קיים, מפתח בלי קידומת מוכרת ומפתח מבוטל מחזירים כולם את
  אותה תשובת `401`. ההבחנה ביניהם היא בדיוק המידע שתוקף מחפש.

### Scopes

לכל מפתח רשימת-הרשאות מפורשת. קריאה מחוץ ל-`scope` מחזירה `403`.

| Scope | מה מותר |
|---|---|
| `contacts:read` | קריאת אנשי-קשר |
| `contacts:write` | יצירה ועדכון של אנשי-קשר |
| `conversations:read` | קריאת שיחות, הודעות, מצב-מסירה וערוצים |
| `messages:send` | שליחת הודעות, מצב-מסירה וערוצים |
| `templates:read` | קריאת תבניות וערוצים |
| `templates:write` | יצירה ומחיקה של תבניות ווטסאפ, והעלאת מדיה לכותרת |
| `webhooks:manage` | ניהול webhooks יוצאים |

נתיב שמקבל כמה scopes (למשל `GET /messages/{id}`) דורש **אחד** מהם.

### מה מפתח **אינו**

מפתח שייך ל**עסק**, לא לנציג. אין לו היקף-ראייה: `GET /conversations` מחזיר
את כל שיחות העסק. הוא גם אינו יכול לגעת בעסק אחר — מזהה של עסק אחר מחזיר
`404`, בדיוק כמו מזהה שאינו קיים.

---

## 2. מגבלת-קצב · Rate limits

המכסה נגזרת מהתוכנית של העסק (`limits.api_rate_per_min`, חלון של דקה).
חריגה מחזירה `429` עם `Retry-After: 60`. המכסה נמדדת פר-**עסק**, לא פר-מפתח:
שני מפתחות באותו עסק חולקים אותה מכסה. את המכסה של התוכנית שלכם רואים במסך
"API ומפתחים".

---

## 3. שגיאות · Errors

כל שגיאה נראית אותו דבר:

```json
{
  "error": "conflict",
  "reason": "conflict",
  "text": "חלון-המענה נסגר — אפשר לפתוח שיחה מחדש רק בתבנית מאושרת (template)…",
  "details": { "action": "template" }
}
```

`error` ו-`reason` זהים תמיד (`reason` נשמר לתאימות עם הלקוחות הקיימים).
`text` מוכן-לתצוגה בעברית. אל תנתחו אותו — התנו על `error` ועל `details`.

| קוד | HTTP | מתי |
|---|---|---|
| `unauthenticated` | 401 | מפתח חסר או לא תקף |
| `forbidden` | 403 | חסר `scope`; העסק מושעה; או מפתח `wh_test_` שניסה פעולה שמשנה מצב אצל מטא — יצירת/מחיקת תבנית או העלאת מדיה-לכותרת (`details.code:"test-key-readonly"`). מפתח-בדיקה קורא ומדמה; לשינוי אמיתי נדרש `wh_live_` |
| `feature-not-entitled` | 403 | התוכנית של העסק אינה כוללת את היכולת. `details.feature` הוא המפתח: `api.access` (‏API כלשהו), `webhooks.outgoing` (מנויי-webhook), `desk.templates.meta` (ניהול תבניות). ‏**גם מפתח קיים מפסיק לעבוד** אם התוכנית ירדה — אין צורך לבטל אותו |
| `validation` | 400 | קלט שגוי — `details` מפרט (למשל `missing`/`unknown` בפרמטרים נקובים) |
| `not-found` | 404 | לא קיים, או שייך לעסק אחר |
| `conflict` | 409 | חלון-מענה סגור (`details.action:"template"`), תבנית לא-מאושרת, אין מופע מופעל (`details.code:"channel-unavailable"`), בקשה אידמפוטנטית שעדיין רצה (`details.code:"idempotency-in-flight"`) |
| `live-locked` | 423 | שער-השליחה נעול בסביבה הזו |
| `rate-limited` | 429 | חריגה מהמכסה |
| `internal` | 502 | ספק חיצוני (מטא/ערוץ) נפל — הבקשה עצמה הייתה תקינה; `details.messageId` כשההודעה נשמרה כ-`failed` |

---

## 4. שליחת הודעה · Sending a message

```
POST /messages          scope: messages:send
```

זו נקודת-השליחה היחידה. אתם אומרים **למי** ו**מה**; השרת מכריע ערוץ, מופע
ותוכן, ומחזיר מה נבחר בפועל.

```jsonc
{
  // ── למי (בדיוק אחד) ──
  "to": "+972501234567",                    // טלפון ⇒ ווטסאפ · מייל ⇒ מייל
  "to": { "kind": "ig", "value": "…" },     // או מפורש: wa | tel | email | ig | fb
  "contactPublicId": "01J8XK…",             // או איש-קשר קיים
  "conversationId": "l7d3p0",               // או שיחה קיימת

  // ── ניתוב (רשות) ──
  "channel": "auto",                        // ברירת-מחדל. גם: wa|whatsapp, email|mail, fb|messenger, ig|instagram
  "instanceId": "wa2",                      // מופע מסוים — ר' GET /channels

  // ── מה (text ו/או template, או media) ──
  "text": "היי דנה, ההזמנה 12345 יצאה למשלוח",
  "template": { "name": "order_shipped", "language": "he", "body": { "name": "דנה", "order": "12345" } },
  "media": { "url": "https://…/invoice.pdf", "filename": "invoice.pdf", "caption": "החשבונית שלך" },
  "subject": "ההזמנה שלך",                  // מייל בלבד

  "idempotencyKey": "order-12345-shipped"   // או כותרת Idempotency-Key
}
```

התשובה (`201`; `200` בתשובה-חוזרת):

```json
{
  "id": "m8x2k1q9", "conversationId": "l7d3p0",
  "channel": "wa", "instanceId": "wa",
  "sent": "text", "status": "sent",
  "mock": false, "replayed": false
}
```

* `channel` / `instanceId` — מה נבחר בפועל.
* `sent` — `text` / `template` / `media`: מה יצא, חשוב כששלחתם `text` ו-`template` יחד.
* `status` — מיידי: `sent` או `failed`. `delivered`/`read` מגיעים אחר-כך
  (ר' §5 ו-§9).
* `id` — לשאילתה ב-`GET /messages/{id}` ולהתאמה עם אירועי `message.*`.

> **`"mock": true`** פירושו שהשער היוצא של הסביבה נעול (פיתוח / בדיקות /
> מפתח `wh_test_` / העסק לא הפעיל שליחה חיה). ההודעה **נרשמה בדסק ולא יצאה
> ללקוח**. השדה `mockReasons[]` מסביר למה, בעברית. אל תתייחסו ל-`mock:true`
> כאל מסירה.

### 4.1 היעד · Target

סדר-ההכרעה: `conversationId` ⇐ `contactPublicId` ⇐ `to`. שולחים **אחד**.

* **`to` כמחרוזת** מזוהה אוטומטית: מספר-טלפון (E.164 `+972501234567` או מקומי
  `0501234567`) ⇒ ווטסאפ; כתובת מייל ⇒ מייל. כל דבר אחר ⇒ `400` עם הסבר.
* **`to` כאובייקט** `{kind, value}` — לאינסטגרם/מסנג׳ר (מזהי-מטא), או כשרוצים
  להיות מפורשים. `tel` שקול ל-`wa` (אותו מספר).
* **`contactPublicId`** — ה-`id` מ-`/contacts`. הזהות נבחרת לפי הערוץ (ר' 4.2).
* **`conversationId`** — ממשיכים שיחה קיימת; הערוץ והמופע כבר קבועים.

איש-קשר שלא היה קיים נוצר; שיחה שלא הייתה קיימת נפתחת.

### 4.2 הערוץ · Channel

`channel` חסר או `"auto"` (ברירת-המחדל):

1. אם הנמען מוכר כאיש-קשר ויש לו **שיחה קיימת שחלון-המענה שלה פתוח עכשיו** —
   היא נבחרת, לפי עדיפות ווטסאפ ⇐ מסנג׳ר ⇐ אינסטגרם ⇐ מייל. שם אפשר לשלוח
   טקסט חופשי בלי תבנית.
2. אחרת — הערוץ הטבעי של הזהות: טלפון ⇒ ווטסאפ, מייל ⇒ מייל, וכו'.

ערוץ מפורש מכובד תמיד; אם לאיש-הקשר אין זהות בערוץ הזה ⇒ `400`. כינויים
מתקבלים: `whatsapp`, `mail`, `messenger`, `facebook`, `instagram`.

### 4.3 המופע · Instance

עסק יכול להחזיק כמה מספרי-ווטסאפ / תיבות-מייל. `instanceId` חסר:

1. המופע שכבר מנהל שיחה עם הנמען;
2. אחרת מופע-ברירת-המחדל של הערוץ (`wa`, `email`…), אם מופעל;
3. אחרת המופע המופעל הראשון;
4. אחרת `409` עם `details.code:"channel-unavailable"`.

מופע מפורש חייב להיות מהסוג של הערוץ ומופעל. את הרשימה מקבלים ב-`GET /channels`.

### 4.4 התוכן · Content strategy

| שלחתם | מה קורה |
|---|---|
| `text` בלבד | טקסט חופשי. חלון-המענה סגור ⇒ `409` עם `details.action:"template"` |
| `template` בלבד | תבנית מאושרת (ווטסאפ). פותחת שיחה גם מחוץ לחלון |
| `text` **+** `template` | **הטקסט אם החלון פתוח, אחרת התבנית.** `sent` אומר מה יצא |
| `media` (+ `caption`) | קובץ — ר' §8. לא משתלב עם `text`/`template` |

**חלון-המענה**: בוואטסאפ ובערוצי-מטא אפשר לשלוח טקסט חופשי רק תוך 24 שעות
מההודעה האחרונה של הלקוח. למייל אין חלון. `GET /conversations/{id}` מחזיר
`windowOpen`, ו-`GET /channels` מחזיר `capabilities.windowHours`.

הדפוס המומלץ למערכת עסקית: לשלוח **תמיד** `text` **ו**-`template` יחד —
הלקוח שכתב לכם היום יקבל טקסט חם, ומי שלא — תבנית מאושרת. בלי `if` אצלכם.

#### דוגמאות לכל מקרה

**טקסט לשיחה קיימת** (החלון פתוח):

```json
{ "conversationId": "l7d3p0", "text": "בדקנו — ההחזר יגיע תוך 3 ימי-עסקים." }
```

**תבנית לטלפון** (חלון סגור או לא ידוע):

```json
{
  "to": "+972501234567",
  "template": { "name": "order_shipped", "language": "he", "body": ["דנה", "12345"] }
}
```

**טקסט + תבנית** — השרת בוחר:

```json
{
  "to": "+972501234567",
  "text": "היי דנה, ההזמנה 12345 יצאה למשלוח 🎉",
  "template": { "name": "order_shipped", "language": "he", "body": { "name": "דנה", "order": "12345" } },
  "idempotencyKey": "order-12345-shipped"
}
```

```json
{ "id": "m8x2k1q9", "conversationId": "l7d3p0", "channel": "wa", "instanceId": "wa", "sent": "template", "status": "sent", "mock": false, "replayed": false }
```

**מייל** — `text` הוא הגוף, `subject` רשות (ברירת-מחדל: `Re: <נושא-השיחה>`
או `הודעה מ-<שם-העסק>`). תבנית למייל ⇒ `409`.

```json
{ "to": "dana@example.co.il", "subject": "ההזמנה שלך", "text": "שלום דנה,\nההזמנה 12345 יצאה למשלוח." }
```

**איש-קשר, ערוץ אוטומטי** — אם דנה כתבה אתמול במסנג׳ר, זה יצא במסנג׳ר:

```json
{ "contactPublicId": "01J8XK2Q9M4V7C0RTBNEFGHJ5A", "text": "תודה על הפנייה!" }
```

**מופע מסוים**:

```json
{ "to": "+972501234567", "instanceId": "wa2", "template": { "name": "welcome" } }
```

### 4.5 תבניות · Templates

```bash
GET /templates?instanceId=wa      # scope: templates:read
```

רק תבנית בסטטוס `APPROVED` ניתנת לשליחה; אחרת `409`. כל פריט נושא `params`
— כל מה שצריך כדי לבנות את הבקשה בלי לראות את `components` של מטא:

```json
{
  "name": "order_shipped", "language": "he", "status": "APPROVED", "category": "UTILITY",
  "body": "שלום {{name}}, ההזמנה {{order}} יצאה למשלוח.",
  "params": { "body": 2, "named": ["name", "order"], "header": "none", "headerVariable": false, "buttons": [] }
}
```

| `params` | משמעות | מה שולחים |
|---|---|---|
| `body` | כמה משתנים בגוף | `template.body` — מערך באורך הזה, או אובייקט |
| `named` | שמות המשתנים לפי סדר (תבנית נקובה); `null` בתבנית מיקומית | אובייקט `{name: value}` — או מערך לפי הסדר |
| `header` | `text` / `image` / `video` / `document` / `location` / `none` | `header.text` (כש-`headerVariable`), `header.mediaId`, `header.location` |
| `buttons[].variable` | כפתור שדורש ערך (קישור עם משתנה, `copy_code`) | `buttons["<index>"] = { "type": "url", "value": "…" }` |

**פרמטרים נקובים**: כש-`template.body` הוא אובייקט, השרת ממפה לפי שמות
המשתנים בגוף התבנית. שם חסר או לא-מוכר ⇒ `400` עם `details.missing`,
`details.unknown`, `details.expected`. מערך עובד תמיד — לפי הסדר.

---

## 5. מצב הודעה · Message status

```
GET /messages/{id}      scope: messages:send או conversations:read
```

לפולינג אחרי שליחה, למי שלא עובד עם webhooks:

```json
{
  "id": "m8x2k1q9", "conversationId": "l7d3p0", "channel": "wa",
  "direction": "out", "kind": "template", "text": "שלום דנה, ההזמנה 12345 יצאה למשלוח.",
  "status": "delivered", "statusAt": "2026-09-04T10:12:03.000Z", "error": null,
  "template": { "name": "order_shipped", "language": "he" }, "media": null,
  "at": "2026-09-04T10:12:00.000Z"
}
```

`status` מתקדם `queued → sent → delivered → read`, או `failed`. ב-`failed`
`error` נושא `{ "code", "he" }` — קוד קצר והסבר בעברית, בלי פרטי-ספק
(למשל `outbound-locked` במצב-הדמיה). מזהה של עסק אחר ⇒ `404`.

עדיף מפולינג: אירועי `message.delivered` / `message.read` / `message.failed` (§11).

---

## 6. ערוצים · Channels

```
GET /channels           scope: messages:send / templates:read / conversations:read
```

```json
{
  "items": [
    { "id": "wa",    "type": "wa",    "name": "ווטסאפ", "display": "+972-3-555-1234",       "enabled": true, "isDefault": true,
      "capabilities": { "text": true, "templates": true,  "media": true,  "windowHours": 24,   "maxText": 4096 } },
    { "id": "email", "type": "email", "name": "מייל",   "display": "support@example.co.il", "enabled": true, "isDefault": true,
      "capabilities": { "text": true, "templates": false, "media": true,  "windowHours": null, "maxText": 20000 } },
    { "id": "phone", "type": "phone", "name": "טלפון",  "display": "+972-3-555-0000",       "enabled": true, "isDefault": true,
      "capabilities": { "text": false, "templates": false, "media": false, "windowHours": null, "maxText": 0 } }
  ]
}
```

* `id` הוא ה-`instanceId` לשליחה. `isDefault` — מופע-ברירת-המחדל של הסוג.
* `display` — מה שהלקוח רואה: המספר, כתובת-המייל השולחת, `@שם` באינסטגרם, שם-הדף.
* מופעים מושבתים נכללים עם `enabled:false`, כדי ש"למה נדחיתי" תהיה שאלה עם תשובה.
* `windowHours: null` = אין חלון-מענה (מייל). `templates` — רק ווטסאפ.

---

## 7. ניהול תבניות · Templates

תבנית ווטסאפ היא הדרך היחידה לפנות ללקוח **מחוץ** לחלון 24 השעות. כל תבנית
עוברת בדיקת-תוכן של מטא, ולכן היא נוצרת מראש ונשלחת אחר-כך.

```
GET    /templates?instanceId=&name=&status=&language=    scope: templates:read
GET    /templates/{name}?instanceId=&language=           scope: templates:read
POST   /templates                                        scope: templates:write
POST   /templates/header-media                           scope: templates:write
DELETE /templates/{name}?instanceId=                     scope: templates:write
```

`templates:read` נשאר **קריאה-בלבד** במכוון: מפתח שרק שולח הודעות לא צריך
להיות מסוגל למחוק את התבנית שהעסק אישר מול מטא. `instanceId` הוא רשות בכל
הנתיבים — בלעדיו נבחר מופע-הווטסאפ הראשי (`wa`); ר' `GET /channels`.

### מחזור-החיים

```
POST /templates ──> PENDING ──> APPROVED  ──(איכות/תלונות)──> PAUSED ──> DISABLED
                       │
                       └──────> REJECTED  ──(עריכה ושליחה מחדש)──> PENDING
```

* רק `APPROVED` ניתנת לשליחה. `PENDING` נמשך בדרך-כלל דקות, לפעמים שעות.
* `statusHe` הוא אותו סטטוס בעברית, מוכן-לתצוגה (`"מאושרת"`, `"ממתינה לאישור"`).
* `qualityScore` — `GREEN` / `YELLOW` / `RED`. מטא מודדת חסימות ותלונות של
  נמענים; `RED` מוביל להשהיה אוטומטית.
* `rejectedReason` הוא הקוד של מטא, ו-`rejectedReasonHe` ההסבר בעברית. קוד
  שעדיין לא תורגם אצלנו יוצא כמשפט שנושא את הקוד — לא כניחוש.
* **ההתראה**: אירוע `template.status_changed` (§11). הפולינג המקביל:
  `GET /templates/{name}?language=he`.

### קטגוריה

| קטגוריה | מתי | דוגמה |
|---|---|---|
| `UTILITY` | עדכון על עסקה או בקשה **קיימת** של הלקוח | "ההזמנה 12345 יצאה למשלוח" |
| `MARKETING` | פנייה יזומה, מבצע, הזמנה לחזור | "10% הנחה השבוע" |

מטא בודקת את התוכן מול הקטגוריה: ניסוח מכירתי בתבנית `UTILITY` הוא סיבת-דחייה
מתועדת. `AUTHENTICATION` (קודי OTP) **אינה נתמכת** ליצירה דרך ה-API — המבנה
שלה שונה לגמרי (כפתור OTP ייעודי, בלי כותרת/תחתית/כפתורים רגילים) ⇒ `400`.

### הגוף, הכותרת והמשתנים

```jsonc
POST /templates
{
  "instanceId": "wa",                  // רשות
  "name": "order_shipped",             // a-z, 0-9, _ — עד 60 תווים
  "language": "he",                    // he / en / en_US … (ברירת-מחדל he)
  "category": "UTILITY",

  "header": { "type": "text", "text": "עדכון הזמנה {{1}}", "example": "12345" },
  "body": "שלום {{1}}, ההזמנה {{2}} יצאה למשלוח.",
  "examples": ["דנה", "12345"],        // דוגמה לכל משתנה בגוף, לפי הסדר
  "footer": "WeHub",

  "buttons": [
    { "type": "quick_reply",  "text": "תודה" },
    { "type": "url",          "text": "מעקב", "url": "https://example.co.il/track/{{1}}", "example": "ABC123" },
    { "type": "phone_number", "text": "התקשרו", "phone": "+97231234567" },
    { "type": "copy_code",    "example": "SAVE20" }
  ]
}
```

תשובה `201`:

```json
{ "id": "1234567890123456", "name": "order_shipped", "language": "he",
  "category": "UTILITY", "status": "PENDING", "statusHe": "ממתינה לאישור", "instanceId": "wa" }
```

**המגבלות שנאכפות** (הן של מטא; נבדקות אצלנו **לפני** השליחה, ומחזירות `400`
עם הסבר בעברית):

| מה | מגבלה |
|---|---|
| שם | עד **60** תווים; אותיות קטנות, ספרות וקו-תחתון. תווים אחרים מומרים לקו-תחתון — ולכן `name` בתשובה הוא השם שנוצר בפועל |
| כותרת-טקסט | עד **60** תווים, עד **משתנה אחד**, ואז `example` חובה |
| גוף | עד **1024** תווים; אסור להתחיל או להסתיים במשתנה |
| תחתית | עד **60** תווים, בלי משתנים |
| כפתורים | עד **10** בסך הכול: עד **10** תשובה-מהירה, עד **2** קישור, **1** טלפון, **1** העתקת-קוד |
| תווית-כפתור | עד **25** תווים · URL עד **2000** · טלפון עד **20** · דוגמת-קוד עד **20** |

**משתנים**: מיקומיים `{{1}}`, `{{2}}` — ברצף עולה, בלי דילוגים ובלי כפילות —
או נקובים `{{customer_name}}`. לכל משתנה בגוף חייבת להיות דוגמה ב-`examples`
לפי אותו סדר; מטא בודקת את התבנית דרך הדוגמאות. שלוש סיבות-הדחייה הנפוצות
(משתנים לא-רצופים, גוף שמתחיל או מסתיים במשתנה, דוגמה חסרה) נחסמות אצלנו
לפני שהתבנית בכלל נשלחת.

**כפתורים**: כפתורי תשובה-מהירה חייבים להיות **רצופים** — כולם בתחילת הרשימה
או כולם בסופה. `"קישור, תשובה-מהירה, קישור"` נדחה. בכפתור קישור מותר משתנה
אחד בלבד, `{{1}}`, **בסוף** ה-URL, ועם `example`. לכפתור העתקת-קוד אין תווית
מותאמת — רק `example`.

### כותרת עם מדיה

כותרת תמונה / וידאו / מסמך דורשת `handle` שהועלה מראש למטא — **לא** מזהה-מדיה
של הודעה; מטא מבחינה בין השניים.

```bash
POST /templates/header-media
{ "url": "https://cdn.example.com/banner.png", "filename": "banner.png" }

→ 201 { "handle": "4::aW1hZ2UvcG5n:ARZ…", "mime": "image/png", "filename": "banner.png", "size": 48213, "instanceId": "wa" }
```

ואז ביצירה: `"header": { "type": "image", "handle": "4::aW1hZ2UvcG5n:ARZ…" }`.

* השרת מוריד מהכתובת (https ציבורי בלבד, עד **15MB**), מוודא סוג, ומעלה.
* הסוגים כאן צרים מאלה של `media.url` בשליחה: **`image/*`, `video/mp4`,
  `application/pdf` בלבד** — אלה פורמטי-הכותרת של מטא. אחר ⇒ `400` עם
  `details.allowed`.
* ה-`handle` תקף לזמן מוגבל אצל מטא — השתמשו בו מיד ביצירת התבנית.

### קריאה, סטטוס ומחיקה

```bash
# כל התבניות, עם סינון
GET /templates?status=APPROVED&language=he

# תבנית אחת — בלי language מוחזרות כל השפות
GET /templates/order_shipped?language=he

# מחיקה — כל השפות של השם
DELETE /templates/order_shipped
→ 200 { "ok": true, "name": "order_shipped", "instanceId": "wa" }
```

* `GET /templates` מחזיר `{ "instanceId", "items": [...] }`. הסינון
  (`name` · `status` · `language`) מתבצע בצד שלנו על הרשימה המלאה, ו-`status`
  ו-`language` אינם רגישים לרישיות.
* `GET /templates/{name}` **עם** `language` מחזיר את אובייקט-התבנית;
  **בלעדיו** מחזיר `{ "instanceId", "items": [...] }` — לתבנית אחת אצל מטא יש
  גרסה נפרדת, עם סטטוס נפרד, לכל שפה. שם שאינו קיים ⇒ `404`.
* `DELETE` מוחק את **השם** על כל שפותיו — כך מטא עובדת, אין מחיקת-שפה בודדת.
  המחיקה אינה הפיכה, ושליחה של תבנית שנמחקה תיכשל.

### שגיאות שכדאי להתנות עליהן

| קוד | `details` | מתי |
|---|---|---|
| `400 validation` | `message` בעברית | הפרת מגבלה, משתנה בלי דוגמה, קטגוריה או קוד-שפה לא תקינים |
| `409 conflict` | `code: "not-connected"` | מופע-הווטסאפ אינו מחובר למטא — יש להשלים את חיבור הערוץ בהגדרות |
| `409 conflict` | `code: "template-exists"` | השם+השפה כבר קיימים במטא. בחרו שם אחר, או מחקו את הקיימת קודם |
| `404 not-found` | `template`, `instanceId` | קריאה או מחיקה של שם שאינו קיים במופע |

---

## 8. מדיה · Media

```json
{ "to": "+972501234567", "media": { "url": "https://cdn.example.com/inv/12345.pdf", "filename": "invoice-12345.pdf", "caption": "החשבונית שלך" } }
```

* השרת **מוריד** מהכתובת (https ציבורי בלבד — כתובות פנימיות נדחות), מאמת,
  שומר, ושולח כ-`image` / `video` / `audio` / `document` לפי הסוג. התשובה: `"sent": "media"`.
* עד **15MB**. סוגים: `image/*`, `video/mp4`, `audio/*`, `application/pdf`,
  `doc`/`docx`/`xls`/`xlsx`. אחר ⇒ `400` עם `details.allowed`.
* `filename` — מה שהלקוח יראה; ברירת-מחדל: מהכתובת. `caption` — כיתוב (ווטסאפ).
* `media` לא משתלב עם `text` (השתמשו ב-`caption`) או עם `template` (כותרת-מדיה
  של תבנית נמסרת ב-`template.header.mediaId`).
* דורש חלון-מענה פתוח, כמו טקסט. במייל אין קבצים דרך ה-API (`409`) — שלחו קישור בגוף.

---

## 9. אידמפוטנטיות · Idempotency

מערכת שנופלת באמצע (timeout, ריסטארט, ניסיון-חוזר של תור) שולחת את אותה
בקשה שוב. בלי הגנה, הלקוח מקבל **שתי** הודעות-ווטסאפ על אותה הזמנה — ואין
דרך למשוך אחת חזרה.

```http
POST /messages
Idempotency-Key: order-12345-shipped
```

או `"idempotencyKey": "order-12345-shipped"` בגוף (הגוף גובר).

* אותו מפתח ב-**24 השעות** הבאות מחזיר את התשובה המקורית — `200` עם
  `"replayed": true` — ולא שולח שוב. המפתח הוא פר-עסק, עד 255 תווים.
* רק **הצלחה** נשמרת. בקשה שנכשלה (`400`/`409`/`502`) משחררת את המפתח מיד —
  תקנו ונסו שוב עם אותו מפתח.
* בקשה עם אותו מפתח **בזמן** שהראשונה עדיין רצה ⇒ `409` עם
  `details.code:"idempotency-in-flight"`. המתינו רגע ונסו שוב.

מפתח טוב הוא מזהה-האירוע העסקי: `order-12345-shipped`, `ticket-88-reply-3`.

---

## 10. נתיבים נוספים · Other endpoints

### אנשי-קשר

```bash
GET    /contacts?q=דנה&tag=vip&page=1&limit=25
POST   /contacts       {"name":"דנה כהן","identities":[{"kind":"wa","value":"+972501234567"}]}
GET    /contacts/{publicId}
PATCH  /contacts/{publicId}   {"tags":["vip"],"fields":{"city":"תל אביב"}}
```

`kind` הוא אחד מ-`wa` · `tel` · `email` · `ig` · `fb`. הערך מנורמל בשרת
(E.164 בלי `+` לטלפון, lower-case לאימייל), ולכן `+972-50-123-4567` ו-
`0501234567` מגיעים לאותו כרטיס.

### שיחות

```bash
GET /conversations?status=open&channel=wa&instanceId=wa&limit=25&cursor=<nextCursor>
GET /conversations/{id}
GET /conversations/{id}/messages?limit=50&before=<messageId>
```

העימוד הוא cursor-based: העבירו את `nextCursor` מהתשובה הקודמת. `null` = סוף.
`windowOpen` בשיחה אומר אם אפשר לשלוח עכשיו טקסט חופשי.

### ‏webhooks יוצאים

```bash
GET    /webhooks
POST   /webhooks     {"url":"https://example.com/wehub","events":["message.received","message.delivered"]}
GET    /webhooks/{id}
PATCH  /webhooks/{id}
DELETE /webhooks/{id}
GET    /webhooks/{id}/deliveries?status=failed&limit=50
```

התשובה ל-`POST` נושאת `secret` — **פעם אחת**. שמרו אותו; הוא החותם. מנוי
לאירועי-תוכן דורש שהמפתח יחזיק גם `conversations:read`.

---

## 11. ‏webhooks — קליטה ואימות · Receiving webhooks

### האירועים

| אירוע | מתי | מה בגוף |
|---|---|---|
| `message.received` | הודעה נכנסת בכל ערוץ | `conversation`, `message` |
| `message.sent` | הודעה יוצאת נשלחה בפועל | `conversation`, `message` |
| `message.delivered` | הודעה יוצאת נמסרה ללקוח | `conversation`, `message` (`status:"delivered"`) |
| `message.read` | הודעה יוצאת נקראה | `conversation`, `message` (`status:"read"`) |
| `message.failed` | שליחה נכשלה (ספק דחה / מצב-הדמיה) | `conversation`, `message`, `error: {code, he}` |
| `conversation.opened` | שיחה חדשה נפתחה | `conversation` |
| `conversation.closed` | שיחה נסגרה | `conversation`, `closeReason`, `closedBy` |
| `conversation.assigned` | רשימת-האחראים השתנתה | `conversation`, `assignees`, `previousAssignees` |
| `csat.received` | הלקוח דירג את השירות | `conversation`, `score`, `scale` |
| `call.ended` | שיחת-טלפון הסתיימה | `conversation`, `call` |
| `template.status_changed` | מטא אישרה / דחתה / השהתה תבנית | `instanceId`, `template`, `status`, `statusHe`, `reason`, `reasonHe`, `at` |

אירועי-הסטטוס נפלטים **פעם אחת** לכל (הודעה, סטטוס) — גם כשהספק מדווח פעמיים.
`message.id` בגוף הוא ה-`id` שקיבלתם מ-`POST /messages`.

`template.status_changed` אינו אירוע-תוכן — אין בו זהות-לקוח ולא טקסט-שיחה —
ולכן המנוי אליו אינו דורש `conversations:read`. גופו:

```json
{
  "instanceId": "wa",
  "template": { "id": "1234567890123456", "name": "order_shipped", "language": "he" },
  "status": "REJECTED",
  "statusHe": "נדחתה",
  "reason": "PROMOTIONAL",
  "reasonHe": "מטא סימנה את התבנית כתוכן שיווקי-מדי ביחס לקטגוריה שנבחרה…",
  "at": "2026-09-04T11:02:00.000Z"
}
```

`status` הוא הערך של מטא (`APPROVED` · `REJECTED` · `PENDING` · `PAUSED` ·
`DISABLED` · `IN_APPEAL` …), ו-`statusHe` אותו ערך בעברית. `reason`/`reasonHe`
מלאים בעיקר ב-`REJECTED`. **אין כאן דה-דופליקציה בצד שלנו**: אותם (שם, סטטוס)
חוזרים לגיטימית אחרי עריכה ושליחה-מחדש. דה-דופ, כרגיל, לפי `id` של המעטפת.

### הגוף

```json
{
  "id": "01J8XK2Q9M4V7C0RTBNEFGHJ5A",
  "event": "message.delivered",
  "tenantId": "01J8XA0000000000000000TEN",
  "at": "2026-09-04T10:12:03.000Z",
  "attempt": 1,
  "data": {
    "conversation": { "id": "l7d3p0", "channel": "wa", "instanceId": "wa", "status": "pending", "windowOpen": true, "contact": { "id": "01J8…", "name": "דנה כהן" } },
    "message": { "id": "m8x2k1q9", "conversationId": "l7d3p0", "direction": "out", "kind": "template", "status": "delivered", "text": "שלום דנה, ההזמנה 12345 יצאה למשלוח.", "at": "2026-09-04T10:12:00.000Z" }
  }
}
```

`id` **יציב בין ניסיונות** — השתמשו בו לדה-דופליקציה. `tenantId` הוא המזהה
הציבורי של העסק.

### החתימה

```
x-wehub-signature: v1=<hex>
x-wehub-timestamp: 1756800000
```

```
signature = HMAC_SHA256(secret, timestamp + "." + rawBody)
```

**חשוב**: החתימה מחושבת על ה-**גוף הגולמי**, לפני כל פרסור. מסגרת שממירה
JSON לאובייקט ואז מסדרת אותו מחדש תייצר מחרוזת אחרת והאימות ייכשל.

בדקו גם את `x-wehub-timestamp`: הפרש גדול מ-**300 שניות** מהשעון שלכם הוא
ניסיון-replay ויש לדחות אותו.

### ניסיונות-חוזרים · Retries

תשובה שאינה `2xx`, פסק-זמן של 10 שניות או שגיאת-רשת גוררים ניסיון חוזר:
+1 דקה, +5 דקות, +30 דקות, +2 שעות, +12 שעות. אחרי השישי המשלוח מסומן
`exhausted`. כל הניסיונות נראים ב-`GET /webhooks/{id}/deliveries`.

**כללי-אצבע לצד המקבל**

* ענו `2xx` **לפני** שאתם מעבדים. 10 שניות זה פחות ממה שנדמה.
* דה-דופליקציה לפי `id`. אותו אירוע יכול להגיע פעמיים.
* אין הבטחת-סדר. `message.delivered` יכול להגיע לפני `message.sent`.
* הפניות (`3xx`) אינן נעקבות ואינן הצלחה. תנו את הכתובת הסופית.
* `https` בלבד; כתובות פנימיות (`localhost`, `10.x`, `169.254.x`) נדחות
  בזמן-ההגדרה.

---

## 12. דוגמאות-קוד · Code samples

ארבע הדוגמאות הראשונות שולחות את אותה בקשה — טקסט + תבנית עם
מפתח-אידמפוטנטיות — ואז מאמתות webhook. אחריהן, אותן ארבע שפות ליצירת תבנית
ובדיקת הסטטוס שלה. החליפו `WEHUB_API_KEY` ו-`WEHUB_WEBHOOK_SECRET`.

### curl

```bash
export WEHUB_API_KEY=wh_live_…
export WEHUB_BASE=https://api.wehub.co.il/api/v1/public

curl -s -X POST "$WEHUB_BASE/messages" \
  -H "Authorization: Bearer $WEHUB_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: order-12345-shipped' \
  -d '{
    "to": "+972501234567",
    "text": "היי דנה, ההזמנה 12345 יצאה למשלוח",
    "template": { "name": "order_shipped", "language": "he", "body": { "name": "דנה", "order": "12345" } }
  }'

# מצב-מסירה
curl -s "$WEHUB_BASE/messages/m8x2k1q9" -H "Authorization: Bearer $WEHUB_API_KEY"

# הערוצים שלכם
curl -s "$WEHUB_BASE/channels" -H "Authorization: Bearer $WEHUB_API_KEY"
```

### Node.js

```js
// שליחה — fetch מובנה (Node 18+)
const BASE = 'https://api.wehub.co.il/api/v1/public';
const KEY = process.env.WEHUB_API_KEY;

async function sendOrderShipped(phone, name, order) {
  const res = await fetch(`${BASE}/messages`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': `order-${order}-shipped`,
    },
    body: JSON.stringify({
      to: phone,
      text: `היי ${name}, ההזמנה ${order} יצאה למשלוח`,
      template: { name: 'order_shipped', language: 'he', body: { name, order } },
    }),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(`${data.error}: ${data.text}`);   // data.details מפרט
  return data;   // { id, channel, sent: 'text' | 'template', status, mock, replayed }
}

// קליטת webhook — Express
const crypto = require('node:crypto');
const express = require('express');
const app = express();
const SECRET = process.env.WEHUB_WEBHOOK_SECRET;

// ‏express.json שומר את הגוף הגולמי — בלעדיו אי-אפשר לאמת.
app.use('/wehub', express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));

function verify(req) {
  const presented = String(req.get('x-wehub-signature') || '');
  const timestamp = Number(req.get('x-wehub-timestamp') || 0);
  if (!presented.startsWith('v1=') || !Number.isFinite(timestamp)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) return false;   // replay
  const expected = 'v1=' + crypto.createHmac('sha256', SECRET)
    .update(`${timestamp}.${req.rawBody.toString('utf8')}`).digest('hex');
  const a = Buffer.from(expected), b = Buffer.from(presented);
  return a.length === b.length && crypto.timingSafeEqual(a, b);   // השוואה בזמן-קבוע
}

app.post('/wehub', (req, res) => {
  if (!verify(req)) return res.status(401).end();
  res.status(200).end();                     // עונים מיד; מעבדים אחר-כך
  const { id, event, data } = req.body;
  if (alreadyHandled(id)) return;            // `id` יציב בין ניסיונות
  if (event === 'message.delivered') markDelivered(data.message.id);
  if (event === 'message.failed') alertOps(data.message.id, data.error.he);
});
app.listen(3000);
```

### PHP

```php
<?php
declare(strict_types=1);

const WEHUB_BASE = 'https://api.wehub.co.il/api/v1/public';

function wehub_send(string $phone, string $name, string $order): array
{
    $ch = curl_init(WEHUB_BASE . '/messages');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . getenv('WEHUB_API_KEY'),
            'Content-Type: application/json',
            'Idempotency-Key: order-' . $order . '-shipped',
        ],
        CURLOPT_POSTFIELDS     => json_encode([
            'to'       => $phone,
            'text'     => "היי $name, ההזמנה $order יצאה למשלוח",
            'template' => ['name' => 'order_shipped', 'language' => 'he', 'body' => ['name' => $name, 'order' => $order]],
        ], JSON_UNESCAPED_UNICODE),
    ]);
    $raw    = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    $data = json_decode((string) $raw, true) ?? [];
    if ($status >= 400) {
        throw new RuntimeException(($data['error'] ?? 'error') . ': ' . ($data['text'] ?? ''));
    }
    return $data;   // id, channel, sent, status, mock, replayed
}

// ‏קליטת webhook — אימות על הגוף הגולמי, לפני json_decode.
function wehub_verify(string $secret): bool
{
    $presented = $_SERVER['HTTP_X_WEHUB_SIGNATURE'] ?? '';
    $timestamp = (int) ($_SERVER['HTTP_X_WEHUB_TIMESTAMP'] ?? 0);
    if (!str_starts_with($presented, 'v1=') || abs(time() - $timestamp) > 300) {
        return false;
    }
    $body     = file_get_contents('php://input');
    $expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
    return hash_equals($expected, $presented);
}

if (!wehub_verify(getenv('WEHUB_WEBHOOK_SECRET') ?: '')) {
    http_response_code(401);
    exit;
}
http_response_code(200);
if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();              // עונים מיד; העיבוד אחרי סגירת החיבור
}
$payload = json_decode(file_get_contents('php://input'), true);
if ($payload['event'] === 'message.failed') {
    error_log('WeHub failed: ' . $payload['data']['message']['id'] . ' — ' . $payload['data']['error']['he']);
}
```

### Python

```python
import hashlib, hmac, os, time
import requests   # pip install requests

BASE = 'https://api.wehub.co.il/api/v1/public'
HEADERS = {'Authorization': f"Bearer {os.environ['WEHUB_API_KEY']}"}

def send_order_shipped(phone: str, name: str, order: str) -> dict:
    r = requests.post(f'{BASE}/messages', headers={**HEADERS, 'Idempotency-Key': f'order-{order}-shipped'}, json={
        'to': phone,
        'text': f'היי {name}, ההזמנה {order} יצאה למשלוח',
        'template': {'name': 'order_shipped', 'language': 'he', 'body': {'name': name, 'order': order}},
    }, timeout=30)
    data = r.json()
    if r.status_code >= 400:
        raise RuntimeError(f"{data['error']}: {data['text']} {data.get('details')}")
    return data   # id, channel, sent, status, mock, replayed

def message_status(message_id: str) -> str:
    r = requests.get(f'{BASE}/messages/{message_id}', headers=HEADERS, timeout=30)
    r.raise_for_status()
    return r.json()['status']   # queued | sent | delivered | read | failed

# קליטת webhook — Flask
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['WEHUB_WEBHOOK_SECRET'].encode()

def verify(req) -> bool:
    presented = req.headers.get('x-wehub-signature', '')
    ts = int(req.headers.get('x-wehub-timestamp', '0') or 0)
    if not presented.startswith('v1=') or abs(time.time() - ts) > 300:
        return False
    expected = 'v1=' + hmac.new(SECRET, f'{ts}.'.encode() + req.get_data(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, presented)

@app.post('/wehub')
def wehub():
    if not verify(request):
        abort(401)
    payload = request.get_json()
    handle_async(payload['id'], payload['event'], payload['data'])   # דה-דופ לפי id, עיבוד ברקע
    return '', 200
```

### יצירת תבנית ובדיקת סטטוס · Create a template and poll its status

התבנית נולדת `PENDING`; האישור מגיע דקות עד שעות אחר-כך. מי שמאזין ל-webhooks
מקבל `template.status_changed` ולא צריך את הפולינג שכאן.

#### curl

```bash
export WEHUB_API_KEY=wh_live_…                     # scope: templates:write
export WEHUB_BASE=https://api.wehub.co.il/api/v1/public

# 1. מדיה לכותרת (רק לתבנית עם כותרת תמונה/וידאו/מסמך)
curl -s -X POST "$WEHUB_BASE/templates/header-media" \
  -H "Authorization: Bearer $WEHUB_API_KEY" -H 'Content-Type: application/json' \
  -d '{"url":"https://cdn.example.com/banner.png","filename":"banner.png"}'
#   → { "handle": "4::aW1hZ2UvcG5n:ARZ…", … }

# 2. יצירה
curl -s -X POST "$WEHUB_BASE/templates" \
  -H "Authorization: Bearer $WEHUB_API_KEY" -H 'Content-Type: application/json' \
  -d '{
    "name": "order_shipped",
    "language": "he",
    "category": "UTILITY",
    "body": "שלום {{1}}, ההזמנה {{2}} יצאה למשלוח.",
    "examples": ["דנה", "12345"],
    "footer": "WeHub",
    "buttons": [{"type":"url","text":"מעקב","url":"https://example.co.il/track/{{1}}","example":"ABC123"}]
  }'
#   → 201 { "id": "…", "status": "PENDING", "statusHe": "ממתינה לאישור", … }

# 3. בדיקת סטטוס
curl -s "$WEHUB_BASE/templates/order_shipped?language=he" \
  -H "Authorization: Bearer $WEHUB_API_KEY" | jq '{status, statusHe, rejectedReasonHe}'

# 4. מחיקה (כל השפות של השם)
curl -s -X DELETE "$WEHUB_BASE/templates/order_shipped" -H "Authorization: Bearer $WEHUB_API_KEY"
```

#### Node.js

```js
const BASE = 'https://api.wehub.co.il/api/v1/public';
const KEY = process.env.WEHUB_API_KEY;
const headers = { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' };

async function call(method, path, body) {
  const res = await fetch(`${BASE}${path}`, { method, headers, ...(body ? { body: JSON.stringify(body) } : {}) });
  const data = await res.json();
  if (!res.ok) throw new Error(`${data.error}: ${data.text} ${JSON.stringify(data.details ?? {})}`);
  return data;
}

async function createOrderShipped() {
  const created = await call('POST', '/templates', {
    name: 'order_shipped',
    language: 'he',
    category: 'UTILITY',
    body: 'שלום {{1}}, ההזמנה {{2}} יצאה למשלוח.',
    examples: ['דנה', '12345'],
    footer: 'WeHub',
    buttons: [{ type: 'url', text: 'מעקב', url: 'https://example.co.il/track/{{1}}', example: 'ABC123' }],
  });
  return created;                       // { id, name, status: 'PENDING', … }
}

// ‏פולינג — למי שלא עובד עם webhooks. `template.status_changed` עדיף.
async function waitForApproval(name, language = 'he', everyMs = 60_000, tries = 60) {
  for (let i = 0; i < tries; i++) {
    const t = await call('GET', `/templates/${encodeURIComponent(name)}?language=${language}`);
    if (t.status !== 'PENDING') return t;            // APPROVED / REJECTED
    await new Promise((r) => setTimeout(r, everyMs));
  }
  throw new Error('התבנית עדיין ממתינה לאישור');
}

// ‏כותרת עם מדיה: קודם handle, ואז header ביצירה.
async function withImageHeader(url) {
  const { handle } = await call('POST', '/templates/header-media', { url });
  return call('POST', '/templates', {
    name: 'promo_banner', category: 'MARKETING',
    header: { type: 'image', handle },
    body: 'שלום {{1}}, המבצע שלנו נגמר מחר.', examples: ['דנה'],
  });
}
```

#### PHP

```php
<?php
declare(strict_types=1);

const WEHUB_BASE = 'https://api.wehub.co.il/api/v1/public';

function wehub_call(string $method, string $path, ?array $body = null): array
{
    $ch = curl_init(WEHUB_BASE . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . getenv('WEHUB_API_KEY'),
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body, JSON_UNESCAPED_UNICODE),
    ]);
    $raw    = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    $data = json_decode((string) $raw, true) ?? [];
    if ($status >= 400) {
        // ‏details.code: "not-connected" / "template-exists" — אפשר להתנות עליו.
        throw new RuntimeException(($data['error'] ?? 'error') . ': ' . ($data['text'] ?? ''));
    }
    return $data;
}

$created = wehub_call('POST', '/templates', [
    'name'     => 'order_shipped',
    'language' => 'he',
    'category' => 'UTILITY',
    'body'     => 'שלום {{1}}, ההזמנה {{2}} יצאה למשלוח.',
    'examples' => ['דנה', '12345'],
    'footer'   => 'WeHub',
    'buttons'  => [['type' => 'url', 'text' => 'מעקב', 'url' => 'https://example.co.il/track/{{1}}', 'example' => 'ABC123']],
]);                                        // status: PENDING

$state = wehub_call('GET', '/templates/order_shipped?language=he');
echo $state['status'] . ' — ' . $state['statusHe'] . PHP_EOL;
if ($state['status'] === 'REJECTED') {
    echo $state['rejectedReasonHe'] . PHP_EOL;
}
```

#### Python

```python
import os, time
import requests   # pip install requests

BASE = 'https://api.wehub.co.il/api/v1/public'
HEADERS = {'Authorization': f"Bearer {os.environ['WEHUB_API_KEY']}"}

def call(method: str, path: str, body: dict | None = None) -> dict:
    r = requests.request(method, f'{BASE}{path}', headers=HEADERS, json=body, timeout=30)
    data = r.json()
    if r.status_code >= 400:
        raise RuntimeError(f"{data['error']}: {data['text']} {data.get('details')}")
    return data

created = call('POST', '/templates', {
    'name': 'order_shipped',
    'language': 'he',
    'category': 'UTILITY',
    'body': 'שלום {{1}}, ההזמנה {{2}} יצאה למשלוח.',
    'examples': ['דנה', '12345'],
    'footer': 'WeHub',
    'buttons': [{'type': 'url', 'text': 'מעקב', 'url': 'https://example.co.il/track/{{1}}', 'example': 'ABC123'}],
})
print(created['status'], created['statusHe'])          # PENDING ממתינה לאישור

def wait_for_approval(name: str, language: str = 'he', every: int = 60, tries: int = 60) -> dict:
    for _ in range(tries):
        t = call('GET', f'/templates/{name}?language={language}')
        if t['status'] != 'PENDING':
            return t                                    # APPROVED / REJECTED
        time.sleep(every)
    raise TimeoutError('התבנית עדיין ממתינה לאישור')

state = wait_for_approval('order_shipped')
if state['status'] == 'REJECTED':
    print(state['rejectedReasonHe'])
```

---

## 13. סיבוב וביטול · Rotation

* **מפתח שדלף** — סיבוב במסך "API ומפתחים" (או
  `POST /api/v1/admin/integrations/api-keys/{id}/rotate`). מקבלים מפתח חדש,
  והישן מפסיק לעבוד **מיד**. אין חפיפה: זו בדיוק הפעולה שמבצעים כשחושדים
  בדליפה. מי שצריך חפיפה מנפיק מפתח שני ומבטל את הראשון כשסיים לעבור.
* **סוד-webhook שדלף** — `POST …/webhooks/{id}/rotate-secret`, באותה סמנטיקה.
* **"בדוק חיבור"** — `POST …/webhooks/{id}/test` שולח משלוח-בדיקה מיידי
  (`data.test === true`) ומחזיר את שורת-היומן עם קוד-התשובה.

---

## 14. Quick start

```bash
export WEHUB_API_KEY=wh_live_…
export WEHUB_BASE=https://api.wehub.co.il/api/v1/public

# ‏1. מה ה-API יודע לעשות, ומה הערוצים שלכם
curl -s "$WEHUB_BASE/openapi.json" | jq '.paths | keys'
curl -s "$WEHUB_BASE/channels" -H "Authorization: Bearer $WEHUB_API_KEY" | jq '.items[] | {id, display, enabled}'

# ‏2. אילו תבניות מאושרות (ואם אין — יוצרים אחת; ר' §7)
curl -s "$WEHUB_BASE/templates?status=APPROVED" -H "Authorization: Bearer $WEHUB_API_KEY" | jq '.items[] | {name, language, params}'

# ‏3. הודעה — טקסט אם אפשר, תבנית אם צריך
curl -s -X POST "$WEHUB_BASE/messages" \
  -H "Authorization: Bearer $WEHUB_API_KEY" -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: demo-1' \
  -d '{"to":"+972501234567","text":"שלום!","template":{"name":"welcome","language":"he"}}'

# ‏4. webhook לסטטוסי-מסירה
curl -s -X POST "$WEHUB_BASE/webhooks" \
  -H "Authorization: Bearer $WEHUB_API_KEY" -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com/wehub","events":["message.received","message.delivered","message.read","message.failed"]}'
#   → שמרו את ה-secret מהתשובה. הוא לא יוצג שוב.
```
