מוסכמות

מעטפת השגיאה

כל תשובת שגיאה — יהיה קוד הסטטוס אשר יהיה — משתמשת באותו מבנה:

error response
{
  "error": {
    "code": "STUDENT_NOT_FOUND",
    "message": "Student not found.",
    "details": {
      "studentId": "0190a1b2-..."
    },
    "requestId": "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b"
  }
}

message הוא תמיד באנגלית ומיועד ליומנים, לא למשתמשי הקצה — תרגמו לפי code בממשק שלכם. details מופיע רק כשהוא מוסיף משהו שהלקוח יכול לפעול לפיו (איזה שדה נכשל בוולידציה, לאיזה מזהה בוצע חיפוש). requestId תואם לערך שהממשק רושם גם ביומני השרת — צרפו אותו כשאתם מדווחים על תקלה.

קודי שגיאה

כל קוד שהממשק יכול להחזיר, נוצר ישירות מ-packages/shared/src/errors.ts — המקור היחיד שכל יצרן שגיאות בבסיס הקוד בונה ממנו, כך שהטבלה הזו לעולם לא תסטה ממה שהממשק באמת שולח.

קודסטטוס HTTPתיאור
VALIDATION_ERROR422The request parsed, but its content is not acceptable: a schema failure at the boundary (body, query, path params or headers — always carrying `details.issues`) or a domain rule rejecting a well-formed value. Every validation failure in the API uses this one status; a 400 (`MALFORMED_REQUEST`) means only that the request could not be read at all.
MALFORMED_REQUEST400The request could not be read at all, before any schema ran: an unparseable body, an unusable content type, a payload over the size limit. Deliberately not `VALIDATION_ERROR`, which always means 422, so a client can tell "I could not read your request" apart from "I read it and its content is not acceptable" from the code alone.
UNAUTHORIZED401No valid credentials (JWT, API key, or session) were presented.
FORBIDDEN403The caller is authenticated but lacks the permission this action requires.
NOT_FOUND404The requested resource does not exist (or is not visible to this tenant).
CONFLICT409The request conflicts with the resource’s current state.
INTERNAL_ERROR500An unexpected server error. Never leaks a stack trace or SQL to the client.
PAYMENT_REQUIRED402The tenant's subscription does not currently permit this action.
SUBSCRIPTION_SUSPENDED402Subscription/license lifecycle has reached `suspended`/`stopped` (docs/ARCHITECTURE.md §1).
INSUFFICIENT_CREDIT402The messaging relay's atomic conditional debit found no row to update: the account's balance minus the message's cost would fall below `-overdraft_units`, the account is not `active`, or it does not exist yet (docs/specs/phase9-messaging-relay.md §10.3). Nothing is debited and nothing is sent when this is thrown — the caller falls back to the in-app inbox. Never used for a `system`-class message, which is never blocked by balance (§11.1).
RATE_LIMITED429The caller exceeded a rate limit (per-IP, per-user, or per-API-key sliding window). See the `RateLimit-*` response headers.
INVALID_CREDENTIALS401Email/password did not match any account.
ACCOUNT_LOCKED423Account temporarily locked out after repeated failed logins.
ACCOUNT_DISABLED403The user account itself has been disabled (distinct from a locked-out but active account).
SESSION_REVOKED401A refresh token that was already rotated away was presented again (reuse detected).
TOKEN_INVALID401A refresh/reset/verification token is missing, malformed, expired, or already used.
REGISTRATION_DISABLED403Self-registration is disabled for this tenant/deployment.
ACCOUNT_EMAIL_UNVERIFIED403`POST /accounts/sign-in` for an account whose `email_verified_at` is still null (docs/specs/phase13-portal-and-institflow-account.md §2.2). A distinct, actionable code so the web client can offer "resend the code" instead of showing a generic credentials error — the password was correct.
ROLE_IMMUTABLE403Attempted to edit the immutable system `owner` role.
PERMISSION_ESCALATION403Caller tried to grant a permission (directly, or via a role assignment) they do not themselves hold.
INVITATION_INVALID400The invitation link is invalid, expired, or already used.
MEMBERSHIP_INACTIVE403The caller's membership in the active tenant is not `active` (invited or disabled).
TENANT_SUSPENDED403The active tenant itself is not usable (archived). Not used for billing suspension — see `subscriptionSuspended`.
TENANT_NOT_FOUND404No tenant matches the given id/slug, or it is not visible to this caller.
TENANT_SLUG_TAKEN409The slug (or the subdomain built from it) already belongs to a tenant. `details`, when the owning tenant is known, is the same `{ tenantId, tenantName, status, endedAt }` shape `ownerEmailTaken` carries, so the console can name that institute and link to it (docs/specs/phase15-institute-delete.md §1).
OWNER_EMAIL_TAKEN409`POST /platform/tenants` (and the customer-request approval that reuses it): the owner email is already the owner of another institute — active, suspended or ended (archived). A deleted institute never matches: deleting it removed its owner user. `details` is `{ tenantId, tenantName, status, endedAt }` (`endedAt` is `null` unless the institute was ended) — docs/specs/phase15-institute-delete.md §1.
LICENSE_INVALID400A license JWT failed signature/shape verification (self-hosted `LicenseService`).
VERSION_CONFLICT409`common/optimistic-lock.ts`'s `updateWithVersion` matched zero rows: the caller's `expectedVersion` no longer matches the row's current `version` (someone else wrote it first). Distinct from the generic `conflict()` (`CONFLICT`) so clients can special-case "reload and retry" instead of showing a generic conflict message.
IDEMPOTENCY_KEY_REQUIRED428An API-key caller sent a POST/PUT/PATCH/DELETE with no `Idempotency-Key` header (docs/specs/phase4-integration-mcp.md §A). 428 (not 400) so clients can tell "you forgot a required precondition" apart from a generic validation failure — documented choice, see `docs/build-log/phase4-integration.md`.
IDEMPOTENCY_KEY_REUSED409The same `Idempotency-Key` was reused with a different method/path/body than the first request that used it.
OAUTH_CLIENT_NOT_FOUND404`POST /api/v1/oauth/authorize/decision` (or `/context`) referenced an unknown or disabled OAuth client.
OAUTH_INVALID_REDIRECT_URI400The `redirect_uri` does not exactly match one registered for this OAuth client.
OAUTH_ACCESS_DENIED403The tenant/membership selected on the consent screen does not hold `mcp.access`.
FEATURE_DISABLED403`FeatureGuard` (docs/specs/phase6-courses-video.md §3.1) found the resolved tenant's effective feature flags do not grant the `@RequireFeature(...)` key the route declares. Runs after `PermissionsGuard`, so a caller lacking the permission still sees `FORBIDDEN`, never this.
QUOTA_EXCEEDED403A plan-quota check (docs/specs/phase6-courses-video.md §3.2, mirrors the notification quota) rejected a create before any write happened. `details` carries `{ limit, current, key }`.
VIDEO_URL_INVALID422`parseVideoUrl()` (`packages/shared/src/video.ts`) could not recognize or validate a pasted video URL. `details.reason` names the specific cause (e.g. `'playlist_not_supported'`, `'unknown_host'`).
VIDEO_PROVIDER_UNSUPPORTED422The URL's provider was recognized but is not one `VIDEO_PROVIDERS` accepts: only `youtube` from 1.4.0 (ق-32, docs/DECISIONS-LOG.md — a second provider was removed entirely, not gated off; `upload`/`vimeo`/`bunny` remain declared-but-unbuilt, D8).
STORAGE_FULL409The server's disk has reached the reserve kept free for the database, backups and updates (docs/specs/phase20-course-files.md §3): the upload is refused **before** a signed URL is issued, so nothing is ever half-uploaded. A business refusal, not a server fault — hence 409, not 5xx. `details` carries `{ requestedBytes, remainingBytes }`.
FILE_TYPE_NOT_ALLOWED422A file's type is not one the course-files feature accepts (docs/specs/phase20-course-files.md §4): its extension or declared MIME type is off the allowlist, or — at `complete` — the object's first bytes do not match what its extension claims. `details.reason` names which.
MFA_STEP_UP_REQUIRED403The access token's `mfa_at` (last successful TOTP check) is older than the sensitive route's `@RequireRecentMfa(seconds)` window (docs/specs/phase11-vendor-console.md §2.4). The client is expected to prompt for the current 6-digit code and retry via `POST /auth/mfa/step-up`, which returns a fresh access token with a new `mfa_at`.

