API Reference
Host-server and client endpoints for the T-Chat Edge Function API.
Base URL:
https://<project-ref>.supabase.co/functions/v1/apiAll JSON bodies use Content-Type: application/json. Errors return { "error": "message" } unless noted.
Authentication
| Auth | Header | Used for |
|---|---|---|
| Operator | Authorization: Bearer <supabase_access_token> | Dashboard: list own tenants, API keys, users, conversations |
| Admin | X-Admin-Secret: <secret> | Super-admin scripts: all tenants (optional; not used by dashboard SPA) |
| API key | Authorization: Bearer tes_… | User provisioning, token exchange, conversation CRUD, system messages |
| User JWT | Authorization: Bearer <accessToken> | Moderated message send/edit (custom HS256 chat token) |
Dashboard — signup
POST /v1/auth/signup
Public. Creates a Supabase Auth user, tenant, and owner membership in one request. Email is auto-confirmed.
Body
{ "email": "owner@acme.com", "password": "minimum-8-chars", "fullName": "Jane Owner", "orgName": "Acme Community"}Response 201
{ "tenantId": "uuid", "tenantName": "Acme Community", "session": { "accessToken": "…", "refreshToken": "…", "expiresIn": 3600, "tokenType": "Bearer" }}Response 409 — email already registered
Dashboard login uses Supabase Auth signInWithPassword on the client (no dedicated Edge login route).
Supabase project: enable the Email auth provider and set Site URL / redirect URLs to your dashboard origin (e.g. http://localhost:5173).
Health
GET /health
No auth.
Response 200
{ "status": "ok" }Admin — tenants
GET /v1/tenants
Auth: operator Bearer token (member tenants only) or X-Admin-Secret (all tenants).
Response 200
{ "data": [ { "tenantId": "uuid", "name": "Acme", "plan": "sandbox", "createdAt": "2026-01-01T00:00:00.000Z" } ]}POST /v1/tenants
Auth: operator Bearer token (creates tenant and owner membership) or X-Admin-Secret (super-admin scripts only).
Body
{ "name": "Acme" }New tenants always start on the Sandbox plan with no billing period. A tenant_billing row is created automatically.
Response 201 — TenantResponse (plan is sandbox).
GET /v1/tenants/:tenantId/billing
Auth: operator Bearer token with access to the tenant.
Returns commercial status, billing period dates, usage counters for the current period, and a server-computed upgradeAction for the dashboard contact modal.
Response 200 — TenantBillingResponse
{ "tenantId": "uuid", "plan": "sandbox", "billingStatus": "none", "commercialStatusLabel": "Free evaluation", "periodStart": null, "periodEnd": null, "isPastDue": false, "pastDueMessage": null, "upgradeMessage": "Sandbox includes evaluation limits…", "upgradeAction": { "label": "Contact us to upgrade", "intent": "upgrade", "subject": "T-Chat plan upgrade", "description": "Hello,\\n\\nI would like to upgrade…", "selectablePlans": ["starter", "pro", "enterprise"], "defaultPlan": "starter", "helperDescription": "Move from Sandbox to Starter, Pro, or Enterprise…" }, "usagePeriodLabel": "Jun 2026 (UTC billing month)", "trialEndsAt": null, "usage": { "mau": { "used": 0, "limit": 50, "unit": "count" }, "messages": { "used": 0, "limit": 1000, "unit": "count" }, "storage": { "used": 0, "limit": 262144000, "unit": "bytes" }, "bandwidth": { "used": 0, "limit": 5368709120, "unit": "bytes" } }}billingStatus: none (Sandbox / no open period), active, past_due, or trial (when trial_ends_at is set and in the future). trialEndsAt is the ISO end timestamp for Starter trials; null otherwise.
Paid periods are opened by ops via record_tenant_renewal() (see Ops runbook below). Usage counters increment in TCH-8; bandwidth returns 0 until then.
GET /v1/tenants/:tenantId/users
List provisioned users for a tenant (paginated).
Query
| Param | Description |
|---|---|
limit | Optional. Default 50, max 100. |
cursor | Opaque cursor from a prior response’s nextCursor. |
appMetadata.<key> | Optional. Flat filter params (e.g. appMetadata.plan=pro). Rows must contain all given key/value pairs in appMetadata (JSON containment). |
Response 200
{ "data": [ UserResponse, ... ], "nextCursor": "2026-01-01T00:00:00.000Z|uuid-or-null"}nextCursor is null on the last page. System users (__system*) are excluded.
GET /v1/tenants/:tenantId/conversations
List conversations for a tenant (newest activity first, paginated).
Query — same pagination and appMetadata.* filters as user list above.
Response 200
{ "data": [ ConversationResponse, ... ], "nextCursor": "|uuid"}For conversations with no messages, cursor timestamps encode as |conversationId (empty timestamp prefix).
GET /v1/tenants/:tenantId/system-profile
Auth: operator Bearer token (tenant member).
Returns the tenant’s system sender profile (name and avatar shown in chat for server-posted messages).
Response 200
{ "userId": "uuid", "displayName": "Support", "avatarUrl": "https://example.com/avatar.png"}Response 404 — system user not provisioned for tenant
PATCH /v1/tenants/:tenantId/system-profile
Auth: operator Bearer token (tenant member).
Creates or updates the reserved system user (external_id: __system). Does not add the system user as a conversation member.
Body
{ "displayName": "Support", "avatarUrl": "https://example.com/avatar.png"}avatarUrl may be null to clear.
Response 200 — SystemProfile (userId, displayName, avatarUrl)
Admin — API keys
POST /v1/api-keys
Body
{ "tenantId": "uuid", "name": "production", "scopes": ["read", "write", "system:write"]}Default scopes when omitted: read, write, system:write. The system:write scope is required for POST /v1/system-messages.
Response 201 — includes one-time apiKey plaintext.
GET /v1/api-keys?tenantId=<uuid>
Response 200
{ "data": [ ApiKeyResponse, ... ] }DELETE /v1/api-keys/:apiKeyId
Revokes a key. Response 200 — ApiKeyResponse.
Admin — Webhook endpoints
Host webhooks are available on Starter, Pro, and Enterprise plans only. Sandbox tenants receive 403 Forbidden on these routes.
T-Chat emits signed HTTPS events to your backend; your platform owns downstream email, push, and nudges.
POST /v1/webhook-endpoints
Body
{ "tenantId": "uuid", "url": "https://api.example.com/tchat/webhooks", "eventTypes": ["conversation.unread_stale"], "eventSettings": { "conversation.unread_stale": { "staleAfterMinutes": 30, "redeliverAfterMinutes": 1440 } }}eventTypes defaults to ["conversation.unread_stale"] when omitted. url must use HTTPS.
eventSettings is an optional per-(endpoint, event) timing map; only events with configurable timing accept it. For conversation.unread_stale: staleAfterMinutes (1–1440, default 30) is how long a message stays unread before the event fires; redeliverAfterMinutes (0–43200, default 1440) is how often T-Chat re-checks unchanged stale sets for timer-based re-notification (0 = notify once). When the stale-member set is unchanged, a timer re-delivery fires only if new unread non-event message activity arrived since the last successful delivery. Settings are stored sparsely in webhook_endpoint_event_settings; endpoints with no row fall back to the defaults.
Response 201 — includes one-time signingSecret plaintext (whsec_…). Store it immediately; it is not returned again. Stored eventSettings are echoed back.
GET /v1/webhook-endpoints?tenantId=<uuid>
Lists active endpoints for the tenant (excludes legacy soft-revoked rows).
Response 200
{ "data": [ WebhookEndpointResponse, ... ] }GET /v1/webhook-endpoints/:webhookEndpointId
Response 200 — WebhookEndpointDetailResponse (includes signingSecretHint, e.g. whsec_••••••a1b2c3 for cross-checking the secret saved at creation).
PATCH /v1/webhook-endpoints/:webhookEndpointId
Updates an existing endpoint’s URL, subscribed events, and/or per-event timing. Send any subset; at least one of url, eventTypes, eventSettings is required. url must be HTTPS and unique among the tenant’s active endpoints; eventTypes must be a non-empty subset of the known event catalog. When eventTypes is provided, settings for events no longer subscribed are pruned. Revoked endpoints return 409. The signing secret is never changed. Logged in the activity log with the resulting URL and event types.
Request body
{ "url": "https://api.example.com/tchat/webhooks", "eventTypes": ["conversation.unread_stale"], "eventSettings": { "conversation.unread_stale": { "staleAfterMinutes": 60, "redeliverAfterMinutes": 0 } }}Response 200 — WebhookEndpointDetailResponse (includes signingSecretHint and the stored eventSettings).
DELETE /v1/webhook-endpoints/:webhookEndpointId
Permanently deletes the endpoint and its delivery dedup rows (webhook_stale_set_deliveries cascade). Logged in the activity log with URL and event types.
Response 200
{ "deleted": true, "webhookEndpointId": "uuid" }POST /v1/webhook-endpoints/:webhookEndpointId/test
Sends a signed sample conversation.unread_stale payload (sample: true). Response 200 — { "delivered": true } when the endpoint returns 2xx.
Host webhooks — conversation.unread_stale
When a conversation member has had unread messages longer than the endpoint’s staleAfterMinutes (default 30 minutes, measured from the oldest unread message), T-Chat POSTs a signed event to each subscribed endpoint. One delivery lists all stale members in that conversation. Timing is resolved per (endpoint, event) from webhook_endpoint_event_settings, so two endpoints can use different thresholds.
While a conversation stays unread, T-Chat re-checks every redeliverAfterMinutes (default 1440 / daily; 0 = notify once). If the stale-member set is unchanged, it re-delivers only when new unread non-event message activity arrived since the last successful delivery. It also delivers immediately whenever the stale-member set changes (for example a member reads or another member becomes stale).
Payload
{ "event": "conversation.unread_stale", "tenantId": "uuid", "conversationId": "uuid", "externalConversationId": "deal-abc123", "emittedAt": "2026-06-22T12:00:00.000Z", "staleMembers": [ { "userId": "uuid", "externalId": "charlie", "lastReadAt": "2026-06-22T10:00:00.000Z", "oldestUnreadSentAt": "2026-06-22T10:05:00.000Z", "oldestUnreadKind": "system", "oldestUnreadMetadata": { "type": "announcement", "workflowId": "wf_123" } } ]}All timestamps are ISO 8601 UTC (…Z). lastReadAt is null when the member has never read the conversation. externalConversationId is included only when the conversation has a host external_id set; otherwise the field is omitted.
Each staleMembers[] entry includes the oldest unread message for that member:
oldestUnreadKind—user,system, orevent(frommessages.kind). Timeline events (event) are excluded from stale-unread eligibility, so this is typicallyuserorsystem.oldestUnreadMetadata— optional. Copied from the oldest unread message’s host-ownedcontent.metadatawhen present. Omitted for messages without metadata.
oldestUnreadMetadatais host-owned, same ascontent.metadataon system/event messages. T-Chat platform data (moderation, signals, etc.) will use separate message fields — not this webhook field.
Dedup: T-Chat tracks a stale-set hash per (endpoint, conversation) and skips delivery while the set is unchanged. A new delivery fires when the stale set changes (member reads, new member goes stale, etc.). Failed deliveries do not persist the hash; the next cron tick retries.
Headers on delivery
| Header | Value |
|---|---|
Content-Type | application/json |
x-tchat-signature | t=<unix_seconds>,v1=<hex_hmac_sha256> |
Signature is HMAC-SHA256 over {timestamp}.{rawBody} using your endpoint signing secret (Stripe-style).
Verify (Node.js example)
import crypto from "node:crypto";
function verifyTchatWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) { const parts = Object.fromEntries( signatureHeader.split(",").map((part) => part.split("=")), ); const timestamp = Number(parts.t); const provided = parts.v1; if (!Number.isFinite(timestamp) || !provided) return false; if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSec) return false;
const expected = crypto .createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`) .digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));}Return 2xx quickly from your handler; enqueue email/push/workflow side effects asynchronously.
Cron — Webhook stale unread
POST /v1/cron/webhook-stale-unread
Auth: X-Cron-Secret header must match CRON_SECRET.
Scans stale unread members (30-minute threshold), groups by conversation, compares stale-set hashes per endpoint, and POSTs signed payloads (3× retry with exponential backoff in the same run). Each run writes one job_runs row per affected tenant (shared invocationId) for operator observability.
Response 200
{ "delivered": 2, "skipped": 5, "failed": 0, "candidates": 7, "invocationId": "00000000-0000-4000-8000-000000000001"}delivered, skipped, failed, and candidates are aggregate delivery-attempt counts across all tenants (endpoint × conversation). Per-tenant breakdown is stored in job_runs and queryable via GET /v1/tenants/:tenantId/job-runs.
Schedule (production): every 30 minutes via pg_cron + pg_net. Job webhook-stale-unread is created by db push (migration 20260629120000_cron_jobs_via_vault.sql). URL and invoke secret are read from Supabase Vault at runtime — not embedded in cron.job records.
One-time bootstrap per Supabase project (Vault + Edge CRON_SECRET):
pnpm exec supabase db pushPROJECT_REF=<project-ref> CRON_SECRET="$(openssl rand -hex 32)" \ pnpm db:setup-cron-vault --linked# or: ./scripts/setup-cron-vault.sh --project-ref <ref> --linkedSecret rotation: update cron_invoke_secret in Vault and CRON_SECRET on the Edge Function (re-run bootstrap with --force, or update manually). No cron reschedule needed.
Job runs (dashboard)
GET /v1/tenants/:tenantId/job-runs
Auth: operator session (dashboard) with access to the tenant.
Paginated history of per-tenant cron job runs. Currently populated by webhook-stale-unread; the table is generic for future cron jobs.
Query params
| Param | Description |
|---|---|
jobName | Optional filter, e.g. webhook-stale-unread |
from | ISO timestamp — startedAt >= from |
to | ISO timestamp — startedAt <= to |
cursor | Pagination cursor from nextCursor |
limit | Page size 1–100 (default 50) |
Response 200
{ "data": [ { "jobRunId": "00000000-0000-4000-8000-000000000010", "invocationId": "00000000-0000-4000-8000-000000000001", "jobName": "webhook-stale-unread", "tenantId": "00000000-0000-4000-8000-000000000002", "status": "partial", "startedAt": "2026-06-30T10:00:00.000Z", "finishedAt": "2026-06-30T10:00:05.000Z", "candidates": 3, "delivered": 1, "skipped": 1, "failed": 1, "error": null, "metadata": {} } ], "nextCursor": null}status is success, partial (mixed delivered/skipped and failures), or failed (only failures). Counts are delivery attempts (endpoint × conversation) for that tenant in that run.
GET /v1/tenants/:tenantId/job-runs/summary
Auth: operator session (dashboard) with access to the tenant.
Aggregated totals over the same filter window as the list endpoint. Powers the dashboard Deliveries summary cards with true window totals (server-side aggregation, not a sum of one paginated page).
Query params
| Param | Description |
|---|---|
jobName | Optional filter, e.g. webhook-stale-unread |
from | ISO timestamp — startedAt >= from |
to | ISO timestamp — startedAt <= to |
Response 200
{ "totalRuns": 288, "successRuns": 270, "partialRuns": 15, "failedRuns": 3, "totalCandidates": 1240, "totalDelivered": 1100, "totalSkipped": 120, "totalFailed": 20, "lastRunAt": "2026-06-30T10:00:00.000Z", "lastRunStatus": "success"}Cron — Billing past due
POST /v1/cron/billing-past-due
Auth: X-Cron-Secret header must match CRON_SECRET (same secret as webhook cron).
Flips open billing periods from active to past_due when period_end is in the past and sets commercial_frozen_at. The dashboard billing API runs the same RPC lazily on GET /v1/tenants/:tenantId/billing.
Response 200
{ "markedPastDue": 2 }Schedule (production): daily at 00:05 UTC via pg_cron + pg_net. Job billing-past-due is created by db push (migration 20260629120000_cron_jobs_via_vault.sql). Uses the same Vault bootstrap as webhook cron (pnpm db:setup-cron-vault --linked).
Ops runbook — manual billing renewal
Ops records payment and opens a new commercial period via SQL (service role). Storage on tenant_billing is never reset; period-scoped counters (mau_count, message_count, bandwidth_bytes) start at zero on each renewal.
Monthly Starter example
select public.record_tenant_renewal( p_tenant_id := '<tenant-uuid>'::uuid, p_plan := 'starter', p_period_start := '2026-06-01T00:00:00+00'::timestamptz, p_period_end := '2026-07-01T00:00:00+00'::timestamptz, p_ops_notes := 'Invoice #1042 paid 2026-06-01');Annual Pro example
select public.record_tenant_renewal( p_tenant_id := '<tenant-uuid>'::uuid, p_plan := 'pro', p_period_start := '2026-06-01T00:00:00+00'::timestamptz, p_period_end := '2027-06-01T00:00:00+00'::timestamptz, p_ops_notes := 'Annual invoice #1043');Returns the new billing_periods.id. Closes any prior open period (active or past_due), updates tenants.plan and tenants.current_billing_period_id, and inserts a fresh tenant_usage_metering row.
Starter trial downgrade (env-flagged off in v1)
Column tenant_billing.trial_ends_at supports a future cron that downgrades unpaid trials to Sandbox. Document only — no cron is scheduled until the feature flag ships.
Host API — users and tokens
appMetadata is host-server only: stored on users and conversations, readable/writable via API key routes, and not exposed to end-user JWT sessions or the React SDK.
GET /v1/users
API key auth. Paginated list of users in the API key’s tenant.
Query
| Param | Description |
|---|---|
limit | Optional. Default 50, max 100. |
cursor | Opaque cursor from nextCursor. |
externalId | Optional. Filter to one host-app user id. |
appMetadata.<key> | Optional. Metadata filter (e.g. appMetadata.crmId=123). |
Response 200 — UserListResponse (data, nextCursor)
GET /v1/users/:externalId
API key auth. Single user by host externalId.
Response 200 — UserResponse
Response 404 — user not found
POST /v1/users
API key auth. Upserts a user by externalId within the tenant.
Body
{ "externalId": "user-42", "displayName": "Ada", "avatarUrl": "https://example.com/a.png", "appMetadata": { "plan": "pro", "crmId": "123" }}All fields except externalId are optional. Omitted displayName / avatarUrl leave existing values unchanged on update. appMetadata is shallow-merged on upsert (existing keys are overwritten; omitted keys are preserved).
To mint review tokens (POST /v1/review-token), provision a separate admin identity with operator capability — do not use normal end-user ids:
{ "externalId": "admin-ops", "displayName": "Support Admin", "appMetadata": { "tchatRole": "operator" }}Participant chat (POST /v1/token) does not require tchatRole. Only review-token mint checks this flag (plus the reserved __system identity).
Response 201 — new user (UserResponse)
Response 200 — existing user updated (UserResponse). Re-provisioning a deleted user clears deletedAt and restores any profile fields you send.
DELETE /v1/users/:externalId
API key auth. Soft-deletes a user: clears displayName and avatarUrl, sets deletedAt. Conversation memberships and message history are kept.
Response 200 — UserResponse with deletedAt set (idempotent if already deleted)
Response 404 — user not found
Deleted users cannot exchange tokens (POST /v1/token returns 403). Re-provision with POST /v1/users to restore access.
User conversation memberships (bulk)
Add or remove one user from many conversations in a single request. :externalUserId is always the host user id (users.external_id).
Use the /externals/ routes when the body lists conversationExternalIds. Use the /internals/ routes when the body lists conversationIds. Do not mix id types in one request.
Up to 100 conversation ids per request. Unknown conversation ids are listed in notFound; resolved ids are still processed. Unknown :externalUserId returns 404 for the whole request. A database error mid-batch returns 500; earlier chunks in the same request may already be committed.
POST /v1/users/:externalUserId/externals/conversations/memberships
Body
{ "conversationExternalIds": ["deal-abc123", "support-room-7"], "addedByExternalId": "user-42"}addedByExternalId is optional. Unknown values are ignored (same as POST /v1/conversations/:conversationId/members).
Response 201
{ "added": 2, "skipped": 0, "notFound": ["deal-missing"]}DELETE /v1/users/:externalUserId/externals/conversations/memberships
Body
{ "conversationExternalIds": ["deal-abc123", "support-room-7"]}Response 200
{ "removed": 2, "notFound": ["deal-missing"]}POST /v1/users/:externalUserId/internals/conversations/memberships
Body
{ "conversationIds": [ "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "b2c3d4e5-f6a7-8901-bcde-f12345678901" ], "addedByExternalId": "user-42"}Response 201 — same shape as the externals POST response.
DELETE /v1/users/:externalUserId/internals/conversations/memberships
Body
{ "conversationIds": [ "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "b2c3d4e5-f6a7-8901-bcde-f12345678901" ]}Response 200 — same shape as the externals DELETE response.
For adding many users to one conversation, use POST /v1/conversations/:conversationId/members instead.
POST /v1/token
API key auth. Requires read scope. Mints a participant session JWT (app_metadata.tenant_id only — no tchat_role).
Body
{ "externalUserId": "user-42" }Response 200
{ "accessToken": "eyJ…", "tokenType": "Bearer", "expiresIn": 21600}POST /v1/review-token
API key auth. Requires read scope. Mints a review session JWT for Host Operators (app_metadata.tchat_role: "reviewer"). Separate from participant tokens — never use a participant token for operator/review embeds.
Body
{ "externalUserId": "user-42" }Response 200
{ "accessToken": "eyJ…", "tokenType": "Bearer", "expiresIn": 3600}Errors: 404 user not provisioned · 403 user deleted or not operator-capable for review mint ({ "error": "Forbidden" })
The same provisioned user may hold an active participant token and review token concurrently. Mint each via its own endpoint; tokens are independent (TTL-only expiry).
POST /v1/tenants/:tenantId/users/:externalId/review-token
Operator Bearer token (tenant member) or X-Admin-Secret. Mints the same review session JWT as POST /v1/review-token for a provisioned user in the tenant.
Response 200 — same shape as POST /v1/review-token.
Errors: 404 user not provisioned · 403 user deleted or not operator-capable ({ "error": "Forbidden" })
GET /v1/me
API key auth. Returns key metadata.
Host API — conversations
POST /v1/conversations
Body
{ "memberExternalIds": ["user-42", "user-99"], "externalId": "deal-abc123", "name": "Support", "icon": "💬", "appMetadata": { "dealId": "abc123" }}externalId is optional. When set, it must be unique among active (non-deleted) conversations for the tenant. A duplicate returns 409. After DELETE, the same externalId may be used again. This is the host’s stable identifier for the conversation (distinct from memberExternalIds, which identify users).
appMetadata is optional on create.
Response 201 — ConversationResponse (includes externalId when set)
Response 409 — active conversation already exists for this externalId
GET /v1/conversations
Lists conversations for the API key’s tenant (paginated).
Query
| Param | Required | Description |
|---|---|---|
externalUserId | no | When set, limits results to conversations that user belongs to. When omitted, returns all tenant conversations. |
externalId | no | When set, limits results to the active conversation with this host externalId (at most one per tenant). |
limit | no | Default 50, max 100. |
cursor | no | From nextCursor. |
appMetadata.<key> | no | Metadata filter. |
Response 200 — ConversationListResponse
{ "data": [ ConversationResponse, ... ], "nextCursor": "2026-06-15T12:00:00.000Z|uuid"}GET /v1/conversations/:conversationId
Response 200 — ConversationResponse
PATCH /v1/conversations/:conversationId
Body (all optional)
{ "name": "New name", "icon": "🎉", "isArchived": false, "appMetadata": { "status": "closed" }}appMetadata is shallow-merged with the existing object when provided.
Response 200 — ConversationResponse
DELETE /v1/conversations/:conversationId
Soft-deletes the conversation (sets deleted_at). Members and message history are retained. Releases the externalId for reuse on a future create.
Response 204 — empty body
Response 404 — not found or already deleted
POST /v1/conversations/:conversationId/members
Body
{ "memberExternalIds": ["user-100"], "addedByExternalId": "user-42"}Response 201 — { "added": 1 }
Adds many users to one conversation. To add one user to many conversations, use the bulk membership routes under POST /v1/users/:externalUserId/…/conversations/memberships.
DELETE /v1/conversations/:conversationId/members/:userId
Removes a member. :userId is the internal UUID from UserResponse.userId (not the host externalId).
Response 200 — { "removed": true }
Response 404 — member not found
Host API — system messages
System messages are sent by your backend (not end-user browsers). They appear in chat with the tenant-configured system name and avatar. Moderation is skipped.
Configure the profile first via dashboard System profile or PATCH /v1/tenants/:tenantId/system-profile.
POST /v1/system-messages
Auth: API key with system:write scope.
Body
{ "conversationId": "uuid", "content": { "text": "Your order has shipped.", "metadata": { "type": "announcement", "workflowId": "wf_123" } }, "localId": "optional-client-id-for-idempotency"}content.metadata is optional. When present, it must be a plain JSON object (max 4096 bytes serialized). Reserved keys inside metadata are rejected: kind, eventKind, source, text, attachment, mentions, replyTo.
metadatais host-owned. Use it for your application’s opaque context (routing labels, CRM IDs, workflow tags). T-Chat platform data (moderation results, signals, etc.) will use separatecontentfields — do not put them inmetadata. End-userPOST /v1/messagesdoes not acceptmetadata.
Response 201 — MessageResponse with content.kind set to "system"
Response 200 — same message if localId was already used in that conversation
Response 403 — API key missing system:write
Response 404 — conversation not found in tenant
Response 409 — system profile not configured (configure via dashboard or PATCH system-profile)
The API inserts the message as the tenant’s __system user, broadcasts message:new on Realtime channel conversation:{id}, and may send web push to human members.
Example (host server)
curl -X POST "$API_URL/v1/system-messages" \ -H "Authorization: Bearer $TENANT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "conversationId": "CONVERSATION_UUID", "content": { "text": "Welcome! A moderator will join shortly." } }'POST /v1/externals/conversations/system-messages
Same behavior as POST /v1/system-messages, but targets the conversation by host externalConversationId instead of internal UUID.
Body
{ "externalConversationId": "deal-abc123", "content": { "text": "Your order has shipped." }, "localId": "optional-client-id-for-idempotency"}Response — same status codes and MessageResponse shape as POST /v1/system-messages. Unknown or soft-deleted externalConversationId returns 404.
Example
curl -X POST "$API_URL/v1/externals/conversations/system-messages" \ -H "Authorization: Bearer $TENANT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "externalConversationId": "deal-abc123", "content": { "text": "Welcome! A moderator will join shortly." } }'POST /v1/users rejects externalId "__system" — that identity is reserved for system messages.
Host API — events
Timeline event lines (kind: "event") are host- or platform-posted silent thread markers. They do not increment unread counts, trigger push, or update last_message preview.
POST /v1/events
Auth: API key with system:write scope.
Body
{ "conversationId": "uuid", "eventKind": "optional-host-label", "content": { "text": "Deal marked urgent", "metadata": { "type": "sla_warning", "ticketId": "T-99" } }, "localId": "optional-client-id-for-idempotency"}content.metadata follows the same host-owned rules as system messages (optional, max 4096 bytes, reserved keys rejected). See the callout under POST /v1/system-messages.
Response 201 — MessageResponse with content.kind set to "event" and content.source set to "host".
POST /v1/externals/conversations/events
Same as POST /v1/events, but targets the conversation by host externalConversationId.
User JWT — attachments
Upload flow: init → PUT to Storage → complete → send message with attachmentId.
Max upload size: 100MB. Image preview thumbnails are not generated yet; previewAvailable is always false and download-url?variant=preview returns 404 until thumbnail support ships.
POST /v1/attachments/init
Conversation member only. Reserves an attachment row and returns a signed upload URL.
Body
{ "conversationId": "uuid", "filename": "photo.jpg", "mimeType": "image/jpeg", "sizeBytes": 12345}Response 201
{ "attachmentId": "uuid", "uploadUrl": "https://…", "uploadToken": "…", "storagePath": "tenant/conv/attachment/file.jpg", "expiresAt": "ISO8601"}Response 413 — sizeBytes exceeds 100MB
Pending uploads expire after 24 hours.
POST /v1/attachments/:attachmentId/complete
Uploader only. Verifies the file exists in Storage and marks the attachment ready.
Response 200
{ "attachmentId": "uuid", "status": "ready", "filename": "photo.jpg", "mimeType": "image/jpeg", "sizeBytes": 12345, "previewAvailable": false}Response 400 — file not uploaded yet
Response 410 — pending upload expired
GET /v1/attachments/:attachmentId/download-url
Conversation member only. Returns a short-lived signed URL.
Query: variant=original (default) or variant=preview
Response 200
{ "downloadUrl": "https://…", "expiresAt": "ISO8601", "variant": "preview"}Response 404 — attachment not ready, or preview not available
User JWT — messages (moderated)
POST /v1/messages
Runs harm, profanity, and contact-ID checks before insert (caption only for attachment messages).
Body — text
{ "conversationId": "uuid", "localId": "client-generated-id", "content": { "text": "Hello" }}Body — attachment
{ "conversationId": "uuid", "localId": "client-generated-id", "attachmentId": "uuid", "content": { "text": "optional caption" }}User messages omit content.kind or use "user". System messages use kind: "system" (host API only).
Response 201 — MessageResponse includes mediaType (text | attachment) and, for attachments, content.attachment metadata with previewAvailable.
Response 422 — moderation rejection
{ "error": "Message rejected by moderation", "reason": "profanity", "detail": "Message contains profanity"}reason: harm profanity contact_id
PATCH /v1/messages/:messageId
Sender-only. Same moderation as create.
Body
{ "content": { "text": "Updated text" } }Response 200 — MessageResponse
Client SDK
GET /v1/client-config
Public. Returns Supabase project URL and anon key for @tesark/chat-react. The anon key is safe to expose in browsers; RLS enforces access with the exchanged user JWT.
Response 200
{ "supabaseUrl": "https://<project-ref>.supabase.co", "supabaseAnonKey": "eyJ..."}Response 503 — SUPABASE_URL or SUPABASE_ANON_KEY not set on the Edge Function
Pro — Web Push
GET /v1/push/vapid-public-key
Public. Returns the VAPID public key when web push is configured.
Response 200
{ "publicKey": "..." }Response 503 — VAPID_PUBLIC_KEY not set
POST /v1/push-subscriptions
Auth: user JWT (Authorization: Bearer <accessToken>)
Body — PushSubscriptionRequest
{ "endpoint": "https://…", "keys": { "p256dh": "…", "auth": "…" }}Response 201
{ "subscriptionId": "uuid", "endpoint": "https://…", "createdAt": "ISO-8601"}DELETE /v1/push-subscriptions
Auth: user JWT
Body
{ "endpoint": "https://…" }Response 200 — { "removed": true }
Push notifications are sent server-side when a moderated message is created (recipients with subscriptions, excluding sender).
Pro — Email digest (cron)
POST /v1/cron/email-digest
Auth: X-Cron-Secret header must match CRON_SECRET.
Body (optional)
{ "unreadHours": 4 }Finds users with unread messages older than unreadHours, sends a Resend email (LLM summary when OPENAI_API_KEY is set). Users are matched by external_id containing @ (email). Skips if a digest was sent for the same conversation in the last 24 hours.
Response 200
{ "sent": 2, "skipped": false, "candidates": 3 }Shared types (@tesark/contracts)
| Type | Key fields |
|---|---|
UserResponse | userId, tenantId, externalId, displayName, avatarUrl, appMetadata, createdAt, deletedAt (null when active) |
UserListResponse | data (UserResponse[]), nextCursor (`string |
ConversationResponse | conversationId, tenantId, name, icon, isArchived, membersCount, lastMessage, lastMessageAt, appMetadata, createdAt |
ConversationListResponse | data (ConversationResponse[]), nextCursor (`string |
MessageResponse | messageId, conversationId, tenantId, senderId, localId, content (text, optional kind), isPristine, sentAt |
SystemProfile | userId, displayName, avatarUrl |
TokenExchangeResponse | accessToken, tokenType, expiresIn |
ReviewTokenExchangeRequest | Same as TokenExchangeRequest (externalUserId) |
ReviewTokenExchangeResponse | Same as TokenExchangeResponse |
AddUserConversationMembershipsResponse | added, skipped, notFound |
RemoveUserConversationMembershipsResponse | removed, notFound |
ReadReceiptResponse | userId, conversationId, tenantId, lastReadAt |
PushSubscriptionRequest | endpoint, keys.p256dh, keys.auth |
WebhookEndpointResponse | webhookEndpointId, tenantId, url, eventTypes, isActive, createdAt, revokedAt |
WebhookEndpointDetailResponse | WebhookEndpointResponse + signingSecretHint |
CreateWebhookEndpointResponse | WebhookEndpointResponse + one-time signingSecret |
ConversationUnreadStaleEvent | event, tenantId, conversationId, emittedAt, staleMembers[] with oldestUnreadKind, optional oldestUnreadMetadata per member |
Zod schemas live in packages/contracts/src/schemas.ts.