# Tenant Public API

**Date:** 2026-05-31
**Status:** Draft
**Depends on:** `foundation-auth-rbac`, `white-label-api`
**Referenced by:** (none yet)

---

## Overview

The Tenant Public API is a machine-to-machine REST API that lets Zync tenants integrate external systems with their Zync account. It exposes read and write access to Customers, Invoices, Tasks, and webhook event logs via scoped API keys. The API lives at `api.zync.is/v1/` and is authenticated exclusively by API keys issued through the `/settings/api-keys` UI.

This spec covers the public API surface: base URL, versioning, auth flow, all endpoints with request/response types, rate limiting, error format, scope matrix, tier gate, and design decisions. It does **not** cover the API key management UI (see `white-label-api`) or the internal application API (`/api/**` routes used by the Zync frontend).

---

## Tier Gate

> **Resolved:** `white-label-api` (spec 27) originally framed all API key management as Enterprise-only. That spec has been updated: Business+ can create keys for the standard public API scopes defined here. Enterprise additionally gains broader internal scopes for reseller automation (e.g. `leads:write`). The `tenant_api_keys` table in spec 27 is the authoritative schema for both.

**API key creation requires Business tier or higher.** Freelancer tenants cannot create API keys.

- The `/settings/api-keys` UI shows an upgrade prompt in place of the key creation form for Freelancer tenants.
- All inbound requests to `api.zync.is/v1/**` with a key belonging to a Freelancer tenant are rejected with `403 { error: "tier_required", minimum_tier: "business" }`.
- Tier gate check occurs after key lookup, before scope check.

Tier matrix:

| Tier | Can create API keys | API access |
|------|---------------------|------------|
| `freelancer` | No | No |
| `business` | Yes (max 5 keys) | Yes |
| `enterprise` | Yes (max 20 keys) | Yes |
| `white_label` | Yes (max 20 keys) | Yes |

---

## Base URL and Versioning

```
https://api.zync.is/v1/
```

The `v1` prefix is embedded in all routes. Breaking changes increment the version to `v2`; `v1` is supported for a minimum 12-month deprecation window after `v2` launches. No breaking changes are made within a version.

**Worker routing:** `api.zync.is` is a separate Cloudflare Worker (or a subrouter within `zync-api`) bound to the `api.zync.is` hostname. It does not serve the internal `/api/**` routes — those remain on `app.zync.is/api/**`.

---

## Authentication

### Key Format

API keys follow the format defined in `white-label-api`:
- Live key: `zyk_live_{random32}` (32 random chars; total length 42)
- Test key: `zyk_test_{random32}` (for future sandbox environment)

Keys are shown once on creation. Zync stores only the SHA-256 hash.

### Request Authentication

Every request must include:
```
Authorization: Bearer zyk_live_<key>
```

### Auth Flow (per request)

```
1. Extract Bearer token from Authorization header
   → 401 { error: "unauthenticated" } if missing or malformed

2. SHA-256 hash the token
   → lookup tenant_api_keys WHERE key_hash = $hash AND revoked_at IS NULL
   → 401 { error: "invalid_api_key" } if not found or revoked

3. Check tier gate
   → resolve tenant tier from tenants table
   → 403 { error: "tier_required", minimum_tier: "business" } if Freelancer

4. Check key expiry (expires_at IS NOT NULL AND expires_at < now())
   → 401 { error: "api_key_expired" } if expired

5. Check scope for the requested endpoint (see scope matrix)
   → 403 { error: "insufficient_scope", required: "customers:write" } if missing

6. Update last_used_at (fire-and-forget, no await — do not block the response)
   → UPDATE tenant_api_keys SET last_used_at = now() WHERE id = $keyId

7. Attach resolved tenantId to request context
   → all subsequent DB queries are scoped to this tenantId
```

`last_used_at` update is best-effort (Worker `ctx.waitUntil()`). A failed update does not fail the request.

### Tenant Isolation

Every database query in Public API handlers appends `WHERE tenant_id = $resolvedTenantId`. There is no query path that omits this filter. Cross-tenant data access is impossible by construction.

---

## Rate Limiting

**100 requests per minute per API key.**

### Implementation

Cloudflare KV counter with TTL:

```ts
const key = `ratelimit:${apiKeyId}:${Math.floor(Date.now() / 60_000)}`
const count = await KV.get(key)  // null means first request in this window
const current = count ? parseInt(count) : 0

if (current >= 100) {
  // 429
}

// First request anchors the window; every put carries expirationTtl = remaining window seconds
await KV.put(key, JSON.stringify({ count: current + 1, windowEnd: windowEnd }), { expirationTtl: remainingTtl })
```

