# Invoice engine API – reference for developers and AI agents

> Israeli invoicing system (tax invoices, receipts, credit notes, quotes, delivery notes) that follows the Israeli
> bookkeeping regulations (הוראות ניהול פנקסים). One file with everything needed to use it: REST API, MCP server,
> webhooks. Human-readable version: https://retsef.co.il/docs · OpenAPI 3.1: https://retsef.co.il/docs/openapi.json ·
> MCP endpoint: https://retsef.co.il/api/mcp

## For AI agents and LLMs (read first)

- **What it is.** A document-issuing engine for Israeli businesses. Every issued document gets the next number in
  its series, is stored forever and is chained by hash. **Issued documents can never be edited or deleted**; a
  correction is always a new document (a credit note, docType 330, on the invoice).
- **Base URL.** `https://retsef.co.il/api`. Every path in the reference below is written as `/v1/...`; call it as
  `https://retsef.co.il/api/v1/...` (examples that say `https://<engine>/v1/...` mean the same). Health: `GET https://retsef.co.il/api/health`.
  `/v1/internal/*` is not reachable through the site.
- **Auth.** `Authorization: Bearer <token>` on every call except `POST /v1/auth/signup` and
  `POST /v1/auth/login`. Tokens: user session `ies_...` (12 hours, from login/signup); personal access token
  `iep_...` (up to a year, `POST /v1/me/tokens`, needs the password again); business API key `iek_...` (one
  business, view + issue; server-side only, never in browser code). What a user may do depends on their role in
  each business: `owner` everything, `staff` view + issue, `accountant` view + export.
- **Amounts** are integers in agorot (1 ILS = 100 agorot). `unitPrice` is before VAT unless
  `pricesIncludeVat: true`. **Dates** are `YYYY-MM-DD`; timestamps are ISO 8601 in UTC.
- **Document types (docType):** 10 quote, 100 order, 200 delivery note, 210 return note, 300 transaction invoice
  (חשבון עסקה), 305 tax invoice, 320 tax invoice/receipt (full payment only), 330 credit note (needs `base` +
  `creditReason`), 400 receipt (needs `notes` or `base`), 405 donation receipt. **Payment methods:** 1 cash,
  2 cheque, 3 credit card, 4 bank transfer, 8 standing order, 9 other (`methodLabel`).
- **Irreversible actions need explicit user confirmation.** Issuing a document, producing an original, recording
  an allocation number, registering a business, opening a series or changing its start number, a new signing key,
  the tax-office notice date, revoking keys/tokens/invitations/share links, removing members and deleting webhooks
  cannot be undone. Before any of them: show the user exactly what will happen (for documents, call the same endpoint with
  `"dryRun": true` and show the result: type, number, customer, amounts) and wait for the user's explicit "yes".
  Never confirm on the user's behalf, and never retry an issue call that may have succeeded without checking
  `GET /v1/businesses/:id/documents` first. To show the document itself, `POST /v1/businesses/:id/documents/preview`
  with the same body returns the exact PDF (watermarked, no number) and saves nothing.
- **Share links.** `POST /v1/documents/:docId/share-links` (or `"shareLink": true` when issuing) returns a
  permanent URL (`/s/<token>`, shown once) that anyone holding it can open without logging in, to send to the
  customer. It serves the signed original only when the rules allow it, otherwise a copy.
- **Customer consent** to computerized documents must be explicit and provable: `POST /v1/businesses/:id/consents`
  needs `evidence.description` (what proves it), or send the customer a consent link
  (`POST /v1/businesses/:id/consent-links`) so they approve or withdraw it themselves. Never record consent the
  customer did not give.
- **Customers and items (engine 0.16).** Prefer a customer card: `GET /v1/businesses/:id/customers?q=...`
  (or create one), then issue with `"customerId": "cus_..."` (or `customerExternalId`) instead of a full
  `customer` object; fields you do send in `customer` override the card. A line can carry
  `"itemId": "itm_..."` (or `itemSku`) from `GET /v1/businesses/:id/items` to fill its description and price.
  The document keeps its own snapshot, so editing a card never changes issued documents.
- **Israel Tax Authority (engine 0.17).** When the business is connected (the owner connects on the website only:
  settings → tax authority), issuing a tax invoice above the allocation threshold requests the allocation number
  right after issuing and returns it in `ita.allocation`; a donation receipt of a nonprofit is reported
  (`ita.donation`). A failure never undoes the issue: the document waits ("awaiting allocation number") and
  `POST /v1/documents/:docId/ita/allocation` (or `/ita/donation`) retries. A delayed invoice (460) needs the
  user's decision (`/ita/decision`). `GET /v1/businesses/:id/ita` shows the connection.
- **Signing.** By default the business signing key is held on the server and signing is automatic: any API key or
  token that may issue gets signed computerized originals, with no code. Only if the owner opted into a personal
  signing code (`GET /v1/businesses/:id/signing`, `protection: "pin"`) is a computerized original signed only
  with `"signingPin"` in the body, or by an API key / personal token the owner delegated signing to; otherwise 422
  with `"signingError": "pin_required"`. Never ask the user to paste the signing code into a chat: point them to
  the website (where the owner can also switch back to automatic signing), or produce a printed original
  (`computerized: false`).
- **Errors** are JSON `{ "error": "<message in Hebrew>" }` with the HTTP status: 400 bad request, 401 missing or
  invalid token (login may add `"twoFactorRequired": true`), 403 not allowed, 404 not found, 409 conflict, 413
  body too large, 422 the engine refused for a business rule (show the Hebrew message to the user; do not try to
  work around it), 429 rate limited.
- **Rate limits (engine 0.20).** Per API key / personal token: 10 requests per second (bursts up to 20) and 120 per
  minute; a login session 300 per minute. Issuing (without `dryRun`) is limited per business to 2 per second and 60
  per minute. Over a limit: 429 with `Retry-After` (seconds) and `RateLimit-*` headers; the request was not
  performed (no number is used), so wait at least `Retry-After` and retry, doubling the wait if it repeats. Never
  retry in a tight loop, and issue one document at a time.
- **Language.** The reference is in Hebrew; paths, field names and JSON are in English.

## הנחיות לסוכני AI (עברית)

- מערכת הפקת מסמכים חשבונאיים לעסקים בישראל, לפי הוראות ניהול פנקסים. **מסמך שהופק לא נמחק ולא משתנה**; תיקון
  רק במסמך חדש (חשבונית מס זיכוי, 330).
- **כתובת הבסיס:** `https://retsef.co.il/api`. הנתיבים בתיעוד כתובים כ-`/v1/...`, ושולחים אותם ל-`https://retsef.co.il/api/v1/...`.
- **הרשאה:** `Authorization: Bearer <token>` בכל קריאה, חוץ מהרשמה וכניסה. התפקיד בכל עסק (בעל העסק, עובד,
  רואה חשבון) קובע מה מותר.
- **סכומים** במספרים שלמים באגורות (100 = 1 ₪). **תאריכים** `YYYY-MM-DD`.
- **פעולה בלתי הפיכה** (הפקת מסמך, מקור, מספר הקצאה, רישום עסק, סדרות, מפתח חתימה, ביטולים ומחיקות): קודם מציגים
  למשתמש תצוגה מקדימה (`"dryRun": true` בהפקה, או ה-PDF עצמו מ-`POST /v1/businesses/:id/documents/preview`)
  ומקבלים אישור מפורש. לעולם לא מאשרים בשם המשתמש.
- **קישור שיתוף ללקוח:** `POST /v1/documents/:docId/share-links`, או `"shareLink": true` בהפקה. הכתובת מוחזרת
  פעם אחת; ביטול ב-`DELETE /v1/share-links/:linkId`.
- **לקוחות ופריטים (מנוע 0.16):** עדיף כרטיס לקוח (`customerId`, או `customerExternalId`) על פרטי לקוח מלאים,
  ובשורה `itemId` (או `itemSku`) מהמחירון. מה שנשלח בבקשה גובר על הכרטיס ועל הפריט, והמסמך שומר צילום משלו.
- **רשות המסים (מנוע 0.17):** כשהעסק מחובר (ההתחברות רק באתר), מספר ההקצאה מתבקש מיד אחרי ההפקה (`ita.allocation`
  בתשובה), וקבלה על תרומה מדווחת (`ita.donation`). כישלון לא מבטל את ההפקה: המסמך ממתין, ומנסים שוב ב-`/ita/allocation`.
- **חתימה:** כברירת מחדל מפתח החתימה שמור בשרת והחתימה אוטומטית – כל מפתח API או טוקן שמפיקים מקבלים מקור ממוחשב
  חתום, בלי קוד. רק אם בעל העסק בחר בקוד חתימה אישי (`protection: "pin"`), מקור ממוחשב נחתם רק עם `signingPin`
  או במפתח / טוקן שבעל העסק האציל להם חתימה; אחרת 422 עם `signingError: "pin_required"`. לא מבקשים מהמשתמש להדביק
  את הקוד בשיחה: מפנים לאתר (שם אפשר גם לחזור לחתימה אוטומטית), או מפיקים מקור להדפסה.
- **שגיאות:** `{ "error": "..." }` בעברית. 422 = המנוע סירב, וההודעה מסבירה למה; מציגים אותה למשתמש ולא עוקפים.
- **מגבלות קצב (מנוע 0.20):** 429 עם `Retry-After` – הבקשה לא בוצעה; מחכים ושולחים שוב, ומכפילים את ההמתנה אם זה חוזר.
  הפקה: 2 בשנייה ו-60 בדקה לכל עסק, אחת אחרי השנייה ולא במקביל.

## Quick start

```bash
BASE=https://retsef.co.il/api
```

### 1. הרשמה או כניסה

מקבלים טוקן כניסה (ies_), בתוקף 12 שעות. משתמש עם אימות דו-שלבי שולח גם code בכניסה.

```bash
curl -s -X POST "$BASE/v1/auth/signup" -H "Content-Type: application/json" \
  -d '{"email":"me@example.com","name":"ישראל ישראלי","password":"<10+ chars>","acceptTerms":true}'
# או כניסה למשתמש קיים:
curl -s -X POST "$BASE/v1/auth/login" -H "Content-Type: application/json" \
  -d '{"email":"me@example.com","password":"<password>"}'
# → { "token": "ies_...", "user": {...}, "expiresAt": "..." }
TOKEN=ies_...
```

### 2. טוקן לחיבור (לא חובה)

לחיבור שרץ לאורך זמן: טוקן גישה אישי (iep_) עד שנה, או מפתח API של עסק (iek_) בהגדרות העסק. רק בכניסה עם סיסמה, ועם הסיסמה שוב בגוף הבקשה.

```bash
curl -s -X POST "$BASE/v1/me/tokens" -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"CRM","expiresInDays":90,"currentPassword":"<password>"}'
# → 201 { "token": "iep_...", ... }  (מוצג פעם אחת בלבד)
```

### 3. רישום עסק

בפעולה אחת: פרטים, כל הסדרות שמותרות לסוג העסק (עם מספרי התחלה), מפתח חתימה ותאריך ההודעה לפקיד השומה.

```bash
curl -s -X POST "$BASE/v1/businesses/register" -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"העסק שלי","vatNumber":"123456782","kind":"licensed","city":"תל אביב",
       "firstNumbers":{"305":1,"320":1,"330":1,"400":1},"computerizedNoticeSentOn":"2026-10-01"}'
# → 201 { "businessId": "...", "sequences": [...], "certificatePem": "..." }
BIZ=<businessId>
```

### 4. בדיקה בלי הפקה, ואז הפקה

סכומים באגורות (90000 = 900 ₪). עם "dryRun": true שום דבר לא נשמר. אחרי אישור מפורש – אותה בקשה בלי dryRun.

```bash
curl -s -X POST "$BASE/v1/businesses/$BIZ/documents" -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"docType":320,"dryRun":true,"customer":{"name":"משפחת כהן"},
       "lines":[{"description":"הפעלה","quantity":1,"unitPrice":90000}],"pricesIncludeVat":true,
       "payments":[{"method":4,"amount":90000,"paymentDate":"2026-10-30"}]}'
# → 200 { "dryRun": true, "docNumber": 1, "amountTotal": 90000, ... }
# אחרי אישור: אותו גוף בלי "dryRun" → 201 { "id": 431, "docNumber": 1, ... }
DOC=431
```

### 5. מקור

המקור מופק פעם אחת. computerized: true = מקור ממוחשב חתום (דורש הודעה לפקיד השומה והסכמת הלקוח); false = מקור להדפסה.

```bash
curl -s -X POST "$BASE/v1/documents/$DOC/original" -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -d '{"computerized":false}' -o original.pdf
# העתק בכל עת:
curl -s "$BASE/v1/documents/$DOC/copy" -H "Authorization: Bearer $TOKEN" -o copy.pdf
```

---

<!-- The reference below is engine/docs/API.md, verbatim except that every heading is one level lower. -->

## API של מנוע ההפקה