400 מול 422

ולידציה היא תמיד 422. כל דבר שהממשק מצליח לקרוא אך לא מוכן לקבל — גוף, מחרוזת שאילתה, פרמטר נתיב או כותרת שנכשלים בסכמה שלהם, או כלל עסקי שדוחה ערך תקין מבחינה מבנית — חוזר כ-422 VALIDATION_ERROR, וכישלון סכמה נושא את השדות הכושלים ב-details.issues:

422 validation failure
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid input: expected string, received undefined",
    "details": {
      "issues": [
        {
          "path": [
            "branchId"
          ],
          "message": "Invalid input"
        }
      ]
    },
    "requestId": "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b"
  }
}

400 MALFORMED_REQUEST אומר רק שלא ניתן היה לקרוא את הבקשה כלל — JSON שלא ניתן לפענוח, Content-Type בלתי שמיש, או מטען גדול מדי. בקצרה: 400 הוא באג באופן שבו אתם מסדרים את הבקשה, 422 הוא באג במה ששמתם בתוכה. קומץ נקודות קצה משתמשות ב-400 גם לדחייה ממוקדת אחת ומפורשת (INVITATION_INVALID, LICENSE_INVALID, OAUTH_INVALID_REDIRECT_URI) — ולכן מסתעפים לפי error.code, לעולם לא לפי הסטטוס.