Window is per-key anchored: first request starts a 60-second window; subsequent requests in that window preserve the expiry. Not a sliding window (window does not extend on each request). Equivalent to a fixed epoch window for abuse prevention with more predictable per-key semantics.

### Invalid API Key Rate Limiting

The per-key 100 req/min limit applies only to *authenticated* requests with valid keys. Unauthenticated attempts require separate limits to prevent brute-force key discovery.

- Invalid key responses (`401 invalid_api_key`) tracked in KV: key `api_key_fail:{sha256(prefix)}:{minute-window}`
  where `prefix` = first 8 characters of the submitted key
- **Per-prefix limit:** 10 failures for the same key prefix within 1 minute → `429` with `Retry-After: 300` (5-minute backoff)
- **Per-IP limit:** 50 invalid key attempts per IP per minute across all keys → `429`
- Both limits use `RATE_LIMITER_AUTH` binding (same binding used by the login endpoint — reuse, do not create a new binding)
- `429` response body: `{"error": "too_many_invalid_requests", "retry_after": 300}`
- Counters reset automatically via KV TTL expiry (1-minute window key; 300s backoff window key)

### Rate Limit Headers

All successful responses (and 429 responses) include:

```
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 73
X-RateLimit-Reset: 1748700060
```

`X-RateLimit-Reset` is the Unix timestamp when the current window expires (start of next minute).

### 429 Response

```json
{
  "error": "rate_limited",
  "message": "Rate limit exceeded. 100 requests per minute allowed.",
  "retry_after": 60
}
```

`retry_after` is the number of seconds until the window resets (1–60).

---

## Request / Response Conventions

### Field Casing

All public API fields use **snake_case** in JSON, even though internal Drizzle/TypeScript uses camelCase. The API layer serializes with explicit snake_case mapping — do not expose camelCase keys to external consumers.

### Pagination (Cursor-Based)

All list endpoints are paginated. Default page size: 20. Maximum page size: 100.

```
GET /v1/customers?limit=20&cursor=eyJpZCI6IjEyMyJ9
```

Response envelope:

```json
{
  "data": [...],
  "next_cursor": "eyJpZCI6IjEyMyJ9",
  "has_more": true
}
```

`next_cursor` is a base64-encoded JSON object: `{ "id": "<last_record_id>" }`. Pagination is keyed on `id` (UUID) with `ORDER BY created_at DESC, id DESC` for stable ordering. When `has_more` is false, `next_cursor` is null.

```ts
interface PaginatedResponse<T> {
  data: T[];
  next_cursor: string | null;
  has_more: boolean;
}

interface PaginationParams {
  cursor?: string;  // base64 JSON: { id: string }
  limit?: number;   // 1–100, default 20
}
```

### Content-Type

Requests with a body: `Content-Type: application/json`. Responses: `Content-Type: application/json`.

---

## Error Format

All error responses use a consistent envelope:

```ts
interface ApiError {
  error: string;       // machine-readable error code (snake_case)
  message: string;     // human-readable description (English)
  field?: string;      // field name for validation errors (422 only)
  required?: string;   // required scope (403 insufficient_scope only)
  minimum_tier?: string; // required tier (403 tier_required only)
  retry_after?: number;  // seconds (429 only)
}
```

### HTTP Status Codes

| Code | Meaning | Error codes |
|------|---------|-------------|
| `400` | Bad request (malformed JSON, missing required field) | `bad_request`, `invalid_json` |
| `401` | Unauthenticated | `unauthenticated`, `invalid_api_key`, `api_key_expired` |
| `403` | Forbidden | `insufficient_scope`, `tier_required` |
| `404` | Resource not found (within tenant scope) | `not_found` |
| `409` | Conflict | `conflict` (e.g. duplicate) |
| `422` | Validation error (semantically invalid values) | `validation_error` (with `field`) |
| `429` | Rate limited | `rate_limited` (with `retry_after`) |
| `500` | Internal server error | `internal_error` |

### Examples

```json
// 401
{ "error": "invalid_api_key", "message": "API key not found or revoked." }

// 403 scope
{ "error": "insufficient_scope", "message": "This endpoint requires the 'customers:write' scope.", "required": "customers:write" }

// 403 tier
{ "error": "tier_required", "message": "API access requires Business tier or higher.", "minimum_tier": "business" }

// 404
{ "error": "not_found", "message": "Customer not found." }

// 422
{ "error": "validation_error", "message": "Invalid email address.", "field": "email" }

// 429
{ "error": "rate_limited", "message": "Rate limit exceeded. 100 requests per minute allowed.", "retry_after": 47 }
```