כל פעולה שיש במסכים יש גם ב-API: הרשמת עסק, הפקת כל סוגי המסמכים (כולל חשבונית זיכוי וקבלה על חשבונית),
מספר הקצאה, מקור והעתק, הסכמות, מבנה אחיד, סקירת העסק ויומן הפעולות על כל מסמך, וחיבור לרשות המסים (מנוע 0.17), וקוד חתימה שרק העסק מחזיק (מנוע 0.18), ומינויי אוטומציה ל-Zapier ול-Make (מנוע 0.19), ו[מגבלות קצב](#מגבלות-קצב-מנוע-020) לכל מפתח, טוקן וכניסה (מנוע 0.20).
המסכים עצמם (ההרשמה והאזור האישי) עושים בדיוק את אותן פעולות מול אותו מנוע.

כל הקריאות תחת `/v1` דורשות כותרת `Authorization: Bearer <token>`, חוץ מ-`/v1/auth/login`, `/v1/auth/signup`,
נתיבי [הכניסה עם Google](#כניסה-והרשמה-עם-google) (`/v1/auth/providers`, `/v1/auth/google/*`)
ושני הנתיבים הציבוריים של [קישורי שיתוף](#קישורי-שיתוף) (`/v1/share/:token`), שבהם הטוקן שבנתיב הוא ההרשאה.
יש שישה סוגי טוקנים:

| טוקן | מה מותר |
|---|---|
| **כניסת משתמש** (`ies_...`, מ-`/v1/auth/login`) | לפי התפקיד של המשתמש בכל עסק שהוא שייך אליו (טבלה בהמשך). בתוקף 12 שעות |
| **טוקן גישה אישי** (`iep_...`, מ-`POST /v1/me/tokens`) | בדיוק כמו כניסת המשתמש שיצר אותו, לחיבורים כמו Claude / MCP. בתוקף עד שנה (ברירת מחדל 90 יום). לא יוצר טוקנים, מפתחות API או הזמנות לעסק, לא משנה סיסמה ולא אימות דו-שלבי |
| **מפתח API של עסק** (`iek_...`) | צפייה והפקה בעסק שלו בלבד: חשבוניות, זיכויים, קבלות, מקור, העתק, הסכמות, מבנה אחיד. לא משנה הגדרות, משתמשים או מפתחות. עסק אחר מחזיר 403. חותם על מקור ממוחשב אוטומטית; רק בעסק שבחר בקוד חתימה (מנוע 0.18, אפשרות מתקדמת) – אחרי [האצלת חתימה](#האצלת-חתימה-api-ו-mcp). מנוע 0.19: נרשם ל[מינויי Zapier ו-Make](#מינויי-אוטומציה-zapier-ו-make-מנוע-019) |
| **טוקן שירות** (`ENGINE_API_TOKENS`, למשל של ה-CRM) | צפייה והפקה בכל העסקים, בלי שינוי הגדרות |
| **טוקן מנהל** | הכול, כולל הרשמת עסקים בשם אחרים, איפוס סיסמאות ומספרי התחלה, הענקת [ניהול מערכת](#ניהול-מערכת-מנהלי-פלטפורמה) למשתמש, והמעבר של עסק למצב [מסמכים אמיתיים](#מצב-מסמכים-אמיתיים-מנהל) (גם מנהל פלטפורמה, מהאתר). ב-webhooks: צפייה, השהיה, צמצום, מחיקה, בדיקה ושליחה חוזרת, אבל לא יצירה, הפעלה מחדש או הרחבה (ראו [Webhooks](#webhooks)) |
| **טוקן המשגר של ה-webhooks** (`ENGINE_WEBHOOK_DISPATCH_TOKEN` / Vault `invoice_engine_dispatch_token`) | רק `/v1/internal/webhooks/claim`, `/report` ו-`/routines`, בדרך כלל כאישור קצר-מועד `whd1` (2 דקות). לא תקף בשום נתיב אחר, ושום טוקן אחר לא תקף בהם |

**מה אפשר לעשות עם טוקן המשגר** (או עם אישור `whd1` תקף), אם הוא דולף: לקרוא את משלוחי ה-standard שממתינים בכל
העסקים (גוף ההודעות, החתימות והכתובות המלאות של המקבלים, כולל הפרמטרים שבהן), לדווח שמשלוח נשלח כשלא נשלח, לעכב
משלוחים, ואחרי 3 דיווחים במשך 10 דקות לפחות – להשבית webhook. **מה אי אפשר:** לקרוא סודות חתימה או טוקנים של
routines, להפעיל routines של Claude בעצמו (המנוע שולח אותם, והטוקן לא יוצא ממנו), או לגשת לכל נתיב אחר.
**החלפה:** ב-Supabase:
`select vault.update_secret(id, encode(extensions.gen_random_bytes(32), 'hex')) from vault.secrets where name = 'invoice_engine_dispatch_token';`
(ה-Edge Function קורא את הערך החדש תוך 60 שניות, ו-pg_net מפיק ממנו אישורים מיד). בשרת ה-Node: משנים את משתנה
הסביבה ומפעילים מחדש.

תפקידים בעסק:

| תפקיד | צפייה, העתק, מבנה אחיד | הפקה, זיכוי, קבלה, מקור, מספר הקצאה, הסכמות | הגדרות העסק, משתמשים, מפתחות API, מפתח חתימה |
|---|---|---|---|
| `owner` בעל העסק | ✅ | ✅ | ✅ |
| `staff` עובד | ✅ | ✅ | — |
| `accountant` רואה חשבון | ✅ | — | — |

כרטיסי לקוח ופריטים (מנוע 0.16) יוצרים ועורכים מי שמפיקים: בעל העסק ועובד; רואה חשבון רק צופה.
מפתחות API ומשתמשים מנהל בעל העסק (או המנהל). מפתח API מוצג פעם אחת בלבד, ובבסיס הנתונים נשמר רק ה-SHA-256 שלו.
כל פעולה על מסמך נרשמת ביומן בשם מי שביצע אותה (אימייל המשתמש, או שם המפתח). פעולה בטוקן גישה אישי נרשמת
כאימייל ושם הטוקן, למשל `nissim@example.com (token Claude)`.

### משתמשים וכניסה

| קריאה | גוף | תוצאה |
|---|---|---|
| `POST /v1/auth/signup` | `{ email, name, password, acceptTerms: true }` | `201 { token, user, expiresAt }`. רק כשהשרת פתוח להרשמה עצמית (`ENGINE_ALLOW_SIGNUP=true`). גרסת התנאים שאושרה נשמרת |
| `POST /v1/auth/login` | `{ email, password, code? }` | `{ token, user, expiresAt }`. אחרי 5 ניסיונות כושלים הכניסה נחסמת לרבע שעה (429). משתמש עם אימות דו-שלבי: בלי `code` (או עם קוד שגוי) מתקבל 401 עם `twoFactorRequired: true` |
| `POST /v1/auth/logout` | — | הטוקן שבבקשה מפסיק לעבוד (כניסה, או טוקן גישה אישי) |
| `GET /v1/me` | — | `{ user, expiresAt, twoFactorEnabled, hasPassword, identities: [{ provider: "google", email, createdAt, lastLoginAt }], platformAdmin, businesses: [{ id, name, vatNumber, kind, role }] }`. `hasPassword` הוא `false` למשתמש שנרשם עם Google ועוד לא קבע סיסמה; `identities` – החשבונות החיצוניים המקושרים. בטוקן גישה אישי גם `token: { id, name }`, ו-`expiresAt` הוא תוקף הטוקן. `platformAdmin` (מנוע 0.14) הוא `true` רק למנהל פלטפורמה בכניסה עם סיסמה (בטוקן גישה אישי תמיד `false`), ראו [ניהול מערכת](#ניהול-מערכת-מנהלי-פלטפורמה) |
| `PUT /v1/me/password` | `{ currentPassword, newPassword }` | כל הכניסות וכל טוקני הגישה האישיים של המשתמש מתבטלים. רק בכניסה (לא בטוקן גישה אישי). משתמש בלי סיסמה (נרשם עם Google): `{ newPassword }` בלי `currentPassword`, רק בכניסה טרייה (עד 10 דקות מהכניסה); אחרת 401 |
| `PUT /v1/users/password` (מנהל) | `{ email, newPassword }` | איפוס סיסמה. גם כאן כל הכניסות וכל טוקני הגישה מתבטלים |
| `POST /v1/me/two-factor` | — | `{ secret, otpauthUrl }` להגדרת אפליקציית האימות |
| `POST /v1/me/two-factor/confirm` | `{ code }` | `{ recoveryCodes }` – 8 קודי שחזור, מוצגים פעם אחת. מכאן הכניסה דורשת קוד |
| `DELETE /v1/me/two-factor` | `{ code }` | כיבוי, בקוד תקף |
| `DELETE /v1/users/two-factor` (מנהל) | `{ email }` | כיבוי למשתמש שאיבד את הטלפון ואת קודי השחזור, אחרי זיהוי מחוץ למערכת |

סיסמה: 10 תווים לפחות, לא רק מספרים, ולא שם המשתמש מהאימייל. נשמר רק hash מסוג PBKDF2-SHA256 (600,000 חזרות) עם salt אקראי.
את אימות הדו-שלבי (שלוש הקריאות של `/v1/me/two-factor`), שינוי הסיסמה, יצירת טוקן גישה אישי, יצירת מפתח API לעסק
והזמנה לעסק אפשר לעשות רק בכניסה עם סיסמה, לא בטוקן גישה אישי (403).
"כניסה עם סיסמה" כאן היא כל כניסה (`ies_`), גם כזו שנפתחה עם Google. פעולה שדורשת את הסיסמה הנוכחית (`currentPassword`)
נדחית למשתמש בלי סיסמה (401, עם הסבר לקבוע סיסמה בהגדרות החשבון).

#### כניסה והרשמה עם Google

כשהשרת מוגדר עם `GOOGLE_CLIENT_ID` ו-`GOOGLE_CLIENT_SECRET` (וכתובת האתר `ENGINE_PUBLIC_BASE_URL`), אפשר להירשם ולהיכנס
עם Google (OpenID Connect: authorization code עם PKCE, ‏state ו-nonce). Google רק מוכיח זהות: המנוע מחליף את הקוד מול
Google בצד השרת, מאמת את ה-ID token (חתימת RS256 מול מפתחות Google במטמון, `iss`, `aud`, `exp`, `nonce`,
`email_verified=true`), ומחזיר **אותה כניסה בדיוק** כמו `/v1/auth/login` (`ies_`, 12 שעות). שום טוקן של Google לא נשמר.
כתובת החזרה קבועה: `<ENGINE_PUBLIC_BASE_URL>/auth/google/callback` (לעולם לא מהבקשה).

| קריאה | גוף | תוצאה |
|---|---|---|
| `GET /v1/auth/providers` | — | `{ password: true, google, signup }` – אילו דרכי כניסה פעילות בשרת (בלי סודות). גם `/health` כולל `googleLogin: true` כשפעיל |
| `POST /v1/auth/google/start` | `{ acceptTerms? }` + כותרת `X-Auth-Binding` | `{ url, expiresAt }`: הכתובת ב-Google שאליה מעבירים את הדפדפן. state, ‏nonce ו-PKCE נשמרים בשרת ל-10 דקות, לשימוש אחד, וקשורים לערך של `X-Auth-Binding` (האתר שומר אותו בעוגייה httpOnly ומעביר אותו בעצמו). `acceptTerms: true` כשהמשתמש אישר את התנאים במסך ההרשמה |
| `POST /v1/auth/google/callback` | `{ state, code }` + אותה `X-Auth-Binding` | `{ token, user, expiresAt, created, linked }`. בקשת המשך עם אותו `state` (בלי `code`): `{ state, secondFactor }` או `{ state, acceptTerms: true }` |

סדר הזיהוי: חשבון Google שכבר מקושר (לפי `sub`) ← משתמש קיים עם אותו אימייל ש-Google אישר (מקושר עכשיו, `linked: true`,
ונרשם ביומן `user_identity_events`) ← משתמש חדש בלי סיסמה (`created: true`), רק כשההרשמה העצמית פתוחה ואחרי אישור התנאים.
תשובות:

- `401 { twoFactorRequired: true }` – למשתמש יש אימות דו-שלבי: שולחים שוב עם `secondFactor` (קוד מהאפליקציה או קוד שחזור). אותה חסימה כמו בכניסה.
- `409 { termsRequired: true, email, name }` – משתמש חדש שלא אישר את התנאים: שולחים שוב עם `acceptTerms: true`.
- `400` – התהליך לא נמצא, פג, נוצל, או התחיל בדפדפן אחר. `401` – האימות מול Google נכשל (הודעה אחידה). `403` – Google לא אישר את האימייל, או שההרשמה סגורה. `409` – למשתמש כבר מקושר חשבון Google אחר. `502` – Google לא זמין.
- כניסה עם סיסמה למשתמש שנרשם עם Google בלבד נדחית (401) עם הפניה לכניסה עם Google.

מגבלת קצב: 20 בקשות ברצף לכל כתובת לקוח, ואחר כך בקשה כל שתי שניות (429 עם `Retry-After`). במצב קריאה בלבד הכניסה
עם Google עובדת, אבל לא נפתחים משתמשים חדשים.

### טוקני גישה אישיים (למשל לחיבור Claude / MCP)

משתמש יוצר טוקן לחיבור חיצוני, והחיבור פועל בשמו: אותם עסקים ואותן הרשאות לפי התפקיד בכל עסק (רואה חשבון בטוקן
עדיין לא מפיק מסמכים). כשהמשתמש מוסר מעסק, גם הטוקן מאבד את הגישה אליו. הטוקן מוצג פעם אחת בלבד, ובבסיס הנתונים נשמר
רק ה-SHA-256 שלו. שולחים אותו כמו כל טוקן: `Authorization: Bearer iep_...`.

טוקן שדלף נגמר כשמבטלים אותו, כשמשנים סיסמה או כשמנהל מבטל את הטוקנים של המשתמש. לכן הוא לא יכול ליצור גישה
שנשארת אחריו: לא טוקן חדש, לא מפתח API לעסק (`POST /v1/businesses/:id/api-keys`) ולא הזמנה לעסק
(`POST /v1/businesses/:id/invitations`). את אלה עושים רק בכניסה עם סיסמה (בטוקן מתקבל 403). לבטל מפתח או הזמנה מותר גם בטוקן.
מאותה סיבה טוקן לא יוצר webhook, לא מפעיל מחדש webhook מושבת, לא מוסיף לו סוגי אירועים, לא משנה את הכתובת שלו או
את הטוקן של ה-routine, לא מחליף את סוד החתימה ולא מציג את הכתובת המלאה (כל אלה דורשים גם את הסיסמה שוב בגוף הבקשה),
ולא שולח בדיקה או שליחה חוזרת ל-routine של Claude (כל אחת פותחת שיחה בחשבון של בעל העסק). להשהות, לצמצם, למחוק,
לבדוק webhook רגיל ולשלוח שוב משלוח שנכשל – מותר גם בטוקן.

| קריאה | גוף | תוצאה |
|---|---|---|
| `POST /v1/me/tokens` | `{ name, expiresInDays?, currentPassword, code? }` | `201 { id, token, tokenPrefix, name, createdAt, expiresAt }`. את `token` שומרים מיד; הוא לא יוצג שוב. `name` עד 100 תווים; `expiresInDays` מספר שלם 1 עד 365, ברירת מחדל 90. **רק בכניסה עם סיסמה** (`ies_`): בטוקן גישה מתקבל 403, כך שטוקן שדלף לא יוצר טוקנים חדשים. **ועם אימות מחדש**: `currentPassword` היא הסיסמה של המשתמש, ו-`code` קוד מאפליקציית האימות (או קוד שחזור) כשיש אימות דו-שלבי, כך שכניסה פתוחה שנגנבה לא הופכת לטוקן לשנה. סיסמה שגויה: 401; חסר קוד או קוד שגוי: 401 עם `twoFactorRequired: true`. כישלונות נספרים כמו בכניסה: אחרי 5, חסימה לרבע שעה (429). קלט שגוי (שם, תוקף) נבדק קודם ומחזיר 422 בלי להיספר |
| `GET /v1/me/tokens` | — | `{ tokens: [{ id, name, tokenPrefix, createdAt, expiresAt, lastUsedAt, revokedAt }] }` – כל הטוקנים של המשתמש, גם שפגו או בוטלו, בלי הטוקנים עצמם. `lastUsedAt` מתעדכן לכל היותר פעם בדקה |
| `DELETE /v1/me/tokens/:tokenId` | — | ביטול טוקן של המשתמש עצמו (אפשר גם מתוך טוקן). הוא מפסיק לעבוד מיד, והרשומה נשארת. טוקן שלא נמצא או שכבר בוטל: 404 |
| `DELETE /v1/users/tokens` (מנהל) | `{ email }` | `{ ok, revoked }` – ביטול כל הטוקנים של המשתמש, למשל כשטוקן דלף |

שינוי סיסמה (או איפוס על ידי מנהל) מבטל את כל הטוקנים של המשתמש, כמו את הכניסות.

### משתמשים בעסק (בעל העסק)

| קריאה | תוצאה |
|---|---|
| `GET /v1/businesses/:id/members` | `{ members: [{ userId, email, name, role }], invitations: [...] }` |
| `POST /v1/businesses/:id/invitations` עם `{ email, role }` | `201 { token, ... }`. את הקישור `/invite/<token>` שולחים למוזמן. בתוקף שבוע, לשימוש אחד. רק בכניסה עם סיסמה, לא בטוקן גישה אישי (403) |
| `DELETE /v1/businesses/:id/invitations/:invitationId` | ביטול הזמנה |
| `DELETE /v1/businesses/:id/members/:userId` | הסרה מהעסק. בעל העסק האחרון לא מוסר |
| `GET /v1/invitations/:token` (משתמש מחובר) | שם העסק, האימייל שהוזמן והתפקיד |
| `POST /v1/invitations/:token/accept` (משתמש מחובר) | הצטרפות. רק המשתמש עם האימייל שהוזמן |

הסכומים הם באגורות (מספר שלם), והתאריכים בפורמט `YYYY-MM-DD`. השגיאות חוזרות כ-JSON בצורה `{ "error": "..." }`:

| קוד | משמעות |
|---|---|
| 400 | בקשה לא תקינה |
| 401 | חסר טוקן, או שהטוקן שגוי |
| 403 | אין הרשאה לפעולה (למשל רק למנהל, תפקיד בלי הרשאה, עסק אחר, או פעולה שדורשת כניסה עם סיסמה) |
| 404 | לא נמצא |
| 409 | ניסיון לשנות את הקובץ הקבוע, או לשנות את המספור של סדרה שכבר הופקו בה מסמכים |
| 422 | המנוע סירב, וההודעה בעברית מסבירה למה (למשל "חשבונית מס/קבלה מחייבת תשלום מלא") |
| 429 | יותר מדי בקשות: [מגבלת קצב](#מגבלות-קצב-מנוע-020) (עם `Retry-After`), או חסימה אחרי ניסיונות כושלים |

### מגבלות קצב (מנוע 0.20)

כל אישור נספר בנפרד: **מפתח API** (`iek_`), **טוקן גישה אישי** (`iep_`) ו**כניסת משתמש** (`ies_`; כל הכניסות של אותו
משתמש יחד). בנוסף, **הפקת מסמכים** מוגבלת לכל עסק, מכל האישורים יחד, כדי שאוטומציה שנתקעה בלולאה לא תשרוף מספרים
בסדרה (מספר שהופק לא חוזר). המספרים נקבעים במקום אחד, `RATE_LIMITS` ב-`engine/src/domain/rateLimit.ts`:

| מגבלה | ברירת מחדל | איך נספר |
|---|---|---|
| פרץ קצר, לכל אישור | 10 בקשות בשנייה, ועד 20 בבת אחת | דלי אסימונים בזיכרון של כל עותק של המנוע |
| מתמשכת, לכל אישור | 120 בקשות בדקה (`iek_`, `iep_`); 300 בכניסה עם סיסמה, כי מסכי האתר שולחים כמה קריאות בכל טעינה | מונה בחלון קבוע של דקה ב-Postgres |
| הפקה, לכל עסק | 2 הפקות בשנייה ו-60 בדקה | פרץ בזיכרון, ומונה בחלון ב-Postgres |

- **מה נחשב הפקה:** `POST /v1/businesses/:id/documents` בלי `dryRun`. בדיקה בלי הפקה (`"dryRun": true`) ותצוגה מקדימה
  (`/documents/preview`) לא נספרות כהפקה (רק במגבלה הכללית). הספירה אחרי בדיקת ההרשאה, כך שאישור של עסק אחר לא
  מנצל את המכסה של העסק. כל ניסיון הפקה נספר, גם כזה שהמנוע דחה (422), כדי שאוטומציה ששולחת שוב ושוב בקשה שגויה
  תיעצר גם היא.
- **מה לא נספר:** `GET /health`, נתיבי המשגר (`/v1/internal/*`), טוקן המנהל, והנתיבים הציבוריים (כניסה והרשמה, קישורי
  שיתוף ואישור הסכמה), שיש להם מגבלות משלהם. טוקני שירות (`ENGINE_API_TOKENS`, מערכות של מפעיל השרת) לא נספרים
  במגבלה הכללית, אבל ההפקה שלהם נספרת במגבלת העסק.
- **בקשה שעברה** מקבלת את הכותרות של הטיוטה של IETF (`draft-ietf-httpapi-ratelimit-headers`): `RateLimit-Limit`
  (המכסה בדקה), `RateLimit-Remaining` (כמה נשארו), `RateLimit-Reset` (שניות עד הדקה הבאה) ו-`RateLimit-Policy`
  (כל המגבלות שחלות, למשל `10;w=1, 120;w=60`). בהפקה – של מגבלת ההפקה (`2;w=1, 60;w=60`).
- **בקשה שנחסמה:** `429` עם `Retry-After` (שניות), אותן כותרות (`RateLimit-Remaining: 0`), והודעה בעברית:
  `{ "error": "יותר מדי בקשות. נסו שוב בעוד 45 שניות.", "retryAfter": 45 }`. הבקשה **לא בוצעה** (גם לא הפקה: שום מספר
  לא נשרף), ולכן בטוח לשלוח אותה שוב אחרי ההמתנה. גם בקשה שנחסמה נספרת בדקה שלה, כך שמי שממשיך לשלוח בלי לחכות לא
  מקבל מכסה חדשה באמצע הדקה.
- **ניסיונות חוזרים:** מחכים לפחות `Retry-After` שניות, ואם שוב 429 – מכפילים את ההמתנה (1, 2, 4, 8... שניות, עם רכיב
  אקראי קטן) עד דקה. לא שולחים שוב בלולאה צמודה. בהפקה רצופה (למשל ייבוא) שולחים אחת אחרי השנייה, לא במקביל.
  Zapier מטפל בזה לבד (ThrottledError עם ההמתנה), וב-Make מגדירים טיפול בשגיאה (Break עם ניסיונות חוזרים).
- **איך זה עובד בכמה עותקים:** המנוע רץ ב-Edge Function בכמה עותקים במקביל, ומונה בזיכרון רואה רק את העותק שלו.
  לכן המגבלה לדקה נספרת ב-Postgres (טבלת `api_rate_counters`, מיגרציה 021): **upsert אחד לבקשה** (`INSERT ... ON
  CONFLICT DO UPDATE ... RETURNING hits`) על שורה אחת לאישור ולדקה, בטבלה `UNLOGGED` (בלי יומן כתיבה; אחרי קריסה של
  בסיס הנתונים היא מתרוקנת, וזה רק מאפס את הדקה). חלון קבוע ולא נע: שורה אחת במקום שורה לכל בקשה; את החריגה האפשרית
  סביב גבול הדקה מחליק הפרץ שבזיכרון. בהפקה יש upsert שני (של העסק). אם המונה לא זמין, הבקשה עוברת (ונרשמת שורה ביומן).

### בריאות

`GET /health` → `{ "ok": true, "software": "...", "version": "...", "registrationNumber": "...", "build": "<sha256>" }` (בלי טוקן).
כשהכניסה עם Google פעילה נוסף `"googleLogin": true`.
`build` היא טביעת המהדורה שרצה בשרת, כדי לוודא שזו המהדורה שנרשמה (ראו `EDITIONS.md`).

#### מצב קריאה בלבד (מנוע 0.21)

בחלון ההקפאה של העברת השרת (ובתחזוקה) השרת יכול לרוץ במצב קריאה בלבד (`ENGINE_READ_ONLY=1`, ראו
`deploy/README.md`). אז `/health` כולל `"readOnly": true`, וכל בקשה שכותבת מקבלת `503` עם `Retry-After: 600` ו-
`{ "error": "המערכת במצב קריאה בלבד בזמן העברה לשרת חדש...", "readOnly": true }`: כל `POST`/`PUT`/`PATCH`/`DELETE` חוץ
מ-`/v1/auth/login`, `/v1/auth/logout` ו-`/v1/auth/google/*` (בלי הרשמה של משתמש חדש), וגם `GET` שרושם משהו בספרים (העתק `/v1/documents/:id/copy`, ה-PDF של קישור
שיתוף, ייצוא מלא `/v1/businesses/:id/archive`). כל השאר (רשימות, מסמך, סקירה) עובד כרגיל. מערכת שמתחברת ל-API
מנסה שוב אחרי `Retry-After`.

### הרשמת עסק

קריאה אחת שעושה את כל מה שעושה מסך ההרשמה, בטרנזקציה אחת (אם שלב נכשל, לא נשאר עסק חצי רשום).
משתמש מחובר נעשה בעל העסק (כשהשרת פתוח להרשמה עצמית). מנהל יכול לצרף בעל עסק עם
`"owner": { "email", "name", "password" }`; אם האימייל כבר רשום, המשתמש הקיים מצורף.

`POST /v1/businesses/register`

```json
{
  "name": "חויה בקליק",
  "vatNumber": "316288877",
  "kind": "licensed",
  "street": "שמואל הנביא", "houseNumber": "7", "city": "בני ברק", "phone": "054-...", "email": "...",
  "firstNumbers": { "305": 60001, "320": 50001, "330": 80001, "400": 70001 },
  "computerizedNoticeSentOn": "2026-10-01"
}
```

- נפתחות כל הסדרות שמותרות לסוג העסק: עוסק מורשה וחברה 305, 320, 330, 400; עוסק פטור 300, 400; עמותה 400.
  סדרה שלא צוינה ב-`firstNumbers` מתחילה ב-1. סדרה שאסורה לסוג העסק נדחית. מספר התחלה הוא מספר שלם מ-1 עד
  9,999,999,999 (10 ספרות). כל עוד לא הופק בסדרה מסמך, אפשר להעלות אותו אחר כך
  ([שינוי מספר ההתחלה](#שינוי-מספר-ההתחלה-של-סדרה)).
- נוצרים מפתח ותעודת חתימה לעסק.
- `computerizedNoticeSentOn` לא חובה. בלעדיו אפשר להפיק רק מקור להדפסה, עד שרושמים את התאריך.

התשובה: `201 { businessId, sequences: [{ docType, firstNumber }], certificatePem }`.

### סקירת העסק

`GET /v1/businesses/:id` מחזיר את מה שמוצג במסך הסקירה באזור האישי:

| שדה | תוכן |
|---|---|
| `business` | פרטי העסק, כולל `computerizedNoticeSentOn` |
| `allowedDocTypes` | סוגי המסמכים שהעסק רשאי להפיק |
| `sequences` | `[{ docType, nextNumber, issued }]`: המספר הבא בכל סדרה וכמה מסמכים הופקו בה. כל עוד `issued` הוא 0 אפשר להעלות את המספר הבא ([שינוי מספר ההתחלה](#שינוי-מספר-ההתחלה-של-סדרה)) |
| `signingCertificate` | שם, מספר עוסק, תוקף וטביעת אצבע SHA-256 של תעודת החתימה, וה-PEM |
| `computerizedReady` | אפשר להפיק מקור ממוחשב חתום (יש מפתח, ונרשמה הודעה לפקיד השומה; מנוע 0.18: וכשמדיניות המערכת דורשת קוד חתימה – נקבע קוד) |
| `signingProtection` | (מנוע 0.18) `{ mode, exclusiveControl, pinSetAt, requirePin }`: איך מוגן מפתח החתימה – `platform` ברירת המחדל (המפתח בשרת, חתימה אוטומטית), `pin` בקוד חתימה שרק העסק מחזיק (מתקדם), `none` אין מפתח. ראו [מפתח החתימה וקוד החתימה](#מפתח-החתימה-וקוד-החתימה-מנוע-018) |
| `month`, `year` | מספר מסמכים, הכנסות לפני מע"מ (חשבוניות פחות זיכויים) ותקבולים |
| `pending` | חשבוניות שחסר להן מספר הקצאה, תקבולים שמחכים למקור מודפס לחתימת יד, ומספר המסמכים שעוד לא הופק להם מקור |
| `realDocumentsSince` | מתי העסק עבר למצב [מסמכים אמיתיים](#מצב-מסמכים-אמיתיים-מנהל) (ISO), או `null` במצב הדגמה |
| `lastArchiveExportAt` | מתי הורד ה[ייצוא המלא](#ייצוא-מלא) האחרון (ISO), או `null` כשעוד לא הורד. לתזכורת הגיבוי הרבעונית |
| `designs` | (מנוע 0.13) `{ count, active, defaultId, defaultName }`: כמה [עיצובים](#עיצובים-ומותגים) יש לעסק, כמה פעילים (לא בארכיון), ועיצוב ברירת המחדל |

גם `GET /v1/businesses` מחזיר לכל עסק את `real_documents_since` (או `null`).

### תקרת המחזור השנתי: עוסק פטור ועסק זעיר (מנוע 0.22)

`GET /v1/businesses/:id/turnover?year=2026` (כל מי שרשאי לקרוא את העסק; `year` לא חובה, ברירת המחדל היא השנה
הנוכחית בשעון ישראל) → `{ businessId, year, kind, smallBusiness, applies, demo, turnoverAgorot, ceilingAgorot,
ceilingSource, percent, level, message, parametersReviewed }`.

- **המחזור** (באגורות, לפי תאריך המסמך בשנה): עוסק מורשה / חברה – חשבוניות מס (305) וחשבוניות מס / קבלה (320) פחות
  חשבוניות זיכוי (330), **לפני מע"מ**; קבלות וחשבון עסקה לא נספרים. עוסק פטור – חשבוניות עסקה (300) וקבלות (400)
  שאינן מפנות לחשבונית עסקה (קבלה על חשבונית עסקה היא תשלום על עסקה שכבר נספרה), כולל ניכוי במקור. עמותה – קבלות
  וקבלות על תרומה, לידיעה בלבד.
- **התקרה** חלה (`applies: true`) על עוסק פטור ועל עסק שמסומן עסק זעיר (`smallBusiness`, למטה). `ceilingSource` הוא
  `exempt` או `small_business` (לעוסק פטור שהוא גם עסק זעיר – הנמוכה מביניהן). התקרות נקבעות לכל שנה בידי מנהל
  המערכת ([פרמטרים שנתיים](#פרמטרים-שנתיים-מנוע-022)); לשנה בלי תקרה `ceilingAgorot`, `percent` ו-`level` הם `null`.
- **`level`**: `ok` מתחת ל-80%, `near80` מ-80%, `near90` מ-90%, `over` כשהמחזור עולה על התקרה. `message` בעברית
  מ-`near80` ומעלה (ב-`over` של עוסק פטור: לפנות למשרד מע"מ כדי לעבור למעמד עוסק מורשה; של עסק זעיר: יציאה ממסלול עסק זעיר).
- **`demo: true`** כשהעסק במצב הדגמה: המחזור מחושב על מסמכי ההדגמה. במצב מסמכים אמיתיים נספרים רק מסמכים אמיתיים.

**התראה בהפקה:** כשמסמך מעביר את העסק את 80%, 90% או את התקרה עצמה, התשובה ל-`POST /v1/businesses/:id/documents`
כוללת `turnoverNotice: { kind: "turnover", year, level, ceilingSource, turnoverAgorot, ceilingAgorot, percent, demo, message }`,
וההודעה מתווספת גם ל-`warnings`. רק פעם אחת לכל סף (המסמך הבא באותה רמה לא מתריע), ולא בבדיקה בלי הפקה.

**עסק זעיר:** `PUT /v1/businesses/:id` עם `{ "smallBusiness": true }` (בעל העסק; רק עוסק פטור או עוסק מורשה, אחרת
`422`). כל שינוי נרשם ביומן `business_setting_changes` (מה, מי ומתי). הסקירה מחזירה את `business.smallBusiness`.

### שינוי מספר ההתחלה של סדרה

עסק שנרשם בלי `firstNumbers` (או עם מספר לא נכון) יכול לקבוע את המספר הבא של סדרה, **כל עוד לא הופק בעסק אף מסמך
מאותו סוג**. למשל כשממשיכים ממערכת אחרת (סאמיט וכדומה) באמצע השנה.

`PUT /v1/businesses/:id/sequences/:docType` עם `{ "nextNumber": 60001 }` → `200 { docType, nextNumber, previousNextNumber }`.
המסמך הראשון מהסוג הזה יקבל את `nextNumber`.

- **רק כלפי מעלה:** `nextNumber` חייב להיות גדול מהמספר הבא הנוכחי (`sequences[].nextNumber` בסקירת העסק). אי אפשר
  להוריד מספר, גם לא חזרה למספר שהיה לפני שינוי קודם (הטריגר של בסיס הנתונים לא מאפשר מונה שחוזר אחורה).
- **רק לפני המסמך הראשון** מהסוג הזה בעסק. מסמכים מסוג אחר, או בדיקה בלי הפקה (`dryRun`), לא חוסמים.
  אחרי מסמך אחד – 409. אפשר לשנות כמה פעמים לפני המסמך הראשון, וכל פעם רק למעלה.
- **מספר שלם מ-1 עד 9,999,999,999** (10 ספרות). המספר נשמר כ-bigint ומודפס כמו שהוא, ובמבנה האחיד יש לו 20 תווים;
  המגבלה משאירה מקום לכל המסמכים שאחריו ותופסת טעות הקלדה (אפס מיותר), שאחריה אי אפשר היה לחזור למספר נמוך.
- **מי:** בעל העסק או מנהל. מותר גם בטוקן גישה אישי (`iep_`), כי השינוי לא נותן גישה שנשארת אחרי הטוקן. לא במפתח
  API של עסק, לא בטוקן שירות, ולא לעובד או לרואה חשבון (403).
- **יומן:** כל שינוי נרשם (המספר הבא לפני ואחרי, מי ומתי), ואת הרשומות אי אפשר לשנות או למחוק. הן נכללות גם
  בייצוא המלא (`sequence-changes.csv`).
- **מקביליות:** השינוי וההפקה נועלים את העסק ואת הסדרה באותו סדר, ולכן רצים אחד אחרי השני: אם ההפקה נשמרה קודם,
  השינוי נדחה ב-409; אם השינוי נשמר קודם, המסמך מקבל את המספר החדש.

| קוד | מתי |
|---|---|
| 400 | `docType` לא תקין, או `nextNumber` שאינו מספר (JSON number) |
| 403 | אין הרשאה (ראו למעלה) |
| 404 | העסק לא נמצא, או שלא נפתחה סדרה לסוג הזה (פותחים אותה ב-`POST /v1/businesses/:id/sequences` עם המספר הרצוי) |
| 409 | "כבר הופקו מסמכים מסוג זה בעסק; אי אפשר לשנות את המספור" |
| 422 | המספר לא גדול מהמספר הבא הנוכחי, לא מספר שלם חיובי, או יותר מ-10 ספרות |

`GET /v1/businesses/:id/sequences/changes` (כל מי שיש לו גישת צפייה בעסק: בעל העסק, עובד, רואה חשבון, מפתח API של
העסק, מנהל) → `{ changes: [{ id, docType, previousNextNumber, newNextNumber, actor, at }] }`, מהישן לחדש. `actor` הוא
אימייל המשתמש (בטוקן גישה אישי גם שם הטוקן, למשל `nissim@example.com (token Claude)`) או `admin`.

**מעבר ממערכת אחרת:** לכל סוג מסמך בנפרד קובעים **המספר הבא = המספר האחרון שהופק במערכת הקודמת מאותו סוג + 1**
(חשבונית מס, חשבונית מס/קבלה, זיכוי, קבלה וכו'), כך שהמספור ממשיך ברצף ולא חוזר על מספר שכבר הונפק באותה שנה
(הוראה 18(א)(3)). את המספר האחרון בודקים במערכת הקודמת אחרי שמפסיקים להפיק בה. **אף פעם לא מפיקים את אותו סוג
מסמך בשתי המערכות במקביל:** מפסיקים במערכת הקודמת, קובעים כאן את המספר הבא, ורק אז מפיקים כאן. מסמך שהופק במערכת
הקודמת אחרי שנקבע המספר כאן היה מקבל מספר שהמערכת הזו תיתן שוב.

### הקמה ידנית, צעד אחר צעד (מנהל)

| קריאה | גוף | תוצאה |
|---|---|---|
| `POST /v1/businesses` | `{ name, vatNumber, kind: "licensed"\|"exempt"\|"company"\|"nonprofit", street?, houseNumber?, city?, zip?, phone?, email? }` | `201 { id }` |
| `PUT /v1/businesses/:id` | שדות לעדכון (למעט מספר עוסק), כולל `section46Approval` ו-`section46ValidUntil` לעמותה | בעל העסק או מנהל. מסמכים קיימים שומרים את הפרטים שהיו בזמן ההפקה |
| `POST /v1/businesses/:id/sequences` | `{ docType, firstNumber }` | פתיחת סדרה, פעם אחת לכל סדרה. בעל העסק או מנהל |
| `PUT /v1/businesses/:id/sequences/:docType` | `{ nextNumber }` | שינוי המספר הבא, רק למעלה ורק לפני המסמך הראשון ([שינוי מספר ההתחלה](#שינוי-מספר-ההתחלה-של-סדרה)) |
| `GET /v1/businesses/:id/sequences/changes` | — | יומן שינויי מספר ההתחלה |
| `POST /v1/businesses/:id/signing-keys` | `{ pin?, currentPassword?, code? }` | `201 { certificatePem, signing }`. החלפת מפתח: מסמכים ישנים נשארים חתומים במפתח הקודם. בלי `pin` – המפתח החדש בשרת (חתימה אוטומטית): בעל העסק או מנהל, ובעסק שקבע קוד חתימה רק בעל העסק עם `currentPassword` (גם הדרך כששכחו את הקוד). עם `pin` – המפתח החדש נעול רק בקוד (ראו [מפתח החתימה וקוד החתימה](#מפתח-החתימה-וקוד-החתימה-מנוע-018)) |
| `PUT /v1/businesses/:id/computerized-notice` | `{ sentOn }` | הוראה 18ב(ב). בעל העסק או מנהל |
| `GET /v1/businesses` | — | רשימת העסקים. משתמש רואה את העסקים שלו, ומפתח של עסק רק את העסק שלו |
| `PUT /v1/businesses/:id/real-documents` | `{ enabled, note? }` | רק מנהל: מצב מסמכים אמיתיים ([פירוט](#מצב-מסמכים-אמיתיים-מנהל)) |

### ייצוא מלא

`GET /v1/businesses/:id/archive` (כל מי שיש לו גישת צפייה) מחזיר ZIP עם:
- `originals/` – כל המקורות כפי שנמסרו ללקוחות, כולל החתימה הדיגיטלית;
- `openformat/<שנה>/` – קובץ מבנה אחיד לכל שנת מס;
- `documents.csv`, `consents.csv`, `events.csv` – רשימת המסמכים, הסכמות הלקוחות ויומן הפעולות;
- `sequence-changes.csv` – שינויי מספר ההתחלה של סדרות (לפני המסמך הראשון בכל סדרה);
- `mode-changes.csv` – המעברים בין מצב הדגמה למצב מסמכים אמיתיים (ב-`documents.csv` העמודה "מסמך אמיתי": `כן`, או
  `לא (הדגמה)` למסמך שהופק במצב הדגמה);
- `designs.csv` – (מנוע 0.13) ה[עיצובים](#עיצובים-ומותגים) של העסק: מזהה, שם, ברירת מחדל, ארכיון והגרסה הנוכחית. ב-`documents.csv`
  העמודות "עיצוב" (מזהה העיצוב) ו"גרסת עיצוב" אומרות באיזה עיצוב ובאיזו גרסה שלו הופק כל מסמך;
- `customers.csv`, `items.csv` – (מנוע 0.16) [כרטיסי הלקוח והפריטים](#כרטיסי-לקוח-ופריטים), ולכל כרטיס מזהי המסמכים
  (העמודה "מזהה" ב-`documents.csv`) שמקושרים אליו;
- `certificates/` – תעודות החתימה, לאימות החתימות בעתיד;
- `business.json`, `README.txt`.

אפשר להוריד בכל עת, וגם בסיום השימוש, כדי לשמור את הרשומות 7 שנים (סעיף 25).

**יומן הייצואים (מנוע 0.12):** כל ייצוא מלא שהמנוע מחזיר נרשם בטבלה `archive_exports` (העסק, מי – `actor` כמו ביומן
הפעולות, מתי ומספר המסמכים בקובץ). אי אפשר לשנות או למחוק רשומה. בסקירת העסק `lastArchiveExportAt` הוא הייצוא
האחרון. לפי הוראה 25(ו)(2) הגיבוי נעשה בשבוע הראשון של כל רבעון, ונשמר בישראל במקום אחר מהמקום שבו מוחזקת
המערכת; האתר מציג לבעל העסק ולרואה החשבון תזכורת כשלא הורד ייצוא מלא מתחילת הרבעון הנוכחי (שעון ישראל).

### מצב מסמכים אמיתיים (מנהל)

בשרת שמסמן PDF של עסק במצב הדגמה בסימן מים (למשל "הדגמה בלבד – אינו מסמך חשבונאי"), מנהל המנוע יכול להעביר עסק
מסוים למצב **מסמכים אמיתיים**. **רק טוקן המנהל**, או מפעיל עם גישה לבסיס הנתונים: לא בעל העסק, לא טוקן גישה אישי,
לא מפתח API, לא טוקן שירות, ולכן גם לא כלי ה-MCP (כולם מקבלים 403). ממנוע 0.14 גם **מנהל פלטפורמה** מהאתר, בנתיב
נפרד עם אימות מחדש (ראו [ניהול מערכת](#ניהול-מערכת-מנהלי-פלטפורמה)); הנתיב כאן נשאר לטוקן המנהל בלבד.

**עסק חדש ישר במצב מסמכים אמיתיים:** בשרת שמוגדר כך (`ENGINE_REAL_DOCUMENTS_DEFAULT=1` בשרת ה-Node, ‏`realDocumentsByDefault`
ב-Edge Function; מופעל בשירות הייצור של רצף), עסק חדש – בהרשמה (`POST /v1/businesses/register`) או בהקמה של מנהל
(`POST /v1/businesses`) – נפתח ישר במצב מסמכים אמיתיים: `real_documents_since` נקבע לזמן היצירה, ורשומה ביומן
`business_mode_changes` (`actor`: `system`, וההערה אומרת מי פתח את העסק) נכתבת באותה טרנזקציה, דרך אותה פונקציה
`set_real_documents`. בתשובת ההרשמה `realDocumentsSince` (או `null` כשהעסק נפתח במצב הדגמה), וב-`/health`
‏`"realDocumentsByDefault": true`. ברירת המחדל (`0`): עסק חדש נפתח במצב הדגמה. בשני המקרים המנהל יכול להעביר עסק בין
המצבים בנתיב שלמטה (למשל עסק בדיקה למצב הדגמה).

`PUT /v1/businesses/:id/real-documents` עם `{ "enabled": true, "note": "למה" }` (`note` לא חובה, עד 1,000 תווים) →
`200 { realDocuments, realDocumentsSince, changed, changes: [{ id, realDocuments, actor, note, at }] }`.
`enabled: false` מחזיר את העסק למצב הדגמה. מעבר למצב שהעסק כבר נמצא בו לא משנה דבר ולא נרשם (`changed: false`).
קודים: 400 (`enabled` שאינו true/false, `note` שאינו טקסט או ארוך מדי, מזהה עסק לא תקין), 403 (לא טוקן המנהל),
404 (העסק לא נמצא).

**מפעיל עם גישה לבסיס הנתונים בלבד** (למשל SQL Editor של Lovable Cloud, שבו טבלאות המנוע בסכימה `invoice`):

```sql
select * from invoice.set_real_documents('<מזהה העסק>'::uuid, true, '<מי מבצע>', '<למה>');
-- חזרה למצב הדגמה
select * from invoice.set_real_documents('<מזהה העסק>'::uuid, false, '<מי מבצע>', '<למה>');
```

הפונקציה (מיגרציה 013) נועלת את שורת העסק, מעדכנת את `businesses.real_documents_since` (זמן המעבר, או `null`) ורושמת
את המעבר ביומן `business_mode_changes` (מצב, מי, הערה, מתי), הכול בטרנזקציה אחת; ה-API קורא לאותה פונקציה. ביומן אי
אפשר לשנות או למחוק רשומה, והוא נכלל בייצוא המלא (`mode-changes.csv`). הפונקציה רצה בהרשאות מי שקורא לה (לא
`security definer`), וההרשאה ל-`PUBLIC` הוסרה ממנה.

**מה משתנה במסמכים:**
- כל מסמך מקבל בהפקה את הסימון `documents.real_document` לפי המצב של העסק באותו רגע (באותה טרנזקציה, כשהעסק נעול),
  והסימון לא משתנה לעולם. **מסמך שהופק לפני המעבר נשאר מסמך הדגמה**, וכל העתק שלו ממשיך לצאת עם סימן המים. מסמך
  שהופק במצב מסמכים אמיתיים נשאר אמיתי גם אם העסק יחזור למצב הדגמה.
- במסמך אמיתי (מקור, העתק, ובדיקה בלי הפקה): **אין סימן מים**, והשורה התחתונה בכל עמוד היא בדיוק `הופק על ידי העסק`
  (במקום `הופק באמצעות <שם התוכנה> <גרסה> | מספר רישום תוכנה: …`). מספר העמוד וטביעת המסמך נשארים. מסמך הדגמה – בלי
  שינוי. תצוגת עיצוב לדוגמה (`POST /v1/businesses/:id/design/preview`) אינה מסמך ונשארת כמו שהייתה.
- `realDocument` (true/false) מוחזר בתשובת ההפקה, ב-`GET /v1/documents/:docId` ובבדיקה בלי הפקה.
- הסימון **אינו חלק מ-`contentHash`**: ה-hash מכסה את התוכן החשבונאי של המסמך ואת השרשרת, והסימון קובע רק איך הסביבה
  מציגה אותו. העמודה חסומה לשינוי כמו כל שורת המסמך, ולכן אין צורך להגן עליה גם ב-hash, וה-hash של מסמכים קיימים
  נשאר כפי שחושב.

### ניהול מערכת (מנהלי פלטפורמה)

ממנוע 0.14 (מיגרציה 015) משתמש רגיל יכול להיות **מנהל פלטפורמה** (`users.platform_admin`): בעל התוכנה מנהל מהאתר
(דף `/admin`) את מה שעד עכשיו רק טוקן המנהל יכול היה לעשות – רשימת כל העסקים והמעבר של עסק למצב
[מסמכים אמיתיים](#מצב-מסמכים-אמיתיים-מנהל).

**מה זה נותן, ומה לא:**
- רק את הנתיבים `/v1/platform/*` שבטבלה. מנהל פלטפורמה **לא** מקבל גישה למסמכים, ללקוחות, להגדרות או למשתמשים של
  עסק שהוא לא חבר בו: שם ממשיכים לבדוק את התפקיד שלו בעסק (403 כמו לכל משתמש). הרשימה מציגה רק נתונים מסכמים.
- **רק בכניסה עם אימייל וסיסמה** (`ies_`). בטוקן גישה אישי (`iep_`), ולכן גם בכלי ה-MCP ובחיבור ל-Claude, במפתח API
  ובטוקן שירות – 403. אין כלי MCP לנתיבים האלה.
- המעבר עצמו דורש **אימות מחדש** בגוף הבקשה, כמו ביצירת טוקן גישה אישי: `currentPassword`, ו-`code` (מאפליקציית
  האימות, או קוד שחזור) כשיש אימות דו-שלבי. סיסמה שגויה: 401; חסר קוד או קוד שגוי: 401 עם `twoFactorRequired: true`.
  כישלונות נספרים כמו בכניסה: אחרי 5, חסימה לרבע שעה (429). קלט שגוי נבדק קודם ומחזיר 400 בלי להיספר.
- הסימון נקרא מחדש בכל בקשה: ביטול נכנס לתוקף מיד, גם בכניסה שכבר פתוחה.
- טוקן המנהל עובד גם בנתיבים האלה (בלי אימות מחדש, ונרשם `admin`).

| קריאה | גוף | תוצאה |
|---|---|---|
| `GET /v1/platform/businesses?q=&limit=&offset=` | — | `{ businesses: [{ id, name, vatNumber, kind, createdAt, realDocumentsSince, ownerEmails, documents, realDocuments, lastDocumentDate, lastIssuedAt }], total, limit, offset }`. מהחדש לישן. `q` (עד 100 תווים) מחפש בשם העסק, במספר העוסק ובאימייל של בעל העסק; `limit` 1 עד 200 (ברירת מחדל 50), `offset` מ-0 |
| `PUT /v1/platform/businesses/:id/real-documents` | `{ enabled, note, currentPassword, code? }` | `200 { realDocuments, realDocumentsSince, changed, business, changes }` – אותה פונקציה (`set_real_documents`) ואותו יומן כמו בנתיב של טוקן המנהל, עם האימייל של המנהל כ-`actor`. `note` **חובה**, 1 עד 300 תווים (אחרי הסרת רווחים). מעבר למצב שהעסק כבר נמצא בו לא משנה דבר ולא נרשם (`changed: false`). קודים: 400, 401, 403, 404, 429 |
| `GET /v1/platform/businesses/:id/mode-changes` | — | `{ changes: [{ id, realDocuments, actor, note, at }] }` – יומן המעברים של העסק, מהישן לחדש |
| `PUT /v1/users/platform-admin` (**טוקן מנהל בלבד**) | `{ email, enabled, note? }` | `{ userId, email, platformAdmin, changed, admins: [{ email, name }], changes }` – הענקה או ביטול. 404 כשהמשתמש לא נמצא |

**איך מעניקים** (המשתמש נרשם קודם באתר כרגיל). אין שום נתיב של משתמש שמעניק או מבטל – גם לא מנהל פלטפורמה
אחר. רק טוקן המנהל (`PUT /v1/users/platform-admin` למעלה), או מפעיל עם גישה לבסיס הנתונים (למשל SQL Editor של
Lovable Cloud, שבו טבלאות המנוע בסכימה `invoice`):

```sql
-- הענקה (נרשמת ביומן עם משתמש בסיס הנתונים שביצע אותה)
update invoice.users set platform_admin = true where email = 'owner@example.com';
-- או עם מי ולמה ביומן
select * from invoice.set_platform_admin('owner@example.com', true, '<מי מבצע>', '<למה>');
-- ביטול
select * from invoice.set_platform_admin('owner@example.com', false, '<מי מבצע>', '<למה>');
-- מי מנהל עכשיו, והיומן
select email, name from invoice.users where platform_admin;
select * from invoice.platform_admin_changes order by id;
```

כל שינוי של העמודה – דרך הפונקציה, דרך טוקן המנהל או ב-`update` ישיר – נרשם בטריגר ביומן `platform_admin_changes`
(המשתמש, האימייל, המצב החדש, מי ביצע, הערה ומתי). ביומן אי אפשר לשנות או למחוק רשומה. הפונקציה רצה בהרשאות מי
שקורא לה, וההרשאה ל-`PUBLIC` הוסרה ממנה. האימייל צריך להיות כמו שנרשם (באותיות קטנות).

#### פרמטרים שנתיים (מנוע 0.22)

תקרות המחזור של [עוסק פטור ועסק זעיר](#תקרת-המחזור-השנתי-עוסק-פטור-ועסק-זעיר-מנוע-022) לכל שנה (טבלת
`annual_parameters`, מיגרציה 022; 2025 ו-2026 נזרעו כערכים התחלתיים שלא אושרו). שיעור המע"מ וסף מספר ההקצאה **לא**
כאן: הם בקוד לפי תאריך תחילה, כדי שכל מסמך ישן יחושב בדיוק כפי שהופק, ושינוי שלהם הוא עדכון תוכנה.

| קריאה | גוף | תוצאה |
|---|---|---|
| `GET /v1/platform/annual-parameters` | — | `{ today, year, current, reminder, inEffect: { vatRateBp, allocationThresholdAgorot, exemptCeilingAgorot, smallBusinessCeilingAgorot }, codeManagedNote, parameters, changes }`. `reminder: true` כשאין שורה לשנה הנוכחית או שעוד לא אושרה (דף `/admin` מציג אז תזכורת) |
| `PUT /v1/platform/annual-parameters/:year` | `{ exemptCeilingAgorot, smallBusinessCeilingAgorot?, notes?, currentPassword, code? }` | `200 { saved, status }`: שומר את התקרות (באגורות, מספר שלם חיובי; `smallBusinessCeilingAgorot` יכול להיות `null`), ומסמן שהשנה נבדקה (`reviewedAt`, `reviewedBy`). כל שמירה נרשמת ביומן `annual_parameter_changes`, גם בלי שינוי בערכים (זה האישור השנתי). `year` מ-2020 עד השנה הבאה. אימות מחדש כמו בשאר השינויים; קודים: 400, 401, 403, 429 |

### ספר הלקוחות של היצרן (מנהל)

`GET /v1/admin/customer-register` מחזיר את כל ההרשמות ושינויי הפרטים (שם, כתובת, מספר עוסק, טלפון, אימייל, מתי),
לפי נספח ה'(ה). הרשומות לא משתנות ולא נמחקות.

### עיצובים ומותגים

מנוע 0.13: לעסק אחד יכולים להיות כמה עיצובים בשמות (מותגים) – למשל שני מותגים של אותו עוסק. העסק המשפטי אחד: שם
העסק, מספר העוסק, הכתובת וכל מה שהוראות ניהול הפנקסים מחייבות מודפסים מפרטי העסק, זהים בכל עיצוב, וכל סוג מסמך
ממוספר בסדרה **אחת** משותפת לכל העיצובים. העיצוב משנה רק מראה: לוגו, צבע הדגשה, תבנית (`classic`, `modern`,
`minimal`) ושורת תחתית – כללית, ומנוע 0.15 גם לכל סוג מסמך (`footerByType`).

- לכל עסק יש תמיד **בדיוק עיצוב ברירת מחדל אחד** (נאכף גם בבסיס הנתונים). עסק חדש מקבל עיצוב ראשון בשם העסק.
- כל שינוי בעיצוב נשמר כ**גרסה** חדשה שלא משתנה. כל מסמך שומר את העיצוב ואת הגרסה שהיו בתוקף כשהופק, ולכן המקור,
  כל העתק וכל תצוגה שלו נראים תמיד כמו ביום ההפקה – גם אחרי שהעיצוב השתנה או הועבר לארכיון.
- עיצוב **לא נמחק** (מסמכים מפנים אליו). אפשר להעביר אותו לארכיון: אז אי אפשר להפיק בו מסמכים חדשים או לשנות אותו.
  את עיצוב ברירת המחדל אי אפשר להעביר לארכיון (קובעים קודם ברירת מחדל אחרת).
- מזהה עיצוב: `dsg_` ו-20 תווים הקסדצימליים. השם (1–60 תווים, ייחודי בעסק) פנימי ולא מודפס.

| קריאה | מי | תוצאה |
|---|---|---|
| `GET /v1/businesses/:id/designs` | כל מי שיש לו גישת צפייה (גם מפתח API) | `{ designs: [...], defaultDesignId }`. לכל עיצוב: `{ id, name, isDefault, archived, archivedAt, createdAt, version, versions, template, accentColor, footerText, footerByType, hasLogo, logoType, logoBytes }` – בלי הלוגו עצמו. ברירת המחדל ראשונה, אחריה הפעילים ובסוף שבארכיון. `version: null` = עוד לא נשמרה גרסה (העיצוב הבסיסי של המנוע) |
| `GET /v1/designs/:designId` | צפייה | העיצוב המלא: `{ id, businessId, name, isDefault, archived, archivedAt, createdAt, version, template, accentColor, footerText, footerByType, logo }`. `logo` הוא data URL או `null` |
| `POST /v1/businesses/:id/designs` | בעל העסק או מנהל | `{ name, template, accentColor, footerText?, footerByType?, logo?, isDefault? }` → `201` עם שורת העיצוב (כמו ברשימה). `isDefault: true` קובע אותו גם כברירת המחדל |
| `PUT /v1/designs/:designId` | בעל העסק או מנהל | גרסה חדשה: `{ template?, accentColor?, footerText?, footerByType?, logo?, name? }`. שדה שלא נשלח נשאר כמו בגרסה הנוכחית (`logo: null` מסיר). `name` משנה שם. מחזיר את שורת העיצוב |
| `POST /v1/designs/:designId/default` | בעל העסק או מנהל | קובע את ברירת המחדל (לא עיצוב שבארכיון). מחזיר את שורת העיצוב |
| `POST /v1/designs/:designId/archive` | בעל העסק או מנהל | העברה לארכיון (לא את ברירת המחדל). קריאה חוזרת לא משנה דבר. מחזיר את שורת העיצוב |
| `POST /v1/designs/:designId/preview` | צפייה | PDF לדוגמה בעיצוב, בלי לשמור. גוף ריק = העיצוב כפי שהוא; עם שדות כמו ב-`PUT` = איך ייראה אחרי השינוי. `docType` (0.15): סוג המסמך לדוגמה, כדי לראות את שורת התחתית שלו |

`logo`: data URL (או base64) של PNG או JPEG, עד 300KB. `accentColor` בפורמט `#RRGGBB`; `footerText` עד 300 תווים.

**שורת תחתית לפי סוג מסמך (מנוע 0.15):** `footerByType` הוא אובייקט מקוד סוג המסמך לטקסט (עד 300 תווים לכל סוג),
למשל `{ "400": "תודה על התשלום!", "305": "תנאי תשלום: שוטף+30" }`. מה מודפס על מסמך: השורה של הסוג שלו, אחרת
`footerText` של העיצוב, ואחרת כלום. טקסט ריק = בלי שורה משלו (נשמט). ב-`PUT` שולחים את האובייקט כולו, והוא מחליף
את הקודם; בלי השדה הוא נשאר כמו בגרסה הנוכחית (גם בנתיב הישן `PUT /v1/businesses/:id/design`). הטקסט נשמר עם גרסת
העיצוב, ולכן העתק של מסמך ישן מדפיס תמיד את הטקסט שהיה בתוקף כשהופק. קוד סוג לא מוכר או טקסט ארוך: `422`.
השורה התחתונה שהחוק מחייב (במסמך אמיתי "הופק על ידי העסק", אחרת שם התוכנה ומספר הרישום) נפרדת ולא משתנה.
מפתח API וטוקן שירות לא יוצרים ולא משנים עיצובים (403), אבל **בוחרים עיצוב בהפקה** (`designId`) – זו לא הגדרה של העסק.
שגיאות אימות (שם תפוס או ארוך, לוגו לא תקין, עיצוב בארכיון, ארכיון של ברירת המחדל) הן `422` עם הודעה בעברית;
מזהה לא מוכר בנתיב הוא `404`.

**הפקה בעיצוב:** `designId` אופציונלי ב-`POST /v1/businesses/:id/documents` (גם עם `dryRun`). בלעדיו – עיצוב ברירת
המחדל. עיצוב לא מוכר, של עסק אחר או בארכיון: `422`, ושום מספר לא מנוצל. התשובה, `GET /v1/documents/:docId` ותוצאת
`dryRun` כוללות `designId` ו-`designName`; ברשימת המסמכים `design_id` ו-`design_name`. מסמך שהופק לפני 0.13 מוצג עם
העיצוב הראשון של העסק (שבו הופק). ב-webhooks נוסף `designId` לאובייקט `document` (בלי השם).

דוגמה – רשימת העיצובים, ואז הפקה במותג השני:

```bash
curl -s https://<engine>/v1/businesses/$BIZ/designs -H "Authorization: Bearer $TOKEN"
```
```json
{
  "designs": [
    { "id": "dsg_3f9c0a7e1b2d4c5e6f70", "name": "חוויה בקליק", "isDefault": true, "archived": false, "archivedAt": null,
      "createdAt": "2026-10-01T08:00:00.000Z", "version": 12, "versions": 3, "template": "classic", "accentColor": "#1a4a8c",
      "footerText": "", "hasLogo": true, "logoType": "png", "logoBytes": 18234 },
    { "id": "dsg_8a1b2c3d4e5f60718293", "name": "מדיה בקליק", "isDefault": false, "archived": false, "archivedAt": null,
      "createdAt": "2026-10-20T08:00:00.000Z", "version": 15, "versions": 1, "template": "modern", "accentColor": "#6a1b9a",
      "footerText": "תודה!", "hasLogo": false, "logoType": null, "logoBytes": 0 }
  ],
  "defaultDesignId": "dsg_3f9c0a7e1b2d4c5e6f70"
}
```
```bash
curl -s -X POST https://<engine>/v1/businesses/$BIZ/documents -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "docType": 305, "designId": "dsg_8a1b2c3d4e5f60718293",
        "customer": { "name": "עיריית רמלה", "vatNumber": "513746776" },
        "lines": [{ "description": "יום הפעלות", "quantity": 1, "unitPrice": 300000 }] }'
```
```json
{ "id": 431, "docType": 305, "docNumber": 60012, "designId": "dsg_8a1b2c3d4e5f60718293", "designName": "מדיה בקליק", "...": "..." }
```

המסמך הבא, בכל עיצוב, יקבל את המספר 60013: הסדרה משותפת.

#### הנתיבים מלפני 0.13 (עיצוב ברירת המחדל)

ממשיכים לעבוד, על עיצוב ברירת המחדל של העסק:

| קריאה | תוצאה |
|---|---|
| `GET /v1/businesses/:id/design` | `{ version, template, accentColor, footerText, footerByType, logo, designId, name }`. `logo` הוא data URL או null; `designId` ו-`name` (מ-0.13) הם של עיצוב ברירת המחדל |
| `PUT /v1/businesses/:id/design` (בעל העסק) | `{ template: "classic"\|"modern"\|"minimal", accentColor: "#RRGGBB", footerText, logo? }` → `{ version, designId }`. `logo`: data URL של PNG/JPEG עד 300KB, `null` להסרה, או בלי השדה כדי להשאיר את הקיים. כל שמירה היא גרסה חדשה של עיצוב ברירת המחדל. כאן שדה חסר מקבל ערך בסיסי (לא נשאר כמו קודם) |
| `POST /v1/businesses/:id/design/preview` | אותו גוף, מחזיר PDF לדוגמה בלי לשמור (לוגו שלא נשלח – מעיצוב ברירת המחדל) |

כל מסמך נשמר עם גרסת העיצוב שהייתה בתוקף כשהופק, ולכן העתק שמופק אחר כך נראה כמו המקור. העיצוב משנה רק מראה;
כל השדות שהחוק מחייב מודפסים בכל תבנית.

### כרטיסי לקוח ופריטים

מנוע 0.16: כרטיסי לקוח ומחירון פריטים לכל עסק. כרטיס או פריט הם נתוני עבודה: עורכים אותם, מעבירים לארכיון ומוחקים
כרטיס שאף מסמך לא מקושר אליו. **המסמך עצמו לא משתנה:** בהפקה הוא שומר צילום משלו של פרטי הלקוח ושל כל שורה, כמו
קודם, וה-hash מכסה את הצילום. שינוי בכרטיס או בפריט אחר כך לא משנה אף מסמך שכבר הופק.

- **מזהים:** כרטיס לקוח `cus_` ופריט `itm_`, ואחריהם 20 תווים הקסדצימליים. באתר: בראש כל כרטיס ("מזהה API"), עם כפתור העתקה.
- **`externalId`:** המזהה במערכת אחרת (למשל מספר הלקוח ב-CRM), עד 100 תווים, **ייחודי בעסק** כשקיים (כפילות: `409`).
  מחפשים לפיו ב-`GET /v1/businesses/:id/customers/external/:externalId`, ומפיקים לפיו ב-`customerExternalId`.
- **הרשאות:** צפייה – כל מי שיש לו גישת צפייה (גם רואה חשבון ומפתח API). יצירה, עריכה, ארכיון, ייבוא ומחיקה – מי
  שמפיק מסמכים: בעל העסק, עובד, מפתח API של העסק, טוקן שירות ומנהל. רואה חשבון מקבל `403`.
- **ארכיון:** כרטיס או פריט בארכיון לא מופיעים ברשימה הרגילה, ואי אפשר להפיק להם מסמך (`422`). המסמכים שכבר מקושרים
  נשארים מקושרים. **מחיקה** רק לכרטיס שאף מסמך לא מקושר אליו (ולפריט שאף שורה לא הופקה ממנו); אחרת `409`.
- **אירועים:** יצירה ועדכון של כרטיס או פריט לא נכנסים ליומן האירועים של העסק ולא נשלחים ב-webhooks. היומן וה-webhooks
  מתעדים רק פעולות על מסמכים.

#### כרטיס לקוח

| שדה | תוכן |
|---|---|
| `name` | חובה, 1–200 תווים |
| `vatNumber` | ח"פ / ע"מ / ת"ז, עם ספרת ביקורת תקינה (נשמר ב-9 ספרות); ריק = אין |
| `email`, `phone`, `mobile` | אימייל (עד 200), טלפון ונייד (עד 30) |
| `address`, `city`, `zip`, `country` | כתובת (רחוב ומספר בשורה אחת), עיר, מיקוד, וקוד ארץ ISO בשתי אותיות (ברירת מחדל `IL`) |
| `contactPerson` | איש קשר |
| `paymentTermsDays` | תנאי תשלום, שוטף + ימים (0–365), או `null` |
| `notes` | הערות פנימיות, עד 2,000 תווים |
| `externalId` | ראו למעלה; `null` מוחק |
| `tags` | עד 20 תגיות, כל אחת עד 40 תווים |
| `customFields` | שדות נוספים: אובייקט של מפתח (1–40 תווים) וערך **טקסט** (עד 500), עד 30 שדות. מספר או true/false נשמרים כטקסט; ערך ריק או `null` משמיט את השדה |

התשובה: כל השדות, ועוד `id`, `businessId`, `archived`, `archivedAt`, `createdAt`, `updatedAt`. שדה לא מוכר בגוף הבקשה נדחה
ב-`422` (כדי ששגיאת כתיב לא תיבלע בשקט).

| קריאה | מי | תוצאה |
|---|---|---|
| `GET /v1/businesses/:id/customers?q&archived&limit&offset&sort&since&vatNumber&email&externalId` | צפייה | `{ customers, total, limit, offset }`, לפי שם. `q` מחפש חלק מהשם, ח"פ, אימייל, טלפון, נייד, איש קשר ו-`externalId`; `archived`: `active` (ברירת מחדל), `archived` או `all`; `limit` עד 200 (ברירת מחדל 50). מנוע 0.19: `sort=newest` – החדשים קודם (לפי `createdAt`); `since` (ISO 8601) – רק מה שנוצר אחריו; `vatNumber`, `email` ו-`externalId` – התאמה מדויקת (ח"פ מנורמל ל-9 ספרות, אימייל בלי הבדל אותיות) |
| `POST /v1/businesses/:id/customers` | הפקה | כרטיס חדש → `201` עם הכרטיס |
| `GET /v1/customers/:customerId` | צפייה | הכרטיס, ועוד `stats: { documents, invoiced, received, lastDocumentDate }`: המסמכים המקושרים, חשבוניות פחות זיכויים (כולל מע"מ) ותקבולים, באגורות |
| `GET /v1/businesses/:id/customers/external/:externalId` | צפייה | אותו דבר, לפי `externalId` (מקודד ל-URL). לא נמצא: `404` |
| `PATCH /v1/customers/:customerId` | הפקה | עדכון חלקי: רק השדות שנשלחו. `tags` ו-`customFields` שנשלחו **מחליפים** את הקיימים |
| `POST /v1/customers/:customerId/archive` | הפקה | העברה לארכיון. קריאה חוזרת לא משנה דבר |
| `POST /v1/customers/:customerId/unarchive` | הפקה | החזרה מהארכיון |
| `DELETE /v1/customers/:customerId` | הפקה | מחיקה; כרטיס עם מסמכים מקושרים: `409` |
| `POST /v1/businesses/:id/customers/bulk` | הפקה | `{ rows: [...], dryRun? }`, 1 עד 500 שורות → `{ dryRun, created, failed, results: [{ index, ok, record?, error? }] }`. כל שורה נבדקת ונשמרת לחוד: שורה שגויה לא עוצרת את השאר. `externalId` שחוזר באותה בקשה או שכבר קיים בעסק – שגיאה בשורה. עם `dryRun: true` רק בודקים |
| `POST /v1/businesses/:id/customers/import-from-documents` | הפקה | [ייבוא הלקוחות מהמסמכים הקיימים](#ייבוא-הלקוחות-מהמסמכים-הקיימים) |

```bash
curl -s -X POST https://<engine>/v1/businesses/$BIZ/customers -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "name": "עיריית רמלה", "vatNumber": "513746776", "email": "office@ramla.example", "city": "רמלה",
        "paymentTermsDays": 30, "externalId": "crm-77", "tags": ["רשויות"], "customFields": { "ענף": "חינוך" } }'
```
```json
{ "id": "cus_5c1f0e9a7b3d2c4e6f80", "businessId": "...", "name": "עיריית רמלה", "vatNumber": "513746776",
  "externalId": "crm-77", "tags": ["רשויות"], "customFields": { "ענף": "חינוך" }, "archived": false, "...": "..." }
```

#### פריט (מחירון)

| שדה | תוכן |
|---|---|
| `name` | חובה, 1–200 תווים. זה התיאור שמודפס בשורה |
| `description` | תיאור פנימי, עד 1,000 תווים (לא מודפס) |
| `sku` | מק"ט, עד 50 תווים, **ייחודי בעסק** כשקיים (כפילות: `409`); `null` מוחק |
| `unitPrice` | מחיר ליחידה באגורות, מספר שלם 0 ומעלה |
| `pricesIncludeVat` | האם `unitPrice` כולל מע"מ (ברירת מחדל `false`) |
| `unit` | יחידת מידה (ברירת מחדל "יחידה"), למשל שעה או יום |
| `externalId`, `customFields` | כמו בכרטיס לקוח |

אין לפריט סימון "פטור ממע"מ": המנוע מחשב שיעור מע"מ אחד לכל המסמך, לפי סוג המסמך וסוג העסק.

| קריאה | מי | תוצאה |
|---|---|---|
| `GET /v1/businesses/:id/items?q&archived&limit&offset&sort&since&sku` | צפייה | `{ items, total, limit, offset }`; `q` מחפש בשם, מק"ט, תיאור ו-`externalId`. מנוע 0.19: `sort=newest`, `since` כמו בכרטיסי הלקוח, ו-`sku` – מק"ט מדויק |
| `POST /v1/businesses/:id/items` | הפקה | פריט חדש → `201` |
| `GET /v1/items/:itemId` | צפייה | הפריט |
| `GET /v1/businesses/:id/items/external/:externalId` | צפייה | הפריט לפי `externalId` |
| `PATCH /v1/items/:itemId` | הפקה | עדכון חלקי |
| `POST /v1/items/:itemId/archive`, `/unarchive` | הפקה | ארכיון והחזרה |
| `DELETE /v1/items/:itemId` | הפקה | מחיקה; פריט שהופקו ממנו שורות: `409` |
| `POST /v1/businesses/:id/items/bulk` | הפקה | כמו בלקוחות; מק"ט או `externalId` שחוזרים או תפוסים – שגיאה בשורה |

#### הפקה עם כרטיס ופריט

ב-`POST /v1/businesses/:id/documents` (גם עם `dryRun`):

- **`customerId`** (או **`customerExternalId`**): המנוע ממלא את פרטי הלקוח במסמך מהכרטיס – שם, ח"פ, כתובת (ברחוב), עיר,
  מיקוד, קוד ארץ, טלפון (ובלעדיו הנייד) ואימייל. `customer` לא חובה; **כל שדה שנשלח ב-`customer` גובר על הכרטיס** (גם ערך
  ריק). `customer.key` לא נלקח מהכרטיס. המסמך מקושר לכרטיס: `customerId` בתשובה, ב-`GET /v1/documents/:docId`, ב-`customer_id`
  ברשימת המסמכים, וב-`GET /v1/businesses/:id/documents?customerId=cus_...`. כרטיס של עסק אחר, שלא קיים או שבארכיון: `422`,
  ושום מספר לא מנוצל.
- **בלי `customerId`** ההפקה עובדת בדיוק כמו קודם (חובה `customer` עם `name`), והמסמך לא מקושר לכרטיס. אין קישור אוטומטי לפי
  ח"פ; מקשרים אחר כך ב[ייבוא מהמסמכים](#ייבוא-הלקוחות-מהמסמכים-הקיימים).
- **`itemId`** (או **`itemSku`**) בשורה: התיאור (שם הפריט), המחיר, המק"ט והיחידה מתמלאים מהפריט, **אלא אם נשלחו בשורה**.
  `description` ו-`unitPrice` לא חובה בשורה כזו. מחיר הפריט מותאם ל-`pricesIncludeVat` של המסמך: פריט שמחירו כולל מע"מ
  במסמך שמחיריו לפני מע"מ – מפורק לפי שיעור המע"מ של המסמך, ולהפך – מתווסף מע"מ (במסמך בלי מע"מ המחיר נשאר כמו שהוא).
  השורה שומרת את הערכים עצמם; ב-`GET /v1/documents/:docId` לכל שורה גם `itemId`.

```json
{ "docType": 305, "customerId": "cus_5c1f0e9a7b3d2c4e6f80", "customer": { "email": "billing@ramla.example" },
  "lines": [{ "itemId": "itm_0a9b8c7d6e5f40312233", "quantity": 2 }, { "itemSku": "ACT-1", "quantity": 1, "unitPrice": 120000 }] }
```

הקישורים נשמרים בטבלאות נפרדות (`document_customers`, `document_line_items`) שאפשר רק להוסיף להן, מחוץ ל-hash ובלי לגעת
בשורת המסמך, שנשארת חסומה לשינוי.

#### ייבוא הלקוחות מהמסמכים הקיימים

`POST /v1/businesses/:id/customers/import-from-documents` עם `{ "dryRun": true }` (או גוף ריק להרצה) →
`{ dryRun, documentsScanned, customersFound, customersCreated, customersMatched, documentsLinked }`.

- עובר על כל מסמכי העסק **שעוד לא מקושרים לכרטיס**, ומקבץ אותם לפי ח"פ הלקוח; בלי ח"פ – לפי שם ואימייל (בלי הבדלי רווחים
  ואותיות גדולות).
- לקוח שכבר יש לו כרטיס (אותו ח"פ; בלי ח"פ – כרטיס בלי ח"פ עם אותו שם ואימייל) מקושר לכרטיס הקיים. אחרת נוצר כרטיס
  מהפרטים של **המסמך האחרון** שלו.
- **המסמכים לא משתנים:** לכל מסמך נוספת רק שורה ב-`document_customers` (`source: backfill`). ה-hash והשרשרת לא משתנים.
- אפשר להריץ שוב בכל עת: רק מסמכים חדשים בלי כרטיס נספרים. `dryRun: true` רק סופר.

### מפתחות API (בעל העסק או מנהל)

| קריאה | תוצאה |
|---|---|
| `POST /v1/businesses/:id/api-keys` עם `{ name }` | `201 { key, id, prefix, ... }`. את `key` שומרים מיד; הוא לא יוצג שוב. משתמש: רק בכניסה עם סיסמה, לא בטוקן גישה אישי (403), כי המפתח לא פג ולא מתבטל עם הטוקן |
| `GET /v1/businesses/:id/api-keys` | רשימת המפתחות (שם, תחילית, מתי נוצר ומתי בוטל), בלי המפתחות עצמם |
| `DELETE /v1/api-keys/:keyId` | ביטול המפתח. הוא מפסיק לעבוד מיד, והרשומה נשארת. מנוע 0.18: גם [האצלת החתימה](#האצלת-חתימה-api-ו-mcp) שלו נמחקת |

### מפתח החתימה וקוד החתימה (מנוע 0.18)

חוזר מס הכנסה 24/2004 מגדיר "חתימה אלקטרונית מאובטחת" לפי חוק חתימה אלקטרונית, ואחד התנאים הוא שאמצעי החתימה
**בשליטתו הבלעדית** של בעל החתימה – כאן העסק. עד מנוע 0.17 המפתח הפרטי של כל עסק הוצפן בסיסמה אחת של השרת
(`SIGNING_KEY_PASSPHRASE` / Vault `invoice_engine_signing_passphrase`), ולכן מפעיל השרת יכול היה לחתום בשם כל עסק.
ממנוע 0.18 (מיגרציה 019) בעל העסק יכול לקבוע **קוד חתימה**, והמפתח נעול בו.

**ברירת המחדל: חתימה אוטומטית.** בלי קוד, מפתח החתימה של העסק שמור בשרת (מוצפן בסיסמת המערכת), `protection: "platform"`,
והכל עובד אוטומטית: האתר, כל מפתח API וכל טוקן (גם חיבור MCP) שמפיקים בעסק מקבלים מקור ממוחשב חתום, בלי קוד ובלי
האצלות. האתר לא מציג על כך אזהרה ולא מבקש לקבוע קוד. קוד החתימה הוא **אפשרות מתקדמת** (הגדרות ← חתימה ← מתקדם)
לבעל עסק שרוצה שליטה בלעדית, ומי שבחר בה יכול תמיד לחזור לחתימה אוטומטית (`DELETE .../signing/pin`, למטה).

עם קוד חתימה:

- **הקוד:** 8 תווים לפחות, לא סיסמת החשבון, בלי רווח בקצוות, לפחות 4 תווים שונים. באתר ברירת המחדל היא קוד אקראי
  שהדפדפן יוצר (20 תווים, `XXXX-XXXX-XXXX-XXXX-XXXX`, כ-99 ביט), שמוצג פעם אחת ואפשר לזכור אותו בדפדפן.
- **ההצפנה:** PBKDF2-SHA256 עם 600,000 סבבים ומלח אקראי של 16 בתים לכל מפתח, ומהתוצאה מפתח AES-256-GCM; ההקשר (העסק
  וטביעת התעודה) הוא חלק מהנתונים המאומתים. הקוד **לא נשמר בשום מקום**, וגם לא שום דבר שפותח את המפתח בלעדיו.
- **בקביעה הראשונה** המפתח נפתח פעם אחרונה בסיסמת המערכת, נעטף בקוד, העטיפה נבדקת (נפתחת בקוד ומתאימה לתעודה), ורק
  אז **העותק בסיסמת המערכת נמחק** – מכל המפתחות של העסק, גם מפתחות קודמים, כדי שאי אפשר יהיה לחתום בהם בדיעבד.
- **התעודה והמפתח הציבורי לא משתנים:** כל מקור שכבר נחתם נשאר תקף ומאומת באותה תעודה.
- **עסק שלא קבע קוד** עובד בחתימה אוטומטית (`protection: "platform"`), כמו שתואר למעלה.
- **הקוד לא נרשם ביומן השרת** (הוא בגוף הבקשה, שלא נרשם) ולא חוזר בשום תשובה.
- **ניסיונות כושלים:** כמו בכניסה – אחרי 5 קודים שגויים החתימה של העסק נחסמת לרבע שעה (`429`, `signingError: "pin_locked"`).
- **חזרה לחתימה אוטומטית** (מיגרציה 023): `DELETE /v1/businesses/:id/signing/pin` עם הקוד הנוכחי. כל מפתח של העסק
  שנעול בקוד הזה נעטף שוב בסיסמת המערכת (העותק נבדק לפני שהעטיפה בקוד נמחקת), `pin_set_at` ו-`legacy_destroyed_at`
  מתנקים, כל ההאצלות של העסק נמחקות (כל אחת נרשמת כ-`delegation_revoked` עם `reason: "pin_removed"`), ונרשם
  `pin_removed` – הכל בטרנזקציה אחת. המפתח והתעודה לא משתנים. הקוד נבדק עם אותה ספירת ניסיונות כושלים כמו בחתימה.
  מפתח ישן שנעול בקוד אחר (קודם) נשאר כמו שהוא: הוא כבר לא חותם, והתעודה שלו ממשיכה לאמת מסמכים ישנים.
  השומר של שורות המפתחות (מיגרציה 023) מתיר לכתוב שוב את העותק בסיסמת המערכת **רק** באותו עדכון שמוחק את העטיפה
  בקוד, ולמחוק את העטיפה בקוד רק כשנכתב עותק – למפתח תמיד נשארת עטיפה אחת שנפתחת.
- **שכחתם את הקוד:** אי אפשר לשחזר אותו – גם לא מפעיל המערכת. יוצרים מפתח ותעודה חדשים בחתימה אוטומטית
  (`POST /v1/businesses/:id/signing-keys` בלי `pin`; בעסק שיש לו קוד – רק בעל העסק, עם `currentPassword`). המקורות
  הקודמים נשארים תקפים בתעודה הקודמת; ההאצלות של המפתח הקודם נמחקות, והמפתח הקודם נשאר כמו שהוא. אפשר גם ליצור
  מפתח חדש עם קוד חדש (`pin`) – למשל כשמדיניות המערכת דורשת קוד.

| קריאה | מי | גוף | תוצאה |
|---|---|---|---|
| `GET /v1/businesses/:id/signing` | כל מי שיש לו גישת צפייה | — | `{ protection, exclusiveControl, keyId, certificateFingerprint, pinSetAt, legacyDestroyedAt, legacyCopies, requirePin, lockedUntil, delegations, events }`. ההאצלות והיומן רק למי שמנהל את העסק |
| `PUT /v1/businesses/:id/signing/pin` | בעל העסק, בכניסה עם סיסמה | `{ pin, currentPin?, currentPassword, code? }` | קביעת קוד (או החלפה, עם `currentPin`). מחזיר את המצב. טוקן המנהל, טוקן גישה אישי, מפתח API ועובד: `403` |
| `DELETE /v1/businesses/:id/signing/pin` | בעל העסק, בכניסה עם סיסמה | `{ pin, currentPassword, code? }` | חזרה לחתימה אוטומטית. `200` עם המצב (`protection: "platform"`, `delegations: []`). קוד שגוי `403` (`pin_invalid`), חסום `429` (`pin_locked`), אין קוד `409` (`pin_not_set`), מדיניות שדורשת קוד `409` (`policy_requires_pin`). טוקן המנהל, טוקן גישה אישי, מפתח API ועובד: `403` |
| `POST /v1/businesses/:id/signing-keys` | בלי `pin`: מנהל העסק; בעסק שיש לו קוד – בעל העסק, בכניסה עם סיסמה. עם `pin`: בעל העסק, בכניסה עם סיסמה | `{ pin?, currentPassword?, code? }` | מפתח ותעודה חדשים: בלי `pin` – בשרת (חתימה אוטומטית); עם `pin` – נעולים רק בקוד החדש. נרשם `key_rotated` (עם `protection` ו-`previousProtection`) |
| `PUT /v1/platform/signing-policy` | מנהל פלטפורמה (עם `currentPassword`) או טוקן המנהל | `{ requirePin, note? }` | מדיניות: בלי קוד חתימה אין מקור ממוחשב (`422`, `signingError: "policy_requires_pin"`). ברירת המחדל: כבוי |
| `GET /v1/platform/signing-policy` | אותו דבר | — | `{ policy: { requirePin, updatedAt, updatedBy }, changes }` |

**יומן אירועי החתימה** (`events`, טבלה שאפשר רק להוסיף לה): `pin_set`, `pin_changed`, `legacy_destroyed`,
`delegation_created`, `delegation_revoked`, `key_rotated`, `pin_locked`, `policy_changed`, `pin_removed` (מיגרציה 023: חזרה
לחתימה אוטומטית) – מי ומתי, בלי הקוד.

**שגיאות חתימה** (`signingError` בגוף התשובה): `pin_required` (422: חסר קוד ואין האצלה), `pin_invalid` (403: קוד
שגוי), `pin_locked` (429), `pin_rejected` (422: קוד חדש לא תקין), `policy_requires_pin` (422; בחזרה לחתימה אוטומטית
409), `pin_not_set` (409: לעסק אין קוד), `no_signing_key` (422).

#### האצלת חתימה (API ו-MCP)

נחוץ רק בעסק שבחר בקוד חתימה (בחתימה אוטומטית כל מפתח וטוקן חותמים בלי האצלה). מערכת אוטומטית (CRM, סקריפט, חיבור
Claude) שצריכה להפיק מקורות ממוחשבים בלי שיקלידו קוד: בעל העסק מאציל חתימה
למפתח API של העסק או לטוקן גישה אישי (`iep_`, גם הטוקן של חיבור MCP). המפתח הפרטי נעטף **עוד פעם** במפתח שנגזר
(HKDF-SHA256, מלח אקראי) מ**הסוד המלא** של המפתח או הטוקן. הסוד מוצג פעם אחת ונשמר במנוע רק כ-SHA-256, ולכן השרת לבדו
לא פותח את העטיפה: רק בקשה שמגיעה עם הסוד עצמו. בבקשה כזו המנוע גוזר את המפתח, פותח בזיכרון, חותם ושוכח.

| קריאה | מי | גוף | תוצאה |
|---|---|---|---|
| `POST /v1/businesses/:id/signing/delegations` | בעל העסק, בכניסה עם סיסמה | `{ credential, pin, label?, currentPassword, code? }` | `201 { delegation: { id, credentialKind, credentialPrefix, label, createdAt, ... } }`. `credential` הוא הסוד המלא (`iek_...` של העסק, או `iep_...` של מי שמפיק בעסק) |
| `DELETE /v1/signing-delegations/:delegationId` | מי שמנהל את העסק | — | ביטול: העטיפה נמחקת; המפתח או הטוקן ממשיכים לעבוד, אבל לא חותמים |

- **ביטול המפתח או הטוקן** (או שינוי הסיסמה של בעל הטוקן, שמבטל את כל הטוקנים שלו) מוחק גם את ההאצלה.
- **החלפת מפתח** מוחקת את ההאצלות של המפתח הקודם; יוצרים אותן מחדש. **חזרה לחתימה אוטומטית** מוחקת את כל ההאצלות
  של העסק (אין בהן צורך יותר).
- **חיבור Claude (MCP):** הטוקן של חיבור נשמר מוצפן בשרת של האתר, ולכן ההאצלה עוברת דרכו
  (`POST /api/oauth/connections/:id/signing-delegation` באתר). שימו לב: כך גם השרת של האתר יכול לחתום בשם העסק כל
  עוד החיבור פעיל – זו כבר לא שליטה בלעדית מלאה. בלי האצלה, `produce_original` ב-MCP מחזיר הודעה בעברית עם קישור
  לחתימה באתר, והקוד לא עובר דרך Claude.

### מסמכים

#### הפקה

`POST /v1/businesses/:id/documents`

```json
{
  "docType": 320,
  "operator": "tami",
  "customer": { "key": "crm-1234", "name": "משפחת כהן", "vatNumber": "", "city": "ירושלים", "email": "..." },
  "lines": [{ "description": "הפעלה בקליקרים", "quantity": 1, "unitPrice": 90000 }],
  "pricesIncludeVat": true,
  "payments": [{ "method": 4, "amount": 90000, "paymentDate": "2026-09-22" }],
  "notes": "תודה!"
}
```

| שדה | ערכים |
|---|---|
| `docType` | ראו טבלת סוגי המסמכים בהמשך |
| `method` | 1 מזומן, 2 שיק (חובה פרטי שיק ו-`checkCrossedNotNegotiable`), 3 אשראי, 4 העברה, 8 הוראת קבע, 9 אחר (`methodLabel`, למשל "ביט") |
| `base` | `{ "docType": 305, "docNumber": 60012 }` |

סוגי המסמכים (הקודים לפי נספח 1 למבנה האחיד, חוץ מהצעת מחיר):

| קוד | מסמך | מי רשאי | הערות |
|---|---|---|---|
| 10 | הצעת מחיר | כולם | לא מסמך חשבונאי: סדרה משלה, לא נכלל במבנה האחיד, מקור ממוחשב בלי הודעה לפקיד השומה. `validUntil` = בתוקף עד |
| 100 | הזמנה | מורשה, חברה, פטור | |
| 200 | תעודת משלוח | מורשה, חברה, פטור | אפשר בלי מחירים (`unitPrice: 0`). `base` = הזמנה |
| 210 | תעודת החזרה | מורשה, חברה, פטור | אפשר בלי מחירים. `base` = תעודת משלוח |
| 300 | חשבון עסקה / חשבונית עסקה | מורשה, חברה, פטור | אצל מורשה: דרישת תשלום עם מע"מ, "אינו חשבונית מס", לא נספר כהכנסה. אצל פטור: החשבונית, בלי מע"מ |
| 305 | חשבונית מס | מורשה, חברה | `base` = הזמנה, תעודת משלוח או חשבון עסקה |
| 320 | חשבונית מס / קבלה | מורשה, חברה | רק בתשלום מלא |
| 330 | חשבונית מס זיכוי | מורשה, חברה | חובה `base` (305/320) ו-`creditReason` |
| 400 | קבלה | כולם | חובה `notes` כמהות התקבול, או `base` (305/300) |
| 405 | קבלה על תרומה | עמותה | אישור סעיף 46 מפרטי העסק מודפס כשהוא בתוקף. דיווח ל"תרומות ישראל" עוד לא מחובר |

סוג שנוסף אחרי שהעסק נרשם צריך סדרה: `POST /v1/businesses/:id/sequences` (בעל העסק), פעם אחת. עד המסמך הראשון
מהסוג הזה אפשר להעלות את המספר הבא ב-`PUT /v1/businesses/:id/sequences/:docType`.

`designId` (אופציונלי, מנוע 0.13): העיצוב שבו המסמך יופק, מ-`GET /v1/businesses/:id/designs`; בלעדיו – עיצוב ברירת
המחדל. ראו [עיצובים ומותגים](#עיצובים-ומותגים).

`shareLink` (אופציונלי, מנוע 0.15): `true`, או `{ "expiresInDays": 30 }` – יוצר באותה הפקה גם [קישור שיתוף](#קישורי-שיתוף)
למסמך. הקישור מוחזר ב-`shareLink` של התשובה (`{ id, token, path, url, expiresAt, ... }`, פעם אחת) ובגוף ההודעה של
`document.issued` ב-webhooks. ערך אחר: `400`, ושום מספר לא מנוצל. לא בבדיקה (`dryRun`) ולא בתצוגה מקדימה.

`customerId` / `customerExternalId` ו-`itemId` / `itemSku` בשורה (אופציונליים, מנוע 0.16): פרטי הלקוח והשורות מתמלאים
מכרטיס הלקוח ומהמחירון, ומה שנשלח בבקשה גובר. ראו [הפקה עם כרטיס ופריט](#הפקה-עם-כרטיס-ופריט).

התשובה היא `201` עם `{ id, docNumber, amountNet, vatAmount, amountTotal, requiresManualSignature, designId, designName, customerId, allocation: { required, reason }, shareLink?, warnings?, ... }`.

##### בדיקה בלי הפקה (`dryRun`)

אותה קריאה עם `"dryRun": true` (ובאותה הרשאה, הפקה) בודקת ומחשבת את המסמך בדיוק כמו הפקה אמיתית: אותה פונקציה,
אותן בדיקות, אותם סכומים ומע"מ, המספר הבא בסדרה וחובת מספר ההקצאה. המנוע אף כותב את המסמך בתוך טרנזקציה,
ובסוף מבטל אותה. **שום דבר לא נשמר:** אין מסמך, אין אירוע ביומן, המונה של הסדרה לא מתקדם ושרשרת ה-hash לא משתנה.

```json
{ "docType": 320, "customer": { "name": "משפחת כהן" }, "lines": [...], "payments": [...], "dryRun": true }
```

התשובה היא `200` עם המסמך כפי שהיה מופק (אותם שדות כמו `GET /v1/documents/:docId`: עסק, לקוח, שורות, תשלומים,
סכומים), ועוד `allocation`, `"dryRun": true` ו-`note`. `id` ו-`contentHash` הם `null`, כי שום דבר לא נשמר.
`docNumber` זמני: זה המספר שהמסמך יקבל אם יופק עכשיו, אלא אם יופק לפניו מסמך אחר באותה סדרה.
בקשה לא תקינה נדחית בדיוק כמו בהפקה (422 עם אותה הודעה). `dryRun` חייב להיות `true` או `false`; ערך אחר
(למשל `"true"` כמחרוזת) נדחה ב-400, כדי שלא יופק בטעות מסמך אמיתי.

המזהה הפנימי (`id`) של המסמך הבא אחרי בדיקה עשוי לדלג, כי מונה המזהים של Postgres לא חוזר אחורה בביטול
טרנזקציה. זה לא מספר המסמך: `docNumber` נשאר רציף בלי פערים.

##### תצוגה מקדימה כ-PDF

`POST /v1/businesses/:id/documents/preview` (מנוע 0.15) מקבל **אותו גוף** כמו ההפקה (כולל `designId`), באותה הרשאה
(מי שמפיק – גם מפתח API; רואה חשבון: `403`), ומחזיר `application/pdf`: המסמך **בדיוק כפי שיופק** – אותו ציור, העיצוב
שנבחר, שורת התחתית של סוג המסמך והשורות – ועליו סימן מים באלכסון "תצוגה מקדימה – אינו מסמך", "יוקצה בהפקה" במקום
המספר, סימון "טיוטה" (פלט מקובץ זמני, נספח ה'(א)) במקום מקור או העתק, ובלי טביעת המסמך. הבדיקה היא אותה בדיקה בלי
הפקה (`dryRun`), ולכן בקשה לא תקינה נכשלת **בדיוק באותן שגיאות** כמו ההפקה (אותו קוד ואותה הודעה).
**שום דבר לא נשמר:** לא מספר ולא מונה הסדרה, לא אירוע ביומן, לא משלוח webhook, לא שרשרת ה-hash, לא העתק ביומן
ולא קישור שיתוף (`shareLink` מתעלמים ממנו כאן). בעסק במצב מסמכים אמיתיים התצוגה מסומנת בכל זאת, והשורה התחתונה
היא "הופק על ידי העסק", כמו במסמך שיופק. התשובה עם `Cache-Control: no-store`.

##### חשבונית זיכוי

```json
{
  "docType": 330,
  "customer": { "key": "crm-1234", "name": "עיריית רמלה", "vatNumber": "513746776" },
  "lines": [{ "description": "ביטול חלק מההפעלה", "quantity": 1, "unitPrice": 100000 }],
  "base": { "docType": 305, "docNumber": 60012 },
  "creditReason": "ההפעלה קוצרה בשעה"
}
```

הזיכוי חייב להיות לאותו לקוח של החשבונית, ועד היתרה שלה. כמה כבר זוכה רואים ב-`creditedNet` של החשבונית
(`GET /v1/documents/:docId`) או ב-`credited_net` ברשימת המסמכים. אין מחיקה ואין ביטול: כל תיקון הוא מסמך חדש.

##### קבלה על חשבונית

```json
{
  "docType": 400,
  "customer": { "key": "crm-1234", "name": "עיריית רמלה", "vatNumber": "513746776" },
  "payments": [{ "method": 4, "amount": 354000, "paymentDate": "2026-10-15" }],
  "base": { "docType": 305, "docNumber": 60012 }
}
```

- כש-`allocation.required` אמת, צריך לבקש מספר הקצאה מרשות המסים ולרשום אותו לפני הפקת המקור.
- כש-`requiresManualSignature` אמת (למשל במזומן), אי אפשר להפיק מקור ממוחשב. מפיקים מקור להדפסה וחותמים עליו ביד.

#### שאר הקריאות

| קריאה | תיאור |
|---|---|
| `GET /v1/businesses/:id/documents?from&to&docType&customerKey&customerId&sinceId&docNumber&limit` | רשימת מסמכים, מהחדש לישן (לפי `id`, שעולה לפי סדר ההפקה). מנוע 0.19: `sinceId` – רק מסמכים שהמזהה שלהם גדול ממנו (לסקירה בתדירות), `docNumber` – לפי מספר המסמך (עם `docType`: מסמך אחד). לכל חשבונית גם `credited_net` (כמה זוכה) ומסמך הבסיס של זיכוי או קבלה; לכל מסמך `design_id` ו-`design_name` (העיצוב שבו הופק) ו-`customer_id` (כרטיס הלקוח, מנוע 0.16). `customerId=cus_...`: רק המסמכים שמקושרים לכרטיס |
| `GET /v1/documents/:docId` | כל פרטי המסמך. בחשבונית גם `creditedNet`; `designId` ו-`designName` – העיצוב שבו הופק; `customerId` – כרטיס הלקוח, ו-`itemId` בכל שורה – הפריט שממנו מולאה (מנוע 0.16) |
| `GET /v1/documents/:docId/events` | יומן הפעולות על המסמך: מתי הופק, מי רשם מספר הקצאה, מתי הופק המקור ומתי הודפסו העתקים |
| `GET /v1/businesses/:id/events?after&limit` | יומן האירועים של כל העסק לפי הסדר (בהמשך). להודעות בזמן אמת: [Webhooks](#webhooks) |
| `POST /v1/documents/:docId/allocation` `{ allocationNumber, method: "manual"\|"api" }` | רישום מספר הקצאה (9 עד 30 ספרות; המספר המלא מרשות המסים הוא בדרך כלל 26 ספרות) |
| `POST /v1/documents/:docId/original` `{ computerized: true\|false, signingPin? }` | ה-PDF של המקור. מנוע 0.18: כשמפתח החתימה נעול בקוד, מקור ממוחשב דורש `signingPin` (קוד החתימה של העסק), או מפתח API / טוקן שקיבלו [האצלת חתימה](#האצלת-חתימה-api-ו-mcp); אחרת `422` עם `signingError: "pin_required"`. true = מקור ממוחשב חתום לשליחה ללקוח; false = מקור להדפסה. המקור מופק פעם אחת. מנוע 0.15: קריאה חוזרת מחזירה **העתק** מסומן "העתק" (`X-Already-Produced: true`, `X-Original-Served: copy`) ונרשמת ביומן המסמך כ-`original_retrieved` בשם מי שביקש; המקור עצמו לא יוצא שוב. מקור ממוחשב רק ל-300, 305, 320, 330, 400 ו-405 (חוזר מס הכנסה 24/2004) ולהצעת מחיר (10); להזמנה (100), תעודת משלוח (200) ותעודת החזרה (210) – רק מקור להדפסה (`422`) |
| `GET /v1/documents/:docId/copy` | PDF של העתק |
| `POST /v1/businesses/:id/documents/preview` | PDF של [תצוגה מקדימה](#תצוגה-מקדימה-כ-pdf), בלי לשמור דבר |
| `POST /v1/documents/:docId/share-links`, `GET .../share-links` | [קישורי שיתוף](#קישורי-שיתוף) של המסמך |

#### יומן האירועים של העסק

`GET /v1/businesses/:id/events?after=<מזהה>&limit=<1..500>` (צפייה) מחזיר את כל האירועים של מסמכי העסק – הפקה,
מספר הקצאה, מקור, העתקים – לפי סדר המזהה, מהישן לחדש. מיועד לחיבור חיצוני שבודק מה חדש מאז הפעם הקודמת:

```json
{
  "events": [
    { "id": 812, "documentId": 431, "docType": 305, "docNumber": 60012, "event": "issued",
      "at": "2026-10-15T09:12:44.000Z", "actor": "nissim@example.com (token Claude)", "details": { "contentHash": "..." } }
  ],
  "next": 812
}
```

- מנוע 0.19: `event` – רק סוג אחד (`issued`, `original_produced`, `copy_produced`, `allocation_recorded`, ...); `order=desc` – העמוד
  מהחדש לישן (עדיין רק מזהים גדולים מ-`after`), ואז `next` הוא המזהה הגדול ביותר בעמוד. לסקירה בתדירות של Zapier ו-Make.
- `after` (ברירת מחדל 0): מחזיר רק אירועים שהמזהה שלהם גדול ממנו. `limit` ברירת מחדל 100, עד 500. ערך לא תקין: 400.
- `next` הוא המזהה של האירוע האחרון בעמוד, או `after` עצמו כשאין חדשים. שולחים אותו כ-`after` בקריאה הבאה.
- `id`, `documentId`, `docType` ו-`docNumber` הם תמיד מספרים (JSON number). `at` בפורמט ISO 8601 ב-UTC.
- רק מסמכים של העסק שבנתיב. מפתח API רואה רק את העסק שלו, ומשתמש רק עסק שהוא שייך אליו.
- בכל עסק המזהים נקבעים לפי סדר השמירה: כתיבות ליומן של אותו עסק ממתינות זו לזו עד סוף הטרנזקציה. לכן כשאירוע
  מופיע, כל האירועים הקודמים של העסק כבר מופיעים, וחיבור שמתקדם לפי `next` לא מפספס אף אירוע, גם כשכמה פעולות רצות במקביל.
- המזהים לא רציפים: יש ביניהם אירועים של עסקים אחרים ומזהים של בדיקות בלי הפקה (`dryRun`) שבוטלו.

### קישורי שיתוף

מנוע 0.15: קישור קבוע וחתום למסמך אחד, שכל מי שמחזיק בו פותח **בלי להתחבר** – למשל כדי לשלוח ללקוח בוואטסאפ או
במייל. הקישור הוא כתובת באתר, `<האתר>/s/<token>`. הטוקן אקראי (24 בתים, 32 תווי base64url), מוחזר **פעם אחת**
ביצירה, ובבסיס הנתונים נשמר רק ה-SHA-256 שלו (מיגרציה 016). אי אפשר להציג שוב קישור קיים: יוצרים חדש ומבטלים את הישן.

| קריאה | מי | תוצאה |
|---|---|---|
| `POST /v1/documents/:docId/share-links` `{ expiresInDays? }` | מי שמפיק (בעל העסק, עובד, מפתח API, טוקן שירות) | `201 { id, documentId, status, createdAt, createdBy, expiresAt, opens, token, path, url }`. `expiresInDays` 1–3650; בלעדיו – קישור בלי תוקף. עד 20 קישורים פעילים למסמך (`409`) |
| `GET /v1/documents/:docId/share-links` | צפייה | `{ links: [...] }` מהחדש לישן: `status` (`active`, `revoked`, `expired`), `opens`, `lastOpenedAt`, מי יצר ומי ביטל – בלי הכתובות |
| `GET /v1/share-links/:linkId` | צפייה | `{ link, opens: [{ at, served, client }] }` – 50 הפתיחות האחרונות |
| `DELETE /v1/share-links/:linkId` | מי שמפיק | ביטול: הקישור מפסיק לעבוד מיד, והרשומה נשארת. קריאה חוזרת לא משנה דבר |
| `GET /v1/share/:token` | ציבורי (בלי `Authorization`) | `{ businessName, docType, docTypeName, docNumber, documentDate, served, expiresAt, filename }` – מה שדף השיתוף מציג. בלי פרטי הלקוח |
| `GET /v1/share/:token/pdf` | ציבורי | ה-PDF, עם `X-Share-Served: original` או `copy`. כל הורדה נרשמת כפתיחה |

`url` היא הכתובת המלאה כשהמנוע יודע את כתובת האתר (`ENGINE_SHARE_BASE_URL` בשרת; ב-Edge Function – כתובת האתר
של הפרויקט), ואחרת `null`, ואז `path` (`/s/<token>`) מצטרף לכתובת האתר.

**מה מוגש:** המקור הממוחשב החתום – **רק** כשהכללים של המנוע כבר מתירים לתת אותו ללקוח: הוא הופק
(`POST /v1/documents/:docId/original` עם `computerized: true`, אחרי הודעה לפקיד השומה, הסכמת הלקוח ואמצעי תשלום
מותר), וההחלטה האחרונה של הלקוח היא עדיין הסכמה (הוראה 18ב(ג); בהצעת מחיר אין צורך בהסכמה). בכל מקרה אחר – **העתק**
מסומן "העתק", שנרשם ביומן המסמך כמו כל העתק (`copy_produced`, בשם `share-link:<id>`, וגם ב-webhooks). הקישור לא
מפיק מקור ולא עוקף שום כלל; מקור להדפסה לא נשלח בקישור.

**פרטיות ואבטחה:** הנתיבים הציבוריים עונים עם `Cache-Control: no-store`, `X-Robots-Tag: noindex` ו-`Referrer-Policy:
no-referrer`. טוקן לא מוכר: `404`; קישור שבוטל או פג: `410` (אותה הודעה). מגבלת קצב לכל קישור ולכל רשת של לקוח
(`429`). ביומן הפתיחות לא נשמרת כתובת ה-IP: רק `client`, טביעה מקוצרת (16 תווים) של הרשת (IPv4 /24, IPv6 /48)
יחד עם מזהה הקישור, שאי אפשר להחזיר ממנה את הכתובת או לקשר בין קישורים. הטוקן לא נכתב ליומן השרת. קישור לא נמחק
ולא משתנה בבסיס הנתונים, חוץ מהביטול; הקישורים (בלי הטוקנים) נכללים בייצוא המלא (`share-links.csv`).

**הפקה עם קישור:** `"shareLink": true` (או `{ "expiresInDays": 30 }`) ב-`POST /v1/businesses/:id/documents` יוצר את
הקישור באותה טרנזקציה של ההפקה. הכתובת נכנסת לגוף ההודעה של `document.issued` (`data.shareLink`), אבל לא ליומן
האירועים הקבוע. שימו לב: גוף ההודעה נשמר בהיסטוריית המשלוחים עד 30 יום, וכל מי שמנהל את ה-webhooks של העסק רואה אותו.

### Webhooks

בעל העסק יכול לחבר נקודות קצה (webhooks) שמקבלות הודעה על כל אירוע במסמכי העסק, במקום לבדוק כל הזמן את
[יומן האירועים](#יומן-האירועים-של-העסק). יש שני סוגים:

- **`standard`** – כתובת https של המערכת שלכם (CRM, הנהלת חשבונות וכו'). כל הודעה נשלחת ב-`POST` וחתומה לפי
  [Standard Webhooks](https://www.standardwebhooks.com/), בסוד חתימה (`whsec_...`) שמוצג פעם אחת ביצירה.
- **`claude_routine`** – routine של Claude Code (claude.ai/code/routines) עם טריגר API. המנוע עצמו מפעיל אותו, בכתובת
  הקבועה `https://api.anthropic.com/v1/claude_code/routines/<trig_...>/fire`, עם הטוקן של ה-routine (`sk-ant-oat01-...`).
  הטוקן נשמר מוצפן, לא מוצג שוב ולא יוצא מהמנוע (גם לא למשגר).

סוגי האירועים (אירוע אחד ביומן = הודעה אחת):

| אירוע | מתי | ברירת מחדל |
|---|---|---|
| `document.issued` | הופק מסמך | standard, claude_routine |
| `document.original_produced` | הופק המקור (ממוחשב חתום או להדפסה) | standard |
| `document.copy_produced` | הופק העתק (כל הורדה של העתק) | – |
| `document.allocation_recorded` | נרשם מספר הקצאה | standard |
| `webhook.test` | הודעת בדיקה (`POST /v1/webhooks/:id/test`) | תמיד |

קריאה ל-/copy יוצרת document.copy_produced. כדי להוריד PDF בתגובה ל-webhook השתמשו ב-/original (אחרי הפעם הראשונה בלי אירוע) או בנתוני ה-JSON, אחרת כל הורדה תיצור הודעה חדשה.
לכן `document.copy_produced` לא נבחר כברירת מחדל, routine של Claude לא יכול לקבל אותו, ולאותו webhook ואותו מסמך
נשלחת לכל היותר הודעת העתק אחת בכל 10 דקות.

#### הרשאות

רק בעל העסק (וטוקן המנהל, בחלק מהפעולות). עובד, רואה חשבון, מפתח API וטוקן שירות מקבלים 403. חריג אחד (מנוע 0.19):
[מינויי Zapier ו-Make](#מינויי-אוטומציה-zapier-ו-make-מנוע-019), במפתח API או בטוקן גישה, רק לכתובות של הפלטפורמות.

- **T** – בעל העסק בכניסה עם סיסמה (`ies_`) או בטוקן גישה אישי (`iep_`), או טוקן המנהל.
- **O** – בעל העסק בכניסה עם סיסמה בלבד.
- **S** – O, ועוד **אימות מחדש בגוף הבקשה**: `currentPassword`, ו-`code` כשיש אימות דו-שלבי (כמו ביצירת טוקן גישה).
  סיסמה שגויה: 401 (ונספרת לחסימה); בלי קוד: 401 עם `twoFactorRequired: true`. קלט שגוי נבדק קודם ומחזיר 422 בלי להיספר.

webhook ממשיך לשלוח נתונים גם אחרי שטוקן בוטל או שסיסמה הוחלפה, ולכן יצירה, הפעלה מחדש, הוספת אירועים, שינוי כתובת
או טוקן, החלפת סוד והצגת הכתובת המלאה הם S. בעל העסק שביצע פעולת S אחרונה הוא **האחראי** ל-webhook; כשהוא מוסר
מהעסק או מפסיק להיות בעל העסק, ה-webhook מושבת מיד (`creator_removed`), ובעל עסק אחר צריך לבדוק אותו ולהפעיל מחדש.

ההרשאה נבדקת שוב בתוך הטרנזקציה של השינוי: משתמש שהוסר מהעסק (או שכבר אינו בעל העסק) בזמן שהבקשה שלו רצה מקבל 403,
גם אם הבדיקה בתחילת הבקשה עברה. `PATCH` מחליט אם הוא S לפי מצב ה-webhook בתחילת הבקשה; אם בינתיים ה-webhook הושבת
או צומצם, כך שהשינוי הפך להפעלה מחדש או להוספת אירוע, והבקשה לא כללה כניסה עם סיסמה ואימות מחדש, התשובה היא 409
(`ה-webhook השתנה בינתיים...`) ושום דבר לא משתנה. שולחים שוב, והפעם נדרש S.

| קריאה | הרשאה | גוף | תוצאה |
|---|---|---|---|
| `GET /v1/businesses/:id/webhooks` | T | — | `{ deliveryEnabled, limits, eventTypes: [{ type, name }], endpoints, audit }`. `deliveryEnabled: false` = אין בשרת הזה משגר, ושום הודעה לא נשלחת |
| `POST /v1/businesses/:id/webhooks` | S | `{ kind?: "standard", url, description?, eventTypes?, currentPassword, code? }` או `{ kind: "claude_routine", routineId, routineToken, description?, eventTypes?, currentPassword, code? }` | `201` נקודת הקצה, ועם `secret` (רק standard, **פעם אחת**). 422 כשאין משגר בשרת: `שליחת webhooks לא מופעלת בשרת הזה` |
| `GET /v1/webhooks/:endpointId` | T | — | נקודת הקצה |
| `POST /v1/webhooks/:endpointId/reveal-url` | S | `{ currentPassword, code? }` | `{ url }` – הכתובת המלאה, עם הפרמטרים. נרשם בהיסטוריית השינויים |
| `PATCH /v1/webhooks/:endpointId` | T; S להפעלה, להוספת אירוע, לכתובת ולטוקן | `{ description?, eventTypes?, enabled?, url?, routineToken?, currentPassword?, code? }` | נקודת הקצה. גוף ריק או שדה לא מוכר: 400. ה-webhook השתנה בינתיים והשינוי דורש עכשיו S: 409 |
| `DELETE /v1/webhooks/:endpointId` | T | — | `{ ok: true }`. מחיקה סופית, עם המשלוחים; רישום המחיקה נשאר בהיסטוריה |
| `POST /v1/webhooks/:endpointId/rotate-secret` | S | `{ previousSecretValidHours?: 0..72, currentPassword, code? }` | נקודת הקצה עם `secret` החדש (פעם אחת). רק standard |
| `POST /v1/webhooks/:endpointId/test` | T (routine: O) | — | `202` המשלוח של הודעת הבדיקה. עד 5 בשעה (429); webhook מושבת: 422 |
| `GET /v1/webhooks/:endpointId/deliveries?status&before&limit` | T | — | `{ deliveries, next }` מהחדש לישן. `limit` 1..200 (50); `before` = `next` מהעמוד הקודם; `status` = `pending`/`succeeded`/`failed`/`cancelled` |
| `POST /v1/webhook-deliveries/:deliveryId/retry` | T (routine: O) | — | המשלוח, שוב בתור. רק `failed` או `cancelled`, צעיר מ-29 יום, ב-webhook פעיל; עד 50 בשעה לכל webhook (429) |

התשובות של היצירה, `reveal-url` ו-`rotate-secret` נשלחות עם `Cache-Control: no-store`.

נקודת קצה (`WebhookEndpointInfo`):

```json
{
  "id": "7d0e5b8a-3c2f-4e1d-9b6a-0f1e2d3c4b5a", "businessId": "5f0c3c1e-...", "kind": "standard",
  "url": "https://api.my-crm.com/webhooks/…?token=***", "urlHasQuery": true, "urlMasked": true, "routineId": null,
  "description": "CRM", "eventTypes": ["document.issued", "document.original_produced", "document.allocation_recorded"],
  "secretHint": "…Hh8=", "previousSecretExpiresAt": null,
  "enabled": true, "disabledReason": null, "disabledAt": null,
  "consecutiveFailures": 0, "failingSince": null, "lastSuccessAt": "2026-10-15T09:12:45.120Z", "lastFailureAt": null, "holdUntil": null,
  "createdAt": "...", "createdBy": "nissim@example.com", "updatedAt": "...", "stats": { "pending": 0, "failed24h": 0 }
}
```

- **הכתובת מוצגת תמיד מקוצרת**, כי הנתיב והפרמטרים כוללים לפעמים סיסמה של המערכת המקבלת (Slack, Discord, Zapier
  ו-Make שמים אותה בנתיב): רק הקטע הראשון בנתיב, וגם הוא רק כשהוא מילה (עד 24 תווים, לכל היותר 2 ספרות), ואחריו `…`;
  בפרמטרים `name=***` כשהשם מילה, ו-`***` לכל חלק אחר. למשל `https://hooks.slack.com/services/T0/B0/XXXX` מוצג
  `https://hooks.slack.com/services/…`, ו-`https://hook.eu1.make.com/<טוקן>` מוצג `https://hook.eu1.make.com/…`.
  `urlMasked: true` כשחלק מהכתובת מוסתר. הכתובת המלאה רק ב-`reveal-url`. גם בהיסטוריית השינויים הכתובת מקוצרת כך.
  ב-routine של Claude הכתובת קבועה ונגזרת מהמזהה, ולכן מוצגת במלואה.
- `routineId` ב-routine: אפשר להדביק את המזהה (`trig_...`) או את כתובת ה-API שמוצגת ב-claude.ai; המנוע לוקח ממנה את המזהה.
- עד 10 webhooks לעסק (כולל מושבתים), וכתובת אחת פעם אחת בלבד (routine – מזהה אחד פעם אחת).
- `description` עד 200 תווים, בלי תווי בקרה.

#### כללי הכתובת (standard)

`https://` בלבד, בפורט הרגיל (443), לשם מתחם ולא לכתובת IP (בכל צורה), בלי שם משתמש וסיסמה, בלי `#`, עד 2,048 תווים.
אסורים `localhost` ושמות פנימיים או שמורים: `.local`, `.localdomain`, `.internal`, `.intranet`, `.lan`, `.home`,
`.corp`, `.private`, `.arpa` (כולל `home.arpa`), `.test`, `.example`, `.invalid`, `.onion`, ושם בלי נקודה. פרמטרים
(`?a=b`) מותרים. הכתובת נשמרת מנורמלת (אותיות קטנות בשם המתחם, punycode, בלי `:443`). שם מתחם ציבורי שמפנה לכתובת
פנימית (למשל `10.0.0.1.nip.io`) נעצר ברמת הרשת, אצל המשגר: שרת ה-Node לא מתחבר לכתובות פרטיות, שמורות, loopback,
link-local, CGNAT, multicast ו-IPv4 בתוך IPv6 (ועוד טווחים ב-`ENGINE_WEBHOOK_BLOCK_CIDRS`), ו-Cloudflare Workers לא
מתחבר לרשתות פרטיות. האתר גם לא שולח לכתובות בדומיין שלו עצמו (למשל `*.lovable.app`); מקבל שמתארח שם משתמש בכתובת אחרת.

#### גוף ההודעה

JSON בלי רווחים, שנבנה פעם אחת כשהאירוע נכתב ונשלח בדיוק כך בכל ניסיון. רק מזהים, סוג, מספר, תאריך וסכומים
(באגורות): **בלי פרטי לקוח, בלי מי שביצע ובלי שם העסק**. את שאר הפרטים קוראים ב-API, בהרשאות של המקבל
(למשל `GET /v1/documents/:docId` עם `data.document.id`).

```
{
  "type":      "document.issued" | "document.original_produced" | "document.copy_produced" | "document.allocation_recorded" | "webhook.test",
  "timestamp": זמן האירוע (ISO 8601 UTC),
  "data": {
    "eventId":    המזהה ביומן האירועים (בלי ב-webhook.test),
    "businessId": "...",
    "document": { "id", "docType", "docTypeName", "docNumber", "documentDate", "currency", "amountNet", "vatAmount", "amountTotal",
                  "designId"?: (מנוע 0.13: מזהה העיצוב שבו הופק, בלי השם),
                  "baseDocument"?: { "docType", "docNumber" }, "allocationNumber"?: (רק ב-document.allocation_recorded) },
    "original"?: { "kind": "computerized" | "printed" },   (רק ב-document.original_produced)
    "shareLink"?: { "id", "url"?, "path", "expiresAt" },   (מנוע 0.15, רק ב-document.issued שהופק עם shareLink)
    "endpointId"?, "message"?                                (רק ב-webhook.test)
  }
}
```

```json
{"type":"document.issued","timestamp":"2026-10-15T09:12:44.000Z","data":{"eventId":812,"businessId":"5f0c3c1e-8a4b-4c47-9a39-2f7d2b6f1a10","document":{"id":431,"docType":305,"docTypeName":"חשבונית מס","docNumber":60012,"documentDate":"2026-10-15","currency":"ILS","amountNet":300000,"vatAmount":54000,"amountTotal":354000}}}
```
```json
{"type":"document.original_produced","timestamp":"2026-10-15T09:13:02.517Z","data":{"eventId":813,"businessId":"5f0c3c1e-8a4b-4c47-9a39-2f7d2b6f1a10","document":{"id":431,"docType":305,"docTypeName":"חשבונית מס","docNumber":60012,"documentDate":"2026-10-15","currency":"ILS","amountNet":300000,"vatAmount":54000,"amountTotal":354000},"original":{"kind":"computerized"}}}
```
```json
{"type":"document.copy_produced","timestamp":"2026-10-16T07:40:11.204Z","data":{"eventId":820,"businessId":"5f0c3c1e-8a4b-4c47-9a39-2f7d2b6f1a10","document":{"id":433,"docType":400,"docTypeName":"קבלה","docNumber":70031,"documentDate":"2026-10-16","currency":"ILS","amountNet":354000,"vatAmount":0,"amountTotal":354000,"baseDocument":{"docType":305,"docNumber":60012}}}}
```
```json
{"type":"document.allocation_recorded","timestamp":"2026-10-15T09:20:00.000Z","data":{"eventId":815,"businessId":"5f0c3c1e-8a4b-4c47-9a39-2f7d2b6f1a10","document":{"id":431,"docType":305,"docTypeName":"חשבונית מס","docNumber":60012,"documentDate":"2026-10-15","currency":"ILS","amountNet":300000,"vatAmount":54000,"amountTotal":354000,"allocationNumber":"202610150001234"}}}
```
```json
{"type":"webhook.test","timestamp":"2026-10-15T10:00:00.000Z","data":{"businessId":"5f0c3c1e-8a4b-4c47-9a39-2f7d2b6f1a10","endpointId":"7d0e5b8a-3c2f-4e1d-9b6a-0f1e2d3c4b5a","message":"הודעת בדיקה. אין מסמך חדש."}}
```

#### חתימה (standard)

כל בקשה נשלחת ב-`POST` עם הכותרות:

| כותרת | ערך |
|---|---|
| `content-type` | `application/json` |
| `user-agent` | `InvoiceEngine-Webhooks/<גרסה>`, למשל `InvoiceEngine-Webhooks/0.10.0` |
| `webhook-id` | מזהה ההודעה (`msg_` ו-32 תווי hex). **קבוע בכל הניסיונות** של אותה הודעה: לפיו מזהים כפילויות |
| `webhook-timestamp` | שניות מאז 1970 (UTC), של הניסיון הזה |
| `webhook-signature` | `v1,<base64>`; בזמן החלפת סוד שתי חתימות מופרדות ברווח: `v1,<חדש> v1,<קודם>` |

`signature = base64(HMAC-SHA256(key = base64decode(הסוד בלי "whsec_"), message = "{webhook-id}.{webhook-timestamp}.{body}"))`,
כש-`body` הוא הבתים של גוף הבקשה כמו שהתקבלו (לא JSON שפוענח ונבנה מחדש). דחו הודעה כש-`webhook-timestamp` רחוק
מעכשיו ביותר מ-5 דקות. וקטור לבדיקה (הסוד הוא הבתים 0x00..0x1f):

```
secret    = whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
webhook-id = msg_0123456789abcdef0123456789abcdef
webhook-timestamp = 1792055564
body      = הדוגמה של document.issued למעלה, בדיוק (333 בתים ב-UTF-8)
signature = v1,Vj1weAfat3Gnkclb/AS+L5gH7UB52TP/WeXEsvRcSuw=
```

בדיקה ב-Node.js (בלי תלויות):

```js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyWebhook(secret, headers, rawBody, nowSeconds = Date.now() / 1000) {
  const id = headers["webhook-id"], timestamp = headers["webhook-timestamp"], signatures = headers["webhook-signature"] ?? "";
  if (!id || !/^\d+$/.test(timestamp ?? "") || Math.abs(nowSeconds - Number(timestamp)) > 300) throw new Error("bad timestamp");
  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
  const valid = signatures.split(" ").some((entry) => {
    const [version, value] = entry.split(",");
    const given = Buffer.from(value ?? "", "base64");
    return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
  });
  if (!valid) throw new Error("bad signature");
  return JSON.parse(rawBody);
}
```

בדיקה ב-Python (בלי תלויות; `raw_body` הם הבתים של הגוף, `headers` עם שמות באותיות קטנות או אובייקט כותרות שלא
מבחין בין אותיות):

```python
import base64, hashlib, hmac, json, time

def verify_webhook(secret, headers, raw_body, now=None):
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp") or ""
    signatures = headers.get("webhook-signature") or ""
    now = time.time() if now is None else now
    if not msg_id or not timestamp.isdigit() or abs(now - int(timestamp)) > 300:
        raise ValueError("bad timestamp")
    key = base64.b64decode(secret[len("whsec_"):])
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    for entry in signatures.split(" "):
        version, _, value = entry.partition(",")
        if version == "v1" and hmac.compare_digest(value, expected):
            return json.loads(raw_body)
    raise ValueError("bad signature")
```

אפשר גם להשתמש בספריות הרשמיות של Standard Webhooks (`standardwebhooks`, בכל שפה).

**החלפת סוד** (`rotate-secret`): הסוד החדש מוחזר פעם אחת. בלי `previousSecretValidHours` (ברירת מחדל 24) הסוד
הקודם ממשיך לחתום במשך 24 שעות, וכל הודעה נושאת שתי חתימות, כדי שתספיקו לעדכן את המערכת המקבלת. כשהסוד דלף שולחים
`previousSecretValidHours: 0`, והסוד הקודם מפסיק מיד. הסוד נשמר במנוע רק מוצפן (AES-256-GCM, במפתח שנגזר מסיסמת
מפתחות החתימה של השרת).

#### מסירה

- **לפחות פעם אחת** (standard): אותה הודעה יכולה להגיע יותר מפעם אחת (למשל כשהתשובה שלכם אבדה בדרך). זהו כפילויות
  לפי `webhook-id`. אין הבטחה לסדר: השתמשו ב-`data.eventId` (עולה לפי סדר השמירה בעסק), או קראו את המצב ב-API.
- **יומן האירועים הוא המקור המוסמך** (`GET /v1/businesses/:id/events`): ההודעות נכתבות לתור באותה טרנזקציה של
  האירוע, אבל תקלה בתור לעולם לא מכשילה הפקה של מסמך (ואז ההודעה לא נוצרת). להתאמה מלאה, השוו מדי פעם מול היומן.
- **זמן:** בדרך כלל תוך שניות; לכל היותר כחצי דקה אחרי האירוע (בלי pg_cron 1.5 ומעלה: עד דקה), ויותר כשה-webhook
  בהשהיה של המפסק (בהמשך).
- **תשובה:** כל 2xx היא הצלחה. הגוף לא נקרא. הפניות (3xx) לא נעקבות ונחשבות כישלון. זמן קצוב: 10 שניות.

ניסיונות חוזרים (standard, עד 8 ניסיונות, כ-44 שעות). לכל השהיה תזוזה קבועה של ±10% לכל הודעה:

| אחרי ניסיון שנכשל | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| הניסיון הבא בעוד | דקה | 5 דקות | 30 דקות | שעתיים | 6 שעות | 12 שעות | 24 שעות | — נכשל |

- 429 ו-503: לפי `Retry-After` (שניות או תאריך), לא פחות מההשהיה שבטבלה ולא יותר מ-24 שעות.
- 410: הכתובת לא קיימת עוד – ה-webhook מושבת (`gone`).
- **מפסק:** אחרי 3 כישלונות ברצף לא שולחים ל-webhook במשך 1, 5, 15, 30 דקות, ואחר כך שעה אחרי כל כישלון נוסף,
  ונשלחת רק הודעה אחת בכל פעם (בדיקה). הצלחה משחררת את כל מה שממתין (עד 5 בכל סבב).
- **השבתה אוטומטית:** אחרי 10 כישלונות ברצף שנמשכים 72 שעות לפחות (`failing`); 410 (`gone`); כתובת שמפנה לרשת
  פנימית (`blocked_address`) או שלא עוברת את בדיקת הכתובות של המשגר (`invalid_url`); סוד שאי אפשר לפענח
  (`secret_unreadable`); 1,000 הודעות שממתינות (`backlog`); האחראי כבר לא בעל העסק (`creator_removed`). כשהתוצאה
  מגיעה ממשגר חיצוני (Cloudflare Worker), 410 / כתובת חסומה / כתובת לא תקינה הן "פסילה": ההודעה נשלחת שוב,
  וה-webhook מושבת רק אחרי 3 פסילות במשך 10 דקות לפחות. הצלחה מאפסת אותן.
- **השהיה מול השבתה:** `PATCH { enabled: false }` היא השהיה (`manual`): הודעות שכבר המתינו בזמן ההשהיה נשמרות ונשלחות
  אחרי הפעלה מחדש (S). אירועים שקורים בזמן ההשהיה לא נכנסים לתור ולא יישלחו גם אחרי ההפעלה; מי שצריך אותם קורא את
  יומן האירועים (`GET /v1/businesses/:id/events`). בהשבתה אוטומטית ההודעות שממתינות מבוטלות (`cancelled`), והפעלה מחדש לא שולחת אותן; אפשר לשלוח שוב
  משלוח שבוטל או נכשל, אחד אחד (`retry`), עם אותו `webhook-id` ואותו גוף.
- **שמירה:** משלוחים שהסתיימו נמחקים 30 יום אחרי שנוצרו, ממתינים (למשל ב-webhook מושהה) אחרי 31 יום; היסטוריית
  השינויים אחרי שנתיים.

משלוח (`WebhookDeliveryInfo`): `{ id, endpointId, messageId, eventType, eventId, documentId, status, attempts, nextAttemptAt,
lastAttemptAt, lastStatusCode, lastError, lastDurationMs, resultUrl, createdAt, completedAt, payload }`. `nextAttemptAt: null`
בממתין = מיד. `lastError` הוא הסבר קצר בעברית (בלי גוף התשובה, בלי כתובות ובלי טוקנים).

#### routine של Claude Code

- יוצרים routine ב-claude.ai/code/routines, מחברים אליו את המחבר של מערכת הפקת המסמכים, ובחלק Select a trigger
  בוחרים API ולוחצים Generate token. את הכתובת (או המזהה `trig_...`) ואת הטוקן (`sk-ant-oat01-...`) מדביקים ביצירה
  (S). הטוקן מוחלף ב-`PATCH { routineToken }` (S).
- **מומלץ לשלוח רק `document.issued`** (ברירת המחדל), ו-routine ייעודי שמחובר רק למחבר של מערכת הפקת המסמכים. פרטי
  הלקוחות שה-routine קורא מהמסמכים נשמרים בהיסטוריית השיחות ב-claude.ai.
- המנוע שולח בעצמו: `POST` לכתובת הקבועה עם `authorization: Bearer <הטוקן>`, `anthropic-version: 2023-06-01` וגוף
  `{"text": "..."}`. הטקסט: שורה בעברית לכל אירוע, ואחריה מערך ה-JSON של ההודעות (בדיוק הגוף של ה-webhooks).
- **קבוצות ומרווח:** הפעלה אחת לכל האירועים שממתינים (עד 20), ולכל היותר הפעלה אחת בכל 3 דקות לכל routine (עד 20
  בשעה, מתוך 30 שמותרות ל-routine).
- **בלי ניסיון חוזר אחרי תוצאה לא ודאית:** ל-API של Claude אין מזהה שמונע כפילות, וכל הפעלה פותחת שיחה. המנוע מנסה
  שוב לבד רק כשבטוח שלא נפתחה שיחה: 429 (לפי `Retry-After`, בין דקה לשעה, עד 24 שעות מהאירוע, בלי להיחשב ככישלון),
  500/503/529 וחיבור שלא הוקם (עד 4 ניסיונות). זמן קצוב (30 שניות), תקלת רשת או 5xx אחר: נכשל, עם האזהרה "ייתכן שנפתחה
  שיחה ב-Claude". 400 (למשל routine מושהה) משבית (`routine_paused_or_invalid`); 401/403/404 משביתים (`routine_rejected`).
- שליחה חוזרת ידנית ובדיקה של routine פותחות שיחה חדשה בחשבון של בעל העסק, ולכן רק בכניסה עם סיסמה (O), עם אזהרה באתר.
- הצלחה: `resultUrl` של המשלוח הוא הקישור לשיחה (`https://claude.ai/code/session_...`).

Channels והתראות שרת של claude.ai לא נתמכים: הם עובדים רק בתוך Claude Code מקומי, ולא משרת מרוחק.

#### היסטוריית שינויים

כל יצירה, עדכון, החלפת סוד, הצגת כתובת, הפעלה, השהיה, השבתה אוטומטית ומחיקה נרשמים (`audit` ב-`GET .../webhooks`,
50 האחרונים): `{ id, endpointId, action, actor, kind, url, eventTypes, detail, at }`, עם הכתובת המקוצרת ומי שביצע
(`system` בהשבתה אוטומטית). הרשומות נשארות גם אחרי מחיקת ה-webhook, ונמחקות אחרי שנתיים.

#### מינויי אוטומציה: Zapier ו-Make (מנוע 0.19)

פלטפורמות אוטומציה (Zapier, Make) נרשמות לאירועים ומבטלות את הרישום בעצמן (REST hooks), כשהמשתמש מפעיל או מכבה
זאפ או תרחיש. לכן כאן **לא נדרשת סיסמת בעל העסק**: מינוי נוצר ב**מפתח API של העסק** (`iek_`) או ב**טוקן גישה אישי**
(`iep_`) של מי שמפיק בעסק (בעל העסק או עובד). בכניסה עם סיסמה (`ies_`), בטוקן שירות ובטוקן המנהל – 403; רואה חשבון – 403.

מה שומר על זה בטוח:
- **רשימת היתר אחת** (`INTEGRATION_HOOK_HOSTS` ב-`engine/src/domain/webhooks.ts`): Zapier – `hooks.zapier.com`; Make –
  `hook.<אזור>.make.com` (eu1, eu2, us1, us2...), `hook.integromat.com`, כל `*.make.com`, ו-`hook.<אזור>.make.celonis.com`.
  כתובת אחרת: 422. קודם כל חלים [כללי הכתובת](#כללי-הכתובת-standard) הרגילים (https בלבד, פורט 443, לא IP, לא שם
  פנימי), והמשגר בודק שוב לפני כל שליחה, כולל ההגנה ברמת הרשת.
- **המינוי קשור למפתח או לטוקן שיצר אותו:** ביטול המפתח (`DELETE /v1/api-keys/:keyId`) או הטוקן, וגם שינוי סיסמה (שמבטל את
  כל הטוקנים), **מוחקים** את המינויים שלו (בהיסטוריה: `zapier: credential_revoked`). טוקן שפג מפסיק לקבל הודעות מיד. כך מינוי
  לא נותן גישה שנשארת אחרי האישור – ומפתח API ממילא קורא את כל המסמכים של העסק.
- **אותו גוף, אותה חתימה, אותו משגר:** מינוי הוא webhook רגיל מסוג `standard`, ולכן ההודעה זהה ל[גוף ההודעה](#גוף-ההודעה)
  (כולל `data.shareLink` כשהמסמך הופק עם קישור שיתוף), חתומה כרגיל (הסוד לא מוחזר; הפלטפורמות לא חייבות לבדוק), עם אותם
  ניסיונות חוזרים, מפסק והשבתה (410 מהפלטפורמה משבית).
- **גלוי לבעל העסק:** המינויים מופיעים ב-`GET /v1/businesses/:id/webhooks` עם `integration: "zapier" | "make"` (ובאתר:
  בכרטיס ה-webhooks עם שם הפלטפורמה), נרשמים בהיסטוריית השינויים (`create` / `delete`, עם שם המפתח), ובעל העסק מבטל
  כל אחד (`DELETE /v1/webhooks/:endpointId`). את הכתובת של מינוי אי אפשר לשנות (422): מוחקים, והפלטפורמה נרשמת מחדש.
- **מגבלה:** עד 25 מינויים לעסק, **בנפרד** מ-10 ה-webhooks של בעל העסק.
- **מגבלות קצב (מנוע 0.20):** הקריאות של הפלטפורמות נספרות כמו כל קריאה במפתח או בטוקן ([מגבלות קצב](#מגבלות-קצב-מנוע-020)).
  על 429 מחכים לפי `Retry-After`; האפליקציה של Zapier עושה זאת לבד.

| קריאה | מי | גוף | תוצאה |
|---|---|---|---|
| `GET /v1/businesses/:id/integration-hooks` | צפייה | — | `{ deliveryEnabled, limits: { hooksPerBusiness }, allowedHosts: { zapier, make }, eventTypes, hooks }` |
| `POST /v1/businesses/:id/integration-hooks` | מפתח API / טוקן גישה של מי שמפיק | `{ platform: "zapier" \| "make", url, eventTypes? \| event?, docTypes?, description? }` | `201` המינוי. אותה כתובת שכבר רשומה באותו אישור: `200` עם הקיים (רישום חוזר). `eventTypes` (או `event` אחד) מתוך סוגי האירועים של ה-webhooks, ברירת מחדל `["document.issued"]`; `docTypes` – רק סוגי המסמכים האלה (למשל `[305, 320]`), בלעדיו כולם. 422 כשאין משגר בשרת |
| `DELETE /v1/integration-hooks/:hookId` | מי שמפיק בעסק | — | `{ ok: true }`. מזהה של webhook רגיל: 404 |

מינוי (`IntegrationHookInfo`): `{ id, businessId, platform, url, eventTypes, docTypes, enabled, disabledReason, createdAt, createdBy }`,
עם הכתובת המקוצרת (`https://hooks.zapier.com/hooks/…`).

```bash
curl -s -X POST https://<site>/api/v1/businesses/$BIZ/integration-hooks -H "Authorization: Bearer $IEK" \
  -H "Content-Type: application/json" \
  -d '{ "platform": "zapier", "url": "https://hooks.zapier.com/hooks/standard/123/abc/", "event": "document.issued", "docTypes": [305, 320] }'
```

**סקירה בתדירות (polling):** כשאין מינוי (או כגיבוי), הטריגרים קוראים רשימות מהחדש לישן עם מזהה קבוע, והפלטפורמה
מסננת כפילויות לפי `id`: מסמך חדש – `GET /v1/businesses/:id/documents?limit=50` (או `sinceId`); מקור שהופק –
`GET /v1/businesses/:id/events?event=original_produced&order=desc`; לקוח חדש – `GET /v1/businesses/:id/customers?sort=newest`
(ליצירה ועדכון של כרטיסים אין אירועים ואין webhooks). האפליקציות המוכנות נמצאות ב-`integrations/zapier` וב-`integrations/make`,
והמדריך באתר ב-`/docs/integrations`.

#### המשגר (`/v1/internal/webhooks/*`)

מי ששולח בפועל את משלוחי ה-standard הוא **משגר**: בשרת ה-Node הרשום, לולאה בתוך התהליך
(`ENGINE_WEBHOOK_DISPATCHER=inprocess`, עם ההגנה ברמת הרשת); בשירות הנוכחי על Lovable Cloud, ה-Worker של האתר
(`POST /api/webhooks/dispatch`), ש-pg_net ו-pg_cron מעירים. משגר חיצוני מדבר עם המנוע בשלוש קריאות, בטוקן המשגר בלבד:

| קריאה | גוף | תוצאה |
|---|---|---|
| `POST /v1/internal/webhooks/claim` | `{ limit?: 1..20 }` (ברירת מחדל 10) | `{ requests: [{ leaseToken, deliveryId, endpointId, url, headers, body, timeoutMs }], leaseSeconds: 60 }` – בקשות חתומות ומוכנות לשליחה. עד 5 לכל webhook (אחת כשהוא נכשל ברצף), ובלי webhook שמשלוח שלו כבר בדרך. עד 120 קריאות בדקה (429) |
| `POST /v1/internal/webhooks/report` | `{ results: [{ leaseToken, deliveryId, outcome, status?, durationMs, retryAfter?, error? }] }` (1..50) | `{ accepted, ignored }` |
| `POST /v1/internal/webhooks/routines` | `{}` | `{ fired, succeeded, failed, retried, ms }` – המנוע מפעיל בעצמו את ה-routines שהגיע זמנם |

- `outcome`: `response` (עם `status` 100..599), `timeout`, `network`, `connect` (החיבור לא הוקם), `blocked` (כתובת
  פנימית), `invalid_url`. המשגר מדווח רק עובדות; המנוע מחליט על ניסיון חוזר והשבתה.
- **חכירה:** כל בקשה שנלקחה שמורה למשגר 60 שניות. דיווח מתקבל רק עם `leaseToken` הנוכחי של המשלוח, ולכן מותר לשלוח
  אותו שוב אחרי תקלת רשת (בפעם השנייה הוא `ignored`). בלי דיווח החכירה פגה והמשלוח יילקח שוב.
- לפני כל שליחה המשגר בודק שוב את הכתובת (אותם כללים, והכתובת חייבת להיות כבר מנורמלת), שולח רק את הכותרות שבבקשה,
  לא עוקב אחרי הפניות ולא קורא את גוף התשובה.
- בקשה לא כוללת אף פעם `authorization` או טוקן של routine.

**אישור קצר-מועד `whd1`:** במקום הטוקן עצמו, המשגר שולח `Authorization: Bearer whd1.<unix>.<hex>`, כש-`unix` הוא
הזמן בשניות (10 ספרות) ו-`hex` הוא HMAC-SHA256 בהקסדצימלי (אותיות קטנות), במפתח = הבתים של טוקן המשגר (UTF-8),
על `invoice-engine/dispatch/v1|<unix>`. תקף כשהזמן רחוק מעכשיו לכל היותר 120 שניות. וקטור:

```
token  = 0f1e2d3c4b5a69788796a5b4c3d2e1f00112233445566778899aabbccddeeff
unix   = 1792055564
result = whd1.1792055564.a7be6b62a5030574d45830e37a3fe699fb94bb8414275180233e5e369a059f12
```

ב-SQL (pgcrypto): `'whd1.' || unix || '.' || encode(hmac('invoice-engine/dispatch/v1|' || unix, token, 'sha256'), 'hex')`.
ה-Edge Function מקבל רק `whd1`. שרת Node מקבל גם את הטוקן עצמו רק עם `ENGINE_WEBHOOK_DISPATCH_RAW=1`. בלי טוקן משגר
(ובלי משגר בתוך התהליך) הנתיבים האלה מחזירים 404, ויצירת webhook מחזירה 422.

### הסכמה לקבלת מסמכים ממוחשבים (הוראה 18ב(ג))

הסכמה של לקוח לקבל מסמכים ממוחשבים צריכה להיות **אישית, מפורשת וניתנת להוכחה**, והלקוח רשאי לבטל אותה בכל עת.
מקור ממוחשב מופק רק ללקוח שההחלטה האחרונה שלו היא הסכמה. כל החלטה (הסכמה או ביטול) היא רשומה חדשה שלא משתנה.

- `POST /v1/businesses/:id/consents` עם `{ customerKey, granted: true, channel: "email", evidence: { description: "אישור במייל מ-dana@example.com מיום 1.10.2026", ... } }`.
  מנוע 0.15: בהסכמה **חובה** `evidence.description` – מה מוכיח שהלקוח הסכים (3–500 תווים, למשל "טופס חתום מיום ...").
  בלעדיו: `422`. ביטול (`granted: false`) לא דורש ראיה (אפשר הערה ב-`description`).
- `GET /v1/businesses/:id/consents/:customerKey` → `{ granted }`

#### קישור אישור ללקוח

מנוע 0.15: העסק שולח ללקוח קישור (`<האתר>/c/<token>`), והלקוח רואה את שם העסק ואת **נוסח ההסכמה המדויק**, ומאשר
("אני מסכים/ה לקבל מסמכים ממוחשבים") או מבטל בעצמו, בלי להתחבר. ההחלטה נרשמת ב-consents עם `channel: "customer_link"`
והראיה: `{ method: "customer_link", linkId, at, decision, wordingVersion, wordingSha256, client, userAgent }` – הזמן,
גרסת הנוסח וה-SHA-256 של הנוסח שהוצג, טביעה מקוצרת של רשת הלקוח (לא כתובת ה-IP) והדפדפן. הטוקן מוחזר פעם אחת
ונשמר רק ה-SHA-256 שלו; אפשר לבטל קישור, וההחלטות שנרשמו נשארות.

| קריאה | מי | תוצאה |
|---|---|---|
| `POST /v1/businesses/:id/consent-links` `{ customerKey, customerName?, expiresInDays? }` | מי שמפיק | `201 { id, customerKey, customerName, status, token, path, url, ... }` |
| `GET /v1/businesses/:id/consent-links?customerKey=` | צפייה | `{ links: [...] }` |
| `DELETE /v1/consent-links/:linkId` | מי שמפיק | ביטול הקישור (לא ההסכמה) |
| `GET /v1/consent/:token` | ציבורי | `{ businessName, customerName, wording, wordingVersion, granted, decidedAt, expiresAt }` |
| `POST /v1/consent/:token` `{ decision: "grant" \| "revoke" }` | ציבורי | ההחלטה נרשמת; מחזיר את המצב החדש. `404` / `410` לקישור לא מוכר / שבוטל או פג, `429` מגבלת קצב |

#### אזהרה: קבלה במזומן על מסמך ממוחשב

לפי הוראה 18ב(ד), מי ששולח מסמך ממוחשב בחתימה מאובטחת מקבל את התקבול רק בכרטיס אשראי, בשיק משורטט "לא סחיר" או
בהעברה בנקאית. מנוע 0.15: קבלה (400) עם `base` של מסמך שכבר נמסר כמקור ממוחשב, בתקבול אחר (למשל מזומן), **לא
נחסמת**, אבל בתשובה של `dryRun`, של ההפקה ושל התצוגה המקדימה (כותרת `X-Issue-Warnings`, JSON מקודד ל-URL) מופיע
`warnings: ["..."]` בעברית. את המקור של קבלה כזו מדפיסים וחותמים ביד.

### מבנה אחיד

`POST /v1/businesses/:id/openformat` עם `{ from, to }` מחזיר ZIP ובתוכו `OPENFRMT/<8 ספרות>.<שנה>/<MMDDhhmm>/INI.TXT` ו-`BKMVDATA.zip`.
עם `?format=json` מתקבל רק הסיכום: מספר רשומות לכל סוג ודוח המסמכים של נספח 1.
מנוע 0.17: עם `"transmit": true` (רק מי שמפיק) הקובץ גם משודר לרשות המסים מיד עם סיום ההפקה – אותם בתים שיורדים –
ותוצאת השידור חוזרת בכותרת `X-Ita-Transmission` (JSON מקודד ל-URL: `status` = `uploaded` / `failed` / `not_sent`,
`summary`, `sandboxPlaceholder`, `error`). ההורדה עובדת גם כשהשידור נכשל או שהעסק לא מחובר. ראו
[רשות המסים](#רשות-המסים-מנוע-017).

### רשות המסים (מנוע 0.17)

חיבור לשירותי ה-Open API של רשות המסים (שע"ם): מספרי הקצאה (חשבוניות ישראל, API 2.0), דיווח קבלות על תרומה
("תרומות ישראל", מודל תרומות 1.0), בדיקת סעיף 46, ושידור קובץ המבנה האחיד (תוספת מס' 1, 3/2026). מתחילים
ב-sandbox. כל הפניות לרשות המסים ול-Google עוברות בשרת; הטוקנים לא יוצאים ממנו.

#### האפליקציה של בית התוכנה (פעם אחת)

| קריאה | מי | מה |
|---|---|---|
| `GET /v1/platform/ita` | מנהל פלטפורמה (כניסה עם סיסמה) או טוקן המנהל | `{ app: { configured, environment, clientIdLast4, secretStore, redirectUri, scope, configuredAt, configuredBy }, recommendedRedirectUri, changes }`. הסוד לא מוחזר לעולם |
| `PUT /v1/platform/ita` `{ environment?, clientId?, clientSecret?, redirectUri?, scope?, currentPassword, code? }` | מנהל פלטפורמה, עם אימות מחדש | כותב בלבד. בפעם הראשונה חובה `clientId` ו-`clientSecret`; אחר כך שדה שלא נשלח נשאר |
| `PUT /v1/admin/ita-app` (אותו גוף, בלי סיסמה) | טוקן המנהל בלבד | אותה שמירה, למשל מסקריפט התקנה |

`environment`: `sandbox` (ברירת המחדל) או `production`. `redirectUri`: בדיוק הכתובת שנרשמה באפליקציה בפורטל, ברירת
המחדל `<האתר>/ita/callback` (`https://retsef.co.il/ita/callback`). `scope`: מה שמבקשים בהתחברות
(ברירת המחדל `scope`, כמו בדוגמאות של המדריך). **הסוד:** ב-Edge Function נכתב ל-Supabase Vault
(`invoice_engine_ita_client_secret`, `vault.create_secret` / `vault.update_secret`); כשאין Vault או שהכתיבה נכשלת –
מוצפן בבסיס הנתונים (AES-256-GCM במפתח שנגזר מסיסמת מפתחות החתימה שב-Vault), ו-`secretStore` אומר איפה. כל שינוי
נרשם ביומן `ita_app_changes` בלי הסוד. מעבר סביבה מחייב כל עסק להתחבר מחדש.

#### התחברות של עסק (רק מהאתר)

1. `POST /v1/businesses/:id/ita/connect` – בעל העסק, בכניסה עם סיסמה (לא טוקן גישה אישי, לא MCP). מחזיר
   `{ authorizeUrl, expiresAt }`: כתובת ההזדהות ברשות המסים עם `state` אקראי (נשמר רק ה-hash שלו), קשור למשתמש
   ולעסק, תקף עשר דקות ולשימוש אחד.
2. רשות המסים מחזירה את הדפדפן ל-`/ita/callback?code=...&state=...`. הדף מוחק את הקוד מהכתובת ושולח
   `POST /v1/ita/callback { code, state }`. המנוע בודק שה-state של אותו משתמש, שיש לו עדיין הרשאת ניהול בעסק,
   שלא פג ולא נוצל, ומחליף את הקוד בטוקנים (Basic `client_id:client_secret`).
3. הטוקנים נשמרים מוצפנים. טוקן הגישה תקף כעשר דקות; ה-refresh token **חד-פעמי**, ומוחלף תחת נעילת השורה
   (`SELECT … FOR UPDATE`), כך שבקשות במקביל לא שולחות אותו פעמיים. 401 משירות: חידוש אחד וניסיון חוזר.
   refresh token שנדחה (או שעברו 90 יום) מסמן את החיבור "צריך להתחבר מחדש".

| קריאה | מי | מה |
|---|---|---|
| `GET /v1/businesses/:id/ita` | צפייה | `{ connection, section46? }`: `connected`, `status`, `connectedAt`, `connectedBy`, `accessExpiresAt`, `refreshExpiresAt`, `reauthBy` (המוקדם מבין תוקף ה-refresh token ו-90 יום מההתחברות), `daysUntilReauth`, `reauthWarning` (פחות מ-14 יום), `identity` |
| `DELETE /v1/businesses/:id/ita` | ניהול | ניתוק: הטוקנים נמחקים (במפרט אין שירות ביטול טוקן) |
| `GET /v1/businesses/:id/ita/log` | ניהול | יומן החיבור: `connect`, `refresh`, `refresh_failed`, `disconnect`, `connect_failed` (רק הוספה, בלי טוקנים) |

רשות המסים לא מחזירה את זהות המשתמש שהזדהה (הטוקן אטום), ולכן `identity` מציג את המשתמש באתר שחיבר ואת מספר העוסק.

#### מספר הקצאה (Approval v2)

חשבונית מס / חשבונית מס/קבלה שחייבת מספר הקצאה ([הכלל של המנוע](#מסמכים), ללא שינוי בספים) מופקת **קודם**, כרגיל,
ומיד אחר כך – מחוץ לטרנזקציה של ההפקה – נשלחת הבקשה (המפרט, 2.1: הפנייה כשהחשבונית "נושאת כבר את מספר האסמכתא").
התשובה של ההפקה כוללת `ita.allocation`:

| `outcome` | מה קרה | מה עכשיו |
|---|---|---|
| `approved` | התקבל מספר (`allocationNumber`), נרשם (`method: "api"`) ומודפס במקור | — |
| `delayed` | עיכוב: קוד 460 / 461 (`decisionNeeded: true`) או 462 (ההחלטה כבר נשלחה) | החלטה ב-`/ita/decision` |
| `rejected` | נתונים שנדחו (למשל 434 תאריך ישן, 435 תאריך עתידי, 431, 446), או 400 / 422 | המסמך כבר הופק: מבטלים בזיכוי ומפיקים מתוקן |
| `unavailable` | 5xx, 404, תקלת רשת או זמן | `POST /v1/documents/:docId/ita/allocation` מאוחר יותר |
| `auth` / `not_connected` | החיבור פג / העסק לא מחובר | להתחבר, ואז לבקש שוב; או לרשום מספר ידני (`POST /v1/documents/:docId/allocation`) |
| `forbidden` | 403 (האפליקציה לא רשומה לשירות) / 406 (אין הרשאה למספר העוסק) | לבדוק בפורטל ובאזור האישי |

עד שיש מספר המסמך מסומן "ממתין למספר הקצאה" ואת **המקור** אי אפשר להפיק (כמו קודם). כך כישלון לא מבטל מספר ולא
משאיר מסמך חצי מופק: המסמך שלם בקובץ הקבוע, ורק מספר ההקצאה (טבלה נפרדת שאפשר רק להוסיף לה) מחכה.

| קריאה | מי | מה |
|---|---|---|
| `GET /v1/documents/:docId/ita` | צפייה | `{ allocation: { required, state, allocationNumber, shortNumber, lastAttempt, decision, canRequest, decisionNeeded }, donation, requests }`. `state`: `not_required`, `approved`, `awaiting`, `delayed`, `decision_sent`, `continued_without`, `rejected` |
| `POST /v1/documents/:docId/ita/allocation` | מי שמפיק | בקשה (חוזרת) עם אותו `invoice_id` |
| `POST /v1/documents/:docId/ita/decision` `{ decision }` | מי שמפיק | חשבונית שעוכבה: `cancel` (ואז זיכוי רגיל), `continue` (המקור מותר, ומודפס "אין לנכות מס תשומות בגין חשבונית זו"), `further_objection` (שימוע בפורטל; אחריו בקשה חוזרת). היפוך חיוב (חלופה שלישית) עוד לא אוטומטי. `409` כשאין עיכוב או שכבר נשלחה החלטה; `463` חוזר ב-`result` |
| `GET /v1/businesses/:id/ita/pending` | צפייה | חשבוניות שממתינות למספר וקבלות שממתינות לדיווח |
| `POST /v1/businesses/:id/ita/invoice-information` `{ confirmationNumber, supplierVatNumber? }` | צפייה | חשבונית **שהעסק קיבל**: הפרטים לפי מספר ההקצאה (העסק הוא `Customer_VAT_Number`) |
| `POST /v1/businesses/:id/ita/confirmation-number` `{ supplierVatNumber, paymentAmount, vatAmount, invoiceDate, invoiceReferenceNumber? }` | צפייה | מספר ההקצאה של חשבונית שהעסק קיבל, לפי פרטיה (סכומים בשקלים) |

`invoice_id` (A50) הוא `R<סוג>-<מספר>-<12 תווים מה-hash>`: קבוע לכל מסמך, כך שניסיון חוזר (גם אחרי שימוע) נשלח עם
אותו מזהה. `user_name` הוא מבצע הפעולה (עד 25 תווים). `accounting_software_number` – מספר הרישום, ועד שיש כזה מספר
העוסק של מפיק המסמך, כמו במפרט.

#### קבלות על תרומה ("תרומות ישראל")

עמותה (`kind: "nonprofit"`) עם אישור סעיף 46 רשום ועם חיבור: כל קבלה על תרומה (405) מדווחת (`Donations/v1/Report`) מיד
אחרי ההפקה, באותו עיקרון – הקבלה קודם, הדיווח בטבלה נפרדת (`document_donation_reports`). התשובה כוללת `ita.donation`.
מספר האישור והמשפט "פרטי התרומה דווחו לרשות המסים" מודפסים על הקבלה (מודל תרומות 2.2) – רק כשהדיווח נרשם לפני הפקת
המקור, כדי שכל העתק יהיה זהה למקור. לכן כשהעמותה מחוברת והקבלה עוד לא דווחה, המקור לא מופק (422) אלא אם שולחים
`withoutDonationReport: true` ב-`POST /v1/documents/:docId/original`.

| קריאה | מי | מה |
|---|---|---|
| `POST /v1/documents/:docId/ita/donation` | מי שמפיק | דיווח (חוזר) של קבלה אחת |
| `POST /v1/businesses/:id/ita/donations/report-pending` | מי שמפיק | דיווח מרוכז (`MultiReport`, עד 100) של כל הקבלות שממתינות; תוצאה לכל קבלה |
| `POST /v1/businesses/:id/ita/section46-check` | צפייה | `Charity/v1/check46Clause`: `{ recordedApproval, recordedValidUntil, lastCheck: { ok, startDate, endDate, asOfDate }, mismatch, daysLeft, warning }`. אזהרה 60 יום לפני הסוף, וכשהתוקף שונה מהרשום |

זהות התורם (2.3.1): מספר הזהות / ח"פ של הלקוח; תושב חוץ `888888888`; בלי מספר – תרומה בעילום שם `999999999`. אמצעי
תשלום: מזומן 01, המחאה 02 (עם פרטי ההמחאה), אשראי 03 (עם סוג העסקה), העברה 04, הוראת קבע 08, "אחר" (ביט ודומיו)
10, תווים / שטר 11. קבלה עם כמה סוגי אמצעי תשלום לא מדווחת (2.3.4: קבלה נפרדת לכל סוג).

#### שידור קובץ המבנה האחיד (תוספת מס' 1)

`GetUrlsForUploadingFiles { caseNumber: <מספר העוסק>, startPeriod, endPeriod }` → לכל קובץ (INI, BKM): `POST` ל-`signUrl`
עם הכותרות שהתקבלו (201 + `Location`), ואז `PUT` לפי טבלת הגדלים: עד 32MB בבקשה אחת; מעל זה חלקים של 32MB / 64MB /
128MB עם `Content-Range` (308 עד החלק האחרון). לפני ההעלאה נבדק הגודל מול `x-goog-content-length-range`; קובץ גדול
מדי לא נשלח, וההודעה מפנה ל-`APISUPPORT@TAXES.GOV.IL`. נשלח `BKMVDATA.TXT` עצמו (לא ה-ZIP), כמו בשמות הקבצים
בדוגמאות של המפרט. **sandbox:** מקבל רק PDF עד 1MB, ולכן שם נשלח PDF קטן במקום כל קובץ (`sandboxPlaceholder: true`,
מסומן בממשק וביומן); הגודל וה-SHA-256 שנשמרים הם של הקובץ האמיתי.

| קריאה | מי | מה |
|---|---|---|
| `POST /v1/businesses/:id/ita/transmissions` `{ from, to }` | מי שמפיק | ייצוא חדש של התקופה ושידורו. `201`, או `200` כשנכשל (נרשם ביומן) |
| `GET /v1/businesses/:id/ita/transmissions?limit=` | צפייה | יומן השידורים: `uniqueId`, ולכל קובץ `fileUniqueId`, `uploadStatus`, `itaStatus` (`Uploaded` / `Approved` / `Rejected`), `description`, `statusText` |
| `GET /v1/ita/transmissions/:transmissionId` | צפייה | שידור אחד |
| `POST /v1/ita/transmissions/:transmissionId/refresh` | צפייה | בדיקה עכשיו ב-`get-file-status` |

עבודת רקע: `POST /v1/internal/ita/poll` (טוקן המשגר / `whd1` בלבד) בודק שידורים מ-30 הימים האחרונים שעוד לא קיבלו
תשובה סופית, לכל היותר פעם בעשר דקות לכל שידור. ב-Lovable Cloud מפעילים אותו ב-`supabase/sql/ita_poll.sql` (pg_cron,
כל רבע שעה).

#### שגיאות

שגיאה של רשות המסים בנתיבים האלה: `409` עם `itaError: "not_connected"` / `"auth"` (צריך להתחבר), `422` עם
`"rejected"` / `"forbidden"`, `503` עם `"unavailable"`. ההודעה בעברית. בקשה ולוג לעולם לא כוללים טוקן, קוד או סוד.

### חיבור מה-CRM

את הקריאות שולחים מצד השרת בלבד (Supabase Edge Function), כשמפתח ה-API של העסק שמור בסודות. מפתח API
אסור לשים בקוד הדפדפן. בדפדפן משתמשים רק בטוקן כניסה של משתמש.

---

## MCP server (Claude)

The same engine, as an MCP server (Streamable HTTP) for Claude and other MCP clients. Every tool runs as the
signed-in user, with that user's role in each business. Setup guide in Hebrew: https://retsef.co.il/connect

- **Endpoint:** `https://retsef.co.il/api/mcp`
- **OAuth (recommended).** claude.ai / Claude Desktop (and then the mobile apps): Customize → Connectors → + → Add
  custom connector, paste the endpoint, leave the OAuth client fields empty, then Connect; the sign-in happens on
  this site (email, password and the 2FA code if enabled). Discovery:
  `https://retsef.co.il/.well-known/oauth-protected-resource/api/mcp` and `https://retsef.co.il/.well-known/oauth-authorization-server`.
  Scopes: `invoices:read` (view) and `invoices:write` (issue and change, always with confirmation).
- **Claude Code:** `claude mcp add --transport http invoices https://retsef.co.il/api/mcp` (add `--scope user` for all
  projects), then `/mcp` → invoices → Authenticate.
- **Personal MCP token (no browser).** Create one on https://retsef.co.il/account ("חיבורים ל-Claude"), then
  `claude mcp add --transport http invoices https://retsef.co.il/api/mcp --header "Authorization: Bearer imcp_pat_..."`.
  The MCP endpoint accepts only its own tokens (`imcp_at_...` from OAuth, `imcp_pat_...`); engine tokens
  (`ies_`, `iep_`, `iek_`) are rejected there – they are for the REST API.
- **Confirmation.** Irreversible tools ask for confirmation themselves: a confirmation dialog (elicitation), or a
  `confirmationToken` returned with the preview that must be sent back with the same arguments after the user
  agrees. Never confirm on the user's behalf. Files (PDF/ZIP) come back as a temporary download link (30 minutes).

### Tools (62)

- `whoami` (מי מחובר) *[read-only]* – The connected user, their businesses with roles, and this connection's permissions. Start here.
- `list_personal_tokens` (טוקני הגישה שלי) *[read-only]* – The user's personal access tokens (metadata only, never the secrets).
- `revoke_personal_token` (ביטול טוקן גישה) *[irreversible – asks the user to confirm]* – Revoke one of the user's personal access tokens. Irreversible; requires confirmation.
- `list_businesses` (העסקים שלי) *[read-only]* – The businesses the user belongs to, with details and role.
- `get_business` (סקירת עסק) *[read-only]* – Business overview: details, allowed document types, next numbers and documents issued per series, series start change log, signing certificate, month/year totals, pending items, real-documents mode and the last full backup export.
- `register_business` (רישום עסק חדש) *[irreversible – asks the user to confirm]* – Register a new business (sequences, signing key, notice date). Irreversible; requires confirmation.
- `update_business_details` (עדכון פרטי העסק) – Update business details (not the VAT number). Owner only.
- `open_document_sequence` (פתיחת סדרת מסמכים) *[irreversible – asks the user to confirm]* – Open the numbering series of a document type, once. Irreversible; requires confirmation.
- `set_sequence_start` (שינוי מספר ההתחלה של סדרה) *[irreversible – asks the user to confirm]* – Set the next number of a document series before its first document (e.g. continuing another system: its last number + 1). Only upwards, logged. Irreversible; requires confirmation. Owner only.
- `get_signing_status` (מצב מפתח החתימה) *[read-only]* – Signing key status: by default the key is held on the server and signing is automatic for every connection; only with the optional personal signing code does a connection need a signing delegation.
- `add_signing_key` (מפתח חתימה חדש) *[irreversible – asks the user to confirm]* – Create a new signing key and certificate (held on the server, automatic signing). Irreversible; requires confirmation. Businesses that chose a personal signing code rotate keys on the website only.
- `set_computerized_notice_date` (רישום ההודעה לפקיד השומה) *[irreversible – asks the user to confirm]* – Record the date the computerized-documents notice was sent. Requires confirmation.
- `list_business_events` (יומן האירועים של העסק) *[read-only]* – Business event feed in order; pass after=next from the previous call to get what is new.
- `list_documents` (רשימת מסמכים) *[read-only]* – Documents issued by a business, newest first, filterable by date range, type and customer key.
- `get_document` (פרטי מסמך) *[read-only]* – Full details of one document.
- `get_document_events` (יומן הפעולות על מסמך) *[read-only]* – Audit log of one document.
- `preview_document` (תצוגה מקדימה של מסמך) *[read-only]* – Dry run: computes the document exactly like a real issue, saves nothing.
- `issue_document` (הפקת מסמך) *[irreversible – asks the user to confirm]* – Issue a document of any type; shareLink=true also returns a share link for the customer. Irreversible; requires explicit user confirmation.
- `record_allocation_number` (רישום מספר הקצאה) *[irreversible – asks the user to confirm]* – Record the tax-authority allocation number of an invoice. Irreversible; requires confirmation.
- `produce_original` (הפקת מקור) *[irreversible – asks the user to confirm]* – Produce the document original (PDF), once. Requires confirmation; returns a temporary download link. Signing is automatic; only if the owner opted into a personal signing code without delegating to this connection, a link to sign on the website is returned.
- `get_document_copy` (העתק של מסמך) – PDF copy of a document; every copy is permanently logged in the document audit log. Returns a temporary download link plus a resource link.
- `list_share_links` (קישורי השיתוף של מסמך) *[read-only]* – Share links of a document with status and open counts; the URL itself is only shown when created.
- `create_share_link` (קישור שיתוף למסמך) – Create a permanent share link to a document (the signed original only when the rules allow it, otherwise a copy). The URL is returned once.
- `revoke_share_link` (ביטול קישור שיתוף) *[irreversible – asks the user to confirm]* – Revoke a share link; cannot be undone. Requires explicit user confirmation.
- `record_customer_consent` (רישום הסכמת לקוח) – Record a customer's consent (or withdrawal) to receive computerized documents.
- `get_customer_consent` (הסכמת לקוח) *[read-only]* – Whether the customer's latest decision is consent to computerized documents.
- `create_consent_link` (קישור אישור הסכמה ללקוח) – A link the customer opens to give (or withdraw) explicit, provable consent to computerized documents.
- `export_open_format` (ייצוא מבנה אחיד) *[read-only]* – Open-format (OPENFRMT) export for a period: summary plus a temporary ZIP download link.
- `export_business_archive` (ייצוא מלא של העסק) *[read-only]* – Full business archive ZIP via a temporary download link.
- `list_customers` (כרטיסי לקוח) *[read-only]* – Customer cards of the business, with search (q) and paging; pass the id as customerId when issuing.
- `get_customer` (כרטיס לקוח) *[read-only]* – One customer card by customerId or externalId, with all fields and a summary of its documents.
- `create_customer` (כרטיס לקוח חדש) – Create a customer card. Owner or staff.
- `update_customer` (עדכון כרטיס לקוח) – Partially update a customer card; issued documents never change. Owner or staff.
- `list_items` (פריטים (מחירון)) *[read-only]* – The business price list (items) with search; pass the id as a line itemId when issuing.
- `create_item` (פריט חדש במחירון) – Create a price-list item. Owner or staff.
- `update_item` (עדכון פריט במחירון) – Partially update a price-list item; issued documents never change. Owner or staff.
- `list_designs` (עיצובי המסמכים (מותגים)) *[read-only]* – The business's document designs (brands); pass the id as designId when issuing.
- `get_design` (עיצוב המסמכים) *[read-only]* – One document design of the business (the default design when designId is omitted), including the footer per document type.
- `update_design` (עדכון עיצוב המסמכים) – Save a new version of a document design (future documents only), optionally with a footer per document type. Owner only.
- `create_design` (עיצוב חדש (מותג)) – Create another document design (brand). Owner only.
- `set_default_design` (קביעת עיצוב ברירת המחדל) – Set the design used when no designId is given. Owner only.
- `preview_design` (תצוגה מקדימה של עיצוב) *[read-only]* – Sample PDF in a design, without saving; omitted fields come from that design; docType picks the sample document type.
- `list_members` (משתמשי העסק) *[read-only]* – Business members with roles, and pending invitations. Owner only.
- `invite_member` (הזמנת משתמש לעסק) *[irreversible – asks the user to confirm]* – Invite a user by email (grants access; owner = full control). Requires explicit confirmation; returns an invitation link. Owner only.
- `revoke_invitation` (ביטול הזמנה) *[irreversible – asks the user to confirm]* – Revoke a pending invitation. Requires confirmation. Owner only.
- `remove_member` (הסרת משתמש מהעסק) *[irreversible – asks the user to confirm]* – Remove a member from the business. Requires confirmation. Owner only.
- `list_api_keys` (מפתחות API של העסק) *[read-only]* – The business API keys (metadata only). Owner only.
- `revoke_api_key` (ביטול מפתח API) *[irreversible – asks the user to confirm]* – Revoke a business API key immediately. Irreversible; requires confirmation. Owner only.
- `list_webhooks` (ה-webhooks של העסק) *[read-only]* – The business webhooks (masked URLs, events, state). Owner only. Creating, re-enabling, adding events, rotating secrets and revealing URLs happen only on the site with the password.
- `list_webhook_deliveries` (משלוחים של webhook) *[read-only]* – Recent deliveries of a webhook with status, response code, attempts and error. Owner only.
- `send_test_webhook` (הודעת בדיקה ל-webhook) – Queue a test message to an enabled webhook (max 5 per hour). Owner only; testing a Claude routine happens only on the site.
- `disable_webhook` (השהיית webhook) – Pause a webhook: messages already queued are kept and sent after re-enabling, but events that happen while it is paused are never sent. Re-enabling happens only on the site with the password. Owner only.
- `enable_webhook` (הפעלה מחדש של webhook) *[read-only]* – Re-enabling a webhook is only possible on the site with the password; this tool changes nothing and returns the settings link.
- `delete_webhook` (מחיקת webhook) *[irreversible – asks the user to confirm]* – Delete a webhook (pending messages cancelled, its delivery list removed; the change history keeps the deletion). Irreversible; requires confirmation. Owner only.
- `retry_webhook_delivery` (שליחה חוזרת של משלוח) – Retry a failed or cancelled delivery (same message id). Owner only; retrying a Claude routine happens only on the site.
- `prepare_webhook` (הכנת webhook (קישור לאתר)) *[read-only]* – Prepare a webhook without creating it: validates the URL or routine id and returns a link to the site's settings page with the details prefilled; the owner creates it there with the password. Never send tokens or secrets here; a URL whose path may hold a credential (Slack, Discord, Make, Zapier) is left out of the link and pasted by the owner on the site.
- `ita_connection_status` (חיבור לרשות המסים) *[read-only]* – The business's connection to the Israel Tax Authority. Connecting is done on the website only.
- `request_allocation_number` (בקשת מספר הקצאה) – Request an allocation number for an issued invoice that is awaiting one.
- `report_donation` (דיווח קבלה על תרומה) – Report a donation receipt (or all pending ones) to the Israel Tax Authority.
- `check_section46` (בדיקת סעיף 46) *[read-only]* – Check the nonprofit's section 46 approval validity with the Israel Tax Authority.
- `transmit_open_format` (שידור מבנה אחיד לרשות המסים) *[irreversible – asks the user to confirm]* – Produce the uniform-structure file for a period and transmit it to the Israel Tax Authority. Requires confirmation.
- `get_transmission_status` (מצב שידורי המבנה האחיד) *[read-only]* – Status of uniform-structure transmissions; refresh=true checks with the Tax Authority now.

---

## Appendix: verifying webhook signatures

Standard webhooks are signed per [Standard Webhooks](https://www.standardwebhooks.com/). This is the same text as the
"חתימה (standard)" section of the reference above, repeated here for quick access.

כל בקשה נשלחת ב-`POST` עם הכותרות:

| כותרת | ערך |
|---|---|
| `content-type` | `application/json` |
| `user-agent` | `InvoiceEngine-Webhooks/<גרסה>`, למשל `InvoiceEngine-Webhooks/0.10.0` |
| `webhook-id` | מזהה ההודעה (`msg_` ו-32 תווי hex). **קבוע בכל הניסיונות** של אותה הודעה: לפיו מזהים כפילויות |
| `webhook-timestamp` | שניות מאז 1970 (UTC), של הניסיון הזה |
| `webhook-signature` | `v1,<base64>`; בזמן החלפת סוד שתי חתימות מופרדות ברווח: `v1,<חדש> v1,<קודם>` |

`signature = base64(HMAC-SHA256(key = base64decode(הסוד בלי "whsec_"), message = "{webhook-id}.{webhook-timestamp}.{body}"))`,
כש-`body` הוא הבתים של גוף הבקשה כמו שהתקבלו (לא JSON שפוענח ונבנה מחדש). דחו הודעה כש-`webhook-timestamp` רחוק
מעכשיו ביותר מ-5 דקות. וקטור לבדיקה (הסוד הוא הבתים 0x00..0x1f):

```
secret    = whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
webhook-id = msg_0123456789abcdef0123456789abcdef
webhook-timestamp = 1792055564
body      = הדוגמה של document.issued למעלה, בדיוק (333 בתים ב-UTF-8)
signature = v1,Vj1weAfat3Gnkclb/AS+L5gH7UB52TP/WeXEsvRcSuw=
```

בדיקה ב-Node.js (בלי תלויות):

```js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyWebhook(secret, headers, rawBody, nowSeconds = Date.now() / 1000) {
  const id = headers["webhook-id"], timestamp = headers["webhook-timestamp"], signatures = headers["webhook-signature"] ?? "";
  if (!id || !/^\d+$/.test(timestamp ?? "") || Math.abs(nowSeconds - Number(timestamp)) > 300) throw new Error("bad timestamp");
  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
  const valid = signatures.split(" ").some((entry) => {
    const [version, value] = entry.split(",");
    const given = Buffer.from(value ?? "", "base64");
    return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
  });
  if (!valid) throw new Error("bad signature");
  return JSON.parse(rawBody);
}
```

בדיקה ב-Python (בלי תלויות; `raw_body` הם הבתים של הגוף, `headers` עם שמות באותיות קטנות או אובייקט כותרות שלא
מבחין בין אותיות):

```python
import base64, hashlib, hmac, json, time

def verify_webhook(secret, headers, raw_body, now=None):
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp") or ""
    signatures = headers.get("webhook-signature") or ""
    now = time.time() if now is None else now
    if not msg_id or not timestamp.isdigit() or abs(now - int(timestamp)) > 300:
        raise ValueError("bad timestamp")
    key = base64.b64decode(secret[len("whsec_"):])
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    for entry in signatures.split(" "):
        version, _, value = entry.partition(",")
        if version == "v1" and hmac.compare_digest(value, expected):
            return json.loads(raw_body)
    raise ValueError("bad signature")
```

אפשר גם להשתמש בספריות הרשמיות של Standard Webhooks (`standardwebhooks`, בכל שפה).

**החלפת סוד** (`rotate-secret`): הסוד החדש מוחזר פעם אחת. בלי `previousSecretValidHours` (ברירת מחדל 24) הסוד
הקודם ממשיך לחתום במשך 24 שעות, וכל הודעה נושאת שתי חתימות, כדי שתספיקו לעדכן את המערכת המקבלת. כשהסוד דלף שולחים
`previousSecretValidHours: 0`, והסוד הקודם מפסיק מיד. הסוד נשמר במנוע רק מוצפן (AES-256-GCM, במפתח שנגזר מסיסמת
מפתחות החתימה של השרת).
