{
  "info": {
    "name": "Orki In-App Chat \u2014 Public Web API",
    "description": "Everything a client app needs to run a customer chat on the Orki platform \u2014 the same API the embeddable web widget uses.\n\n## Quick start\n1. Pick an environment (**Orki-Local** / **Orki-Production**) and fill in `tenant_id` + `integration_id`.\n2. Run **1 \u00b7 Bootstrap \u2192 Mint / resume session** \u2014 it stores `session_token`, `customer_id`, `chat_id` for every other request.\n3. Create the WebSocket request (folder **5**) and say hello.\n4. Fetch the conversation with **3 \u00b7 Get messages**.\n\n## Golden rules\n- `Authorization: Bearer {{session_token}}` on all authenticated calls (set at collection level \u2014 requests inherit it).\n- **`X-Public-Chat-Client: 1`** on every POST/PUT/DELETE \u2014 missing it = `403`.\n- Sending messages is **WebSocket-only** (folder 5). REST covers everything else.\n- `401` anywhere \u2192 token expired: clear `session_token` and re-run *Mint / resume session*.\n\n## Message model (what `Message` objects look like)\n```jsonc\n{\n  \"id\": \"665f\u2026\",            // Mongo ObjectId\n  \"chatId\": \"\u2026\",\n  \"isCustomer\": true,          // true = visitor, false = AI or human agent\n  \"content\": { \"text\": \"\u2026\", \"mediaIds\": [\"\u2026\"], \"location\": [lon,lat], \"carousel\": [...] },\n  \"replyTo\": { \"messageId\", \"content\", \"isCustomer\" },\n  \"timestamp\": \"2026-08-11T09:12:33.123Z\",\n  \"status\": \"stored\",        // stored \u2192 sent \u2192 delivered \u2192 read | failed\n  \"readBy\": [ { \"userId\", \"timestamp\" } ]\n}\n```\n\nFull integration guide: `README.md` in this kit. WebSocket details: `websocket-guide.md`. Demo talk tracks: `demo-scenarios.md`.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "bearer",
    "bearer": [
      {
        "key": "token",
        "value": "{{session_token}}",
        "type": "string"
      }
    ]
  },
  "variable": [
    {
      "key": "session_token",
      "value": ""
    },
    {
      "key": "customer_id",
      "value": ""
    },
    {
      "key": "chat_id",
      "value": ""
    },
    {
      "key": "next_before",
      "value": ""
    },
    {
      "key": "last_message_id",
      "value": ""
    },
    {
      "key": "message_id",
      "value": ""
    },
    {
      "key": "temp_ref",
      "value": ""
    },
    {
      "key": "media_ids",
      "value": ""
    },
    {
      "key": "media_id",
      "value": ""
    },
    {
      "key": "turnstile_token",
      "value": ""
    }
  ],
  "item": [
    {
      "name": "1 \u00b7 Bootstrap",
      "description": "Start every session here.\n\n1. **Get widget config** (anonymous) \u2014 branding + which profile fields the tenant wants collected.\n2. **Mint / resume session** \u2014 returns `session_token` (30-day JWT). The test script stores `session_token`, `customer_id`, `chat_id` as collection variables, so every later request just works.\n\nRe-running *Mint / resume session* with a stored token **resumes** the same customer + chat (no Turnstile needed). If you get `403`, the token is stale: clear `session_token` and run again (with a fresh `turnstile_token` when Turnstile is enabled on the environment).",
      "item": [
        {
          "name": "Get widget config",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/tenants/{{tenant_id}}/integrations/{{integration_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "tenants",
                "{{tenant_id}}",
                "integrations",
                "{{integration_id}}"
              ]
            },
            "description": "**Anonymous.** Returns the integration's branding and behaviour config.\n\nFields worth reading:\n- `internalAuth` \u2014 must be `false` for the public session flow described in this collection\n- `initialForm` \u2014 `{isEnabled, collectName, collectEmail, collectPhone}`: which profile fields to ask for before the first message (then `PUT /customer/me`)\n- `starterMessages` \u2014 suggested first messages to render as quick-reply chips\n- `aiProfileName`, `defaultHandlerPhotoUrl` \u2014 agent display name/avatar\n",
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "const j = pm.response.json();",
                  "pm.test('public auth mode', () => pm.expect(j.internalAuth).to.not.eql(true));",
                  "console.log('Agent name:', j.aiProfileName, '| widget style:', j.widgetStyle);"
                ]
              }
            }
          ]
        },
        {
          "name": "Mint / resume session",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "X-Public-Chat-Client",
                "value": "1"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/tenants/{{tenant_id}}/integrations/{{integration_id}}/session",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "tenants",
                "{{tenant_id}}",
                "integrations",
                "{{integration_id}}",
                "session"
              ]
            },
            "description": "Creates a customer + chat (first run) or resumes the existing ones (when the request carries a previously issued bearer).\n\n**Body**: `turnstileToken` is required for *new* sessions only when Turnstile is enabled (production). Local/dev stacks run with Turnstile disabled, so the empty default works. `chatName` is optional (named chat threads).\n\n**Response (top level is snake_case \u2014 unique in this API):** `session_token`, `customer_id`, `chat_id`, `unread_count`, plus `customer` and `chat` objects.\n\n**Errors**: `403` Turnstile failed or stale bearer \u2192 clear `session_token` variable, redo Turnstile, run again. `429` per-IP mint limit (30/hour). `404` bad tenant/integration ids.\n\nThe test script saves `session_token`, `customer_id`, `chat_id` for the rest of the collection and prints the ready-to-use WebSocket URL.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"turnstileToken\": \"{{turnstile_token}}\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "const j = pm.response.json();",
                  "pm.collectionVariables.set('session_token', j.session_token || '');",
                  "pm.collectionVariables.set('customer_id', j.customer_id);",
                  "pm.collectionVariables.set('chat_id', j.chat_id);",
                  "pm.test('got a session token', () => pm.expect(j.session_token).to.be.a('string').and.not.empty);",
                  "console.log('customer_id =', j.customer_id);",
                  "console.log('chat_id     =', j.chat_id);",
                  "console.log('chat status =', j.chat && j.chat.status, '| handler:', j.chat && j.chat.handlerName);",
                  "const ws = (pm.environment.get('ws_url')||'').replace(/\\/$/,'');",
                  "console.log('WebSocket URL \u2192', ws + '/chat/hub?tenantId=' + pm.variables.get('tenant_id') + '&integrationId=' + pm.variables.get('integration_id') + '&access_token=' + j.session_token);"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "2 \u00b7 Customer profile",
      "description": "Ghost customers start with empty name/email/phone. If the widget config's `initialForm.isEnabled` is true, collect the flagged fields in a pre-chat form and save them here.",
      "item": [
        {
          "name": "Get my customer + chat",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/tenants/{{tenant_id}}/integrations/{{integration_id}}/customer/me",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "tenants",
                "{{tenant_id}}",
                "integrations",
                "{{integration_id}}",
                "customer",
                "me"
              ]
            },
            "description": "Returns `{customer, chat}`. `customer: null` means the bearer is valid but no customer exists in this tenant yet \u2192 run *Mint / resume session*."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));"
                ]
              }
            }
          ]
        },
        {
          "name": "Update my profile",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "X-Public-Chat-Client",
                "value": "1"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/tenants/{{tenant_id}}/integrations/{{integration_id}}/customer/me",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "tenants",
                "{{tenant_id}}",
                "integrations",
                "{{integration_id}}",
                "customer",
                "me"
              ]
            },
            "description": "Partial update \u2014 omitted / null fields are left untouched. Returns `{\"updated\": true}`.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Demo Customer\",\n  \"email\": \"demo.customer@example.com\",\n  \"phone\": \"+96890000000\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "3 \u00b7 Conversation (REST)",
      "description": "History, pagination and read receipts. **Sending is not REST** \u2014 see folder *5 \u00b7 WebSocket hub* and `websocket-guide.md`.",
      "item": [
        {
          "name": "Get chat info",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/tenants/{{tenant_id}}/integrations/{{integration_id}}/chats/{{chat_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "tenants",
                "{{tenant_id}}",
                "integrations",
                "{{integration_id}}",
                "chats",
                "{{chat_id}}"
              ]
            },
            "description": "`{id, tenantId, status, handler, createdAt, platformId}`.\n\n`status` values: `unopened` (fresh session, no message yet) \u2192 `open` \u2192 terminal states (`closed`, `snoozed`, `needs_attention`). Poll this if your UI wants to show \"transferred to a human\" (the `handler` changes) or \"conversation closed\". A resolved chat reopens automatically when the customer sends a new message."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "console.log('status =', pm.response.json().status, '| handler =', pm.response.json().handler);"
                ]
              }
            }
          ]
        },
        {
          "name": "Get messages (latest page)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/tenants/{{tenant_id}}/integrations/{{integration_id}}/chats/{{chat_id}}/messages?pageSize=50",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "tenants",
                "{{tenant_id}}",
                "integrations",
                "{{integration_id}}",
                "chats",
                "{{chat_id}}",
                "messages"
              ],
              "query": [
                {
                  "key": "pageSize",
                  "value": "50"
                }
              ]
            },
            "description": "Newest-first. Response `{data: Message[], hasMore, nextBefore}`.\n\nThe test script stores `next_before` (for *Get messages (older page)*) and `last_message_id` (for *Mark as read*).\n\nMessage shape: see the collection description \u00a7Message model. Media messages carry only `content.mediaIds` \u2014 resolve them with the requests in *4 \u00b7 Media*."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "const j = pm.response.json();",
                  "if (j.nextBefore) pm.collectionVariables.set('next_before', j.nextBefore);",
                  "if (j.data && j.data.length) {",
                  "  pm.collectionVariables.set('last_message_id', j.data[0].id);",
                  "  pm.collectionVariables.set('message_id', j.data.find(m => (m.content||{}).mediaIds && m.content.mediaIds.length)?.id || j.data[0].id);",
                  "  console.log(j.data.length + ' messages; newest: [' + (j.data[0].isCustomer ? 'customer' : 'agent') + '] ' + ((j.data[0].content||{}).text||'<media>'));",
                  "} else { console.log('No messages yet \u2014 send one over the WebSocket first.'); }",
                  "console.log('hasMore =', j.hasMore, '| nextBefore =', j.nextBefore);"
                ]
              }
            }
          ]
        },
        {
          "name": "Get messages (older page)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/tenants/{{tenant_id}}/integrations/{{integration_id}}/chats/{{chat_id}}/messages?pageSize=50&before={{next_before}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "tenants",
                "{{tenant_id}}",
                "integrations",
                "{{integration_id}}",
                "chats",
                "{{chat_id}}",
                "messages"
              ],
              "query": [
                {
                  "key": "pageSize",
                  "value": "50"
                },
                {
                  "key": "before",
                  "value": "{{next_before}}"
                }
              ]
            },
            "description": "Pagination: pass the previous response's `nextBefore` as `before` (exclusive upper bound). Repeat until `hasMore` is `false`. Run *Get messages (latest page)* first to populate `{{next_before}}`."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "const j = pm.response.json();",
                  "if (j.nextBefore) pm.collectionVariables.set('next_before', j.nextBefore);",
                  "console.log(j.data.length + ' older messages | hasMore =', j.hasMore);"
                ]
              }
            }
          ]
        },
        {
          "name": "Mark as read",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "X-Public-Chat-Client",
                "value": "1"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/tenants/{{tenant_id}}/integrations/{{integration_id}}/chats/{{chat_id}}/read",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "tenants",
                "{{tenant_id}}",
                "integrations",
                "{{integration_id}}",
                "chats",
                "{{chat_id}}",
                "read"
              ]
            },
            "description": "Marks `lastMessageId` and everything older as read (drives the agent-side read ticks). Call when the newest message becomes visible in your UI. `{{last_message_id}}` is set by *Get messages (latest page)*.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"lastMessageId\": \"{{last_message_id}}\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('2xx', () => pm.expect(pm.response.code).to.be.below(300));"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "4 \u00b7 Media (upload & download)",
      "description": "**Sending an attachment is a 3-step flow:**\n\n1. *Upload file (staged)* \u2014 per file \u2192 returns a `ref`\n2. *Promote staged files* \u2014 `refs` \u2192 permanent `ids`\n3. WebSocket `CustomerMessage` with `content.mediaIds = ids` (the promote request prints the exact frame in the Postman console)\n\nLimits: 25 MiB per file (`413`), all files are content-type + antivirus scanned (`400` on rejection). Images, audio, video, PDFs and office docs are all accepted.\n\n**Voice notes**: same mechanism \u2014 upload the recorded audio file (e.g. `.webm`, `.ogg`, `.m4a`) and send it with **no text**. The platform auto-transcribes it and the AI answers the transcription. (The production widget uses the legacy one-shot upload for voice; both paths work.)",
      "item": [
        {
          "name": "Upload file (staged)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "X-Public-Chat-Client",
                "value": "1"
              }
            ],
            "url": {
              "raw": "{{base_url}}/tenants/{{tenant_id}}/integrations/{{integration_id}}/chats/{{chat_id}}/media/temp",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "tenants",
                "{{tenant_id}}",
                "integrations",
                "{{integration_id}}",
                "chats",
                "{{chat_id}}",
                "media",
                "temp"
              ]
            },
            "description": "`multipart/form-data`, single field `file`. **Select a file in the Body tab before sending.**\n\nReturns `{ref, contentType, sizeBytes, fileName}`. The `ref` is stored as `{{temp_ref}}`.\n\nPreview the staged file with *Preview staged file* (anonymous capability URL \u2014 handy for showing a thumbnail before the message is sent).",
            "body": {
              "mode": "formdata",
              "formdata": [
                {
                  "key": "file",
                  "type": "file",
                  "src": ""
                }
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "const j = pm.response.json();",
                  "pm.collectionVariables.set('temp_ref', j.ref);",
                  "console.log('staged:', j.fileName, j.contentType, j.sizeBytes + ' bytes \u2192 ref', j.ref);"
                ]
              }
            }
          ]
        },
        {
          "name": "Preview staged file",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/temp-media/{{tenant_id}}/{{temp_ref}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "temp-media",
                "{{tenant_id}}",
                "{{temp_ref}}"
              ]
            },
            "description": "**Anonymous** \u2014 the unguessable `ref` acts as the credential. This is the URL your app can put straight into an `<img>` for the pre-send preview. Served with `nosniff` + attachment disposition. Note: this endpoint lives outside the `/tenants/\u2026` prefix.",
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));"
                ]
              }
            }
          ]
        },
        {
          "name": "Remove staged file",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "X-Public-Chat-Client",
                "value": "1"
              }
            ],
            "url": {
              "raw": "{{base_url}}/tenants/{{tenant_id}}/integrations/{{integration_id}}/chats/{{chat_id}}/media/temp/{{temp_ref}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "tenants",
                "{{tenant_id}}",
                "integrations",
                "{{integration_id}}",
                "chats",
                "{{chat_id}}",
                "media",
                "temp",
                "{{temp_ref}}"
              ]
            },
            "description": "Un-stage a file the user removed from the composer. Idempotent \u2014 `204` either way."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('2xx', () => pm.expect(pm.response.code).to.be.below(300));"
                ]
              }
            }
          ]
        },
        {
          "name": "Promote staged files",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "X-Public-Chat-Client",
                "value": "1"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/tenants/{{tenant_id}}/integrations/{{integration_id}}/chats/{{chat_id}}/media/from-temp",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "tenants",
                "{{tenant_id}}",
                "integrations",
                "{{integration_id}}",
                "chats",
                "{{chat_id}}",
                "media",
                "from-temp"
              ]
            },
            "description": "Converts staged refs into permanent media ids attached to this chat and deletes the temps. Do this at send time, then put the returned `ids` into the WebSocket message's `content.mediaIds`.\n\n**The test script prints the complete, ready-to-send WebSocket frame in the Postman console** \u2014 copy it into the WS request (remember the trailing 0x1E terminator; easiest is to paste the mediaIds into a frame copied from `postman/ws-frames.txt`).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"refs\": [\"{{temp_ref}}\"]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "const j = pm.response.json();",
                  "pm.collectionVariables.set('media_ids', JSON.stringify(j.ids));",
                  "const frame = {type:1,target:'CustomerMessage',arguments:[{chatId:pm.variables.get('chat_id'),tempId:'replace-with-uuid',content:{text:'Here you go',mediaIds:j.ids},correlationId:'replace-with-uuid'}]};",
                  "console.log('WS frame (append the invisible 0x1E terminator):');",
                  "console.log(JSON.stringify(frame));"
                ]
              }
            }
          ]
        },
        {
          "name": "Upload voice note (legacy one-shot)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "X-Public-Chat-Client",
                "value": "1"
              }
            ],
            "url": {
              "raw": "{{base_url}}/tenants/{{tenant_id}}/integrations/{{integration_id}}/chats/{{chat_id}}/media",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "tenants",
                "{{tenant_id}}",
                "integrations",
                "{{integration_id}}",
                "chats",
                "{{chat_id}}",
                "media"
              ]
            },
            "description": "Single-request upload \u2192 `{id}` immediately usable in `content.mediaIds`. This is what the production widget uses for voice recordings (`voiceRecording.webm`). No staging/preview step. **Select an audio file in the Body tab**, then send a WS `CustomerMessage` with this id and no text \u2014 the platform transcribes it and the AI answers the transcription.",
            "body": {
              "mode": "formdata",
              "formdata": [
                {
                  "key": "file",
                  "type": "file",
                  "src": ""
                }
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "const j = pm.response.json();",
                  "pm.collectionVariables.set('media_ids', JSON.stringify([j.id]));",
                  "console.log('media id:', j.id, '\u2192 send WS CustomerMessage with content.mediaIds=[\"' + j.id + '\"] and NO text for voice transcription');"
                ]
              }
            }
          ]
        },
        {
          "name": "Get message attachments (metadata)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/tenants/{{tenant_id}}/integrations/{{integration_id}}/chats/{{chat_id}}/message/{{message_id}}/media",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "tenants",
                "{{tenant_id}}",
                "integrations",
                "{{integration_id}}",
                "chats",
                "{{chat_id}}",
                "message",
                "{{message_id}}",
                "media"
              ]
            },
            "description": "For an incoming message with `content.mediaIds`: returns `[{id, mimeType, name, size, width, height, numOfPages}]` \u2014 everything needed to pick a renderer (image / audio player / document card) before downloading bytes.\n\n`{{message_id}}` is auto-set by *Get messages (latest page)* (prefers a message that has media)."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "const j = pm.response.json();",
                  "if (j.length) { pm.collectionVariables.set('media_id', j[0].id); console.log(j.map(m=>m.mimeType+' '+m.name+' ('+m.size+'B)').join(', ')); }",
                  "else console.log('This message has no attachments \u2014 pick another message_id.');"
                ]
              }
            }
          ]
        },
        {
          "name": "Download attachment bytes",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/tenants/{{tenant_id}}/integrations/{{integration_id}}/chats/{{chat_id}}/message/{{message_id}}/media/{{media_id}}?access_token={{session_token}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "tenants",
                "{{tenant_id}}",
                "integrations",
                "{{integration_id}}",
                "chats",
                "{{chat_id}}",
                "message",
                "{{message_id}}",
                "media",
                "{{media_id}}"
              ],
              "query": [
                {
                  "key": "access_token",
                  "value": "{{session_token}}"
                }
              ]
            },
            "description": "Streams the file (HTTP range supported \u2014 works for `<video>`/`<audio>` seeking). The `access_token` query param lets you use this URL directly as an `img/video/audio` `src` where you can't set an Authorization header."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));"
                ]
              }
            }
          ]
        },
        {
          "name": "Get agent avatar",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/tenants/{{tenant_id}}/integrations/{{integration_id}}/chats/{{chat_id}}/handler/photo",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "tenants",
                "{{tenant_id}}",
                "integrations",
                "{{integration_id}}",
                "chats",
                "{{chat_id}}",
                "handler",
                "photo"
              ]
            },
            "description": "Current handler's photo (AI profile or human agent). Empty response if none \u2014 fall back to `GET /default-handler/photo` or your own placeholder.",
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('2xx', () => pm.expect(pm.response.code).to.be.below(300));"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "5 \u00b7 WebSocket hub (read me)",
      "description": "**Sending messages and receiving replies happens on a SignalR WebSocket \u2014 Postman collections cannot contain WebSocket requests, so create it once by hand** (full walkthrough: `websocket-guide.md`).\n\n1. **New \u2192 WebSocket** and connect to:\n```\n{{ws_url}}/chat/hub?tenantId={{tenant_id}}&integrationId={{integration_id}}&access_token={{session_token}}\n```\n(run *Mint / resume session* first \u2014 the console prints this URL fully resolved).\n\n2. Send the **handshake** frame, then converse with the frames in `postman/ws-frames.txt` (each already ends with the invisible `0x1E` terminator SignalR requires \u2014 save them as Postman *Saved messages* for click-to-send demos):\n   - handshake \u2192 `{\"protocol\":\"json\",\"version\":1}`\n   - send text \u2192 invoke `CustomerMessage` `{chatId, tempId, content:{text}, correlationId}`\n   - send attachments \u2192 same, with `content.mediaIds` from *Promote staged files*\n   - typing on/off \u2192 invoke `Typing` `{chatId, userId: customer_id, show}`\n   - keepalive `{\"type\":6}` \u2014 click every ~20 s or the server drops the idle connection\n\n3. Watch the incoming frames: your own **echo** (`Message` with your `tempId`), the agent **typing indicator**, and the **AI/human reply** (`Message` with `isCustomer: false`).\n\n**Production code should NOT do any of this by hand** \u2014 use the official SignalR client (`@microsoft/signalr` etc.); see `reference-client/chat-client.js` for a complete working example.",
      "item": []
    },
    {
      "name": "6 \u00b7 Demo scenarios (read me)",
      "description": "Six scripted conversations for client demos \u2014 full step-by-step talk tracks in `demo-scenarios.md`:\n\n1. **First contact** \u2014 mint session \u2192 WS hello \u2192 AI answers \u2192 history \u2192 read receipt\n2. **Returning customer** \u2014 bearer resume, profile update, history persistence\n3. **Damaged product photo** \u2014 staged upload \u2192 promote \u2192 AI *sees* the image\n4. **Voice note** \u2014 audio upload, auto-transcription, AI answers the transcription\n5. **Human handover & reopen** \u2014 AI hands over, human replies, resolve, implicit reopen\n6. **PDF / document** \u2014 the AI reads an invoice or spec sheet\n\nAll of them reuse the requests in folders 1\u20134 plus the WebSocket request from folder 5.",
      "item": []
    }
  ]
}