---

## Scope Matrix

| Endpoint | Required Scope |
|----------|---------------|
| `GET /v1/customers` | `customers:read` |
| `GET /v1/customers/:id` | `customers:read` |
| `POST /v1/customers` | `customers:write` |
| `PATCH /v1/customers/:id` | `customers:write` |
| `GET /v1/invoices` | `invoices:read` |
| `GET /v1/invoices/:id` | `invoices:read` |
| `POST /v1/invoices` | `invoices:write` |
| `PATCH /v1/invoices/:id/status` | `invoices:write` |
| `GET /v1/tasks` | `tasks:read` |
| `GET /v1/tasks/:id` | `tasks:read` |
| `POST /v1/tasks` | `tasks:write` |
| `PATCH /v1/tasks/:id` | `tasks:write` |
| `GET /v1/events` | `events:read` |

### Available Scopes

```ts
type ApiScope =
  | 'customers:read'
  | 'customers:write'
  | 'invoices:read'
  | 'invoices:write'
  | 'tasks:read'
  | 'tasks:write'
  | 'events:read'
```

Write scopes imply read access for the same resource — a key with `customers:write` can call `GET /v1/customers` without needing `customers:read`. However, keys are created with explicit scope arrays; granting `customers:write` does not automatically add `customers:read` to the stored array — the auth middleware accepts either scope for read endpoints.

---

## Endpoints

### Customers

#### `GET /v1/customers`

List customers, paginated, with optional filters.

**Scope:** `customers:read`

**Query parameters:**

```ts
interface ListCustomersParams extends PaginationParams {
  search?: string;   // substring match on name or email
  status?: 'active' | 'archived';  // default: 'active'
}
```

**Response:** `PaginatedResponse<CustomerObject>`

```ts
interface CustomerObject {
  id: string;                    // UUID
  name: string;
  company: string | null;
  email: string | null;
  phone: string | null;
  address: {
    street: string | null;
    city: string | null;
    state: string | null;
    zip: string | null;
    country: string | null;
  } | null;
  status: 'active' | 'archived';
  created_at: string;            // ISO 8601
  updated_at: string;
}
```

---

#### `GET /v1/customers/:id`

Get a single customer.

**Scope:** `customers:read`

**Response:** `CustomerObject`

Returns `404` if the customer does not exist within the tenant.

---

#### `POST /v1/customers`

Create a customer.

**Scope:** `customers:write`

**Request body:**

```ts
interface CreateCustomerBody {
  name: string;                  // required
  company?: string;
  email?: string;                // must be valid email if provided
  phone?: string;
  address?: {
    street?: string;
    city?: string;
    state?: string;
    zip?: string;
    country?: string;
  };
}
```

**Response:** `201 CustomerObject`

Validation errors:
- `name` missing → `422 { error: "validation_error", field: "name" }`
- `email` invalid format → `422 { error: "validation_error", field: "email" }`

---

#### `PATCH /v1/customers/:id`

Update a customer. All fields optional (partial update).

**Scope:** `customers:write`

**Request body:**

```ts
interface UpdateCustomerBody {
  name?: string;
  company?: string | null;
  email?: string | null;
  phone?: string | null;
  address?: {
    street?: string | null;
    city?: string | null;
    state?: string | null;
    zip?: string | null;
    country?: string | null;
  } | null;
  status?: 'active' | 'archived';
}
```

Setting a field to `null` clears it. Omitting a field leaves it unchanged.

**Response:** `200 CustomerObject`

Note: Archiving a customer with open invoices returns `409 { error: "conflict", message: "Cannot archive customer with open invoices." }`.

---

### Invoices

#### `GET /v1/invoices`

List invoices, paginated.

**Scope:** `invoices:read`

**Query parameters:**

```ts
interface ListInvoicesParams extends PaginationParams {
  customer_id?: string;          // filter by customer UUID
  status?: InvoiceStatus;        // filter by single status
  from_date?: string;            // ISO date, filters on created_at
  to_date?: string;              // ISO date, filters on created_at
}

type InvoiceStatus =
  | 'DRAFT'
  | 'SENT'
  | 'APPROVED'
  | 'REJECTED'
  | 'TAX_ISSUED'
  | 'PAID'
```