עימוד באמצעות Cursor

כל נקודת קצה שמחזירה רשימה מעומדת באמצעות cursor — לעולם לא לפי offset או מספר עמוד, כך שהתוצאות נשארות נכונות גם בזמן ששורות חדשות נכנסות במקביל. העבירו limit (מקסימום 100, ברירת המחדל משתנה לפי נקודת הקצה), ובכל עמוד אחרי הראשון גם את ה-cursor מתשובת העמוד הקודם:

page shape
{
  "data": [
    "…"
  ],
  "nextCursor": "eyJpZCI6IjAxOTAwMDAwLTAwMDAtNzAwMC04MDAwLTAwMDAwMDAwMDAwMSJ9"
}

nextCursor הוא string | null — null אומר שהגעתם לעמוד האחרון. התייחסו ל-cursor כאל ערך אטום: זהו מצביע מקודד ב-base64url, לא היסט של שורה, והמבנה הפנימי שלו עשוי להשתנות.

סכומי כסף

כל ערך כספי הוא אובייקט מקונן — { amountMinor, currency } — מספר שלם ביחידת המשנה של המטבע (סנטים, אגורות, פילס) יחד עם שדה currency משלו על אותו אובייקט עצמו, למשל total.amountMinor + total.currency של חשבונית, או amountMinor + currency של תשלום. זה לעולם לא מספר חשוף שהמטבע שלו יושב במקום אחר על אובייקט האב. לעולם לא נקודה צפה. { amountMinor: 1250, currency: "USD" } הוא 12.50 דולר.

חותמות זמן

כל חותמת זמן היא ISO-8601 ב-UTC (2026-03-02T09:00:00.000Z). תאריכים ללא רכיב שעה (תאריך יעד לתשלום, תאריך לידה) הם YYYY-MM-DD פשוט. המירו לאזור הזמן של הדייר (ברירת המחדל Asia/Jerusalem) לצורך תצוגה — הממשק לעולם לא עושה זאת עבורכם.

ריבוי דיירים

לעולם אינכם מעבירים מזהה דייר בעצמכם. JWT של צד ראשון נושא את הדייר שנבחר בשלב select-tenant; מפתח API קשור לצמיתות לדייר שבו נוצר. כל שאילתה שהממשק מריץ תחומה לאותו דייר גם בשכבת האפליקציה וגם באבטחת השורות (row-level security) של מסד הנתונים עצמו — אין שום דרך לפנות לנתונים של דייר אחר בניחוש מזהה.

requestId הוא הידיד שלכם

רשמו את error.requestId לצד יומני הבקשות שלכם. זו הדרך המהירה ביותר לקבל תשובה מהירה וממוקדת כשמשהו נראה שגוי בצד השרת.