{"components":{"securitySchemes":{"ApiKeyAuth":{"description":"Use a key from /dashboard/settings/keys (sb_live_*)","scheme":"bearer","type":"http"},"BearerAuth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}},"info":{"description":"Tenant-scoped email service provider API.","title":"SendBolt API","version":"1.0.0"},"openapi":"3.0.3","paths":{"/admin/compliance/soc2/dates":{"get":{"description":"Returns dates newest-first. An empty list means the Soc2EvidenceWorker hasn't run yet or SOC2_EVIDENCE_ENABLED is unset.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"dates":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List the calendar dates that have a SOC2 evidence pack on disk (super-admin only)"}},"/admin/compliance/soc2/run-now":{"post":{"description":"Bypasses both the 02:00 UTC hour gate and the 'already wrote today' gate. Overwrites today's pack on disk. Returns 503 if the worker isn't wired, 502 on worker-level failure.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"artifact":{"type":"string"},"elapsed_ms":{"type":"integer"},"triggered":{"type":"boolean"}},"type":"object"}}},"description":"OK"}},"summary":"Build today's SOC2 evidence pack out of band (super-admin only)"}},"/admin/compliance/soc2/today":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"access_reviews":{"type":"object"},"auth_events":{"type":"object"},"change_management":{"type":"object"},"date":{"type":"string"},"errors":{"type":"array"},"generated_at":{"type":"string"},"incident_response":{"type":"object"},"suppression_discipline":{"type":"object"}},"type":"object"}}},"description":"OK"}},"summary":"Today's SOC2 daily evidence pack (super-admin only). Returns the JSON artifact produced by the Soc2EvidenceWorker at 02:00 UTC. Sections: access_reviews, change_management, incident_response, suppression_discipline, auth_events. Returns 404 when SOC2_EVIDENCE_ENABLED is unset / today's pack hasn't been written yet."}},"/admin/compliance/soc2/{date}":{"get":{"description":"date is YYYY-MM-DD UTC. Returns the JSON artifact produced by Soc2EvidenceWorker (same shape as /admin/compliance/soc2/today). 404 when the pack doesn't exist for the date; 400 when the date string is malformed.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"access_reviews":{"type":"object"},"auth_events":{"type":"object"},"change_management":{"type":"object"},"date":{"type":"string"},"generated_at":{"type":"string"},"incident_response":{"type":"object"},"suppression_discipline":{"type":"object"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Get the SOC2 evidence pack for a specific date (super-admin only)"}},"/admin/dkim-rotations":{"get":{"description":"Reads dkim_rotations (migration 000192). One row per IN-FLIGHT rotation (state in pending|publishing|active|retiring). For rotations in `publishing` with `cloudflare_managed=false`, the response includes a pre-rendered `dns_record` the operator can paste into their DNS provider. Optional filters: ?tenant_id=, ?include_retired=true (default false). Pagination via ?limit (default 100, cap 500) + ?offset.","parameters":[{"in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"in":"query","name":"offset","required":false,"schema":{"type":"integer"}},{"in":"query","name":"tenant_id","required":false,"schema":{"type":"string"}},{"in":"query","name":"include_retired","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"},"limit":{"type":"integer"},"offset":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"In-flight DKIM rotations (super-admin only)"}},"/admin/dkim-rotations/{id}/confirm-published":{"post":{"description":"Stamps dns_published_at and clears any error on the row, but does NOT bypass the multi-resolver propagation check — the rotator's next 30-min Tick will re-poll 1.1.1.1, 8.8.8.8, 9.9.9.9 and only flip to `active` when all three return the new TXT record.","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"state":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Operator confirms manual DNS publish for a DKIM rotation (super-admin only)"}},"/admin/dkim-rotations/{id}/rollback":{"post":{"description":"Transitions a non-terminal rotation directly to `retired` without ever activating. The existing key remains the live signer (the rotator never writes the new key to sending_domains in any non-active state). Used when a publish stalls (Cloudflare token revoked, DNS provider outage) or the operator wants to defer the rotation to a maintenance window.","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"state":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Abandon an in-flight DKIM rotation (super-admin only)"}},"/admin/dkim/rotations":{"get":{"description":"Reads dkim_rotation_audit (migration 000157). One row per rotation event with old/new selector, reason, and DNS-publish status. Optional filters: ?tenant_id=, ?domain_id=, ?since=\u003cRFC3339\u003e. Pagination via ?limit (default 100, cap 500) + ?offset.","parameters":[{"in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"in":"query","name":"offset","required":false,"schema":{"type":"integer"}},{"in":"query","name":"tenant_id","required":false,"schema":{"type":"string"}},{"in":"query","name":"domain_id","required":false,"schema":{"type":"string"}},{"in":"query","name":"since","required":false,"schema":{"format":"date-time","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"},"limit":{"type":"integer"},"offset":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"DKIM rotation audit log across every tenant (super-admin only)"}},"/admin/ip-watchdog":{"get":{"description":"Returns the W235 watchdog snapshot. `pools` holds one row per ip_pool_members entry with quarantine state (active vs quarantined_until) and the most recent ip_pool_events row joined in. `events` holds the last 50 ip_pool_events rows across every pool, sorted newest first. Used by the W215 admin dashboard panel.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"events":{"type":"array"},"pools":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Per-IP reputation watchdog status (super-admin only)"}},"/admin/license-keys":{"get":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List license keys (super-admin only)"}},"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"expires_at":{"type":"string"},"plan_id":{"type":"string"},"tenant_id":{"type":"string"}},"required":["plan_id"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Mint a new license key (super-admin only)"}},"/admin/license-keys/bulk/revoke":{"post":{"description":"Same partial-success contract as /admin/tenants/bulk/delete: individual key failures surface per-row, overall HTTP status is always 200 unless validation fails. Already-revoked keys are treated as ok (no audit re-emit; the row is reported with status='already_revoked').","requestBody":{"content":{"application/json":{"schema":{"properties":{"keys":{"type":"array\u003cstring\u003e"}},"required":["keys"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Bulk revoke up to 200 license keys in one call (super-admin only)"}},"/admin/license-keys/{key}/revoke":{"post":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Revoke a license key (super-admin only) — sets revoked_at; the key is rejected on next validation"}},"/admin/observability":{"get":{"description":"Aggregates tenant counts, domain verification state, 24h send funnel + bounce/complaint rate, warmup occupancy, daily healthcheck colour distribution, per-ESP delivery score, open DNS drift alerts, and a process snapshot (BuildSHA + uptime). Per-section errors degrade fields to zero-value rather than 500ing the whole payload; failures surface in the `errors` map.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"dns_drift":{"type":"object"},"domains":{"type":"object"},"errors":{"type":"object"},"esp_scores":{"type":"array"},"healthchecks":{"type":"object"},"process":{"type":"object"},"schema_drift":{"type":"object"},"sends_24h":{"type":"object"},"tenants":{"type":"object"},"warmup":{"type":"object"}},"type":"object"}}},"description":"OK"}},"summary":"Platform-wide ops snapshot (super-admin only)"}},"/admin/observability/slow-queries":{"get":{"description":"Reads query_cost_stats (populated by internal/db/observ.go's background flusher). DISTINCT ON (query_id) returns each query's most-recent 5min window so the dashboard reflects current state, not historical spikes — a separate alerter (DetectRegressions) surfaces 'was fast, is now 3x slow' history. Out-of-bound query params are clamped silently to keep the response shape boring.","parameters":[{"description":"Top N rows (1..100, default 10)","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Lookback window in hours (1..168, default 24)","in":"query","name":"hours","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"as_of":{"type":"string"},"error":{"type":"string"},"error_ms":{"type":"integer"},"items":{"type":"array"},"lookback_hours":{"type":"integer"},"warn_ms":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"Top N slow queries by p99 latency over a lookback window (super-admin only)"}},"/admin/ratelimits":{"get":{"description":"Returns one row per active tenant: tenant_id, tenant_name, capacity (per-sec cap from tenant_rate_limits override OR the global default), current (live counter from the in-process limiter for this 1-second window), utilization_pct, recent_throttle_count (count of 429s in the last 5 minutes), last_throttle_at (RFC3339 timestamp of the most recent 429, omitted when none). Sorted by utilization desc, then recent_throttle_count desc, then name asc. Partial failures degrade to an empty `items` array with the error surfaced in `errors[\"tenants\"]`.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"as_of":{"type":"string"},"errors":{"type":"object"},"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Per-tenant rate-limit snapshot for the operator dashboard (super-admin only)"}},"/admin/seedlist":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"},"total":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"List every seed mailbox (super-admin only). Returns {items:[...], total:N}."},"post":{"description":"Inserts a new row in the global seedlist. `provider` must be one of: gmail, outlook, yahoo, aol, icloud, proton, fastmail, custom. `email` must be a valid RFC-5322 address and is rejected with 409 if it already exists. `label` is an optional operator-set string (e.g. 'Gmail consumer', 'Outlook m365').","requestBody":{"content":{"application/json":{"schema":{"properties":{"email":{"type":"string"},"label":{"type":"string"},"provider":{"type":"string"}},"required":["email","provider"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Add a seed mailbox (super-admin only)"}},"/admin/seedlist/bulk":{"post":{"description":"Up to 500 rows per call. Per-row failures (invalid email, bad provider, duplicate) surface in `results` without aborting the batch; the response totals are inserted/duplicates/invalid.","requestBody":{"content":{"application/json":{"schema":{"properties":{"rows":{"type":"array"}},"required":["rows"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Bulk-create seed mailboxes (super-admin only)"}},"/admin/seedlist/{id}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Delete a seed mailbox (super-admin only)"},"patch":{"description":"Mutates `label` and/or `active`. Email and provider are immutable so historical placement reports aren't retconned by a relabel. At least one of the two fields is required.","requestBody":{"content":{"application/json":{"schema":{"properties":{"active":{"type":"boolean"},"label":{"type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Update a seed mailbox (super-admin only)"}},"/admin/seedlist/{id}/hits":{"get":{"description":"Returns expected vs observed placement for each campaign that included this seed. Pagination via ?limit (1..200, default 25) + ?offset.","parameters":[{"in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"in":"query","name":"offset","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Per-seed placement history (super-admin only)"}},"/admin/tenants":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List every tenant in the platform (super-admin only)"}},"/admin/tenants/bulk-action":{"post":{"description":"Reuses the per-tenant suspend/unsuspend helper so audit + Slack semantics are identical to /admin/tenants/{id}/suspend; every audit row also carries a bulk_request_id field so an operator can correlate the batch. Partial success is success — individual tenant failures (e.g. unknown id) are surfaced per-row in `results` with status='error' but the overall HTTP status is always 200. Scheduled-suspend (delayed cron-style suspension) is intentionally out of scope.","requestBody":{"content":{"application/json":{"schema":{"properties":{"action":{"type":"string"},"tenant_ids":{"type":"array\u003cstring\u003e"}},"required":["tenant_ids","action"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Bulk suspend or unsuspend up to 100 tenants in one call (super-admin only)"}},"/admin/tenants/bulk/delete":{"post":{"description":"DELETE-equivalent of /admin/tenants/bulk-action. Cascades remove every per-tenant row (users, contacts, campaigns, events, audit_events). The super-admin tenant is never deleted — that id reports per-row status='error' with reason 'cannot delete super-admin tenant'. Partial success is success: individual failures surface per-row, overall HTTP status is always 200 unless validation fails up front.","requestBody":{"content":{"application/json":{"schema":{"properties":{"tenant_ids":{"type":"array\u003cstring\u003e"}},"required":["tenant_ids"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Bulk delete up to 100 tenants in one call (super-admin only)"}},"/admin/tenants/bulk/suspend":{"post":{"description":"Same audit + Slack semantics as the bulk-action endpoint; exists so the W135-C frontend can use a uniform /bulk/\u003cverb\u003e URL shape instead of an action discriminator in the body.","requestBody":{"content":{"application/json":{"schema":{"properties":{"tenant_ids":{"type":"array\u003cstring\u003e"}},"required":["tenant_ids"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Alias for POST /admin/tenants/bulk-action with action=suspend (super-admin only)"}},"/admin/tenants/bulk/unsuspend":{"post":{"description":"See /admin/tenants/bulk/suspend.","requestBody":{"content":{"application/json":{"schema":{"properties":{"tenant_ids":{"type":"array\u003cstring\u003e"}},"required":["tenant_ids"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Alias for POST /admin/tenants/bulk-action with action=unsuspend (super-admin only)"}},"/admin/tenants/{id}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"deleted":{"type":"boolean"},"id":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Permanently delete a tenant (super-admin only). FK CASCADE wipes the per-tenant graph. The super-admin's own tenant always 403s."},"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Tenant detail (super-admin only)"}},"/admin/tenants/{id}/activity":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Per-tenant activity timeline (super-admin only) — last N audit-log entries with actor + event"}},"/admin/tenants/{id}/db-health":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"connections_active":{"type":"integer"},"db_size_mb":{"type":"integer"},"last_vacuum_at":{"type":"string"},"replication_lag_s":{"type":"integer"},"slow_query_count_24h":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"Per-tenant DB SLO tiles (super-admin only). Any single failing probe yields null for that field rather than 500-ing the endpoint."}},"/admin/tenants/{id}/digest/run-now":{"post":{"description":"Skips both the 09:00 IST gate and the digest_last_posted_at gate. Falls back to logs-only when no Slack notifier is wired.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"elapsed_ms":{"type":"integer"},"triggered":{"type":"boolean"}},"type":"object"}}},"description":"OK"}},"summary":"Generate + post the digest line for one tenant out of band (super-admin only)"}},"/admin/tenants/{id}/health-checks":{"get":{"description":"Returns the most-recent N runs in descending order by ran_at. ?limit clamps to [1, 200] (default 30).","parameters":[{"in":"query","name":"limit","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"count":{"type":"integer"},"items":{"type":"array"},"limit":{"type":"integer"},"tenant_id":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Recent send-path healthcheck history for a tenant (super-admin only)"}},"/admin/tenants/{id}/health-checks/run-now":{"post":{"description":"Skips the 06:00 UTC cron gate. Returns 503 when the worker isn't wired.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"elapsed_ms":{"type":"integer"},"triggered":{"type":"boolean"},"verdict":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Trigger an out-of-band send-path healthcheck for a tenant (super-admin only)"}},"/admin/tenants/{id}/impersonate":{"post":{"description":"Returns a JWT scoped to the target tenant_id with is_impersonating=true and impersonator_user_id set to the calling super-admin's user_id. TTL defaults to 30 minutes (min 1, max 120). Every POST/PUT/PATCH/DELETE made under the returned token writes a super_admin.impersonation_action audit row asynchronously so the request path stays unblocked.","requestBody":{"content":{"application/json":{"schema":{"properties":{"ttl_minutes":{"type":"integer"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Mint a short-lived impersonation JWT for a tenant (super-admin only)"}},"/admin/tenants/{id}/plan":{"put":{"description":"Updates plan_id on the tenant. Quota counters are NOT reset by this call — the next monthly bucket starts under the new plan limits.","requestBody":{"content":{"application/json":{"schema":{"properties":{"plan_id":{"type":"string"}},"required":["plan_id"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Change a tenant's plan (super-admin only)"}},"/admin/tenants/{id}/rate-limit":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Get the tenant's API rate-limit override (super-admin only). All-NULL fields mean the tenant uses the global default; default_per_sec surfaces that default so the UI doesn't need to hard-code it."},"put":{"description":"Upserts a row in tenant_rate_limits. Either column may be NULL to clear that dimension; sending {} resets to the global default. Changes apply within the next request via the 60s in-memory cache invalidation.","requestBody":{"content":{"application/json":{"schema":{"properties":{"api_requests_per_day":{"type":"integer|null"},"api_requests_per_sec":{"type":"integer|null"},"notes":{"type":"string|null"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Set the tenant's API rate-limit override (super-admin only)"}},"/admin/tenants/{id}/sandbox":{"put":{"description":"W213. When is_sandbox=true the engine intercepts every outbound send into simulated_sends. Audit-logged. Default false at tenant creation.","requestBody":{"content":{"application/json":{"schema":{"properties":{"is_sandbox":{"type":"boolean"}},"required":["is_sandbox"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"is_sandbox":{"type":"boolean"},"tenant_id":{"type":"string"}},"type":"object"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Flip a tenant's sandbox flag (super-admin only)"}},"/admin/tenants/{id}/soc2-evidence":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"as_of":{"type":"string"},"controls":{"type":"object"},"tenant_id":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Per-tenant SOC2 evidence snapshot (super-admin only). Returns a current-state document of seven control signals: encryption_at_rest, tls_only_ingress, backup_success_last_24h, last_restore_drill, admin_mfa_coverage_pct, audit_log_hash_chain_intact, suspicious_activity_last_24h. Each control carries status ('green' | 'yellow' | 'red'), optional value, and a free-text note. Controls without underlying data yet return yellow with a TICKET reference, never fabricated values."}},"/admin/tenants/{id}/suspend":{"post":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Suspend a tenant (super-admin only) — blocks all sends + login until unsuspended"}},"/admin/tenants/{id}/unsuspend":{"post":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Lift a tenant suspension (super-admin only)"}},"/admin/tenants/{id}/warmup-schedule":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"},"tenant_id":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Read the 30-row warmup plan for a tenant (super-admin only)"}},"/admin/tenants/{id}/warmup-schedule/dispatch-now":{"post":{"description":"Fires the scheduled send for today immediately instead of waiting for the dispatch cron. Idempotent — re-dispatching a row that's already 'sent' is a no-op.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"day_offset":{"type":"integer"},"send_status":{"type":"string"},"triggered":{"type":"boolean"}},"type":"object"}}},"description":"OK"}},"summary":"Dispatch today's warmup-schedule row out of band (super-admin only)"}},"/admin/tenants/{id}/warmup-schedule/generate":{"post":{"description":"strategy ∈ {standard, aggressive, gentle} chooses the ramp curve. total_target_recipients is capped at 10,000,000. Overwrites any existing plan in tenant_warmup_schedule.","requestBody":{"content":{"application/json":{"schema":{"properties":{"domain_id":{"type":"string"},"strategy":{"type":"string"},"total_target_recipients":{"type":"integer"}},"required":["strategy","total_target_recipients"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Generate a fresh 30-row warmup plan for a tenant (super-admin only)"}},"/admin/tenants/{id}/warmup-schedule/{day_offset}":{"patch":{"description":"day_offset is 0..29. daily_target_recipients/template_slug/notes/send_status are all optional; only fields present in the body change.","requestBody":{"content":{"application/json":{"schema":{"properties":{"daily_target_recipients":{"type":"integer"},"notes":{"type":"string"},"send_status":{"type":"string"},"template_slug":{"type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Mutate one day in the warmup plan (super-admin only)"}},"/admin/tenants/{id}/warmup/reconciliation":{"get":{"description":"Surfaces per_domain_limit, per_esp_limit (or null when the ramp worker hasn't seeded a row yet), effective_cap = min(both), 24h sent count, and would_have_throttled_count for each pair so an operator can preview the impact of flipping WARMUP_PER_ESP_ENFORCE before turning it on.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"},"tenant_id":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Per-(sending_domain, ESP) warmup cap reconciliation report (super-admin only)"}},"/admin/warmup/auto-ramp/run-now":{"post":{"description":"Honours the per-(tenant, domain, day) idempotency gate in warmup_decisions — a same-day re-run is a no-op rather than a double bump.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"elapsed_ms":{"type":"integer"},"triggered":{"type":"boolean"}},"type":"object"}}},"description":"OK"}},"summary":"Walk every verified sending domain and apply the daily ramp logic now (super-admin only)"}},"/admin/workers":{"get":{"description":"One JSON row per registered long-running worker: name, last_tick_at, last_tick_outcome, last_tick_duration_ms, next_tick_estimate, env_gate_active, last_alert_at, last_alert_event. Sorted by name so refresh order is stable.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Per-worker status feed for the observability dashboard (super-admin only)"}},"/analytics/cohort-retention":{"get":{"description":"Of contacts acquired in week N, what % were still engaged in week N+1, N+2, ... Reads W184 denormalised columns (last_open_at / last_click_at / last_send_at) so no event-table join. Cached in-process for 1 hour per (tenant, metric, weeks). Query params: cohort=acquisition_week (default; only value supported in v1); metric=opened|clicked|sent (default opened); weeks=4..12 (default 8). Future cells in the matrix are returned as null so the heatmap can render blank instead of 0%.","parameters":[{"in":"query","name":"cohort","required":false,"schema":{"enum":["acquisition_week"],"type":"string"}},{"in":"query","name":"metric","required":false,"schema":{"enum":["opened","clicked","sent"],"type":"string"}},{"in":"query","name":"weeks","required":false,"schema":{"maximum":12,"minimum":4,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"cohort":{"type":"string"},"cohorts":{"type":"array"},"max_weeks":{"type":"integer"},"metric":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Acquisition-week cohort retention matrix (opened / clicked / sent per offset week)."}},"/analytics/esp-scores":{"get":{"description":"Populated by the esp_score_worker. Sort: score DESC with 'other' pinned to the bottom. window_end is the date the most-recent rollup was computed against.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"},"window_end":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Per-ESP rolling delivery score table (gmail/outlook/yahoo/aol/icloud/proton/fastmail/other)"}},"/analytics/esp-suppressions":{"get":{"description":"An 'active' row is unrevoked AND expires_at is still in the future — same predicate the dispatcher uses. Items are ordered triggered_at DESC.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"},"total":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"Active + recent (last 7d) auto-suppressions per ESP"}},"/analytics/rollup":{"get":{"description":"Reads analytics_rollups_hourly, populated every 5 minutes by the rollup worker. Cheaper than /analytics/overview at 1M+ events. Query params: since=\u003cRFC3339\u003e (default now-30d), until=\u003cRFC3339\u003e (default now), granularity=hour|day (default day). Range capped at 90 days.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"buckets":{"type":"array"},"granularity":{"type":"string"},"since":{"type":"string"},"until":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Pre-computed dashboard rollups (sent / bounced / opened / clicked / complained per hour or day bucket)."}},"/analytics/trust-score":{"get":{"description":"Rolls up DNS verification, bounce rate, engagement, complaint rate, warmup maturity, per-ESP score coverage, and DNS drift into one number plus per-factor breakdown. Tier in {excellent, good, caution, at-risk}.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"factors":{"type":"array"},"score":{"type":"integer"},"tier":{"type":"string"},"updated_at":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Composite 0-100 trust score for the calling tenant"}},"/api-keys":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List API keys (includes scopes per key; empty scopes = all access)"},"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"name":{"type":"string"},"scopes":{"description":"Optional list of scope strings. Empty or omitted = all scopes granted (back-compat). Valid values: campaigns:read, campaigns:write, contacts:read, contacts:write, lists:read, lists:write, templates:read, templates:write, domains:read, domains:write, suppressions:write, events:read, reputation:read.","items":{"type":"string"},"type":"array"}},"required":["name"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"key":{"type":"string"},"name":{"type":"string"},"prefix":{"type":"string"},"scopes":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Create an API key (plaintext returned once)"}},"/api-keys/usage":{"get":{"description":"Returns a 7-day call total + day-by-day series per key, plus last_used metadata. Counts come from api_key_calls_daily (populated by the usage writer). ?days=N (1..30, default 7).","parameters":[{"description":"Window in days (1..30, default 7)","in":"query","name":"days","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"days":{"type":"integer"},"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Per-tenant API key usage analytics (one item per non-revoked key)"}},"/api-keys/{keyID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Revoke an API key"}},"/api-keys/{keyID}/activity":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"calls_per_day":{"type":"array"},"p95_latency_ms":{"type":"integer"},"status_codes":{"type":"object"},"suspicious":{"type":"object"},"top_endpoints":{"type":"array"}},"type":"object"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Per-API-key activity analytics: 30-day calls/day, top 10 endpoints, status-code histogram, p95 latency, suspicious-activity flags"}},"/api-keys/{keyID}/rate-limit-status":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"key_id":{"type":"string"},"key_name":{"type":"string"},"requests_last_hour":{"type":"integer"},"requests_last_minute":{"type":"integer"},"requests_today":{"type":"integer"}},"type":"object"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Current-window request counts for an API key (last minute, hour, and today)"}},"/api-keys/{keyID}/scopes":{"patch":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"scopes":{"description":"New scope list. Empty array resets to full access (back-compat). Valid values: campaigns:read, campaigns:write, contacts:read, contacts:write, lists:read, lists:write, templates:read, templates:write, domains:read, domains:write, suppressions:write, events:read, reputation:read.","items":{"type":"string"},"type":"array"}},"required":["scopes"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"scopes":{"type":"array"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Update scopes on an existing API key without revoke + reissue"}},"/audit":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"type":"array"},"limit":{"type":"integer"},"offset":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"Recent campaign send audit log"}},"/audit-events.csv":{"get":{"parameters":[{"in":"query","name":"from","schema":{"format":"date","type":"string"}},{"in":"query","name":"to","schema":{"format":"date","type":"string"}}],"responses":{"200":{"description":"CSV stream"}},"summary":"Stream audit events as CSV (default: last 30 days)"}},"/audit-events/export.pdf":{"get":{"description":"Owner-only; requires audit:export scope when called via API key. Returns application/pdf with a sha256 digest in the footer of every page over the canonical concatenation of all rows. Capped at 10000 rows; narrow your filter if you hit a 400.","parameters":[{"in":"query","name":"since","schema":{"format":"date-time","type":"string"}},{"in":"query","name":"until","schema":{"format":"date-time","type":"string"}},{"in":"query","name":"action","schema":{"type":"string"}},{"in":"query","name":"action_prefix","schema":{"type":"string"}},{"in":"query","name":"resource_type","schema":{"type":"string"}},{"in":"query","name":"resource_id","schema":{"type":"string"}},{"in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"description":"PDF document"},"400":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"},"limit":{"type":"integer"},"row_count":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"Export audit events as a signed PDF (SOC2 evidence)"}},"/audit-filters":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List the calling user's saved audit-log filters"},"post":{"description":"definition is a JSONB blob whose shape mirrors the audit page's URL state (q, range, event_type, actor, date_from, date_to). Capped at 4KB per row + 50 rows per user.","requestBody":{"content":{"application/json":{"schema":{"properties":{"definition":{"type":"object"},"name":{"type":"string"}},"required":["name","definition"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Save a named audit-log filter for the calling user"}},"/audit-filters/{id}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Delete a saved audit-log filter (owner of the row only)"}},"/auth/2fa/challenge":{"post":{"description":"Public. Trades a totp_session_token + 6-digit TOTP code (or single-use recovery code) for the full JWT.","requestBody":{"content":{"application/json":{"schema":{"properties":{"code":{"type":"string"},"totp_session_token":{"type":"string"}},"required":["totp_session_token","code"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"expires_at":{"type":"integer"},"tenant_id":{"type":"string"},"token":{"type":"string"},"user_id":{"type":"string"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"security":[],"summary":"Complete TOTP 2FA login"}},"/auth/2fa/disable":{"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"password":{"type":"string"}},"required":["password"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Disable 2FA after re-verifying the user's password"}},"/auth/2fa/enroll":{"post":{"description":"Stores the secret immediately but does not flip totp_enabled_at — call /auth/2fa/verify with a code from the authenticator app to finalize enrollment.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"otpauth_url":{"type":"string"},"qr_code_dataurl":{"type":"string"},"secret":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Generate a fresh TOTP secret + QR code"}},"/auth/2fa/status":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"enabled":{"type":"boolean"},"enabled_at":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Whether 2FA is enabled for the calling user"}},"/auth/2fa/verify":{"post":{"description":"On success: sets totp_enabled_at, generates 8 single-use bcrypt-hashed recovery codes, and returns the plaintext codes (one-time only).","requestBody":{"content":{"application/json":{"schema":{"properties":{"code":{"type":"string"}},"required":["code"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"enabled_at":{"type":"string"},"recovery_codes":{"type":"array"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Confirm enrollment by submitting a TOTP code"}},"/auth/accept-invite":{"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"first_name":{"type":"string"},"password":{"type":"string"},"token":{"type":"string"}},"required":["token","password"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"expires_at":{"type":"integer"},"tenant_id":{"type":"string"},"token":{"type":"string"},"user_id":{"type":"string"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"},"410":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"security":[],"summary":"Public — claim a pending invite and create your user under the inviting tenant"}},"/auth/login":{"post":{"description":"When the user has TOTP 2FA enabled, this returns {requires_totp:true, totp_session_token:'...'} instead of the JWT — POST that token + a 6-digit code (or recovery code) to /auth/2fa/challenge to complete the login.","requestBody":{"content":{"application/json":{"schema":{"properties":{"email":{"type":"string"},"password":{"type":"string"}},"required":["email","password"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"expires_at":{"type":"integer"},"requires_totp":{"type":"boolean"},"tenant_id":{"type":"string"},"token":{"type":"string"},"totp_session_token":{"type":"string"},"user_id":{"type":"string"}},"type":"object"}}},"description":"OK"}},"security":[],"summary":"Login and receive a JWT"}},"/auth/mail/invite/consume":{"post":{"description":"Trades a single-use invite token + new password for a 24h JWT scoped to the inviting tenant + the mailbox the invitee was added to. Subsequent calls with the same token return 410.","requestBody":{"content":{"application/json":{"schema":{"properties":{"new_password":{"type":"string"},"token":{"type":"string"}},"required":["token","new_password"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"address":{"type":"string"},"expires_at":{"type":"integer"},"mailbox_id":{"type":"string"},"tenant_id":{"type":"string"},"token":{"type":"string"},"user_id":{"type":"string"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"},"410":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"security":[],"summary":"Public — claim a mailbox invite and create your user under the inviting tenant"}},"/auth/password-reset/confirm":{"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"new_password":{"type":"string"},"token":{"type":"string"}},"required":["token","new_password"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"security":[],"summary":"Public — finish a password-reset flow with the single-use token mailed to the user"}},"/auth/password-reset/request":{"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"email":{"type":"string"}},"required":["email"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"security":[],"summary":"Public — start a password-reset flow (always returns 200 to avoid leaking which emails belong to a real account)"}},"/auth/register":{"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"email":{"type":"string"},"password":{"type":"string"},"tenant_name":{"type":"string"},"tenant_slug":{"type":"string"}},"required":["tenant_name","tenant_slug","email","password"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"properties":{"expires_at":{"type":"integer"},"tenant_id":{"type":"string"},"token":{"type":"string"},"user_id":{"type":"string"}},"type":"object"}}},"description":"OK"}},"security":[],"summary":"Register a new tenant + user"}},"/bounce-mailboxes":{"get":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List configured POP3 bounce mailboxes (lite version)"}},"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"delete_after_read":{"type":"boolean"},"enabled":{"type":"boolean"},"host":{"type":"string"},"name":{"type":"string"},"password":{"type":"string"},"poll_interval_s":{"type":"integer"},"port":{"type":"integer"},"use_tls_implicit":{"type":"boolean"},"username":{"type":"string"}},"required":["name","host","port","username","password"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Create a bounce mailbox to poll"}},"/bounce-mailboxes/{mailboxID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Delete a bounce mailbox configuration"}},"/bounce-mailboxes/{mailboxID}/verify":{"post":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"},"ok":{"type":"boolean"}},"type":"object"}}},"description":"OK"}},"summary":"Test the POP3 mailbox credentials (real dial + AUTH)"}},"/campaigns":{"get":{"description":"?q= filters by case-insensitive substring match on name OR subject (TICKET-224). Empty / whitespace-only q returns the unfiltered list.","parameters":[{"description":"Substring match on campaign name OR subject","in":"query","name":"q","required":false,"schema":{"type":"string"}},{"in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"in":"query","name":"offset","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List campaigns (newest first)"},"post":{"description":"Target EXACTLY one of list_id or segment_id. subject, preheader_text, body_html, and body_text support Liquid template syntax: {{first_name | default: \"there\"}}, {% if country == \"US\" %}...{% endif %}, {{contact.email}}, {{custom_fields.account_id}}. utm_tagging (TICKET-223) appends utm_source/utm_medium/utm_campaign to body_html links at send-time. send_time_optimization (TICKET-212) is a config-only toggle today; per-contact best-send-hour scheduling lands in a follow-up.","requestBody":{"content":{"application/json":{"schema":{"properties":{"ab_split_pct":{"type":"integer"},"body_html":{"type":"string"},"body_text":{"type":"string"},"list_id":{"type":"string"},"name":{"type":"string"},"preheader_text":{"type":"string"},"segment_id":{"type":"string"},"send_time_optimization":{"type":"boolean"},"subject":{"type":"string"},"subject_b":{"type":"string"},"template_id":{"type":"string"},"utm_medium":{"type":"string"},"utm_source":{"type":"string"},"utm_tagging":{"type":"boolean"}},"required":["name"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Create campaign"}},"/campaigns/stats-batch":{"get":{"description":"Returns sent count, unique opens, unique clicks, unsubscribes, total bounces, complaints, and their corresponding rates for each campaign ID passed in the comma-separated `ids` query parameter. Campaigns with zero events appear in the response with all-zero counts so the caller can distinguish 'no events yet' from 'unknown id' (the latter just doesn't appear in the response). Max 500 ids per call.","parameters":[{"description":"Comma-separated campaign IDs (1..500)","in":"query","name":"ids","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Batch per-campaign metrics (lightweight) for many campaigns at once"}},"/campaigns/{campaignID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Delete a campaign (only valid while status='draft')"},"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"name":{"type":"string"},"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Get a campaign"},"patch":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"body_html":{"type":"string"},"body_text":{"type":"string"},"list_id":{"type":"string"},"name":{"type":"string"},"preheader_text":{"type":"string"},"segment_id":{"type":"string"},"subject":{"type":"string"},"template_id":{"type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Update campaign fields (subject, body, target, schedule etc.)"}},"/campaigns/{campaignID}/ab-results":{"get":{"description":"Returns variant-level sent/open/click counts and computed rates. Returns {has_ab_test:false} when the campaign has no A/B split configured.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"campaign_id":{"type":"string"},"has_ab_test":{"type":"boolean"},"recommendation":{"type":"string"},"variant_a":{"type":"object"},"variant_b":{"type":"object"},"winner":{"type":"string"},"winner_declared_at":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"A/B test result stats for a campaign"}},"/campaigns/{campaignID}/ab/finalize":{"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"winner":{"type":"string"}},"required":["winner"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Pick the winning variant ('a' or 'b') for an A/B subject test"}},"/campaigns/{campaignID}/click-heatmap":{"get":{"description":"Top 100 URLs ordered by click count desc. Each entry includes raw clicks + pct_of_total computed across the returned set.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"by_url":{"type":"array"},"campaign_id":{"type":"string"},"total_clicks":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"Click distribution by destination URL for a campaign"}},"/campaigns/{campaignID}/duplicate":{"post":{"responses":{"201":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"name":{"type":"string"},"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Duplicate a campaign as a new draft (copies subject, body, target, preheader)"}},"/campaigns/{campaignID}/engagement.csv":{"get":{"description":"Recipient-by-recipient timeline. Requires both campaigns:read and audit:export scopes (PII-heavy export). Time window capped at 90 days unless ?since=\u003cRFC3339\u003e narrows it further. Columns: time,event_type,contact_email,contact_id,sequence_step,user_agent,ip_truncated,bounce_subtype.","parameters":[{"in":"query","name":"since","required":false,"schema":{"format":"date-time","type":"string"}}],"responses":{"200":{"content":{"text/csv":{"schema":{"type":"string"}}},"description":"CSV download"}},"summary":"Stream every send/open/click/bounce/complaint/unsub event for one campaign as CSV"}},"/campaigns/{campaignID}/events":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Recent events for a campaign"}},"/campaigns/{campaignID}/goal-conversions":{"get":{"description":"Returns total_conversions, unique_converters, revenue_cents across all click events whose URL matched a tenant goal (W195), plus a per-goal breakdown sorted by revenue descending. A goal deleted after the click still appears in by_goal with goal_id=null and goal_name='(deleted goal)'.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"by_goal":{"type":"array"},"campaign_id":{"type":"string"},"revenue_cents":{"type":"integer"},"total_conversions":{"type":"integer"},"unique_converters":{"type":"integer"}},"type":"object"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Conversion + revenue rollup for a campaign"}},"/campaigns/{campaignID}/md5-list":{"patch":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"list_id":{"type":"string|null"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Attach (or clear) an MD5 suppression list for the campaign"}},"/campaigns/{campaignID}/schedule":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Cancel a scheduled send (only valid while status='scheduled'; returns the campaign to 'draft')"},"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"scheduled_at":{"type":"string"}},"required":["scheduled_at"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Schedule campaign send for a future timestamp"}},"/campaigns/{campaignID}/send":{"post":{"parameters":[{"in":"header","name":"Idempotency-Key","required":false,"schema":{"type":"string"}}],"responses":{"202":{"content":{"application/json":{"schema":{"properties":{"campaign_id":{"type":"string"},"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Trigger campaign send (idempotent via Idempotency-Key header)"}},"/campaigns/{campaignID}/stats":{"get":{"description":"Returns sent/delivered counts, unique + total opens and clicks, unsubscribes, hard + soft bounces, complaints, computed rate metrics (open rate, click rate, CTOR, bounce rate), and an hourly engagement trend for the last 72 hours. Campaign must belong to the caller's tenant.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"bounce_rate":{"type":"number"},"bounces":{"type":"integer"},"campaign_id":{"type":"string"},"campaign_name":{"type":"string"},"click_rate":{"type":"number"},"click_to_open_rate":{"type":"number"},"complaints":{"type":"integer"},"countries":{"type":"array"},"delivered":{"type":"integer"},"hard_bounces":{"type":"integer"},"hourly_trend":{"type":"array"},"open_rate":{"type":"number"},"sent":{"type":"integer"},"soft_bounces":{"type":"integer"},"status":{"type":"string"},"total_clicks":{"type":"integer"},"total_opens":{"type":"integer"},"unique_clicks":{"type":"integer"},"unique_opens":{"type":"integer"},"unsubscribe_rate":{"type":"number"},"unsubscribes":{"type":"integer"}},"type":"object"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Comprehensive per-campaign statistics"}},"/contact-custom-fields":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List the tenant's custom field catalog (top-level alias)"},"post":{"description":"key must match ^[a-z][a-z0-9_]{0,49}$ and must NOT be a reserved first-class column (email, first_name, last_name, phone, company, city, state, country, postal_code, title, timezone, mobile_phone, email_format, confirmed_status, mobile_status, crm_id, crm_contact_id, crm_lead_id, status, id, tenant_id, created_at, updated_at, custom_fields). type ∈ text|longtext|number|date|datetime|boolean|select|email|url|phone. options is required and non-empty when type=select.","requestBody":{"content":{"application/json":{"schema":{"properties":{"key":{"type":"string"},"label":{"type":"string"},"options":{"type":"array\u003cstring\u003e"},"required":{"type":"boolean"},"type":{"type":"string"}},"required":["key","label","type"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Define a new custom field on the catalog"}},"/contact-custom-fields/{fieldID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Remove a custom field from the catalog (does NOT touch values stored on existing contacts)"},"put":{"description":"key and type are immutable. Deleting a field does NOT strip stored values from existing contacts — they become orphaned and ignored.","requestBody":{"content":{"application/json":{"schema":{"properties":{"label":{"type":"string"},"options":{"type":"array\u003cstring\u003e"},"position":{"type":"integer"},"required":{"type":"boolean"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Update label/options/required/position on a catalog entry"}},"/contacts":{"get":{"description":"?q= filters by email substring (lowercase). ?status= one of active|unsubscribed|deleted|all (default = active+unsubscribed minus deleted+unsubscribed). Each item carries best_send_hour (TICKET-212): nullable hour-of-day (0..23, UTC) the contact has historically opened most. Read-only; populated by a background cron.","parameters":[{"description":"Lowercase substring match on email","in":"query","name":"q","required":false,"schema":{"type":"string"}},{"description":"active | unsubscribed | deleted | all","in":"query","name":"status","required":false,"schema":{"type":"string"}},{"in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"in":"query","name":"offset","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List contacts (default excludes deleted + unsubscribed)"},"post":{"description":"All default+mobile+CRM fields are optional. email_format ∈ {html,text}. confirmed_status, mobile_status ∈ {confirmed,unconfirmed} (default unconfirmed). country is ISO-2 (auto-uppercased). subscriber_timezone is an IANA tz string (e.g. America/Los_Angeles). custom_fields stays as a JSONB blob for tenant-defined fields.","requestBody":{"content":{"application/json":{"schema":{"properties":{"city":{"type":"string"},"company":{"type":"string"},"confirmed_status":{"type":"string"},"country":{"type":"string"},"crm_contact_id":{"type":"string"},"crm_id":{"type":"string"},"crm_lead_id":{"type":"string"},"custom_fields":{"type":"object"},"email":{"type":"string"},"email_format":{"type":"string"},"first_name":{"type":"string"},"last_name":{"type":"string"},"mobile_phone":{"type":"string"},"mobile_status":{"type":"string"},"phone":{"type":"string"},"state":{"type":"string"},"subscriber_timezone":{"type":"string"},"title":{"type":"string"}},"required":["email"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Create a contact"}},"/contacts/bulk-remove":{"post":{"description":"Sets status='deleted' and removes the contacts from every list. Idempotent — already-deleted ids count as 'missing'. Cap is 1000 per request.","requestBody":{"content":{"application/json":{"schema":{"properties":{"contact_ids":{"type":"array\u003cstring\u003e"}},"required":["contact_ids"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Soft-delete up to 1000 contacts in one call"}},"/contacts/custom-fields":{"get":{"description":"Catalog declares the SHAPE of contacts.custom_fields entries so the UI can render the right input + validate. Values continue to live on the contact record.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List the tenant's custom field catalog (sorted by position)"},"post":{"description":"key must match ^[a-z][a-z0-9_]{0,49}$ (snake_case). type ∈ text|number|date|datetime|boolean|select|email|url|phone. options is required and non-empty when type=select. position is auto-assigned to MAX+1.","requestBody":{"content":{"application/json":{"schema":{"properties":{"key":{"type":"string"},"label":{"type":"string"},"options":{"type":"array\u003cstring\u003e"},"required":{"type":"boolean"},"type":{"type":"string"}},"required":["key","label","type"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Define a new custom field on the catalog"}},"/contacts/custom-fields/reorder":{"post":{"description":"Each id's position is set to its index in the array (0..N). All ids must belong to the calling tenant — unknown ids return 400 and the transaction is rolled back.","requestBody":{"content":{"application/json":{"schema":{"properties":{"ids":{"type":"array\u003cstring\u003e"}},"required":["ids"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Reorder the catalog by sending the full ordered id list"}},"/contacts/custom-fields/{id}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Remove a custom field from the catalog (does NOT touch values stored on existing contacts — they become orphaned)"},"put":{"description":"All fields are optional. Changing type to 'select' requires non-empty options. Deleting a field does NOT strip stored values from existing contacts — they become orphaned and ignored.","requestBody":{"content":{"application/json":{"schema":{"properties":{"label":{"type":"string"},"options":{"type":"array\u003cstring\u003e"},"position":{"type":"integer"},"required":{"type":"boolean"},"type":{"type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Update label/type/options/required/position on a catalog entry"}},"/contacts/duplicates":{"get":{"description":"Returns up to 50 groups of non-deleted contacts that share the same email_lower within this tenant. Each group includes slim contact rows (id, email, first_name, status, created_at) so the UI can present a merge picker. Groups are ordered by duplicate count DESC.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"groups":{"type":"array\u003c{email, count, contacts: array\u003c{id,email,first_name,status,created_at}\u003e}\u003e"},"total_groups":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"Find duplicate contacts (same email, different records)"}},"/contacts/export":{"get":{"description":"Columns: id,email,status,created_at,custom_fields_json. Honours the same ?status= filter as /contacts. Response is text/csv with Content-Disposition: attachment.","parameters":[{"description":"active | unsubscribed | deleted | all","in":"query","name":"status","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"text/csv":{"schema":{"type":"string"}}},"description":"CSV download"}},"summary":"Stream the tenant's contacts as CSV"}},"/contacts/import":{"post":{"description":"Each row accepts the same default+mobile+CRM fields as POST /contacts.","requestBody":{"content":{"application/json":{"schema":{"properties":{"_array":{"type":"array of {email, custom_fields, first_name, last_name, phone, company, city, state, country, title, subscriber_timezone, email_format, confirmed_status, mobile_status, mobile_phone, crm_id, crm_contact_id, crm_lead_id}"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Bulk import contacts"}},"/contacts/{contactID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Delete a contact"},"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Get a contact"},"put":{"description":"Same field set as POST /contacts. Updating mobile_status auto-sets mobile_status_changed_at. Audit log emits contact.update with changed_fields.","requestBody":{"content":{"application/json":{"schema":{"properties":{"city":{"type":"string"},"company":{"type":"string"},"confirmed_status":{"type":"string"},"country":{"type":"string"},"crm_contact_id":{"type":"string"},"crm_id":{"type":"string"},"crm_lead_id":{"type":"string"},"custom_fields":{"type":"object"},"email":{"type":"string"},"email_format":{"type":"string"},"first_name":{"type":"string"},"last_name":{"type":"string"},"mobile_phone":{"type":"string"},"mobile_status":{"type":"string"},"phone":{"type":"string"},"state":{"type":"string"},"subscriber_timezone":{"type":"string"},"title":{"type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Update a contact (partial; only fields present in the body change)"}},"/contacts/{contactID}/activity":{"get":{"description":"Drives the contact detail page. Events are ordered DESC by occurred_at and include event_type (sent|open|click|bounce|unsub), campaign_id, campaign_name, and a non-null url for clicks. totals are aggregate counts per event type. lists is the set of list memberships for the contact.","parameters":[{"description":"Default 100, max 500","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"in":"query","name":"offset","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"contact_id":{"type":"string"},"events":{"type":"array"},"limit":{"type":"integer"},"lists":{"type":"array"},"offset":{"type":"integer"},"totals":{"type":"object"}},"type":"object"}}},"description":"OK"}},"summary":"Per-contact event timeline + list memberships + aggregate totals"}},"/contacts/{contactID}/erase":{"post":{"description":"Irreversible. Requires Owner role. Body must include confirm_email matching the contact's stored email exactly (case-insensitive, post-trim) as a typed-confirmation guard. On success: deletes events (events.contact_id is ON DELETE SET NULL so we explicitly DELETE), deletes email-scope suppression rows, deletes the contact (list_contacts cascades), and inserts an append-only row in gdpr_erasures with SHA-256(lower(email)) as proof-of-deletion. Returns 200 + audit_id + email_hash. 400 when confirm_email mismatches; 404 cross-tenant or unknown id.","requestBody":{"content":{"application/json":{"schema":{"properties":{"confirm_email":{"type":"string"},"reason":{"type":"string"}},"required":["confirm_email"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"GDPR Article 17 erasure — HARD DELETE of the contact, its events, and email-scope suppressions"}},"/contacts/{contactID}/export":{"post":{"description":"Returns application/zip containing contact.json (profile + custom_fields), events.csv (every event for this contact, oldest first), subscriptions.csv (current list memberships), and suppressions.csv (email-scope suppression rows for this contact's email). Rate-limited 1/min per (tenant, contact_id) to prevent scraping. Emits an audit_events row tagged gdpr.export (with SHA-256 email hash only — no plaintext PII in the audit metadata).","responses":{"200":{"content":{"application/zip":{"schema":{"format":"binary","type":"string"}}},"description":"ZIP file"},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"},"429":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"GDPR Article 15 export — ZIP of everything we hold about a contact"}},"/contacts/{contactID}/master-unsubscribe":{"post":{"description":"One-shot: status -\u003e 'unsubscribed', removed from list_contacts, suppression added (scope=email, reason=master-unsubscribe), and a global 'unsub' event with NULL campaign_id is recorded. Idempotent.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"email":{"type":"string"},"id":{"type":"string"},"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Master unsubscribe — opts the contact out of every list and adds an email-scope suppression"}},"/contacts/{contactID}/merge":{"post":{"description":"Merges the source contact (URL param) into the target (merge_into body field). All list memberships and events from source are re-assigned to target. The source is then soft-deleted. Runs in a single transaction. Returns kept_id and deleted_id.","requestBody":{"content":{"application/json":{"schema":{"properties":{"merge_into":{"type":"string"}},"required":["merge_into"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Merge source contact into target contact"}},"/dashboard/stream":{"get":{"description":"Long-lived connection that emits an `event: refresh` line when new rows land in the tenant's events table, otherwise a `: ping` comment every 25s. EventSource doesn't carry the Authorization header, so callers may pass ?access_token=\u003cjwt\u003e; the PromoteSSEAccessTokenToBearer middleware promotes it before auth runs.","parameters":[{"description":"JWT (alternative to Authorization header for EventSource clients)","in":"query","name":"access_token","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"text/event-stream"}},"summary":"Server-Sent Events stream for dashboard auto-refresh (text/event-stream)"}},"/deliverability/recommendations":{"get":{"description":"Rules cover verified-domain count, DNS pending age, SMTP relay failures, 7-day bounce/complaint rates, DKIM rotation age, SMTP circuit breaker state, outbound webhook health, bounce mailbox absence, warmup absence, and contact list size. Sorted high → medium → low.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"computed_at":{"type":"string"},"count":{"type":"integer"},"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Computed list of deliverability recommendations for the tenant (capped at 8)"}},"/deliverability/rfc8058-status":{"get":{"description":"Returns whether the engine would emit a Gmail-bulk-sender-compliant `List-Unsubscribe` + `List-Unsubscribe-Post` header pair for this tenant. Also returns per-check detail (https URL, mailto address, HMAC secret presence) so the dashboard can render a red/amber/green badge with actionable detail.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"checks":{"type":"array"},"compliant":{"type":"boolean"},"docs_link":{"type":"string"},"post_endpoint":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"RFC 8058 one-click List-Unsubscribe compliance status for the tenant"}},"/delivery-servers":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"},"supported_types":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List the tenant's delivery servers (secrets are stripped; has_\u003cfield\u003e bools are exposed instead). Wave 24 — schema/CRUD only; the MTA engine does NOT consult these rows yet."},"post":{"description":"type must be one of smtp|ses|sendgrid|mailgun|sparkpost. config is a per-type JSON blob; secret keys (password for SMTP, api_key/secret_access_key for cloud APIs) are AES-GCM encrypted on write and never echoed.","requestBody":{"content":{"application/json":{"schema":{"properties":{"config":{"type":"object"},"daily_cap":{"type":"integer|null"},"hourly_cap":{"type":"integer|null"},"monthly_cap":{"type":"integer|null"},"name":{"type":"string"},"status":{"type":"string"},"type":{"type":"string"},"weight":{"type":"integer"}},"required":["name","type"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Create a delivery server"}},"/delivery-servers/routes":{"get":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List routing rules ordered by priority desc, created asc"}},"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"priority":{"type":"integer"},"recipient_glob":{"type":"string"},"server_id":{"type":"string"}},"required":["recipient_glob","server_id"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Create a routing rule. recipient_glob supports leading '*' suffix patterns ('*@gmail.com', '*.edu') or '*' as catch-all. server_id must belong to your tenant."}},"/delivery-servers/routes/{routeID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Delete a routing rule"}},"/delivery-servers/{serverID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Delete a delivery server (cascades routes + counter buckets)"},"patch":{"description":"Setting status away from 'error' also clears last_error_at + last_error_msg.","requestBody":{"content":{"application/json":{"schema":{"properties":{"config":{"type":"object"},"daily_cap":{"type":"integer|null"},"hourly_cap":{"type":"integer|null"},"monthly_cap":{"type":"integer|null"},"name":{"type":"string"},"status":{"type":"string"},"weight":{"type":"integer"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Partial update; type is immutable; secret config fields with empty/missing values keep the existing ciphertext (same rule as wave 12/17 SMTP creds)"}},"/dmarc/reports":{"get":{"description":"Tenant-scoped DMARC reports rollup over the last N days (default 30). Pass-rate counts a record as 'pass' when EITHER SPF OR DKIM is aligned (RFC 7489 §6.6). by_source_ip returns the top 10 IPs by volume with their alignment outcome. Result is cached in-process for 1h per (tenant, range_days); the X-Cache header is HIT or MISS.","parameters":[{"description":"Window expressed as days, optional 'd' suffix (e.g. 30 or 30d). 1..365, default 30.","in":"query","name":"range","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"by_sending_domain":{"type":"array"},"by_source_ip":{"type":"array"},"range_days":{"type":"integer"},"summary":{"type":"object"},"window_end":{"type":"string"},"window_start":{"type":"string"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"DMARC aggregate-report dashboard (top-line + by-source-IP + by-sending-domain)"}},"/domains":{"get":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List sending domains"}},"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"domain":{"type":"string"}},"required":["domain"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Add a sending domain"}},"/domains/bulk":{"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"domains":{"type":"array\u003cstring\u003e"}},"required":["domains"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Bulk-add up to 50 domains"}},"/domains/{domainID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Delete a sending domain"}},"/domains/{domainID}/bimi-advisor":{"get":{"description":"Read-only — performs a DNS TXT lookup on default._bimi.\u003cdomain\u003e. Returns parsed BIMI tags + recommendations on logo URL / VMC.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"domain":{"type":"string"},"domain_id":{"type":"string"},"recommendations":{"type":"array"},"record":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Live BIMI record advisor for a sending domain (Wave 89 / TICKET-414)"}},"/domains/{domainID}/cap":{"patch":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"daily_cap":{"type":"integer|null"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Set per-domain daily send cap override"}},"/domains/{domainID}/dmarc-advisor":{"get":{"description":"Read-only — performs a DNS TXT lookup on _dmarc.\u003cdomain\u003e on every call (no DB writes). Returns parsed DMARC tags + recommendations (e.g. suggest p=reject when none/quarantine is observed).","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"domain":{"type":"string"},"domain_id":{"type":"string"},"policy":{"type":"string"},"recommendations":{"type":"array"},"record":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Live DMARC record advisor for a sending domain (Wave 89 / TICKET-411)"}},"/domains/{domainID}/dns-records":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"DNS records to publish for a domain"}},"/domains/{domainID}/reputation":{"get":{"description":"Aggregates events into 7d/30d/90d windows (sent/opens/clicks/bounces/unsubs + percentage rates), the latest DNSBL checks for the box's IPs, and DMARC pass/fail with the top-5 failing source IPs over 90d. DNSBL is tenant-wide because ip_blacklist_checks has no IP→domain link at probe time. Events attribution requires sending_domain on the row (post-migration 000025).","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"dmarc":{"type":"object"},"dnsbl":{"type":"object"},"domain":{"type":"string"},"domain_id":{"type":"string"},"last_verified_at":{"type":"string|null"},"windows":{"type":"object"}},"type":"object"}}},"description":"OK"}},"summary":"Per-domain reputation drilldown (7d / 30d / 90d + DNSBL + DMARC)"}},"/domains/{domainID}/reputation/trend":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"days":{"type":"array"},"domain":{"type":"string"},"domain_id":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Daily reputation trend for the per-domain drill-down sparkline (last 30 days: sent / opens / clicks / bounces / unsubs per day)"}},"/domains/{domainID}/smtp":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Clear the per-domain SMTP relay (revert to direct MX delivery)"},"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"has_password":{"type":"boolean"},"host":{"type":"string"},"last_verified_at":{"type":"string|null"},"last_verify_error":{"type":"string|null"},"port":{"type":"integer"},"use_starttls":{"type":"boolean"},"use_tls_implicit":{"type":"boolean"},"username":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Get the per-domain SMTP relay (404 when none configured)"},"put":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"host":{"type":"string"},"password":{"type":"string"},"port":{"type":"integer"},"use_starttls":{"type":"boolean"},"use_tls_implicit":{"type":"boolean"},"username":{"type":"string"}},"required":["host","port","username","password"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Configure or replace the per-domain SMTP relay; password is encrypted before storage"}},"/domains/{domainID}/smtp/analytics":{"get":{"description":"Aggregate sends/successes/failures over a sliding ?days= window plus a per-day series. Includes the current circuit-breaker state for the relay.","parameters":[{"description":"Window in days (1..90, default 7)","in":"query","name":"days","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"circuit_opened_at":{"type":"string|null"},"circuit_state":{"type":"string"},"consecutive_failures":{"type":"integer"},"daily":{"type":"array"},"days":{"type":"integer"},"failures":{"type":"integer"},"success_rate":{"type":"number"},"successes":{"type":"integer"},"total_sends":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"Per-domain relay analytics (Wave 99 / TICKET-624)"}},"/domains/{domainID}/smtp/verify":{"post":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"},"ok":{"type":"boolean"}},"type":"object"}}},"description":"OK"}},"summary":"Test the configured relay (real EHLO + STARTTLS + AUTH)"}},"/domains/{domainID}/spf-advisor":{"get":{"description":"Read-only — performs a DNS TXT lookup on the domain's SPF record. Returns parsed mechanisms + recommendations (e.g. flag overly-permissive +all).","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"domain":{"type":"string"},"domain_id":{"type":"string"},"recommendations":{"type":"array"},"record":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Live SPF record advisor for a sending domain (Wave 89 / TICKET-412)"}},"/domains/{domainID}/spf-flatten":{"post":{"description":"Expands every include:/a/mx mechanism in the published SPF record to its constituent ip4:/ip6: addresses and returns a single-lookup SPF record the operator can paste into DNS. When the Cloudflare integration is wired AND the domain's zone is manageable by the token, ALSO upserts the new TXT record into Cloudflare. Otherwise returns manual_paste_record + manual_paste_hostname for the operator to publish manually. Returns 422 when the domain has no v=spf1 record published.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"domain":{"type":"string"},"domain_id":{"type":"string"},"flattened_at":{"type":"string"},"flattened_record":{"type":"string"},"manual_paste_hostname":{"type":"string"},"manual_paste_record":{"type":"string"},"message":{"type":"string"},"published_at_cloudflare":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"422":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Flatten a sending domain's SPF record (W212 / Issue #93)"}},"/domains/{domainID}/spf-status":{"get":{"description":"Read-only — performs a recursive DNS TXT walk on the domain's SPF record and counts include: lookups per RFC 7208 §4.6.4 (10-lookup ceiling). Returns at_limit=true when count \u003e= 9 (about to break Gmail/Yahoo SPF eval) and over_limit=true when count \u003e 10 (already broken). When at_limit, includes suggested_flat — a flattened candidate the operator can preview before publishing.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"at_limit":{"type":"boolean"},"checked_at":{"type":"string"},"domain":{"type":"string"},"domain_id":{"type":"string"},"found":{"type":"boolean"},"include_count":{"type":"integer"},"limit":{"type":"integer"},"over_limit":{"type":"boolean"},"record":{"type":"string"},"suggested_flat":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Live SPF lookup-budget status for a sending domain (W212 / Issue #93)"}},"/domains/{domainID}/throttle":{"patch":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"throttle_per_min":{"type":"integer|null"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Set per-domain msgs-per-minute throttle (null removes)"}},"/domains/{domainID}/tracking":{"get":{"description":"Returns cname_record (e.g. t.calorisync.com), cname_target (t.sendbolt.com by default), and the current verified flag. The dashboard renders this in the 'Tracking domain' card. Read-scoped (domains:read) so any teammate can copy the value.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"cname_record":{"type":"string"},"cname_target":{"type":"string"},"verified":{"type":"boolean"},"verified_at":{"type":"string|null"}},"type":"object"}}},"description":"OK"}},"summary":"Get the per-tenant tracking CNAME record + verification state"}},"/domains/{domainID}/tracking/verify":{"post":{"description":"Looks up the CNAME on t.{domain} and flips tracking_cname_verified=TRUE when it resolves to the configured target. The engine reads that flag inside applyTrackingFor to decide whether to use the per-tenant host or fall back to the platform default. Does not publish DNS — operator owns that.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"cname_record":{"type":"string"},"cname_target":{"type":"string"},"observed_target":{"type":"string|null"},"verified":{"type":"boolean"},"verified_at":{"type":"string|null"}},"type":"object"}}},"description":"OK"}},"summary":"Verify the per-tenant tracking CNAME via DNS lookup"}},"/domains/{domainID}/verify":{"post":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"dkim_ok":{"type":"boolean"},"mx_ok":{"type":"boolean"},"spf_ok":{"type":"boolean"}},"type":"object"}}},"description":"OK"}},"summary":"Verify DNS for a domain"}},"/forms":{"get":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List subscription forms with submission counts"}},"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"confirm_body":{"type":"string"},"confirm_subject":{"type":"string"},"double_opt_in":{"type":"boolean"},"fields":{"type":"array"},"list_id":{"type":"string"},"name":{"type":"string"},"redirect_url":{"type":"string|null"},"slug":{"type":"string"}},"required":["list_id","slug","name"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Create a subscription form (slug must be globally unique)"}},"/forms/{formID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Delete a form (cascades submissions)"}},"/forms/{formID}/analytics":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"confirmed":{"type":"integer"},"form_id":{"type":"string"},"referrers":{"type":"array"},"submissions":{"type":"integer"},"trend":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Per-form analytics — submissions over time, double-opt-in confirm rate, top referrers"}},"/forms/{formID}/duplicate":{"post":{"responses":{"201":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Duplicate a form (copies fields + double-opt-in + redirect; submissions are NOT carried over; slug is suffixed with '-copy-N')"}},"/forms/{formID}/stats":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"confirm_rate_pct":{"type":"number"},"confirmed":{"type":"integer"},"form_id":{"type":"string"},"submissions":{"type":"integer"},"unconfirmed":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"Form submission + double-opt-in confirm rate"}},"/forms/{formID}/submissions":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"},"limit":{"type":"integer"},"offset":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"Paginated submission log for a form (email, contact_id, IP, UA, confirmed_at)"}},"/goals":{"get":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List click-goal definitions"}},"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"match_url_pattern":{"type":"string"},"name":{"type":"string"},"value_cents":{"type":"integer"}},"required":["name","match_url_pattern"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Create a click-goal (URL-prefix → value mapping). value_cents defaults to 0."}},"/goals/{goalID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Delete a click-goal (historical events keep their goal_value_cents snapshot)"},"patch":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"match_url_pattern":{"type":"string"},"name":{"type":"string"},"value_cents":{"type":"integer"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Update a click-goal. Any subset of {name, match_url_pattern, value_cents} may be sent."}},"/lists":{"get":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List contact lists"}},"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Create a list"}},"/lists/{listID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Delete a list"},"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Get a list (with contact_count)"},"put":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Rename a list"}},"/lists/{listID}/contact-count":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"active":{"type":"integer"},"bounced":{"type":"integer"},"list_id":{"type":"string"},"total":{"type":"integer"},"unsubscribed":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"Contact count for a list (active vs unsubscribed vs bounced — drives the list page header)."}},"/lists/{listID}/contacts":{"get":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Contacts in a list"}},"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"contact_id":{"type":"string"}},"required":["contact_id"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Add contact to list"}},"/lists/{listID}/contacts/import":{"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"_array":{"type":"array of {email, custom_fields}"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Bulk import contacts directly into a list (CSV upload). Creates new contacts + attaches all (incl. pre-existing) to the list."}},"/lists/{listID}/contacts/{contactID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Remove contact from list"}},"/lists/{listID}/stats":{"get":{"description":"Counts events for campaigns whose target list matches this list. Returns members + sent/opened/clicked/bounced/unsubscribed + percent rates.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"bounce_rate_pct":{"type":"number"},"bounced":{"type":"integer"},"click_rate_pct":{"type":"number"},"clicked":{"type":"integer"},"list_id":{"type":"string"},"members":{"type":"integer"},"open_rate_pct":{"type":"number"},"opened":{"type":"integer"},"sent":{"type":"integer"},"unsub_rate_pct":{"type":"number"},"unsubscribed":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"List-scoped engagement stats (Pinpointe-style)"}},"/lookalike/jobs":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List lookalike jobs for the tenant (newest 100). Each item includes status (pending|running|done|error), seed_segment_id, output_segment_id once status=done. W229."},"post":{"description":"Asynchronous. Returns 202 + the job id; the worker polls pending rows every 30s and produces a new tenant-owned segment whose definition is one `id IN [...]` rule. seed_segment_id must reference a segment owned by the same tenant; top_n is clamped to [100, 10000] (default 1000). Poll GET /lookalike/jobs/{id} for status; when status=done the output_segment_id is set and the new segment is usable in any campaign/template/automation that consumes segments.","requestBody":{"content":{"application/json":{"schema":{"properties":{"seed_segment_id":{"type":"string"},"top_n":{"type":"integer"}},"required":["seed_segment_id"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Create a lookalike job. Builds a cosine-similarity-based top-N segment from the seed cohort."}},"/lookalike/jobs/{jobID}":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"completed_at":{"type":"string"},"created_at":{"type":"string"},"error":{"type":"string"},"id":{"type":"string"},"output_segment_id":{"type":"string"},"seed_segment_id":{"type":"string"},"status":{"type":"string"},"tenant_id":{"type":"string"},"top_n":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"Get the status of one lookalike job. Tenant-scoped (cross-tenant ids return 404). When status=done the output_segment_id is populated. W229."}},"/mail/domains":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List the tenant's mailbox-enabled domains (Workspace v0.1)"}},"/mail/domains/{domainID}/enable":{"post":{"description":"INSERTs the mailbox_domains row (status='pending'), UPSERTs the 4 DNS records via Cloudflare, then polls dig MX for up to 60s; flips status='verified' on hit. Idempotent.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"domain":{"type":"string"},"domain_id":{"type":"string"},"id":{"type":"string"},"records":{"type":"array"},"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Enable the Workspace mailbox flow for a verified sending domain (owner-only)"}},"/mail/domains/{domainID}/verify":{"post":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"domain":{"type":"string"},"id":{"type":"string"},"records":{"type":"array"},"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Re-poll the inbound MX for a mailbox-enabled domain and update status"}},"/mail/mailboxes":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List the tenant's mailboxes (lean view; no quota/members)"},"post":{"description":"kind ∈ {personal, shared}. Personal mailboxes auto-seed the calling user as owner; shared mailboxes start empty and members are added via POST /members. address must be unique within the mailbox_domains scope.","requestBody":{"content":{"application/json":{"schema":{"properties":{"daily_send_cap":{"type":"integer"},"display_name":{"type":"string"},"domain_id":{"type":"string"},"kind":{"type":"string"},"local_part":{"type":"string"},"quota_bytes":{"type":"integer"}},"required":["domain_id","local_part","kind"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Create a mailbox (owner/admin only)"}},"/mail/mailboxes/bulk":{"post":{"description":"Each row carries the same fields as POST /mail/mailboxes. Per-row failures (duplicate address, invalid kind, etc.) are reported in the response without aborting the batch.","requestBody":{"content":{"application/json":{"schema":{"properties":{"rows":{"type":"array"}},"required":["rows"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Bulk-create mailboxes (owner/admin only)"}},"/mail/mailboxes/{mailboxID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Delete a mailbox (owner/admin only; cascades members + invites)"},"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Get a mailbox detail (with member list + quota)"},"patch":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"daily_send_cap":{"type":"integer"},"display_name":{"type":"string"},"quota_bytes":{"type":"integer"},"status":{"type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Update mailbox display_name / quota / daily_send_cap / status (owner/admin only)"}},"/mail/mailboxes/{mailboxID}/members":{"post":{"description":"role ∈ {admin, replier, reader}. 'owner' is seeded at mailbox creation and managed via PATCH /members/{userID} instead.","requestBody":{"content":{"application/json":{"schema":{"properties":{"role":{"type":"string"},"user_id":{"type":"string"}},"required":["user_id","role"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Add a member to a mailbox (owner/admin only)"}},"/mail/mailboxes/{mailboxID}/members/{userID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Remove a member from a mailbox (owner/admin only)"},"patch":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"role":{"type":"string"}},"required":["role"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Change a member's role on a mailbox (owner/admin only)"}},"/mail/me/mailboxes":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Mailboxes the calling user is a member of (for sidebar population)"}},"/mail/messages":{"get":{"description":"Caller must be a member of the mailbox. folder ∈ {inbox, sent, drafts, trash} (default inbox).","parameters":[{"in":"query","name":"mailbox_id","required":true,"schema":{"type":"string"}},{"in":"query","name":"folder","required":false,"schema":{"type":"string"}},{"in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"in":"query","name":"offset","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"},"limit":{"type":"integer"},"offset":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"List messages in a mailbox folder"},"post":{"description":"Internal short-circuit for tenant-to-tenant traffic; external outbound goes through the existing MTA. Caller must be a member of from_mailbox_id with role owner/admin/replier.","requestBody":{"content":{"application/json":{"schema":{"properties":{"body_html":{"type":"string"},"body_text":{"type":"string"},"from_mailbox_id":{"type":"string"},"in_reply_to":{"type":"string"},"subject":{"type":"string"},"to":{"type":"array\u003cstring\u003e"}},"required":["from_mailbox_id","to","subject"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Send a mail message from a mailbox (owner/admin/replier members only)"}},"/mail/messages/{messageID}":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Get a full message (body_text + body_html). Caller must be a member of the mailbox."}},"/media":{"get":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List the tenant's media library (newest first)"}},"post":{"description":"Returns the row + a stable public URL. Re-uploading the same bytes returns the existing row (dedupe by sha256).","requestBody":{"content":{"multipart/form-data":{"schema":{"properties":{"file":{"format":"binary","type":"string"}},"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"mime":{"type":"string"},"sha256":{"type":"string"},"size_bytes":{"type":"integer"},"url":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Upload an image (multipart, field name 'file'); png/jpg/gif/webp/avif up to 8 MB"}},"/media/{mediaID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Delete a media row (file remains on disk so existing email URLs keep working)"}},"/oauth/{provider}/authorize":{"get":{"description":"For HubSpot: returns {authorize_url, state} so the dashboard can redirect the user to the provider's consent page; the state token carries tenant_id + a CSRF nonce that the /callback handler verifies. For Salesforce (JWT-bearer, no user consent step): mints a token inline and returns {connected:true, instance_url}. Returns 503 with a 'Provider not configured' message when the relevant env vars (HUBSPOT_CLIENT_ID / SALESFORCE_CLIENT_ID etc.) are absent.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"authorize_url":{"type":"string"},"connected":{"type":"boolean"},"instance_url":{"type":"string"},"state":{"type":"string"}},"type":"object"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"},"502":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"},"503":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Begin the OAuth flow for a connector (hubspot|salesforce)"}},"/oauth/{provider}/callback":{"get":{"description":"Redirect target from HubSpot's consent page. Validates ?state (CSRF + tenant binding) and exchanges ?code for access + refresh tokens. Tokens are AES-256-GCM encrypted with the API encryption key and persisted to tenant_oauth_tokens. Salesforce uses JWT-bearer and never reaches this endpoint — a non-HubSpot provider returns 400.","parameters":[{"in":"query","name":"code","required":true,"schema":{"type":"string"}},{"description":"Opaque token returned from /authorize; carries tenant_id + CSRF nonce.","in":"query","name":"state","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"connected":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"},"502":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Exchange an OAuth2 authorization code for tokens (HubSpot only)"}},"/oauth/{provider}/disconnect":{"post":{"description":"Deletes the tenant's row in tenant_oauth_tokens for the given provider (hubspot|salesforce). Returns 204 on success, 404 when no row was present. Audit-logged (oauth.disconnect).","responses":{"204":{"description":"Tokens cleared"},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Clear stored OAuth tokens for a provider"}},"/oauth/{provider}/runs":{"get":{"description":"Lists the tenant's connector_sync_runs rows for the given provider ordered by created_at DESC, capped at 20. Each item carries id, direction, status (pending|running|completed|failed), records_synced, error (omitted on success), started_at, completed_at, created_at.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Recent connector sync runs for a provider (newest 20)"}},"/oauth/{provider}/status":{"get":{"description":"Returns {connected:false} when no row exists for (tenant, provider). Otherwise {connected:true, expires_at, scope, instance_url} — instance_url is only populated for Salesforce. Used by the dashboard's Connectors page to render connected/not-connected tiles + an 'about to expire' warning.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"connected":{"type":"boolean"},"expires_at":{"type":"string"},"instance_url":{"type":"string"},"scope":{"type":"string"}},"type":"object"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Connection state + token expiry for a provider"}},"/oauth/{provider}/sync":{"post":{"description":"Inserts a row into connector_sync_runs with status='pending'; the connector worker picks it up on its next tick. direction must be 'pull' (provider→SendBolt) or 'push' (SendBolt→provider). Returns the new run id immediately so the caller can poll /runs for status.","requestBody":{"content":{"application/json":{"schema":{"properties":{"direction":{"type":"string"}},"required":["direction"],"type":"object"}}},"required":true},"responses":{"202":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"status":{"type":"string"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Enqueue a pull or push sync run for a provider"}},"/outbound-webhooks":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"available_events":{"type":"array"},"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List configured outbound webhooks + the catalog of available_events"},"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"enabled":{"type":"boolean"},"events":{"type":"array\u003cstring\u003e"},"name":{"type":"string"},"secret":{"type":"string"},"url":{"type":"string"}},"required":["name","url"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Register a webhook URL. Secret is auto-generated when omitted; it's returned ONCE in the response — store it."}},"/outbound-webhooks/deliveries/{deliveryID}/replay":{"post":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"delivery_id":{"type":"string"},"duration_ms":{"type":"integer"},"status":{"type":"string"},"status_code":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"W206 — re-fire an existing delivery's stored payload at the webhook URL. Creates a NEW outbound_webhook_deliveries row (does NOT mutate the source row); returns the new delivery_id + HTTP response status."}},"/outbound-webhooks/stats":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"days":{"type":"integer"},"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Per-webhook delivery stats across ALL tenant webhooks in one call (delivered, failed, success_rate). Window controlled by ?days=N (default 7, max 90). Replaces the per-webhook fan-out the dashboard previously used."}},"/outbound-webhooks/{webhookID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Remove a webhook (cascades pending + historic deliveries)"}},"/outbound-webhooks/{webhookID}/analytics":{"get":{"parameters":[{"description":"Window (default 24h). 24h → 1h buckets; 7d → 6h; 30d → 1d.","in":"query","name":"range","required":false,"schema":{"enum":["24h","7d","30d"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"buckets":{"type":"array"},"p50_latency_ms":{"type":"integer"},"p95_latency_ms":{"type":"integer"},"p99_latency_ms":{"type":"integer"},"range":{"type":"string"},"success_rate":{"type":"number"},"top_failures":{"type":"array"},"total_attempts":{"type":"integer"},"total_fail":{"type":"integer"},"total_success":{"type":"integer"},"webhook_id":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"W234 — per-webhook delivery analytics: success rate + counts + latency percentiles (p50/p95/p99) + bucketed time series + top failure reasons over ?range=24h|7d|30d."}},"/outbound-webhooks/{webhookID}/deliveries":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Recent delivery attempts (most-recent first; max 100)"}},"/outbound-webhooks/{webhookID}/deliveries/{deliveryID}/retry":{"post":{"responses":{"202":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Re-fire a failed/dead delivery — flips it back to pending with next_attempt_at=now()"}},"/outbound-webhooks/{webhookID}/simulate":{"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"event_type":{"type":"string"},"payload":{"type":"object"}},"required":["event_type"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"W206 — fire a synthetic event of any supported type at the webhook URL, signed exactly like a real dispatcher delivery. Records a fresh outbound_webhook_deliveries row and returns its id + the HTTP response status. payload is optional — when omitted a deterministic sample for the event_type is used."}},"/outbound-webhooks/{webhookID}/test":{"post":{"responses":{"202":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Queue a synthetic 'webhook.test' event to verify the URL + signature"}},"/plans":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List available plans (any authed tenant can browse)"}},"/reports/campaigns":{"get":{"description":"Returns up to N campaigns from the last D days, sorted by send count desc, with derived open/click/bounce rates per campaign.","parameters":[{"description":"Window in days (1..365, default 30)","in":"query","name":"days","required":false,"schema":{"type":"integer"}},{"description":"Max rows (1..100, default 25)","in":"query","name":"limit","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"days":{"type":"integer"},"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Top campaigns by send volume (Pinpointe-style 'Campaign Insights')"}},"/reports/campaigns.csv":{"get":{"parameters":[{"description":"Window in days (1..365, default 30)","in":"query","name":"days","required":false,"schema":{"type":"integer"}},{"description":"Max rows (1..100, default 25)","in":"query","name":"limit","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"text/csv":{"schema":{"type":"string"}}},"description":"CSV download"}},"summary":"Stream the campaigns-rollup as CSV (mirrors GET /reports/campaigns; same ?days/?limit query params)"}},"/reports/database":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"active_contacts":{"type":"integer"},"bounced_contacts":{"type":"integer"},"deleted_contacts":{"type":"integer"},"forms":{"type":"integer"},"lists":{"type":"integer"},"segments":{"type":"integer"},"sending_domains":{"type":"integer"},"suppressions":{"type":"object"},"templates":{"type":"integer"},"unsubscribed_contacts":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"Tenant-wide database rollup (active vs unsubscribed vs deleted contacts, lists, segments, forms, suppressions, templates, sending domains)"}},"/reports/database.csv":{"get":{"responses":{"200":{"content":{"text/csv":{"schema":{"type":"string"}}},"description":"CSV download"}},"summary":"Stream the tenant-wide database rollup as CSV (mirrors GET /reports/database, framed for spreadsheets)"}},"/reputation/actions":{"get":{"description":"Returns the last N (default 10, max 50) reputation_actions rows for the calling tenant. Each row records a throttle_halved (auto) or throttle_overridden (operator) event with the before/after warmup cap, the observed bounce rate, and the reason. Used by the dashboard to render the audit timeline and decide whether to show the 'throttle currently halved' red banner.","parameters":[{"description":"1..50, default 10","in":"query","name":"limit","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"count":{"type":"integer"},"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Most-recent reputation_actions for the calling tenant"}},"/reputation/dnsbl":{"get":{"description":"Returns the latest (ip, bl_name) probe per blacklist. any_listed=true triggers a red badge in the dashboard. Results are populated every 30 minutes by the background monitor.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"any_listed":{"type":"boolean"},"checks":{"type":"array"},"last_run_at":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Most-recent DNSBL check results for this box's sending IPs"}},"/reputation/override":{"post":{"description":"Restores warmup_domains.override_daily_limit on (tenant, sending_domain) to the operator-chosen value AND records a throttle_overridden audit row. Same auth shape as /warmup/{domain}/override: ScopeDomainsWrite + RoleOwner.","requestBody":{"content":{"application/json":{"schema":{"properties":{"new_cap":{"type":"integer"},"reason":{"type":"string"},"sending_domain":{"type":"string"}},"required":["sending_domain","new_cap"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"action_id":{"type":"string"},"created_at":{"type":"string"},"new_warmup_cap":{"type":"integer"},"previous_warmup_cap":{"type":"integer"},"sending_domain":{"type":"string"},"tenant_id":{"type":"string"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Override an auto-throttled warmup cap (operator escape hatch)"}},"/segments":{"get":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List saved segments (with cached last_count if previewed). Each item includes source_lists, source_segments, exclude_lists, exclude_segments arrays (migration 000070)."}},"post":{"description":"definition.op is AND or OR; definition.rules is a flat list of {type:'field'|'event', ...}. Field rules use operator in {eq,ne,contains,starts_with,gt,lt,gte,lte,between} on email|email_lower|status|created_at|custom_fields.\u003ckey\u003e. Event rules use operator in {eq,ne,gt,lt,gte,lte,in_last_days,not_in_last_days} on event_type in {sent,open,click,bounce,unsub} with in_last_days bounded 1..3650. definition.include/exclude are SourceRef arrays [{type:'list'|'segment', id:'...'}] for combining lists + segments. The convenience arrays source_lists, source_segments, exclude_lists, exclude_segments can be supplied directly and are kept in sync with the definition.","requestBody":{"content":{"application/json":{"schema":{"properties":{"definition":{"type":"object"},"exclude_lists":{"type":"array"},"exclude_segments":{"type":"array"},"name":{"type":"string"},"source_lists":{"type":"array"},"source_segments":{"type":"array"}},"required":["name","definition"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Create a segment with source list/segment combinations"}},"/segments/{segmentID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Delete a segment"},"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Get a segment + its definition + source/exclude arrays"},"put":{"description":"Same body shape as POST /segments. source_lists, source_segments, exclude_lists, exclude_segments are kept in sync with definition.include/exclude.","requestBody":{"content":{"application/json":{"schema":{"properties":{"definition":{"type":"object"},"exclude_lists":{"type":"array"},"exclude_segments":{"type":"array"},"name":{"type":"string"},"source_lists":{"type":"array"},"source_segments":{"type":"array"}},"required":["name","definition"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Update a segment (name, definition, source combinations)"}},"/segments/{segmentID}/definition":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"definition":{"type":"object"},"exclude_lists":{"type":"array"},"exclude_segments":{"type":"array"},"id":{"type":"string"},"name":{"type":"string"},"source_lists":{"type":"array"},"source_segments":{"type":"array"},"tag_filters":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Copy-as-API-JSON: returns a POSTable representation of the segment (name + definition + source/exclude arrays + tag_filters). The shape mirrors POST /segments minus the id wrapper — paste into POST /segments to recreate the segment elsewhere. Tenant-scoped (cross-tenant ids return 404). W182."}},"/segments/{segmentID}/duplicate":{"post":{"responses":{"201":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"name":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Duplicate a segment (mirrors campaign-duplicate; copies definition + source/exclude arrays; name gets ' (copy)' appended once)"}},"/segments/{segmentID}/preview":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"count":{"type":"integer"},"sample":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Compile + run the segment query; returns total count + up to 10 sample contact emails. Side-effect: caches the count on the segment."},"post":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"count":{"type":"integer"},"sample":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Compile + run the segment query (POST kept for backward-compat; prefer GET)."}},"/segments/{segmentID}/sample":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Random sample of contacts matching the segment (default 10, capped at 25 via ?limit=N). Returns a flat JSON array with id, email, status, source, last_open_at per row. Tenant-scoped (cross-tenant ids return 404); empty result is 200 with []. Rate-limited 100/min per tenant. W202."}},"/segments/{segmentID}/share":{"patch":{"description":"Sets segments.shared_at = COALESCE(shared_at, now()) so the row becomes visible via GET /shared/segments. Idempotent — re-sharing does not bump shared_at. Returns {id, shared_at}. 404 when the id is not owned by the calling tenant.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"shared_at":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Publish a segment to the workspace (owner-only)"}},"/segments/{segmentID}/unshare":{"patch":{"description":"Clears segments.shared_at and shared_by_user_id. Pre-existing share_clone_audit rows are untouched — clones the recipients already took remain theirs. Returns {id, shared_at:null}. 404 when the id is not owned by the calling tenant.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"shared_at":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Retract a previously-shared segment (owner-only)"}},"/sequences":{"get":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List sequences (with step_count)"}},"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"name":{"type":"string"},"steps":{"type":"array\u003c{wait_hours,condition,subject,body_text,body_html,template_id}\u003e"}},"required":["name"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Create sequence with optional initial steps"}},"/sequences/{sequenceID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Delete sequence (cascades steps + enrollments)"},"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Get a sequence with its ordered steps"},"put":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"name":{"type":"string"},"status":{"type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Update sequence name/status (validated transition)"}},"/sequences/{sequenceID}/enroll":{"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"contact_ids":{"type":"array\u003cstring\u003e"}},"required":["contact_ids"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Bulk enroll contacts on the first step (cap 1000; already-enrolled skipped, returns {received,enrolled,exists,skipped})"}},"/sequences/{sequenceID}/enrollments":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List enrollments for a sequence (with status counts)"}},"/sequences/{sequenceID}/enrollments/{enrollmentID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Mark enrollment as exited (manual unenroll)"}},"/sequences/{sequenceID}/step-stats":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"sequence_id":{"type":"string"},"steps":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Per-step stats for a sequence — sent / open / click / unsub / bounce counts plus rates per step"}},"/sequences/{sequenceID}/steps":{"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"body_html":{"type":"string"},"body_text":{"type":"string"},"condition":{"type":"string"},"subject":{"type":"string"},"template_id":{"type":"string"},"wait_hours":{"type":"integer"}},"required":["subject"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Append a step"}},"/sequences/{sequenceID}/steps/{stepID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Delete a step"},"put":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"body_html":{"type":"string"},"body_text":{"type":"string"},"condition":{"type":"string"},"subject":{"type":"string"},"template_id":{"type":"string"},"wait_hours":{"type":"integer"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Update a step"}},"/settings":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"always_append_signature":{"type":"boolean"},"bounces_localpart":{"type":"string"},"default_from_email":{"type":"string"},"default_from_name":{"type":"string"},"default_reply_to":{"type":"string"},"favicon_url":{"type":"string"},"frequency_cap_per_week":{"type":"integer|null"},"noreply_localpart":{"type":"string"},"primary_color":{"type":"string"},"privacy_url":{"type":"string"},"soft_bounce_threshold":{"type":"integer"},"soft_bounce_window_days":{"type":"integer"},"status_url":{"type":"string"},"terms_url":{"type":"string"},"tracking_domain":{"type":"string|null"},"unsubscribe_localpart":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Tenant settings (frequency cap, soft-bounce policy, tracking domain, sender identity defaults, always-append-signature, system mailbox localparts, branding)"}},"/settings/branding":{"put":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"favicon_url":{"type":"string|null"},"logo_url":{"type":"string|null"},"primary_color":{"type":"string|null"},"privacy_url":{"type":"string|null"},"status_url":{"type":"string|null"},"terms_url":{"type":"string|null"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Set tenant branding: logo URL, primary colour (W156-JJ — #RRGGBB), favicon URL + footer override URLs (W156-KK — privacy/terms/status, http(s) URL \u003c=512 chars). All fields are independently optional; empty/null clears that field; omitted leaves it untouched."}},"/settings/byo-settings":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"byo_pause_mode":{"type":"string"},"byo_paused_until":{"type":"string|null"},"delivery_mode":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Get tenant BYO-SMTP whitelabel settings"},"patch":{"description":"Toggles delivery_mode ('platform' ↔ 'byo_smtp') and/or byo_pause_mode ('auto_resume' ↔ 'require_operator_unfreeze'). Both keys are optional (PATCH semantics); the request must set at least one. byo_paused_until is set only by the bounce/complaint auto-pause workers — operators clear it from the admin dashboard, not via this endpoint.","requestBody":{"content":{"application/json":{"schema":{"properties":{"byo_pause_mode":{"type":"string"},"delivery_mode":{"type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Update tenant BYO-SMTP whitelabel settings"}},"/settings/email-defaults":{"patch":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"always_append_signature":{"type":"boolean"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Update tenant-level outbound-mail defaults (W156-OO always-append-signature toggle; future fields plug in here)"}},"/settings/frequency-cap":{"patch":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"cap":{"type":"integer|null"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Update rolling 7-day frequency cap (null clears)"}},"/settings/me/timezone":{"put":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"timezone":{"type":"string"}},"required":["timezone"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Set the calling user's IANA timezone preference (e.g. America/Los_Angeles). No role gate — every user owns their own preference."}},"/settings/quota":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"month_bucket":{"type":"string"},"plan":{"type":"object"},"sends_remaining":{"type":"integer"},"sends_this_month":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"Tenant plan + this-month send usage"}},"/settings/sender-identity":{"patch":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"from_email":{"type":"string"},"from_name":{"type":"string"},"reply_to":{"type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Set tenant defaults for From name/email and Reply-To (auto-populate new campaigns); returns warning if from_email's domain is not a sending domain"}},"/settings/signatures":{"get":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List the tenant's reusable email signatures (TICKET-464). Used by template/campaign body editors to populate the 'Insert signature' dropdown."}},"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"body_html":{"type":"string"},"body_text":{"type":"string"},"is_default":{"type":"boolean"},"name":{"type":"string"}},"required":["name"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Create a tenant-scoped reusable email signature (owner-only)"}},"/settings/signatures/{sigID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Delete a signature (owner-only). Tenant default flips to the next-most-recently-updated signature, if any."},"put":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"body_html":{"type":"string"},"body_text":{"type":"string"},"is_default":{"type":"boolean"},"name":{"type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Update a signature (owner-only)"}},"/settings/signatures/{sigID}/default":{"post":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"is_default":{"type":"boolean"}},"type":"object"}}},"description":"OK"}},"summary":"Mark a signature as the tenant's default (owner-only) — used by editors when no signature is explicitly chosen"}},"/settings/smtp":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Clear the tenant-level fallback relay"},"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"has_password":{"type":"boolean"},"host":{"type":"string"},"port":{"type":"integer"},"use_starttls":{"type":"boolean"},"use_tls_implicit":{"type":"boolean"},"username":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Get the tenant-level fallback SMTP relay (used when no per-domain relay is configured)"},"put":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"host":{"type":"string"},"password":{"type":"string"},"port":{"type":"integer"},"use_starttls":{"type":"boolean"},"use_tls_implicit":{"type":"boolean"},"username":{"type":"string"}},"required":["host","port"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Configure the tenant-level fallback relay; password optional (empty = no-auth relay)"}},"/settings/smtp-relay/test-send":{"post":{"description":"Unlike POST /settings/smtp/verify (which only probes EHLO+STARTTLS+AUTH), this performs an actual MAIL FROM / RCPT TO / DATA round-trip and returns the message id + elapsed duration. Returns 503 if the domain service is unconfigured (missing API_ENCRYPTION_KEY) or the per-domain relay circuit is open. Audit-logged.","requestBody":{"content":{"application/json":{"schema":{"properties":{"to":{"type":"string"}},"required":["to"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Run a real end-to-end send through the tenant's saved SMTP relay (owner-only)"}},"/settings/smtp/password":{"put":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"password":{"type":"string"}},"required":["password"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Rotate the tenant-level fallback SMTP relay password (re-encrypts ciphertext at rest; other fields untouched)"}},"/settings/smtp/verify":{"post":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"},"ok":{"type":"boolean"}},"type":"object"}}},"description":"OK"}},"summary":"Test the tenant-level fallback relay (real EHLO + STARTTLS + AUTH)"}},"/settings/soft-bounce":{"patch":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"threshold":{"type":"integer"},"window_days":{"type":"integer"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Update soft-bounce promotion policy (threshold=0 disables)"}},"/settings/system-mailboxes":{"put":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"bounces_localpart":{"type":"string|null"},"noreply_localpart":{"type":"string|null"},"unsubscribe_localpart":{"type":"string|null"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"W156-QQ — set per-tenant system mailbox localparts. Each field is the localpart only (^[a-z0-9._-]{1,64}$); the domain part is derived implicitly from the tenant's sending domain at send time. Empty string clears the override (engine falls back to noreply/bounces/unsubscribe). At least one field is required."}},"/settings/tracking-domain":{"patch":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"domain":{"type":"string|null"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Set or clear per-tenant tracking domain (bare hostname; null clears)"}},"/shared/segments":{"get":{"description":"Returns segments with shared_at IS NOT NULL belonging to any tenant in the caller's workspace, EXCLUDING the caller's own tenant. Limited to 500 most-recently-shared rows. Each item carries source_tenant_id + shared_at + shared_by_user_id + the portable definition JSON so the clone endpoint can re-insert verbatim.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List segments shared by other tenants in the workspace"}},"/shared/segments/{segmentID}/clone":{"post":{"description":"Copies a workspace-visible (shared_at IS NOT NULL) segment into the caller's tenant with a fresh id; the definition + tag_filters + source/exclude arrays are copied verbatim. On UNIQUE(tenant_id, name) collision, ' (cloned)' is appended up to 5 times before returning 409. Every clone is recorded in share_clone_audit inside the same transaction. Returns the new resource id. 404 when the source isn't shared or belongs to the caller.","responses":{"201":{"content":{"application/json":{"schema":{"properties":{"new_id":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Clone a shared segment into the caller's tenant"}},"/shared/templates":{"get":{"description":"Returns templates with shared_at IS NOT NULL belonging to any tenant in the caller's workspace, EXCLUDING the caller's own tenant. Limited to the 500 most-recently-shared rows. Each item carries source_tenant_id + shared_at + shared_by_user_id so the dashboard can render 'Shared by Acme — 3 days ago'.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List templates shared by other tenants in the workspace"}},"/shared/templates/{templateID}/clone":{"post":{"description":"Copies a workspace-visible (shared_at IS NOT NULL) template into the caller's tenant with a fresh id; the new name has ' (cloned)' appended once. The clone is recorded in share_clone_audit inside the same transaction so a failed audit insert rolls back the clone. Returns the new resource id. 404 when the source isn't shared or belongs to the caller.","responses":{"201":{"content":{"application/json":{"schema":{"properties":{"new_id":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Clone a shared template into the caller's tenant"}},"/spam-score/preview":{"post":{"description":"Composer-side affordance. Returns score, verdict (inbox|promotions|spam), and the list of contributing reasons. Tenant context comes from auth — body cannot override tenant_id.","requestBody":{"content":{"application/json":{"schema":{"properties":{"body_html":{"type":"string"},"body_text":{"type":"string"},"from_domain":{"type":"string"},"has_list_unsub":{"type":"boolean"},"subject":{"type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Score a draft email's spam risk (0-10) without sending"}},"/status":{"get":{"description":"PUBLIC. Returns overall status, per-component (API / MTA / Database / Redis) status + 24h/90d uptime, recent incidents, and platform-wide 24h send metrics. NO tenant-specific data is exposed. On a fresh deploy with no system_health_log history yet, returns collecting_data=true so the frontend can render 'Collecting data — first 24h' instead of misleading 100% bars.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"collecting_data":{"type":"boolean"},"components":{"type":"array"},"incidents":{"type":"array"},"metrics_24h":{"type":"object"},"overall":{"type":"string"}},"type":"object"}}},"description":"OK"}},"security":[],"summary":"Public platform status (status.stripe.com-style)"}},"/suppressions":{"get":{"description":"Each item carries scope ('email'|'domain'|'md5') and value. The legacy 'email' field mirrors value for back-compat.","parameters":[{"description":"email | domain | md5 | all (default: email)","in":"query","name":"scope","required":false,"schema":{"type":"string"}},{"in":"query","name":"limit","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List suppression entries (default scope=email; pass ?scope=domain|md5|all for others)"},"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"reason":{"type":"string"},"scope":{"type":"string"},"value":{"type":"string"}},"required":["value"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Add a single suppression entry (scope=email|domain). MD5 hashes are managed via /md5-lists."}},"/suppressions/import":{"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"emails":{"type":"array\u003cstring\u003e"}},"required":["emails"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Bulk import emails into suppression list (reason='import'); duplicates skipped silently"}},"/suppressions/md5-lists":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List MD5 offer-suppression lists for the tenant"},"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Create a new named MD5 suppression list"}},"/suppressions/md5-lists/{listID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Delete an MD5 list and its hashes (campaigns referencing it have FK set to NULL)"}},"/suppressions/md5-lists/{listID}/hashes":{"post":{"description":"Pass body as text/plain. Lines that look like emails are MD5-hashed automatically. Returns received/inserted/skipped/invalid.","requestBody":{"content":{"text/plain":{"schema":{"type":"string"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"inserted":{"type":"integer"},"invalid":{"type":"integer"},"received":{"type":"integer"},"skipped":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"Append MD5 hashes to a list (text body, one hex hash per line; emails are auto-hashed)"}},"/suppressions/scoped/{scope}/{value}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Remove an entry by (scope, value); scope is email or domain"}},"/suppressions/transient":{"get":{"description":"Pinpointe-style 'Transient Suppressions' view. Returns contacts whose recent soft-bounce count is below the per-tenant threshold (defaults: 5 / 30 days). Useful for spotting deliverability patterns before they harden into permanent suppressions.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"description":{"type":"string"},"items":{"type":"array"},"threshold":{"type":"integer"},"window_days":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"Contacts with soft bounces below the promotion threshold"}},"/suppressions/{email}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Remove an email from suppression list"}},"/templates":{"get":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List templates"}},"post":{"description":"subject, preheader_text, body_html, and body_text support Liquid template syntax: {{first_name | default: \"there\"}}, {% if country == \"US\" %}...{% endif %}, {{contact.email}}, {{custom_fields.account_id}}. category is optional and one of welcome|promo|transactional|newsletter|reengagement|announcement|other. W205 — builder_state (optional, object) is the canonical block-tree for templates authored in the drag-drop builder; body_html is derived from it. Leave builder_state null for raw-HTML expert-mode templates.","requestBody":{"content":{"application/json":{"schema":{"properties":{"body_html":{"type":"string"},"body_text":{"type":"string"},"builder_state":{"type":"object|null"},"category":{"type":"string|null"},"name":{"type":"string"},"preheader_text":{"type":"string"},"subject":{"type":"string"}},"required":["name"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Create template"}},"/templates/clone-gallery":{"post":{"description":"Reads the gallery template's embedded HTML body, inserts a new row into the templates table with the caller's tenant_id, and returns the new template DTO. gallery_id must be one of the ids returned by GET /templates/gallery. Optional name overrides the catalog default (handy when cloning the same gallery template multiple times). The new template is fully editable like any other tenant-created template.","requestBody":{"content":{"application/json":{"schema":{"properties":{"gallery_id":{"type":"string"},"name":{"type":"string"}},"required":["gallery_id"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"W197 — Clone a curated gallery template into the caller's tenant"}},"/templates/gallery":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array\u003cobject\u003e"}},"type":"object"}}},"description":"OK"}},"summary":"W197 — List the curated template gallery (10 pre-built responsive templates). Returns id, name, subject, blurb, category, and preview_url (a static-served HTML URL the frontend uses for iframe thumbnails)."}},"/templates/preview":{"post":{"description":"Editor-side preview. Substitutes {{first_name}}, {{last_name}}, {{company}}, {{unsubscribe_url}} with merge_vars (or sensible defaults). Returns rendered subject + body_html + body_text. No email is sent.","requestBody":{"content":{"application/json":{"schema":{"properties":{"body_html":{"type":"string"},"body_text":{"type":"string"},"merge_vars":{"type":"object"},"subject":{"type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Render arbitrary subject/body fields against a sample contact (no template id required)"}},"/templates/{templateID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Delete template"},"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Get a template"},"put":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"body_html":{"type":"string"},"body_text":{"type":"string"},"builder_state":{"type":"object|null"},"category":{"type":"string|null"},"name":{"type":"string"},"preheader_text":{"type":"string"},"subject":{"type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Update template (category and builder_state accept value|null; absent key keeps current). W205 — builder_state is the block-tree from the drag-drop builder; sending null clears it (template switched to raw-HTML expert mode)."}},"/templates/{templateID}/duplicate":{"post":{"responses":{"201":{"content":{"application/json":{"schema":{"properties":{"category":{"type":"string|null"},"id":{"type":"string"},"name":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Duplicate a template (copies subject, preheader, bodies, category; name gets ' (copy)' appended once)"}},"/templates/{templateID}/inbox-preview":{"get":{"description":"Renders the template's HTML body against 6 fixed email-client profiles (Gmail web/Android, Outlook 365 web / Windows desktop, Apple Mail iOS/macOS) and returns one PNG snapshot per profile. PNGs are cached under MEDIA_DIR/inbox-preview/{template_id}/... and served back via GET /templates/{templateID}/inbox-preview/{client_id}.png. The renderer is pure-Go (no Playwright / chromedp dep added) — see internal/inboxpreview/renderer.go for the design notes.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"cached":{"type":"boolean"},"items":{"type":"array\u003cobject\u003e"},"template_id":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"W192 — Multi-client inbox preview snapshots"}},"/templates/{templateID}/inbox-preview/invalidate":{"post":{"description":"Deletes every cached PNG for the given template. The next GET /templates/{templateID}/inbox-preview re-renders all profiles. Powers the dashboard's \"Re-render all\" button.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"deleted":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"W192 — Invalidate cached inbox-preview snapshots"}},"/templates/{templateID}/inbox-preview/{clientPNG}":{"get":{"description":"Returns the image/png bytes for one client profile. Renders on demand if not in cache. The clientPNG path component is one of {gmail_web, gmail_android, outlook_365_web, outlook_windows, apple_mail_ios, apple_mail_macos}, optionally suffixed with .png (handler trims the suffix).","responses":{"200":{"content":{"image/png":{}},"description":"PNG bytes"}},"summary":"W192 — Raw PNG for a single (template, client) snapshot"}},"/templates/{templateID}/preview":{"post":{"description":"Fetches the tenant-owned template's body_html, body_text, and subject, substitutes {{first_name}}, {{last_name}}, {{company}}, and {{unsubscribe_url}} with the supplied merge_vars (or sensible defaults), and returns the rendered strings. Intended for the email client preview UI — no email is sent.","requestBody":{"content":{"application/json":{"schema":{"properties":{"merge_vars":{"type":"object"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Render a template preview with merge variable substitution"}},"/templates/{templateID}/send-test":{"post":{"description":"Renders subject + preheader + body_html/body_text against either the supplied contact's data (when contact_id is provided) or stub data (first_name='Friend', last_name='Tester'). Subject is prefixed with [TEST]. Goes through the same MTA transport as production sends. No campaign is involved so the message is not attributed to campaign stats.","requestBody":{"content":{"application/json":{"schema":{"properties":{"contact_id":{"type":"string"},"email":{"type":"string"}},"required":["email"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Send a [TEST]-prefixed render of the template to a single email"}},"/templates/{templateID}/share":{"patch":{"description":"Sets templates.shared_at = COALESCE(shared_at, now()) so the row becomes visible via GET /shared/templates. Idempotent — re-sharing does not bump shared_at. Returns {id, shared_at}. 404 when the id is not owned by the calling tenant.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"shared_at":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Publish a template to the workspace (owner-only)"}},"/templates/{templateID}/star":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Unstar a template"},"post":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"starred":{"type":"boolean"}},"type":"object"}}},"description":"OK"}},"summary":"Star a template (per-tenant favorites)"}},"/templates/{templateID}/unshare":{"patch":{"description":"Clears templates.shared_at and shared_by_user_id. Pre-existing share_clone_audit rows are untouched — clones the recipients already took remain theirs. Returns {id, shared_at:null}. 404 when the id is not owned by the calling tenant.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"shared_at":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Retract a previously-shared template (owner-only)"}},"/tenant/impersonate":{"post":{"description":"Owner-only. Caller specifies a target user_id (same-tenant, non-admin). Returns a JWT with is_impersonating=true, target_user_id, impersonator_user_id, and exp bounded to ImpersonationMaxTTLMinutes (default 30 / max 120). Audit row tenant_admin.impersonation_start is written for every successful mint; tenant_admin.impersonation_denied is written for cross-tenant or admin-target rejections. Member email notification is queued via the audit row's target_email metadata for a follow-up worker (W247-A).","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"expires_at":{"type":"integer"},"impersonator_user_id":{"type":"string"},"target_user_id":{"type":"string"},"tenant_id":{"type":"string"},"token":{"type":"string"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"},"403":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Mint a short-lived impersonation JWT scoped to a member of the caller's tenant"}},"/tenant/pci-lite/ack":{"post":{"description":"Stamps pci_lite_ack_at = now() + pci_lite_ack_by_user_id = caller. Idempotent — re-acking refreshes the timestamp + user, which is the auditable record we want (the most recent operator who reviewed the controls). Body is empty; auth context provides tenant + user.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"ack_at":{"type":"string"},"ack_by_user_id":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Acknowledge the PCI-DSS-lite controls (owner-only)"}},"/tenant/pci-lite/attestation":{"post":{"description":"Builds a 1-2 page PDF (via go-pdf/fpdf — same library as the W102 audit-export PDF, the maintained fork of the now-archived jung-kurt/gofpdf) documenting tenant identification, ack timestamp + operator, PSPs detected in the 12-month window, the four controls in place, and the list of flagged campaigns. Returns the body inline as application/pdf with Content-Disposition: attachment. On any PDF-library panic / error the handler falls back to a self-contained HTML attestation the operator can Print-to-PDF from the browser.","responses":{"200":{"description":"application/pdf (or text/html fallback) with Content-Disposition: attachment"}},"summary":"Generate the annual PCI-DSS-lite attestation document (owner-only)"}},"/tenant/pci-lite/status":{"get":{"description":"Read-only — any signed-in tenant member can call it. Returns ack_at (zero = banner shown), ack_by_user_email (FK resolved at read time), last_attestation_at, and campaigns_in_scope: every campaign flagged contains_payment_link in the last 12 months (max 200 rows). campaigns_in_scope is always a non-null array so the FE renders an empty state without a typeof check.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"ack_at":{"type":"string"},"ack_by_user_email":{"type":"string"},"campaigns_in_scope":{"type":"array"},"last_attestation_at":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"PCI-DSS-lite acknowledgement state + 12-month flagged campaigns"}},"/tenant/sandbox/sends":{"get":{"description":"W213. Returns the most-recent simulated_sends rows for the current tenant ordered by created_at DESC. limit clamped to [1, 200] (default 50). Non-sandbox tenants legitimately get an empty list. List view omits headers — fetch a single id for full envelope.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List recent simulated sends"}},"/tenant/sandbox/sends/{id}":{"get":{"description":"W213. Returns id, tenant_id, campaign_id, contact_id, envelope_from, envelope_to, subject, body_size_bytes, headers (object), created_at.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"body_size_bytes":{"type":"integer"},"campaign_id":{"type":"string"},"contact_id":{"type":"string"},"created_at":{"type":"string"},"envelope_from":{"type":"string"},"envelope_to":{"type":"string"},"headers":{"type":"object"},"id":{"type":"string"},"subject":{"type":"string"},"tenant_id":{"type":"string"}},"type":"object"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Get full envelope for one simulated send"}},"/tenant/sandbox/status":{"get":{"description":"Returns {is_sandbox: bool}. A sandbox tenant has every outbound send intercepted into simulated_sends instead of reaching the MTA. The flag is set by a super-admin via PUT /admin/tenants/{id}/sandbox.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"is_sandbox":{"type":"boolean"}},"type":"object"}}},"description":"OK"}},"summary":"Check whether the current tenant is in sandbox mode"}},"/transactional/send":{"post":{"description":"Public single-shot transactional API. Honours suppression list, per-tenant frequency cap, monthly send cap (X-Quota-* headers on every response), and optionally the W133-C bounce-risk predictor when bounce_risk_check_enabled is true. Accepts inline subject+body OR template_id (template_vars are forwarded into the Liquid binding map). Returns status=queued|skipped_suppressed|skipped_frequency_cap|skipped_high_risk on 200; 400 on validation failure; 402 when the monthly plan cap is exhausted. Requires the transactional:send scope.","requestBody":{"content":{"application/json":{"schema":{"properties":{"body_html":{"type":"string"},"body_text":{"type":"string"},"bounce_risk_check_enabled":{"type":"boolean"},"extra_metadata":{"type":"object"},"from_email":{"type":"string"},"from_name":{"type":"string"},"reply_to":{"type":"string"},"send_time_optimization_enabled":{"type":"boolean"},"subject":{"type":"string"},"template_id":{"type":"string"},"template_vars":{"type":"object"},"to":{"type":"string"}},"required":["to"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Send a single transactional email"}},"/triggers":{"get":{"description":"Returns the tenant's triggers. Each item carries name, enabled flag, predicate (event_type, optional campaign_id, optional link_url, within_seconds), action (type, template_id or sequence_id), last_evaluated_at, and audit timestamps. v1 predicate event_types: opened, clicked. v1 action types: send_template, enroll_sequence. within_seconds bounded 1..7776000 (90 days).","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"},"limit":{"type":"integer"},"offset":{"type":"integer"}},"type":"object"}}},"description":"OK"}},"summary":"List behavioral triggers"},"post":{"description":"predicate.event_type is one of {opened, clicked}. predicate.within_seconds bounded 1..7776000 (90 days). predicate.campaign_id and predicate.link_url are optional filters (link_url only valid when event_type='clicked'). action.type is one of {send_template, enroll_sequence}; the corresponding template_id or sequence_id must be set on the action. Cross-tenant template/sequence ids return 400. Triggers fire at most once per (trigger, contact) — enforced by the UNIQUE constraint on trigger_runs.","requestBody":{"content":{"application/json":{"schema":{"properties":{"action":{"type":"object"},"enabled":{"type":"boolean"},"name":{"type":"string"},"predicate":{"type":"object"}},"required":["name","predicate","action"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Create a behavioral trigger"}},"/triggers/{triggerID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Delete a trigger (cascades trigger_runs)"},"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"action":{"type":"object"},"created_at":{"type":"string"},"enabled":{"type":"boolean"},"id":{"type":"string"},"last_evaluated_at":{"type":"string|null"},"name":{"type":"string"},"predicate":{"type":"object"},"updated_at":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Get a trigger"},"patch":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"action":{"type":"object"},"enabled":{"type":"boolean"},"name":{"type":"string"},"predicate":{"type":"object"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Partial update (name, enabled, predicate, action). Validation rules from POST apply on the post-merge value."}},"/triggers/{triggerID}/test-fire":{"post":{"description":"Bypasses the predicate (operator vetted). Honours the per-(trigger, contact) UNIQUE constraint — 409 on re-fire. Returns action_result on success: 'success', 'error: \u003creason\u003e', or 'skipped: \u003creason\u003e'.","requestBody":{"content":{"application/json":{"schema":{"properties":{"contact_id":{"type":"string"}},"required":["contact_id"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"Manually fire the trigger for one contact"}},"/user-invites":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List pending (unaccepted) team invites"},"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"email":{"type":"string"},"role":{"type":"string (owner|member|viewer)"}},"required":["email"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"properties":{"accept_url":{"type":"string"},"email":{"type":"string"},"expires_at":{"type":"string"},"id":{"type":"string"},"role":{"type":"string"},"token":{"type":"string"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"},"409":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Create a single-use invite (returns the accept URL — share manually for v1)"}},"/user-invites/{inviteID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Revoke a pending invite"}},"/users":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List users in the calling tenant"}},"/users/{userID}":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Remove a user from the tenant (rejected if self or last owner)"}},"/users/{userID}/role":{"patch":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"role":{"type":"string (owner|member|viewer)"}},"required":["role"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"role":{"type":"string"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Change a user's role (rejected if self or demoting the last owner)"}},"/warmup/schedule":{"get":{"description":"Returns the per-day plan (day_offset 0..29) with daily_target_recipients + template_slug + notes + send_status. The super-admin POST /admin/tenants/{id}/warmup-schedule/generate endpoint is the only writer.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"Tenant read-only view of the 30-row warmup schedule generated by a super-admin"}},"/warmup/{domainID}/isp-curves":{"get":{"description":"Returns the warmup_isp_curves rows in ISP-alpha order. Empty array when no overrides have been written; the gate falls back to the auto-curve cap for any ISP without a row.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"domain":{"type":"string"},"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"List per-ISP warmup overrides for this (tenant, domain)"},"post":{"description":"Body: isp in {gmail|outlook|yahoo|apple|aol|other}, daily_cap (1..1_000_000), ramp_factor (0.5..3.0, default 1.0). 409 on duplicate (tenant, domain, isp) — use PATCH to update instead.","requestBody":{"content":{"application/json":{"schema":{"properties":{"daily_cap":{"type":"integer"},"isp":{"type":"string"},"ramp_factor":{"type":"number"}},"required":["isp","daily_cap"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"properties":{"created_at":{"type":"string"},"daily_cap":{"type":"integer"},"domain":{"type":"string"},"id":{"type":"string"},"isp":{"type":"string"},"ramp_factor":{"type":"number"},"tenant_id":{"type":"string"},"updated_at":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Create a per-ISP warmup override"}},"/warmup/{domainID}/isp-curves/{isp}":{"delete":{"description":"Idempotent: returns 204 whether or not a row existed for (tenant, domain, isp). After deletion the gate falls back to the auto-curve cap for that ISP.","responses":{"204":{"description":"Deleted"}},"summary":"Remove a per-ISP warmup override"},"patch":{"description":"At least one of daily_cap or ramp_factor must be present. 404 when no row exists for (tenant, domain, isp).","requestBody":{"content":{"application/json":{"schema":{"properties":{"daily_cap":{"type":"integer"},"ramp_factor":{"type":"number"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"created_at":{"type":"string"},"daily_cap":{"type":"integer"},"domain":{"type":"string"},"id":{"type":"string"},"isp":{"type":"string"},"ramp_factor":{"type":"number"},"tenant_id":{"type":"string"},"updated_at":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Update an existing per-ISP warmup override"}},"/webhook-deliveries/{id}/retry":{"post":{"description":"Looks up the delivery row tenant-scoped, POSTs the stored payload with the same HMAC signature + X-SendBolt-* headers the dispatcher uses, and writes a fresh row in webhook_delivery_logs. The parent dispatcher row is marked 'delivered' only on 2xx — non-2xx leaves the existing backoff schedule untouched. Requires webhooks:write scope.","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"status":{"type":"string"},"status_code":{"type":"integer"}},"type":"object"}}},"description":"OK"},"202":{"content":{"application/json":{"schema":{"properties":{"id":{"type":"string"},"status":{"type":"string"}},"type":"object"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Synchronously re-fire a failed outbound webhook delivery"}},"/webhooks/bounce":{"post":{"parameters":[{"description":"Required when using HMAC auth","in":"query","name":"tenant_id","required":false,"schema":{"type":"string"}},{"description":"sha256=\u003chex\u003e over raw body","in":"header","name":"X-Signature","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"bounce_type":{"type":"string (hard|soft, default hard)"},"email":{"type":"string"},"reason":{"type":"string"},"tenant_id":{"type":"string"}},"required":["email"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"OK"}},"security":[],"summary":"Mark contact as bounced. Auth via tenant JWT, OR via X-Signature: sha256=\u003chex\u003e header (HMAC-SHA256 of the raw body using the tenant's bounce_hmac_secret) plus ?tenant_id= query param."}},"/webhooks/playground/attempts":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"type":"array"}},"type":"object"}}},"description":"OK"}},"summary":"W227 — list the most-recent webhook delivery attempts for the current tenant (default 50, max 200). Includes playground custom-URL fires plus the regular simulate/replay activity, ordered most-recent first."}},"/webhooks/playground/test-fire":{"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"custom_url":{"type":"string"},"event_type":{"type":"string"},"payload":{"type":"object"},"webhook_id":{"type":"string"}},"required":["webhook_id","event_type"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{},"type":"object"}}},"description":"OK"}},"summary":"W227 — fire a synthetic signed event at either the webhook's configured URL or a per-request custom URL (https-only, no private/loopback/link-local). Reuses the W206 simulator's signing pipeline; rate-limited to 60/min/tenant. payload defaults to a deterministic sample when omitted."}},"/whoami":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"tenant_id":{"type":"string"}},"type":"object"}}},"description":"OK"}},"summary":"Identify the calling tenant"}}},"security":[{"BearerAuth":[]}],"servers":[{"description":"Current host","url":"/api/v1"}]}