**Response:** `PaginatedResponse<InvoiceObject>`

```ts
interface InvoiceObject {
  id: string;
  customer_id: string;
  project_id: string | null;
  invoice_number: string | null;      // assigned at TAX_ISSUED; null before
  proforma_number: string | null;     // assigned at SENT; null before
  status: InvoiceStatus;
  currency: string;                   // default 'ILS'
  issue_date: string | null;          // ISO date
  tax_issue_date: string | null;      // ISO date
  due_date: string | null;            // ISO date
  vat_rate: string | null;            // e.g. "0.1800" (stored at issue time)
  subtotal: string;                   // decimal string e.g. "1000.00"
  vat_amount: string;
  total: string;
  notes: string | null;
  source: 'manual' | 'retainer' | 'hourly_auto' | 'fixed_deposit' | 'credit_note';
  paid_at: string | null;             // ISO 8601 timestamp
  lines: InvoiceLineObject[];
  created_at: string;
  updated_at: string;
}

interface InvoiceLineObject {
  id: string;
  description: string;
  quantity: string;                   // decimal string
  unit_price: string;                 // decimal string
  discount_pct: string;               // e.g. "0.00"
  line_total: string;
  taxable: boolean;
  position: number;
}
```

Monetary amounts are returned as decimal strings to avoid floating-point precision loss. Consumers should use a decimal library (e.g. `decimal.js`) for arithmetic.

---

#### `GET /v1/invoices/:id`

Get a single invoice with its line items.

**Scope:** `invoices:read`

**Response:** `InvoiceObject`

---

#### `POST /v1/invoices`

Create an invoice in `DRAFT` status.

**Scope:** `invoices:write`

**Request body:**

```ts
interface CreateInvoiceBody {
  customer_id: string;                // required; must belong to tenant
  project_id?: string;                // optional; must belong to tenant
  due_date?: string;                  // ISO date
  currency?: string;                  // default 'ILS'
  notes?: string;
  lines: CreateInvoiceLineBody[];     // at least 1 line required
}

interface CreateInvoiceLineBody {
  description: string;                // required
  quantity?: number;                  // default 1
  unit_price: number;                 // required; in currency units
  discount_pct?: number;              // 0–100, default 0
  taxable?: boolean;                  // default true
  position?: number;                  // display order; auto-assigned if omitted
}
```

**Response:** `201 InvoiceObject`

The API creates the invoice in `DRAFT` status. No invoice number or proforma number is assigned at creation. VAT is not applied at draft time. At creation, `subtotal` is computed from lines; `vat_amount` is `0`, `total` equals `subtotal`. When the invoice transitions to `SENT`, the current VAT rate is looked up from `vat_rates` and stamped immutably on the invoice (matching invoices-core: `issue_date` is the VAT-stamping moment). See `PATCH /v1/invoices/:id/status`.

Validation errors:
- `customer_id` missing → `422`
- `customer_id` not found in tenant → `404 { error: "not_found", message: "Customer not found." }`
- `lines` empty → `422 { error: "validation_error", field: "lines", message: "At least one line item is required." }`
- `unit_price` negative → `422`

---

#### `PATCH /v1/invoices/:id/status`

Update invoice status. Only valid state transitions are permitted.

**Scope:** `invoices:write`

**Request body:**

```ts
interface UpdateInvoiceStatusBody {
  status: InvoiceStatus;
}
```

**Allowed transitions via Public API:**

| From | To | Notes |
|------|----|-------|
| `DRAFT` | `SENT` | Assigns proforma number atomically. Stamps VAT rate from `vat_rates` at `issue_date`. No webhook event emitted. |
| `SENT` | `APPROVED` | Customer approval recorded. Triggers `invoice.proforma_approved` webhook event. |
| `SENT` | `REJECTED` | Returns to draft; invoice becomes editable. |
| `REJECTED` | `DRAFT` | Explicit reset to draft for re-editing. |
| `APPROVED` | `TAX_ISSUED` | Assigns sequential invoice number atomically. Triggers `invoice.issued` webhook event. **Immutable after this point.** |
| `TAX_ISSUED` | `PAID` | Records payment. Triggers `invoice.paid` webhook event. |

**Disallowed transitions** return `422 { error: "invalid_transition", message: "Cannot transition from TAX_ISSUED to DRAFT." }`.

VAT rate stamping occurs at `DRAFT→SENT` (the `issue_date` moment), consistent with `invoices-core`. By the time `TAX_ISSUED` is reached, the rate is already fixed.

