{
  "info": {
    "_postman_id": "b1f0c6a2-7d41-4d8e-9a3b-000000000001",
    "name": "Trivora AI — Gateway & Tenant API",
    "description": "The RUNTIME surface. Everything a customer's Salesforce org and its users touch.\n\nTwo hosts, and the split is not what it looks like:\n\n| | Host | Credential | Who calls it |\n|---|---|---|---|\n| Tenant control | `{{admin_base}}` | OAuth client credentials, `tenant` scope, pinned to one organisation | the customer's **Apex** |\n| Inference | `{{gateway_base}}` | a **session key** minted by the call above | the customer's **browser** (LWC) |\n\nRead that table before wiring anything up. The browser never holds an OAuth credential and never calls `{{admin_base}}` — if it could, an end user could mint keys attributed to a colleague, or mint one for the reasoning tier they are not entitled to. Apex mints; the browser spends.\n\n**Getting started**\n1. Import the `Trivora Tenant` environment alongside this collection.\n2. Run **Setup → Redeem activation code** once. It fills in `client_id` and `client_secret`.\n3. Everything else works. A collection pre-request script fetches and caches the OAuth token for you.\n\nThe control plane — creating organisations, amending contracts, granting credits, issuing project keys — is a different collection and a different credential. A `tenant` credential gets 404, not 403, on anything outside its own organisation, so it cannot even discover that other tenants exist.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "bearer",
    "bearer": [
      {
        "key": "token",
        "value": "{{access_token}}",
        "type": "string"
      }
    ]
  },
  "event": [
    {
      "listen": "prerequest",
      "script": {
        "type": "text/javascript",
        "exec": [
          "// Fetches an OAuth token on demand and caches it until ~30s before it",
          "// expires. Tokens last 15 minutes; without this you would re-run the token",
          "// request by hand four times an hour.",
          "const path = pm.request.url.getPath();",
          "const host = pm.request.url.getHost().join('.');",
          "",
          "// Skip: the token endpoint itself, the unauthenticated activation call,",
          "// and anything aimed at the gateway (which uses a session key, not this).",
          "if (path.indexOf('/admin/oauth/token') === 0) { return; }",
          "if (path.indexOf('/admin/activate') === 0) { return; }",
          "if (path.indexOf('/v1/') === 0) { return; }",
          "",
          "const now = Math.floor(Date.now() / 1000);",
          "const expiresAt = Number(pm.environment.get('token_expires_at') || 0);",
          "if (pm.environment.get('access_token') && expiresAt > now + 30) { return; }",
          "",
          "const id = pm.environment.get('client_id');",
          "const secret = pm.environment.get('client_secret');",
          "if (!id || !secret) {",
          "  console.warn('client_id / client_secret not set — run Setup → Redeem activation code');",
          "  return;",
          "}",
          "",
          "pm.sendRequest({",
          "  url: pm.environment.get('admin_base') + '/admin/oauth/token',",
          "  method: 'POST',",
          "  header: { 'Content-Type': 'application/x-www-form-urlencoded' },",
          "  body: { mode: 'urlencoded', urlencoded: [",
          "    { key: 'grant_type',    value: 'client_credentials' },",
          "    { key: 'client_id',     value: id },",
          "    { key: 'client_secret', value: secret }",
          "  ]}",
          "}, (err, res) => {",
          "  if (err || res.code !== 200) {",
          "    console.error('token request failed', err || res.text());",
          "    return;",
          "  }",
          "  const body = res.json();",
          "  pm.environment.set('access_token', body.access_token);",
          "  pm.environment.set('token_expires_at', now + body.expires_in);",
          "});"
        ]
      }
    }
  ],
  "variable": [
    {
      "key": "admin_base",
      "value": "https://admin.trivora.tech",
      "description": "Tenant control plane. Apex calls this; the browser never does."
    },
    {
      "key": "gateway_base",
      "value": "https://ai.trivora.tech",
      "description": "OpenAI-compatible inference. Also returned in every session response as `gateway_base` — prefer that value over hardcoding this one."
    }
  ],
  "item": [
    {
      "name": "Setup",
      "description": "Run once per Salesforce org, at package install.",
      "item": [
        {
          "name": "Redeem activation code",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "// The secret is returned EXACTLY ONCE and is not recoverable.",
                  "// If this test does not run, re-issue the code — there is no way to",
                  "// read the secret back out of the server.",
                  "if (pm.response.code === 200) {",
                  "  const b = pm.response.json();",
                  "  pm.environment.set('client_id', b.client_id);",
                  "  pm.environment.set('client_secret', b.client_secret);",
                  "  pm.environment.set('org_ref', b.organization_id);",
                  "  pm.environment.unset('access_token');",
                  "  console.log('stored tenant credential for ' + b.organization_id);",
                  "}",
                  "pm.test('activated', () => pm.response.to.have.status(200));"
                ]
              }
            }
          ],
          "request": {
            "auth": {
              "type": "noauth"
            },
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"code\": \"{{activation_code}}\",\n  \"salesforce_org_id\": \"{{salesforce_org_id}}\",\n  \"label\": \"Acme Corp production\"\n}"
            },
            "url": {
              "raw": "{{admin_base}}/admin/activate",
              "host": [
                "{{admin_base}}"
              ],
              "path": [
                "admin",
                "activate"
              ]
            },
            "description": "Paste the code Trivora gave you into the `activation_code` environment variable, set `salesforce_org_id` to your own org id, and send. Do not edit the body.\n\n**Unauthenticated by necessity** — the package has no credential yet; obtaining one is the point.\n\nWhat stands in for authentication is the code: 128 bits of CSPRNG output, single-use, time-limited, stored only as a SHA-256 digest, and bound to one Salesforce org id. Once an org id is recorded, a leaked code is useless anywhere else.\n\nEvery failure returns the **same** 400 with the same message. \"No such code\", \"already redeemed\" and \"wrong Salesforce org\" are indistinguishable on purpose — otherwise this endpoint is an oracle for which organisations exist and which have been onboarded.\n\nThe returned `client_secret` is shown once. Store it in the External Credential immediately."
          }
        },
        {
          "name": "Get access token",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "if (pm.response.code === 200) {",
                  "  const b = pm.response.json();",
                  "  pm.environment.set('access_token', b.access_token);",
                  "  pm.environment.set('token_expires_at', Math.floor(Date.now()/1000) + b.expires_in);",
                  "}",
                  "pm.test('token issued', () => pm.response.to.have.status(200));"
                ]
              }
            }
          ],
          "request": {
            "auth": {
              "type": "noauth"
            },
            "method": "POST",
            "header": [],
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "grant_type",
                  "value": "client_credentials"
                },
                {
                  "key": "client_id",
                  "value": "{{client_id}}"
                },
                {
                  "key": "client_secret",
                  "value": "{{client_secret}}"
                }
              ]
            },
            "url": {
              "raw": "{{admin_base}}/admin/oauth/token",
              "host": [
                "{{admin_base}}"
              ],
              "path": [
                "admin",
                "oauth",
                "token"
              ]
            },
            "description": "OAuth 2.0 client credentials. **Form-encoded**, because that is what Salesforce's External Credential sends.\n\nThe token lasts 15 minutes and Named Credentials cache it for its lifetime — that is one token request per 15 minutes per org, not one per call.\n\nYou do not normally need to run this by hand. The collection pre-request script does it automatically and caches the result."
          }
        },
        {
          "name": "Who am I",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "X-Trivora-Actor",
                "value": "{{actor_user_id}}"
              },
              {
                "key": "X-Trivora-Actor-Name",
                "value": "{{actor_name}}"
              }
            ],
            "url": {
              "raw": "{{admin_base}}/admin/whoami",
              "host": [
                "{{admin_base}}"
              ],
              "path": [
                "admin",
                "whoami"
              ]
            },
            "description": "Confirms the Named Credential is wired up correctly before anyone relies on the audit trail.\n\nCheck three things in the response:\n- `scopes` contains `tenant`\n- `org_scope` is your organisation id, not null\n- `actor_user_id` echoes back what you sent\n\nA null `actor_user_id` means the header is not reaching us, and every write will be rejected with 400. Note the headers are `X-Trivora-Actor` and `X-Trivora-Actor-Name` — `-Label` is the field name in the *response*, not a header, and sending it silently yields nothing."
          }
        }
      ]
    },
    {
      "name": "Conversations",
      "description": "The hot path. One session mint per conversation, then the browser talks to the gateway directly.",
      "item": [
        {
          "name": "Mint a session key",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "if (pm.response.code === 201) {",
                  "  const b = pm.response.json();",
                  "  pm.environment.set('session_key', b.key);",
                  "  pm.environment.set('gateway_base', b.gateway_base);",
                  "  console.log('models: ' + b.models.join(', ') + ' | expires in ' + b.expires_in + 's');",
                  "  console.log('seat licensed ' + b.license.start + ' to ' + b.license.end);",
                  "}",
                  "pm.test('minted', () => pm.response.to.have.status(201));",
                  "pm.test('reasoning tier is NOT in a browser key', () => {",
                  "  pm.expect(pm.response.json().models).to.not.include('trivora-reasoning');",
                  "});",
                  "// The key may be SHORTER than 15 minutes near a licence expiry — it is",
                  "// clamped to min(15 min, license_end - now). Never assume 900.",
                  "pm.test('key cannot outlive the licence', () => {",
                  "  pm.expect(pm.response.json().expires_in).to.be.at.most(15 * 60);",
                  "});"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "X-Trivora-Actor",
                "value": "{{actor_user_id}}",
                "description": "REQUIRED. The Salesforce User Id, set by Apex from UserInfo.getUserId(). This is the identity the key is minted for and the seat that gets billed — never take it from a request body, or a user can mint a key attributed to a colleague."
              },
              {
                "key": "X-Trivora-Actor-Name",
                "value": "{{actor_name}}"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"display_name\": \"Priya Sharma\",\n  \"email\": \"priya@acme.com\",\n  \"conversation_id\": \"a0X000000000001\"\n}"
            },
            "url": {
              "raw": "{{admin_base}}/admin/orgs/{{org_ref}}/sessions",
              "host": [
                "{{admin_base}}"
              ],
              "path": [
                "admin",
                "orgs",
                "{{org_ref}}",
                "sessions"
              ]
            },
            "description": "Claims a licence seat if the user does not have one, then mints a short-lived scoped key for the browser.\n\n**Seat registration happens here and cannot be skipped.** `/key/generate` creates no organisation membership and `/user/new` ignores `organization_id` outright, so a user who gets a key without this call transacts, bills the organisation, and attributes to nobody.\n\n### Seats are licence slots\nThe contract creates N empty seat rows at onboarding, each carrying its own `license_start` / `license_end`. This call binds the user to a free one. Claiming is a single atomic statement, so concurrent first-mints cannot overshoot the seat count.\n\n**The licence window is checked on EVERY mint, not just the first.** A lapsed seat stops the next conversation, not merely the next new hire:\n\n| Response | Meaning | What to show the user |\n|---|---|---|\n| `201` | key issued | — |\n| `409` | no seat available | Read `detail` — it distinguishes *cap reached*, *seats lapsed* and *seats start on a future date*. Three different remedies. |\n| `403 … licence starts on <date>` | seat is scheduled, not yet live | \"Your licence begins on X.\" |\n| `403 … licence ended on <date>` | seat lapsed | \"Your licence ended on X — contact your admin.\" |\n\n### ⚠️ `expires_in` is NOT always 900\nThe key TTL is `min(15 min, license_end − now)`. Near an expiry it can be minutes or seconds. **Always read `expires_in` from the response**; hardcoding 15 minutes means the browser keeps using a key the gateway has already stopped honouring.\n\nWhy the clamp exists: a seat minting at 23:58 on its last day would otherwise hold a working key until 00:13, and the browser has it — nothing can take it back.\n\n### Response\n```json\n{\n  \"key\": \"sk-…\",\n  \"gateway_base\": \"https://ai.trivora.tech\",\n  \"models\": [\"trivora-premium\", \"trivora-standard\", \"trivora-extract\"],\n  \"expires_at\": \"2026-08-07T12:15:00+00:00\",\n  \"expires_in\": 900,\n  \"seat_created\": false,\n  \"reasoning_available\": true,\n  \"license\": { \"id\": 42, \"start\": \"2026-01-01\", \"end\": \"2026-12-31\" }\n}\n```\n\n`license` is echoed so the LWC can show \"licensed until\" without a second call.\n\n**No Idempotency-Key.** This is on the hot path of every conversation and a duplicate just produces a second short-lived key; requiring one would add a round trip to the thing users wait on."
          }
        },
        {
          "name": "Request an escalation",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const b = pm.response.json();",
                  "if (b.granted) {",
                  "  pm.environment.set('escalation_key', b.key);",
                  "  console.log('granted — one-shot key, ' + b.expires_in + 's');",
                  "} else {",
                  "  console.log('DENIED: ' + b.deny_reason + ' — show the user a sentence, not an error');",
                  "}",
                  "pm.test('answered', () => pm.expect([200,201]).to.include(pm.response.code));"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "X-Trivora-Actor",
                "value": "{{actor_user_id}}"
              },
              {
                "key": "X-Trivora-Actor-Name",
                "value": "{{actor_name}}"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"conversation_id\": \"a0X000000000001\",\n  \"reason\": \"Question requires comparing pipeline movement across two quarters and attributing the delta.\"\n}"
            },
            "url": {
              "raw": "{{admin_base}}/admin/orgs/{{org_ref}}/escalations",
              "host": [
                "{{admin_base}}"
              ],
              "path": [
                "admin",
                "orgs",
                "{{org_ref}}",
                "escalations"
              ]
            },
            "description": "Decides whether this user may reach the reasoning tier, mints a one-shot key if so, and records the decision either way.\n\n**This exists because the session key cannot reach `trivora-reasoning`.** That exclusion is the control point: the browser holds the session key, so anything checked in the LWC is advisory, but a model the key does not list is simply refused by the gateway (*\"key not allowed to access model\"* — verified against the live gateway, not asserted).\n\n**A denial is a 200 with `granted: false`, not a 4xx.** The caller is an agent mid-conversation that needs to say something useful. An HTTP error gets handled as a fault and surfaces as \"something went wrong\" instead of \"your plan does not include deep analysis\". Read `deny_reason`:\n\n| `deny_reason` | What to tell the user |\n|---|---|\n| `plan_not_entitled` | Deep analysis is not on this plan. |\n| `daily_cap` | Nothing. This is an internal anti-runaway guardrail, not a product limit — a human hitting it means something is looping upstream. |\n\nThe granted key is 5 minutes and $0.10 — a reasoning turn lists at $0.050, so it buys two and no more.\n\n`reason` is the model's own stated justification. Keep sending it: it is the only evidence for whether the `escalate_analysis` tool description is working, which is still an open question."
          }
        },
        {
          "name": "Escalation history",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "X-Trivora-Actor",
                "value": "{{actor_user_id}}"
              }
            ],
            "url": {
              "raw": "{{admin_base}}/admin/orgs/{{org_ref}}/escalations?limit=100",
              "host": [
                "{{admin_base}}"
              ],
              "path": [
                "admin",
                "orgs",
                "{{org_ref}}",
                "escalations"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "100"
                }
              ]
            },
            "description": "Who asked, whether it was granted, and why not.\n\nLiteLLM sees the reasoning *call* and never the *decision*. This is the only thing that answers \"why was our bill higher last month\", and the only place a denial is visible at all — a denied escalation makes no gateway request, so it appears in no usage report."
          }
        }
      ]
    },
    {
      "name": "Gateway — chat",
      "description": "OpenAI-compatible. Authenticated with the **session key**, not the OAuth token.\n\nPoint any OpenAI SDK at `{{gateway_base}}/v1` and it works unchanged.",
      "item": [
        {
          "name": "List models",
          "request": {
            "auth": {
              "type": "bearer",
              "bearer": [
                {
                  "key": "token",
                  "value": "{{session_key}}",
                  "type": "string"
                }
              ]
            },
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{gateway_base}}/v1/models",
              "host": [
                "{{gateway_base}}"
              ],
              "path": [
                "v1",
                "models"
              ]
            },
            "description": "What this key may call. A session key lists only what it was scoped to, so this is also how the LWC discovers whether the reasoning tier is reachable — it never is on a session key."
          }
        },
        {
          "name": "Chat completion",
          "request": {
            "auth": {
              "type": "bearer",
              "bearer": [
                {
                  "key": "token",
                  "value": "{{session_key}}",
                  "type": "string"
                }
              ]
            },
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"model\": \"trivora-premium\",\n  \"messages\": [\n    { \"role\": \"system\", \"content\": \"You are a Salesforce CRM assistant.\" },\n    { \"role\": \"user\", \"content\": \"Summarise this account's open opportunities.\" }\n  ],\n  \"max_tokens\": 512\n}"
            },
            "url": {
              "raw": "{{gateway_base}}/v1/chat/completions",
              "host": [
                "{{gateway_base}}"
              ],
              "path": [
                "v1",
                "chat",
                "completions"
              ]
            },
            "description": "**Start every interactive turn on `trivora-premium`.** There is no cheaper rung to start on, and there deliberately is not one — see the model table below.\n\n**Keep the whole conversation on one model.** Escalation is a separate one-shot call, not a model switch, precisely so the prompt cache built over prior turns survives. Cache hit rate is load-bearing for margin.\n\n| Model | Use it for |\n|---|---|\n| `trivora-premium` | Every interactive turn. Caches in 256-token blocks. |\n| `trivora-standard` | Deprecated alias of premium, at premium's price. Exists only so code already written against it keeps working. |\n| `trivora-extract` | Structured extraction, classification, strict JSON. Better tool discipline, **no prompt caching** — do not put a conversation on it. |\n| `trivora-reasoning` | Escalation only, via a one-shot key. |\n\nCheck `usage.prompt_tokens_details.cached_tokens` in the response — that is the number the margin depends on. Groq's `prompt_tokens` is the **total** and includes cached tokens; Anthropic's is the uncached remainder. Getting that backwards misreads the bill by ~20%."
          }
        },
        {
          "name": "Chat completion (streaming)",
          "request": {
            "auth": {
              "type": "bearer",
              "bearer": [
                {
                  "key": "token",
                  "value": "{{session_key}}",
                  "type": "string"
                }
              ]
            },
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "Accept",
                "value": "text/event-stream"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"model\": \"trivora-premium\",\n  \"messages\": [\n    { \"role\": \"user\", \"content\": \"Walk me through last quarter's pipeline.\" }\n  ],\n  \"stream\": true,\n  \"stream_options\": { \"include_usage\": true }\n}"
            },
            "url": {
              "raw": "{{gateway_base}}/v1/chat/completions",
              "host": [
                "{{gateway_base}}"
              ],
              "path": [
                "v1",
                "chat",
                "completions"
              ]
            },
            "description": "**Always send `stream_options.include_usage`.** Without it the final chunk carries no `usage` object, the request bills as zero, and revenue silently vanishes. There is no error and no warning.\n\nStreaming is why the browser calls the gateway directly. Apex cannot stream, and its **cumulative callout limit is 120 seconds per transaction — async does not lift it, and `Continuation` is unsupported in LWC and capped at 120s regardless**. Reasoning-tier turns routinely exceed that.\n\nStreaming also sidesteps the ALB's idle timeout: each SSE chunk resets the timer. A **non-streaming** call has 300 seconds and no more."
          }
        },
        {
          "name": "Tool calling + escalate_analysis",
          "request": {
            "auth": {
              "type": "bearer",
              "bearer": [
                {
                  "key": "token",
                  "value": "{{session_key}}",
                  "type": "string"
                }
              ]
            },
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"model\": \"trivora-premium\",\n  \"messages\": [\n    { \"role\": \"user\", \"content\": \"Why did our pipeline drop in Q3?\" }\n  ],\n  \"tools\": [\n    {\n      \"type\": \"function\",\n      \"function\": {\n        \"name\": \"query_records\",\n        \"description\": \"Fetch CRM records. Emit the query INTENT; Apex executes it.\",\n        \"parameters\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"object\": { \"type\": \"string\", \"enum\": [\"Opportunity\", \"Account\", \"Contact\"] },\n            \"fields\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n            \"filters\": { \"type\": \"object\" }\n          },\n          \"required\": [\"object\", \"fields\"]\n        }\n      }\n    },\n    {\n      \"type\": \"function\",\n      \"function\": {\n        \"name\": \"escalate_analysis\",\n        \"description\": \"Call this INSTEAD OF ANSWERING when you cannot complete the task confidently with the records you have. Example: the user asks why a metric moved and the answer requires attributing a change across several related objects.\",\n        \"parameters\": {\n          \"type\": \"object\",\n          \"properties\": { \"reason\": { \"type\": \"string\" } },\n          \"required\": [\"reason\"]\n        }\n      }\n    }\n  ],\n  \"tool_choice\": \"auto\"\n}"
            },
            "url": {
              "raw": "{{gateway_base}}/v1/chat/completions",
              "host": [
                "{{gateway_base}}"
              ],
              "path": [
                "v1",
                "chat",
                "completions"
              ]
            },
            "description": "**Never let the model touch raw rows.** The pattern is: model emits structured query intent → Apex executes SOQL deterministically → model narrates the exact numbers. LLMs are unreliable at arithmetic over hundreds of rows; SOQL aggregates are exact. What we need from the model is tool calling and strict JSON adherence, not analytical horsepower. Charts follow the same rule — the model emits a spec, the LWC renders it.\n\n**Tier escalation is model-driven, not classifier-driven.** A classifier sees the question cold; the model has already read the retrieved records. \"Why did pipeline drop?\" is unclassifiable from the prompt alone.\n\n⚠️ **Measured: `trivora-premium` falsely escalates on 27% of trivial questions**, against 73% correct escalation on hard ones. At 28× the cost of a premium turn, over-escalation is the expensive failure mode — and it was not the anticipated one. Fix it with **concrete positive examples in the tool description**, as above, and by trying `reasoning_effort`. Do *not* fix it with \"you MUST escalate when unsure\", which makes it worse.\n\nWhen the model calls `escalate_analysis`, do not switch models. Call `POST /escalations`, get a one-shot key, make a single reasoning call, and continue the conversation on premium so the prompt cache survives."
          }
        },
        {
          "name": "Structured extraction (trivora-extract)",
          "request": {
            "auth": {
              "type": "bearer",
              "bearer": [
                {
                  "key": "token",
                  "value": "{{session_key}}",
                  "type": "string"
                }
              ]
            },
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"model\": \"trivora-extract\",\n  \"messages\": [\n    { \"role\": \"system\", \"content\": \"Extract fields as JSON. Return only JSON.\" },\n    { \"role\": \"user\", \"content\": \"Call notes: Spoke to Rajesh at Runwal Enterprises about a 2 BHK in Dombivli, 650 sq ft carpet. Budget 85 lakh. Follow up Tuesday.\" }\n  ],\n  \"response_format\": { \"type\": \"json_object\" },\n  \"max_tokens\": 512\n}"
            },
            "url": {
              "raw": "{{gateway_base}}/v1/chat/completions",
              "host": [
                "{{gateway_base}}"
              ],
              "path": [
                "v1",
                "chat",
                "completions"
              ]
            },
            "description": "For extraction, classification and tagging — the latency-tolerant bulk lane that Apex can call directly, inside its 120-second budget.\n\nMeasured over 253 live requests, `trivora-extract` is **100% on tool, object and classification adherence** where premium is not, and it produces ~5.5× fewer output tokens because it does not emit a visible chain of thought.\n\n⚠️ **It has zero prompt caching** — verified, 0 cached tokens across 119 successful calls including many repeats of an identical prompt. So it is the wrong choice for a conversation, where ~49% of input volume is normally cached, and the right choice for one-shot structured work where there is nothing to cache anyway.\n\n⚠️ It also produces a real failure mode premium does not: a malformed function call comes back as a **400 `tool_use_failed`**, not a degraded answer. Handle it. Safer than a confident wrong answer, but it is an error your code has to catch."
          }
        },
        {
          "name": "Reasoning tier (one-shot key)",
          "request": {
            "auth": {
              "type": "bearer",
              "bearer": [
                {
                  "key": "token",
                  "value": "{{escalation_key}}",
                  "type": "string"
                }
              ]
            },
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"model\": \"trivora-reasoning\",\n  \"messages\": [\n    { \"role\": \"user\", \"content\": \"Here are the aggregates Apex computed. Explain what drove the change and what to do about it.\" }\n  ],\n  \"max_tokens\": 2048\n}"
            },
            "url": {
              "raw": "{{gateway_base}}/v1/chat/completions",
              "host": [
                "{{gateway_base}}"
              ],
              "path": [
                "v1",
                "chat",
                "completions"
              ]
            },
            "description": "Uses `{{escalation_key}}` — the one-shot key from `POST /escalations`. A session key sent here is refused by the gateway with *\"key not allowed to access model\"*.\n\nA reasoning turn costs **28× a premium turn**. That ratio is why escalation is a server-side decision with an audit record rather than a client-side model swap.\n\nThis call is **not streamed** in the usual design, which puts it against the ALB's 300-second non-streaming ceiling. It comfortably fits — but the 40-odd second wait does not read as thinking unless you say so. Show \"Looking at this more closely…\" in the LWC; a silent 40 seconds reads as a hang."
          }
        }
      ]
    },
    {
      "name": "Gateway — transcription",
      "description": "Two tiers, and they are different capabilities rather than two grades of one.",
      "item": [
        {
          "name": "Transcribe (English-only calls)",
          "request": {
            "auth": {
              "type": "bearer",
              "bearer": [
                {
                  "key": "token",
                  "value": "{{session_key}}",
                  "type": "string"
                }
              ]
            },
            "method": "POST",
            "header": [],
            "body": {
              "mode": "formdata",
              "formdata": [
                {
                  "key": "file",
                  "type": "file",
                  "src": [],
                  "description": "audio/mpeg, audio/wav, audio/m4a…"
                },
                {
                  "key": "model",
                  "value": "trivora-transcribe",
                  "type": "text"
                },
                {
                  "key": "language",
                  "value": "en",
                  "type": "text"
                },
                {
                  "key": "prompt",
                  "value": "This is a call with Priya Sharma of Oberoi Realty about a property in Thane.",
                  "type": "text",
                  "description": "A SENTENCE, not a keyword list. See the description."
                }
              ]
            },
            "url": {
              "raw": "{{gateway_base}}/v1/audio/transcriptions",
              "host": [
                "{{gateway_base}}"
              ],
              "path": [
                "v1",
                "audio",
                "transcriptions"
              ]
            },
            "description": "**For conversations that are entirely in English.** Fast — ~108× realtime, 0.8s on a 61-second call — and half the cost of the pro tier.\n\n⚠️ **Without a prompt it silently destroys proper nouns.** Measured on a real Indian-accented English call over 3 repetitions: \"Oberoi Realty\" came back as *\"overall reality\"* **3 times out of 3**. Ordinary English is perfect; brand names are not. And *\"overall reality\"* is a plausible English phrase, so no spell-check, confidence threshold, or human skim catches it.\n\nWith a prompt built from CRM context: **3/3 correct, zero leakage** — despite the prompt naming four entities absent from that call.\n\nTwo rules for `prompt`, both non-obvious and both measured:\n\n1. **Prose, not a list.** A comma-separated keyword list scored 0/3 *and* stripped the sentence punctuation, turning the transcript into `\"Hello, hello, hello, audible, yes sir you are audible, so...\"`. `prompt` is a **continuation** mechanism — the model copies the prompt's style as well as its vocabulary. Build a sentence from the CRM record; do not join names with commas.\n2. ⚠️ **Never prompt non-English audio.** An English prompt against a Marathi call injected a sentence that is not in the recording — *\"Names of the people who have been affected by the pandemic\"* — and inverted \"were you looking for property\" into \"did you sell property\". The fabrication came from the language mismatch, not from prompting as such.\n\nAlso: this tier returns **segments only**. `timestamp_granularities[]=word` is accepted and ignored. And it normalises numerals — `$42,000` where the pro tier writes `forty-two thousand dollars`."
          }
        },
        {
          "name": "Transcribe Pro (multilingual, diarised)",
          "request": {
            "auth": {
              "type": "bearer",
              "bearer": [
                {
                  "key": "token",
                  "value": "{{session_key}}",
                  "type": "string"
                }
              ]
            },
            "method": "POST",
            "header": [],
            "body": {
              "mode": "formdata",
              "formdata": [
                {
                  "key": "file",
                  "type": "file",
                  "src": []
                },
                {
                  "key": "model",
                  "value": "trivora-transcribe-pro",
                  "type": "text"
                },
                {
                  "key": "diarize",
                  "value": "true",
                  "type": "text"
                },
                {
                  "key": "detect_speaker_roles",
                  "value": "true",
                  "type": "text"
                },
                {
                  "key": "keyterms",
                  "value": "Runwal Enterprises",
                  "type": "text",
                  "description": "REPEATED FIELD, one term each, under 50 chars. Not a JSON array."
                },
                {
                  "key": "keyterms",
                  "value": "Oberoi Realty",
                  "type": "text"
                },
                {
                  "key": "keyterms",
                  "value": "Dombivli",
                  "type": "text"
                },
                {
                  "key": "keyterms",
                  "value": "Priya Sharma",
                  "type": "text"
                }
              ]
            },
            "url": {
              "raw": "{{gateway_base}}/v1/audio/transcriptions",
              "host": [
                "{{gateway_base}}"
              ],
              "path": [
                "v1",
                "audio",
                "transcriptions"
              ]
            },
            "description": "**The default for real call recording.** Sell it on entity accuracy, not on multilingual — the accuracy argument applies to English customers too, and anyone logging calls against CRM records needs the names correct.\n\n**Measured, on real calls:**\n\n| | base | pro |\n|---|---|---|\n| \"Oberoi Realty\", Indian-accented English, 3 reps | **0/3** → *\"overall reality\"* | **3/3** |\n| Marathi/English code-switched | **translates it to English** and invents brand names | auto-detects `mar` at p=0.96, preserves the English verbatim |\n| Latency, 61s call | 0.8s | 3.3s |\n\n`language=en` on the base tier changes none of that.\n\n**`keyterms` is the feature that makes this a product.** Passing the org's Account / Contact / Project names took brand capture from 1/3 to **3/3 with zero leakage**, and returned the entities in **CRM spelling** — `Runwal Enterprises`, `Dombivli` in Latin script — while leaving the rest of the sentence in Devanagari. Downstream matching becomes exact string comparison. Costs ~15% latency.\n\n⚠️ **`keyterms` must be repeated form fields**, one term per field, each under 50 characters. A JSON array arrives as one oversized keyword and is rejected with *\"All keywords must be less than 50 characters\"*.\n\n**Routing:** Apex usually will not know the call language in advance. Default to pro in multilingual markets and let English-only customers opt down. Running base first and re-running on pro when `language != en` doubles the cost on exactly the calls that are already the expensive ones.\n\n**Also available:** `entity_redaction` (the only PII control that exists for audio — the field-level denylist cannot reach inside a waveform), `num_speakers`, `seed` (entity capture varies run to run without it, so single-run comparisons are worthless), `no_verbatim`, and `source_url` + `webhook`.\n\n⚠️ **Hard limit: this tier runs at ~9.7× realtime and the ALB cuts non-streaming requests at 300 seconds — so any recording over ~48 minutes is killed mid-request**, surfacing as a dropped connection rather than an error. Hour-long sales calls are normal. Use `source_url` + `webhook`, or chunk."
          }
        }
      ]
    },
    {
      "name": "Seats",
      "description": "Seats are created by the CUSTOMER's org, not by Trivora — normally as a side effect of the first session mint.",
      "item": [
        {
          "name": "List seats",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "X-Trivora-Actor",
                "value": "{{actor_user_id}}"
              }
            ],
            "url": {
              "raw": "{{admin_base}}/admin/orgs/{{org_ref}}/seats",
              "host": [
                "{{admin_base}}"
              ],
              "path": [
                "admin",
                "orgs",
                "{{org_ref}}",
                "seats"
              ]
            },
            "description": "Seats, their licence windows, and the organisation's capacity `inventory`.\n\n**Capacity is a step function**, because seats carry independent end dates. A contract that renewed 40 of 50 seats for six months has no single \"seat count\":\n\n```json\n\"inventory\": {\n  \"seats_total\": 50, \"seats_live\": 50, \"seats_assigned\": 46,\n  \"seats_available\": 4, \"seats_scheduled\": 0, \"seats_lapsed\": 0,\n  \"seats_retired\": 0,\n  \"next_expiry\": \"2027-06-30\", \"last_expiry\": \"2027-12-31\"\n}\n```\n\n`next_expiry` is the date the org first loses capacity — the number a renewal conversation starts from. Surface it in the tenant org; it is the one field that turns a silent lapse into a planned renewal.\n\nEach seat row carries `license_id`, `license_start`, `license_end` and `license_active`."
          }
        },
        {
          "name": "Register a seat explicitly",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "X-Trivora-Actor",
                "value": "{{actor_user_id}}"
              },
              {
                "key": "X-Trivora-Actor-Name",
                "value": "{{actor_name}}"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{$guid}}",
                "description": "REQUIRED."
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"salesforce_user_id\": \"005000000000002AAA\",\n  \"display_name\": \"Rajesh Kumar\",\n  \"email\": \"rajesh@acme.com\"\n}"
            },
            "url": {
              "raw": "{{admin_base}}/admin/orgs/{{org_ref}}/seats",
              "host": [
                "{{admin_base}}"
              ],
              "path": [
                "admin",
                "orgs",
                "{{org_ref}}",
                "seats"
              ]
            },
            "description": "Optional. Pre-registering a seat before the user's first conversation, e.g. to reserve capacity during a rollout.\n\nYou normally do not need this — `POST /sessions` registers the seat on first use. Use it when you want the seat count to reflect an intended rollout rather than actual usage.\n\nOne Salesforce user belongs to one organisation. Sharing a user across tenants would make their spend unattributable to either, so a user who already holds a seat elsewhere is rejected with 409."
          }
        },
        {
          "name": "Get a seat's allowance",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "X-Trivora-Actor",
                "value": "{{actor_user_id}}"
              }
            ],
            "url": {
              "raw": "{{admin_base}}/admin/orgs/{{org_ref}}/seats/005000000000001AAA/allowance",
              "host": [
                "{{admin_base}}"
              ],
              "path": [
                "admin",
                "orgs",
                "{{org_ref}}",
                "seats",
                "005000000000001AAA",
                "allowance"
              ]
            },
            "description": "What this seat may spend this month, and what is left.\n\n### The allowance follows the SEAT, not the person\nA rep who leaves on the 15th having spent 400 of 500 leaves **100** for whoever takes the slot next — not a fresh 500. `credits_used_mtd` is the slot's consumption for the month, which may span two occupants: `carried_from_prior_occupants_usd` is the departed rep's share.\n\nWithout that, rotating a user out and back in would reset the allowance on demand.\n\n`included_remaining` is this seat's own and no other seat can consume it. `org_purchased_credits` is the shared pool it may draw on once its own is gone.\n\nCall this when a user reports being cut off: it separates *this seat is exhausted* (raise the override, or buy credits) from *the org pool is exhausted* (only a purchase helps)."
          }
        },
        {
          "name": "Raise one seat's allowance",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "X-Trivora-Actor",
                "value": "{{actor_user_id}}"
              },
              {
                "key": "X-Trivora-Actor-Name",
                "value": "{{actor_name}}"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"included_credits_override\": 2000,\n  \"note\": \"On calls all week — inside side letter, approved by AM\"\n}"
            },
            "url": {
              "raw": "{{admin_base}}/admin/orgs/{{org_ref}}/seats/005000000000001AAA/allowance",
              "host": [
                "{{admin_base}}"
              ],
              "path": [
                "admin",
                "orgs",
                "{{org_ref}}",
                "seats",
                "005000000000001AAA",
                "allowance"
              ]
            },
            "description": "Raise or clear one seat's included allowance. `null` clears the override and returns the seat to the contract's `included_credits_per_seat`.\n\nThis exists *because* the allowance is per seat: without it, the only remedy for a single blocked rep is buying purchased credits for the whole organisation — a poor answer when one person happens to be on calls all week.\n\n**No Idempotency-Key.** This sets an absolute value rather than applying a delta, so a duplicate call is a no-op by construction. Unlike the credit ledger, where a repeated purchase is a second purchase.\n\nThe new ceiling is pushed to LiteLLM immediately rather than on the next hourly sweep. Waiting would mean raising a blocked rep's allowance and telling them to come back in an hour."
          }
        },
        {
          "name": "Release a seat",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "X-Trivora-Actor",
                "value": "{{actor_user_id}}"
              },
              {
                "key": "X-Trivora-Actor-Name",
                "value": "{{actor_name}}"
              }
            ],
            "url": {
              "raw": "{{admin_base}}/admin/orgs/{{org_ref}}/seats/005000000000002AAA",
              "host": [
                "{{admin_base}}"
              ],
              "path": [
                "admin",
                "orgs",
                "{{org_ref}}",
                "seats",
                "005000000000002AAA"
              ]
            },
            "description": "Frees the licence slot for someone else.\n\n**This is what crystallises the outgoing occupant's spend onto the slot.** Releasing through this endpoint is what makes the replacement inherit the remaining allowance. Deleting the LiteLLM membership by any other route would lose that and hand the next person a full allowance.\n\nReturns `license_freed` — the slot id, now assignable.\n\nUsage history is retained deliberately. Deleting it would rewrite past invoices."
          }
        }
      ]
    },
    {
      "name": "Usage & logs (read-only)",
      "description": "A tenant credential reads only its own organisation. Anything else returns **404, not 403**, so it cannot probe which organisations exist.",
      "item": [
        {
          "name": "This organisation",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "X-Trivora-Actor",
                "value": "{{actor_user_id}}"
              }
            ],
            "url": {
              "raw": "{{admin_base}}/admin/orgs/{{org_ref}}",
              "host": [
                "{{admin_base}}"
              ],
              "path": [
                "admin",
                "orgs",
                "{{org_ref}}"
              ]
            },
            "description": "Contract, current entitlement, pending amendments, credit balance and seats in use.\n\n`pending_amendments` are signed changes with a future effective date — a December step-up signed in August appears here from August, and reaches the gateway only when the date arrives."
          }
        },
        {
          "name": "Monthly usage",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "X-Trivora-Actor",
                "value": "{{actor_user_id}}"
              }
            ],
            "url": {
              "raw": "{{admin_base}}/admin/orgs/{{org_ref}}/usage?month=2026-08",
              "host": [
                "{{admin_base}}"
              ],
              "path": [
                "admin",
                "orgs",
                "{{org_ref}}",
                "usage"
              ],
              "query": [
                {
                  "key": "month",
                  "value": "2026-08",
                  "description": "YYYY-MM. Defaults to the current month."
                }
              ]
            },
            "description": "Credits used, tier mix, error rate, and the pooled balance for the month.\n\n**1 credit = $0.01 of Trivora list price.** That identity holds exactly, because the gateway is priced at sell rates and its spend counter *is* revenue.\n\nPublish rates to end users **in credits, not currency**. Audio especially: it is the one thing priced in the same unit the commodity vendors quote, so a dollar figure invites a direct comparison against running Whisper yourself. Credits are friction, not concealment — the identity is public and the comparison is one arithmetic step away. What it buys is that audio stops being a standalone line item to shop around."
          }
        },
        {
          "name": "Per-seat usage",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "X-Trivora-Actor",
                "value": "{{actor_user_id}}"
              }
            ],
            "url": {
              "raw": "{{admin_base}}/admin/orgs/{{org_ref}}/usage/seats?month=2026-08",
              "host": [
                "{{admin_base}}"
              ],
              "path": [
                "admin",
                "orgs",
                "{{org_ref}}",
                "usage",
                "seats"
              ],
              "query": [
                {
                  "key": "month",
                  "value": "2026-08"
                }
              ]
            },
            "description": "Credits, tier split, reasoning count, cache hit rate, and mean input/output tokens per seat.\n\n**Two percentages come back and they answer different questions. Keep both:**\n\n| Column | Seat measured against | Read it when |\n|---|---|---|\n| `pct_of_notional_share` | its own included allowance | sizing the allowance |\n| `pct_of_org_consumption` | everything the organisation used | the pool nears its ceiling — *who is eating it* |\n\nThey diverge hard, which is the point. A seat can sit at 31% of its notional share while being 89% of all consumption. Only the second names the seat worth a phone call.\n\n`avg_prompt_tokens` / `avg_completion_tokens` are how the 3k-input / 400-output assumption behind every cost figure finally gets validated against real traffic."
          }
        },
        {
          "name": "Usage trend",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "X-Trivora-Actor",
                "value": "{{actor_user_id}}"
              }
            ],
            "url": {
              "raw": "{{admin_base}}/admin/orgs/{{org_ref}}/usage/trend?months=12",
              "host": [
                "{{admin_base}}"
              ],
              "path": [
                "admin",
                "orgs",
                "{{org_ref}}",
                "usage",
                "trend"
              ],
              "query": [
                {
                  "key": "months",
                  "value": "12"
                }
              ]
            },
            "description": "Month over month, oldest first. **Size the allowance from `p95_seat_credits`, not the mean.**\n\nBe careful applying the usual pooling intuition here. Light seats absorbing heavy ones depends on usage being *uncorrelated* across seats. Call recording is a team practice — if the organisation records calls, every rep records calls — so audio consumption is correlated and the pool provides no absorption at all. Modelled at 75 credits per audio-hour, the ratio is identical at 5 seats and 50."
          }
        },
        {
          "name": "Unattributed spend",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "X-Trivora-Actor",
                "value": "{{actor_user_id}}"
              }
            ],
            "url": {
              "raw": "{{admin_base}}/admin/orgs/{{org_ref}}/usage/unattributed?month=2026-08",
              "host": [
                "{{admin_base}}"
              ],
              "path": [
                "admin",
                "orgs",
                "{{org_ref}}",
                "usage",
                "unattributed"
              ],
              "query": [
                {
                  "key": "month",
                  "value": "2026-08"
                }
              ]
            },
            "description": "**This should always be zero. Alert on it.**\n\nNon-zero means something minted a session key for a user it never registered. That spend bills the organisation but belongs to no seat, so it is invisible in every per-seat view — and it is always a bug in the minting path, never a legitimate state.\n\nThe seat→org edge is `LiteLLM_OrganizationMembership` and nothing else populates it. This endpoint reconciles seat rows against org rows and is non-zero exactly when that edge is missing."
          }
        },
        {
          "name": "Credit ledger",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "X-Trivora-Actor",
                "value": "{{actor_user_id}}"
              }
            ],
            "url": {
              "raw": "{{admin_base}}/admin/orgs/{{org_ref}}/credits?limit=50",
              "host": [
                "{{admin_base}}"
              ],
              "path": [
                "admin",
                "orgs",
                "{{org_ref}}",
                "credits"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50"
                }
              ]
            },
            "description": "Balance plus the grant and purchase history.\n\nThe ledger is append-only and records **grants and purchases only — never consumption**. Consumption is derived from the gateway's own spend rollup at read time. Writing usage into a ledger is how ledgers drift from the system of record."
          }
        },
        {
          "name": "Request log",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "X-Trivora-Actor",
                "value": "{{actor_user_id}}"
              }
            ],
            "url": {
              "raw": "{{admin_base}}/admin/orgs/{{org_ref}}/requests?month=2026-08&limit=100",
              "host": [
                "{{admin_base}}"
              ],
              "path": [
                "admin",
                "orgs",
                "{{org_ref}}",
                "requests"
              ],
              "query": [
                {
                  "key": "month",
                  "value": "2026-08"
                },
                {
                  "key": "status",
                  "value": "failure",
                  "disabled": true
                },
                {
                  "key": "model",
                  "value": "trivora-premium",
                  "disabled": true
                },
                {
                  "key": "limit",
                  "value": "100"
                },
                {
                  "key": "before",
                  "value": "",
                  "disabled": true,
                  "description": "Keyset cursor — pass the previous page's next_before."
                }
              ]
            },
            "description": "One row per request, newest first.\n\n**Keyset pagination, not offset.** The spend log grows without bound and is never auto-pruned, so offset would degrade linearly with a tenant's history *and* skip or repeat rows when new requests land mid-page. Page by passing the previous response's `next_before`.\n\nNo prompt or completion content is here to read — message logging is off, so those columns hold `{}` on every row. That is what makes this safe to surface to a customer at all."
          }
        },
        {
          "name": "Error summary",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "X-Trivora-Actor",
                "value": "{{actor_user_id}}"
              }
            ],
            "url": {
              "raw": "{{admin_base}}/admin/orgs/{{org_ref}}/errors?month=2026-08&limit=20",
              "host": [
                "{{admin_base}}"
              ],
              "path": [
                "admin",
                "orgs",
                "{{org_ref}}",
                "errors"
              ],
              "query": [
                {
                  "key": "month",
                  "value": "2026-08"
                },
                {
                  "key": "limit",
                  "value": "20"
                }
              ]
            },
            "description": "Failure rate, failures grouped by cause, and recent samples.\n\n**`exception_type` is usually null.** LiteLLM's error log table is empty on this build — 0 rows against known failures — so grouping collapses to model alone. `status` is the reliable signal; treat the exception fields as a bonus if error logging is ever enabled.\n\nRead this alongside `GET /usage`, which carries `error_rate_pct` for the same month. The two are computed from **different tables**, so a persistent disagreement means requests are escaping one of them."
          }
        }
      ]
    }
  ]
}
