{"openapi":"3.0.3","info":{"title":"CleanChat REST API","version":"1.0.0","description":"Create fictional conversations and export them with ordinary HTTP requests. No MCP client or JSON-RPC envelope is needed. This reference covers the studio’s existing conversation, rendering and video-import routes.\n\n## Authentication\n\nCreate a key in [Studio → API keys](/studio?view=tokens), choose its permissions, and copy it once. Use the same `cc_…` key you already use in the app:\n\n```sh\nexport CLEANCHAT_URL=\"https://cleanchat.app\"\nexport CLEANCHAT_API_KEY=\"cc_REPLACE_WITH_YOUR_KEY\"\ncurl --fail-with-body \"$CLEANCHAT_URL/api/conversations\" -H \"Authorization: Bearer $CLEANCHAT_API_KEY\"\n```\n\nKeys expire after 90 days. Revoking a key takes effect immediately. Keep it on your server, in an environment variable; do not put it in frontend code or a public repository. Existing user-session JWTs still work for the studio. MCP OAuth tokens (`cco_…`) remain restricted to the MCP endpoint.\n\n| Permission | HTTP operations |\n| --- | --- |\n| `scenes:read` | List and read conversations |\n| `scenes:write` | Create and replace conversations |\n| `scenes:delete` | Delete conversations |\n| `render:export` | Start, read and cancel exports |\n\nWorkspace restrictions configured under **MCP server** also apply to REST keys. Creation checks create_conversation, replacement checks update_conversation, and cancellation checks render_export. A key cannot manage billing, the account, other keys or team membership. REST calls appear in the existing activity log with a REST label.\n\nA key is permanently bound to the workspace selected when you created it. No workspace header is needed. If you send `X-CleanChat-Workspace`, it must match the key's team workspace; it cannot switch the key to another workspace. Removing a team member removes their key's access too. This is a server-to-server API: browser requests from a different origin are rejected.\n\n## First scene → finished video\n\n1. Generate a UUID and **PUT /api/conversations/{id}** with `revision: null`, a title and a complete project. Copy the request example below; creation returns HTTP 200.\n2. Keep the returned `id` and `revision`. Before any later edit or deletion, GET the scene again. A save replaces the whole project; it is not a patch.\n3. **POST /api/exports** with that ID, revision, a fresh UUID `requestId`, and `options: {format: \"mp4\"}`.\n4. **GET /api/exports/{exportId}** after `pollAfterSeconds` until `status` is `ready` or `failed`. Stop on failure and inspect `error`.\n5. Download `downloadUrl` directly. The signed URL needs no Authorization header. Treat it as private.\n\nReuse the same export requestId **and identical payload** after a timeout; a new ID starts another export and can use more minutes. Only one queued or processing export is allowed per workspace. A 409 requires reading the current scene or waiting for the active export, not blindly retrying with a new ID.\n\n## Limits and rendering\n\nJSON bodies are limited to 2,500,000 bytes. A scene has at most 100 messages of 1,500 characters each. Times use HH:mm; animation delays are seconds. PNG captures use 3× resolution; MP4 uses 2× at 30 fps. Export settings are temporary and do not modify the saved scene. The default format is PNG, so explicitly request MP4 for a video.\n\nAn MP4 reserves its final duration rounded up to whole cloud minutes. PNG and repeated downloads use no minutes. Failed or canceled jobs return their reservation. Team exports use the owner's balance. Download links last at most 15 minutes, and polling a ready job refreshes the link until the file expires after 24 hours. There are no completion webhooks; poll the job. Outputs are limited to 50 MB.\n\n## Errors\n\nFailures use an HTTP status and `{\"error\":\"Explanation\"}`. A render can also return HTTP 200 with `status: \"failed\"`; always inspect the job status. On 409, fetch the latest revision and merge your intended changes before saving. Never silently overwrite a newer scene.\n\n[MCP documentation](/docs) · [OpenAPI JSON](/api/openapi)"},"servers":[{"url":"https://cleanchat.app/api","description":"Current CleanChat deployment"}],"security":[{"ApiKey":[]}],"tags":[{"name":"Conversations","description":"List, read and save scenes in your workspace."},{"name":"Exports","description":"Render images or videos and retrieve private download links."},{"name":"Media","description":"Prepare a video before embedding it in a scene."},{"name":"Service","description":"Public service and sign-in configuration."}],"paths":{"/conversations":{"get":{"tags":["Conversations"],"operationId":"listConversations","summary":"List conversations","description":"Returns up to 50 rows, newest updated first. Add 50 to offset for each page and stop when a page has fewer than 50 rows. The response is an array, without a total or pagination envelope.","parameters":[{"name":"X-CleanChat-Workspace","in":"header","description":"Usually omit: API keys automatically use their bound workspace. If supplied, this must match the key’s team workspace. With a user-session JWT, omit for personal space or pass a team UUID.","schema":{"type":"string","format":"uuid"}},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"maximum":100000,"default":0}},{"name":"preview","in":"query","description":"Include a lightweight preview of the last four sent messages and messageCount.","schema":{"type":"boolean","default":false}}],"responses":{"200":{"description":"Conversations in this workspace.","content":{"application/json":{"schema":{"type":"array","maxItems":50,"items":{"$ref":"#/components/schemas/ConversationSummary"}}}}},"400":{"description":"Invalid JSON, fields, UUID or conversation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid, expired or revoked API key/session. Create a new key or sign in again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key permission missing, operation disabled, wrong workspace, lost team access or Origin not allowed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Operation failed. Retry without discarding your local changes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-codeSamples":[{"lang":"Shell","label":"cURL","source":"curl --fail-with-body \"$CLEANCHAT_URL/api/conversations?offset=0\" \\\n  -X GET \\\n  -H \"Authorization: Bearer $CLEANCHAT_API_KEY\""},{"lang":"JavaScript","label":"JavaScript","source":"const response = await fetch(process.env.CLEANCHAT_URL + '/api/conversations?offset=0', {\n  method: 'GET',\n  headers: {\n    Authorization: 'Bearer ' + process.env.CLEANCHAT_API_KEY,\n  },\n});\nconst result = await response.json();\nif (!response.ok) throw new Error(result.error);\nconsole.log(result);"}]}},"/conversations/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"X-CleanChat-Workspace","in":"header","description":"Usually omit: API keys automatically use their bound workspace. If supplied, this must match the key’s team workspace. With a user-session JWT, omit for personal space or pass a team UUID.","schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Conversations"],"operationId":"getConversation","summary":"Read a conversation","description":"Read the full project and current revision before editing, deleting or exporting.","responses":{"200":{"description":"Saved conversation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Conversation"},"example":{"id":"91c394cf-e76a-4d78-b099-5df18b38c495","title":"The missing charger","revision":1,"created_at":"2026-09-24T12:00:00Z","updated_at":"2026-09-24T12:00:00Z","project":{"theme":"imessage","dark":false,"contact":"Camille","avatar":"","clock":"09:41","battery":100,"date":"Today","keyboard":"hidden","frame":false,"phoneLanguage":"en","keyboardLanguage":"en","messages":[{"id":"message-1","sender":"contact","text":"Did you take my charger?","blur":false,"delay":2,"time":"09:40"},{"id":"message-2","sender":"me","text":"Maybe…","blur":false,"delay":2,"time":"09:41"}]}}}}},"400":{"description":"Invalid JSON, fields, UUID or conversation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid, expired or revoked API key/session. Create a new key or sign in again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key permission missing, operation disabled, wrong workspace, lost team access or Origin not allowed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Resource not found in this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Operation failed. Retry without discarding your local changes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-codeSamples":[{"lang":"Shell","label":"cURL","source":"curl --fail-with-body \"$CLEANCHAT_URL/api/conversations/91c394cf-e76a-4d78-b099-5df18b38c495\" \\\n  -X GET \\\n  -H \"Authorization: Bearer $CLEANCHAT_API_KEY\""},{"lang":"JavaScript","label":"JavaScript","source":"const response = await fetch(process.env.CLEANCHAT_URL + '/api/conversations/91c394cf-e76a-4d78-b099-5df18b38c495', {\n  method: 'GET',\n  headers: {\n    Authorization: 'Bearer ' + process.env.CLEANCHAT_API_KEY,\n  },\n});\nconst result = await response.json();\nif (!response.ok) throw new Error(result.error);\nconsole.log(result);"}]},"put":{"tags":["Conversations"],"operationId":"saveConversation","summary":"Create or replace a conversation","description":"Generate the path UUID yourself. Set revision to null to create a scene, or to the latest positive revision to replace it. title and the complete project are always required. Both creation and update return HTTP 200. A successful update increments revision. Retrying an identical creation at the same ID returns the existing scene; a different payload at that ID returns 409. For an uncertain update, read the scene again before retrying. REST requires complete media metadata; unlike MCP, it does not normalize audio or video.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SaveConversation"},"example":{"title":"The missing charger","revision":null,"project":{"theme":"imessage","dark":false,"contact":"Camille","avatar":"","clock":"09:41","battery":100,"date":"Today","keyboard":"hidden","frame":false,"phoneLanguage":"en","keyboardLanguage":"en","messages":[{"id":"message-1","sender":"contact","text":"Did you take my charger?","blur":false,"delay":2,"time":"09:40"},{"id":"message-2","sender":"me","text":"Maybe…","blur":false,"delay":2,"time":"09:41"}]}}}}},"responses":{"200":{"description":"Saved scene with its current revision.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Conversation"},"example":{"id":"91c394cf-e76a-4d78-b099-5df18b38c495","title":"The missing charger","revision":1,"created_at":"2026-09-24T12:00:00Z","updated_at":"2026-09-24T12:00:00Z","project":{"theme":"imessage","dark":false,"contact":"Camille","avatar":"","clock":"09:41","battery":100,"date":"Today","keyboard":"hidden","frame":false,"phoneLanguage":"en","keyboardLanguage":"en","messages":[{"id":"message-1","sender":"contact","text":"Did you take my charger?","blur":false,"delay":2,"time":"09:40"},{"id":"message-2","sender":"me","text":"Maybe…","blur":false,"delay":2,"time":"09:41"}]}}}}},"400":{"description":"Invalid JSON, fields, UUID or conversation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid, expired or revoked API key/session. Create a new key or sign in again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"Not enough cloud video minutes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key permission missing, operation disabled, wrong workspace, lost team access or Origin not allowed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Resource not found in this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Revision conflict, duplicate conversation ID, reused export ID with different options, or another export already running.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Request body too large.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Operation failed. Retry without discarding your local changes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-codeSamples":[{"lang":"Shell","label":"cURL","source":"curl --fail-with-body \"$CLEANCHAT_URL/api/conversations/91c394cf-e76a-4d78-b099-5df18b38c495\" \\\n  -X PUT \\\n  -H \"Authorization: Bearer $CLEANCHAT_API_KEY\" \\\n  -H 'Content-Type: application/json' \\\n  --data '{\n  \"title\": \"The missing charger\",\n  \"revision\": null,\n  \"project\": {\n    \"theme\": \"imessage\",\n    \"dark\": false,\n    \"contact\": \"Camille\",\n    \"avatar\": \"\",\n    \"clock\": \"09:41\",\n    \"battery\": 100,\n    \"date\": \"Today\",\n    \"keyboard\": \"hidden\",\n    \"frame\": false,\n    \"phoneLanguage\": \"en\",\n    \"keyboardLanguage\": \"en\",\n    \"messages\": [\n      {\n        \"id\": \"message-1\",\n        \"sender\": \"contact\",\n        \"text\": \"Did you take my charger?\",\n        \"blur\": false,\n        \"delay\": 2,\n        \"time\": \"09:40\"\n      },\n      {\n        \"id\": \"message-2\",\n        \"sender\": \"me\",\n        \"text\": \"Maybe…\",\n        \"blur\": false,\n        \"delay\": 2,\n        \"time\": \"09:41\"\n      }\n    ]\n  }\n}'"},{"lang":"JavaScript","label":"JavaScript","source":"const response = await fetch(process.env.CLEANCHAT_URL + '/api/conversations/91c394cf-e76a-4d78-b099-5df18b38c495', {\n  method: 'PUT',\n  headers: {\n    Authorization: 'Bearer ' + process.env.CLEANCHAT_API_KEY,\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({\n  \"title\": \"The missing charger\",\n  \"revision\": null,\n  \"project\": {\n    \"theme\": \"imessage\",\n    \"dark\": false,\n    \"contact\": \"Camille\",\n    \"avatar\": \"\",\n    \"clock\": \"09:41\",\n    \"battery\": 100,\n    \"date\": \"Today\",\n    \"keyboard\": \"hidden\",\n    \"frame\": false,\n    \"phoneLanguage\": \"en\",\n    \"keyboardLanguage\": \"en\",\n    \"messages\": [\n      {\n        \"id\": \"message-1\",\n        \"sender\": \"contact\",\n        \"text\": \"Did you take my charger?\",\n        \"blur\": false,\n        \"delay\": 2,\n        \"time\": \"09:40\"\n      },\n      {\n        \"id\": \"message-2\",\n        \"sender\": \"me\",\n        \"text\": \"Maybe…\",\n        \"blur\": false,\n        \"delay\": 2,\n        \"time\": \"09:41\"\n      }\n    ]\n  }\n}),\n});\nconst result = await response.json();\nif (!response.ok) throw new Error(result.error);\nconsole.log(result);"}]},"delete":{"tags":["Conversations"],"operationId":"deleteConversation","summary":"Delete a conversation","description":"Permanently delete this scene using its current revision. Send a JSON body even though the method is DELETE. A stale revision or an already deleted scene returns 409.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Revision"},"example":{"revision":1}}}},"responses":{"200":{"description":"Deleted scene ID.","content":{"application/json":{"schema":{"type":"object","required":["deleted"],"properties":{"deleted":{"type":"string","format":"uuid"}}}}}},"400":{"description":"Invalid JSON, fields, UUID or conversation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid, expired or revoked API key/session. Create a new key or sign in again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key permission missing, operation disabled, wrong workspace, lost team access or Origin not allowed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Revision conflict, duplicate conversation ID, reused export ID with different options, or another export already running.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Request body too large.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Operation failed. Retry without discarding your local changes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-codeSamples":[{"lang":"Shell","label":"cURL","source":"curl --fail-with-body \"$CLEANCHAT_URL/api/conversations/91c394cf-e76a-4d78-b099-5df18b38c495\" \\\n  -X DELETE \\\n  -H \"Authorization: Bearer $CLEANCHAT_API_KEY\" \\\n  -H 'Content-Type: application/json' \\\n  --data '{\n  \"revision\": 1\n}'"},{"lang":"JavaScript","label":"JavaScript","source":"const response = await fetch(process.env.CLEANCHAT_URL + '/api/conversations/91c394cf-e76a-4d78-b099-5df18b38c495', {\n  method: 'DELETE',\n  headers: {\n    Authorization: 'Bearer ' + process.env.CLEANCHAT_API_KEY,\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({\n  \"revision\": 1\n}),\n});\nconst result = await response.json();\nif (!response.ok) throw new Error(result.error);\nconsole.log(result);"}]}},"/exports":{"post":{"tags":["Exports"],"operationId":"startExport","summary":"Start an export","description":"Returns a job, not the file. Pass export settings inside options. Generate a requestId UUID once per export and reuse it with exactly the same request for retries. The exportId equals requestId. The saved scene is unchanged. A ready job can be returned when retrying an already completed request.","parameters":[{"name":"X-CleanChat-Workspace","in":"header","description":"Usually omit: API keys automatically use their bound workspace. If supplied, this must match the key’s team workspace. With a user-session JWT, omit for personal space or pass a team UUID.","schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateExport"},"example":{"id":"91c394cf-e76a-4d78-b099-5df18b38c495","revision":1,"requestId":"c2e0856f-927a-4e31-800a-b0039ec8afce","options":{"format":"mp4","speed":1,"settings":{}}}}}},"responses":{"202":{"description":"Export accepted; poll its status.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExportJob"},"example":{"exportId":"c2e0856f-927a-4e31-800a-b0039ec8afce","conversationId":"91c394cf-e76a-4d78-b099-5df18b38c495","revision":1,"status":"queued","progress":0,"chargedMinutes":1,"format":"mp4","filename":"The-missing-charger-c2e0856f.mp4","mimeType":"video/mp4","width":804,"height":1748,"durationSeconds":12,"bytes":null,"expiresAt":"2026-09-25T12:00:00Z","pollAfterSeconds":3,"nextTool":"get_export"}}}},"400":{"description":"Invalid JSON, fields, UUID or conversation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid, expired or revoked API key/session. Create a new key or sign in again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"Not enough cloud video minutes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key permission missing, operation disabled, wrong workspace, lost team access or Origin not allowed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Resource not found in this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Revision conflict, duplicate conversation ID, reused export ID with different options, or another export already running.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"410":{"description":"Export expired. Create a new export with a new requestId.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Request body too large.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Operation failed. Retry without discarding your local changes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-codeSamples":[{"lang":"Shell","label":"cURL","source":"curl --fail-with-body \"$CLEANCHAT_URL/api/exports\" \\\n  -X POST \\\n  -H \"Authorization: Bearer $CLEANCHAT_API_KEY\" \\\n  -H 'Content-Type: application/json' \\\n  --data '{\n  \"id\": \"91c394cf-e76a-4d78-b099-5df18b38c495\",\n  \"revision\": 1,\n  \"requestId\": \"c2e0856f-927a-4e31-800a-b0039ec8afce\",\n  \"options\": {\n    \"format\": \"mp4\",\n    \"speed\": 1,\n    \"settings\": {}\n  }\n}'"},{"lang":"JavaScript","label":"JavaScript","source":"const response = await fetch(process.env.CLEANCHAT_URL + '/api/exports', {\n  method: 'POST',\n  headers: {\n    Authorization: 'Bearer ' + process.env.CLEANCHAT_API_KEY,\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({\n  \"id\": \"91c394cf-e76a-4d78-b099-5df18b38c495\",\n  \"revision\": 1,\n  \"requestId\": \"c2e0856f-927a-4e31-800a-b0039ec8afce\",\n  \"options\": {\n    \"format\": \"mp4\",\n    \"speed\": 1,\n    \"settings\": {}\n  }\n}),\n});\nconst result = await response.json();\nif (!response.ok) throw new Error(result.error);\nconsole.log(result);"}]}},"/exports/{exportId}":{"parameters":[{"name":"exportId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"X-CleanChat-Workspace","in":"header","description":"Usually omit: API keys automatically use their bound workspace. If supplied, this must match the key’s team workspace. With a user-session JWT, omit for personal space or pass a team UUID.","schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Exports"],"operationId":"getExport","summary":"Check progress and download","description":"Poll queued/processing jobs after pollAfterSeconds. ready includes downloadUrl and downloadExpiresAt; failed includes error. A canceled job is reported as failed with error \"Export canceled.\". Each ready response renews the signed download link within the 24-hour retention period. Deleting a scene or losing team access prevents issuing new links. nextTool is a compatibility field; HTTP clients can ignore it.","responses":{"200":{"description":"Current job state.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExportJob"},"example":{"exportId":"c2e0856f-927a-4e31-800a-b0039ec8afce","conversationId":"91c394cf-e76a-4d78-b099-5df18b38c495","revision":1,"status":"ready","progress":100,"chargedMinutes":1,"format":"mp4","filename":"The-missing-charger-c2e0856f.mp4","mimeType":"video/mp4","width":804,"height":1748,"durationSeconds":12,"bytes":125000,"expiresAt":"2026-09-25T12:00:00Z","downloadUrl":"https://example.com/private-export.mp4?signature=example","downloadExpiresAt":"2026-09-24T12:15:00Z"}}}},"400":{"description":"Invalid JSON, fields, UUID or conversation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid, expired or revoked API key/session. Create a new key or sign in again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key permission missing, operation disabled, wrong workspace, lost team access or Origin not allowed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Resource not found in this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"410":{"description":"Export expired. Create a new export with a new requestId.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Operation failed. Retry without discarding your local changes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-codeSamples":[{"lang":"Shell","label":"cURL","source":"curl --fail-with-body \"$CLEANCHAT_URL/api/exports/c2e0856f-927a-4e31-800a-b0039ec8afce\" \\\n  -X GET \\\n  -H \"Authorization: Bearer $CLEANCHAT_API_KEY\""},{"lang":"JavaScript","label":"JavaScript","source":"const response = await fetch(process.env.CLEANCHAT_URL + '/api/exports/c2e0856f-927a-4e31-800a-b0039ec8afce', {\n  method: 'GET',\n  headers: {\n    Authorization: 'Bearer ' + process.env.CLEANCHAT_API_KEY,\n  },\n});\nconst result = await response.json();\nif (!response.ok) throw new Error(result.error);\nconsole.log(result);"}]},"delete":{"tags":["Exports"],"operationId":"cancelExport","summary":"Cancel a pending export","description":"Cancels a queued or processing job and returns its reserved minutes. This does not delete a completed file or refund a completed render. The response is also cancelled:true when the job has already finished.","responses":{"200":{"description":"Cancellation request handled.","content":{"application/json":{"schema":{"type":"object","required":["cancelled"],"properties":{"cancelled":{"type":"boolean","enum":[true]}}}}}},"400":{"description":"Invalid JSON, fields, UUID or conversation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid, expired or revoked API key/session. Create a new key or sign in again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key permission missing, operation disabled, wrong workspace, lost team access or Origin not allowed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Resource not found in this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"410":{"description":"Export expired. Create a new export with a new requestId.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Operation failed. Retry without discarding your local changes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-codeSamples":[{"lang":"Shell","label":"cURL","source":"curl --fail-with-body \"$CLEANCHAT_URL/api/exports/c2e0856f-927a-4e31-800a-b0039ec8afce\" \\\n  -X DELETE \\\n  -H \"Authorization: Bearer $CLEANCHAT_API_KEY\""},{"lang":"JavaScript","label":"JavaScript","source":"const response = await fetch(process.env.CLEANCHAT_URL + '/api/exports/c2e0856f-927a-4e31-800a-b0039ec8afce', {\n  method: 'DELETE',\n  headers: {\n    Authorization: 'Bearer ' + process.env.CLEANCHAT_API_KEY,\n  },\n});\nconst result = await response.json();\nif (!response.ok) throw new Error(result.error);\nconsole.log(result);"}]}},"/media/video":{"post":{"tags":["Media"],"operationId":"importVideo","summary":"Import a video","security":[],"description":"Send raw file bytes, not multipart/form-data and not JSON. Up to 100,000,000 bytes; use video/mp4, video/quicktime or video/webm. Returns a normalized video object directly, ready for message.video in an iMessage/WhatsApp project. Set the message text to an empty string. This endpoint is also available to guests; cross-origin browser requests remain rejected.","requestBody":{"required":true,"content":{"video/mp4":{"schema":{"type":"string","format":"binary"}},"video/quicktime":{"schema":{"type":"string","format":"binary"}},"video/webm":{"schema":{"type":"string","format":"binary"}}}},"responses":{"200":{"description":"Prepared video. Embed the whole object as message.video.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Video"}}}},"400":{"description":"Invalid JSON, fields, UUID or conversation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key permission missing, operation disabled, wrong workspace, lost team access or Origin not allowed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Request body too large.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"415":{"description":"Unsupported video Content-Type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Video cannot be processed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Media runner busy. Retry later.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Operation failed. Retry without discarding your local changes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Media processing failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Service temporarily unavailable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-codeSamples":[{"lang":"Shell","label":"cURL","source":"curl --fail-with-body \"$CLEANCHAT_URL/api/media/video\" \\\n  -H \"Content-Type: video/mp4\" \\\n  --data-binary @clip.mp4"}]}},"/health":{"get":{"tags":["Service"],"operationId":"health","summary":"Check service health","security":[],"responses":{"200":{"description":"Web service is responding; does not probe the database or renderer.","content":{"application/json":{"schema":{"type":"object","required":["ok","app"],"properties":{"ok":{"type":"boolean","enum":[true]},"app":{"type":"string","enum":["CleanChat"]}}}}}}}}},"/config":{"get":{"tags":["Service"],"operationId":"getConfig","summary":"Get sign-in configuration","security":[],"description":"Public Supabase URL and publishable key for signing in. supabaseKey is not a privileged server key. testMode describes billing configuration.","responses":{"200":{"description":"Public configuration.","content":{"application/json":{"schema":{"type":"object","required":["supabaseUrl","supabaseKey","testMode"],"properties":{"supabaseUrl":{"type":"string","format":"uri"},"supabaseKey":{"type":"string"},"testMode":{"type":"boolean"}}}}}},"500":{"description":"Operation failed. Retry without discarding your local changes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Service temporarily unavailable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"components":{"securitySchemes":{"ApiKey":{"type":"http","scheme":"bearer","bearerFormat":"cc_…","description":"Personal key from Studio → API keys, valid for 90 days. Permissions and workspace restrictions apply. Send Authorization: Bearer cc_…. User-session JWTs are also accepted; MCP OAuth tokens are not."}},"schemas":{"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"string"}}},"Revision":{"type":"object","additionalProperties":false,"required":["revision"],"properties":{"revision":{"type":"integer","minimum":1}}},"StudioSettings":{"type":"object","properties":{"theme":{"type":"string","enum":["whatsapp","imessage","instagram"]},"dark":{"type":"boolean"},"contact":{"type":"string","maxLength":40},"avatar":{"type":"string","maxLength":1999999,"description":"PNG, JPEG or WebP data URL, or empty to use initials. External URLs are not accepted."},"selfAvatar":{"type":"string","maxLength":350000,"description":"Your avatar for sent WhatsApp voice messages. Embedded image, or empty for initials."},"username":{"type":"string","pattern":"^[a-zA-Z0-9._]{0,30}$"},"blurContact":{"type":"boolean"},"unreadCount":{"type":"integer","minimum":0,"maximum":999},"silentMode":{"type":"boolean"},"network":{"description":"Connection shown in the phone status bar. Global default is wifi; message/day overrides persist until changed.","type":"string","enum":["wifi","5g","4g","3g","none","airplane"]},"signalStrength":{"description":"Cellular signal bars, 0–4; independent of Wi-Fi strength.","anyOf":[{"type":"number","enum":[0]},{"type":"number","enum":[1]},{"type":"number","enum":[2]},{"type":"number","enum":[3]},{"type":"number","enum":[4]}]},"wifiStrength":{"description":"Wi-Fi strength, 0 hides the Wi-Fi icon, 3 is full.","anyOf":[{"type":"number","enum":[0]},{"type":"number","enum":[1]},{"type":"number","enum":[2]},{"type":"number","enum":[3]}]},"clockMode":{"type":"string","enum":["messages","fixed"],"description":"Defaults to messages: start at the first live sent message time, then follow each new message at arrival (or at typing start when it opens a day separator). History and unsent typing never change the phone clock. With no live messages use clock. fixed always uses clock."},"clock":{"type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","description":"Start time. Changing it shifts all message times by the same interval; messages mode anchors on the first live sent message."},"timeFormat":{"type":"string","enum":["12h","24h"]},"battery":{"type":"number","minimum":0,"maximum":100},"date":{"type":"string","maxLength":50},"keyboard":{"type":"string","enum":["azerty","qwerty","hidden"],"description":"hidden closes the keyboard and gives the conversation more room."},"pace":{"type":"string","enum":["snappy","natural","slow"],"description":"Apply a timing preset to messages without customTiming. Saved custom timings are kept."},"typing":{"type":"object","properties":{"charactersPerSecond":{"description":"Default 5. Lower is slower. Automatic duration follows text length, with deterministic word/punctuation pauses, in every keyboard mode and for both speakers.","type":"number","minimum":1,"maximum":15},"startPause":{"description":"Seconds before your first key, default 0.7. At least 0.45 when opening the keyboard.","type":"number","minimum":0,"maximum":10},"sendPause":{"description":"Review pause before sending your text, default 0.55 seconds. Unsent drafts hesitate at least 0.3 seconds before erasing.","type":"number","minimum":0,"maximum":10},"tapDuration":{"description":"Visible key press duration in seconds, default 0.1; shortened when needed to release before the next tap.","type":"number","minimum":0.04,"maximum":0.3},"pauseBetween":{"description":"Gap after each message, before the next pauseBefore, default 0.45 seconds. Does not truncate media.","type":"number","minimum":0,"maximum":10}},"additionalProperties":false,"description":"Global typing settings at 1x, shared by preview and exports. Replaces this settings object; omitted fields use defaults, {} resets it. Does not change audio playback rate. customTiming keeps the selected text typing seconds; outgoing lead/review pauses still apply."},"keyboardMode":{"type":"string","enum":["hidden","typing","always"],"description":"Keyboard visibility. typing slides it open; keyboardLanguage controls the layout. All modes use the same human typing cadence from settings.typing (customTiming keeps the chosen typing seconds)."},"frame":{"type":"boolean","description":"Include the device frame. Use false for a native phone screenshot."},"screenRecording":{"type":"boolean"},"phoneLanguage":{"type":"string","enum":["fr","en","es","pt"]},"keyboardLanguage":{"type":"string","enum":["fr","en","es","pt"]},"historyScroll":{"nullable":true,"description":"Scroll through messages with play:false before live playback. duration is 1–30 seconds of scrolling; playing playAudio:true history vocals or playVideo:true history videos adds their duration and a short tap/pause. messageId starts at that history message, otherwise at the beginning. Null disables the intro. Missing/deleted anchors fall back to the beginning.","type":"object","properties":{"seed":{"description":"Scroll rhythm variation. Change it for a different pattern of gestures and pauses; the same value keeps preview and export identical.","type":"integer","minimum":0,"maximum":4294967295},"duration":{"type":"number","minimum":1,"maximum":30},"pauseAfter":{"description":"Pause after scrolling, before any message-specific pause. Replaces the first media delay; zero skips this pause. Omit to preserve legacy timing.","type":"number","minimum":0,"maximum":30},"messageId":{"type":"string","maxLength":100}},"required":["duration"],"additionalProperties":false}},"additionalProperties":false},"Message":{"type":"object","properties":{"network":{"description":"Connection shown in the phone status bar. Global default is wifi; message/day overrides persist until changed.","type":"string","enum":["wifi","5g","4g","3g","none","airplane"]},"signalStrength":{"description":"Cellular signal bars, 0–4; independent of Wi-Fi strength.","anyOf":[{"type":"number","enum":[0]},{"type":"number","enum":[1]},{"type":"number","enum":[2]},{"type":"number","enum":[3]},{"type":"number","enum":[4]}]},"wifiStrength":{"description":"Wi-Fi strength, 0 hides the Wi-Fi icon, 3 is full.","anyOf":[{"type":"number","enum":[0]},{"type":"number","enum":[1]},{"type":"number","enum":[2]},{"type":"number","enum":[3]}]},"reaction":{"description":"Instagram only: one emoji in a small badge below this sent or received message. Supports text, photos, vocals and story replies. Empty string removes it. History reactions are already visible; live reactions appear after delivery with a short transition (0.9s after arrival, or after a successful retry). Message blur hides the reaction too. Unsent actions cannot have reactions. Independent of story.emoji, which is the large emoji over the story image.","type":"string","maxLength":32},"phoneBefore":{"description":"Apply this message battery, silentMode and network overrides this many seconds before arrival, clamped to frame zero. Default 0. Does not change message timing; clock still follows arrival. Ignored for history and unsent actions.","type":"number","minimum":0,"maximum":30},"failure":{"description":"Outgoing sent messages only, all media: show Not Delivered 0.7s after arrival. retryAfter schedules a tap to resend that many seconds after failure, without a modal or blocking new messages. Omit retryAfter to leave failed. Media is not auto-played on retry; outgoing recordBeforeSend records once before the first attempt. Overlapping retries queue to allow scrolling. Ignored on history.","type":"object","properties":{"retryAfter":{"type":"number","minimum":0.4,"maximum":120}},"additionalProperties":false},"id":{"type":"string","maxLength":100},"playAudio":{"description":"For play:false audio messages only: stop scrolling to listen, then resume. Requires historyScroll. Does not resend or record the vocal. Blurred vocals stay muted; vocals before the start anchor are skipped.","type":"boolean"},"customTiming":{"description":"Keep this message timing when changing the scene pace.","type":"boolean"},"text":{"description":"For story, text is the reply below the thumbnail. Leave empty for a regular photo, audio or video; put accompanying text in a separate message.","type":"string","maxLength":1500},"photo":{"$ref":"#/components/schemas/Photo"},"story":{"$ref":"#/components/schemas/Story"},"audio":{"$ref":"#/components/schemas/Audio"},"video":{"$ref":"#/components/schemas/Video"},"playVideo":{"description":"Live videos play after arrival by default. False leaves a still thumbnail. On play:false history, true plays the existing video during historyScroll without resending; scrolling resumes after playback. Blurred videos do not play.","type":"boolean"},"sender":{"type":"string","enum":["me","contact"]},"send":{"description":"Defaults to true. Text only: false shows the contact typing then stopping, or types and erases your outgoing draft, without ever sending a bubble. play:false skips this unsent action entirely. delay and pauseBefore still control timing.","type":"boolean"},"play":{"description":"Defaults to true. False makes the message already visible at video start, without typing, sending or recording. Use playAudio:true to listen to an existing vocal during historyScroll.","type":"boolean"},"blur":{"type":"boolean"},"delay":{"description":"Text: minimum typing seconds, or exact typing seconds with customTiming:true (0.4–120); excludes outgoing startPause/sendPause. Otherwise duration also follows settings.typing.charactersPerSecond. Media: delay before recording/sending.","type":"number","minimum":0.4,"maximum":120},"pauseBefore":{"description":"Extra idle pause before this message starts typing, recording or sending. Defaults to 0. Added after any history scroll pause; ignored for play:false.","type":"number","minimum":0,"maximum":30},"time":{"type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","description":"HH:mm, always stored in 24-hour format."},"silentMode":{"description":"Phone silent mode from this new sent message arrival onward. true enables it; false disables it. Omit for no change. History and unsent actions ignore it. Does not mute voice messages or videos.","type":"boolean"},"dayStart":{"description":"Phone settings when this day separator appears, before typing starts; they persist until the next override. dark:true switches the whole phone to dark mode; dark:false to light mode; omit to keep the current appearance. Requires a nonempty date (or settings.date on the first sent message). The phone clock uses this message time when clockMode is messages. History and unsent actions ignore dayStart. Message-level battery/silentMode can override again on arrival.","type":"object","properties":{"network":{"description":"Connection shown in the phone status bar. Global default is wifi; message/day overrides persist until changed.","type":"string","enum":["wifi","5g","4g","3g","none","airplane"]},"signalStrength":{"description":"Cellular signal bars, 0–4; independent of Wi-Fi strength.","anyOf":[{"type":"number","enum":[0]},{"type":"number","enum":[1]},{"type":"number","enum":[2]},{"type":"number","enum":[3]},{"type":"number","enum":[4]}]},"wifiStrength":{"description":"Wi-Fi strength, 0 hides the Wi-Fi icon, 3 is full.","anyOf":[{"type":"number","enum":[0]},{"type":"number","enum":[1]},{"type":"number","enum":[2]},{"type":"number","enum":[3]}]},"battery":{"type":"integer","minimum":0,"maximum":100},"silentMode":{"type":"boolean"},"dark":{"type":"boolean"}},"additionalProperties":false},"battery":{"description":"Phone battery percentage from this message arrival onward, until the next override. Omit for no change. Only new sent messages apply it; history and unsent typing ignore it. Does not change scene timing.","type":"integer","minimum":0,"maximum":100},"date":{"description":"Day separator before this message, e.g. Monday, Yesterday or Today. Set on the first message of each new day; omit or leave blank on following messages. On the first message, overrides settings.date (empty hides it).","type":"string","maxLength":50}},"required":["id","sender","text","blur","delay","time"],"additionalProperties":false},"Photo":{"type":"object","properties":{"src":{"type":"string","maxLength":350000,"pattern":"^data:image\\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$"},"width":{"type":"integer","minimum":1,"maximum":1024},"height":{"type":"integer","minimum":1,"maximum":1024},"live":{"type":"boolean"},"hd":{"type":"boolean"}},"required":["src","width","height"],"additionalProperties":false,"description":"All messaging themes. Embedded image data URL, max 350,000 characters, longest side 1024px. Width and height must match the decoded image. No external URLs. live shows the Live Photo badge on iMessage/WhatsApp; hd shows the HD badge on Instagram. Both are visual badges on the still image."},"Audio":{"type":"object","properties":{"src":{"type":"string","maxLength":1400000,"pattern":"^data:audio\\/(mpeg|mp3|mp4|x-m4a|wav|x-wav|ogg|webm);base64,[A-Za-z0-9+/]+={0,2}$"},"duration":{"type":"number","minimum":0,"exclusiveMinimum":true,"maximum":120},"waveform":{"minItems":40,"maxItems":40,"type":"array","items":{"type":"number","minimum":0,"maximum":1}},"recordingWaveform":{"description":"Computed automatically from the sound for smooth recording animation.","minItems":1,"maxItems":2400,"type":"array","items":{"type":"number","minimum":0,"maximum":1}},"recordBeforeSend":{"description":"Outgoing vocals: defaults to true. Animate recording with sound and a growing waveform, review, then send. Set false to send immediately and play afterward. Incoming vocals always play after arrival.","type":"boolean"},"showTranscript":{"description":"Show the Instagram View transcription row; defaults to true. Set false for a compact voice bubble.","type":"boolean"},"transcript":{"description":"Optional text displayed under the iMessage waveform. Supplied by you, not automatically transcribed.","type":"string","maxLength":1500}},"required":["src","duration","waveform"],"additionalProperties":false,"description":"Complete voice message. REST requires duration (seconds) and 40 waveform values in [0,1]; it does not compute them. Preserve these fields from an existing scene. Max 1 MB decoded audio and 120 seconds. Optional recordingWaveform must contain ceil(duration × 20) values."},"Video":{"type":"object","properties":{"src":{"type":"string","maxLength":1400000,"pattern":"^data:video\\/(mp4|quicktime|webm);base64,[A-Za-z0-9+/]+={0,2}$"},"muted":{"type":"boolean"},"width":{"type":"integer","exclusiveMinimum":true,"maximum":640,"minimum":0},"height":{"type":"integer","exclusiveMinimum":true,"maximum":640,"minimum":0},"duration":{"type":"number","minimum":0,"exclusiveMinimum":true,"maximum":30},"poster":{"$ref":"#/components/schemas/Photo"},"audio":{"$ref":"#/components/schemas/Audio"}},"required":["src","width","height","duration","poster"],"additionalProperties":false,"description":"Complete video for iMessage or WhatsApp. Use the object returned by POST /api/media/video. Poster dimensions must match the video. Optional audio duration must match within 0.15 seconds. No Instagram videos."},"Story":{"type":"object","properties":{"photo":{"$ref":"#/components/schemas/Photo"},"emoji":{"type":"string","maxLength":32},"blur":{"type":"boolean"}},"required":["photo"],"additionalProperties":false,"description":"Instagram only. A portrait story thumbnail with optional reaction emoji. Put the reply in message.text (empty for reaction only). story.blur hides only the photo; message.blur hides only the reply text and reaction emoji, including typed drafts. No audio, photo or video alongside story; send:false is unsupported. Uses the normal message timing, history and sender."},"Project":{"type":"object","properties":{"theme":{"type":"string","enum":["whatsapp","imessage","instagram"]},"dark":{"type":"boolean"},"contact":{"type":"string","maxLength":40},"avatar":{"type":"string","maxLength":1999999,"description":"PNG, JPEG or WebP data URL, or empty to use initials. External URLs are not accepted."},"selfAvatar":{"type":"string","maxLength":350000,"description":"Your avatar for sent WhatsApp voice messages. Embedded image, or empty for initials."},"username":{"type":"string","pattern":"^[a-zA-Z0-9._]{0,30}$"},"blurContact":{"type":"boolean"},"unreadCount":{"type":"integer","minimum":0,"maximum":999},"silentMode":{"type":"boolean"},"network":{"description":"Connection shown in the phone status bar. Global default is wifi; message/day overrides persist until changed.","type":"string","enum":["wifi","5g","4g","3g","none","airplane"]},"signalStrength":{"description":"Cellular signal bars, 0–4; independent of Wi-Fi strength.","anyOf":[{"type":"number","enum":[0]},{"type":"number","enum":[1]},{"type":"number","enum":[2]},{"type":"number","enum":[3]},{"type":"number","enum":[4]}]},"wifiStrength":{"description":"Wi-Fi strength, 0 hides the Wi-Fi icon, 3 is full.","anyOf":[{"type":"number","enum":[0]},{"type":"number","enum":[1]},{"type":"number","enum":[2]},{"type":"number","enum":[3]}]},"clockMode":{"type":"string","enum":["messages","fixed"],"description":"Defaults to messages: start at the first live sent message time, then follow each new message at arrival (or at typing start when it opens a day separator). History and unsent typing never change the phone clock. With no live messages use clock. fixed always uses clock."},"clock":{"type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","description":"Phone clock in HH:mm. Update message times yourself when changing it through REST."},"timeFormat":{"type":"string","enum":["12h","24h"]},"battery":{"type":"number","minimum":0,"maximum":100},"date":{"type":"string","maxLength":50},"keyboard":{"type":"string","enum":["azerty","qwerty","hidden"],"description":"hidden closes the keyboard and gives the conversation more room."},"pace":{"type":"string","enum":["snappy","natural","slow"],"description":"Saved pace label. A REST save does not recalculate message delays or pauses."},"typing":{"type":"object","properties":{"charactersPerSecond":{"description":"Default 5. Lower is slower. Automatic duration follows text length, with deterministic word/punctuation pauses, in every keyboard mode and for both speakers.","type":"number","minimum":1,"maximum":15},"startPause":{"description":"Seconds before your first key, default 0.7. At least 0.45 when opening the keyboard.","type":"number","minimum":0,"maximum":10},"sendPause":{"description":"Review pause before sending your text, default 0.55 seconds. Unsent drafts hesitate at least 0.3 seconds before erasing.","type":"number","minimum":0,"maximum":10},"tapDuration":{"description":"Visible key press duration in seconds, default 0.1; shortened when needed to release before the next tap.","type":"number","minimum":0.04,"maximum":0.3},"pauseBetween":{"description":"Gap after each message, before the next pauseBefore, default 0.45 seconds. Does not truncate media.","type":"number","minimum":0,"maximum":10}},"additionalProperties":false,"description":"Typing settings at 1×. Omitted fields use renderer defaults. Preserve other settings when replacing the full project."},"keyboardMode":{"type":"string","enum":["hidden","typing","always"],"description":"Keyboard visibility. typing slides it open; keyboardLanguage controls the layout. All modes use the same human typing cadence from settings.typing (customTiming keeps the chosen typing seconds)."},"frame":{"type":"boolean","description":"Include the device frame. Use false for a native phone screenshot."},"screenRecording":{"type":"boolean"},"phoneLanguage":{"type":"string","enum":["fr","en","es","pt"]},"keyboardLanguage":{"type":"string","enum":["fr","en","es","pt"]},"historyScroll":{"nullable":true,"description":"Scroll through messages with play:false before live playback. duration is 1–30 seconds of scrolling; playing playAudio:true history vocals or playVideo:true history videos adds their duration and a short tap/pause. messageId starts at that history message, otherwise at the beginning. Null disables the intro. Missing/deleted anchors fall back to the beginning.","type":"object","properties":{"seed":{"description":"Scroll rhythm variation. Change it for a different pattern of gestures and pauses; the same value keeps preview and export identical.","type":"integer","minimum":0,"maximum":4294967295},"duration":{"type":"number","minimum":1,"maximum":30},"pauseAfter":{"description":"Pause after scrolling, before any message-specific pause. Replaces the first media delay; zero skips this pause. Omit to preserve legacy timing.","type":"number","minimum":0,"maximum":30},"messageId":{"type":"string","maxLength":100}},"required":["duration"],"additionalProperties":false},"messages":{"type":"array","maxItems":100,"items":{"$ref":"#/components/schemas/Message"}}},"additionalProperties":false,"required":["theme","dark","contact","avatar","clock","battery","date","keyboard","frame","messages"],"description":"Complete saved scene, not a settings patch. Message IDs must be unique. Media messages need empty text and one attachment, except Instagram story replies. Reactions and stories require Instagram; unsent actions are text-only. Embedded media totals at most 1,800,000 characters, photos 16 megapixels, and audio/video 180 seconds per scene. Cross-field rules are validated by the server. REST stores values as supplied: it does not fill message defaults, normalize media, shift times when clock changes, or apply pace presets."},"SaveConversation":{"type":"object","additionalProperties":false,"required":["title","project","revision"],"properties":{"title":{"type":"string","minLength":1,"maxLength":100,"description":"Whitespace is trimmed; the title must remain nonempty."},"revision":{"type":"integer","minimum":1,"nullable":true,"description":"null for creation, latest revision for updates."},"project":{"$ref":"#/components/schemas/Project"}}},"ConversationSummary":{"type":"object","required":["id","title","revision","created_at","updated_at"],"properties":{"id":{"type":"string","format":"uuid"},"title":{"type":"string"},"revision":{"type":"integer","minimum":1},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"messageCount":{"type":"integer","description":"Only when preview=true."},"preview":{"$ref":"#/components/schemas/Project","description":"Only when preview=true; up to the last four sent messages."}}},"Conversation":{"type":"object","required":["id","title","revision","created_at","updated_at","project"],"properties":{"id":{"type":"string","format":"uuid"},"title":{"type":"string"},"revision":{"type":"integer","minimum":1},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"messageCount":{"type":"integer","description":"Only when preview=true."},"preview":{"$ref":"#/components/schemas/Project","description":"Only when preview=true; up to the last four sent messages."},"project":{"$ref":"#/components/schemas/Project"},"user_id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid","nullable":true}}},"ExportOptions":{"type":"object","properties":{"format":{"default":"png","type":"string","enum":["png","mp4"]},"settings":{"allOf":[{"$ref":"#/components/schemas/StudioSettings"}],"default":{},"description":"Temporary overrides only. These apply studio transformations, including clock shifts and pace presets."},"time":{"description":"PNG capture time in the animation, in seconds. Omit to show all messages.","type":"number","minimum":0,"maximum":86400},"cursorTime":{"description":"PNG cursor blink phase, in seconds. Omit to use the capture time.","type":"number","minimum":0,"maximum":86400},"speed":{"default":1,"description":"MP4 playback speed, as in the studio.","type":"number","minimum":0.5,"maximum":4}},"additionalProperties":false},"CreateExport":{"type":"object","additionalProperties":false,"required":["id","revision","requestId","options"],"properties":{"id":{"type":"string","format":"uuid"},"revision":{"type":"integer","minimum":1},"requestId":{"type":"string","format":"uuid","description":"Idempotency key; reuse with identical payload for a retry."},"options":{"$ref":"#/components/schemas/ExportOptions"}}},"ExportJob":{"type":"object","required":["exportId","conversationId","revision","status","progress","chargedMinutes","format","filename","mimeType","width","height","durationSeconds","bytes","expiresAt"],"properties":{"exportId":{"type":"string","format":"uuid"},"conversationId":{"type":"string","format":"uuid"},"revision":{"type":"integer","minimum":1},"status":{"type":"string","enum":["queued","processing","ready","failed"]},"progress":{"type":"integer","minimum":0,"maximum":100},"chargedMinutes":{"type":"integer","minimum":0},"format":{"type":"string","enum":["png","mp4"]},"filename":{"type":"string"},"mimeType":{"type":"string","enum":["image/png","video/mp4"]},"width":{"type":"integer"},"height":{"type":"integer"},"durationSeconds":{"type":"number","nullable":true,"description":"null for PNG."},"bytes":{"type":"integer","nullable":true},"expiresAt":{"type":"string","format":"date-time"},"error":{"type":"string"},"pollAfterSeconds":{"type":"integer","enum":[3]},"nextTool":{"type":"string","enum":["get_export"]},"downloadUrl":{"type":"string","format":"uri","description":"Private signed URL; only present when ready. Download without an Authorization header."},"downloadExpiresAt":{"type":"string","format":"date-time"}}}}}}