**Credit note creation** (cancelling a `TAX_ISSUED` invoice) is out of scope for the Public API v1 — it requires the full IL legal credit note flow. Attempting `PATCH` on a `TAX_ISSUED` invoice with any status other than `PAID` returns `422 { error: "tax_issued_immutable", message": "Tax-issued invoices cannot be modified. Issue a credit note via the Zync dashboard." }`.

**Response:** `200 InvoiceObject`

---

### Tasks

#### `GET /v1/tasks`

List tasks, paginated.

**Scope:** `tasks:read`

**Query parameters:**

```ts
interface ListTasksParams extends PaginationParams {
  project_id?: string;
  assignee_id?: string;
  status_id?: string;    // task_statuses.id
  priority?: 'low' | 'medium' | 'high' | 'urgent';
}
```

**Response:** `PaginatedResponse<TaskObject>`

```ts
interface TaskObject {
  id: string;
  project_id: string | null;
  status_id: string;              // references task_statuses.id
  status_name: string;            // denormalized for convenience: task_statuses.name
  title: string;
  description_text: string | null; // plain-text extraction from Tiptap JSONB; full JSON not exposed
  priority: 'low' | 'medium' | 'high' | 'urgent';
  assignee_id: string | null;
  reporter_id: string;
  due_date: string | null;        // ISO date
  estimated_hours: string | null; // decimal string
  labels: string[];
  source: string;
  created_at: string;
  updated_at: string;
}
```

`description_text` is a plain-text extraction from the internal Tiptap JSONB `description` field. The raw JSONB is not exposed — it is an internal rendering format. If `description` is null, `description_text` is null.

---

#### `GET /v1/tasks/:id`

Get a single task.

**Scope:** `tasks:read`

**Response:** `TaskObject`

---

#### `POST /v1/tasks`

Create a task.

**Scope:** `tasks:write`

**Request body:**

```ts
interface CreateTaskBody {
  title: string;                    // required
  project_id?: string;              // must belong to tenant if provided
  status_id?: string;               // defaults to first status in tenant's default order
  priority?: 'low' | 'medium' | 'high' | 'urgent'; // default: 'medium'
  assignee_id?: string;             // must be a user in the tenant
  due_date?: string;                // ISO date
  estimated_hours?: number;
  description?: string;             // plain text; stored as Tiptap paragraph node
  labels?: string[];
}
```

**Response:** `201 TaskObject`

If `status_id` is omitted, the first task status in `task_statuses` ordered by `position` for the tenant (or project) is used. If the tenant has no statuses configured (edge case), returns `422 { error: "no_statuses_configured" }`.

`reporter_id` is set to `tenant_api_keys.created_by` — the user who created the API key. This is always a real tenant user, requires no service account infrastructure, and provides a clean audit trail linking API-created tasks to the key owner.

---

#### `PATCH /v1/tasks/:id`

Update a task. All fields optional.

**Scope:** `tasks:write`

**Request body:**

```ts
interface UpdateTaskBody {
  title?: string;
  status_id?: string;
  priority?: 'low' | 'medium' | 'high' | 'urgent';
  assignee_id?: string | null;      // null = unassign
  due_date?: string | null;
  estimated_hours?: number | null;
  description?: string | null;      // plain text; overwrites full description
  labels?: string[];                // replaces full label set
}
```

**Response:** `200 TaskObject`

---

### Events (Webhook Audit Log)

#### `GET /v1/events`

List recent outbound webhook delivery records for the tenant. This is a read-only audit trail — it shows what events Zync emitted and their delivery status. Useful for debugging integrations.

**Scope:** `events:read`

**Query parameters:**

```ts
interface ListEventsParams extends PaginationParams {
  event_type?: string;              // e.g. 'invoice.paid'
  status?: 'pending' | 'delivered' | 'failed';
  from_date?: string;               // ISO date
  to_date?: string;
}
```

**Response:** `PaginatedResponse<EventObject>`

```ts
interface EventObject {
  id: string;                       // webhook_deliveries.id
  event_type: string;               // e.g. 'invoice.paid'
  status: 'pending' | 'delivered' | 'failed';
  endpoint_url: string;             // destination URL (truncated to domain for security: 'https://hooks.example.com/...')
  attempt: number;
  response_status: number | null;   // HTTP status from recipient
  delivered_at: string | null;      // ISO 8601
  created_at: string;
}
```

