{
  "openapi": "3.0.3",
  "info": {
    "title": "GramClaw Telegram API",
    "version": "1.0.0",
    "description": "Disabled-by-default, workspace-scoped API for eligible internal Telegram accounts. It supports scoped reads, gated CRM/Pipeline operations, username start-chat, idempotent text or one-file message sends (25 MiB maximum), explicit read-state, and campaign reads. Campaign-scoped keys can create, pause, resume, or permanently cancel worker-dispatched internal campaigns only behind the independent mutation gates and exact allowlists. Broadcasts and Telegram administration remain unsupported.",
    "x-mcp": { "endpoint": "/api/mcp", "readTools": ["gramclaw_whoami", "gramclaw_list_chats", "gramclaw_get_chat", "gramclaw_get_messages", "gramclaw_search_messages"], "approvedWriteTools": ["gramclaw_send_message", "gramclaw_move_pipeline_stage", "gramclaw_set_column"], "writeAuthorization": "Write tools require the matching explicit API-key scope, independent feature gates, and a two-minute single-use token issued by an authenticated user at /dashboard/mcp-approvals. The token binds app user and Supabase Auth UUID, workspace, API key, connected account/backend identity, action, target and exact payload hash. Campaign controls and attachments are not MCP tools." }
  },
  "servers": [{ "url": "https://gramclaw.com/api/v1" }],
  "security": [{ "bearerAuth": [] }],
  "components": {
    "securitySchemes": { "bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "gc_live_..." } },
    "parameters": {
      "accountId": { "name": "account_id", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Workspace-owned GramClaw compatibility account ID." },
      "optionalAccountId": { "name": "account_id", "in": "query", "schema": { "type": "string" }, "description": "Optional exact workspace-owned GramClaw compatibility account ID." },
      "cursor": { "name": "cursor", "in": "query", "schema": { "type": "string" }, "description": "Opaque cursor returned by the preceding page." },
      "limit": { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 100 } },
      "idempotencyKey": { "name": "Idempotency-Key", "in": "header", "required": true, "schema": { "type": "string", "minLength": 1, "maxLength": 255 }, "description": "Stable caller-generated identity for this exact mutation. Ambiguous operations are not retried." }
    },
    "schemas": {
      "Error": { "type": "object", "required": ["error"], "properties": { "error": { "type": "string" }, "code": { "type": "string" } } },
      "SendRequest": { "type": "object", "required": ["account_id", "chat_id", "text"], "properties": { "account_id": { "type": "string" }, "chat_id": { "type": "string", "pattern": "^telegram:chat:-?[0-9]+$" }, "text": { "type": "string", "minLength": 1, "maxLength": 4096 } } },
      "AttachmentSendRequest": { "type": "object", "required": ["account_id", "chat_id", "attachments"], "properties": { "account_id": { "type": "string" }, "chat_id": { "type": "string", "pattern": "^telegram:chat:-?[0-9]+$" }, "text": { "type": "string", "maxLength": 4096 }, "attachments": { "type": "string", "format": "binary", "description": "Exactly one file, maximum 25 MiB. Multiple attachments are rejected." } } },
      "ReadStateRequest": { "type": "object", "additionalProperties": false, "required": ["account_id", "unread"], "properties": { "account_id": { "type": "string", "description": "Exact workspace-owned GramClaw compatibility account ID." }, "unread": { "type": "boolean", "description": "true marks the chat unread; false explicitly marks it read." } } },
      "StartChatRequest": { "type": "object", "additionalProperties": false, "required": ["account_id", "identifier"], "properties": { "account_id": { "type": "string" }, "identifier": { "type": "string", "description": "Telegram username, @username, or supported t.me username link." } } },
      "CreateCampaignRequest": { "type": "object", "additionalProperties": false, "required": ["message_text", "recipients"], "properties": { "name": { "type": "string", "maxLength": 200 }, "message_text": { "type": "string", "minLength": 1, "maxLength": 4096, "description": "Supports [[name]] and [[username]], resolved from Telegram for every recipient." }, "recipients": { "type": "array", "minItems": 1, "maxItems": 100, "items": { "type": "object", "additionalProperties": false, "required": ["account_id", "chat_id"], "properties": { "account_id": { "type": "string" }, "chat_id": { "type": "string", "pattern": "^telegram:chat:[0-9]+$" } } } }, "steps": { "type": "array", "maxItems": 10, "description": "Follow-ups. Without branching every step uses branch no_reply (max 5); with branching each step is seen or unseen (max 5 per track).", "items": { "type": "object", "additionalProperties": false, "required": ["branch", "wait_hours", "variants"], "properties": { "branch": { "type": "string", "enum": ["no_reply", "seen", "unseen"] }, "wait_hours": { "type": "number", "minimum": 0.0833 }, "variants": { "type": "array", "minItems": 1, "maxItems": 5, "items": { "type": "string", "minLength": 1, "maxLength": 4096 } } } } }, "branching_enabled": { "type": "boolean", "description": "Seen/unseen branching; rejected unless the independent U4 branching and read-receipt gates are on." }, "daily_quota_per_account": { "type": "integer", "minimum": 1, "maximum": 200, "default": 30 }, "max_followups": { "type": "integer", "minimum": 0, "maximum": 5, "default": 2 }, "scheduled_at": { "type": "string", "format": "date-time", "description": "Not in the past; at most one year ahead." }, "schedule_timezone": { "type": "string", "description": "IANA time zone, default UTC." } } }
    }
  },
  "paths": {
    "/me": { "get": { "summary": "Verify key and list eligible Telegram accounts", "operationId": "whoami", "responses": { "200": { "description": "Workspace identity, key scopes, eligible accounts and capabilities" }, "401": { "description": "Invalid or revoked key" }, "503": { "description": "REST API feature disabled" } } } },
    "/chats": { "get": { "summary": "List existing scoped Telegram chats", "operationId": "listChats", "parameters": [{ "$ref": "#/components/parameters/accountId" }, { "$ref": "#/components/parameters/cursor" }, { "$ref": "#/components/parameters/limit" }], "responses": { "200": { "description": "Account-scoped chat page" }, "400": { "description": "Missing or invalid account scope" }, "403": { "description": "Account is not owned or eligible" } } } },
    "/chats/{chat_id}": { "get": { "summary": "Get an existing chat and supported participant metadata", "operationId": "getChat", "parameters": [{ "name": "chat_id", "in": "path", "required": true, "schema": { "type": "string" } }, { "$ref": "#/components/parameters/accountId" }], "responses": { "200": { "description": "Exact account/chat metadata" }, "404": { "description": "Chat unavailable in the selected account" } } } },
    "/chats/{chat_id}/messages": { "get": { "summary": "Read scoped Telegram history without marking it read", "operationId": "listMessages", "parameters": [{ "name": "chat_id", "in": "path", "required": true, "schema": { "type": "string" } }, { "$ref": "#/components/parameters/accountId" }, { "name": "before", "in": "query", "schema": { "type": "string" }, "description": "Opaque provider cursor returned by the preceding page." }, { "$ref": "#/components/parameters/limit" }], "responses": { "200": { "description": "Account-scoped message page" }, "404": { "description": "Chat unavailable in the selected account" } } } },
    "/chats/{chat_id}/read-state": { "post": { "summary": "Explicitly set Telegram chat read/unread state", "operationId": "setChatReadState", "description": "Requires an explicitly granted write-scoped key and Idempotency-Key. The selected account and chat are resolved together; ambiguous outcomes are not retried. Ordinary history reads remain non-mutating.", "parameters": [{ "name": "chat_id", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^telegram:chat:-?[0-9]+$" } }, { "$ref": "#/components/parameters/idempotencyKey" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ReadStateRequest" } } } }, "responses": { "200": { "description": "The explicit read-state operation was accepted or the exact completed operation was replayed." }, "400": { "description": "Invalid account/chat/body or missing idempotency key." }, "403": { "description": "Missing write scope or account/chat ownership." }, "409": { "description": "Idempotency collision or ambiguous/in-flight mutation." }, "429": { "description": "Rate limit exceeded." }, "503": { "description": "REST API disabled or scoped session unavailable." } } } },
    "/chats/start": { "post": { "summary": "Open a private chat by Telegram username", "operationId": "startChatByUsername", "description": "Requires a send-scoped key, the Start Chat feature gate, and Idempotency-Key. Accepts only usernames and supported t.me username links. Resolves and opens the chat without sending a message; no Unipile or legacy provider fallback.", "parameters": [{ "$ref": "#/components/parameters/idempotencyKey" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StartChatRequest" } } } }, "responses": { "200": { "description": "Exact account-scoped private chat, or replay of the completed operation" }, "400": { "description": "Invalid username, phone number, unsupported input, or idempotency collision" }, "403": { "description": "Missing send scope or unavailable account" }, "404": { "description": "Username not found" }, "409": { "description": "Suppressed/ineligible identity, in-flight request, or ambiguous prior outcome" }, "429": { "description": "Telegram lookup/open rate limit or FLOOD_WAIT" }, "503": { "description": "REST or Start Chat feature disabled" } } } },
    "/search": { "post": { "summary": "Search persisted scoped Telegram messages", "operationId": "searchMessages", "description": "Search completeness is limited to history already persisted by GramClaw.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["query"], "properties": { "query": { "type": "string", "minLength": 1 }, "account_id": { "type": "string" }, "chat_id": { "type": "string" }, "limit": { "type": "integer", "minimum": 1, "maximum": 100 } } } } } }, "responses": { "200": { "description": "Scoped persisted matches and completeness metadata" }, "400": { "description": "Invalid query or scope" } } } },
    "/contacts": { "get": { "summary": "List persisted account-scoped Telegram contacts", "operationId": "listContacts", "description": "Requires the REST and CRM feature gates. No live Telegram lookup is performed.", "parameters": [{ "$ref": "#/components/parameters/optionalAccountId" }, { "$ref": "#/components/parameters/cursor" }, { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 200 } }, { "name": "q", "in": "query", "schema": { "type": "string" } }], "responses": { "200": { "description": "Scoped persisted contact page" }, "403": { "description": "Account is not owned or eligible" }, "503": { "description": "CRM disabled or scoped schema unavailable" } } } },
    "/pipeline": { "get": { "summary": "Read workspace pipeline and account-scoped placements", "operationId": "getPipeline", "description": "Requires the REST and Pipeline feature gates.", "responses": { "200": { "description": "Workspace stages and account-qualified chat placements" }, "503": { "description": "Pipeline disabled or scoped schema unavailable" } } } },
    "/pipeline/move": { "post": { "summary": "Move one exact account-scoped chat to a visible stage", "operationId": "movePipelineChat", "description": "Requires a write-scoped key plus the REST and Pipeline feature gates. Repeating the same assignment is safe.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["account_id", "chat_id", "stage_key"], "properties": { "account_id": { "type": "string" }, "chat_id": { "type": "string", "pattern": "^telegram:chat:-?[0-9]+$" }, "stage_key": { "type": "string" } } } } } }, "responses": { "200": { "description": "Account-qualified placement" }, "400": { "description": "Invalid or hidden stage" }, "403": { "description": "Missing write scope or unavailable account" }, "404": { "description": "Chat unavailable in the selected account" }, "503": { "description": "Pipeline disabled or scoped schema unavailable" } } } },
    "/columns": { "get": { "summary": "Read custom columns and account-scoped values", "operationId": "getColumns", "description": "Requires the REST and CRM feature gates.", "parameters": [{ "$ref": "#/components/parameters/optionalAccountId" }], "responses": { "200": { "description": "Workspace columns and account-qualified chat values" }, "403": { "description": "Account is not owned or eligible" }, "503": { "description": "CRM disabled or scoped schema unavailable" } } } },
    "/columns/set": { "post": { "summary": "Set or clear a column value on one exact account-scoped chat", "operationId": "setColumnValue", "description": "Requires a write-scoped key plus the REST and CRM feature gates. Values must match the configured column type/options; an empty array clears the value.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["account_id", "chat_id", "column_id", "value"], "properties": { "account_id": { "type": "string" }, "chat_id": { "type": "string", "pattern": "^telegram:chat:-?[0-9]+$" }, "column_id": { "type": "string" }, "value": { "type": "array", "items": { "type": "string" } } } } } } }, "responses": { "200": { "description": "Account-qualified saved value or clear result" }, "400": { "description": "Value violates configured column rules" }, "403": { "description": "Missing write scope or unavailable account" }, "404": { "description": "Chat or column unavailable in this workspace" }, "503": { "description": "CRM disabled or scoped schema unavailable" } } } },
    "/analytics": { "get": { "summary": "Read authoritative internal Telegram analytics", "operationId": "getAnalytics", "description": "Requires the REST and Analytics feature gates. Delivery, read, and engagement are never inferred.", "parameters": [{ "name": "period", "in": "query", "schema": { "type": "string", "enum": ["7d", "30d", "all"], "default": "7d" } }], "responses": { "200": { "description": "Scoped persisted message and durable campaign-send counts" }, "503": { "description": "Analytics disabled or scoped schema unavailable" } } } },
    "/campaigns": {
      "get": {
        "summary": "List internal Telegram campaigns",
        "operationId": "listCampaigns",
        "description": "Requires an explicitly issued campaign-scoped key plus the REST and Campaigns feature gates and campaign user allowlist. Returns truthful durable reporting; legacy campaigns are excluded.",
        "parameters": [{ "$ref": "#/components/parameters/cursor" }, { "$ref": "#/components/parameters/limit" }],
        "responses": { "200": { "description": "Internal campaign page and coverage limitations" }, "403": { "description": "Missing campaign scope or campaign allowlist authorization" }, "503": { "description": "REST/Campaigns disabled or campaign schema unavailable" } }
      },
      "post": {
        "summary": "Create an internal Telegram campaign for existing private chats",
        "operationId": "createCampaign",
        "description": "Requires an explicitly issued campaign-scoped key, the REST, Campaigns, scoped-routing and independent campaign-mutation feature gates, the exact campaign user allowlist, the exact campaign workspace allowlist, and Idempotency-Key (bound to this exact payload). Recipients must be existing private chats of READY internal Telegram accounts in the key workspace (max 100; duplicates collapse). No @username resolution, attachments, client-supplied names, broadcasts, or Unipile fallback. Names/usernames for personalization come from Telegram. Suppressed recipients reject the whole request. Campaign, steps and recipients are written in one database transaction; this endpoint never sends a message — the durable worker dispatches queued recipients. Ambiguous outcomes are never retried.",
        "parameters": [{ "$ref": "#/components/parameters/idempotencyKey" }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateCampaignRequest" } } } },
        "responses": { "200": { "description": "Existing campaign for this operation (idempotent)" }, "201": { "description": "Campaign created and queued for the worker; nothing was sent" }, "400": { "description": "Invalid or unsupported input, suppressed recipients, unresolved personalization, missing Idempotency-Key, or disabled branching" }, "403": { "description": "Missing campaign scope, user/workspace allowlist, or account ownership" }, "404": { "description": "Recipient chat not found for the account" }, "409": { "description": "Idempotency collision, in-flight or ambiguous prior operation, or sending account no longer READY" }, "429": { "description": "Campaign creation rate limit" }, "503": { "description": "Feature disabled, U5d2 migration not installed, suppression list unavailable, or outcome unknown (do not retry)" } }
      }
    },
    "/campaigns/{campaign_id}/pause": { "post": { "summary": "Pause an internal sequence campaign", "operationId": "pauseCampaign", "description": "Requires campaign scope, the independent REST/Campaigns mutation gates, exact user/workspace allowlists, and Idempotency-Key. This only changes queue state; the durable worker remains the sole dispatcher. Sequence campaigns only. Takes no request body.", "parameters": [{ "name": "campaign_id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }, { "$ref": "#/components/parameters/idempotencyKey" }], "responses": { "200": { "description": "Campaign status and whether it changed" }, "403": { "description": "Missing campaign scope or allowlist" }, "404": { "description": "Campaign absent, legacy, or outside the key workspace/user" }, "409": { "description": "Invalid sequence status or ambiguous prior operation" }, "503": { "description": "Feature or campaign schema unavailable" } } } },
    "/campaigns/{campaign_id}/resume": { "post": { "summary": "Resume a paused internal sequence campaign", "operationId": "resumeCampaign", "description": "Requires campaign scope, the independent REST/Campaigns mutation gates, exact user/workspace allowlists, and Idempotency-Key. This only reopens queue state; the durable worker remains the sole dispatcher. Sequence campaigns only. Takes no request body.", "parameters": [{ "name": "campaign_id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }, { "$ref": "#/components/parameters/idempotencyKey" }], "responses": { "200": { "description": "Campaign status and whether it changed" }, "403": { "description": "Missing campaign scope or allowlist" }, "404": { "description": "Campaign absent, legacy, or outside the key workspace/user" }, "409": { "description": "Campaign is not paused, sequence is unsupported, or prior operation is ambiguous" }, "503": { "description": "Feature or campaign schema unavailable" } } } },
    "/campaigns/{campaign_id}/cancel": { "post": { "summary": "Permanently cancel an internal Telegram campaign", "operationId": "cancelCampaign", "description": "Same gates as campaign creation, plus Idempotency-Key. Atomically compare-and-sets a scheduled, queued, sending or paused campaign to cancelled and stops every queued recipient. Idempotent (changed=false when already cancelled); irreversible — there is no un-cancel through REST. A recipient whose send had already crossed the Telegram submission boundary may still complete; the worker re-checks status immediately before every send. Takes no request body.", "parameters": [{ "name": "campaign_id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }, { "$ref": "#/components/parameters/idempotencyKey" }], "responses": { "200": { "description": "{ campaign: { id, status, cancelled_at }, changed }" }, "400": { "description": "Invalid campaign ID, request body, or missing Idempotency-Key" }, "403": { "description": "Missing campaign scope or user/workspace allowlist" }, "404": { "description": "Campaign absent, legacy, or outside the key workspace/user" }, "409": { "description": "Campaign already completed (not cancellable), idempotency collision, or ambiguous prior operation" }, "429": { "description": "Cancellation rate limit" }, "503": { "description": "Feature disabled, U5d2 migration not installed, or outcome unknown" } } } },
    "/campaigns/{campaign_id}": { "get": { "summary": "Get internal Telegram campaign detail", "operationId": "getCampaign", "description": "Returns durable recipients, sequence steps, operation-ledger rows, and truthful reporting. create/cancel/pause/resume capabilities reflect the key's explicit scope, allowlists, feature gates, and current campaign state. Legacy campaigns fail closed.", "parameters": [{ "name": "campaign_id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }], "responses": { "200": { "description": "Owned internal campaign detail" }, "403": { "description": "Missing campaign scope or campaign allowlist authorization" }, "404": { "description": "Campaign absent, legacy, or outside the key workspace" }, "503": { "description": "REST/Campaigns disabled or campaign schema unavailable" } } } },
    "/messages": { "post": { "summary": "Send text and optionally one attachment to an existing eligible chat", "operationId": "sendMessage", "description": "Requires a send-scoped key and Idempotency-Key. JSON sends text; multipart sends exactly one attachment (maximum 25 MiB) with optional text. Multiple attachments, automatic retries, provider fallbacks, new-chat creation, and bulk sends are unsupported.", "parameters": [{ "$ref": "#/components/parameters/idempotencyKey" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SendRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/AttachmentSendRequest" } } } }, "responses": { "200": { "description": "Accepted canonical result, or replay of the same completed idempotent operation" }, "400": { "description": "Invalid input, unsupported attachment count/type, or idempotency collision" }, "403": { "description": "Missing send scope or account ownership" }, "409": { "description": "Operation still in progress or prior outcome ambiguous" }, "413": { "description": "Attachment exceeds 25 MiB" }, "503": { "description": "Scoped provider unavailable or outcome ambiguous; do not retry" } } } }
  }
}
