Skip to content

API Reference

Host-server and client endpoints for the T-Chat Edge Function API.

Base URL:

https://<project-ref>.supabase.co/functions/v1/api

All JSON bodies use Content-Type: application/json. Errors return { "error": "message" } unless noted.

Authentication

AuthHeaderUsed for
OperatorAuthorization: Bearer <supabase_access_token>Dashboard: list own tenants, API keys, users, conversations
AdminX-Admin-Secret: <secret>Super-admin scripts: all tenants (optional; not used by dashboard SPA)
API keyAuthorization: Bearer tes_…User provisioning, token exchange, conversation CRUD, system messages
User JWTAuthorization: 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 201TenantResponse (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 200TenantBillingResponse

{
"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

ParamDescription
limitOptional. Default 50, max 100.
cursorOpaque 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 200SystemProfile (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 200ApiKeyResponse.


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 200WebhookEndpointDetailResponse (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 200WebhookEndpointDetailResponse (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:

  • oldestUnreadKinduser, system, or event (from messages.kind). Timeline events (event) are excluded from stale-unread eligibility, so this is typically user or system.
  • oldestUnreadMetadata — optional. Copied from the oldest unread message’s host-owned content.metadata when present. Omitted for messages without metadata.

oldestUnreadMetadata is host-owned, same as content.metadata on 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

HeaderValue
Content-Typeapplication/json
x-tchat-signaturet=<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):

Terminal window
pnpm exec supabase db push
PROJECT_REF=<project-ref> CRON_SECRET="$(openssl rand -hex 32)" \
pnpm db:setup-cron-vault --linked
# or: ./scripts/setup-cron-vault.sh --project-ref <ref> --linked

Secret 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

ParamDescription
jobNameOptional filter, e.g. webhook-stale-unread
fromISO timestamp — startedAt >= from
toISO timestamp — startedAt <= to
cursorPagination cursor from nextCursor
limitPage 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

ParamDescription
jobNameOptional filter, e.g. webhook-stale-unread
fromISO timestamp — startedAt >= from
toISO 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

ParamDescription
limitOptional. Default 50, max 100.
cursorOpaque cursor from nextCursor.
externalIdOptional. Filter to one host-app user id.
appMetadata.<key>Optional. Metadata filter (e.g. appMetadata.crmId=123).

Response 200UserListResponse (data, nextCursor)

GET /v1/users/:externalId

API key auth. Single user by host externalId.

Response 200UserResponse

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 200UserResponse 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 201ConversationResponse (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

ParamRequiredDescription
externalUserIdnoWhen set, limits results to conversations that user belongs to. When omitted, returns all tenant conversations.
externalIdnoWhen set, limits results to the active conversation with this host externalId (at most one per tenant).
limitnoDefault 50, max 100.
cursornoFrom nextCursor.
appMetadata.<key>noMetadata filter.

Response 200ConversationListResponse

{
"data": [ ConversationResponse, ... ],
"nextCursor": "2026-06-15T12:00:00.000Z|uuid"
}

GET /v1/conversations/:conversationId

Response 200ConversationResponse

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 200ConversationResponse

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.

metadata is 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 separate content fields — do not put them in metadata. End-user POST /v1/messages does not accept metadata.

Response 201MessageResponse 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)

Terminal window
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

Terminal window
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 201MessageResponse 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 413sizeBytes 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 201MessageResponse 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 200MessageResponse


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 503SUPABASE_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 503VAPID_PUBLIC_KEY not set

POST /v1/push-subscriptions

Auth: user JWT (Authorization: Bearer <accessToken>)

BodyPushSubscriptionRequest

{
"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)

TypeKey fields
UserResponseuserId, tenantId, externalId, displayName, avatarUrl, appMetadata, createdAt, deletedAt (null when active)
UserListResponsedata (UserResponse[]), nextCursor (`string
ConversationResponseconversationId, tenantId, name, icon, isArchived, membersCount, lastMessage, lastMessageAt, appMetadata, createdAt
ConversationListResponsedata (ConversationResponse[]), nextCursor (`string
MessageResponsemessageId, conversationId, tenantId, senderId, localId, content (text, optional kind), isPristine, sentAt
SystemProfileuserId, displayName, avatarUrl
TokenExchangeResponseaccessToken, tokenType, expiresIn
ReviewTokenExchangeRequestSame as TokenExchangeRequest (externalUserId)
ReviewTokenExchangeResponseSame as TokenExchangeResponse
AddUserConversationMembershipsResponseadded, skipped, notFound
RemoveUserConversationMembershipsResponseremoved, notFound
ReadReceiptResponseuserId, conversationId, tenantId, lastReadAt
PushSubscriptionRequestendpoint, keys.p256dh, keys.auth
WebhookEndpointResponsewebhookEndpointId, tenantId, url, eventTypes, isActive, createdAt, revokedAt
WebhookEndpointDetailResponseWebhookEndpointResponse + signingSecretHint
CreateWebhookEndpointResponseWebhookEndpointResponse + one-time signingSecret
ConversationUnreadStaleEventevent, tenantId, conversationId, emittedAt, staleMembers[] with oldestUnreadKind, optional oldestUnreadMetadata per member

Zod schemas live in packages/contracts/src/schemas.ts.