Payload is **not** returned (it may contain sensitive data; consumers can cross-reference by `event_type` + `created_at`). The `endpoint_url` is truncated to scheme + host + first path segment to avoid leaking webhook secrets embedded in URLs.

---

## Schema

No new tables. The `tenant_api_keys` table defined in `white-label-api` is the authoritative source:

```sql
-- Existing (from white-label-api spec):
tenant_api_keys (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  name TEXT NOT NULL,
  key_prefix TEXT NOT NULL,         -- first 8 chars: e.g. "zyk_live"
  key_hash TEXT NOT NULL,           -- SHA-256 of full key
  scopes TEXT[] NOT NULL,
  expires_at TIMESTAMPTZ,           -- null = never expires
  last_used_at TIMESTAMPTZ,
  created_by UUID NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now(),
  revoked_at TIMESTAMPTZ
)
```

### `last_used_at` Update Logic

`last_used_at` is updated on every authenticated request that passes auth validation (step 6 of the auth flow). Update is fire-and-forget via `ctx.waitUntil()` to avoid adding latency:

```ts
ctx.waitUntil(
  db.update(tenantApiKeys)
    .set({ lastUsedAt: new Date() })
    .where(eq(tenantApiKeys.id, keyId))
)
```

This means `last_used_at` lags real-time by up to a few seconds under load; this is acceptable for a display-only "last used" timestamp.

### Task API Source

`tasks.source` gains a new value `'api'` (alongside existing `'manual'`, `'email'`, `'telegram'`, `'trello'`, etc.) to identify tasks created via Public API. `reporter_id` for API-created tasks is set to `tenant_api_keys.created_by` — no new infrastructure required.

---

## OpenAPI / Documentation

An OpenAPI 3.1 schema is generated from Hono route definitions using `@hono/zod-openapi`. The schema drives:

- Auto-generated docs at `docs.zync.is/api` (static site, generated from OpenAPI — **deferred**).
- SDK generation (TypeScript client) — **deferred, post-v1**.

Zod schemas for request/response validation are the source of truth; TypeScript types in this spec are the specification-time representation. Implementation must match.

---

## Foundation Deltas

**New Worker route (or subrouter):** `api.zync.is` — handles all `GET|POST|PATCH /v1/**` requests. Auth middleware applied globally before any route handler.

**New KV namespace:** `RATELIMIT_KV` — used for per-key rate limit counters. Binding declared in `wrangler.toml`.

**New task source value:** `'api'` in `tasks.source`.

**New environment binding:** No new secrets needed. `RATELIMIT_KV` KV namespace binding required in `wrangler.toml`.

---

## Design Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Separate `api.zync.is` subdomain | Not `/api/v1/` on `app.zync.is` | Separate surface for external consumers; cleaner rate limiting by hostname; can be independently deployed or scaled |
| Cursor-based pagination | Not offset/page | Consistent results under concurrent writes; no page-drift when records are inserted; scales to large datasets without `COUNT(*)` |
| No GraphQL | REST only | Lower implementation complexity; easier to document and version; IL market integrators (Zapier, Make, custom scripts) expect REST |
| Scope array on key | Not role-based permissions | Least-privilege for API keys; external systems get only what they need; decoupled from RBAC role model |
| `v1` version prefix | In URL path | Explicit, unforgeable versioning; easier to route in Workers; no content-negotiation complexity |
| Monetary values as decimal strings | Not floats | IEEE 754 floats lose precision for IL NIS amounts; string preserves exact NUMERIC(12,2) from Postgres |
| `description_text` not raw Tiptap JSON | Plain text extraction | Tiptap JSONB is an internal rendering format; exposing it would create a versioned contract; text is stable |
| `events:read` scope | Separate from other read scopes | Webhook delivery records are meta-data about the tenant's infrastructure; warrant explicit opt-in |
| `reporter_id` = key's `created_by` for API tasks | Not service account, not required in body | `reporter_id` is NOT NULL; using the key owner's user ID is a real audit trail without inventing service account infrastructure; `source = 'api'` distinguishes origin |
| Business+ tier gate | Not all tiers | API key creation requires developer intent; Freelancer tier is individual/small-scale; API access is a B2B integration feature |
| `last_used_at` fire-and-forget | `ctx.waitUntil()` | Display-only field; adding DB write to the critical path adds ~5ms latency on every request; not worth it |
| Fixed 1-minute rate limit window | Not sliding window | Simpler KV implementation; predictable for consumers; sufficient for abuse prevention at this scale |
