{
  "info": {
    "name": "Assilya — API Agent",
    "description": "Endpoints for the API Agent — async polling model with suggestion-based human handoff. Optional webhook callback removes the need to poll.\n\n## Setup\n1. Create an API agent in Assilya (`AgentType::Api`) and copy its `settings.api_key`.\n2. Set the `apiKey` collection variable.\n3. `baseUrl` defaults to `https://app.assilya.fr`.\n4. (Optional) Enable the webhook in the agent settings, paste your HTTPS callback URL, and copy the auto-generated `webhook_secret` to verify signatures server-side.\n\n## Message flow\n1. `POST /api/message` → 202 with `id`, `thread`, `contact` (auto-generated `id`). Optionally pin your own message id via `X-Assilya-Message`; otherwise the server generates a UUID and returns it as `id`.\n2. Poll `GET /api/message/{messageId}` until `state` is `answered`, `pending` or `failed` — OR receive a signed POST on your webhook URL with the same body (see the *Webhook callback* folder).\n3. If `pending`: call `POST /api/message/{messageId}/answer` with one of: a free-form `content` (custom reply), a `suggestion` rank 1..4 (use one of the AI alternatives verbatim), or both (`content` becomes a human edit on top of the picked suggestion). The outbound message also accepts `X-Assilya-Message` to pin its id.\n4. Outbound attachments come back as signed URLs in the `attachments` array.\n\n## Response shape (per state)\n- `processing` (HTTP 202): `id`, `thread`, `state`, `contact`.\n- `pending` (HTTP 200): + `answer: null`, `confidence`, `suggestions: [{rank, content}]`, `contact`.\n- `answered` (HTTP 200): + `answer: string`, `confidence`, `suggestions: []`, `attachments`, `contact`, `sent_at`.\n- `failed` (HTTP 200): + `error`, `contact`.\n\n## Webhook callback (push)\nWhen enabled in the agent settings, Assilya POSTs the same body as `GET /api/message/{messageId}` to your URL as soon as the thread reaches a terminal state (`answered`, `pending` or `failed`). HTTPS only.\n\n- Headers: `X-Assilya-Message`, `X-Assilya-Event` (`message.answered|pending|failed`), `X-Assilya-Timestamp`, `X-Assilya-Signature: sha256=<hmac>`, `User-Agent: Assilya-Webhook/1.0`.\n- Signature: `hash_hmac('sha256', timestamp + '.' + rawBody, webhook_secret)`. Compare with `hash_equals` to defeat timing attacks.\n- Retries: first attempt fires immediately; on non-2xx (or timeout > 30 s), retried at +60 s, +5 min and +15 min — 4 attempts in total. After the 4th failure the delivery is abandoned and `metadata.webhook.status` flips to `failed` — fall back to polling.\n- Treat the webhook as best-effort: respond 2xx within 30 s and persist the payload before doing heavy work; idempotency is your responsibility (`id` is stable).\n\nSee the *Webhook callback* folder below for a documented sample of what your endpoint will receive.\n\n## Contact identifiers\nEvery resolved Contact gets a `ContactAgent` row with an agent-scoped id (UUID v7 if you don't supply `X-Assilya-User-Id`). The first response surfaces it in `contact.id` — store it and echo it back via `X-Assilya-User-Id` on subsequent calls to skip the email/phone lookup. Two read endpoints (`/api/contacts`, `/api/contacts/{id}`) let you query the directory.\n\n## Identifier convention\nInternal numeric ids are never exposed. The fields `id`, `thread.id` and `contact.id` carry agent-scoped opaque keys — either the values you pass via `X-Assilya-Message` / `X-Assilya-Thread` / `X-Assilya-User-Id`, or server-generated UUID v7 fallbacks. They double as the canonical identifier in URL paths (e.g. `/api/message/{messageId}`).\n\n## Variable capture\nThe first message request stores `messageId` and `contactId` automatically (whether server-generated or echoed from `X-Assilya-Message` / `X-Assilya-User-Id`). `threadId` and `messageId` are opaque keys you choose and pass via the matching headers (set the collection variables yourself).",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
    "_postman_id": "a1b2c3d4-0002-0000-0000-000000000001"
  },
  "auth": {
    "type": "bearer",
    "bearer": [
      {
        "key": "token",
        "value": "{{apiKey}}",
        "type": "string"
      }
    ]
  },
  "variable": [
    {
      "key": "baseUrl",
      "value": "https://app.assilya.fr",
      "type": "string"
    },
    {
      "key": "tenant",
      "value": "your-company-slug",
      "type": "string",
      "description": "URL slug of your Company in Assilya. Every API URL is prefixed by it: `/{{tenant}}/api/...`. Find it in the Filament panel URL bar after picking your tenant."
    },
    {
      "key": "apiKey",
      "value": "ak_replace_me_with_the_api_agent_api_key",
      "type": "string"
    },
    {
      "key": "threadId",
      "value": "demo-thread-001",
      "type": "string",
      "description": "Opaque thread key your app uses to pin a Thread (max 191 chars). Sent via the X-Assilya-Thread header and returned as `thread.id` in every response."
    },
    {
      "key": "messageId",
      "value": "",
      "type": "string",
      "description": "Optional opaque message key your app uses to pin an inbound (or outbound) Message (max 191 chars). Sent via the X-Assilya-Message header and echoed back as `id`. Leave empty to let the server generate a UUID v7."
    },
    {
      "key": "messageId",
      "value": "",
      "type": "string",
      "description": "Agent-scoped external id of the inbound Message — the value you supplied via X-Assilya-Message or a server-generated UUID v7 fallback. Auto-populated by POST /message."
    },
    {
      "key": "contactId",
      "value": "",
      "type": "string",
      "description": "Agent-scoped contact id (a UUID v7) — auto-populated by POST /message from `contact.id`; reuse via the X-Assilya-User-Id header to keep talking to the same Contact."
    },
    {
      "key": "tagId",
      "value": "",
      "type": "string"
    },
    {
      "key": "tagName",
      "value": "priorité",
      "type": "string"
    },
    {
      "key": "noteId",
      "value": "",
      "type": "string",
      "description": "UUID of a thread note. Auto-populated by POST /api/threads/:key/notes from the latest entry in `thread.notes`."
    },
    {
      "key": "webhookReceiverUrl",
      "value": "https://your-server.example.com/webhooks/assilya",
      "type": "string",
      "description": "HTTPS endpoint you registered in the agent's Webhook section. Replaced into the documented sample below."
    },
    {
      "key": "webhookSecret",
      "value": "whsec_replace_me_with_the_agent_webhook_secret",
      "type": "string",
      "description": "Copy from Assilya → Agent → Webhook → Secret. Used by the Pre-request script in the *Verify a sample webhook signature* request to compute the expected `X-Assilya-Signature` value."
    }
  ],
  "item": [
    {
      "name": "Messages",
      "item": [
        {
          "name": "POST /api/message (submit)",
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "if (pm.response.code === 202) {",
                  "  const body = pm.response.json();",
                  "  if (body.id) {",
                  "    pm.collectionVariables.set('messageId', body.id);",
                  "    console.log('messageId set to', body.id);",
                  "  }",
                  "  if (body.contact && body.contact.id) {",
                  "    pm.collectionVariables.set('contactId', body.contact.id);",
                  "    console.log('contactId set to', body.contact.id);",
                  "  }",
                  "  pm.test('state is processing', () => pm.expect(body.state).to.eql('processing'));",
                  "}"
                ],
                "type": "text/javascript"
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "X-Assilya-Thread",
                "value": "{{threadId}}",
                "description": "Optional but recommended. Opaque thread key chosen by your app (max 191 chars); pinning a value here lets you keep posting messages to the same thread across calls. Returned as `thread.id` on every response."
              },
              {
                "key": "X-Assilya-Message",
                "value": "{{messageId}}",
                "description": "Optional. Opaque inbound-message key chosen by your app (max 191 chars). Pinning a value here makes `id` predictable for your downstream traceability. If omitted, Assilya generates a UUID v7. Re-using a value already taken by another inbound on the same agent returns 409.",
                "disabled": true
              },
              {
                "key": "X-Assilya-Locale",
                "value": "fr",
                "description": "Optional. Falls back to the agent's settings.default_locale."
              },
              {
                "key": "X-Assilya-User-Id",
                "value": "",
                "description": "Optional external user id used by your app .Contact.id returned by a previous response to keep using the same Contact (and skip email/phone lookup).",
                "disabled": true
              },
              {
                "key": "X-Assilya-User-Email",
                "value": "",
                "description": "Optional. Used by ContactResolver to find/create the Contact when no User-Id is set.",
                "disabled": true
              },
              {
                "key": "X-Assilya-User-Name",
                "value": "",
                "description": "Optional. Backfilled onto the Contact if the field is empty.",
                "disabled": true
              }
            ],
            "body": {
              "mode": "formdata",
              "formdata": [
                {
                  "key": "content",
                  "value": "Bonjour, où en est ma commande 12345 ? Pouvez-vous me donner le délai de livraison estimé ?",
                  "type": "text"
                },
                {
                  "key": "title",
                  "value": "Question suivi commande",
                  "type": "text",
                  "description": "Optional short subject. Used as the initial Thread title when the thread is created or when its title is still empty; never overwrites an existing non-empty title."
                },
                {
                  "key": "files[]",
                  "value": "",
                  "type": "file",
                  "description": "Optional multipart files (up to 10, ≤10 MiB each).",
                  "disabled": true
                }
              ]
            },
            "url": {
              "raw": "{{baseUrl}}/{{tenant}}/api/message",
              "host": ["{{baseUrl}}"],
              "path": ["{{tenant}}", "api", "message"]
            },
            "description": "Creates the inbound Message, dispatches the answer Job, and returns 202 with `id`, `thread`, `state: processing` and `contact`. The test script auto-stores `messageId` and `contactId`.\n\n## Identifiers you control\n- `threadId`: opaque key sent via `X-Assilya-Thread`. Reusing the same value pins the Message to an existing Thread (idempotent upsert).\n- `messageId`: opaque key sent via `X-Assilya-Message`. Becomes the `id` returned in the response. **Strict** — re-sending the same value for this agent returns 409 (no idempotent upsert). Omit the header to let Assilya generate a UUID v7."
          },
          "response": [
            {
              "name": "202 — accepted (processing)",
              "originalRequest": {
                "method": "POST",
                "header": [{ "key": "X-Assilya-Thread", "value": "{{threadId}}" }],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/message", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "message"] }
              },
              "status": "Accepted",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"id\": \"01928f7c-aaaa-7bbb-cccc-dddddddddddd\",\n  \"thread\": {\n    \"id\": \"demo-thread-001\",\n    \"title\": \"Question commande\"\n  },\n  \"state\": \"processing\",\n  \"contact\": {\n    \"id\": \"01928f7c-cccc-7eee-aaaa-bbbbbbbbbbbb\",\n    \"name\": null,\n    \"email\": null,\n    \"phone\": null\n  }\n}"
            },
            {
              "name": "401 — missing api key",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/message", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "message"] }
              },
              "status": "Unauthorized",
              "code": 401,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"error\": \"Clé d'API manquante.\"\n}"
            },
            {
              "name": "403 — wrong agent type or inactive",
              "originalRequest": {
                "method": "POST",
                "header": [{ "key": "Authorization", "value": "Bearer ak_revoked" }],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/message", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "message"] }
              },
              "status": "Forbidden",
              "code": 403,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"error\": \"Cet agent n'accepte pas de requêtes.\"\n}"
            },
            {
              "name": "422 — content required",
              "originalRequest": {
                "method": "POST",
                "header": [{ "key": "X-Assilya-Thread", "value": "{{threadId}}" }],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/message", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "message"] }
              },
              "status": "Unprocessable Entity",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"message\": \"The content field is required.\",\n  \"errors\": {\n    \"content\": [\"The content field is required.\"]\n  }\n}"
            },
            {
              "name": "409 — message id already in use",
              "originalRequest": {
                "method": "POST",
                "header": [
                  { "key": "X-Assilya-Thread", "value": "{{threadId}}" },
                  { "key": "X-Assilya-Message", "value": "order-1234-msg-001" }
                ],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/message", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "message"] }
              },
              "status": "Conflict",
              "code": 409,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"error\": \"Cet identifiant de message est déjà utilisé.\"\n}"
            }
          ]
        },
        {
          "name": "GET /api/message/:messageId (poll)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/{{tenant}}/api/message/{{messageId}}",
              "host": ["{{baseUrl}}"],
              "path": ["{{tenant}}", "api", "message", "{{messageId}}"]
            },
            "description": "Returns the current state. `messageId` is the agent-scoped external id of the inbound Message (the value you supplied via `X-Assilya-Message` or the server-generated UUID v7) — same value as `id` in every response body.\n\nSymmetric `answer` / `suggestions` contract:\n\n- `processing` (HTTP 202): `id`, `thread`, `state`, `contact`.\n- `pending` (HTTP 200): + `answer: null`, `confidence: int`, `suggestions: [{rank, content}]` (3 alternatives the human can pick from), `contact`.\n- `answered` (HTTP 200): + `answer: string`, `confidence: int`, `suggestions: []`, `attachments` (signed URLs), `contact`, `sent_at`.\n- `failed` (HTTP 200): + `error`, `contact`.\n\nCall repeatedly every few seconds until `state` stabilises."
          },
          "response": [
            {
              "name": "202 — still processing",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/message/{{messageId}}", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "message", "{{messageId}}"] }
              },
              "status": "Accepted",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"id\": \"01928f7c-aaaa-7bbb-cccc-dddddddddddd\",\n  \"thread\": {\n    \"id\": \"demo-thread-001\",\n    \"title\": \"Question commande\"\n  },\n  \"state\": \"processing\",\n  \"contact\": {\n    \"id\": \"01928f7c-cccc-7eee-aaaa-bbbbbbbbbbbb\",\n    \"name\": null,\n    \"email\": null,\n    \"phone\": null\n  }\n}"
            },
            {
              "name": "200 — pending (2-4 suggestions for human pick)",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/message/{{messageId}}", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "message", "{{messageId}}"] }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"id\": \"01928f7c-aaaa-7bbb-cccc-dddddddddddd\",\n  \"thread\": {\n    \"id\": \"demo-thread-001\",\n    \"title\": \"Question commande\"\n  },\n  \"state\": \"pending\",\n  \"answer\": null,\n  \"confidence\": 42,\n  \"required_action\": null,\n  \"suggestions\": [\n    { \"rank\": 1, \"content\": \"Le délai dépend du transporteur, comptez 24 à 72 h.\" },\n    { \"rank\": 2, \"content\": \"Nous livrons en 48 h en moyenne.\" },\n    { \"rank\": 3, \"content\": \"Un conseiller peut vous donner le délai exact.\" }\n  ],\n  \"contact\": {\n    \"id\": \"01928f7c-cccc-7eee-aaaa-bbbbbbbbbbbb\",\n    \"name\": \"Léa Martin\",\n    \"email\": \"lea@example.com\",\n    \"phone\": \"+33612345678\"\n  }\n}"
            },
            {
              "name": "200 — pending (action required for your team)",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/message/{{messageId}}", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "message", "{{messageId}}"] }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"id\": \"01928f7c-aaaa-7bbb-cccc-dddddddddddd\",\n  \"thread\": {\n    \"id\": \"demo-thread-001\",\n    \"title\": \"Réexpédition de la commande 12345\"\n  },\n  \"state\": \"pending\",\n  \"answer\": null,\n  \"confidence\": 88,\n  \"required_action\": {\n    \"summary\": \"Réexpédier la commande 12345 à l'adresse confirmée par le client.\"\n  },\n  \"suggestions\": [\n    { \"rank\": 1, \"content\": \"Bonjour Léa, nous avons procédé à la réexpédition de votre commande 12345, sans frais supplémentaires.\" },\n    { \"rank\": 2, \"content\": \"Bonjour Léa, votre nouvelle expédition est enregistrée ; le nouveau numéro de suivi vous parviendra dès sa prise en charge.\" }\n  ],\n  \"contact\": {\n    \"id\": \"01928f7c-cccc-7eee-aaaa-bbbbbbbbbbbb\",\n    \"name\": \"Léa Martin\",\n    \"email\": \"lea@example.com\",\n    \"phone\": \"+33612345678\"\n  }\n}"
            },
            {
              "name": "200 — answered (with attachment)",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/message/{{messageId}}", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "message", "{{messageId}}"] }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"id\": \"01928f7c-aaaa-7bbb-cccc-dddddddddddd\",\n  \"thread\": {\n    \"id\": \"demo-thread-001\",\n    \"title\": \"Question commande\"\n  },\n  \"state\": \"answered\",\n  \"answer\": \"Votre commande a été expédiée hier et arrivera sous 48 h.\",\n  \"confidence\": 92,\n  \"suggestions\": [],\n  \"attachments\": [\n    {\n      \"id\": \"01928f7d-eeee-7fff-aaaa-bbbbbbbbbbbb\",\n      \"filename\": \"facture.pdf\",\n      \"mime\": \"application/pdf\",\n      \"size\": 18234,\n      \"url\": \"https://app.assilya.fr/api/files/01928f7d-eeee-7fff-aaaa-bbbbbbbbbbbb/01928f7d-eeee-7fff-aaaa-bbbbbbbbbbbb?expires=1714060800&signature=…\"\n    }\n  ],\n  \"contact\": {\n    \"id\": \"01928f7c-cccc-7eee-aaaa-bbbbbbbbbbbb\",\n    \"name\": \"Léa Martin\",\n    \"email\": \"lea@example.com\",\n    \"phone\": \"+33612345678\"\n  },\n  \"sent_at\": \"2026-04-25T14:32:11+00:00\"\n}"
            },
            {
              "name": "200 — failed (AI generation error)",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/message/{{messageId}}", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "message", "{{messageId}}"] }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"id\": \"01928f7c-aaaa-7bbb-cccc-dddddddddddd\",\n  \"thread\": {\n    \"id\": \"demo-thread-001\",\n    \"title\": \"Question commande\"\n  },\n  \"state\": \"failed\",\n  \"error\": \"Le fournisseur LLM a renvoyé une erreur après 3 tentatives.\",\n  \"contact\": {\n    \"id\": \"01928f7c-cccc-7eee-aaaa-bbbbbbbbbbbb\",\n    \"name\": null,\n    \"email\": null,\n    \"phone\": null\n  }\n}"
            },
            {
              "name": "404 — unknown messageId",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/message/00000000-0000-0000-0000-000000000000", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "message", "00000000-0000-0000-0000-000000000000"] }
              },
              "status": "Not Found",
              "code": 404,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": ""
            }
          ]
        },
        {
          "name": "POST /api/message/:messageId/answer (human reply or pick suggestion)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "X-Assilya-Message",
                "value": "",
                "description": "Optional. Opaque outbound-message key chosen by your app (max 191 chars). Pinning a value here makes the new outbound `id` predictable. If omitted, Assilya generates a UUID v7. Re-using a value already taken by another message on the same agent returns 409.",
                "disabled": true
              }
            ],
            "body": {
              "mode": "formdata",
              "formdata": [
                {
                  "key": "content",
                  "value": "Bonjour, votre commande a été expédiée hier et arrivera sous 48h. Bonne réception !",
                  "type": "text",
                  "description": "Free-form human reply. Required unless `suggestion` is provided. When set together with `suggestion`, this value wins (human edit on top of the picked alternative — source becomes suggestion_edited)."
                },
                {
                  "key": "suggestion",
                  "value": "",
                  "type": "text",
                  "description": "Optional. Rank 1..4 of one of the AI-generated alternatives returned in `suggestions[].rank` from GET /api/message/:messageId. Used verbatim when `content` is empty (source = suggestion_selected); used as the basis when `content` is also set (source = suggestion_edited).",
                  "disabled": true
                },
                {
                  "key": "title",
                  "value": "",
                  "type": "text",
                  "description": "Optional. Used as the outbound Message subject and backfilled onto the thread title when it is still empty. Existing non-empty thread titles are never overwritten.",
                  "disabled": true
                },
                {
                  "key": "files[]",
                  "value": "",
                  "type": "file",
                  "description": "Optional multipart files.",
                  "disabled": true
                }
              ]
            },
            "url": {
              "raw": "{{baseUrl}}/{{tenant}}/api/message/{{messageId}}/answer",
              "host": ["{{baseUrl}}"],
              "path": ["{{tenant}}", "api", "message", "{{messageId}}", "answer"]
            },
            "description": "Only valid when the Thread is in `pending` state. Publishes the outbound Message and flips the Thread to `answered`.\n\n## Path\n- `messageId`: agent-scoped external id of the inbound Message — same value as `id` in every response body.\n\n## Optional header\n- `X-Assilya-Message`: pin the *outbound* message id (max 191 chars). Otherwise auto UUID v7. Strict — duplicate id returns 409.\n\n## Body\n- `content` (string, max 4000): free-form human reply.\n- `suggestion` (int 1..4): rank of one of the AI alternatives surfaced in the polling response.\n- `title` (string, max 80, optional): outbound subject + thread-title backfill (only if currently empty).\n- `files[]` (multipart, optional): attachments.\n\n## Three modes\n1. `content` only → `metadata.source = human`.\n2. `suggestion` only → suggestion content is used verbatim, `metadata.source = suggestion_selected`, the picked MessageSuggestion `resulting_message_id` pointing at the new outbound.\n3. `content` + `suggestion` → `content` wins, `metadata.source = suggestion_edited`, the suggestion is still marked selected.\n\n## Errors\n- 422 if neither `content` nor `suggestion` is provided, or `suggestion` is outside 1..3.\n- 404 if `suggestion` references a rank that does not exist for this inbound.\n- 409 if the thread is not in `pending` state, or if `X-Assilya-Message` is already used by another message of this agent."
          },
          "response": [
            {
              "name": "200 — human custom reply",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "body": { "mode": "formdata", "formdata": [{ "key": "content", "value": "Bonjour, votre commande a été expédiée hier et arrivera sous 48 h.", "type": "text" }] },
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/message/{{messageId}}/answer", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "message", "{{messageId}}", "answer"] }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"id\": \"01928f7c-aaaa-7bbb-cccc-dddddddddddd\",\n  \"thread\": {\n    \"id\": \"demo-thread-001\",\n    \"title\": \"Question commande\"\n  },\n  \"state\": \"answered\",\n  \"answer\": \"Bonjour, votre commande a été expédiée hier et arrivera sous 48 h.\",\n  \"confidence\": null,\n  \"suggestions\": [],\n  \"attachments\": [],\n  \"contact\": {\n    \"id\": \"01928f7c-cccc-7eee-aaaa-bbbbbbbbbbbb\",\n    \"name\": \"Léa Martin\",\n    \"email\": \"lea@example.com\",\n    \"phone\": \"+33612345678\"\n  },\n  \"sent_at\": \"2026-04-25T14:35:02+00:00\"\n}"
            },
            {
              "name": "200 — suggestion picked verbatim",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "body": { "mode": "formdata", "formdata": [{ "key": "suggestion", "value": "2", "type": "text" }] },
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/message/{{messageId}}/answer", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "message", "{{messageId}}", "answer"] }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"id\": \"01928f7c-aaaa-7bbb-cccc-dddddddddddd\",\n  \"thread\": {\n    \"id\": \"demo-thread-001\",\n    \"title\": \"Question commande\"\n  },\n  \"state\": \"answered\",\n  \"answer\": \"Nous livrons en 48 h en moyenne.\",\n  \"confidence\": null,\n  \"suggestions\": [],\n  \"attachments\": [],\n  \"contact\": {\n    \"id\": \"01928f7c-cccc-7eee-aaaa-bbbbbbbbbbbb\",\n    \"name\": \"Léa Martin\",\n    \"email\": \"lea@example.com\",\n    \"phone\": \"+33612345678\"\n  },\n  \"sent_at\": \"2026-04-25T14:35:08+00:00\"\n}"
            },
            {
              "name": "200 — suggestion edited (content + suggestion)",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "body": {
                  "mode": "formdata",
                  "formdata": [
                    { "key": "suggestion", "value": "1", "type": "text" },
                    { "key": "content", "value": "Le délai dépend du transporteur — comptez 24 à 72 h. Vous recevrez un email de suivi.", "type": "text" }
                  ]
                },
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/message/{{messageId}}/answer", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "message", "{{messageId}}", "answer"] }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"id\": \"01928f7c-aaaa-7bbb-cccc-dddddddddddd\",\n  \"thread\": {\n    \"id\": \"demo-thread-001\",\n    \"title\": \"Question commande\"\n  },\n  \"state\": \"answered\",\n  \"answer\": \"Le délai dépend du transporteur — comptez 24 à 72 h. Vous recevrez un email de suivi.\",\n  \"confidence\": null,\n  \"suggestions\": [],\n  \"attachments\": [],\n  \"contact\": {\n    \"id\": \"01928f7c-cccc-7eee-aaaa-bbbbbbbbbbbb\",\n    \"name\": \"Léa Martin\",\n    \"email\": \"lea@example.com\",\n    \"phone\": \"+33612345678\"\n  },\n  \"sent_at\": \"2026-04-25T14:35:14+00:00\"\n}"
            },
            {
              "name": "404 — suggestion rank not found",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "body": { "mode": "formdata", "formdata": [{ "key": "suggestion", "value": "3", "type": "text" }] },
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/message/{{messageId}}/answer", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "message", "{{messageId}}", "answer"] }
              },
              "status": "Not Found",
              "code": 404,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"error\": \"Cette suggestion n'existe pas pour ce message.\"\n}"
            },
            {
              "name": "409 — thread not pending",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "body": { "mode": "formdata", "formdata": [{ "key": "content", "value": "x", "type": "text" }] },
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/message/{{messageId}}/answer", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "message", "{{messageId}}", "answer"] }
              },
              "status": "Conflict",
              "code": 409,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"error\": \"Cette conversation n'est pas en attente d'une réponse humaine.\"\n}"
            },
            {
              "name": "409 — outbound message id already in use",
              "originalRequest": {
                "method": "POST",
                "header": [{ "key": "X-Assilya-Message", "value": "reply-already-used" }],
                "body": { "mode": "formdata", "formdata": [{ "key": "content", "value": "Réponse humaine.", "type": "text" }] },
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/message/{{messageId}}/answer", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "message", "{{messageId}}", "answer"] }
              },
              "status": "Conflict",
              "code": 409,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"error\": \"Cet identifiant de message est déjà utilisé.\"\n}"
            },
            {
              "name": "422 — neither content nor suggestion",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "body": { "mode": "formdata", "formdata": [] },
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/message/{{messageId}}/answer", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "message", "{{messageId}}", "answer"] }
              },
              "status": "Unprocessable Entity",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"message\": \"The content field is required when suggestion is not present. (and 1 more error)\",\n  \"errors\": {\n    \"content\": [\"The content field is required when suggestion is not present.\"],\n    \"suggestion\": [\"The suggestion field is required when content is not present.\"]\n  }\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Threads",
      "item": [
        {
          "name": "GET /api/threads",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/{{tenant}}/api/threads",
              "host": ["{{baseUrl}}"],
              "path": ["{{tenant}}", "api", "threads"]
            },
            "description": "Lists the 50 most recent threads for the calling agent with their tags."
          },
          "response": [
            {
              "name": "200 — list of threads",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/threads", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "threads"] }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"threads\": [\n    {\n      \"id\": \"demo-thread-001\",\n      \"status\": \"answered\",\n      \"requires_action\": false,\n      \"tags\": [\n        { \"id\": 1, \"name\": \"priorité\", \"color\": \"red\", \"hex\": \"#ef4444\" }\n      ]\n    },\n    {\n      \"id\": \"order-12345\",\n      \"status\": \"pending\",\n      \"requires_action\": true,\n      \"tags\": []\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "GET /api/threads/:key",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/{{tenant}}/api/threads/{{threadId}}",
              "host": ["{{baseUrl}}"],
              "path": ["{{tenant}}", "api", "threads", "{{threadId}}"]
            },
            "description": "Fetch a single thread. `:key` accepts either the internal numeric id OR the external id (`X-Assilya-Thread` you used when creating the thread). Scoped to the calling agent — 404 for threads owned by another agent.\n\nReturns `tags` and `notes` (newest-first by `created_at`). Notes are read-only here — manage them via the `/notes` endpoints below."
          },
          "response": [
            {
              "name": "200 — thread with tags + notes",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/threads/{{threadId}}", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "threads", "{{threadId}}"] }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"thread\": {\n    \"id\": \"demo-thread-001\",\n    \"status\": \"answered\",\n    \"title\": \"Question commande\",\n    \"tags\": [\n      { \"id\": 1, \"name\": \"priorité\", \"color\": \"red\", \"hex\": \"#ef4444\" }\n    ],\n    \"notes\": [\n      { \"id\": \"01928f80-bbbb-7ccc-aaaa-eeeeeeeeeeee\", \"note\": \"Client VIP — répondre en priorité.\", \"created_at\": \"2026-04-25T14:30:00+00:00\" }\n    ]\n  }\n}"
            },
            {
              "name": "404 — unknown thread for this agent",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/threads/unknown", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "threads", "unknown"] }
              },
              "status": "Not Found",
              "code": 404,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": ""
            }
          ]
        },
        {
          "name": "GET /api/threads/:key/messages (list messages)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/{{tenant}}/api/threads/{{threadId}}/messages?page=1&per_page=100",
              "host": ["{{baseUrl}}"],
              "path": ["{{tenant}}", "api", "threads", "{{threadId}}", "messages"],
              "query": [
                {
                  "key": "page",
                  "value": "1",
                  "description": "Optional. Page number, ≥1."
                },
                {
                  "key": "per_page",
                  "value": "100",
                  "description": "Optional. Items per page, default 100, capped at 200."
                }
              ]
            },
            "description": "Lists every message of the thread (inbound + outbound), newest → oldest, paginated with the standard Laravel envelope. Default page size is 100, capped at 200 — page through every message of long threads.\n\nScoped to the calling agent — 404 if `:key` belongs to another agent. `:key` accepts the internal numeric id OR the external id (`X-Assilya-Thread`).\n\n## Per-message payload\n- `id` (UUID) — for **inbound** messages this is also the `id` you can poll via `GET /api/message/{messageId}`.\n- `direction` — `inbound` (from the contact) or `outbound` (from the agent).\n- `status` — `draft`, `queued`, `sent`, `failed`.\n- `subject`, `content`, `sent_at` (ISO 8601, nullable).\n- `confidence` — int 0..100, **outbound only** (null for inbound). Same scale as the polling endpoint.\n- `source` — origin of the message: `agent` (inbound from a contact), `ai` (auto-published by the agent), `suggestion_selected` (human picked one of the AI suggestions verbatim), `suggestion_edited` (human edited a suggestion), `human` (free-form human reply).\n- `sender` — `{type: 'contact', name, email, phone}` for inbound, `{type: 'agent', name}` for outbound. `null` if no sender is recorded.\n- `required_action` — on an inbound the AI escalated because an operator action is needed (reship, refund…): `{summary, processed_at}`, where `processed_at` is stamped once a human replied or closed the thread. `null` on every other message.\n- `attachments` — list of `{id, filename, mime, size, url}`. `url` is a signed URL with a 15-minute TTL — regenerated on every call.\n- `error_message` — populated only when `status = failed`.\n\nHuman-handoff suggestions are NOT included here (use `GET /api/message/{messageId}` to fetch them for a specific inbound)."
          },
          "response": [
            {
              "name": "200 — paginated list of messages",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/threads/{{threadId}}/messages?page=1&per_page=100", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "threads", "{{threadId}}", "messages"], "query": [{ "key": "page", "value": "1" }, { "key": "per_page", "value": "100" }] }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"01928f80-aaaa-7bbb-cccc-dddddddddddd\",\n      \"direction\": \"outbound\",\n      \"status\": \"sent\",\n      \"subject\": null,\n      \"content\": \"Votre commande a été expédiée hier et arrivera sous 48 h.\",\n      \"confidence\": 92,\n      \"source\": \"ai\",\n      \"sender\": { \"type\": \"agent\", \"name\": \"Assistant SAV\" },\n      \"required_action\": null,\n      \"attachments\": [\n        {\n          \"id\": \"01928f7d-eeee-7fff-aaaa-bbbbbbbbbbbb\",\n          \"filename\": \"facture.pdf\",\n          \"mime\": \"application/pdf\",\n          \"size\": 18234,\n          \"url\": \"https://app.assilya.fr/api/files/01928f80-aaaa-7bbb-cccc-dddddddddddd/01928f7d-eeee-7fff-aaaa-bbbbbbbbbbbb?expires=1714060800&signature=…\"\n        }\n      ],\n      \"error_message\": null,\n      \"sent_at\": \"2026-04-25T14:32:11+00:00\"\n    },\n    {\n      \"id\": \"01928f7c-aaaa-7bbb-cccc-dddddddddddd\",\n      \"direction\": \"inbound\",\n      \"status\": \"sent\",\n      \"subject\": \"Question suivi commande\",\n      \"content\": \"Bonjour, où en est ma commande 12345 ?\",\n      \"confidence\": null,\n      \"source\": \"agent\",\n      \"sender\": {\n        \"type\": \"contact\",\n        \"name\": \"Léa Martin\",\n        \"email\": \"lea@example.com\",\n        \"phone\": \"+33612345678\"\n      },\n      \"required_action\": null,\n      \"attachments\": [],\n      \"error_message\": null,\n      \"sent_at\": \"2026-04-25T14:31:54+00:00\"\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 100,\n    \"total\": 2,\n    \"last_page\": 1\n  },\n  \"links\": {\n    \"first\": \"https://app.assilya.fr/api/threads/demo-thread-001/messages?page=1\",\n    \"last\": \"https://app.assilya.fr/api/threads/demo-thread-001/messages?page=1\",\n    \"prev\": null,\n    \"next\": null\n  }\n}"
            },
            {
              "name": "404 — unknown thread for this agent",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/threads/unknown/messages", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "threads", "unknown", "messages"] }
              },
              "status": "Not Found",
              "code": 404,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": ""
            }
          ]
        },
        {
          "name": "POST /api/threads/:key/tags (attach tag)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"{{tagName}}\",\n  \"color\": \"#f59e0b\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/{{tenant}}/api/threads/{{threadId}}/tags",
              "host": ["{{baseUrl}}"],
              "path": ["{{tenant}}", "api", "threads", "{{threadId}}", "tags"]
            },
            "description": "Attach a tag to a thread. Accepts EITHER `tag_id` (existing tag) OR `name` + optional `color` (creates the tag if absent, no-op if it already exists). Returns the thread with its full tag list."
          },
          "response": [
            {
              "name": "200 — tag attached",
              "originalRequest": {
                "method": "POST",
                "header": [{ "key": "Content-Type", "value": "application/json" }],
                "body": { "mode": "raw", "raw": "{\n  \"name\": \"priorité\",\n  \"color\": \"red\"\n}" },
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/threads/{{threadId}}/tags", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "threads", "{{threadId}}", "tags"] }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"thread\": {\n    \"id\": \"demo-thread-001\",\n    \"status\": \"answered\",\n    \"tags\": [\n      { \"id\": 1, \"name\": \"priorité\", \"color\": \"red\", \"hex\": \"#ef4444\" },\n      { \"id\": 7, \"name\": \"VIP\", \"color\": \"amber\", \"hex\": \"#f59e0b\" }\n    ]\n  }\n}"
            }
          ]
        },
        {
          "name": "DELETE /api/threads/:key/tags/:tagId",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/{{tenant}}/api/threads/{{threadId}}/tags/{{tagId}}",
              "host": ["{{baseUrl}}"],
              "path": [
                "{{tenant}}",
                "api",
                "threads",
                "{{threadId}}",
                "tags",
                "{{tagId}}"
              ]
            },
            "description": "Detach a tag from a thread."
          },
          "response": [
            {
              "name": "200 — tag detached",
              "originalRequest": {
                "method": "DELETE",
                "header": [],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/threads/{{threadId}}/tags/{{tagId}}", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "threads", "{{threadId}}", "tags", "{{tagId}}"] }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"thread\": {\n    \"id\": \"demo-thread-001\",\n    \"status\": \"answered\",\n    \"tags\": [\n      { \"id\": 1, \"name\": \"priorité\", \"color\": \"red\", \"hex\": \"#ef4444\" }\n    ]\n  }\n}"
            }
          ]
        },
        {
          "name": "POST /api/threads/:key/notes (create + attach note)",
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "if (pm.response.code === 200) {",
                  "  const body = pm.response.json();",
                  "  const notes = body.thread && body.thread.notes ? body.thread.notes : [];",
                  "  if (notes.length > 0 && notes[0].id) {",
                  "    pm.collectionVariables.set('noteId', notes[0].id);",
                  "    console.log('noteId set to', notes[0].id);",
                  "  }",
                  "}"
                ],
                "type": "text/javascript"
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              { "key": "Accept", "value": "application/json" },
              { "key": "Content-Type", "value": "application/json" }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"note\": \"Client VIP — répondre en priorité.\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/{{tenant}}/api/threads/{{threadId}}/notes",
              "host": ["{{baseUrl}}"],
              "path": ["{{tenant}}", "api", "threads", "{{threadId}}", "notes"]
            },
            "description": "Create an internal note and attach it to the thread in one call. Notes created via the API are stored unauthored (`user_id` is always null) — they are read-only from the Filament UI (only the original Filament author can edit/delete a note in the panel).\n\n## Body\n- `note` (string, required, max 1000): the note content. Whitespace-trimmed server-side.\n\nReturns the thread with its full `notes` list (newest-first by `created_at`). The test script auto-stores the latest `note.id` as the `noteId` collection variable for the follow-up DELETE call."
          },
          "response": [
            {
              "name": "200 — note created and attached",
              "originalRequest": {
                "method": "POST",
                "header": [{ "key": "Content-Type", "value": "application/json" }],
                "body": { "mode": "raw", "raw": "{\n  \"note\": \"Client VIP — répondre en priorité.\"\n}" },
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/threads/{{threadId}}/notes", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "threads", "{{threadId}}", "notes"] }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"thread\": {\n    \"id\": \"demo-thread-001\",\n    \"status\": \"answered\",\n    \"tags\": [],\n    \"notes\": [\n      { \"id\": \"01928f80-bbbb-7ccc-aaaa-eeeeeeeeeeee\", \"note\": \"Client VIP — répondre en priorité.\", \"created_at\": \"2026-04-25T14:30:00+00:00\" }\n    ]\n  }\n}"
            },
            {
              "name": "422 — note required or too long",
              "originalRequest": {
                "method": "POST",
                "header": [{ "key": "Content-Type", "value": "application/json" }],
                "body": { "mode": "raw", "raw": "{\n  \"note\": \"\"\n}" },
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/threads/{{threadId}}/notes", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "threads", "{{threadId}}", "notes"] }
              },
              "status": "Unprocessable Entity",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"message\": \"The note field is required.\",\n  \"errors\": {\n    \"note\": [\"The note field is required.\"]\n  }\n}"
            },
            {
              "name": "404 — unknown thread for this agent",
              "originalRequest": {
                "method": "POST",
                "header": [{ "key": "Content-Type", "value": "application/json" }],
                "body": { "mode": "raw", "raw": "{\n  \"note\": \"x\"\n}" },
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/threads/unknown/notes", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "threads", "unknown", "notes"] }
              },
              "status": "Not Found",
              "code": 404,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": ""
            }
          ]
        },
        {
          "name": "DELETE /api/threads/:key/notes/:noteId (detach + delete note)",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/{{tenant}}/api/threads/{{threadId}}/notes/{{noteId}}",
              "host": ["{{baseUrl}}"],
              "path": [
                "{{tenant}}",
                "api",
                "threads",
                "{{threadId}}",
                "notes",
                "{{noteId}}"
              ]
            },
            "description": "Detach and permanently delete a note from a thread (single operation — there is no soft-delete). `:noteId` must be the UUID returned by `POST /api/threads/:key/notes` (or seen in `thread.notes[].id`). Scoped to the thread — 404 if the note belongs to another thread or is unknown."
          },
          "response": [
            {
              "name": "200 — note detached and deleted",
              "originalRequest": {
                "method": "DELETE",
                "header": [],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/threads/{{threadId}}/notes/{{noteId}}", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "threads", "{{threadId}}", "notes", "{{noteId}}"] }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"thread\": {\n    \"id\": \"demo-thread-001\",\n    \"status\": \"answered\",\n    \"tags\": [],\n    \"notes\": []\n  }\n}"
            },
            {
              "name": "404 — note not found on this thread",
              "originalRequest": {
                "method": "DELETE",
                "header": [],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/threads/{{threadId}}/notes/00000000-0000-0000-0000-000000000000", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "threads", "{{threadId}}", "notes", "00000000-0000-0000-0000-000000000000"] }
              },
              "status": "Not Found",
              "code": 404,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": ""
            }
          ]
        }
      ]
    },
    {
      "name": "Tags",
      "item": [
        {
          "name": "GET /api/tags",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/{{tenant}}/api/tags",
              "host": ["{{baseUrl}}"],
              "path": ["{{tenant}}", "api", "tags"]
            },
            "description": "Lists every tag (globally unique by name)."
          },
          "response": [
            {
              "name": "200 — tag list",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/tags", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "tags"] }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"tags\": [\n    { \"id\": 1, \"name\": \"priorité\", \"color\": \"red\" },\n    { \"id\": 7, \"name\": \"VIP\", \"color\": \"amber\" },\n    { \"id\": 12, \"name\": \"sav\", \"color\": \"sky\" }\n  ]\n}"
            }
          ]
        },
        {
          "name": "POST /api/tags (idempotent create)",
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "if (pm.response.code === 200) {",
                  "  const body = pm.response.json();",
                  "  if (body.tag && body.tag.id) {",
                  "    pm.collectionVariables.set('tagId', String(body.tag.id));",
                  "    console.log('tagId set to', body.tag.id);",
                  "  }",
                  "}"
                ],
                "type": "text/javascript"
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"{{tagName}}\",\n  \"color\": \"#f59e0b\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/{{tenant}}/api/tags",
              "host": ["{{baseUrl}}"],
              "path": ["{{tenant}}", "api", "tags"]
            },
            "description": "Creates a tag, or returns the existing one when the name collides. The test script stores the returned `tag.id` as `tagId` for the follow-up thread-attach / detach calls."
          },
          "response": [
            {
              "name": "200 — tag created (or already existing)",
              "originalRequest": {
                "method": "POST",
                "header": [{ "key": "Content-Type", "value": "application/json" }],
                "body": { "mode": "raw", "raw": "{\n  \"name\": \"priorité\",\n  \"color\": \"red\"\n}" },
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/tags", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "tags"] }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"tag\": {\n    \"id\": 1,\n    \"name\": \"priorité\",\n    \"color\": \"red\"\n  }\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Webhook callback",
      "description": "Reference for the **outbound** push Assilya sends to your server when the webhook is enabled in the agent settings. These are not requests you send to Assilya — they document what your endpoint will receive, plus a self-test that verifies your `webhookSecret` collection variable matches Assilya's signature.",
      "item": [
        {
          "name": "POST {webhookReceiverUrl} (sample payload Assilya sends to you)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "User-Agent",
                "value": "Assilya-Webhook/1.0"
              },
              {
                "key": "X-Assilya-Message",
                "value": "{{messageId}}",
                "description": "Agent-scoped external id of the inbound Message — the value supplied via X-Assilya-Message at create time, or a server-generated UUID. Same value as `id` in the body."
              },
              {
                "key": "X-Assilya-Event",
                "value": "message.answered",
                "description": "One of `message.answered`, `message.pending`, `message.failed`."
              },
              {
                "key": "X-Assilya-Timestamp",
                "value": "1714060800",
                "description": "Unix epoch (seconds). Combined with the raw body to compute the signature."
              },
              {
                "key": "X-Assilya-Signature",
                "value": "sha256=<hmac>",
                "description": "HMAC-SHA256 of `<X-Assilya-Timestamp>.<rawBody>` using the agent's `webhook_secret`. Verify with `hash_equals`."
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"id\": \"01928f7c-aaaa-7bbb-cccc-dddddddddddd\",\n  \"thread\": {\n    \"id\": \"demo-thread-001\",\n    \"title\": \"Question suivi commande\"\n  },\n  \"state\": \"answered\",\n  \"answer\": \"Votre commande a été expédiée hier et arrivera sous 48h.\",\n  \"confidence\": 92,\n  \"suggestions\": [],\n  \"attachments\": [\n    {\n      \"id\": \"01928f7d-eeee-7fff-aaaa-bbbbbbbbbbbb\",\n      \"filename\": \"facture.pdf\",\n      \"mime\": \"application/pdf\",\n      \"size\": 18234,\n      \"url\": \"https://app.assilya.fr/api/files/<messageId>/<token>?expires=...&signature=...\"\n    }\n  ],\n  \"contact\": {\n    \"name\": \"Léa Martin\",\n    \"email\": \"lea@example.com\",\n    \"phone\": \"+33612345678\",\n    \"id\": \"01928f7c-cccc-7eee-aaaa-bbbbbbbbbbbb\"\n  },\n  \"sent_at\": \"2026-04-25T14:32:11+00:00\"\n}"
            },
            "url": {
              "raw": "{{webhookReceiverUrl}}",
              "host": ["{{webhookReceiverUrl}}"]
            },
            "description": "Sample of the POST request your endpoint will receive when an answer is auto-published.\n\n## Body shape per state\nIdentical to `GET /api/message/{messageId}`:\n- `state: answered` → `answer`, `confidence`, `attachments`, `sent_at`.\n- `state: pending` → `answer: null`, `confidence`, `required_action` (`{summary}` when your team must perform an operation — reship, refund… — before answering; `null` otherwise), `suggestions: [{rank, content}]`.\n- `state: failed` → `error`.\n\n## Signature verification (PHP)\n```php\n$timestamp = $request->header('X-Assilya-Timestamp');\n$signature = $request->header('X-Assilya-Signature'); // \"sha256=...\"\n$rawBody   = $request->getContent();\n$expected  = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $webhookSecret);\nabort_unless(hash_equals($expected, $signature), 401);\n```\n\n## Signature verification (Node.js)\n```js\nconst crypto = require('crypto');\nconst expected = 'sha256=' + crypto\n  .createHmac('sha256', webhookSecret)\n  .update(req.headers['x-assilya-timestamp'] + '.' + rawBody)\n  .digest('hex');\nif (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.headers['x-assilya-signature']))) {\n  return res.sendStatus(401);\n}\n```\n\n## Retry policy\nFirst delivery attempt fires immediately when the answer Job commits. On failure (non-2xx, network error, timeout > 30 s), retried at +60 s, +5 min and +15 min — 4 attempts in total over ~21 min. After exhaustion the inbound Message is stamped with `metadata.webhook.status = 'failed'` and you should fall back to polling `GET /api/message/{messageId}`.\n\n## Implementation tips\n- Persist the payload synchronously and return 2xx fast; defer heavy work to your own queue.\n- Treat `id` as the idempotency key — Assilya may retry after a transient 5xx and you should not double-process.\n- Reject requests where `|now - X-Assilya-Timestamp| > 5 min` to defeat replay attacks."
          },
          "response": []
        },
        {
          "name": "Verify a sample webhook signature (self-test)",
          "event": [
            {
              "listen": "prerequest",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "// Computes the X-Assilya-Signature header that Assilya would send",
                  "// for the canned body below, given your `webhookSecret` collection",
                  "// variable. Useful to validate that your stored secret matches the",
                  "// one configured in Assilya before going live.",
                  "const CryptoJS = require('crypto-js');",
                  "const timestamp = Math.floor(Date.now() / 1000).toString();",
                  "const body = pm.request.body && pm.request.body.raw ? pm.request.body.raw : '';",
                  "const secret = pm.collectionVariables.get('webhookSecret');",
                  "const hmac = CryptoJS.HmacSHA256(timestamp + '.' + body, secret).toString(CryptoJS.enc.Hex);",
                  "pm.collectionVariables.set('computedTimestamp', timestamp);",
                  "pm.collectionVariables.set('computedSignature', 'sha256=' + hmac);",
                  "pm.request.headers.upsert({ key: 'X-Assilya-Timestamp', value: timestamp });",
                  "pm.request.headers.upsert({ key: 'X-Assilya-Signature', value: 'sha256=' + hmac });",
                  "console.log('Computed signature:', 'sha256=' + hmac);"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "User-Agent",
                "value": "Assilya-Webhook/1.0"
              },
              {
                "key": "X-Assilya-Message",
                "value": "{{messageId}}"
              },
              {
                "key": "X-Assilya-Event",
                "value": "message.answered"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\"id\":\"01928f7c-aaaa-7bbb-cccc-dddddddddddd\",\"thread\":{\"id\":\"demo-thread-001\",\"title\":\"Question suivi commande\"},\"state\":\"answered\",\"answer\":\"Votre commande arrivera sous 48h.\",\"confidence\":92,\"suggestions\":[],\"attachments\":[],\"contact\":{\"name\":\"Léa Martin\",\"email\":\"lea@example.com\",\"phone\":\"+33612345678\",\"id\":\"01928f7c-cccc-7eee-aaaa-bbbbbbbbbbbb\"},\"sent_at\":\"2026-04-25T14:32:11+00:00\"}"
            },
            "url": {
              "raw": "{{webhookReceiverUrl}}",
              "host": ["{{webhookReceiverUrl}}"]
            },
            "description": "Sends the canned payload to **your** receiver with a freshly-computed `X-Assilya-Signature`. If your endpoint accepts (2xx), your server-side verification logic is wired correctly — production traffic from Assilya will be authenticated identically.\n\n**Pre-request script** computes:\n- `X-Assilya-Timestamp` = current unix epoch\n- `X-Assilya-Signature` = `sha256=` + HMAC-SHA256(`<timestamp>.<rawBody>`, `webhookSecret`)\n\nRequires the `webhookSecret` collection variable to be set to the same value visible in Assilya → Agent → Webhook → Secret. **Set `webhookReceiverUrl` to your local dev URL or a webhook.site link before sending — Postman will POST to it from your machine, not via Assilya.**"
          },
          "response": []
        }
      ]
    },
    {
      "name": "Contacts",
      "item": [
        {
          "name": "GET /api/contacts (search)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/{{tenant}}/api/contacts?name=&email=&phone=&page=1&per_page=50",
              "host": ["{{baseUrl}}"],
              "path": ["{{tenant}}", "api", "contacts"],
              "query": [
                {
                  "key": "name",
                  "value": "",
                  "description": "Optional. Partial, case-insensitive match on the contact name."
                },
                {
                  "key": "email",
                  "value": "",
                  "description": "Optional. Partial, case-insensitive match on the contact email."
                },
                {
                  "key": "phone",
                  "value": "",
                  "description": "Optional. Partial match on the phone (raw substring)."
                },
                {
                  "key": "page",
                  "value": "1",
                  "description": "Optional. Page number, ≥1."
                },
                {
                  "key": "per_page",
                  "value": "50",
                  "description": "Optional. Items per page, silently capped at 50."
                }
              ]
            },
            "description": "Searches contacts linked to the calling agent. Filters combine with AND. Returns the standard Laravel pagination envelope:\n\n```json\n{\n  \"data\": [{\"id\", \"name\", \"email\", \"phone\"}],\n  \"meta\": {\"current_page\", \"per_page\", \"total\", \"last_page\"},\n  \"links\": {\"first\", \"last\", \"prev\", \"next\"}\n}\n```\n\nOnly contacts that have a `ContactAgent` row for the calling agent are returned — contacts seen via other agents stay invisible."
          },
          "response": [
            {
              "name": "200 — paginated search result",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/contacts?name=mart&page=1&per_page=50", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "contacts"], "query": [{ "key": "name", "value": "mart" }, { "key": "page", "value": "1" }, { "key": "per_page", "value": "50" }] }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"01928f7c-cccc-7eee-aaaa-bbbbbbbbbbbb\",\n      \"name\": \"Léa Martin\",\n      \"email\": \"lea@example.com\",\n      \"phone\": \"+33612345678\"\n    },\n    {\n      \"id\": \"01928f7d-aaaa-7bbb-cccc-eeeeeeeeeeee\",\n      \"name\": \"Paul Martinez\",\n      \"email\": \"paul.martinez@example.com\",\n      \"phone\": null\n    }\n  ],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 50,\n    \"total\": 2,\n    \"last_page\": 1\n  },\n  \"links\": {\n    \"first\": \"https://app.assilya.fr/api/contacts?page=1\",\n    \"last\": \"https://app.assilya.fr/api/contacts?page=1\",\n    \"prev\": null,\n    \"next\": null\n  }\n}"
            },
            {
              "name": "200 — empty result (no match)",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/contacts?email=nobody@example.com", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "contacts"], "query": [{ "key": "email", "value": "nobody@example.com" }] }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"data\": [],\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 50,\n    \"total\": 0,\n    \"last_page\": 1\n  },\n  \"links\": {\n    \"first\": \"https://app.assilya.fr/api/contacts?page=1\",\n    \"last\": \"https://app.assilya.fr/api/contacts?page=1\",\n    \"prev\": null,\n    \"next\": null\n  }\n}"
            }
          ]
        },
        {
          "name": "GET /api/contacts/:contactId",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/{{tenant}}/api/contacts/{{contactId}}",
              "host": ["{{baseUrl}}"],
              "path": ["{{tenant}}", "api", "contacts", "{{contactId}}"]
            },
            "description": "Fetches the contact identified by the agent-scoped id returned in `contact.id` from `/api/message`. 404 if no `ContactAgent` exists for `(current agent, id)` — guarantees no leak across agents.\n\n```json\n{\n  \"contact\": {\n    \"id\": \"01928f7c-...\",\n    \"name\": \"Léa Martin\",\n    \"email\": \"lea@example.com\",\n    \"phone\": \"+33612345678\"\n  }\n}\n```"
          },
          "response": [
            {
              "name": "200 — contact found",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/contacts/{{contactId}}", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "contacts", "{{contactId}}"] }
              },
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": "{\n  \"contact\": {\n    \"id\": \"01928f7c-cccc-7eee-aaaa-bbbbbbbbbbbb\",\n    \"name\": \"Léa Martin\",\n    \"email\": \"lea@example.com\",\n    \"phone\": \"+33612345678\"\n  }\n}"
            },
            {
              "name": "404 — unknown id for this agent",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": { "raw": "{{baseUrl}}/{{tenant}}/api/contacts/unknown-id", "host": ["{{baseUrl}}"], "path": ["{{tenant}}", "api", "contacts", "unknown-id"] }
              },
              "status": "Not Found",
              "code": 404,
              "_postman_previewlanguage": "json",
              "header": [{ "key": "Content-Type", "value": "application/json" }],
              "cookie": [],
              "body": ""
            }
          ]
        }
      ]
    }
  ]
}
