{"info":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","description":"<html><head></head><body><h1 id=\"aiployee-whatsapp-agent-platform-public-api-v1\">Aiployee WhatsApp Agent Platform — Public API (v1)</h1>\n<p>Programmatic access to the Aiployee platform. Mirrors the OpenAPI spec at <code>GET /api/v1/openapi.json</code> (rendered by Scalar at <code>/docs</code>). This collection is the sibling artefact of <code>app/api/v1/**</code> and is kept in lock-step with it.</p>\n<h2 id=\"setup-do-this-first\">Setup (do this first)</h2>\n<ol>\n<li><strong>Select an environment</strong> — <em>Aiployee — UAT</em> or <em>Aiployee — Production</em> (top-right). It supplies <code>baseUrl</code>, your <code>aip_api_key</code>, and the test variables below. Requests are <code>https://app.aiployee.co.za/api/v1/...</code>, so <code>baseUrl</code> <strong>ends in <code>/api</code></strong> (e.g. <code>https://app.aiployee.co.za/api</code>).</li>\n<li><strong><code>aip_api_key</code></strong> — issue one on <code>/settings/api-keys</code> (format <code>aip_live_&lt;32 hex&gt;</code>, shown once). Set it as a <strong>secret</strong> environment variable. Collection auth is Bearer <code>aip_live_REPLACE_ME</code>; every request inherits it (except the <em>Bad token</em> and <em>Spec</em> requests, which override on purpose).</li>\n<li><strong><code>test_phone_e164</code></strong> — an E.164 number you control (e.g. <code>+27821234567</code>). Sends will actually message this person.</li>\n<li><strong><code>from_phone_number_id</code></strong> — only needed when the tenant has more than one active WhatsApp number; pass the Meta <code>phone_number_id</code>, the <code>+E.164</code>, or the platform <code>whatsapp_numbers.id</code>.</li>\n<li><strong><code>wa_number_id</code> / <code>template_id</code></strong> — a <code>whatsapp_numbers.id</code> and an approved <code>template_id</code> (UUIDs) used by <em>Create broadcast</em>.</li>\n</ol>\n<blockquote>\n<p>⚠️ <strong>API keys are database-scoped.</strong> A key issued on the self-hosted app authenticates only there; a key issued on the Cloud/mirror deployment authenticates only there. If you get <code>401</code> with a valid-looking key, you're pointing <code>baseUrl</code> at the wrong deployment for that key.</p>\n</blockquote>\n<h2 id=\"whats-covered\">What's covered</h2>\n<p>Every live v1 operation (65 across 12 resource groups): <strong>Messages</strong> (text · template · media · interactive · location) · <strong>Contacts</strong> (+ bulk, attributes, tags, block/unblock) · <strong>Conversations</strong> · <strong>Tags</strong> · <strong>Attributes</strong> · <strong>Knowledge bases</strong> (+ documents, search) · <strong>Broadcasts</strong> (+ schedule, cancel, recipients) · <strong>Analytics</strong> (usage · cost · messages · stage-funnel) · <strong>Webhooks</strong> (+ deliveries, rotate-secret, replay) · <strong>Tools</strong> · <strong>Team</strong> · <strong>Workflows</strong> (+ publish, run, runs). Plus an <strong>Errors &amp; replays</strong> folder (idempotency, unsupported channel, missing scope, bad token) and a <strong>Spec</strong> folder.</p>\n<h2 id=\"conventions\">Conventions</h2>\n<ul>\n<li><strong>Auth:</strong> <code>Authorization: Bearer aip_live_REPLACE_ME</code> (collection-level; inherited).</li>\n<li><strong>Request id:</strong> every response carries <code>X-Request-Id</code>, echoed in <code>error.request_id</code>. Cross-reference it on <code>/settings/api-keys/logs</code> (payloads capped at 4 kB).</li>\n<li><strong>Error envelope:</strong> all 4xx/5xx return <code>{ \"error\": { \"code\", \"message\", \"request_id\", \"details?\" } }</code>. <code>code</code> is stable + machine-readable. A collection-level test asserts this shape on every 4xx.</li>\n<li><strong>Idempotency:</strong> writes accept an <code>Idempotency-Key</code> header — same key + same body within 24h replays the original response; same key + <strong>different</strong> body → <code>422 idempotency_mismatch</code>. Requests that support it send <code>9c8ed026-d7f7-4ce4-a90b-d2986f2e4fe8</code> by default.</li>\n<li><strong>Rate limits:</strong> <code>X-RateLimit-Limit / -Remaining / -Reset</code>; <code>429</code> carries <code>Retry-After</code>.</li>\n<li><strong>Scopes:</strong> each request's description lists the scope(s) it needs. A key only does what its scopes allow (default-deny). Note some reads are gated by the matching <code>:write</code> scope (e.g. <code>GET /tags</code> needs <code>tags:write</code>).</li>\n<li><strong>Channel-neutral:</strong> bodies carry a <code>channel</code> discriminator; only <code>\"whatsapp\"</code> is valid today. New channels add enum values, never new URL prefixes (there will never be <code>/v1/whatsapp/messages</code>).</li>\n</ul>\n<h2 id=\"template-variables--two-shapes-dont-conflate\">Template variables — two shapes, don't conflate</h2>\n<ul>\n<li><strong><code>POST /v1/messages</code> (template)</strong> takes Meta's <strong><code>components</code> array</strong> (<code>[{ type, parameters:[…] }]</code>).</li>\n<li><strong><code>POST /v1/broadcasts</code></strong> and the internal Copilot path take a <strong><code>template_variables</code> map</strong> keyed by placeholder index → <code>{ text | attribute_key }</code>.</li>\n</ul>\n<p>Use the components array for a one-off template send; the map for a campaign. Both are documented on their respective requests.</p>\n</body></html>","schema":"https://schema.getpostman.com/json/collection/v2.0.0/collection.json","toc":[{"content":"Aiployee WhatsApp Agent Platform — Public API (v1)","slug":"aiployee-whatsapp-agent-platform-public-api-v1"}],"owner":"36216276","collectionId":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","publishedId":"2sBY4MtLMg","public":true,"customColor":{"top-bar":"FFFFFF","right-sidebar":"303030","highlight":"FF6C37"},"publishDate":"2026-07-14T06:30:34.000Z"},"item":[{"name":"Messages","item":[{"name":"Send WhatsApp text message","event":[{"script":{"exec":["// Store a fresh idempotency key so the 'Idempotent replay' + 'mismatch' requests can reuse it.","pm.collectionVariables.set('last_idempotency_key', 'pm-' + (pm.variables.replaceIn('6ab7cf98-4162-454c-a51c-2441bf06b616')));"],"type":"text/javascript","id":"ffe142d0-170c-46da-b5e3-4a243bb25229"},"listen":"prerequest"},{"script":{"exec":["// Collection-level test also asserts X-Request-Id + the error envelope on 4xx.","pm.test('documented status code', function () { pm.expect([200,400,401,403,429,502]).to.include(pm.response.code); });","if (pm.response.code === 200) {","    var json = pm.response.json();","    pm.test('send contract', function () {","        pm.expect(json).to.have.property('message_id');","        pm.expect(json).to.have.property('wa_message_id');","        pm.expect(json).to.have.property('conversation_id');","        pm.expect(json.channel).to.eql('whatsapp');","        pm.expect(json.kind).to.eql('text');","    });","    pm.collectionVariables.set('last_conversation_id', json.conversation_id);","    pm.collectionVariables.set('last_message_id', json.message_id);","}"],"type":"text/javascript","id":"8fc9da42-e436-4302-8ea3-d6ffc8fe684e"},"listen":"test"}],"id":"01312ed4-bd93-48ca-a820-eb3556c4dfb8","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"","description":"<p>Reused by the Errors &amp; replays folder to demonstrate idempotent replay + mismatch.</p>\n"}],"body":{"mode":"raw","raw":"{\n  \"channel\": \"whatsapp\",\n  \"to\": \"+27821234567\",\n  \"from\": \"\",\n  \"kind\": \"text\",\n  \"message\": \"Hi 👋 thanks for reaching out — how can we help?\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/messages","description":"<p>Plain-text send (<code>kind:\"text\"</code>, ≤4096 chars). Only works inside the 24-hour customer-service window; outside it use a template.</p>\n<p><strong>Required scope(s):</strong> <code>messages:send</code>\n<strong>Idempotent.</strong></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","messages"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"01312ed4-bd93-48ca-a820-eb3556c4dfb8"},{"name":"Send WhatsApp template message","event":[{"script":{"exec":["// Collection-level test also asserts X-Request-Id + the error envelope on 4xx.","pm.test('documented status code', function () { pm.expect([200,400,401,403,429,502]).to.include(pm.response.code); });","if (pm.response.code === 200) {","    var json = pm.response.json();","    pm.test('send contract', function () {","        pm.expect(json).to.have.property('message_id');","        pm.expect(json).to.have.property('wa_message_id');","        pm.expect(json).to.have.property('conversation_id');","        pm.expect(json.channel).to.eql('whatsapp');","        pm.expect(json.kind).to.eql('template');","    });","    pm.collectionVariables.set('last_conversation_id', json.conversation_id);","    pm.collectionVariables.set('last_message_id', json.message_id);","}"],"type":"text/javascript","id":"72a38b5c-8367-47aa-bff7-2bba5ef3ac2e"},"listen":"test"}],"id":"015dd6ba-a0cc-42d2-ab0e-c90b86238f2d","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"5d6c7111-d569-4512-96dd-546aecdee8c1","description":"<p>Replay-safe key. Same key + same body within 24h → the original response; same key + different body → 422 idempotency_mismatch.</p>\n"}],"body":{"mode":"raw","raw":"{\n  \"channel\": \"whatsapp\",\n  \"to\": \"+27821234567\",\n  \"from\": \"\",\n  \"kind\": \"template\",\n  \"template_name\": \"order_update\",\n  \"language\": \"en_US\",\n  \"components\": [\n    { \"type\": \"header\", \"parameters\": [{ \"type\": \"image\", \"image\": { \"link\": \"https://example.com/banner.jpg\" } }] },\n    { \"type\": \"body\", \"parameters\": [{ \"type\": \"text\", \"text\": \"Ayanda\" }, { \"type\": \"text\", \"text\": \"#A1234\" }] },\n    { \"type\": \"button\", \"sub_type\": \"url\", \"index\": 0, \"parameters\": [{ \"type\": \"text\", \"text\": \"A1234\" }] }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/messages","description":"<p>Send an approved <strong>template</strong> (<code>kind:\"template\"</code>) — the only way to open a conversation <strong>outside the 24-hour window</strong>. <code>components</code> is Meta's array shape; each parameter is keyed on <code>type</code>:</p>\n<div class=\"click-to-expand-wrapper is-table-wrapper\"><table>\n<thead>\n<tr>\n<th>Where</th>\n<th>Shape</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Body/header text</td>\n<td><code>{ \"type\":\"text\", \"text\":\"…\" }</code></td>\n</tr>\n<tr>\n<td>Header image/video/document</td>\n<td><code>{ \"type\":\"image\", \"image\":{ \"link\":\"https://…\" } }</code> (or <code>id</code>; document adds <code>filename</code>)</td>\n</tr>\n<tr>\n<td>Currency</td>\n<td><code>{ \"type\":\"currency\", \"currency\":{ \"fallback_value\":\"R123.45\",\"code\":\"ZAR\",\"amount_1000\":123450 } }</code></td>\n</tr>\n<tr>\n<td>Date/time</td>\n<td><code>{ \"type\":\"date_time\", \"date_time\":{ \"fallback_value\":\"1 Jul 2026\" } }</code></td>\n</tr>\n<tr>\n<td>Quick-reply button</td>\n<td><code>{ \"type\":\"payload\", \"payload\":\"OPT_IN_YES\" }</code> (sub_type <code>quick_reply</code>)</td>\n</tr>\n<tr>\n<td>URL button</td>\n<td><code>{ \"type\":\"text\", \"text\":\"A1234\" }</code> — dynamic suffix (sub_type <code>url</code>)</td>\n</tr>\n<tr>\n<td>Copy-code button</td>\n<td><code>{ \"type\":\"coupon_code\", \"coupon_code\":\"SAVE20\" }</code> (sub_type <code>copy_code</code>)</td>\n</tr>\n</tbody>\n</table>\n</div><p>Button components also need <code>index</code>. <strong>Required scope(s):</strong> <code>messages:send</code>. <strong>Idempotent.</strong></p>\n<blockquote>\n<p>Not the broadcast shape — <code>POST /v1/broadcasts</code> uses the <code>template_variables</code> map instead.</p>\n</blockquote>\n<h3 id=\"what-gets-stored-on-the-conversation\">What gets stored on the conversation</h3>\n<p>The message is persisted with its <strong>rendered body</strong> — the template's approved body text with each <code>{{n}}</code> placeholder filled from the <code>body</code> component's <code>parameters</code>, matched <strong>in order</strong>. So <code>body_text</code> of <code>\"Hi {{1}}, your order {{2}} has shipped.\"</code> plus <code>parameters: [{\"type\":\"text\",\"text\":\"Ayanda\"}, {\"type\":\"text\",\"text\":\"#A1234\"}]</code> is stored and returned as <code>Hi Ayanda, your order #A1234 has shipped.</code> by <code>GET /v1/conversations/{id}/messages</code>.</p>\n<ul>\n<li><code>currency</code> and <code>date_time</code> parameters contribute their <code>fallback_value</code>.</li>\n<li>Header, button, and media parameters do not appear in the body.</li>\n<li>If the template name + language has no matching approved template on your workspace, there is no body to render and <code>content</code> falls back to the marker <code>[template] &lt;name&gt;</code>.</li>\n</ul>\n<blockquote>\n<p><strong>Changed:</strong> this endpoint used to store <code>[template] &lt;name&gt;</code> for every template send and discard the body, so template messages read as a bare placeholder in the inbox and over the API. <strong>Forward-only</strong> — the parameters of past sends were never stored, so historical messages keep the marker.</p>\n</blockquote>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","messages"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"015dd6ba-a0cc-42d2-ab0e-c90b86238f2d"},{"name":"Send WhatsApp media message","event":[{"script":{"exec":["// Collection-level test also asserts X-Request-Id + the error envelope on 4xx.","pm.test('documented status code', function () { pm.expect([200,400,401,403,429,502]).to.include(pm.response.code); });","if (pm.response.code === 200) {","    var json = pm.response.json();","    pm.test('send contract', function () {","        pm.expect(json).to.have.property('message_id');","        pm.expect(json).to.have.property('wa_message_id');","        pm.expect(json.kind).to.eql('media');","    });","}"],"type":"text/javascript","id":"7d91083e-9349-4574-97a9-ab716e256e84"},"listen":"test"}],"id":"7de2b950-478c-4660-a424-2cc550dc180b","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"f9773d99-7ba1-4459-bd9a-f17f30822819","description":"<p>Replay-safe key. Same key + same body within 24h → the original response; same key + different body → 422 idempotency_mismatch.</p>\n"}],"body":{"mode":"raw","raw":"{\n  \"channel\": \"whatsapp\",\n  \"to\": \"+27821234567\",\n  \"kind\": \"media\",\n  \"media_kind\": \"image\",\n  \"link\": \"https://example.com/product.jpg\",\n  \"caption\": \"Here's the item you asked about.\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/messages","description":"<p>Send media by public HTTPS URL (<code>kind:\"media\"</code>, <code>media_kind</code> ∈ image|audio|video|document). Caption on image/video/document only; filename on document only.</p>\n<p><strong>Required scope(s):</strong> <code>messages:send</code>. <strong>Idempotent.</strong></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","messages"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"7de2b950-478c-4660-a424-2cc550dc180b"},{"name":"Send interactive reply buttons","event":[{"script":{"exec":["pm.test('documented status code', function () { pm.expect([200,400,401,403,429,502]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json().kind).to.eql('interactive');"],"type":"text/javascript","id":"4910cda0-5393-4220-a011-9373cafd9aa7"},"listen":"test"}],"id":"4b53771d-4f9b-4e35-ba4b-ffc6c342c162","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"bc74a901-49c5-48ba-96f1-2a79b139972f"}],"body":{"mode":"raw","raw":"{\n  \"channel\": \"whatsapp\",\n  \"to\": \"+27821234567\",\n  \"kind\": \"interactive\",\n  \"interactive_kind\": \"button\",\n  \"body\": \"Would you like to proceed?\",\n  \"buttons\": [\n    { \"id\": \"yes\", \"title\": \"Yes please\" },\n    { \"id\": \"no\", \"title\": \"Not now\" }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/messages","description":"<p>Up to 3 tappable reply buttons (<code>kind:\"interactive\"</code>, <code>interactive_kind:\"button\"</code>). In-window only.</p>\n<p><strong>Required scope(s):</strong> <code>messages:send</code>. <strong>Idempotent.</strong></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","messages"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"4b53771d-4f9b-4e35-ba4b-ffc6c342c162"},{"name":"Send interactive list","event":[{"script":{"exec":["pm.test('documented status code', function () { pm.expect([200,400,401,403,429,502]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json().kind).to.eql('interactive');"],"type":"text/javascript","id":"d9ff4e0a-272c-4fc1-9115-dc6162804657"},"listen":"test"}],"id":"e9271cf4-546c-45ec-995f-a7441e31bc06","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"437bbd39-06b4-47f8-ba22-5376d7df909e"}],"body":{"mode":"raw","raw":"{\n  \"channel\": \"whatsapp\",\n  \"to\": \"+27821234567\",\n  \"kind\": \"interactive\",\n  \"interactive_kind\": \"list\",\n  \"body\": \"Pick a service\",\n  \"button_text\": \"View options\",\n  \"sections\": [\n    { \"title\": \"Services\", \"rows\": [{ \"id\": \"a\", \"title\": \"Support\" }, { \"id\": \"b\", \"title\": \"Sales\" }] }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/messages","description":"<p>A single-select list menu (<code>kind:\"interactive\"</code>, <code>interactive_kind:\"list\"</code>). In-window only.</p>\n<p><strong>Required scope(s):</strong> <code>messages:send</code>. <strong>Idempotent.</strong></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","messages"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"e9271cf4-546c-45ec-995f-a7441e31bc06"},{"name":"Send location","event":[{"script":{"exec":["pm.test('documented status code', function () { pm.expect([200,400,401,403,429,502]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json().kind).to.eql('location');"],"type":"text/javascript","id":"4b1872d6-7da0-4486-a76a-9bbdd8275e3a"},"listen":"test"}],"id":"37be55bc-0595-45ea-a161-1d5eb0dffc5c","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"63b77607-1698-4ddf-9879-ea49fe009d20"}],"body":{"mode":"raw","raw":"{\n  \"channel\": \"whatsapp\",\n  \"to\": \"+27821234567\",\n  \"kind\": \"location\",\n  \"latitude\": -26.2041,\n  \"longitude\": 28.0473,\n  \"name\": \"Aiployee HQ\",\n  \"address\": \"Johannesburg, South Africa\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/messages","description":"<p>Send a location pin (<code>kind:\"location\"</code>). In-window only.</p>\n<p><strong>Required scope(s):</strong> <code>messages:send</code>. <strong>Idempotent.</strong></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","messages"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"37be55bc-0595-45ea-a161-1d5eb0dffc5c"},{"name":"Send template — invalid components (400 invalid_request)","event":[{"listen":"test","script":{"exec":["pm.test('rejected with 400', function () { pm.response.to.have.status(400); });","pm.test('error.code is invalid_request', function () { pm.expect(pm.response.json().error.code).to.eql('invalid_request'); });"],"type":"text/javascript","id":"828d5d75-d607-40c0-9195-04f079645260"}}],"id":"8a4b303a-e945-441b-b6fa-0e8df8a1f182","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"channel\": \"whatsapp\",\n  \"to\": \"+27821234567\",\n  \"kind\": \"template\",\n  \"template_name\": \"order_update\",\n  \"language\": \"en_US\",\n  \"components\": [\n    { \"type\": \"body\", \"parameters\": [{ \"type\": \"text\", \"text\": \"\" }] }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/messages","description":"<p>Negative path — an empty-string body text parameter violates the strict template-parameter schema (<code>text</code> ≥ 1 char), so it's rejected <strong>400 <code>invalid_request</code></strong> before any Meta call. Proves the components schema validates parameter payloads rather than silently stripping them.</p>\n<p><strong>Required scope(s):</strong> <code>messages:send</code>.</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","messages"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"8a4b303a-e945-441b-b6fa-0e8df8a1f182"}],"id":"85ef722c-09af-4e5d-bf34-324c34890e9c","description":"<p>Send a message from your WhatsApp business number into a contact's conversation. One channel-neutral endpoint (<code>POST /v1/messages</code>); the <code>kind</code> discriminator selects the payload shape (text · template · media · interactive · location). Reactivates a dormant conversation or opens a new one.</p>\n","_postman_id":"85ef722c-09af-4e5d-bf34-324c34890e9c","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}}},{"name":"Contacts","item":[{"name":"List contacts","event":[{"listen":"test","script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('data');","// CT-1 — every row in the page carries deep_link, not just the first: a mapping","// bug that only fires on some rows is what a first-element check misses.","// The host is asserted as the CANONICAL one, not baseUrl's — see 'Fetch a","// contact' for why.","if (pm.response.code === 200) {","    pm.test('every contact carries an absolute deep_link on the canonical host', function () {","        var rows = pm.response.json().data;","        rows.forEach(function (c) {","            pm.expect(c, 'contact ' + c.id).to.have.property('deep_link');","            var link = new URL(c.deep_link);","            pm.expect(link.hostname, 'contact ' + c.id).to.equal('app.aiployee.co.za');","            pm.expect(link.pathname, 'contact ' + c.id).to.equal('/go/contact/' + c.id);","        });","    });","}","// CT-2 — same every-row discipline for the owner. The list endpoint has its own","// select, so it is the one most likely to silently omit a newly added column","// while the single-fetch keeps it.","if (pm.response.code === 200) {","    pm.test('every contact carries assigned_operator_id (null = no owner)', function () {","        pm.response.json().data.forEach(function (c) {","            pm.expect(c, 'contact ' + c.id).to.have.property('assigned_operator_id');","            pm.expect(","                c.assigned_operator_id === null || typeof c.assigned_operator_id === 'string',","                'contact ' + c.id + ' owner must be a uuid string or null'","            ).to.be.true;","        });","    });","}","","// API-9 — the default must stay CHEAP. reach costs two extra queries a page, and","// the whole reason it is opt-in is that this endpoint does not pay for them","// unless asked. A regression here is invisible except as latency.","if (pm.response.code === 200) {","    pm.test('the list omits reach unless ?include=reach is passed', function () {","        pm.response.json().data.forEach(function (c) {","            pm.expect(c.reach, 'contact ' + c.id).to.be.undefined;","        });","    });","}"],"type":"text/javascript"}}],"id":"fac5f5eb-be50-4f6d-bc86-9c369bff6e8a","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/contacts","description":"<p>Returns contacts newest-first (cursor pagination via <code>cursor</code> + <code>limit</code>, default 25 / max 200). Filter by <code>q</code>, <code>blocked</code>, <code>opt_in_status</code>.</p>\n<p><strong><code>deep_link</code> (CT-1)</strong> — every contact in the page carries an absolute URL that opens that contact's conversation in Aiployee (<code>https://app.aiployee.co.za/go/contact/{id}</code>), so a bulk import can store the click-through against each of your own records in one pass — no second call per contact. Keyed on the contact, not on a conversation, so it never needs refreshing. See <em>Fetch a contact</em> for the full semantics.</p>\n<p><strong><code>assigned_operator_id</code> (CT-2)</strong> — every contact also carries its <strong>owner</strong> (the workspace user new conversations with that contact are automatically assigned to), or <code>null</code>. Present on every row, so a sync can reconcile a whole book of accounts in one pass.</p>\n<p><strong><code>reach</code> (API-9) is NOT returned here by default.</strong> Add <code>?include=reach</code> — see <em>List contacts with reach</em> — and every row gains the per-channel reach array. It is opt-in because it costs two extra queries per page (the tenant's active channels, plus the page's conversations in one batched query), and most list callers do not want them. It is always present on <code>GET /v1/contacts/{id}</code>.</p>\n<p>⚠ There is <strong>no <code>assigned_operator_id</code> query filter</strong> on this endpoint yet. Filtering and sorting by owner are part of the attribute-filtering work (CT-4); until then, page through and filter client-side, or read the owner column in the dashboard's Contacts table.</p>\n<p><strong>Required scope(s):</strong> <code>contacts:read</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","contacts"],"host":["https://app.aiployee.co.za/api"],"query":[{"disabled":true,"description":{"content":"<p>Full-text search over name / phone / email.</p>\n","type":"text/plain"},"key":"q","value":""},{"disabled":true,"description":{"content":"<p>Filter by blocked state.</p>\n","type":"text/plain"},"key":"blocked","value":"false"},{"disabled":true,"description":{"content":"<p>Filter by opt-in status.</p>\n","type":"text/plain"},"key":"opt_in_status","value":"opted_in"}],"variable":[]}},"response":[],"_postman_id":"fac5f5eb-be50-4f6d-bc86-9c369bff6e8a"},{"name":"Create a contact","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403,409]).to.include(pm.response.code); });","if (pm.response.code === 200) { var j = pm.response.json(); pm.expect(j).to.have.property('identities'); pm.collectionVariables.set('last_contact_id', j.id); }","// CT-1 — a brand-new contact has no conversation yet, and the link must still","// be issued: the whole point is that /go resolves-or-creates on arrival, so the","// URL is valid before any thread exists.","if (pm.response.code === 200) {","    pm.test('a new contact already has a deep_link', function () {","        var j = pm.response.json();","        pm.expect(j).to.have.property('deep_link');","        pm.expect(new URL(j.deep_link).pathname).to.equal('/go/contact/' + j.id);","    });","}","","// API-9 — the owner set at CREATION. Asserted against what was actually asked","// for, not merely that the field exists: a create that silently dropped the","// owner would return a perfectly valid contact with null, and the next thing to","// notice would be a permanently unowned conversation.","if (pm.response.code === 200) {","    var want = pm.environment.get('assignable_user_id');","    if (want) {","        pm.test('the owner asked for is the owner returned', function () {","            pm.expect(pm.response.json().assigned_operator_id).to.eql(want);","        });","    }","    // reach belongs to the read endpoints; a create must not start returning it.","    pm.test('a create does not return reach', function () {","        pm.expect(pm.response.json().reach).to.be.undefined;","    });","}"],"type":"text/javascript"},"listen":"test"}],"id":"019deb53-365c-4617-8aab-2bebf424134b","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"cd37fdc5-1e62-4d8a-94e1-4bcaa9472696","description":"<p>Replay-safe key; same key+body within 24h replays, different body → 422.</p>\n"}],"body":{"mode":"raw","raw":"{\n  \"channel\": \"whatsapp\",\n  \"identities\": [\n    { \"channel\": \"whatsapp\", \"address\": \"+27821234567\" }\n  ],\n  \"display_name\": \"Ayanda M.\",\n  \"opt_in_status\": \"opted_in\",\n  \"assigned_operator_id\": \"{{assignable_user_id}}\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/contacts","description":"<p>Creates a contact. <code>identities</code> carries one WhatsApp identity today. Duplicate identity → 409.</p>\n<p><strong><code>deep_link</code> (CT-1)</strong> — the created contact comes back with the absolute URL that opens its conversation in Aiployee (<code>https://app.aiployee.co.za/go/contact/{id}</code>). Capture it here at creation time and store it on your own record; no follow-up call is needed. See <em>Fetch a contact</em> for the full semantics.</p>\n<p><strong><code>assigned_operator_id</code> (API-9)</strong> — optional (<code>uuid | null</code>), sets the contact's <strong>owner</strong> in this same call. Must be a member whose <code>assignable</code> is <code>true</code> on <code>GET /v1/team</code>. Omit it, or send <code>null</code>, for no owner. Run <em>List members + invitations</em> first and its test script fills <code>{{assignable_user_id}}</code> in for you.</p>\n<p>⚠ <strong>Set the owner here rather than with a follow-up PATCH.</strong> Conversation assignment is decided when the conversation is created and is <strong>never applied retroactively</strong> — deliberately, so a thread somebody is already handling is not moved out from under them. If the contact's first message arrives between your <code>POST</code> and your <code>PATCH</code>, that conversation is created unowned and <strong>stays</strong> unowned for its whole life. Creating a contact and immediately triggering outreach makes that race reachable, not theoretical.</p>\n<p>An id that is not a member of this workspace, or that belongs to an analyst, is rejected with <code>400 invalid_request</code> — the same validation <code>PATCH /v1/contacts/{id}</code> applies, and the two negative-path requests in this folder cover both arms.</p>\n<p><code>reach</code> is <strong>not</strong> returned on a create: a brand-new contact has no conversation yet, and the channels it can be reached on are a read concern. Fetch the contact if you need it.</p>\n<p><strong>Required scope(s):</strong> <code>contacts:write</code> · <strong>Idempotent</strong></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","contacts"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[{"id":"b27f7c7d-7637-80f7-07e7-bc8f962e158f","name":"200 — created with an owner","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"15d9ff4d-6c0c-456d-93f0-450db25ee5a0","description":"Replay-safe key; same key+body within 24h replays, different body → 422."}],"body":{"mode":"raw","raw":"{\n  \"channel\": \"whatsapp\",\n  \"identities\": [\n    { \"channel\": \"whatsapp\", \"address\": \"+27821234567\" }\n  ],\n  \"display_name\": \"Ayanda M.\",\n  \"opt_in_status\": \"opted_in\",\n  \"assigned_operator_id\": \"{{assignable_user_id}}\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/contacts","description":"Creates a contact. `identities` carries one WhatsApp identity today. Duplicate identity → 409.\n\n**`deep_link` (CT-1)** — the created contact comes back with the absolute URL that opens its conversation in Aiployee (`https://app.aiployee.co.za/go/contact/{id}`). Capture it here at creation time and store it on your own record; no follow-up call is needed. See *Fetch a contact* for the full semantics.\n\n**`assigned_operator_id` (API-9)** — optional (`uuid | null`), sets the contact's **owner** in this same call. Must be a member whose `assignable` is `true` on `GET /v1/team`. Omit it, or send `null`, for no owner. Run *List members + invitations* first and its test script fills `{{assignable_user_id}}` in for you.\n\n⚠ **Set the owner here rather than with a follow-up PATCH.** Conversation assignment is decided when the conversation is created and is **never applied retroactively** — deliberately, so a thread somebody is already handling is not moved out from under them. If the contact's first message arrives between your `POST` and your `PATCH`, that conversation is created unowned and **stays** unowned for its whole life. Creating a contact and immediately triggering outreach makes that race reachable, not theoretical.\n\nAn id that is not a member of this workspace, or that belongs to an analyst, is rejected with `400 invalid_request` — the same validation `PATCH /v1/contacts/{id}` applies, and the two negative-path requests in this folder cover both arms.\n\n`reach` is **not** returned on a create: a brand-new contact has no conversation yet, and the channels it can be reached on are a read concern. Fetch the contact if you need it.\n\n**Required scope(s):** `contacts:write` · **Idempotent**"},"status":"OK","code":200,"_postman_previewlanguage":"json","header":[{"value":"application/json","key":"Content-Type"}],"cookie":[],"responseTime":null,"body":"{\n  \"id\": \"0117cb53-d4ef-4940-acba-270dc89e1185\",\n  \"display_name\": \"Ayanda M.\",\n  \"identities\": [\n    { \"channel\": \"whatsapp\", \"address\": \"+27821234567\" }\n  ],\n  \"opt_in_status\": \"opted_in\",\n  \"is_blocked\": false,\n  \"last_active_at\": null,\n  \"opt_in_recorded_at\": \"2026-08-07T09:33:41.000Z\",\n  \"opt_out_recorded_at\": null,\n  \"created_at\": \"2026-08-07T09:33:41.000Z\",\n  \"deep_link\": \"https://app.aiployee.co.za/go/contact/0117cb53-d4ef-4940-acba-270dc89e1185\",\n  \"assigned_operator_id\": \"44bd199f-eaab-4cb4-acc2-2d29611857ec\"\n}"}],"_postman_id":"019deb53-365c-4617-8aab-2bebf424134b"},{"name":"Fetch a contact","event":[{"listen":"test","script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('identities');","// CT-1 — deep_link must be absolute and on the CANONICAL host, asserted against","// app.aiployee.co.za and deliberately NOT against baseUrl's host. The link is","// built from NEXT_PUBLIC_APP_URL, so it stays canonical even when read through a","// preview deployment — which is the correct behaviour, since an external system","// stores this string and a preview URL would rot. Comparing to baseUrl would","// fail on exactly the deployment you are most likely to be testing from.","if (pm.response.code === 200) {","    pm.test('deep_link is an absolute /go/contact URL on the canonical host', function () {","        var j = pm.response.json();","        pm.expect(j).to.have.property('deep_link');","        var link = new URL(j.deep_link);","        pm.expect(link.protocol).to.equal('https:');","        pm.expect(link.hostname).to.equal('app.aiployee.co.za');","        pm.expect(link.pathname).to.equal('/go/contact/' + j.id);","    });","}","// CT-2 — the owner field is always PRESENT; null is a legitimate value meaning","// 'no owner'. Asserting presence, not truthiness: a MISSING key would mean the","// column was dropped from this endpoint's select while the others kept it, which","// is the silent per-endpoint drift this catches.","if (pm.response.code === 200) {","    pm.test('assigned_operator_id is present (null = no owner)', function () {","        var j = pm.response.json();","        pm.expect(j).to.have.property('assigned_operator_id');","        pm.expect(j.assigned_operator_id === null || typeof j.assigned_operator_id === 'string').to.be.true;","    });","    pm.test('owner is a uuid when set', function () {","        var v = pm.response.json().assigned_operator_id;","        if (v !== null) pm.expect(v).to.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);","    });","}","","// API-9 — reach. Always present on the single fetch, and an EMPTY ARRAY is a","// real answer ('cannot be reached right now'), not a missing field.","if (pm.response.code === 200) {","    var c = pm.response.json();","","    pm.test('reach is always present on a single fetch', function () {","        pm.expect(c).to.have.property('reach');","        pm.expect(c.reach).to.be.an('array');","    });","","    // THE rule of this field, asserted rather than trusted. A conversation id in","    // the link would go stale between this response and the click.","    pm.test('every reach url is the contact deep link, never a conversation url', function () {","        c.reach.forEach(function (r) {","            pm.expect(r.url, r.channel).to.eql(c.deep_link + '?channel=' + r.channel);","            pm.expect(r.url, r.channel).to.not.include('/conversations/');","            if (r.conversation_id) {","                pm.expect(r.url, r.channel).to.not.include(r.conversation_id);","            }","        });","    });","","    pm.test('kind is open or start, and only open carries a conversation_id', function () {","        c.reach.forEach(function (r) {","            pm.expect(r.kind, r.channel).to.be.oneOf(['open', 'start']);","            if (r.kind === 'start') pm.expect(r, r.channel).to.not.have.property('conversation_id');","        });","    });","","    // Live threads are listed before startable channels — landing on an existing","    // conversation is never the wrong choice, so it must come first.","    pm.test('open entries come before start entries', function () {","        var kinds = c.reach.map(function (r) { return r.kind; });","        pm.expect(kinds.indexOf('start') === -1 || kinds.lastIndexOf('open') < kinds.indexOf('start')).to.be.true;","    });","","    // deep_link is untouched by API-9 — it must not have grown a channel parameter.","    pm.test('deep_link is still the bare contact link', function () {","        pm.expect(c.deep_link).to.not.include('?');","    });","}"],"type":"text/javascript"}}],"id":"c83329dc-a935-427d-8928-867b3d2fe3d1","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/contacts/:id","description":"<p>Returns one contact with its custom attributes, aggregated conversation tags, and <code>reach</code>.</p>\n<p><strong><code>deep_link</code> (CT-1)</strong> — an absolute URL that opens this contact's conversation in Aiployee, resolving the existing thread or creating one on arrival:</p>\n<p><code>https://app.aiployee.co.za/go/contact/{id}</code></p>\n<p>Store it against your own record. It is keyed on the <strong>contact</strong>, not on a conversation, so it keeps working after the underlying thread is resolved and a new one starts — you never have to refresh it. Opening it requires a signed-in Aiployee session with the <strong>operator</strong> role or higher; an unauthenticated visitor is sent to sign-in and returned to the same link afterwards. Append <code>?channel=</code> (<code>whatsapp</code> default, or <code>web</code> · <code>messenger</code> · <code>instagram</code> · <code>telegram</code>) to target another channel.</p>\n<p><strong><code>assigned_operator_id</code> (CT-2)</strong> — the workspace user who <strong>owns</strong> this contact, or <code>null</code>. New conversations with this contact are assigned to them automatically, on every channel. This is the durable relationship; a conversation's own assignee (who holds one thread right now) lives on the conversation resource. Writable via <code>PATCH /v1/contacts/{id}</code>, and settable at creation via <code>POST /v1/contacts</code>.</p>\n<p>The field is always present, and <code>null</code> genuinely means \"no owner\". A stored owner who has since left the workspace still reads back here, but no longer takes effect — assignment falls through to the workspace's assignment strategy rather than routing to somebody who is gone. If you need to know whether an owner is currently <em>effective</em>, cross-check the id against <code>GET /v1/team</code>.</p>\n<p><strong><code>reach</code> (API-9)</strong> — every channel this contact can be reached on <strong>right now</strong>, one entry per real option, live threads first. It is the same set the Aiployee contacts table draws its own row buttons from, so the API and the dashboard cannot offer different answers.</p>\n<ul>\n<li><code>kind: \"open\"</code> — a thread already exists and the URL lands on it. <code>conversation_id</code> is included <strong>for labelling only</strong> (e.g. \"continue thread 8f2c…\").</li>\n<li><code>kind: \"start\"</code> — no thread yet, and this channel can begin one.</li>\n<li><code>reach: []</code> — there is currently <strong>no way</strong> to reach this contact: no phone number, no live thread, and no channel that may cold-start one. Web-chat, Instagram and Messenger arrivals have no phone number, and Messenger and Instagram are reply-only. Say so rather than offering an action that will fail.</li>\n</ul>\n<p>⚠ <strong><code>url</code> is always <code>/go/contact/{id}?channel=…</code> and never a conversation URL.</strong> A conversation id goes stale between this response and the click — a thread closes, a newer one arrives, a contact is merged — while <code>/go</code> re-resolves the right thread on arrival. That makes the URL safe to store against your own record; a conversation URL would not be.</p>\n<p>Always returned here. On <code>GET /v1/contacts</code> it is <strong>opt-in</strong> via <code>?include=reach</code>, because it costs two extra queries a page.</p>\n<p><strong>Required scope(s):</strong> <code>contacts:read</code> — neither <code>deep_link</code>, <code>assigned_operator_id</code> nor <code>reach</code> needs an additional scope.</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","contacts",":id"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"bf791739-8e9a-4363-8a32-7fe9feba09f6","type":"any","key":"id"}]}},"response":[{"id":"191535e6-76ac-5b2b-da8a-fa8caccff81f","name":"200 — contact with deep_link","originalRequest":{"method":"GET","header":[],"url":{"raw":"https://app.aiployee.co.za/api/v1/contacts/:id","host":["https://app.aiployee.co.za/api"],"path":["v1","contacts",":id"],"variable":[{"id":"bf791739-8e9a-4363-8a32-7fe9feba09f6","key":"id"}]},"description":"Returns one contact with its custom attributes, aggregated conversation tags, and `reach`.\n\n**`deep_link` (CT-1)** — an absolute URL that opens this contact's conversation in Aiployee, resolving the existing thread or creating one on arrival:\n\n`https://app.aiployee.co.za/go/contact/{id}`\n\nStore it against your own record. It is keyed on the **contact**, not on a conversation, so it keeps working after the underlying thread is resolved and a new one starts — you never have to refresh it. Opening it requires a signed-in Aiployee session with the **operator** role or higher; an unauthenticated visitor is sent to sign-in and returned to the same link afterwards. Append `?channel=` (`whatsapp` default, or `web` · `messenger` · `instagram` · `telegram`) to target another channel.\n\n**`assigned_operator_id` (CT-2)** — the workspace user who **owns** this contact, or `null`. New conversations with this contact are assigned to them automatically, on every channel. This is the durable relationship; a conversation's own assignee (who holds one thread right now) lives on the conversation resource. Writable via `PATCH /v1/contacts/{id}`, and settable at creation via `POST /v1/contacts`.\n\nThe field is always present, and `null` genuinely means \"no owner\". A stored owner who has since left the workspace still reads back here, but no longer takes effect — assignment falls through to the workspace's assignment strategy rather than routing to somebody who is gone. If you need to know whether an owner is currently *effective*, cross-check the id against `GET /v1/team`.\n\n**`reach` (API-9)** — every channel this contact can be reached on **right now**, one entry per real option, live threads first. It is the same set the Aiployee contacts table draws its own row buttons from, so the API and the dashboard cannot offer different answers.\n\n- `kind: \"open\"` — a thread already exists and the URL lands on it. `conversation_id` is included **for labelling only** (e.g. \"continue thread 8f2c…\").\n- `kind: \"start\"` — no thread yet, and this channel can begin one.\n- `reach: []` — there is currently **no way** to reach this contact: no phone number, no live thread, and no channel that may cold-start one. Web-chat, Instagram and Messenger arrivals have no phone number, and Messenger and Instagram are reply-only. Say so rather than offering an action that will fail.\n\n⚠ **`url` is always `/go/contact/{id}?channel=…` and never a conversation URL.** A conversation id goes stale between this response and the click — a thread closes, a newer one arrives, a contact is merged — while `/go` re-resolves the right thread on arrival. That makes the URL safe to store against your own record; a conversation URL would not be.\n\nAlways returned here. On `GET /v1/contacts` it is **opt-in** via `?include=reach`, because it costs two extra queries a page.\n\n**Required scope(s):** `contacts:read` — neither `deep_link`, `assigned_operator_id` nor `reach` needs an additional scope."},"status":"OK","code":200,"_postman_previewlanguage":"json","header":[{"value":"application/json","description":"JSON body.","key":"Content-Type"},{"value":"req_01J8ZC4Q7YV2N9K3M5T8XB","description":"Echoed in error.request_id; cross-reference on /settings/api-keys/logs.","key":"X-Request-Id"}],"cookie":[],"responseTime":null,"body":"{\n  \"id\": \"9c2f1d84-6a3b-4f77-9b21-5e0d8a4c1f30\",\n  \"display_name\": \"Ayanda Mokoena\",\n  \"identities\": [\n    { \"channel\": \"whatsapp\", \"address\": \"+27821234567\" }\n  ],\n  \"opt_in_status\": \"opted_in\",\n  \"is_blocked\": false,\n  \"last_active_at\": \"2026-08-05T09:14:22.481Z\",\n  \"opt_in_recorded_at\": \"2026-07-28T11:02:17.003Z\",\n  \"opt_out_recorded_at\": null,\n  \"created_at\": \"2026-07-28T11:02:16.884Z\",\n  \"deep_link\": \"https://app.aiployee.co.za/go/contact/9c2f1d84-6a3b-4f77-9b21-5e0d8a4c1f30\",\n  \"attributes\": {\n    \"company\": \"Kalahari Freight\",\n    \"lead_source\": \"referral\"\n  },\n  \"tags\": [\"qualified\", \"priority\"]\n}"},{"id":"5e663f3c-b435-8a75-a94e-c57661295f92","name":"200 — reachable on two channels (one open, one start)","originalRequest":{"method":"GET","header":[],"url":{"raw":"https://app.aiployee.co.za/api/v1/contacts/:id","host":["https://app.aiployee.co.za/api"],"path":["v1","contacts",":id"],"variable":[{"id":"bf791739-8e9a-4363-8a32-7fe9feba09f6","key":"id"}]},"description":"Returns one contact with its custom attributes, aggregated conversation tags, and `reach`.\n\n**`deep_link` (CT-1)** — an absolute URL that opens this contact's conversation in Aiployee, resolving the existing thread or creating one on arrival:\n\n`https://app.aiployee.co.za/go/contact/{id}`\n\nStore it against your own record. It is keyed on the **contact**, not on a conversation, so it keeps working after the underlying thread is resolved and a new one starts — you never have to refresh it. Opening it requires a signed-in Aiployee session with the **operator** role or higher; an unauthenticated visitor is sent to sign-in and returned to the same link afterwards. Append `?channel=` (`whatsapp` default, or `web` · `messenger` · `instagram` · `telegram`) to target another channel.\n\n**`assigned_operator_id` (CT-2)** — the workspace user who **owns** this contact, or `null`. New conversations with this contact are assigned to them automatically, on every channel. This is the durable relationship; a conversation's own assignee (who holds one thread right now) lives on the conversation resource. Writable via `PATCH /v1/contacts/{id}`, and settable at creation via `POST /v1/contacts`.\n\nThe field is always present, and `null` genuinely means \"no owner\". A stored owner who has since left the workspace still reads back here, but no longer takes effect — assignment falls through to the workspace's assignment strategy rather than routing to somebody who is gone. If you need to know whether an owner is currently *effective*, cross-check the id against `GET /v1/team`.\n\n**`reach` (API-9)** — every channel this contact can be reached on **right now**, one entry per real option, live threads first. It is the same set the Aiployee contacts table draws its own row buttons from, so the API and the dashboard cannot offer different answers.\n\n- `kind: \"open\"` — a thread already exists and the URL lands on it. `conversation_id` is included **for labelling only** (e.g. \"continue thread 8f2c…\").\n- `kind: \"start\"` — no thread yet, and this channel can begin one.\n- `reach: []` — there is currently **no way** to reach this contact: no phone number, no live thread, and no channel that may cold-start one. Web-chat, Instagram and Messenger arrivals have no phone number, and Messenger and Instagram are reply-only. Say so rather than offering an action that will fail.\n\n⚠ **`url` is always `/go/contact/{id}?channel=…` and never a conversation URL.** A conversation id goes stale between this response and the click — a thread closes, a newer one arrives, a contact is merged — while `/go` re-resolves the right thread on arrival. That makes the URL safe to store against your own record; a conversation URL would not be.\n\nAlways returned here. On `GET /v1/contacts` it is **opt-in** via `?include=reach`, because it costs two extra queries a page.\n\n**Required scope(s):** `contacts:read` — neither `deep_link`, `assigned_operator_id` nor `reach` needs an additional scope."},"status":"OK","code":200,"_postman_previewlanguage":"json","header":[{"value":"application/json","key":"Content-Type"}],"cookie":[],"responseTime":null,"body":"{\n  \"id\": \"8f2c1d44-0000-4000-8000-000000000001\",\n  \"display_name\": \"Reachable Two Ways\",\n  \"identities\": [\n    { \"channel\": \"whatsapp\", \"address\": \"+27821234567\" }\n  ],\n  \"opt_in_status\": \"opted_in\",\n  \"is_blocked\": false,\n  \"last_active_at\": \"2026-08-07T09:31:02.000Z\",\n  \"opt_in_recorded_at\": \"2026-08-07T09:30:00.000Z\",\n  \"opt_out_recorded_at\": null,\n  \"created_at\": \"2026-08-07T09:30:00.000Z\",\n  \"deep_link\": \"https://app.aiployee.co.za/go/contact/8f2c1d44-0000-4000-8000-000000000001\",\n  \"assigned_operator_id\": null,\n  \"attributes\": {},\n  \"tags\": [],\n  \"reach\": [\n    {\n      \"channel\": \"telegram\",\n      \"kind\": \"open\",\n      \"url\": \"https://app.aiployee.co.za/go/contact/8f2c1d44-0000-4000-8000-000000000001?channel=telegram\",\n      \"conversation_id\": \"b7e1c9a2-0000-4000-8000-000000000002\"\n    },\n    {\n      \"channel\": \"whatsapp\",\n      \"kind\": \"start\",\n      \"url\": \"https://app.aiployee.co.za/go/contact/8f2c1d44-0000-4000-8000-000000000001?channel=whatsapp\"\n    }\n  ]\n}"},{"id":"c05a7d49-3752-d9a4-8a44-2fba6e71fb49","name":"200 — unreachable right now (reach: [])","originalRequest":{"method":"GET","header":[],"url":{"raw":"https://app.aiployee.co.za/api/v1/contacts/:id","host":["https://app.aiployee.co.za/api"],"path":["v1","contacts",":id"],"variable":[{"id":"bf791739-8e9a-4363-8a32-7fe9feba09f6","key":"id"}]},"description":"Returns one contact with its custom attributes, aggregated conversation tags, and `reach`.\n\n**`deep_link` (CT-1)** — an absolute URL that opens this contact's conversation in Aiployee, resolving the existing thread or creating one on arrival:\n\n`https://app.aiployee.co.za/go/contact/{id}`\n\nStore it against your own record. It is keyed on the **contact**, not on a conversation, so it keeps working after the underlying thread is resolved and a new one starts — you never have to refresh it. Opening it requires a signed-in Aiployee session with the **operator** role or higher; an unauthenticated visitor is sent to sign-in and returned to the same link afterwards. Append `?channel=` (`whatsapp` default, or `web` · `messenger` · `instagram` · `telegram`) to target another channel.\n\n**`assigned_operator_id` (CT-2)** — the workspace user who **owns** this contact, or `null`. New conversations with this contact are assigned to them automatically, on every channel. This is the durable relationship; a conversation's own assignee (who holds one thread right now) lives on the conversation resource. Writable via `PATCH /v1/contacts/{id}`, and settable at creation via `POST /v1/contacts`.\n\nThe field is always present, and `null` genuinely means \"no owner\". A stored owner who has since left the workspace still reads back here, but no longer takes effect — assignment falls through to the workspace's assignment strategy rather than routing to somebody who is gone. If you need to know whether an owner is currently *effective*, cross-check the id against `GET /v1/team`.\n\n**`reach` (API-9)** — every channel this contact can be reached on **right now**, one entry per real option, live threads first. It is the same set the Aiployee contacts table draws its own row buttons from, so the API and the dashboard cannot offer different answers.\n\n- `kind: \"open\"` — a thread already exists and the URL lands on it. `conversation_id` is included **for labelling only** (e.g. \"continue thread 8f2c…\").\n- `kind: \"start\"` — no thread yet, and this channel can begin one.\n- `reach: []` — there is currently **no way** to reach this contact: no phone number, no live thread, and no channel that may cold-start one. Web-chat, Instagram and Messenger arrivals have no phone number, and Messenger and Instagram are reply-only. Say so rather than offering an action that will fail.\n\n⚠ **`url` is always `/go/contact/{id}?channel=…` and never a conversation URL.** A conversation id goes stale between this response and the click — a thread closes, a newer one arrives, a contact is merged — while `/go` re-resolves the right thread on arrival. That makes the URL safe to store against your own record; a conversation URL would not be.\n\nAlways returned here. On `GET /v1/contacts` it is **opt-in** via `?include=reach`, because it costs two extra queries a page.\n\n**Required scope(s):** `contacts:read` — neither `deep_link`, `assigned_operator_id` nor `reach` needs an additional scope."},"status":"OK","code":200,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"cookie":[],"responseTime":null,"body":"{\n  \"id\": \"c4a70e18-0000-4000-8000-000000000003\",\n  \"display_name\": \"Web Chat Visitor\",\n  \"identities\": [],\n  \"opt_in_status\": \"pending\",\n  \"is_blocked\": false,\n  \"last_active_at\": null,\n  \"opt_in_recorded_at\": null,\n  \"opt_out_recorded_at\": null,\n  \"created_at\": \"2026-08-07T09:30:00.000Z\",\n  \"deep_link\": \"https://app.aiployee.co.za/go/contact/c4a70e18-0000-4000-8000-000000000003\",\n  \"assigned_operator_id\": null,\n  \"attributes\": {},\n  \"tags\": [],\n  \"reach\": []\n}"}],"_postman_id":"c83329dc-a935-427d-8928-867b3d2fe3d1"},{"name":"Update a contact","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('identities');","// CT-1 — the link is derived from the id, so a patch must never move it. This is","// the assertion behind the promise that a stored deep_link stays valid.","if (pm.response.code === 200) {","    pm.test('deep_link is unchanged by the patch', function () {","        var j = pm.response.json();","        pm.expect(j).to.have.property('deep_link');","        pm.expect(new URL(j.deep_link).pathname).to.equal('/go/contact/' + j.id);","    });","}","// CT-2 — assigned_operator_id is always PRESENT on the contact resource, and","// null is a legitimate value (no owner). Asserting presence rather than","// truthiness is the point: a missing key would mean the field was dropped from","// one endpoint's select, which is exactly the drift this catches.","if (pm.response.code === 200) {","    pm.test('contact carries assigned_operator_id (null = no owner)', function () {","        var j = pm.response.json();","        pm.expect(j).to.have.property('assigned_operator_id');","        pm.expect(j.assigned_operator_id === null || typeof j.assigned_operator_id === 'string').to.be.true;","    });","    pm.test('the patched owner is what we sent', function () {","        var sent = JSON.parse(pm.request.body.raw);","        if (Object.prototype.hasOwnProperty.call(sent, 'assigned_operator_id')) {","            pm.expect(pm.response.json().assigned_operator_id).to.eql(sent.assigned_operator_id);","        }","    });","}"],"type":"text/javascript"},"listen":"test"}],"id":"1568ccac-626a-4824-b9ff-253434e4ef92","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"bdc819ec-ea4f-4f35-b609-5b7ad1cce8f7"}],"body":{"mode":"raw","raw":"{\n  \"display_name\": \"Thandi Mokoena\",\n  \"opt_in_status\": \"opted_in\",\n  \"assigned_operator_id\": null\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/contacts/:id","description":"<p>Patches a contact. Scope: <code>contacts:write</code>.</p>\n<p>Every field is optional and <strong>omitting a key leaves it untouched</strong> — this is a genuine PATCH, not a replace.</p>\n<ul>\n<li><code>display_name</code> — pass <code>null</code> to clear it.</li>\n<li><code>opt_in_status</code> — <code>opted_in</code> | <code>opted_out</code> | <code>pending</code>. Changing it stamps <code>opt_in_recorded_at</code> / <code>opt_out_recorded_at</code>.</li>\n<li><code>assigned_operator_id</code> (<strong>CT-2</strong>) — the contact's <strong>owner</strong>: the workspace user id that new conversations with this contact are automatically assigned to, on every channel. Pass <code>null</code> to remove the owner. <strong>Omit the key to leave ownership unchanged</strong> — sending <code>null</code> clears it, so a caller syncing only a name must not send the field at all.</li>\n</ul>\n<h2 id=\"about-the-owner\">About the owner</h2>\n<p><code>assigned_operator_id</code> must be a member of this workspace with the <strong>owner</strong>, <strong>admin</strong> or <strong>operator</strong> role. Anything else is rejected with <code>400 invalid_request</code>:</p>\n<ul>\n<li>a user id that is not a member of the workspace → <em>\"That user is not a member of this workspace.\"</em></li>\n<li>an <strong>analyst</strong> → analysts have read-only access to conversations, so assigning one would queue work with somebody who cannot answer it.</li>\n</ul>\n<p>Owner is distinct from a conversation's assignee (<code>PATCH /v1/conversations/{id}</code>): the owner is the durable relationship, the assignee is who holds one thread right now.</p>\n<p>⚠ <strong>Setting an owner does not reassign the contact's existing conversations.</strong> That is deliberate — a thread an operator is already handling is never moved out from under them because a CRM sync changed an account manager. From the moment the owner is set, <em>new</em> conversations go to them.</p>\n<p>If the owner later leaves the workspace, the stored id stays but stops taking effect: assignment falls through to the workspace's assignment strategy (Settings → Handoff) rather than assigning to somebody who is gone.</p>\n<p>Supports <code>Idempotency-Key</code>.</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","contacts",":id"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"ca8e85f7-af7a-4872-9a9e-26dfa390ff1b","type":"any","key":"id"}]}},"response":[{"id":"5b3b8d34-f9be-a9cc-0a4a-f6236abab354","name":"200 — owner assigned","originalRequest":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"29470229-a85d-443d-ac79-4b1bfaa467d7"}],"body":{"mode":"raw","raw":"{\n  \"display_name\": \"Thandi Mokoena\",\n  \"opt_in_status\": \"opted_in\",\n  \"assigned_operator_id\": null\n}","options":{"raw":{"language":"json"}}},"url":{"raw":"https://app.aiployee.co.za/api/v1/contacts/:id","host":["https://app.aiployee.co.za/api"],"path":["v1","contacts",":id"],"variable":[{"id":"ca8e85f7-af7a-4872-9a9e-26dfa390ff1b","key":"id"}]},"description":"Patches a contact. Scope: `contacts:write`.\n\nEvery field is optional and **omitting a key leaves it untouched** — this is a genuine PATCH, not a replace.\n\n- `display_name` — pass `null` to clear it.\n- `opt_in_status` — `opted_in` | `opted_out` | `pending`. Changing it stamps `opt_in_recorded_at` / `opt_out_recorded_at`.\n- `assigned_operator_id` (**CT-2**) — the contact's **owner**: the workspace user id that new conversations with this contact are automatically assigned to, on every channel. Pass `null` to remove the owner. **Omit the key to leave ownership unchanged** — sending `null` clears it, so a caller syncing only a name must not send the field at all.\n\n## About the owner\n\n`assigned_operator_id` must be a member of this workspace with the **owner**, **admin** or **operator** role. Anything else is rejected with `400 invalid_request`:\n\n- a user id that is not a member of the workspace → *\"That user is not a member of this workspace.\"*\n- an **analyst** → analysts have read-only access to conversations, so assigning one would queue work with somebody who cannot answer it.\n\nOwner is distinct from a conversation's assignee (`PATCH /v1/conversations/{id}`): the owner is the durable relationship, the assignee is who holds one thread right now.\n\n⚠ **Setting an owner does not reassign the contact's existing conversations.** That is deliberate — a thread an operator is already handling is never moved out from under them because a CRM sync changed an account manager. From the moment the owner is set, *new* conversations go to them.\n\nIf the owner later leaves the workspace, the stored id stays but stops taking effect: assignment falls through to the workspace's assignment strategy (Settings → Handoff) rather than assigning to somebody who is gone.\n\nSupports `Idempotency-Key`."},"status":"OK","code":200,"_postman_previewlanguage":"json","header":[{"value":"application/json","key":"Content-Type"},{"value":"req_01JZ8QK3M2N4P6R8T0V2W4Y6","key":"X-Request-Id"}],"cookie":[],"responseTime":null,"body":"{\n  \"id\": \"b7f1c2d3-4e5f-4a6b-8c9d-0e1f2a3b4c5d\",\n  \"display_name\": \"Thandi Mokoena\",\n  \"identities\": [\n    {\n      \"channel\": \"whatsapp\",\n      \"address\": \"+27821234567\"\n    }\n  ],\n  \"opt_in_status\": \"opted_in\",\n  \"is_blocked\": false,\n  \"last_active_at\": \"2026-08-05T14:22:08.113Z\",\n  \"opt_in_recorded_at\": \"2026-07-28T09:04:51.002Z\",\n  \"opt_out_recorded_at\": null,\n  \"created_at\": \"2026-07-28T09:04:50.884Z\",\n  \"deep_link\": \"https://app.aiployee.co.za/go/contact/b7f1c2d3-4e5f-4a6b-8c9d-0e1f2a3b4c5d\",\n  \"assigned_operator_id\": \"8c1d4f2a-6b73-4e91-9a05-1f2e3d4c5b6a\"\n}"}],"_postman_id":"1568ccac-626a-4824-b9ff-253434e4ef92"},{"name":"Delete a contact","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });"],"type":"text/javascript","id":"a36cd0f1-d030-4709-bec4-449f3e974163"},"listen":"test"}],"id":"f5360fb8-7f54-40c7-9291-4c85113c3ae2","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"DELETE","header":[],"url":"https://app.aiployee.co.za/api/v1/contacts/:id","description":"<p>Permanently deletes the contact and cascades attribute history + conversations via FK.</p>\n<p><strong>Required scope(s):</strong> <code>contacts:write</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","contacts",":id"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"9f107566-c57d-49b8-a1df-1b19ea7c84ce","type":"any","key":"id"}]}},"response":[],"_postman_id":"f5360fb8-7f54-40c7-9291-4c85113c3ae2"},{"name":"Block contact","event":[{"script":{"type":"text/javascript","exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('identities');","// CT-1 — blocking suppresses SENDING, not linking. The deep_link stays so an","// external system's stored URL does not rot the moment somebody is blocked.","if (pm.response.code === 200) {","    pm.test('a blocked contact keeps its deep_link', function () {","        var j = pm.response.json();","        pm.expect(j.is_blocked).to.be.true;","        pm.expect(j).to.have.property('deep_link');","        pm.expect(new URL(j.deep_link).pathname).to.equal('/go/contact/' + j.id);","    });","}"]},"listen":"test"}],"id":"24c03eca-5e7d-4b36-8b74-ee3e534f1e21","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[],"url":"https://app.aiployee.co.za/api/v1/contacts/:id/block","description":"<p>Blocks the contact. Inbound is still ingested but no agent reply fires; outbound API sends to this contact return 400.</p>\n<p><strong><code>deep_link</code> (CT-1)</strong> — still returned on a blocked contact, and still resolves: the link is not a send. In the dashboard the Message action is shown disabled with the reason, so an operator sees <em>why</em> rather than a dead button. Unblock first if you need to message them.</p>\n<p><strong>Required scope(s):</strong> <code>contacts:write</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","contacts",":id","block"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"31fe6a02-c694-4819-826c-87527fcfce02","type":"any","key":"id"}]}},"response":[],"_postman_id":"24c03eca-5e7d-4b36-8b74-ee3e534f1e21"},{"name":"Unblock contact","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('identities');","// CT-1 — the block/unblock cycle must leave the link exactly where it was.","if (pm.response.code === 200) {","    pm.test('deep_link survives the block/unblock cycle', function () {","        var j = pm.response.json();","        pm.expect(j.is_blocked).to.be.false;","        pm.expect(j).to.have.property('deep_link');","        pm.expect(new URL(j.deep_link).pathname).to.equal('/go/contact/' + j.id);","    });","}"],"type":"text/javascript"},"listen":"test"}],"id":"b7b7e508-45e2-4dc6-99b9-3666b268fee2","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[],"url":"https://app.aiployee.co.za/api/v1/contacts/:id/unblock","description":"<p>Reverses a prior block. No-op if the contact isn't blocked.</p>\n<p><strong><code>deep_link</code> (CT-1)</strong> — returned here too, unchanged by the block/unblock cycle. See <em>Fetch a contact</em>.</p>\n<p><strong>Required scope(s):</strong> <code>contacts:write</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","contacts",":id","unblock"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"02da6097-762c-4406-905f-64e1c7bc6b30","type":"any","key":"id"}]}},"response":[],"_postman_id":"b7b7e508-45e2-4dc6-99b9-3666b268fee2"},{"name":"Set contact attributes","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('contact_id');"],"type":"text/javascript","id":"8e87a63d-2bb9-4991-bf05-cdcc6a8ce359"},"listen":"test"}],"id":"1a46bf12-f6a7-42a8-a762-de2cb176325e","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"9a0b5fe1-d6b6-42e2-9674-744f3ba7b704"}],"body":{"mode":"raw","raw":"{\n  \"values\": {\n    \"industry\": \"Fintech\",\n    \"lifecycle\": \"lead\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/contacts/:id/attributes","description":"<p>Sets custom attributes on the contact. Keys must already exist on /settings/contact-fields; system-managed keys rejected; <code>null</code> clears a value.</p>\n<p><strong>Required scope(s):</strong> <code>contacts:write</code> · <strong>Idempotent</strong></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","contacts",":id","attributes"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"6d1b1c6b-e0d6-4e88-be0c-eb3f74b46a28","type":"any","key":"id"}]}},"response":[],"_postman_id":"1a46bf12-f6a7-42a8-a762-de2cb176325e"},{"name":"Add or remove contact tags","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('added');"],"type":"text/javascript","id":"27972981-7380-4bf1-a846-a80ba6a1cc06"},"listen":"test"}],"id":"5c9b3a0c-de2f-47fb-b709-5e2082153421","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"01fd296f-02f9-4a68-a1b1-7b83f56de8b7"}],"body":{"mode":"raw","raw":"{\n  \"add\": [\"vip\"],\n  \"remove\": [\"cold-lead\"]\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/contacts/:id/tags","description":"<p>Applies tag add/remove across every conversation the contact has. Tags must already exist at /settings/tags.</p>\n<p><strong>Required scope(s):</strong> <code>tags:write</code> · <strong>Idempotent</strong></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","contacts",":id","tags"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"f3c9abb5-cd2d-4f87-b9fa-ea631841cff6","type":"any","key":"id"}]}},"response":[],"_postman_id":"5c9b3a0c-de2f-47fb-b709-5e2082153421"},{"name":"Bulk upsert contacts","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('errors');"],"type":"text/javascript","id":"85ae962e-3d56-4347-a81b-c99378945a14"},"listen":"test"}],"id":"da67e153-bd1e-421d-9221-66c77efd6db6","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"9e47aa34-01dd-41fb-bc95-bb388e532f96"}],"body":{"mode":"raw","raw":"{\n  \"channel\": \"whatsapp\",\n  \"contacts\": [\n    {\n      \"identities\": [{ \"channel\": \"whatsapp\", \"address\": \"+27821234567\" }],\n      \"display_name\": \"Ayanda M.\"\n    }\n  ]\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/contacts/bulk","description":"<p>Upserts up to 1000 contacts by WhatsApp identity. Existing rows patched (display_name + opt_in_status); missing rows created.</p>\n<p><strong>Required scope(s):</strong> <code>contacts:write</code> · <strong>Idempotent</strong></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","contacts","bulk"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"da67e153-bd1e-421d-9221-66c77efd6db6"},{"name":"Assign an ineligible owner (400 invalid_request)","event":[{"listen":"test","script":{"exec":["pm.test('400 invalid_request', function () {","    pm.response.to.have.status(400);","    pm.expect(pm.response.json().error.code).to.eql('invalid_request');","});","pm.test('the message names the actual problem', function () {","    var m = pm.response.json().error.message;","    pm.expect(m).to.match(/not a member of this workspace|Analysts can't own contacts/);","});","// The write must not have half-happened. Ownership is validated BEFORE the","// update statement, so a rejected assignment leaves the contact untouched.","pm.test('nothing was written', function () {","    pm.sendRequest({","        url: pm.variables.replaceIn('https://app.aiployee.co.za/api/v1/contacts/') + pm.request.url.path[pm.request.url.path.length - 1],","        method: 'GET',","        header: { Authorization: 'Bearer ' + pm.variables.replaceIn('aip_live_REPLACE_ME') }","    }, function (err, res) {","        if (err) return;","        pm.expect(res.json().assigned_operator_id).to.not.eql('00000000-0000-4000-8000-000000000000');","    });","});"],"type":"text/javascript"}}],"id":"d9a77697-9ee1-4627-2588-8e32627a5e32","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"assigned_operator_id\": \"00000000-0000-4000-8000-000000000000\"\n}"},"url":"https://app.aiployee.co.za/api/v1/contacts/:id","description":"<p>Negative path for <strong>CT-2</strong> contact ownership. Scope: <code>contacts:write</code>.</p>\n<p>Proves the ownership guard: <code>assigned_operator_id</code> must be a member of <em>this</em> workspace with the <strong>owner</strong>, <strong>admin</strong> or <strong>operator</strong> role. Anything else is refused with <code>400 invalid_request</code> rather than being written and quietly ignored.</p>\n<p>The body ships an all-zeros UUID, which is nobody's user id, so it exercises the not-a-member arm:</p>\n<blockquote>\n<p><code>That user is not a member of this workspace.</code></p>\n</blockquote>\n<p>To exercise the <strong>analyst</strong> arm instead, put a real analyst's user id in the body (find one via <code>GET /v1/team</code>). The message differs, the code does not:</p>\n<blockquote>\n<p><code>Analysts can't own contacts — they have read-only access to conversations, so an assignment would queue work with somebody who cannot answer it.</code></p>\n</blockquote>\n<h2 id=\"why-this-is-refused-rather-than-tolerated\">Why this is refused rather than tolerated</h2>\n<p>An owner is what routes new conversations. Accepting an id that can never receive one would produce a contact that <em>looks</em> owned while its conversations pile up unassigned — the silent-misconfiguration failure this API tries not to have. Note the deliberate asymmetry: a <em>stored</em> owner who later leaves the workspace is <strong>not</strong> an error. It stops taking effect and assignment falls through to the workspace strategy, because by then the user has done nothing wrong and refusing would break inbound messages. Rejecting happens only when somebody is actively naming a person.</p>\n<p>Set the <code>:id</code> path variable to a real contact id (<code></code>).</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","contacts",":id"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"type":"any","key":"id"}]}},"response":[],"_postman_id":"d9a77697-9ee1-4627-2588-8e32627a5e32"},{"name":"Create with an analyst as owner (400 invalid_request)","event":[{"listen":"prerequest","script":{"type":"text/javascript","exec":["// Self-skip rather than fail when the workspace has no analyst. A red test for a","// missing FIXTURE reads as a broken API, which is worse than an honest skip.","if (!pm.environment.get('analyst_user_id')) {","    console.log('No analyst in this workspace — skipping. Invite one to exercise this path.');","    pm.execution.skipRequest();","}"]}},{"listen":"test","script":{"type":"text/javascript","exec":["pm.test('400 invalid_request', function () {","    pm.response.to.have.status(400);","    pm.expect(pm.response.json().error.code).to.eql('invalid_request');","});","","// The message must say WHY — 'not a member' and 'is an analyst' are different","// mistakes and the caller needs to know which one they made.","pm.test('the message says analysts cannot own contacts', function () {","    pm.expect(pm.response.json().error.message.toLowerCase()).to.include('analyst');","});","","// Validation happens BEFORE the insert, so a rejected create must leave nothing","// behind — not a contact with a null owner, nothing at all.","pm.test('no contact was created', function () {","    pm.expect(pm.response.json()).to.not.have.property('id');","});"]}}],"id":"17e28546-a530-55bf-cb8a-6af4ac9c0556","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"identities\": [{ \"channel\": \"whatsapp\", \"address\": \"+27999000911\" }],\n  \"display_name\": \"Postman — should never exist\",\n  \"assigned_operator_id\": \"{{analyst_user_id}}\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/contacts","description":"<p>The <strong>create</strong> path's ownership validation. <em>Assign an ineligible owner</em> covers the PATCH; this covers <code>POST</code>, which is a separate route even though both go through the same validator — and \"both share a validator\" is a claim worth testing rather than trusting.</p>\n<p>An <strong>analyst</strong> cannot own a contact: they have read-only access to conversations, so an assignment would queue work with somebody who cannot answer it. The API rejects it with <code>400 invalid_request</code> and names the reason.</p>\n<p>Uses a throwaway number (<code>+27999000911</code>) rather than <code>+27821234567</code>, deliberately: a pass must not depend on your real test number being free, and this request must never create anything.</p>\n<p>⚠ <strong>There is no analyst on any live workspace today</strong>, so <code>{{analyst_user_id}}</code> is normally blank and the pre-request script skips the request rather than failing it. Invite somebody as an analyst to exercise this path for real. Run <em>List members + invitations</em> first — it fills the variable in when an analyst exists.</p>\n<p><strong>Required scope(s):</strong> <code>contacts:write</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","contacts"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[{"id":"4c6faa63-f59d-734d-d38d-a948d68c859d","name":"400 — analysts can't own contacts","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"identities\": [{ \"channel\": \"whatsapp\", \"address\": \"+27999000911\" }],\n  \"display_name\": \"Postman — should never exist\",\n  \"assigned_operator_id\": \"{{analyst_user_id}}\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/contacts","description":"The **create** path's ownership validation. *Assign an ineligible owner* covers the PATCH; this covers `POST`, which is a separate route even though both go through the same validator — and \"both share a validator\" is a claim worth testing rather than trusting.\n\nAn **analyst** cannot own a contact: they have read-only access to conversations, so an assignment would queue work with somebody who cannot answer it. The API rejects it with `400 invalid_request` and names the reason.\n\nUses a throwaway number (`+27999000911`) rather than `+27821234567`, deliberately: a pass must not depend on your real test number being free, and this request must never create anything.\n\n⚠ **There is no analyst on any live workspace today**, so `{{analyst_user_id}}` is normally blank and the pre-request script skips the request rather than failing it. Invite somebody as an analyst to exercise this path for real. Run *List members + invitations* first — it fills the variable in when an analyst exists.\n\n**Required scope(s):** `contacts:write`"},"status":"Bad Request","code":400,"_postman_previewlanguage":"json","header":[{"value":"application/json","key":"Content-Type"}],"cookie":[],"responseTime":null,"body":"{\n  \"error\": {\n    \"code\": \"invalid_request\",\n    \"message\": \"Analysts can't own contacts — they have read-only access to conversations, so an assignment would queue work with somebody who cannot answer it.\",\n    \"request_id\": \"c0adeb89-50cb-40e1-8e6d-7957e9d7e865\"\n  }\n}"}],"_postman_id":"17e28546-a530-55bf-cb8a-6af4ac9c0556"},{"name":"Create with a non-member owner (400 invalid_request)","event":[{"listen":"test","script":{"type":"text/javascript","exec":["pm.test('400 invalid_request', function () {","    pm.response.to.have.status(400);","    pm.expect(pm.response.json().error.code).to.eql('invalid_request');","});","","// Distinct from the analyst message — that distinction is the point of this","// request existing separately.","pm.test('the message says not a member', function () {","    pm.expect(pm.response.json().error.message).to.include('not a member');","});","","pm.test('no contact was created', function () {","    pm.expect(pm.response.json()).to.not.have.property('id');","});","","// Negative control on the schema layer: a value that is not a uuid at all must","// be refused too, and by a DIFFERENT mechanism (zod, before any database read).","// Without this, the pass above could mean 'every owner is rejected'.","pm.test('a non-uuid owner is refused by the schema as well', function () {","    pm.sendRequest({","        url: pm.variables.replaceIn('https://app.aiployee.co.za/api/v1/contacts'),","        method: 'POST',","        header: {","            Authorization: 'Bearer ' + pm.variables.replaceIn('aip_live_REPLACE_ME'),","            'Content-Type': 'application/json'","        },","        body: {","            mode: 'raw',","            raw: JSON.stringify({","                identities: [{ channel: 'whatsapp', address: '+27999000913' }],","                assigned_operator_id: 'not-a-uuid'","            })","        }","    }, function (err, res) {","        if (err) return;","        pm.expect(res.code).to.eql(400);","        pm.expect(res.json().error.code).to.eql('invalid_request');","    });","});"]}}],"id":"4801d5f8-848e-c682-3ca6-c072fa73e0ef","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"identities\": [{ \"channel\": \"whatsapp\", \"address\": \"+27999000912\" }],\n  \"display_name\": \"Postman — should never exist\",\n  \"assigned_operator_id\": \"00000000-0000-4000-8000-000000000000\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/contacts","description":"<p>The other half of the ownership validation: a <strong>well-formed uuid that belongs to nobody in this workspace</strong>. It passes the schema check and fails the membership lookup, so it reaches a different branch from the analyst case.</p>\n<p>Kept as a separate request from <em>Create with an analyst as owner</em> on purpose. <strong>A member with the wrong role and not a member at all are different mistakes</strong>, and the API tells you which one you made — collapsing them into one test would stop that distinction being verified.</p>\n<p>The all-zeros uuid is the same sentinel <em>Assign an ineligible owner</em> uses on the PATCH. It is a valid v4-shaped uuid, so it cannot be rejected as malformed.</p>\n<p>⚠ The membership check reads <code>tenant_users</code>, <strong>not</strong> the foreign key. <code>assigned_operator_id</code> points at <code>auth.users</code>, which is platform-wide, so somebody who left this workspace months ago still satisfies the FK — only the membership table can answer whether they are here now.</p>\n<p><strong>Required scope(s):</strong> <code>contacts:write</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","contacts"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[{"id":"8334dfc3-07b6-4ad2-2e6d-3583d6e58808","name":"400 — not a member of this workspace","originalRequest":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"identities\": [{ \"channel\": \"whatsapp\", \"address\": \"+27999000912\" }],\n  \"display_name\": \"Postman — should never exist\",\n  \"assigned_operator_id\": \"00000000-0000-4000-8000-000000000000\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/contacts","description":"The other half of the ownership validation: a **well-formed uuid that belongs to nobody in this workspace**. It passes the schema check and fails the membership lookup, so it reaches a different branch from the analyst case.\n\nKept as a separate request from *Create with an analyst as owner* on purpose. **A member with the wrong role and not a member at all are different mistakes**, and the API tells you which one you made — collapsing them into one test would stop that distinction being verified.\n\nThe all-zeros uuid is the same sentinel *Assign an ineligible owner* uses on the PATCH. It is a valid v4-shaped uuid, so it cannot be rejected as malformed.\n\n⚠ The membership check reads `tenant_users`, **not** the foreign key. `assigned_operator_id` points at `auth.users`, which is platform-wide, so somebody who left this workspace months ago still satisfies the FK — only the membership table can answer whether they are here now.\n\n**Required scope(s):** `contacts:write`"},"status":"Bad Request","code":400,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"cookie":[],"responseTime":null,"body":"{\n  \"error\": {\n    \"code\": \"invalid_request\",\n    \"message\": \"That user is not a member of this workspace.\",\n    \"request_id\": \"47cd277a-09b3-429d-bff8-90a0b1b19fef\"\n  }\n}"}],"_postman_id":"4801d5f8-848e-c682-3ca6-c072fa73e0ef"},{"name":"List contacts with reach","event":[{"listen":"test","script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403]).to.include(pm.response.code); });","","if (pm.response.code === 200) {","    var rows = pm.response.json().data;","","    pm.test('include=reach adds reach to EVERY row', function () {","        pm.expect(rows).to.be.an('array').that.is.not.empty;","        rows.forEach(function (c) {","            pm.expect(c, 'contact ' + c.id).to.have.property('reach');","            pm.expect(c.reach, 'contact ' + c.id).to.be.an('array');","        });","    });","","    // Same url rule as the single fetch — the list has its own code path, so it is","    // the one that could silently start emitting a conversation URL.","    pm.test('every reach url on every row is the contact deep link', function () {","        rows.forEach(function (c) {","            c.reach.forEach(function (r) {","                pm.expect(r.url, c.id + '/' + r.channel).to.eql(c.deep_link + '?channel=' + r.channel);","                pm.expect(r.url, c.id + '/' + r.channel).to.not.include('/conversations/');","            });","        });","    });","","    // An empty array is a REAL answer (nobody can be reached), so it must not be","    // mistaken for the field being absent. Both shapes are legitimate; a missing","    // key is not.","    pm.test('an unreachable contact reports [] rather than omitting the field', function () {","        rows.filter(function (c) { return c.reach.length === 0; }).forEach(function (c) {","            pm.expect(c.reach, 'contact ' + c.id).to.eql([]);","        });","    });","","    // NEGATIVE CONTROL — a near-miss token must NOT switch it on. Verified live:","    // ?include=reachable returns rows with no reach key at all. Without this, the","    // assertions above would also pass if the API had started returning reach","    // unconditionally, which would quietly undo the opt-in.","    pm.test('?include=reachable does NOT switch reach on', function () {","        pm.sendRequest({","            url: pm.variables.replaceIn('https://app.aiployee.co.za/api/v1/contacts?limit=5&include=reachable'),","            method: 'GET',","            header: { Authorization: 'Bearer ' + pm.variables.replaceIn('aip_live_REPLACE_ME') }","        }, function (err, res) {","            if (err) return;","            res.json().data.forEach(function (c) {","                pm.expect(c.reach, 'contact ' + c.id).to.be.undefined;","            });","        });","    });","}"],"type":"text/javascript"}}],"id":"ce2ef2f3-23c1-8c14-07cf-2d3de0314246","request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/contacts?limit=25&include=reach","description":"<p><code>GET /v1/contacts</code> with the per-channel <strong><code>reach</code></strong> array on every row.</p>\n<p>Opt-in because it costs two extra queries per page — the tenant's active channels (a property of the workspace, so one query per request) plus the page's conversations (one batched query, never one per contact). Most list callers do not want them, and <code>reach</code> is always present on <code>GET /v1/contacts/{id}</code> anyway.</p>\n<p><code>include</code> is a comma-separated list. <code>reach</code> is the only value it accepts today, and an <strong>unrecognised token is ignored rather than rejected</strong> — so a client that later sends <code>?include=reach,attributes</code> against an older deployment loses the extra section instead of losing the request.</p>\n<p>See <em>Fetch a contact</em> for what each entry means, and for the rule that the <code>url</code> is always <code>/go/contact/{id}?channel=…</code> and never a conversation URL.</p>\n<p><strong>Required scope(s):</strong> <code>contacts:read</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","contacts"],"host":["https://app.aiployee.co.za/api"],"query":[{"description":{"content":"<p>Page size (default 25, max 200).</p>\n","type":"text/plain"},"key":"limit","value":"25"},{"description":{"content":"<p>Comma-separated optional sections. <code>reach</code> is the only accepted value today; an unrecognised token is ignored, not rejected.</p>\n","type":"text/plain"},"key":"include","value":"reach"}],"variable":[]}},"response":[],"_postman_id":"ce2ef2f3-23c1-8c14-07cf-2d3de0314246"},{"name":"Create a contact with a starting stage","event":[{"listen":"test","script":{"exec":["pm.test('200 OK', function () { pm.response.to.have.status(200); });","var b = pm.response.json();","pm.test('the contact carries the starting stage we asked for', function () {","    pm.expect(b.entry_stage_id).to.eql(pm.collectionVariables.get('stage_id_for_contact'));","});","// ⚠ This assertion is the drift guard. GET /v1/contacts returned entry_stage_id","// for a while before the schema declared it or any write path accepted it, so","// pinning its presence on the response is what stops that recurring.","pm.test('entry_stage_id is present on the Contact resource', function () {","    pm.expect(b).to.have.property('entry_stage_id');","});","if (b.id) pm.collectionVariables.set('last_contact_id', b.id);"],"type":"text/javascript"}}],"id":"b3a5bbf3-dc33-670b-5ebc-e06ddd713fab","request":{"method":"POST","header":[{"value":"application/json","key":"Content-Type"},{"description":"<p>Optional replay-safe key.</p>\n","key":"Idempotency-Key","value":"df95324a-8077-45d0-acd9-00976b06790e"}],"body":{"mode":"raw","raw":"{\n  \"channel\": \"whatsapp\",\n  \"identities\": [\n    { \"channel\": \"whatsapp\", \"address\": \"+27821234567\" }\n  ],\n  \"display_name\": \"Starting-stage example\",\n  \"entry_stage_id\": \"{{stage_id_for_contact}}\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/contacts","description":"<p><strong>Scopes:</strong> <code>contacts:write</code></p>\n<p>Creates a contact that already carries a <strong>starting stage</strong> — the stage where that contact's conversations will open.</p>\n<p>Run <strong>Conversations → List stages</strong> first; it sets <code>{{stage_id_for_contact}}</code> from the first non-terminal stage.</p>\n<h3 id=\"why-set-it-here-rather-than-with-a-follow-up-patch\">Why set it here rather than with a follow-up PATCH</h3>\n<p>The starting stage is read when a conversation is <strong>created</strong> and is never applied retroactively. A first message landing between a create and a patch therefore opens the thread wherever the channel would have put it, and the patch then has no effect on it. Same reasoning as <code>assigned_operator_id</code>.</p>\n<h3 id=\"what-it-does\">What it does</h3>\n<p><code>entry_stage_id</code> <strong>outranks the channel's own default entry stage</strong>, so it is how you say \"this account always enters Collections\" without giving that customer a dedicated WhatsApp number. It does <em>not</em> outrank a channel whose agent is switched off entirely.</p>\n<p>It is durable, not a one-shot: it is never cleared on use, so it applies again to the contact's next conversation. Since resolved conversations are not reused, a customer returning after a closed thread starts on their stage again.</p>\n<p>⚠️ <strong>It is not a mirror of the live conversation stage, and there is no field for that.</strong> A contact holding a WhatsApp thread and a web-chat thread can sit on two stages at once. Read a conversation's <code>current_stage_id</code> for where a thread is now.</p>\n<p>Omit the key, or pass <code>null</code>, to let the channel decide. A stage id belonging to another workspace is rejected with <code>400 invalid_request</code> — see <strong>Errors &amp; replays</strong>.</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","contacts"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"b3a5bbf3-dc33-670b-5ebc-e06ddd713fab"},{"name":"Set or clear a contact's starting stage","event":[{"script":{"type":"text/javascript","exec":["pm.test('200 OK', function () { pm.response.to.have.status(200); });","var b = pm.response.json();","pm.test('the starting stage is what we set', function () {","    pm.expect(b.entry_stage_id).to.eql(pm.collectionVariables.get('stage_id_for_contact'));","});","// The omitted-key rule, asserted rather than only described: a follow-up patch","// that touches only the display name must leave the stage alone.","pm.test('a name-only patch does NOT clear the stage', function () {","    pm.sendRequest({","        url: pm.variables.replaceIn('https://app.aiployee.co.za/api/v1/contacts/') + pm.collectionVariables.get('last_contact_id'),","        method: 'PATCH',","        header: {","            'Content-Type': 'application/json',","            Authorization: 'Bearer ' + pm.variables.replaceIn('aip_live_REPLACE_ME')","        },","        body: { mode: 'raw', raw: JSON.stringify({ display_name: 'Starting-stage example (renamed)' }) }","    }, function (err, res) {","        pm.expect(err).to.be.null;","        pm.expect(res.json().entry_stage_id, 'omitting the key must not clear it')","            .to.eql(pm.collectionVariables.get('stage_id_for_contact'));","    });","});"]},"listen":"test"}],"id":"ffdaee17-8b8c-ef5f-b498-0370e82d3acc","request":{"method":"PATCH","header":[{"value":"application/json","key":"Content-Type"},{"value":"34064ac6-7ecc-4ed5-8131-070bce338fbe","key":"Idempotency-Key","description":"<p>Optional replay-safe key.</p>\n"}],"body":{"mode":"raw","raw":"{\n  \"entry_stage_id\": \"{{stage_id_for_contact}}\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/contacts/","description":"<p><strong>Scopes:</strong> <code>contacts:write</code></p>\n<p>Moves, or clears, where this contact's <strong>future</strong> conversations start.</p>\n<h3 id=\"three-spellings-three-different-meanings\">Three spellings, three different meanings</h3>\n<div class=\"click-to-expand-wrapper is-table-wrapper\"><table>\n<thead>\n<tr>\n<th>Body</th>\n<th>Meaning</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>{ \"entry_stage_id\": \"&lt;uuid&gt;\" }</code></td>\n<td>start there from now on</td>\n</tr>\n<tr>\n<td><code>{ \"entry_stage_id\": null }</code></td>\n<td>clear it; the channel decides again</td>\n</tr>\n<tr>\n<td>key <strong>omitted</strong></td>\n<td>leave it exactly as it is</td>\n</tr>\n</tbody>\n</table>\n</div><p>⚠️ <strong>The third row is the one that matters.</strong> A PATCH that syncs only a display name must not silently unpin somebody's stage, which is why the field is optional as well as nullable. If your integration builds a patch body from a CRM record, make sure an absent CRM value produces an <strong>omitted key</strong>, not <code>null</code>.</p>\n<h3 id=\"it-moves-nothing-that-is-already-open\">It moves nothing that is already open</h3>\n<p>A thread already running on a stage — with that stage's prompt, its tools and its transitions — is not moved because a sync changed a field. Only conversations created <em>after</em> this call are affected. To move a live thread, use <code>PATCH /v1/conversations/{id}</code> with <code>current_stage_id</code>, which is a different operation on a different resource for a reason.</p>\n<p>A stage id belonging to another workspace is rejected with <code>400 invalid_request</code>, not silently dropped.</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","contacts",""],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"ffdaee17-8b8c-ef5f-b498-0370e82d3acc"}],"id":"6a38b1c9-f2aa-4e8e-aebe-11cdb3d3a8f1","description":"<p>CRUD over contacts plus bulk upsert, attribute values, tag add/remove, and block/unblock.</p>\n","_postman_id":"6a38b1c9-f2aa-4e8e-aebe-11cdb3d3a8f1","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}}},{"name":"Conversations","item":[{"name":"List conversations","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('data');"],"type":"text/javascript","id":"859a18ef-fb94-4bb5-b5d0-08f165d73d68"},"listen":"test"}],"id":"4a8420d9-acbf-4a29-8cd1-3ec3040ac06f","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/conversations","description":"<p>Conversations sorted by <code>last_message_at</code> desc (cursor pagination). Filter by <code>status</code>, <code>stage_id</code>, <code>number_id</code>, <code>tag</code>, <code>assigned_to</code>.</p>\n<p><strong>Required scope(s):</strong> <code>conversations:read</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","conversations"],"host":["https://app.aiployee.co.za/api"],"query":[{"disabled":true,"description":{"content":"<p>Filter by status.</p>\n","type":"text/plain"},"key":"status","value":"agent_active"},{"disabled":true,"description":{"content":"<p>Assignee user id.</p>\n","type":"text/plain"},"key":"assigned_to","value":""},{"disabled":true,"description":{"content":"<p>whatsapp_numbers.id.</p>\n","type":"text/plain"},"key":"number_id","value":""},{"disabled":true,"description":{"content":"<p>Pipeline stage id.</p>\n","type":"text/plain"},"key":"stage_id","value":""},{"disabled":true,"description":{"content":"<p>Tag slug.</p>\n","type":"text/plain"},"key":"tag","value":""}],"variable":[]}},"response":[],"_postman_id":"4a8420d9-acbf-4a29-8cd1-3ec3040ac06f"},{"name":"Fetch a conversation","event":[{"listen":"test","script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('tags');"],"type":"text/javascript","id":"338577c0-c2d8-478d-8852-95a70d771d2e"}}],"id":"cacd1855-c713-4859-b2aa-1412cf524bfd","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/conversations/:id","description":"<p>Returns a single conversation.</p>\n<p><strong>Required scope(s):</strong> <code>conversations:read</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","conversations",":id"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"58795ebd-2872-4d5d-aad5-864c3a1903b3","type":"any","key":"id"}]}},"response":[],"_postman_id":"cacd1855-c713-4859-b2aa-1412cf524bfd"},{"name":"Patch conversation status / assignee / stage","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('tags');"],"type":"text/javascript","id":"0e515fc9-1b90-476d-aee9-2598bd1aa21b"},"listen":"test"}],"id":"b2c3bf04-6144-4638-bc74-929beaca8f74","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"87e6c9e8-09ee-4879-bcc9-5cba7d7d1f16"}],"body":{"mode":"raw","raw":"{\n  \"status\": \"agent_active\",\n  \"assigned_to\": null,\n  \"current_stage_id\": null\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/conversations/:id","description":"<p>Updates one or more of <code>status</code>, <code>assigned_to</code>, <code>current_stage_id</code> (pass <code>null</code> to clear the last two). Moving to <code>resolved</code> stamps <code>resolved_at</code>.</p>\n<p><strong>Required scope(s):</strong> <code>conversations:write</code> · <strong>Idempotent</strong></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","conversations",":id"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"78ecd0bf-81d3-47f9-a733-b4a2fb845bcc","type":"any","key":"id"}]}},"response":[],"_postman_id":"b2c3bf04-6144-4638-bc74-929beaca8f74"},{"name":"Add or remove conversation tags","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('tags');"],"type":"text/javascript","id":"274a3e17-7131-4060-888c-e12c3a6fe5de"},"listen":"test"}],"id":"6c0cd707-59dd-4e1c-bc7c-8d6c85fd9a9f","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"1248b32a-5f85-4607-a9c3-d7073eb2180e"}],"body":{"mode":"raw","raw":"{\n  \"add\": [\"needs-follow-up\"],\n  \"remove\": []\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/conversations/:id/tags","description":"<p>Adds/removes tags on this single conversation. Tags must already exist at /settings/tags.</p>\n<p><strong>Required scope(s):</strong> <code>tags:write</code> · <strong>Idempotent</strong></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","conversations",":id","tags"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"b87b64d3-4648-4dd6-99d1-a1968880ead0","type":"any","key":"id"}]}},"response":[],"_postman_id":"6c0cd707-59dd-4e1c-bc7c-8d6c85fd9a9f"},{"name":"List messages in a conversation","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });","","if (pm.response.code === 200) {","    var body = pm.response.json();","","    pm.test('page shape', function () {","        pm.expect(body).to.have.property('data');","        pm.expect(body.data).to.be.an('array');","        pm.expect(body).to.have.property('next_cursor');","    });","","    pm.test('every message carries the documented fields', function () {","        body.data.forEach(function (m) {","            pm.expect(m).to.have.property('id');","            pm.expect(m.content, 'content').to.be.a('string');","            pm.expect(m.message_type, 'message_type').to.be.a('string');","            pm.expect(m).to.have.property('media');","        });","    });","","    // The template-body fix. Scoped to the message the most recent send","    // captured, because templates sent BEFORE the fix still carry the old","    // '[template] <name>' marker and can never be backfilled — asserting","    // across the whole page would fail on legacy rows.","    var lastId = pm.collectionVariables.get('last_message_id');","    var sent = lastId ? body.data.filter(function (m) { return m.id === lastId; })[0] : null;","    if (sent && sent.message_type === 'template') {","        pm.test('a template send stores its rendered body, not the [template] marker', function () {","            pm.expect(sent.content).to.not.match(/^\\[template\\]\\s/);","            pm.expect(sent.content.length).to.be.above(0);","        });","    }","}"],"type":"text/javascript"},"listen":"test"}],"id":"06426e60-dad7-f1e3-e479-d3fec0104b89","request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/conversations//messages","description":"<p>Message history for a conversation, newest first (<code>created_at</code> descending), cursor-paginated via <code>cursor</code> + <code>limit</code> (default 25, max 200). Runs against <code></code>, captured by any successful send.</p>\n<p><code>content</code> is the message body as the contact received it:</p>\n<div class=\"click-to-expand-wrapper is-table-wrapper\"><table>\n<thead>\n<tr>\n<th><code>message_type</code></th>\n<th><code>content</code></th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>text</code></td>\n<td>the text sent</td>\n</tr>\n<tr>\n<td><code>template</code></td>\n<td>the <strong>rendered template body</strong>, with every <code>{{n}}</code> placeholder substituted from the <code>components</code> you sent</td>\n</tr>\n<tr>\n<td><code>image</code> / <code>video</code> / <code>document</code></td>\n<td>the caption, when there was one</td>\n</tr>\n<tr>\n<td><code>interactive</code></td>\n<td>the body text</td>\n</tr>\n<tr>\n<td><code>location</code></td>\n<td>a <code>[location] …</code> label</td>\n</tr>\n</tbody>\n</table>\n</div><blockquote>\n<p><strong>Changed:</strong> a template sent through <code>POST /v1/messages</code> used to persist the marker <code>[template] &lt;name&gt;</code> and drop the body. It now stores the rendered body, matching what broadcasts and the dashboard have always done. This is <strong>forward-only</strong> — the parameters of a historical send were never stored, so messages sent before this change keep the old marker and cannot be reconstructed. Treat a leading <code>[template] </code> as a legacy row, not as the current contract.</p>\n</blockquote>\n<p>Attachments are not inlined: when <code>media</code> is non-null, fetch the bytes via <code>GET /v1/media/{message_id}</code> (scope <code>media:read</code>), which returns a short-lived signed URL. The raw storage key is never exposed.</p>\n<p><code>content</code> and <code>transcript</code> are message bodies — personal data under POPIA; treat retention and lawful basis accordingly.</p>\n<p><strong>Required scope(s):</strong> <code>messages:read</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","conversations","","messages"],"host":["https://app.aiployee.co.za/api"],"query":[{"disabled":true,"description":{"content":"<p>Page size. Default 25, max 200.</p>\n","type":"text/plain"},"key":"limit","value":"25"},{"disabled":true,"description":{"content":"<p>Opaque cursor from the previous page's next_cursor.</p>\n","type":"text/plain"},"key":"cursor","value":""}],"variable":[]}},"response":[],"_postman_id":"06426e60-dad7-f1e3-e479-d3fec0104b89"},{"name":"List stages","event":[{"listen":"test","script":{"exec":["pm.test('200 OK', function () { pm.response.to.have.status(200); });","var b = pm.response.json();","pm.test('data is an array', function () { pm.expect(b.data).to.be.an('array'); });","pm.test('each stage carries exactly the documented fields', function () {","    if (b.data.length === 0) return;","    var s = b.data[0];","    pm.expect(s).to.have.property('id');","    pm.expect(s).to.have.property('name');","    pm.expect(s).to.have.property('is_entry_point');","    pm.expect(s).to.have.property('is_terminal');","    pm.expect(s).to.have.property('group_label');","    // Read-only view: no prompt, no transition graph. This is why the endpoint","    // needs no scope of its own.","    pm.expect(s).to.not.have.property('system_prompt');","    pm.expect(s).to.not.have.property('transitions');","});","pm.test('entry points sort first', function () {","    var seenNonEntry = false;","    b.data.forEach(function (s) {","        if (!s.is_entry_point) seenNonEntry = true;","        else pm.expect(seenNonEntry, 'an entry point appeared after a non-entry point').to.be.false;","    });","});","// Hand a usable stage id to the starting-stage requests in Contacts.","var usable = b.data.filter(function (s) { return !s.is_terminal; });","if (usable.length > 0) pm.collectionVariables.set('stage_id_for_contact', usable[0].id);"],"type":"text/javascript"}}],"id":"681ac827-5bc4-85fd-aae0-bcc772fda5e2","request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/stages","description":"<p><strong>Scope:</strong> <code>conversations:read</code></p>\n<p>Every stage in the workspace, entry points first and then oldest-first.</p>\n<p>Read-only. Stages are authored in the Aiployee stage builder, where a stage is a system prompt plus a transition graph plus a canvas layout, and there is no useful HTTP shape for editing one. This endpoint exists so the two write fields that NAME a stage have discoverable values:</p>\n<div class=\"click-to-expand-wrapper is-table-wrapper\"><table>\n<thead>\n<tr>\n<th>Field</th>\n<th>Where</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>entry_stage_id</code></td>\n<td><code>POST /v1/contacts</code>, <code>PATCH /v1/contacts/{id}</code> — where that contact's conversations START</td>\n</tr>\n<tr>\n<td><code>current_stage_id</code></td>\n<td><code>PATCH /v1/conversations/{id}</code> — where one thread is NOW</td>\n</tr>\n</tbody>\n</table>\n</div><p>Pick a stage whose <code>is_terminal</code> is false for either: a conversation on a terminal stage has nowhere left to go.</p>\n<p>⚠️ <strong><code>is_entry_point</code> is not exclusive.</strong> Several stages may carry it, and a conversation with nothing more specific to go on opens on the <strong>oldest</strong> of them. Never assume a single entry point.</p>\n<p>Response shape:</p>\n<pre class=\"click-to-expand-wrapper is-snippet-wrapper\"><code class=\"language-json\">{\n  \"data\": [\n    {\n      \"id\": \"0e2f…\",\n      \"name\": \"Welcome\",\n      \"is_entry_point\": true,\n      \"is_terminal\": false,\n      \"group_label\": \"Intake\"\n    }\n  ]\n}\n</code></pre>\n<p>No prompts and no transition graph are returned, which is why this needs no scope of its own: it is strictly less than a conversation already exposes. Unpaginated — a stage map is a design artefact a human maintains in a canvas, and the largest in production is well under a hundred.</p>\n<p>It sets <code>stage_id_for_contact</code> from the first non-terminal stage, so the two starting-stage requests in <strong>Contacts</strong> can run straight after this one.</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","stages"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[{"id":"b941313d-b3b6-e70d-c41d-3831b6421e4b","name":"200 — stages, entry points first","originalRequest":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/stages","description":"**Scope:** `conversations:read`\n\nEvery stage in the workspace, entry points first and then oldest-first.\n\nRead-only. Stages are authored in the Aiployee stage builder, where a stage is a system prompt plus a transition graph plus a canvas layout, and there is no useful HTTP shape for editing one. This endpoint exists so the two write fields that NAME a stage have discoverable values:\n\n| Field | Where |\n|---|---|\n| `entry_stage_id` | `POST /v1/contacts`, `PATCH /v1/contacts/{id}` — where that contact's conversations START |\n| `current_stage_id` | `PATCH /v1/conversations/{id}` — where one thread is NOW |\n\nPick a stage whose `is_terminal` is false for either: a conversation on a terminal stage has nowhere left to go.\n\n⚠️ **`is_entry_point` is not exclusive.** Several stages may carry it, and a conversation with nothing more specific to go on opens on the **oldest** of them. Never assume a single entry point.\n\nResponse shape:\n\n```json\n{\n  \"data\": [\n    {\n      \"id\": \"0e2f…\",\n      \"name\": \"Welcome\",\n      \"is_entry_point\": true,\n      \"is_terminal\": false,\n      \"group_label\": \"Intake\"\n    }\n  ]\n}\n```\n\nNo prompts and no transition graph are returned, which is why this needs no scope of its own: it is strictly less than a conversation already exposes. Unpaginated — a stage map is a design artefact a human maintains in a canvas, and the largest in production is well under a hundred.\n\nIt sets `stage_id_for_contact` from the first non-terminal stage, so the two starting-stage requests in **Contacts** can run straight after this one."},"status":"OK","code":200,"_postman_previewlanguage":"json","header":[{"value":"application/json","key":"Content-Type"},{"value":"req_2f9c1a4b7e2d4f8a","key":"X-Request-Id"}],"cookie":[],"responseTime":null,"body":"{\n  \"data\": [\n    {\n      \"id\": \"b25ef195-9e3d-4009-b6bf-bc1825f74525\",\n      \"name\": \"Welcome\",\n      \"is_entry_point\": true,\n      \"is_terminal\": false,\n      \"group_label\": \"Intake\"\n    },\n    {\n      \"id\": \"3c7a1e02-55d1-4b6f-9a10-2e8f4c6b90aa\",\n      \"name\": \"Qualify\",\n      \"is_entry_point\": false,\n      \"is_terminal\": false,\n      \"group_label\": \"Intake\"\n    },\n    {\n      \"id\": \"9f4d8b71-0c62-4e35-8d47-71b0a3e5c218\",\n      \"name\": \"Collections\",\n      \"is_entry_point\": false,\n      \"is_terminal\": false,\n      \"group_label\": \"Accounts\"\n    },\n    {\n      \"id\": \"5a1c6e34-8f70-4a92-b3d8-c04e7f91b6d3\",\n      \"name\": \"Closed won\",\n      \"is_entry_point\": false,\n      \"is_terminal\": true,\n      \"group_label\": null\n    }\n  ]\n}"}],"_postman_id":"681ac827-5bc4-85fd-aae0-bcc772fda5e2"}],"id":"dd92260f-40b3-49e9-a0eb-8bd59472094e","description":"<p>List / fetch / patch (status · assignee · stage) conversations and manage their tags.</p>\n","_postman_id":"dd92260f-40b3-49e9-a0eb-8bd59472094e","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}}},{"name":"Tags","item":[{"name":"List tags","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('data');"],"type":"text/javascript","id":"aafc7895-04ab-4760-957e-6af0ce4e02f6"},"listen":"test"}],"id":"0720a434-fdd0-4f8e-9e4e-43293e56ba02","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/tags","description":"<p>Lists every tag, system tags first. <code>?include_counts=true</code> adds per-tag conversation counts.</p>\n<p><strong>Required scope(s):</strong> <code>tags:write</code> (there is no separate tags:read scope)</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","tags"],"host":["https://app.aiployee.co.za/api"],"query":[{"disabled":true,"description":{"content":"<p>Include per-tag usage counts.</p>\n","type":"text/plain"},"key":"include_counts","value":"true"}],"variable":[]}},"response":[],"_postman_id":"0720a434-fdd0-4f8e-9e4e-43293e56ba02"},{"name":"Create a tag","event":[{"listen":"test","script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403,409]).to.include(pm.response.code); });","if (pm.response.code === 200) { var j = pm.response.json(); pm.expect(j).to.have.property('id'); pm.collectionVariables.set('last_tag_id', j.id); }"],"type":"text/javascript","id":"0153ce6a-3a1b-464c-a600-444b5255f454"}}],"id":"a8863005-6c51-4bf3-a6f9-5a6e5ff04997","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"f50ab69a-3bf9-467c-bf7a-408b4ba5857d"}],"body":{"mode":"raw","raw":"{\n  \"tag\": \"vip\",\n  \"label\": \"VIP\",\n  \"description\": \"High-value customers\",\n  \"colour\": \"#C026F2\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/tags","description":"<p>Adds a tag. <code>tag</code> is the immutable snake_case slug (<code>^[a-z][a-z0-9_]*$</code>, unique); duplicate → 409. Captures <code>last_tag_id</code>.</p>\n<p><strong>Required scope(s):</strong> <code>tags:write</code> · <strong>Idempotent</strong></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","tags"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"a8863005-6c51-4bf3-a6f9-5a6e5ff04997"},{"name":"Get a tag","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('id');"],"type":"text/javascript","id":"f15aa832-bb09-4cf2-b17b-0463e8a11fdb"},"listen":"test"}],"id":"13e68e8b-bab1-4974-9aa0-38d9fdb4d8f8","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/tags/:id","description":"<p>Fetches one tag by id.</p>\n<p><strong>Required scope(s):</strong> <code>tags:write</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","tags",":id"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"f712cb25-0c2a-422c-a3b0-4bd258ff5f0b","type":"any","key":"id"}]}},"response":[],"_postman_id":"13e68e8b-bab1-4974-9aa0-38d9fdb4d8f8"},{"name":"Delete a tag","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404,409]).to.include(pm.response.code); });"],"type":"text/javascript","id":"4ba790ae-6b1b-4cc7-b2b0-01a4d470795d"},"listen":"test"}],"id":"7ba7be52-bc59-4f10-bc96-181277396cd0","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"DELETE","header":[],"url":"https://app.aiployee.co.za/api/v1/tags/:id?confirm=true","description":"<p>Deletes a tag. <code>?confirm=true</code> strips it from any conversation still carrying it first.</p>\n<p><strong>Required scope(s):</strong> <code>tags:write</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","tags",":id"],"host":["https://app.aiployee.co.za/api"],"query":[{"description":{"content":"<p>Strip the tag from any conversation still carrying it before deleting.</p>\n","type":"text/plain"},"key":"confirm","value":"true"}],"variable":[{"id":"9c72b150-5d01-4a11-bcf3-e3d32c6fe9d5","type":"any","key":"id"}]}},"response":[],"_postman_id":"7ba7be52-bc59-4f10-bc96-181277396cd0"},{"name":"Update a tag","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('id');"],"type":"text/javascript","id":"aa953166-8430-48e6-9531-f49bff30b602"},"listen":"test"}],"id":"0a2af58b-e8e1-4bc4-8d78-c25b5d0f48bb","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"label\": \"VIP\",\n  \"description\": \"High-value customers\",\n  \"colour\": \"#C026F2\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/tags/:id","description":"<p>Updates label / description / colour. Slug and system flag are immutable.</p>\n<p><strong>Required scope(s):</strong> <code>tags:write</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","tags",":id"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"4b91e37a-0217-4292-af1f-c57d9d52f275","type":"any","key":"id"}]}},"response":[],"_postman_id":"0a2af58b-e8e1-4bc4-8d78-c25b5d0f48bb"}],"id":"685d2879-fb16-4175-a621-ed1b23b83347","description":"<p>CRUD on the tag dictionary. Reads are gated by <code>tags:write</code>.</p>\n","_postman_id":"685d2879-fb16-4175-a621-ed1b23b83347","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}}},{"name":"Attributes","item":[{"name":"List contact-field attributes","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('data');"],"type":"text/javascript","id":"b2223e87-6030-4d14-afff-e2127df15128"},"listen":"test"}],"id":"18413217-67e4-47dd-9450-5f82857fc321","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/attributes","description":"<p>Lists the contact-field dictionary. <code>is_system_managed</code> fields can't be edited/deleted; <code>is_ai_writable</code> fields may be set by the agent.</p>\n<p><strong>Required scope(s):</strong> <code>contacts:write</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","attributes"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"18413217-67e4-47dd-9450-5f82857fc321"},{"name":"Create a contact-field attribute","event":[{"listen":"test","script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403,409]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('options');"],"type":"text/javascript","id":"4f249bb4-a927-4001-87eb-a08dca9c3a82"}}],"id":"c4f24805-6608-4130-9050-4d25af811bb5","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"a3caf63d-e2ca-4798-b3a5-adc0a93c92c3"}],"body":{"mode":"raw","raw":"{\n  \"key\": \"industry\",\n  \"label\": \"Industry\",\n  \"description\": \"Primary industry vertical\",\n  \"data_type\": \"select\",\n  \"options\": [\"Fintech\", \"Retail\", \"Health\"],\n  \"is_required\": false,\n  \"is_ai_writable\": true\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/attributes","description":"<p>Adds a field to the contact-field dictionary. For select / multi_select, supply <code>options</code>. Duplicate key → 409.</p>\n<p><strong>Required scope(s):</strong> <code>contacts:write</code> · <strong>Idempotent</strong></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","attributes"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"c4f24805-6608-4130-9050-4d25af811bb5"},{"name":"Get a contact-field attribute","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('options');"],"type":"text/javascript","id":"23cb4ab0-766e-4072-9e95-b3753717fe91"},"listen":"test"}],"id":"15d60ff0-b0ec-4d94-a3e7-3a37b42c5bca","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/attributes/:id","description":"<p>Fetches one attribute by id.</p>\n<p><strong>Required scope(s):</strong> <code>contacts:write</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","attributes",":id"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"89f29734-7925-4c76-a4a5-7d91c821ccb5","type":"any","key":"id"}]}},"response":[],"_postman_id":"15d60ff0-b0ec-4d94-a3e7-3a37b42c5bca"},{"name":"Update a contact-field attribute","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('options');"],"type":"text/javascript","id":"bb6b3c1c-2a6a-44a8-a2e4-186a79e429f2"},"listen":"test"}],"id":"ca0cc441-63ed-40ef-ac60-9c89e1a6f0aa","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"label\": \"Industry\",\n  \"description\": \"Primary industry vertical\",\n  \"options\": [\"Fintech\", \"Retail\", \"Health\"],\n  \"is_required\": false,\n  \"is_ai_writable\": true,\n  \"display_order\": 3\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/attributes/:id","description":"<p>Updates label / description / options / is_required / is_ai_writable / display_order. Key and data_type are immutable; blocked on system-managed attributes.</p>\n<p><strong>Required scope(s):</strong> <code>contacts:write</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","attributes",":id"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"492f3a20-794e-44a2-a193-4bd16ce943e2","type":"any","key":"id"}]}},"response":[],"_postman_id":"ca0cc441-63ed-40ef-ac60-9c89e1a6f0aa"},{"name":"Delete a contact-field attribute","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404,409]).to.include(pm.response.code); });"],"type":"text/javascript","id":"157edb37-b6d3-4d9f-8035-29ef848dd667"},"listen":"test"}],"id":"29cadcb9-586c-4a3d-889e-caa5ed171d44","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"DELETE","header":[],"url":"https://app.aiployee.co.za/api/v1/attributes/:id?confirm=true","description":"<p>Deletes an attribute. <code>?confirm=true</code> cascades and clears any contact values referencing it.</p>\n<p><strong>Required scope(s):</strong> <code>contacts:write</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","attributes",":id"],"host":["https://app.aiployee.co.za/api"],"query":[{"description":{"content":"<p>Cascade: also clear any contact values referencing this attribute.</p>\n","type":"text/plain"},"key":"confirm","value":"true"}],"variable":[{"id":"5c5758c4-b8e6-428f-a3c3-09b9a0fc515a","type":"any","key":"id"}]}},"response":[],"_postman_id":"29cadcb9-586c-4a3d-889e-caa5ed171d44"}],"id":"3bdb93f1-43f0-489c-959f-4d961f8fab80","description":"<p>CRUD on the contact-field dictionary. Reads are gated by <code>contacts:write</code>.</p>\n","_postman_id":"3bdb93f1-43f0-489c-959f-4d961f8fab80","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}}},{"name":"Knowledge bases","item":[{"name":"List knowledge bases","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('data');"],"type":"text/javascript","id":"9e9ad3fd-5ccb-413b-a238-edadfd0365af"},"listen":"test"}],"id":"e592a25c-7fac-4259-872d-a745fda742fa","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/knowledge_bases","description":"<p>Lists every knowledge base, newest-first, with a per-row document count.</p>\n<p><strong>Required scope(s):</strong> <code>kb:read</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","knowledge_bases"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"e592a25c-7fac-4259-872d-a745fda742fa"},{"name":"Create a knowledge base","event":[{"listen":"test","script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('id');"],"type":"text/javascript","id":"b1d4b5e0-ee37-4606-94d8-e404db1076f5"}}],"id":"3197b0a4-facd-4c6c-97d4-fcfdaa821246","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"67a692f8-b20e-4dce-af50-44a1c7b7980e"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"Product docs\",\n  \"description\": \"Public product documentation\",\n  \"chunk_size\": 800,\n  \"chunk_overlap\": 100,\n  \"top_k\": 5\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/knowledge_bases","description":"<p>Creates a knowledge base; the platform mirrors it to a tenant_tools row so stage admins can attach it as a RAG tool.</p>\n<p><strong>Required scope(s):</strong> <code>kb:write</code> · <strong>Idempotent</strong></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","knowledge_bases"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"3197b0a4-facd-4c6c-97d4-fcfdaa821246"},{"name":"List documents in a knowledge base","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('data');"],"type":"text/javascript","id":"caff5b69-37a8-4ec8-aaac-a91a5e20cdb7"},"listen":"test"}],"id":"2b1d64d8-9eb2-40a7-83cb-589a23c0a9c9","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/knowledge_bases/:id/documents","description":"<p>Lists documents in the KB, newest-first, cursor-paginated.</p>\n<p><strong>Required scope(s):</strong> <code>kb:read</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","knowledge_bases",":id","documents"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"85a3317f-6abb-4440-836f-8b0cf21b1606","type":"any","key":"id"}]}},"response":[],"_postman_id":"2b1d64d8-9eb2-40a7-83cb-589a23c0a9c9"},{"name":"Search a knowledge base","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('data');"],"type":"text/javascript","id":"941f1429-a6d2-4c1b-937d-7d6d72f03b18"},"listen":"test"}],"id":"1e804ff1-6c61-4dcb-b088-bc08164d5b05","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"query\": \"How do refunds work?\",\n  \"top_k\": 5\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/knowledge_bases/:id/search","description":"<p>Vector-similarity search across the KB's document chunks. Returns the top-K passages (default 5, max 20) — the same retrieval path the agent uses for in-conversation RAG.</p>\n<p><strong>Required scope(s):</strong> <code>kb:read</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","knowledge_bases",":id","search"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"577344c3-b73d-4eb2-980d-fa0a0068b0fa","type":"any","key":"id"}]}},"response":[],"_postman_id":"1e804ff1-6c61-4dcb-b088-bc08164d5b05"},{"name":"Upload a document","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('id');"],"type":"text/javascript","id":"774e556e-6e73-4ca9-91aa-2515fa0a4574"},"listen":"test"}],"id":"961aa22d-229d-4a88-b83a-87636d645fd0","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Idempotency-Key","value":"f98c1108-2e21-4530-b600-a1e26d45f1bd"}],"body":{"mode":"formdata","formdata":[{"type":"file","key":"file","description":"<p>The document to embed (PDF, TXT, Markdown, DOCX). Select a local file in Postman.</p>\n","value":null}]},"url":"https://app.aiployee.co.za/api/v1/knowledge_bases/:id/documents","description":"<p>Uploads a document as <strong>multipart/form-data</strong> with field name <code>file</code> (PDF / TXT / Markdown / DOCX). Lands in <code>queued</code>; the process-kb cron embeds it (→ <code>processing</code> → <code>ready</code>/<code>failed</code>).</p>\n<p><strong>Required scope(s):</strong> <code>kb:write</code> · <strong>Idempotent</strong></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","knowledge_bases",":id","documents"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"d06a1f3f-5338-4fd8-8d4f-1abb2ce3e5fe","type":"any","key":"id"}]}},"response":[],"_postman_id":"961aa22d-229d-4a88-b83a-87636d645fd0"}],"id":"ca784b0e-75fa-465f-aeb6-034f34fe7a91","description":"<p>List / create KBs, list + upload documents, semantic search.</p>\n","_postman_id":"ca784b0e-75fa-465f-aeb6-034f34fe7a91","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}}},{"name":"Broadcasts","item":[{"name":"List broadcasts","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('data');"],"type":"text/javascript","id":"0017b6e4-f110-4cfe-9175-c1fd758a6716"},"listen":"test"}],"id":"44bdad4b-d084-40fc-ac3a-1feab816c84e","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/broadcasts","description":"<p>Lists broadcast campaigns, newest-first (cursor pagination). Filter by <code>status</code>.</p>\n<p><strong>Required scope(s):</strong> <code>broadcasts:read</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","broadcasts"],"host":["https://app.aiployee.co.za/api"],"query":[{"disabled":true,"description":{"content":"<p>Filter by campaign status.</p>\n","type":"text/plain"},"key":"status","value":"sent"}],"variable":[]}},"response":[],"_postman_id":"44bdad4b-d084-40fc-ac3a-1feab816c84e"},{"name":"Create a broadcast campaign","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('id');"],"type":"text/javascript","id":"2bfde37d-d727-4ebf-b6d6-602a58cd09d4"},"listen":"test"}],"id":"e8dfbc30-3713-4488-8a37-31f2cad42021","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"bbd7b04e-fb66-4b72-b196-271466843327"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"July promo blast\",\n  \"wa_number_id\": \"\",\n  \"template_id\": \"\",\n  \"template_variables\": {\n    \"1\": { \"text\": \"Ayanda\" },\n    \"2\": { \"attribute_key\": \"city\" }\n  },\n  \"audience_filter\": { \"tags\": [\"vip\"], \"opt_in_status\": \"opted_in\" },\n  \"scheduled_at\": null,\n  \"entry_stage_id\": null\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/broadcasts","description":"<p>Creates a broadcast targeting an approved template. <code>audience_filter</code> scopes recipients (opt-in always enforced server-side). Omit <code>scheduled_at</code> for a <code>draft</code>.</p>\n<p><strong>Required scope(s):</strong> <code>broadcasts:write</code> · <strong>Idempotent</strong></p>\n<blockquote>\n<p><strong>Template variables — the map shape.</strong> <code>template_variables</code> is an index→<code>{ text | attribute_key }</code> map (NOT the Meta <code>components</code> array that <code>POST /v1/messages</code> template uses).</p>\n</blockquote>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","broadcasts"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"e8dfbc30-3713-4488-8a37-31f2cad42021"},{"name":"Get a broadcast","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('id');"],"type":"text/javascript","id":"5b9d4de9-ecba-4728-bdde-b3a7ed54d046"},"listen":"test"}],"id":"f2240cf2-cb91-49f4-8015-d5f20be457b5","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/broadcasts/:id","description":"<p>Fetches one campaign by id, including counters (sent / delivered / failed) and the audience filter / template variables it was created with.</p>\n<p><strong>Required scope(s):</strong> <code>broadcasts:read</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","broadcasts",":id"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"a1a426ff-f928-4429-b590-11322c319775","type":"any","key":"id"}]}},"response":[],"_postman_id":"f2240cf2-cb91-49f4-8015-d5f20be457b5"},{"name":"Schedule or unschedule a broadcast","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('id');"],"type":"text/javascript","id":"fedfd25c-cb5d-4ebd-8e5d-991a83ae7c5a"},"listen":"test"}],"id":"f57a6999-79c7-4185-aebe-d60f03e9b079","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"scheduled_at\": \"2026-07-15T08:00:00Z\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/broadcasts/:id/schedule","description":"<p>Sets <code>scheduled_at</code> on a draft / scheduled broadcast. Pass <code>null</code> to clear it and return to <code>draft</code>. Fails on already sending / completed / cancelled campaigns.</p>\n<p><strong>Required scope(s):</strong> <code>broadcasts:write</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","broadcasts",":id","schedule"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"3ca68fcf-d371-4e6f-997c-c0bf107ea6a9","type":"any","key":"id"}]}},"response":[],"_postman_id":"f57a6999-79c7-4185-aebe-d60f03e9b079"},{"name":"Cancel a broadcast","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('id');"],"type":"text/javascript","id":"5a2c2a2b-31b3-4731-842f-1106cfd7c4f2"},"listen":"test"}],"id":"8733e117-eab3-4e55-b463-9b6b26110b27","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[],"url":"https://app.aiployee.co.za/api/v1/broadcasts/:id/cancel","description":"<p>Cancels a draft, scheduled, or in-flight broadcast. Pending recipients flip to <code>skipped</code> (reason \"Broadcast cancelled\"). Final state is sticky.</p>\n<p><strong>Required scope(s):</strong> <code>broadcasts:write</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","broadcasts",":id","cancel"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"e118ef2f-9c4c-48d1-a072-9b198cfb8991","type":"any","key":"id"}]}},"response":[],"_postman_id":"8733e117-eab3-4e55-b463-9b6b26110b27"},{"name":"List broadcast recipients","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('data');"],"type":"text/javascript","id":"8259bd70-d153-4a89-8b69-ea0a0f0ff8c1"},"listen":"test"}],"id":"95152bac-c20b-471e-9ec8-e7580ce6e8fa","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/broadcasts/:id/recipients","description":"<p>Lists the campaign's recipients + per-recipient delivery status (cursor pagination). Filter by <code>status</code>.</p>\n<p><strong>Required scope(s):</strong> <code>broadcasts:read</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","broadcasts",":id","recipients"],"host":["https://app.aiployee.co.za/api"],"query":[{"disabled":true,"description":{"content":"<p>Filter recipients by delivery status.</p>\n","type":"text/plain"},"key":"status","value":"delivered"}],"variable":[{"id":"84a0aa87-2b5c-4176-aa3e-989e4c36015e","type":"any","key":"id"}]}},"response":[],"_postman_id":"95152bac-c20b-471e-9ec8-e7580ce6e8fa"},{"name":"Create a broadcast — created range, reply exclusion, recipient tag","event":[{"script":{"type":"text/javascript","exec":["pm.test('Created', function () { pm.expect(pm.response.code).to.be.oneOf([200, 201]); });","var b = pm.response.json();","var d = b.data || b;","pm.test('Status is draft', function () { pm.expect(d.status).to.eql('draft'); });","pm.test('recipient_tag is a string or null', function () { pm.expect(d).to.have.property('recipient_tag'); pm.expect(d.recipient_tag === null || typeof d.recipient_tag === 'string').to.be.true; });","pm.test('Audience filter echoes the new fields', function () {","    pm.expect(d.audience_filter).to.have.property('created_from', '2026-10-18');","    pm.expect(d.audience_filter).to.have.property('created_to', '2026-10-23');","    pm.expect(d.audience_filter).to.have.property('exclude_replied_active_conversation', true);","});"]},"listen":"test"}],"id":"c314096f-99dc-ef86-e393-f12006e859df","request":{"method":"POST","header":[{"value":"application/json","key":"Content-Type"},{"value":"f2b45ac3-9cda-4008-b13a-1ba877257cd7","key":"Idempotency-Key"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"Pre-approval follow-up — wave 1\",\n  \"wa_number_id\": \"\",\n  \"template_id\": \"\",\n  \"audience_filter\": {\n    \"attribute_conditions\": [\n      { \"key\": \"customer_status\", \"op\": \"eq\", \"value\": \"Sent Pre approval\" }\n    ],\n    \"created_from\": \"2026-10-18\",\n    \"created_to\": \"2026-10-23\",\n    \"exclude_replied_active_conversation\": true\n  },\n  \"template_variables\": {\n    \"body\": { \"1\": { \"kind\": \"attr\", \"key\": \"first_name\" } }\n  },\n  \"entry_stage_id\": null,\n  \"recipient_tag\": \"campaign_preapproval\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/broadcasts","description":"<p>Scope: <code>broadcasts:write</code>. Creates a <strong>draft</strong> campaign; nothing is sent until it is scheduled or started.</p>\n<p>New audience filters:</p>\n<ul>\n<li><code>created_from</code> / <code>created_to</code> — inclusive range on when the contact was added. A bare date (<code>YYYY-MM-DD</code>) is a whole day in <strong>Africa/Johannesburg</strong> time; a datetime must carry an offset (<code>2026-10-18T08:00:00+02:00</code>) and is that exact instant.</li>\n<li><code>exclude_replied_active_conversation</code> — leaves out anybody who has written at least once in an open conversation (<code>agent_active</code>, <code>pending_handoff</code>, <code>human_active</code>). A thread holding only your own outbound messages does not count.</li>\n</ul>\n<p><code>recipient_tag</code> (optional, top level) — a tag slug that must already exist in the workspace. It is added to the conversation each recipient's template went into, after a successful send only. An unknown tag is refused with <code>400 invalid_request</code>.</p>\n<p>Set <code>recipient_tag</code> to a tag you have created (see <em>Create a tag</em>), or remove it.</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","broadcasts"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"c314096f-99dc-ef86-e393-f12006e859df"}],"id":"ed55b97f-c55a-460f-9fc9-94637f47ec88","description":"<p>Create / list / fetch / schedule / cancel campaigns + inspect recipients.</p>\n","_postman_id":"ed55b97f-c55a-460f-9fc9-94637f47ec88","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}}},{"name":"Analytics","item":[{"name":"Usage summary","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('range');"],"type":"text/javascript","id":"490dcfc9-ecf9-40d7-be97-bd1461690893"},"listen":"test"}],"id":"6531a0fc-df8b-4feb-bfcd-9f8cabc14417","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/analytics/usage?from=2026-06-01&to=2026-06-30","description":"<p>Conversation / message / handoff volumes over a date range. Defaults to last 30 days.</p>\n<p><strong>Required scope(s):</strong> <code>analytics:read</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","analytics","usage"],"host":["https://app.aiployee.co.za/api"],"query":[{"description":{"content":"<p>Range start (YYYY-MM-DD).</p>\n","type":"text/plain"},"key":"from","value":"2026-06-01"},{"description":{"content":"<p>Range end (YYYY-MM-DD).</p>\n","type":"text/plain"},"key":"to","value":"2026-06-30"},{"disabled":true,"description":{"content":"<p>Optional whatsapp_numbers.id filter.</p>\n","type":"text/plain"},"key":"number_id","value":""}],"variable":[]}},"response":[],"_postman_id":"6531a0fc-df8b-4feb-bfcd-9f8cabc14417"},{"name":"Cost summary","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('daily');"],"type":"text/javascript","id":"6f918995-8e94-4e86-9e60-d4bcfbf00f2f"},"listen":"test"}],"id":"a9c74b50-611f-4e2f-9a1d-490dd1a368a7","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/analytics/cost?from=2026-06-01&to=2026-06-30","description":"<p>Daily token + cost rollups (input / output / cache-read / cache-create). Same numbers as the /analytics Cost tab.</p>\n<p><strong>Required scope(s):</strong> <code>analytics:read</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","analytics","cost"],"host":["https://app.aiployee.co.za/api"],"query":[{"description":{"content":"<p>Range start (YYYY-MM-DD).</p>\n","type":"text/plain"},"key":"from","value":"2026-06-01"},{"description":{"content":"<p>Range end (YYYY-MM-DD).</p>\n","type":"text/plain"},"key":"to","value":"2026-06-30"}],"variable":[]}},"response":[],"_postman_id":"a9c74b50-611f-4e2f-9a1d-490dd1a368a7"},{"name":"Messages summary","event":[{"listen":"test","script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('totals');"],"type":"text/javascript","id":"2aec58f0-3278-47e1-b7ff-0f12e27618b8"}}],"id":"2f82e7bc-24ec-4e4c-92ce-8038525a6aa1","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/analytics/messages_summary?from=2026-06-01&to=2026-06-30","description":"<p>Inbound / outbound message totals + daily breakdown over a date range.</p>\n<p><strong>Required scope(s):</strong> <code>analytics:read</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","analytics","messages_summary"],"host":["https://app.aiployee.co.za/api"],"query":[{"description":{"content":"<p>Range start (YYYY-MM-DD).</p>\n","type":"text/plain"},"key":"from","value":"2026-06-01"},{"description":{"content":"<p>Range end (YYYY-MM-DD).</p>\n","type":"text/plain"},"key":"to","value":"2026-06-30"},{"disabled":true,"description":{"content":"<p>Optional whatsapp_numbers.id filter.</p>\n","type":"text/plain"},"key":"number_id","value":""}],"variable":[]}},"response":[],"_postman_id":"2f82e7bc-24ec-4e4c-92ce-8038525a6aa1"},{"name":"Stage funnel","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('data');"],"type":"text/javascript","id":"e4a42a77-3362-4c42-b666-1e7262b8aa5d"},"listen":"test"}],"id":"082a0dd1-e13a-47fe-86a1-cd109ea9ea1d","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/analytics/stage_funnel?from=2026-06-01&to=2026-06-30","description":"<p>Per-stage enter / complete / drop-off counts, avg minutes-in-stage, most common downstream transition.</p>\n<p><strong>Required scope(s):</strong> <code>analytics:read</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","analytics","stage_funnel"],"host":["https://app.aiployee.co.za/api"],"query":[{"description":{"content":"<p>Range start (YYYY-MM-DD).</p>\n","type":"text/plain"},"key":"from","value":"2026-06-01"},{"description":{"content":"<p>Range end (YYYY-MM-DD).</p>\n","type":"text/plain"},"key":"to","value":"2026-06-30"}],"variable":[]}},"response":[],"_postman_id":"082a0dd1-e13a-47fe-86a1-cd109ea9ea1d"}],"id":"464b64bc-a674-4d30-830e-819c087083b3","description":"<p>Read-only usage · cost · messages · stage-funnel summaries.</p>\n","_postman_id":"464b64bc-a674-4d30-830e-819c087083b3","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}}},{"name":"Webhooks","item":[{"name":"List webhook subscriptions","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('data');"],"type":"text/javascript","id":"a32d4f9b-9fc4-46a9-9875-c76a42b23a91"},"listen":"test"}],"id":"b897141f-d294-47fe-8ef5-fa4c20f96abd","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/webhooks","description":"<p>All webhook subscriptions for the tenant, newest-first. Secrets are never returned by list / get.</p>\n<p><strong>Required scope(s):</strong> <code>webhooks:manage</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","webhooks"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"b897141f-d294-47fe-8ef5-fa4c20f96abd"},{"name":"Create a webhook subscription","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('events');"],"type":"text/javascript","id":"8160168a-aaec-4b80-b372-2a2f62dc7d3c"},"listen":"test"}],"id":"40110435-fbd3-4f5d-bd21-7805596f75b7","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"b1cfc95b-542b-45e3-a6eb-ff24ed76c31d"}],"body":{"mode":"raw","raw":"{\n  \"url\": \"https://your-server.example.com/webhooks/aiployee\",\n  \"events\": [\"message.inbound\", \"message.outbound\"],\n  \"is_active\": true\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/webhooks","description":"<p>Creates a subscription. The response includes the signing <code>secret</code> <strong>once</strong> (used to verify the <code>X-Aiployee-Signature</code> HMAC on deliveries) — store it now.</p>\n<p><strong>Required scope(s):</strong> <code>webhooks:manage</code> · <strong>Idempotent</strong></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","webhooks"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"40110435-fbd3-4f5d-bd21-7805596f75b7"},{"name":"Fetch a webhook subscription","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('events');"],"type":"text/javascript","id":"4e1e65f9-acd7-4e90-b6ff-d9d9efb5d255"},"listen":"test"}],"id":"731211e8-377b-4ee7-b91c-92221cde5bcd","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/webhooks/:id","description":"<p>Returns the subscription metadata; the secret is never exposed here.</p>\n<p><strong>Required scope(s):</strong> <code>webhooks:manage</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","webhooks",":id"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"4c9f9b8a-32a8-4967-a667-838e780fbf96","type":"any","key":"id"}]}},"response":[],"_postman_id":"731211e8-377b-4ee7-b91c-92221cde5bcd"},{"name":"Update a webhook subscription","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('events');"],"type":"text/javascript","id":"78761fb5-ed83-4d0d-aa4e-0e4d6274fc8b"},"listen":"test"}],"id":"d8104dcc-a807-4ebf-9470-c952e669f3da","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"url\": \"https://your-server.example.com/webhooks/aiployee\",\n  \"events\": [\"message.inbound\", \"message.outbound\"],\n  \"is_active\": true\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/webhooks/:id","description":"<p>Patches <code>url</code>, <code>events</code>, and/or <code>is_active</code>. The secret isn't changed here — use the rotate endpoint.</p>\n<p><strong>Required scope(s):</strong> <code>webhooks:manage</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","webhooks",":id"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"152f0bc8-5f0d-40d7-8836-37f9d6f3dffb","type":"any","key":"id"}]}},"response":[],"_postman_id":"d8104dcc-a807-4ebf-9470-c952e669f3da"},{"name":"Delete a webhook subscription","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });"],"type":"text/javascript","id":"dc4e060d-8d56-4ba0-977e-1410eb91fd4e"},"listen":"test"}],"id":"53d58f81-74c8-4b8f-9247-7c332c3a6112","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"DELETE","header":[],"url":"https://app.aiployee.co.za/api/v1/webhooks/:id","description":"<p>Permanently removes the subscription. Existing delivery rows are cascaded.</p>\n<p><strong>Required scope(s):</strong> <code>webhooks:manage</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","webhooks",":id"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"1d43db11-be8d-423f-9b23-9de98b45030e","type":"any","key":"id"}]}},"response":[],"_postman_id":"53d58f81-74c8-4b8f-9247-7c332c3a6112"},{"name":"List webhook deliveries","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('data');"],"type":"text/javascript","id":"b647d9ee-cbc2-4faa-9f02-2bc10c9c3bec"},"listen":"test"}],"id":"5630fe4e-ff10-49a2-a78f-3f461658477b","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/webhooks/:id/deliveries","description":"<p>The delivery log for a subscription (attempts, response codes, next-retry), newest-first, cursor-paginated.</p>\n<p><strong>Required scope(s):</strong> <code>webhooks:manage</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","webhooks",":id","deliveries"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"1c0bd32a-b508-427a-9756-585bcdf6555a","type":"any","key":"id"}]}},"response":[],"_postman_id":"5630fe4e-ff10-49a2-a78f-3f461658477b"},{"name":"Rotate signing secret","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('secret');"],"type":"text/javascript","id":"042ff64b-b1f2-436c-9f02-a07f9f633fe3"},"listen":"test"}],"id":"07f5e008-5842-4ca9-aeee-4b06a215277a","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[],"url":"https://app.aiployee.co.za/api/v1/webhooks/:id/rotate-secret","description":"<p>Generates a new signing secret and returns it <strong>once</strong>. The old secret stops verifying immediately — update your endpoint before rotating.</p>\n<p><strong>Required scope(s):</strong> <code>webhooks:manage</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","webhooks",":id","rotate-secret"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"6714595f-d986-45c9-9294-fc921c764f0a","type":"any","key":"id"}]}},"response":[],"_postman_id":"07f5e008-5842-4ca9-aeee-4b06a215277a"},{"name":"Replay a delivery","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });"],"type":"text/javascript","id":"94776534-4a63-4593-a7a0-c4e4ac623a99"},"listen":"test"}],"id":"0156ed5a-6bbb-4ca1-850c-34d2351fb450","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[],"url":"https://app.aiployee.co.za/api/v1/webhooks/:id/deliveries/:deliveryId/replay","description":"<p>Re-dispatches the original payload starting fresh at attempt 1 — useful after fixing a misconfigured endpoint.</p>\n<p><strong>Required scope(s):</strong> <code>webhooks:manage</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","webhooks",":id","deliveries",":deliveryId","replay"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"fb36bfae-429a-4dce-8633-03fd98372fde","type":"any","key":"id"},{"id":"fab0235c-bcdf-46b7-83f0-bfad46bdf644","type":"any","key":"deliveryId"}]}},"response":[],"_postman_id":"0156ed5a-6bbb-4ca1-850c-34d2351fb450"}],"id":"9397dbc6-2d52-4fd6-bdd8-c5ba138b670a","description":"<p>CRUD on outbound webhook subscriptions + delivery log, secret rotation, replay.</p>\n","_postman_id":"9397dbc6-2d52-4fd6-bdd8-c5ba138b670a","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}}},{"name":"Tools","item":[{"name":"List tools","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('data');"],"type":"text/javascript","id":"944513fc-8ebc-4d26-a019-15df3a7a5717"},"listen":"test"}],"id":"69f0acae-7cf9-473f-ad43-69f12810ab55","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/tools","description":"<p>Lists the workspace's tools (HTTP API + RAG). Filter by <code>active</code>, <code>tool_type</code>.</p>\n<p><strong>Required scope(s):</strong> <code>tools:invoke</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","tools"],"host":["https://app.aiployee.co.za/api"],"query":[{"disabled":true,"description":{"content":"<p>Filter by active state.</p>\n","type":"text/plain"},"key":"active","value":"true"},{"disabled":true,"description":{"content":"<p>Filter by tool type (http | rag).</p>\n","type":"text/plain"},"key":"tool_type","value":"rag"}],"variable":[]}},"response":[],"_postman_id":"69f0acae-7cf9-473f-ad43-69f12810ab55"},{"name":"Invoke a tool","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.be.an('object');"],"type":"text/javascript","id":"b88bf90d-ce96-4fde-8673-bc37312a52ec"},"listen":"test"}],"id":"e5df2efe-4c97-43b1-9fa0-d232b98b19e0","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"5cf7e82d-7917-44ac-9f64-39ca76d7f4cc"}],"body":{"mode":"raw","raw":"{\n  \"input\": {\n    \"query\": \"How do refunds work?\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/tools/:id/invoke","description":"<p><strong>POST /v1/tools/{id}/invoke</strong> — programmatically calls a tenant tool. The <code>{id}</code> is the <strong>tenant tool</strong> id (the one <code>GET /v1/tools</code> returns), not an <code>api_tools</code> id.</p>\n<p><strong>Scope:</strong> <code>tools:invoke</code>.</p>\n<ul>\n<li><strong>HTTP API tools</strong> require <code>conversation_id</code> — the tool runs against that conversation's contact, and <code>response_mapping</code> writes attributes back onto it.</li>\n<li><strong>RAG tools</strong> take <code>{ query, top_k? }</code> in <code>input</code> and return matching passages as JSON in <code>response_summary</code>.</li>\n<li><strong>OAuth connection tools</strong> (Google Calendar / Drive / Sheets) run against the workspace's connected account and return the provider result as JSON in <code>response_summary</code>. A token failure returns <code>ok:false</code> with <code>status</code> <code>401</code> (needs reconnect) or <code>502</code>.</li>\n</ul>\n<h2 id=\"pre-mapped-contact-values\">Pre-mapped contact values</h2>\n<p>An HTTP API tool can be configured to send values the workspace <strong>already holds</strong>, rather than asking the caller (or the agent) to supply them:</p>\n<pre class=\"click-to-expand-wrapper is-snippet-wrapper\"><code>{{contact.phone_number}}     +27…\n{{contact.phone_digits}}     27…       for APIs that reject the +\n{{contact.display_name}}\n{{contact.attr.&lt;key&gt;}}       any contact field in the workspace\n</code></pre><p>They resolve against the contact on <code>conversation_id</code> and are <strong>never part of <code>input</code></strong> — a fixed value is invisible to the caller by design, which is the point: it cannot be mis-formatted or invented.</p>\n<p>⚠ <strong>If a pre-mapped value cannot be resolved for that contact, the call is REFUSED</strong> — <code>ok:false</code>, with a message naming the token. It is never sent as an empty value and never as the literal text <code>{{contact.phone_number}}</code>. This is reachable in normal operation: a web-chat, Instagram, Messenger or Telegram contact has no phone number.</p>\n<p>A parameter <strong>default</strong> behaves differently on a miss — it falls through, so <code>input</code> can supply the value instead. That is the difference between \"always the contact's\" (a fixed value) and \"the contact's unless you say otherwise\" (a default).</p>\n<p>⚠ <code>{{contact.email}}</code> does not exist — a contact has no email column. Store an address as a contact field and address it as <code>{{contact.attr.&lt;key&gt;}}</code>.</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","tools",":id","invoke"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"98b3f72b-e419-4429-80bb-09cd45833407","type":"any","key":"id"}]}},"response":[],"_postman_id":"e5df2efe-4c97-43b1-9fa0-d232b98b19e0"},{"name":"Invoke a Google connection tool (OAuth)","event":[{"listen":"test","script":{"type":"text/javascript","exec":["pm.test('HTTP 200 (well-formed invoke)', function () { pm.response.to.have.status(200); });","var b = pm.response.json();","pm.test('Result envelope shape', function () {","  pm.expect(b).to.have.property('ok');","  pm.expect(b).to.have.property('response_summary');","});","if (b.ok) {","  pm.test('response_summary is JSON', function () { JSON.parse(b.response_summary); });","} else {","  pm.test('Failure carries status 401 (reconnect) or 502 (transient)', function () {","    pm.expect([401, 502]).to.include(b.status);","  });","}"]}}],"id":"f2aa16ce-465f-e224-8fab-0abebc0e328d","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"value":"689cd960-1e67-478f-b4f3-4a1e53a0fd23","key":"Idempotency-Key","description":"<p>Optional replay-safe key.</p>\n"}],"body":{"mode":"raw","raw":"{\n  \"input\": {\n    \"time_min\": \"2026-07-25T08:00:00+02:00\",\n    \"time_max\": \"2026-07-25T17:00:00+02:00\",\n    \"calendar_id\": \"primary\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/tools/{{google_tool_id}}/invoke","description":"<p>Invoke an <strong>OAuth connection tool</strong> (CM-2) — the tenant's connected <strong>Google</strong> account runs the action and the provider result is returned as JSON in <code>response_summary</code>.</p>\n<p><strong>Scope:</strong> <code>tools:invoke</code>.</p>\n<p><strong>Setup:</strong></p>\n<ol>\n<li>Connect Google in the app: <em>Settings → Connections → Connect Google</em> (owner/admin). This provisions one <code>tenant_tools</code> row per Google action.</li>\n<li><code>GET /v1/tools?tool_type=oauth</code> to list them; copy the id of the tool you want (e.g. <code>google_calendar_check_availability</code>) into the <code>google_tool_id</code> collection/environment variable.</li>\n</ol>\n<p><strong>Google tools provisioned (least-privilege scopes):</strong></p>\n<ul>\n<li><code>google_calendar_check_availability</code> — <code>{ time_min, time_max, calendar_id? }</code> (freebusy)</li>\n<li><code>google_calendar_create_event</code> — <code>{ summary, start, end, time_zone?, location?, attendees?, calendar_id? }</code></li>\n<li><code>google_drive_search</code> — <code>{ query, page_size? }</code> (per-file access — only files this app created/opened)</li>\n<li><code>google_drive_read_file</code> — <code>{ file_id }</code></li>\n<li><code>google_sheets_read</code> — <code>{ spreadsheet_id, range }</code></li>\n<li><code>google_sheets_append</code> — <code>{ spreadsheet_id, range, values }</code></li>\n</ul>\n<p>The body below targets <code>google_calendar_check_availability</code>; swap <code>input</code> for the tool you set in <code>google_tool_id</code>.</p>\n<p><strong>Result envelope</strong> (same shape as every tool invoke): <code>{ ok, status, response_summary, attributes_updated, attributes_blocked, error? }</code>. <code>response_summary</code> is the JSON-stringified provider result. A <strong>token/connection failure</strong> returns <code>ok:false</code> with <code>status</code> <strong>401</strong> (the connection needs reconnecting in Settings → Connections) or <strong>502</strong> (transient provider error) — the HTTP request itself is still <code>200</code> because the call was well-formed; check <code>ok</code>.</p>\n<p>OAuth tools do not write contact attributes, so <code>attributes_updated</code> / <code>attributes_blocked</code> are always empty.</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","tools","{{google_tool_id}}","invoke"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"f2aa16ce-465f-e224-8fab-0abebc0e328d"}],"id":"47727588-e046-4870-ad7b-aff3cf3eace2","description":"<p>List and programmatically invoke HTTP API / RAG tools.</p>\n","_postman_id":"47727588-e046-4870-ad7b-aff3cf3eace2","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}}},{"name":"Team","item":[{"name":"List members + invitations","event":[{"listen":"test","script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('members');","","// API-9 — `assignable` says who may OWN a contact.","if (pm.response.code === 200) {","    var team = pm.response.json();","","    pm.test('every member carries assignable:boolean', function () {","        pm.expect(team.members).to.be.an('array').that.is.not.empty;","        team.members.forEach(function (m) {","            pm.expect(m, m.email).to.have.property('assignable');","            pm.expect(m.assignable, m.email).to.be.a('boolean');","        });","    });","","    // assignable must track the ROLE, not be a constant. A field that is always","    // true passes a presence check and tells a client nothing.","    pm.test('assignable agrees with the role on every member', function () {","        team.members.forEach(function (m) {","            pm.expect(m.assignable, m.email + ' is ' + m.role)","              .to.equal(['owner', 'admin', 'operator'].indexOf(m.role) !== -1);","        });","    });","","    pm.test('analysts are never assignable', function () {","        team.members.filter(function (m) { return m.role === 'analyst'; })","            .forEach(function (m) { pm.expect(m.assignable, m.email).to.be.false; });","    });","","    // Capture the ids the Contacts requests need, so no uuid is ever pasted by","    // hand. Run this request before the Contacts folder.","    var ok = team.members.find(function (m) { return m.assignable; });","    if (ok) pm.environment.set('assignable_user_id', ok.user_id);","    var no = team.members.find(function (m) { return !m.assignable; });","    pm.environment.set('analyst_user_id', no ? no.user_id : '');","}"],"type":"text/javascript"}}],"id":"f429f485-7979-49db-a8ab-1e82f2bc3ebb","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/team","description":"<p>Returns active members, pending invitations, and the seat count / plan cap.</p>\n<p><strong><code>assignable</code> (API-9)</strong> — each member carries <code>assignable: boolean</code>, <code>true</code> when that member may <strong>own</strong> a contact. Pass their <code>user_id</code> as <code>assigned_operator_id</code> on <code>POST /v1/contacts</code> or <code>PATCH /v1/contacts/{id}</code>.</p>\n<p>Owners, admins and operators are assignable. <strong>Analysts are not</strong>, because their read-only access to conversations means an assignment would queue work with somebody who cannot answer it. Read the flag rather than hardcoding <code>[\"owner\",\"admin\",\"operator\"]</code> — the flag is derived from the platform's own predicate, so your integration cannot drift from it if the rule ever changes.</p>\n<p>⚠ The column this feeds is called <code>assigned_operator_id</code>, where \"operator\" means <em>the person handling the contact</em>, <strong>not</strong> the role named <code>operator</code>. The name carries only one of the two meanings and misleads; <code>assignable</code> is the authority.</p>\n<p><strong>Required scope(s):</strong> <code>team:read</code> — <strong>new in API-9.</strong> A key holding <code>team:manage</code> also satisfies it, so keys issued before the split keep working unchanged: the implication is resolved once, on the key's <em>granted</em> scopes, at authentication. An integration that only needs to discover who can own a contact no longer has to be handed the ability to invite and remove teammates.</p>\n<p>Every mutation in this folder (invite, role change, remove, cancel, resend) still requires <code>team:manage</code>.</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","team"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[{"id":"cf072953-a2e8-361e-f7a7-50d6b4a8c91f","name":"200 — members with assignable","originalRequest":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/team","description":"Returns active members, pending invitations, and the seat count / plan cap.\n\n**`assignable` (API-9)** — each member carries `assignable: boolean`, `true` when that member may **own** a contact. Pass their `user_id` as `assigned_operator_id` on `POST /v1/contacts` or `PATCH /v1/contacts/{id}`.\n\nOwners, admins and operators are assignable. **Analysts are not**, because their read-only access to conversations means an assignment would queue work with somebody who cannot answer it. Read the flag rather than hardcoding `[\"owner\",\"admin\",\"operator\"]` — the flag is derived from the platform's own predicate, so your integration cannot drift from it if the rule ever changes.\n\n⚠ The column this feeds is called `assigned_operator_id`, where \"operator\" means *the person handling the contact*, **not** the role named `operator`. The name carries only one of the two meanings and misleads; `assignable` is the authority.\n\n**Required scope(s):** `team:read` — **new in API-9.** A key holding `team:manage` also satisfies it, so keys issued before the split keep working unchanged: the implication is resolved once, on the key's *granted* scopes, at authentication. An integration that only needs to discover who can own a contact no longer has to be handed the ability to invite and remove teammates.\n\nEvery mutation in this folder (invite, role change, remove, cancel, resend) still requires `team:manage`."},"status":"OK","code":200,"_postman_previewlanguage":"json","header":[{"value":"application/json","key":"Content-Type"}],"cookie":[],"responseTime":null,"body":"{\n  \"members\": [\n    {\n      \"id\": \"3f1c8a90-0000-4000-8000-00000000a001\",\n      \"user_id\": \"f2e432bb-e291-4a3c-b8ff-0c1710514600\",\n      \"email\": \"owner@example.com\",\n      \"full_name\": \"Example Owner\",\n      \"role\": \"owner\",\n      \"created_at\": \"2026-08-07T09:32:09.000Z\",\n      \"last_active_at\": \"2026-08-07T10:01:44.000Z\",\n      \"assignable\": true\n    },\n    {\n      \"id\": \"3f1c8a90-0000-4000-8000-00000000a002\",\n      \"user_id\": \"44bd199f-eaab-4cb4-acc2-2d29611857ec\",\n      \"email\": \"operator@example.com\",\n      \"full_name\": \"Example Operator\",\n      \"role\": \"operator\",\n      \"created_at\": \"2026-08-07T09:32:09.000Z\",\n      \"last_active_at\": null,\n      \"assignable\": true\n    },\n    {\n      \"id\": \"3f1c8a90-0000-4000-8000-00000000a003\",\n      \"user_id\": \"e4aeb840-ff91-485d-b59c-4f37eb471a3d\",\n      \"email\": \"analyst@example.com\",\n      \"full_name\": \"Example Analyst\",\n      \"role\": \"analyst\",\n      \"created_at\": \"2026-08-07T09:32:09.000Z\",\n      \"last_active_at\": null,\n      \"assignable\": false\n    }\n  ],\n  \"invitations\": [],\n  \"seats\": {\n    \"members\": 3,\n    \"pending_invites\": 0,\n    \"total\": 3\n  }\n}"}],"_postman_id":"f429f485-7979-49db-a8ab-1e82f2bc3ebb"},{"name":"Invite a teammate","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403,409]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.be.an('object');"],"type":"text/javascript","id":"62a1e816-be14-4e2f-818d-fe15e4836f28"},"listen":"test"}],"id":"4a57c880-874a-4543-a0c7-157f22aa94fa","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"4000a3c0-8082-4965-b66d-095a7e902ce8"}],"body":{"mode":"raw","raw":"{\n  \"email\": \"teammate@example.com\",\n  \"role\": \"operator\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/team","description":"<p>Sends an invitation email with an accept token. <code>role</code> ∈ admin | operator | analyst (owners aren't invited via this path). Enforces the plan's seat cap; already-a-member → 409.</p>\n<p><strong>Required scope(s):</strong> <code>team:manage</code> · <strong>Idempotent</strong></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","team"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"4a57c880-874a-4543-a0c7-157f22aa94fa"},{"name":"Change a teammate's role","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403,404,409]).to.include(pm.response.code); });"],"type":"text/javascript","id":"c77fde4f-ab64-4ba1-8e47-5f16d889263a"},"listen":"test"}],"id":"a52d3ce6-d4d3-4ab8-9b5d-07c7cb6d6241","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"role\": \"admin\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/team/members/:userId","description":"<p>Updates a membership's role. Valid roles: <code>owner</code>, <code>admin</code>, <code>operator</code>, <code>analyst</code>. Promotion to <code>owner</code> and the last-owner guard are enforced server-side.</p>\n<p><strong>Required scope(s):</strong> <code>team:manage</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","team","members",":userId"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"64f3dd6f-5857-4851-8f69-6dfa8bb2cc14","type":"any","key":"userId"}]}},"response":[],"_postman_id":"a52d3ce6-d4d3-4ab8-9b5d-07c7cb6d6241"},{"name":"Remove a teammate","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404,409]).to.include(pm.response.code); });"],"type":"text/javascript","id":"b13c3d2d-fcd7-4e6a-84c3-7f38671d2b7b"},"listen":"test"}],"id":"f42b2d9a-796e-4fa7-88f0-c711bbe50b27","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"DELETE","header":[],"url":"https://app.aiployee.co.za/api/v1/team/members/:userId","description":"<p>Removes the named teammate's membership. Owners can only be removed by other owners; the last owner is protected.</p>\n<p><strong>Required scope(s):</strong> <code>team:manage</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","team","members",":userId"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"c599a2a0-ac26-452e-9e9d-c9981cba63d6","type":"any","key":"userId"}]}},"response":[],"_postman_id":"f42b2d9a-796e-4fa7-88f0-c711bbe50b27"},{"name":"Cancel a pending invitation","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });"],"type":"text/javascript","id":"a7028c9d-91d4-4705-9f4d-71e4514a2081"},"listen":"test"}],"id":"92c724ef-763e-4833-9da3-371b6eb6b62f","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"DELETE","header":[],"url":"https://app.aiployee.co.za/api/v1/team/invitations/:id","description":"<p>Cancels a pending invitation. Idempotent — already-cancelled → 200.</p>\n<p><strong>Required scope(s):</strong> <code>team:manage</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","team","invitations",":id"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"06bf8a5b-4700-4d68-be20-1fc8cf0e3e4b","type":"any","key":"id"}]}},"response":[],"_postman_id":"92c724ef-763e-4833-9da3-371b6eb6b62f"},{"name":"Resend a pending invitation","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404,409]).to.include(pm.response.code); });"],"type":"text/javascript","id":"b8114f0f-6cb3-49c5-a0e2-be3c2b4d22ef"},"listen":"test"}],"id":"61fc8444-d739-4ae8-bbb0-e9534f956cc9","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[],"url":"https://app.aiployee.co.za/api/v1/team/invitations/:id/resend","description":"<p>Reissues the invitation email with a fresh accept token. Throttled to one send per 5 min per invitation (sooner → 409).</p>\n<p><strong>Required scope(s):</strong> <code>team:manage</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","team","invitations",":id","resend"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"4e8f30f2-f370-4554-9e05-89d32089e178","type":"any","key":"id"}]}},"response":[],"_postman_id":"61fc8444-d739-4ae8-bbb0-e9534f956cc9"}],"id":"0059abdf-5f3f-4027-8ba9-cbb72c41fb71","description":"<p>List members + invitations, invite, change role, remove, cancel / resend invitations.</p>\n","_postman_id":"0059abdf-5f3f-4027-8ba9-cbb72c41fb71","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}}},{"name":"Workflows","item":[{"name":"List workflows","event":[{"listen":"test","script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('data');"],"type":"text/javascript","id":"d942c6bd-5429-4bfd-8ad5-0dd7329c76da"}}],"id":"e8d3ef0d-16ea-4ea9-a46d-b2a311d321b8","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/workflows","description":"<p>Lists every workflow visible to the API key's tenant, newest-first.</p>\n<p><strong>Required scope(s):</strong> <code>workflows:read</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","workflows"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"e8d3ef0d-16ea-4ea9-a46d-b2a311d321b8"},{"name":"Create a workflow","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('id');"],"type":"text/javascript","id":"24f8ee54-837a-4ab8-88ea-527fece3c7eb"},"listen":"test"}],"id":"f3a2f661-0d73-4d0b-a464-6da9f2299bd5","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"7e6a815f-0393-4dd8-8594-68d7117910e8"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"Cal.com bookings\",\n  \"description\": \"Fires when someone books a demo.\",\n  \"trigger_type\": \"webhook\",\n  \"trigger_config\": {\n    \"auth\": {\n      \"mode\": \"hmac_sha256\",\n      \"header\": \"x-cal-signature-256\",\n      \"encoding\": \"hex\"\n    }\n  }\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/workflows","description":"<p><strong>POST /v1/workflows</strong> — creates a workflow in draft state (<code>is_active=false</code>, <code>current_version=0</code>). Wire the definition up in the builder, then <code>POST /v1/workflows/{id}/publish</code>.</p>\n<p><strong>Scope:</strong> <code>workflows:write</code>. Accepts <code>Idempotency-Key</code>.</p>\n<h2 id=\"trigger_config-for-a-webhook-trigger\"><code>trigger_config</code> for a webhook trigger</h2>\n<p><code>trigger_config</code> is a free-form object for seven of the eight trigger types. For <code>trigger_type: \"webhook\"</code> it carries <strong><code>auth</code></strong> — how the sender authenticates against the workflow's inbound URL (<code>/api/inbound/workflows/&lt;token&gt;</code>, returned on the workflow detail after the first publish).</p>\n<p>Four methods:</p>\n<pre class=\"click-to-expand-wrapper is-snippet-wrapper\"><code class=\"language-json\">{ \"mode\": \"hmac_sha256\", \"header\": \"x-cal-signature-256\", \"encoding\": \"hex\" }\n{ \"mode\": \"hmac_sha1\", \"header\": \"x-splynx-signature\" }\n{ \"mode\": \"secret_header\", \"header\": \"authorization\", \"value_prefix\": \"Bearer \" }\n{ \"mode\": \"none\" }\n</code></pre>\n<ul>\n<li><strong><code>hmac_sha256</code></strong> — the sender signs the raw body with the workflow's signing secret. <code>encoding</code> says how the digest is written into the header: <code>prefixed_hex</code> for <code>sha256=&lt;hex&gt;</code> (Meta, GitHub), <code>hex</code> for a bare digest (cal.com and most others), <code>base64</code> (Shopify). The three deliberately do <strong>not</strong> accept each other.</li>\n<li><strong><code>hmac_sha1</code></strong> — the sender signs the raw body with HMAC-SHA1 and sends the bare hex digest. For senders that cannot sign with SHA-256, such as Splynx (<code>X-Splynx-Signature</code>). It never accepts a SHA-256 digest, and <code>hmac_sha256</code> never accepts a SHA-1 one. Do not choose it for a sender that supports SHA-256.</li>\n<li><strong><code>secret_header</code></strong> — the sender puts the signing secret in a header. <code>value_prefix</code> is any text before it; the trailing space in <code>\"Bearer \"</code> is significant.</li>\n<li><strong><code>none</code></strong> — no verification. The URL becomes the only credential, so treat it as a password.</li>\n</ul>\n<p>⚠️ <strong>Omitting <code>auth</code> keeps the default</strong> <code>x-aiployee-signature-256: sha256=&lt;hex&gt;</code> scheme, so workflows created before this field existed behave exactly as they did. A <strong>malformed</strong> <code>auth</code> is rejected with <code>400 invalid_request</code> rather than falling back to <code>none</code> — a broken config never opens an endpoint.</p>\n<p><code>header</code> is stored lower-cased (HTTP headers are case-insensitive) and may not contain a space or a colon.</p>\n<p><code>trigger_config.sample_payload</code> is written by the dashboard's <strong>Listen for a test request</strong> and is not intended to be set through the API.</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","workflows"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"f3a2f661-0d73-4d0b-a464-6da9f2299bd5"},{"name":"Fetch a workflow","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('id');"],"type":"text/javascript","id":"4848cd98-208e-441d-84c4-6d54e7180c34"},"listen":"test"}],"id":"2e19eec8-6fcb-4389-856b-f2d27274e2fc","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/workflows/:id","description":"<p>Returns the workflow metadata plus its latest draft definition. Webhook-trigger workflows also include <code>{ token, secret }</code>.</p>\n<p><strong>Required scope(s):</strong> <code>workflows:read</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","workflows",":id"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"dcbf922b-632a-42a1-b146-0982d942a170","type":"any","key":"id"}]}},"response":[],"_postman_id":"2e19eec8-6fcb-4389-856b-f2d27274e2fc"},{"name":"Update workflow metadata","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('id');"],"type":"text/javascript","id":"fccedd99-2bcd-4fc3-821d-4d7ab569031b"},"listen":"test"}],"id":"f0e5b781-fab0-4c7b-92f6-b69933c2e7a4","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"802678c4-938d-44d9-b56d-b18126e6eeca"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"Lead qualifier v2\",\n  \"description\": \"Updated routing.\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/workflows/:id","description":"<p><strong>PATCH /v1/workflows/{id}</strong> — patches <code>name</code> / <code>description</code> / <code>trigger_type</code> / <code>trigger_config</code> / <code>is_active</code>. Activating a workflow whose <code>current_version</code> is 0 is refused: publish a draft first.</p>\n<p><strong>Scope:</strong> <code>workflows:write</code>.</p>\n<h2 id=\"changing-a-webhook-triggers-authentication\">Changing a webhook trigger's authentication</h2>\n<p><code>trigger_config.auth</code> selects how the sender authenticates — see <strong>Create a workflow</strong> for the three modes and their shapes.</p>\n<p>⚠️ It is validated against the <strong>effective</strong> trigger type, not the one in this patch. Sending only <code>trigger_config</code> on a workflow that is already <code>webhook</code> is checked as well, so a malformed <code>auth</code> cannot be slipped in by omitting <code>trigger_type</code>. A rejection is <code>400 invalid_request</code> and nothing is written.</p>\n<p>⚠️ <code>trigger_config</code> is <strong>replaced, not merged</strong>. Send the whole object, including any <code>max_credits_per_run</code> ceiling and any <code>sample_payload</code> you want to keep — a partial patch drops what it omits.</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","workflows",":id"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"e70add0a-4dd1-413c-83c2-e06088d29840","type":"any","key":"id"}]}},"response":[],"_postman_id":"f0e5b781-fab0-4c7b-92f6-b69933c2e7a4"},{"name":"Delete a workflow","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });"],"type":"text/javascript","id":"db1eebaa-d1c0-4826-9699-8ead6c01e217"},"listen":"test"}],"id":"be3803a2-8593-4f34-b62f-23975488c1f4","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"DELETE","header":[],"url":"https://app.aiployee.co.za/api/v1/workflows/:id","description":"<p>Permanently deletes the workflow and cascades versions, runs and timers via FK.</p>\n<p><strong>Required scope(s):</strong> <code>workflows:write</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","workflows",":id"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"ae705ff6-5561-44c9-8858-bf1899c7f09d","type":"any","key":"id"}]}},"response":[],"_postman_id":"be3803a2-8593-4f34-b62f-23975488c1f4"},{"name":"Publish a workflow","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('workflow_id');"],"type":"text/javascript","id":"701d5b01-609d-4c32-9ff3-7762ed82da1a"},"listen":"test"}],"id":"fa8ac759-5274-4d77-aa9f-4c995cb649a4","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"e9814b17-d922-4e26-8700-5f2ae2f3d2a4"}],"body":{"mode":"raw","raw":"{\n  \"definition\": {\n    \"version\": 1,\n    \"start_node_id\": \"start\",\n    \"nodes\": {\n      \"start\": { \"type\": \"send_message\", \"next\": null }\n    }\n  }\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/workflows/:id/publish","description":"<p>Cuts a new version from the current draft and flips is_active=true. Optionally upserts the draft <code>definition</code> in the same call before publishing.</p>\n<p><strong>Required scope(s):</strong> <code>workflows:write</code> · <strong>Idempotent</strong></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","workflows",":id","publish"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"8c5c1971-bd9a-440f-91f5-f1e2ac4418c5","type":"any","key":"id"}]}},"response":[],"_postman_id":"fa8ac759-5274-4d77-aa9f-4c995cb649a4"},{"name":"Trigger a workflow run","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,400,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('run_id');"],"type":"text/javascript","id":"24a7d183-df25-443e-b14a-b956e9b46571"},"listen":"test"}],"id":"2711548e-54c2-4dba-a2b5-80432afb42a3","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"e3be30d7-72e9-4363-9cea-0ff69bdb39c1"}],"body":{"mode":"raw","raw":"{\n  \"input\": {\n    \"contact_phone\": \"+27821234567\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/workflows/:id/run","description":"<p>Fires the workflow with <code>input</code> as its trigger payload. The workflow must be published. Equivalent to the <code>api</code> trigger — works regardless of the workflow's configured trigger.</p>\n<p><strong>Required scope(s):</strong> <code>workflows:trigger</code> · <strong>Idempotent</strong></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","workflows",":id","run"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"04bbf3e5-c6ee-4695-93eb-ac9157a8c216","type":"any","key":"id"}]}},"response":[],"_postman_id":"2711548e-54c2-4dba-a2b5-80432afb42a3"},{"name":"List workflow runs","event":[{"listen":"test","script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('data');"],"type":"text/javascript","id":"ee348486-5fb8-4255-8c47-5e7fc4c1ecb4"}}],"id":"6aaf9c77-75c0-4e97-9ac7-87bc21560dd9","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/workflows/:id/runs","description":"<p>Lists the most recent runs for this workflow, newest-first (capped at 50 per request).</p>\n<p><strong>Required scope(s):</strong> <code>workflows:read</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","workflows",":id","runs"],"host":["https://app.aiployee.co.za/api"],"query":[{"disabled":true,"description":{"content":"<p>Max runs to return (capped at 50).</p>\n","type":"text/plain"},"key":"limit","value":"25"}],"variable":[{"id":"722c6008-df8c-42d2-9f53-77c361d5deeb","type":"any","key":"id"}]}},"response":[],"_postman_id":"6aaf9c77-75c0-4e97-9ac7-87bc21560dd9"},{"name":"Fetch a workflow run","event":[{"script":{"exec":["pm.test('documented status', function () { pm.expect([200,401,403,404]).to.include(pm.response.code); });","if (pm.response.code === 200) pm.expect(pm.response.json()).to.have.property('steps');"],"type":"text/javascript","id":"14ec6df5-affa-40e1-81dc-b12e8465918b"},"listen":"test"}],"id":"8cacaafa-2afb-41f7-a36e-8bdceecaa828","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/workflow_runs/:id","description":"<p>Returns the run row plus every executed step (oldest first).</p>\n<p><strong>Required scope(s):</strong> <code>workflows:read</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","workflow_runs",":id"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[{"id":"5cf8c97e-1c6d-4244-872b-1e36a77f9421","type":"any","key":"id"}]}},"response":[],"_postman_id":"8cacaafa-2afb-41f7-a36e-8bdceecaa828"},{"name":"Create a contact-date workflow","event":[{"listen":"test","script":{"type":"text/javascript","exec":["pm.test('Created', function () { pm.expect(pm.response.code).to.be.oneOf([200, 201]); });","var b = pm.response.json();","var d = b.data || b;","pm.test('trigger_type is contact_date', function () { pm.expect(d.trigger_type).to.eql('contact_date'); });","pm.test('Created inactive', function () { pm.expect(d.is_active).to.eql(false); });","pm.test('Config echoed', function () { pm.expect(d.trigger_config.offsets_days).to.eql([-1, 0, 1]); pm.expect(d.trigger_config.time_of_day).to.eql('09:00'); });"]}}],"id":"7ebcdadb-4419-8112-83a6-1cf39158ca77","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"92caaad8-1204-4849-9cf3-16072af79328"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"zz Deposit reminders\",\n  \"description\": \"One run per contact whose deposit date is tomorrow, today or yesterday.\",\n  \"trigger_type\": \"contact_date\",\n  \"trigger_config\": {\n    \"attribute_key\": \"deposit_commitment_date\",\n    \"offsets_days\": [-1, 0, 1],\n    \"time_of_day\": \"09:00\",\n    \"timezone\": \"Africa/Johannesburg\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/workflows","description":"<p>Scope: <code>workflows:write</code>. Creates an inactive workflow with an empty draft.</p>\n<p><code>trigger_type: \"contact_date\"</code> runs <strong>once a day</strong> and starts <strong>one run per contact</strong> whose date field is N days away:</p>\n<ul>\n<li><code>attribute_key</code> — a contact field of type <code>date</code>. Checked when the workflow is published.</li>\n<li><code>offsets_days</code> — 1 to 5 distinct whole days between −30 and 30. <code>-1</code> = the day <strong>before</strong> the date, <code>0</code> = on the day, <code>1</code> = the day after.</li>\n<li><code>time_of_day</code> — <code>HH:MM</code>, 24-hour, in <code>timezone</code> (defaults to Africa/Johannesburg).</li>\n</ul>\n<p>Each run carries <code>{{trigger.offset_days}}</code>, <code>{{trigger.date}}</code>, <code>{{trigger.attribute_key}}</code>, <code>{{trigger.local_date}}</code> and <code>{{trigger.timezone}}</code>, so one workflow can branch on the offset. A contact is never started twice for the same offset on the same day, and blocked contacts are skipped.</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","workflows"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"7ebcdadb-4419-8112-83a6-1cf39158ca77"}],"id":"aa2bc8c3-5803-4f4c-8632-ce60afd6ac0b","description":"<p>CRUD, publish, trigger runs, and inspect runs + steps.</p>\n","_postman_id":"aa2bc8c3-5803-4f4c-8632-ce60afd6ac0b","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}}},{"name":"Errors & replays","item":[{"name":"Idempotent replay (same key + body → original response)","event":[{"script":{"exec":["pm.test('replayed (not a fresh error)', function () { pm.expect([200,400,401,403,502]).to.include(pm.response.code); });"],"type":"text/javascript","id":"28beb14f-7b63-48a0-a051-fa30869abe3c"},"listen":"test"}],"id":"8ce65e42-5d5b-4a51-9501-186ab119eaab","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":""}],"body":{"mode":"raw","raw":"{\n  \"channel\": \"whatsapp\",\n  \"to\": \"+27821234567\",\n  \"from\": \"\",\n  \"kind\": \"text\",\n  \"message\": \"Hi 👋 thanks for reaching out — how can we help?\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/messages","description":"<p>Re-send the exact body of \"Send WhatsApp text message\" with the <strong>same</strong> <code>Idempotency-Key</code> (captured in <code>last_idempotency_key</code>) → the platform returns the original stored response without re-sending.</p>\n<p><strong>Required scope(s):</strong> <code>messages:send</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","messages"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"8ce65e42-5d5b-4a51-9501-186ab119eaab"},{"name":"Idempotency mismatch (same key, different body → 422)","event":[{"script":{"exec":["pm.test('422 idempotency_mismatch', function () { pm.response.to.have.status(422); pm.expect(pm.response.json().error.code).to.eql('idempotency_mismatch'); });"],"type":"text/javascript","id":"68caccf9-ed5f-43fc-a1a9-94022cb3163d"},"listen":"test"}],"id":"43f64427-b483-451b-91fc-9671c4b48332","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":""}],"body":{"mode":"raw","raw":"{\n  \"channel\": \"whatsapp\",\n  \"to\": \"+27821234567\",\n  \"kind\": \"text\",\n  \"message\": \"A DIFFERENT message body.\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/messages","description":"<p>Same <code>Idempotency-Key</code> as the text send, <strong>different</strong> body → <code>422 idempotency_mismatch</code>.</p>\n<p><strong>Required scope(s):</strong> <code>messages:send</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","messages"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"43f64427-b483-451b-91fc-9671c4b48332"},{"name":"Unsupported channel (400 unsupported_channel)","event":[{"script":{"exec":["pm.test('400 unsupported_channel', function () { pm.response.to.have.status(400); pm.expect(pm.response.json().error.code).to.eql('unsupported_channel'); });"],"type":"text/javascript","id":"1c236519-1dd1-4fe8-b351-b24e6b79df88"},"listen":"test"}],"id":"a0b18e33-7fb5-49bf-9a33-ae06da1866d2","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"channel\": \"sms\",\n  \"to\": \"+27821234567\",\n  \"kind\": \"text\",\n  \"message\": \"Hi\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/messages","description":"<p><code>channel:\"sms\"</code> isn't supported today → <code>400 unsupported_channel</code>. Future channels are added as new enum members on <code>channel</code>, never new URL prefixes.</p>\n<p><strong>Required scope(s):</strong> <code>messages:send</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","messages"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"a0b18e33-7fb5-49bf-9a33-ae06da1866d2"},{"name":"Missing scope (403 forbidden)","event":[{"script":{"exec":["pm.test('403 when the key lacks messages:send (else 200)', function () { pm.expect([403,200]).to.include(pm.response.code); });","if (pm.response.code === 403) pm.expect(pm.response.json().error.code).to.eql('forbidden');"],"type":"text/javascript","id":"e06010dd-4930-4219-b082-ce35fcb82930"},"listen":"test"}],"id":"b7099696-94a8-437a-a065-96239bf09de5","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"channel\": \"whatsapp\",\n  \"to\": \"+27821234567\",\n  \"kind\": \"text\",\n  \"message\": \"Hi\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/messages","description":"<p>To see <code>403 forbidden</code>, swap <code>aip_api_key</code> for a key WITHOUT the <code>messages:send</code> scope (create one on /settings/api-keys). With a full-scope key this returns 200.</p>\n<p><strong>Required scope(s):</strong> <code>messages:send</code></p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","messages"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"b7099696-94a8-437a-a065-96239bf09de5"},{"name":"Bad token (401 unauthorized)","event":[{"script":{"exec":["pm.test('401 unauthorized', function () { pm.response.to.have.status(401); pm.expect(pm.response.json().error.code).to.eql('unauthorized'); });"],"type":"text/javascript","id":"0a567e03-f3d6-4d53-a380-f441c3832bf3"},"listen":"test"}],"id":"63f17b2e-83b7-4877-90ff-e6385fa80c93","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"<token>"}]},"isInherited":false},"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"channel\": \"whatsapp\",\n  \"to\": \"+27821234567\",\n  \"kind\": \"text\",\n  \"message\": \"Hi\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/messages","description":"<p>A bad/revoked key → <code>401 unauthorized</code>. This request overrides the collection bearer with a deliberately invalid token.</p>\n","urlObject":{"path":["v1","messages"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"63f17b2e-83b7-4877-90ff-e6385fa80c93"},{"name":"List team without team:read (403 forbidden)","event":[{"listen":"prerequest","script":{"exec":["// Skip honestly rather than fail red when the fixture key has not been issued.","if (!pm.environment.get('aip_api_key_no_team')) {","    console.log('Set aip_api_key_no_team to a key with no team scope to run this check.');","    pm.execution.skipRequest();","}"],"type":"text/javascript"}},{"listen":"test","script":{"exec":["pm.test('403 forbidden', function () { pm.response.to.have.status(403); });","","pm.test('it names team:read as the missing scope', function () {","    var e = pm.response.json().error;","    pm.expect(e.code).to.eql('forbidden');","    pm.expect(e.message).to.include('team:read');","    pm.expect(e.details.required).to.eql(['team:read']);","});","","// The refusal must be about the SCOPE, not about the key being broken — otherwise","// this would also pass for a revoked key, which is a different failure (401).","pm.test('the key itself authenticated fine (403, not 401)', function () {","    pm.expect(pm.response.code).to.not.eql(401);","    pm.expect(pm.response.json().error.details.granted).to.be.an('array');","});","","// The refused scope must NOT appear in the granted list — that is the whole","// claim. If the implication map ever widened wrongly, this is where it shows.","pm.test('team:read is genuinely absent from the granted set', function () {","    pm.expect(pm.response.json().error.details.granted).to.not.include('team:read');","});"],"type":"text/javascript"}}],"id":"600cbed1-2c19-f760-8c92-91be4dde9ec3","request":{"auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"{{aip_api_key_no_team}}"}]},"isInherited":false},"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/team","description":"<p>Proves the <strong>new <code>team:read</code> scope is genuinely enforced</strong>. Without this request, every <code>team:read</code> pass elsewhere in the collection could equally mean the endpoint had stopped checking scopes at all — a green board and an open door look identical from the outside.</p>\n<p>Overrides collection auth with <strong><code>{{aip_api_key_no_team}}</code></strong>: a key holding <strong>no team scope whatsoever</strong> (e.g. only <code>contacts:read</code>). Add it to your environment; issue the key on <code>/settings/api-keys</code>.</p>\n<p>⚠ <strong>Read what <code>details.granted</code> reports: the key's EFFECTIVE scopes, not the ones stored on it.</strong> A key storing only <code>team:manage</code> reports <code>[\"team:manage\", \"team:read\"]</code>, because the <code>team:manage ⇒ team:read</code> implication is resolved once, at authentication, on the key's granted scopes. That is deliberate — it tells you what the key can actually reach — so <strong>do not</strong> assert that <code>granted</code> equals the boxes you ticked when you issued it.</p>\n<p>The implication is applied to <em>granted</em> scopes only and never to a route's <em>required</em> list: expanding a requirement would widen every key holding the narrow scope into the broad one, which is the inverse of the intent.</p>\n<p><strong>Required scope(s):</strong> <code>team:read</code> — which this key deliberately lacks.</p>\n","urlObject":{"path":["v1","team"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[{"id":"b24d1c67-61e5-a48b-a9fc-aad8442160ed","name":"403 — missing team:read","originalRequest":{"auth":{"type":"bearer","bearer":{"token":"{{aip_api_key_no_team}}"}},"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/team","description":"Proves the **new `team:read` scope is genuinely enforced**. Without this request, every `team:read` pass elsewhere in the collection could equally mean the endpoint had stopped checking scopes at all — a green board and an open door look identical from the outside.\n\nOverrides collection auth with **`{{aip_api_key_no_team}}`**: a key holding **no team scope whatsoever** (e.g. only `contacts:read`). Add it to your environment; issue the key on `/settings/api-keys`.\n\n⚠ **Read what `details.granted` reports: the key's EFFECTIVE scopes, not the ones stored on it.** A key storing only `team:manage` reports `[\"team:manage\", \"team:read\"]`, because the `team:manage ⇒ team:read` implication is resolved once, at authentication, on the key's granted scopes. That is deliberate — it tells you what the key can actually reach — so **do not** assert that `granted` equals the boxes you ticked when you issued it.\n\nThe implication is applied to *granted* scopes only and never to a route's *required* list: expanding a requirement would widen every key holding the narrow scope into the broad one, which is the inverse of the intent.\n\n**Required scope(s):** `team:read` — which this key deliberately lacks."},"status":"Forbidden","code":403,"_postman_previewlanguage":"json","header":[{"key":"Content-Type","value":"application/json"}],"cookie":[],"responseTime":null,"body":"{\n  \"error\": {\n    \"code\": \"forbidden\",\n    \"message\": \"This API key is missing required scope(s): team:read.\",\n    \"request_id\": \"81345000-48f8-4463-820c-09b7488408c7\",\n    \"details\": {\n      \"required\": [\"team:read\"],\n      \"granted\": [\"contacts:read\"]\n    }\n  }\n}"}],"_postman_id":"600cbed1-2c19-f760-8c92-91be4dde9ec3"},{"name":"Webhook trigger with a malformed auth (400 invalid_request)","event":[{"script":{"type":"text/javascript","exec":["pm.test('refused with 400', function () { pm.response.to.have.status(400); });","pm.test('error code is invalid_request', function () {","    pm.expect(pm.response.json().error.code).to.eql('invalid_request');","});","pm.test('the message names the offending field', function () {","    var m = pm.response.json().error.message;","    pm.expect(m).to.be.a('string');","    pm.expect(m.length).to.be.above(0);","});","pm.test('nothing was created', function () {","    pm.expect(pm.response.json()).to.not.have.property('id');","});"]},"listen":"test"}],"id":"b624d221-320a-dd1a-6cac-13b01f478652","request":{"method":"POST","header":[{"value":"application/json","key":"Content-Type"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"zz-bad-auth (should not be created)\",\n  \"trigger_type\": \"webhook\",\n  \"trigger_config\": {\n    \"auth\": {\n      \"mode\": \"hmac_sha256\",\n      \"header\": \"x cal signature\",\n      \"encoding\": \"sha512\"\n    }\n  }\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/workflows","description":"<p>Proves that a broken webhook <code>auth</code> is <strong>refused</strong> rather than silently ignored.</p>\n<p>Two things are wrong with the body: <code>header</code> contains spaces (not a legal header name), and <code>encoding</code> is not one of <code>prefixed_hex</code> / <code>hex</code> / <code>base64</code>.</p>\n<p><strong>Expect <code>400 invalid_request</code>, and no workflow created.</strong></p>\n<p>⚠️ Why this matters enough to have its own request: the alternative to rejecting is falling back to the default scheme, which would leave the caller looking at a setting that reads as applied and is not — or, far worse, falling back to <code>mode: \"none\"</code>, which would turn a signed endpoint into an open one. The platform fails <strong>closed</strong> to the default <code>x-aiployee-signature-256</code> scheme at request time, and refuses the write here so the mismatch is never stored in the first place.</p>\n<p>Run it after <strong>Create a workflow</strong> so you can see the contrast: the same endpoint, the same scope, one letter of difference in <code>encoding</code>.</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","workflows"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"b624d221-320a-dd1a-6cac-13b01f478652"},{"name":"Starting stage from another workspace (400 invalid_request)","event":[{"script":{"exec":["pm.test('400 Bad Request', function () { pm.response.to.have.status(400); });","pm.test('code is invalid_request', function () {","    pm.expect(pm.response.json().error.code).to.eql('invalid_request');","});","pm.test('and the message names the problem', function () {","    pm.expect(pm.response.json().error.message.toLowerCase()).to.include('stage');","});"],"type":"text/javascript"},"listen":"test"}],"id":"2e6cfbdf-2573-0a28-37d5-60d3099c560d","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"entry_stage_id\": \"00000000-0000-4000-8000-000000000000\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/contacts/","description":"<p><strong>Expect:</strong> <code>400</code> with <code>error.code = \"invalid_request\"</code>.</p>\n<p>A stage id that does not exist in <em>your</em> workspace is a <strong>malformed request</strong>, not a field to drop quietly. Naming a stage you cannot see is a mistake worth reporting, and the alternative — accepting the call and ignoring the value — would leave an integration convinced it had pinned a contact that is not pinned.</p>\n<p>The same 400 is returned whether the id is nonsense, belongs to another tenant, or names a stage since deleted. Distinguishing them would turn this endpoint into an oracle for other workspaces' stage ids.</p>\n<p>There is a second guard underneath this one: a database trigger (<code>contacts_entry_stage_tenant_match</code>) rejects a cross-tenant pairing even on a direct SQL write. That trigger is the floor, not the error surface — it raises a bare Postgres exception, which is why the server validates first and returns this instead.</p>\n<p>Run <strong>Create a contact with a starting stage</strong> first so <code></code> is set.</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","contacts",""],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"2e6cfbdf-2573-0a28-37d5-60d3099c560d"},{"name":"List stages without conversations:read (403 forbidden)","event":[{"script":{"exec":["pm.test('403 Forbidden', function () { pm.response.to.have.status(403); });","pm.test('code is forbidden', function () {","    pm.expect(pm.response.json().error.code).to.eql('forbidden');","});","pm.test('granted lists the EFFECTIVE scopes, and conversations:read is not among them', function () {","    var d = pm.response.json().error.details;","    if (!d || !d.granted) { pm.expect.fail('no details.granted on the 403'); }","    pm.expect(d.granted).to.not.include('conversations:read');","});"],"type":"text/javascript"},"listen":"test"}],"id":"d2c04703-42d7-42da-2f26-e979adb31270","request":{"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/stages","description":"<p><strong>Expect:</strong> <code>403</code> with <code>error.code = \"forbidden\"</code>.</p>\n<p><code>GET /v1/stages</code> is gated on <strong><code>conversations:read</code></strong>, and there is deliberately no <code>stages:read</code>.</p>\n<p>Why it reuses an existing scope rather than minting one:</p>\n<ul>\n<li>A stage is a property of the conversation model. Every key that can already read a conversation can already read its <code>current_stage_id</code>.</li>\n<li>This endpoint returns strictly <strong>less</strong> than a conversation already exposes: a name and two booleans, no system prompt and no transition graph.</li>\n<li>A new scope would be three separate edits (the catalogue, the hardcoded grouping in the key picker, and the tenant docs table) and would leave every key issued before it unable to reach the endpoint until it was re-cut.</li>\n</ul>\n<p><strong>To run this:</strong> point <code>aip_api_key</code> at a key WITHOUT <code>conversations:read</code>. Issue one on <code>/settings/api-keys</code> with, say, <code>contacts:read</code> only. A key that does hold the scope will return <code>200</code> and this request will fail — which is the assertion working, not a bug.</p>\n<p>Check <code>error.details.granted</code> in the response: it reports the key's <strong>effective</strong> scopes, after implications are expanded, not the raw stored list.</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","stages"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"d2c04703-42d7-42da-2f26-e979adb31270"},{"name":"Send to an opted-out contact (409 send_not_permitted)","event":[{"script":{"exec":["pm.test('409 send_not_permitted', function () {","    pm.expect(pm.response.code).to.eql(409);","    pm.expect(pm.response.json().error.code).to.eql('send_not_permitted');","});","","pm.test('details.rule names WHICH rule refused', function () {","    var d = pm.response.json().error.details;","    pm.expect(d).to.have.property('rule');","    pm.expect(['contact_blocked','contact_opted_out','contact_not_opted_in','window_closed','window_never_opened']).to.include(d.rule);","});","","pm.test('details.remedy says whether a template would work instead', function () {","    var d = pm.response.json().error.details;","    pm.expect(d).to.have.property('remedy');","    pm.expect(['template','none']).to.include(d.remedy);","});","","// The point of the arm: an opted-out contact is a CONSENT refusal, so no","// template will help. A 'template' remedy here would mean the guard had","// reported the window instead of the opt-out.","pm.test('an opted-out contact is refused on consent, not on the window', function () {","    var d = pm.response.json().error.details;","    if (d.rule === 'contact_opted_out') pm.expect(d.remedy).to.eql('none');","});","","pm.test('nothing was sent — no message_id came back', function () {","    pm.expect(pm.response.json()).to.not.have.property('message_id');","});"],"type":"text/javascript"},"listen":"test"}],"id":"e8ba9d1a-dce6-3ddc-4b3e-aab4f2ed724f","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n  \"channel\": \"whatsapp\",\n  \"kind\": \"text\",\n  \"to\": \"{{test_opted_out_phone_e164}}\",\n  \"message\": \"This should never be delivered — the opt-out guard must refuse it.\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/messages","description":"<p><strong>Scope:</strong> <code>messages:send</code></p>\n<p>Triggers the <code>send_not_permitted</code> error code, added to <code>POST /v1/messages</code> in the MCP-11 release.</p>\n<h2 id=\"what-changed-and-why-it-matters-if-you-integrate\">What changed, and why it matters if you integrate</h2>\n<p>⚠ <strong>This endpoint now enforces two rules it did not enforce before.</strong> Previously it normalised the number, picked an account, upserted the contact and opened a conversation without reading the contact's opt-in status or WhatsApp's 24-hour service window. Two consequences, and the first is why this is a fix rather than a restriction:</p>\n<ul>\n<li>a send to a contact who had <strong>opted out</strong> went through;</li>\n<li>a <strong>free-form</strong> send outside the 24-hour window reached Meta and came back as a raw provider error, and repeating that damages your WhatsApp number's quality rating.</li>\n</ul>\n<p>Both are now refused here, before anything reaches Meta.</p>\n<h2 id=\"the-rules\">The rules</h2>\n<div class=\"click-to-expand-wrapper is-table-wrapper\"><table>\n<thead>\n<tr>\n<th>Rule</th>\n<th>When</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>contact_blocked</code></td>\n<td>The contact is blocked in your workspace.</td>\n</tr>\n<tr>\n<td><code>contact_opted_out</code></td>\n<td>The contact opted out. Only they can reverse it.</td>\n</tr>\n<tr>\n<td><code>window_closed</code></td>\n<td>They last messaged you more than 24 hours ago, and this is a free-form send.</td>\n</tr>\n<tr>\n<td><code>window_never_opened</code></td>\n<td>They have never messaged you, and this is a free-form send.</td>\n</tr>\n</tbody>\n</table>\n</div><p>⚠ <strong><code>opt_in_status</code> gates BROADCASTS, not replies.</strong> A reply refuses only on <code>blocked</code> and <code>opted_out</code> — a <code>pending</code> contact (the normal state of anyone who has messaged you, since inbound does not record an opt-in) can still be replied to.</p>\n<p>⚠ <strong>Free-form means more than text.</strong> Media, interactive and location sends are all refused outside the window. Only an approved <strong>template</strong> is delivered once it has closed — send <code>kind: \"template\"</code> instead.</p>\n<h2 id=\"response\">Response</h2>\n<p><code>409</code> with the standard envelope, plus <code>details</code>:</p>\n<pre class=\"click-to-expand-wrapper is-snippet-wrapper\"><code class=\"language-json\">{\n  \"error\": {\n    \"code\": \"send_not_permitted\",\n    \"message\": \"This contact has opted out of messages, so nothing can be sent to them. Only they can reverse that.\",\n    \"request_id\": \"…\",\n    \"details\": { \"rule\": \"contact_opted_out\", \"remedy\": \"none\" }\n  }\n}\n</code></pre>\n<p><code>details.remedy</code> is <code>\"template\"</code> when an approved template would be accepted instead, and <code>\"none\"</code> when nothing will be — so you can branch on it rather than retrying the same body.</p>\n<h2 id=\"setup\">Setup</h2>\n<p>Set <code>test_opted_out_phone_e164</code> on your environment to an E.164 number whose contact record has <code>opt_in_status = \"opted_out\"</code> in your workspace (set it on the contact in Contacts, or via <code>PATCH /v1/contacts/{id}</code>).</p>\n<p>⚠ If the number you use is <strong>not</strong> opted out, this returns <code>200</code> and <strong>actually sends a message</strong>. Check the contact first.</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","messages"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"e8ba9d1a-dce6-3ddc-4b3e-aab4f2ed724f"},{"name":"Broadcast with an invalid created_to (400 invalid_request)","event":[{"script":{"type":"text/javascript","exec":["pm.test('400', function () { pm.response.to.have.status(400); });","pm.test('invalid_request', function () { pm.expect(pm.response.json().error.code).to.eql('invalid_request'); });"]},"listen":"test"}],"id":"fdba5e64-3c81-d420-ceda-ecdb4ca67532","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"9a78c19b-c9bb-4539-97bf-9448db999811"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"zz invalid created_to\",\n  \"wa_number_id\": \"\",\n  \"template_id\": \"\",\n  \"audience_filter\": { \"created_to\": \"2026-02-30\" }\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/broadcasts","description":"<p>Scope: <code>broadcasts:write</code>. <code>created_from</code> / <code>created_to</code> must be a real calendar date (<code>YYYY-MM-DD</code>) or a datetime <strong>with an offset</strong>. <code>2026-02-30</code> is not a date, and a datetime without an offset is ambiguous, so both are refused with <code>400 invalid_request</code> rather than guessed.</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","broadcasts"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"fdba5e64-3c81-d420-ceda-ecdb4ca67532"},{"name":"Broadcast with an unknown recipient_tag (400 invalid_request)","event":[{"script":{"exec":["pm.test('400', function () { pm.response.to.have.status(400); });","pm.test('invalid_request', function () { pm.expect(pm.response.json().error.code).to.eql('invalid_request'); });"],"type":"text/javascript"},"listen":"test"}],"id":"0e30cdaf-2205-3124-4fab-c3e98a80e675","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"3423292d-a9e5-47bb-b2b4-173efb97daec"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"zz unknown recipient tag\",\n  \"wa_number_id\": \"\",\n  \"template_id\": \"\",\n  \"recipient_tag\": \"zz_tag_that_does_not_exist\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/broadcasts","description":"<p>Scope: <code>broadcasts:write</code>. <code>recipient_tag</code> must be a tag the workspace has already defined. An unknown one is refused when the broadcast is created, rather than silently tagging nobody.</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","broadcasts"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"0e30cdaf-2205-3124-4fab-c3e98a80e675"},{"name":"Broadcast with a malformed recipient_tag (400 invalid_request)","event":[{"listen":"test","script":{"type":"text/javascript","exec":["pm.test('400', function () { pm.response.to.have.status(400); });","pm.test('invalid_request', function () { pm.expect(pm.response.json().error.code).to.eql('invalid_request'); });"]}}],"id":"f0677d20-634d-e5ec-e335-dcdb9a8ce5a6","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"},{"key":"Idempotency-Key","value":"d3d94597-9f0d-42e9-ac72-1e99283e0a79"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"zz malformed recipient tag\",\n  \"wa_number_id\": \"\",\n  \"template_id\": \"\",\n  \"recipient_tag\": \"Bad Tag!\"\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/broadcasts","description":"<p>Scope: <code>broadcasts:write</code>. A tag slug is lowercase letters, digits and underscores, starting with a letter (<code>^[a-z][a-z0-9_]*$</code>).</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","broadcasts"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"f0677d20-634d-e5ec-e335-dcdb9a8ce5a6"},{"name":"Contact-date workflow with a malformed config (400 invalid_request)","event":[{"listen":"test","script":{"exec":["pm.test('400', function () { pm.response.to.have.status(400); });","pm.test('invalid_request', function () { pm.expect(pm.response.json().error.code).to.eql('invalid_request'); });"],"type":"text/javascript"}}],"id":"202b810d-f837-cffa-70e7-5feb78ca1949","request":{"method":"POST","header":[{"value":"application/json","key":"Content-Type"},{"value":"7d2bbcbe-420f-4ef3-ae40-511c4faf5495","key":"Idempotency-Key"}],"body":{"mode":"raw","raw":"{\n  \"name\": \"zz bad contact_date config\",\n  \"trigger_type\": \"contact_date\",\n  \"trigger_config\": {\n    \"attribute_key\": \"deposit_commitment_date\",\n    \"offsets_days\": [99],\n    \"time_of_day\": \"9am\"\n  }\n}","options":{"raw":{"language":"json"}}},"url":"https://app.aiployee.co.za/api/v1/workflows","description":"<p>Scope: <code>workflows:write</code>. A partly filled <code>contact_date</code> config is refused: offsets must be whole days between −30 and 30, and <code>time_of_day</code> must be <code>HH:MM</code>.</p>\n","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}},"urlObject":{"path":["v1","workflows"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"202b810d-f837-cffa-70e7-5feb78ca1949"}],"id":"31c007cf-68cf-4d3c-b5cd-668243c74860","description":"<p>Negative-path + idempotency behaviour. Run <strong>Send WhatsApp text message</strong> first — it stores <code>last_idempotency_key</code> used here.</p>\n","_postman_id":"31c007cf-68cf-4d3c-b5cd-668243c74860","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}}},{"name":"Spec","item":[{"name":"Get OpenAPI 3.1 spec","event":[{"script":{"exec":["pm.test('200 + openapi doc', function () { pm.response.to.have.status(200); var j = pm.response.json(); pm.expect(j).to.have.property('openapi'); pm.expect(j).to.have.property('paths'); });"],"type":"text/javascript","id":"f01a25da-c5b2-4ae9-893e-0d676ef44abf"},"listen":"test"}],"id":"22b93800-5fa0-4f25-9cca-59170b3c8f96","protocolProfileBehavior":{"disableBodyPruning":true},"request":{"auth":{"type":"noauth","isInherited":false},"method":"GET","header":[],"url":"https://app.aiployee.co.za/api/v1/openapi.json","description":"<p>Public — no auth. The OpenAPI 3.1 document served at <code>/api/v1/openapi.json</code> (rendered by Scalar at <code>/docs</code>). Regenerated from the zod schemas, so it always matches the live API.</p>\n","urlObject":{"path":["v1","openapi.json"],"host":["https://app.aiployee.co.za/api"],"query":[],"variable":[]}},"response":[],"_postman_id":"22b93800-5fa0-4f25-9cca-59170b3c8f96"}],"id":"acc84e07-be36-4b56-8933-04d649cc29b5","description":"<p>The machine-readable contract.</p>\n","_postman_id":"acc84e07-be36-4b56-8933-04d649cc29b5","auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]},"isInherited":true,"source":{"_postman_id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","id":"f5f7f377-fcf4-4fd0-9d74-69f082c79f80","name":"Aiployee Public API Production","type":"collection"}}}],"auth":{"type":"bearer","bearer":{"basicConfig":[{"key":"token","value":"aip_live_REPLACE_ME"}]}},"event":[{"listen":"prerequest","script":{"id":"79601f00-48c1-417c-82fc-59a168780df8","type":"text/javascript","packages":{},"requests":{},"exec":[""]}},{"listen":"test","script":{"id":"1909b7f2-1d2e-496c-818f-7b71e8d86e31","type":"text/javascript","requests":{},"exec":["// Runs after every request.","pm.test('Response carries X-Request-Id', function () { pm.expect(pm.response.headers.has('X-Request-Id')).to.be.true; });","if (pm.response.code >= 400) {","    pm.test('Error envelope shape', function () {","        var b = pm.response.json();","        pm.expect(b).to.have.property('error');","        pm.expect(b.error).to.have.property('code');","        pm.expect(b.error).to.have.property('message');","        pm.expect(b.error).to.have.property('request_id');","    });","    if (pm.response.code === 429) pm.test('429 carries Retry-After', function () { pm.expect(pm.response.headers.has('Retry-After')).to.be.true; });","}"]}}],"variable":[{"key":"baseUrl","value":"https://app.aiployee.co.za/api","description":"Deployment origin + /api. Requests are https://app.aiployee.co.za/api/v1/... so this MUST end in /api (equals the OpenAPI servers.url). No secrets stored here."},{"key":"aip_api_key","value":"aip_live_REPLACE_ME","description":"Tenant API key (aip_live_<32 hex>). Prefer setting it as a secret on the environment."},{"key":"test_phone_e164","value":"+27821234567","description":"E.164 destination you control."},{"key":"from_phone_number_id","value":"","description":"Optional; required only with >1 active number."},{"key":"wa_number_id","value":"","description":"A whatsapp_numbers.id (UUID) — used by Create broadcast."},{"key":"template_id","value":"","description":"An approved template id (UUID) — used by Create broadcast."},{"key":"last_idempotency_key","value":"","description":"Set by 'Send WhatsApp text message'; read by the Errors & replays folder."},{"key":"last_conversation_id","value":"","description":"Captured from a successful send."},{"key":"last_message_id","value":"","description":"Captured from a successful send."},{"key":"last_contact_id","value":"","description":"Captured from a successful contact create/get."},{"key":"last_tag_id","value":"","description":"Captured from a successful Create tag."}]}