{
 "info": {
  "name": "StaqOn Internal API",
  "description": "The **Internal** surface — automation aimed at your own staff.\n\n## A note on message storage\n\nInternal API notifications (delivered via message.php) are stored separately from regular workspace chat messages and are not covered by StaqOn's encryption-at-rest feature. If you need encryption-at-rest coverage for message content, use regular workspace messaging via the web/app, or the External API, where this protection is available for encryption-enabled workspaces.\n\n**This collection is deliberately separate from the External API collection.** A key is bound to one surface: an internal key against an external endpoint returns `400 \"Linked team is not an active external team\"`, and vice versa. Do not copy requests between collections.\n\n**One-way.** There is no internal `messages.php` — nothing reads back.\n\n**Consent-gated.** Unlike the external surface (where the key alone authorises a write), `message.php` requires an *approved* account link. The member approves in their StaqOn account and can revoke at any time, which stops delivery immediately.\n\nSet `baseUrl` and `apiKey` once in Variables.\n\n**Consent boundary:** an API key can *request* a link and *revoke* a link — it can never *approve* one. Only the member can grant consent, in their StaqOn account.\n\n---\n\n## Rate limiting\n\n**100 requests/minute per key, rolling 60-second window** — a real `429 \"Rate limit exceeded. Max 100 requests/minute.\"` with a `Retry-After` header when exceeded. This is the same limit for every endpoint on this surface (`status`, `message`) — there is no per-endpoint override.\n\n**20,000 requests/day per key**, on top of the per-minute limit above, resetting at **midnight Asia/Kolkata (IST)**. A real `429` when exceeded: `{\"ok\":false,\"error\":\"Daily API limit reached (20,000 requests). Resets in 3h 12m.\",\"retry_after_seconds\":11520}`, with a matching `Retry-After` header (seconds until midnight IST). Both limits are checked on every authenticated request; whichever is hit first returns its own `429`.\n\n`status.php`'s `api_key_usage` field is a **lifetime** counter, not the current window — use `rate_remaining` for the live window figure.\n\n---\n\n## Which endpoints need an approved link\n\n| Key-only (no link needed) | Needs an approved link |\n|---|---|\n| `status` | `message` (sending a notification) |\n\n`message` returns `403 \"Member has not approved API messages\"` until the member's `staqon_links` row (workspace × member × `link_type='internal'`) is `approved`, and again the moment it's revoked.\n\nNote the inversion versus the External API: there writing is key-only and reading needs consent; here there is nothing to read and **writing** needs consent.\n\n## Consent — StaqOn's `/link` identity-linking system (2026-09-01)\n\nConsent is governed by `staqon_links` — one row per (workspace, member, `link_type='internal'`) — StaqOn's replacement for the old, removed per-team account-linking flow this collection used to document (`link-request.php` / `link-status.php` / `link-revoke.php` no longer exist).\n\n`/link` is the real, live, self-service mechanism, matching External's own rigor: a member types `/link` in chat (or uses the direct-click popup) and provides TWO required fields — a CRM ID and their email. StaqOn signs and POSTs both to the workspace's Internal Link webhook (`internal_link_keys.webhook_url`, HMAC-signed with `internal_link_keys.secret`). The row is marked `approved` ONLY if that webhook responds `{\"ok\": true}` — a genuine CRM-verified handshake, not an automatic approval on bare free text. A CRM ID alone, or an email alone, is never sufficient.\n\n**Outbound payload (added `username` and `trace_id`, 2026-09-03):**\n```json\n{\n  \"crm_id\": \"CRM100\",\n  \"email\": \"staff@yourcompany.com\",\n  \"username\": \"4821k9037\",\n  \"trace_id\": \"b3f1c2a4-7e5d-4a91-9c2e-1f6a8d0b3c5e\"\n}\n```\nSent with `X-StaqOn-Signature`, `X-StaqOn-Trace-Id` (matching the Chatbot API's own convention), and now also `X-StaqOn-Event: internal_link.verify` (added 2026-09-03) — same real shared-URL convention as External above, so `X-StaqOn-Event` is sent on every real call here too, verify or event. `username` is StaqOn's own real username for the staff member currently linking — already resolved server-side before this call is made. Store it against the matched staff record at verify-time, so it's ready for `message.php` without a separate lookup after approval.\n\nRevocation, in contrast, is real and already works today: once a row is `approved`, the member can revoke it anytime from **Account settings → Linked Accounts**, cutting off `message` delivery immediately (403).\n\n**Operational details, confirmed against real code:**\n- **No rate limit on `verify_internal` itself** — every submission runs the real CRM round-trip directly, live, uncached.\n- **No retry on your webhook's failure** — 3s call timeout, 2s connect timeout, one attempt, full stop.\n- **Any real 2xx counts as success** — same rule as External.\n- **`email` is validated before this call is made; `crm_id` is silently truncated to 190 characters, not rejected** — check for truncation on your side if your CRM ID scheme could exceed that.\n- **`linked_at` (and every timestamp on this page) is `Asia/Kolkata` (+05:30)** — not UTC.\n- **No proactive deactivation notice** — same real gap as External; polling the health endpoint is the only way to detect an auto-disabled key.\n- **No formal ordering/idempotency guarantee on `link.status_changed`** — same real caveat as External.\n- **Webhook URL is never tested for reachability at creation** — same real gap as External; verify it yourself before relying on it.\n- **Rotation is genuinely safe on the outbound side** — `internal_call_crm()` always signs with whatever secret is currently on file; your webhook never needs to try both secrets.\n\n## End-to-end sequence\n\n1. Create a key in **Workspace → Settings → API** against an **internal** team.\n2. `GET /status.php` — confirm the key and its team.\n3. Member's `staqon_links` row reaches `approved` — the member types `/link` in chat (or uses the popup), provides their CRM ID + email, and your webhook confirms the identity (see \"Consent\" above).\n4. `POST /message.php` — deliver notifications. `403` before approval.\n5. Subscribe to `member.*` / `escalation.*` webhooks (see **Webhooks**, below) to track activity — one webhook per **workspace** now, not per team.\n## Verifying webhook signatures (member.* / escalation.*)\n\nEvery webhook event is signed — **identically to the External API** (same `trigger_webhook()` function, same rules, same 5s timeout/no retry, same 10-consecutive-failure auto-disable). Two headers arrive with each delivery: **`X-StaqOn-Signature`** — a **hex-encoded** (lowercase, no `sha256=` prefix) HMAC-SHA256 of the **raw JSON body**, keyed by your webhook's secret — and **`X-StaqOn-Event`** carrying the event name.\n\n**Internal Link webhooks (2026-09-02):** a workspace's Internal Link key (`internal_link_keys`) carries its OWN independent `webhook_url`/`secret`/`events` — genuinely separate from the External `link_keys` row's own webhook, even though both can subscribe to the same real event name. Today the only real event on this list is `link.status_changed` (fired on a real Internal `/link` approval or revoke); `member.*`/`escalation.*` are not available on the Internal Link key — those remain External-only. Configure/rotate/subscribe from **Workspace → Settings → API → Link API → For Internal**.\n\n**PHP:**\n```php\n$raw = file_get_contents('php://input');\n$expected = hash_hmac('sha256', $raw, $yourWebhookSecret);\nif (!hash_equals($expected, $_SERVER['HTTP_X_STAQON_SIGNATURE'] ?? '')) {\n    http_response_code(401);\n    exit;\n}\n```\n\n**Node.js:**\n```javascript\nconst crypto = require('crypto');\nconst expected = crypto.createHmac('sha256', yourWebhookSecret).update(raw).digest('hex');\nconst sig = Buffer.from(req.headers['x-staqon-signature'] || '', 'utf8');\nconst exp = Buffer.from(expected, 'utf8');\nconst valid = sig.length === exp.length && crypto.timingSafeEqual(sig, exp);\nif (!valid) return res.status(401).end();\n```\n\n**Python:**\n```python\nimport hmac, hashlib\n\nexpected = hmac.new(your_webhook_secret.encode(), raw_body, hashlib.sha256).hexdigest()\nif not hmac.compare_digest(expected, request.headers.get('X-StaqOn-Signature', '')):\n    return Response(status=401)\n```\n\n**No retry — plan for delivery loss.** Exactly **one attempt** per event, no retry, no queue. A shared per-webhook `failure_count` increments on every non-2xx; at **10 consecutive failures** the webhook is set inactive (`is_active=0`) permanently — there is no automatic re-enable. Poll the unified health endpoint (see \"Health endpoint\" below) for `failure_count`/`is_active` if you need to detect this, rather than assuming delivery.\n\n## Username → StaqOn account resolution (worked example)\n\nEvery write on this surface (`message.php`) takes a plain `username` string and resolves it server-side with:\n\n```sql\nSELECT id FROM users WHERE username = ?\n```\n\n— a global, case-sensitive, unique lookup (`users.username` has a `UNIQUE` index) — there is no per-team scoping at this step. Team membership is checked *afterward*, as a separate condition:\n\n```sql\nSELECT 1 FROM team_members WHERE team_id = ? AND user_id = ?\n```\n\n**Worked example:** you call `message.php` with `{\"username\": \"4821k9037\", ...}`. StaqOn first resolves `4821k9037` to a real internal `user_id` (say, `812`) — if no such username exists anywhere on StaqOn, you get `404 \"No member with that username\"` immediately, before your key's team is even considered. Only once `user_id=812` is found does StaqOn check whether `812` is a member of *your key's specific team* — if not, you get `404 \"That member is not in your team\"` (deliberately the same 404 shape as \"username doesn't exist at all\", so your integration can't use this endpoint to fingerprint which real usernames exist on StaqOn outside your own team).\n\n## One-line clarifications\n\n- **Never self-notify.** If the acting staff member (the person whose action in your CRM triggered this webhook/notification) is also the StaqOn account you're about to notify, **you must filter them out before calling `message.php` or triggering delivery** — StaqOn does not detect or guard against this on the send side. Sending a notification about a staff member's own action back to that same staff member is entirely possible and will be delivered.\n\n---\n\n## Health endpoint (new)\n\n`POST /api/webhook-health.php?action=check` — a single, read-only, unified endpoint shared across External, Internal and Chatbot. Lets you check your own key/webhook's real status without ever transmitting your secret over the wire.\n\n**Request body:**\n```json\n{\"surface\": \"internal\", \"key_id\": 123, \"timestamp\": \"2026-08-30T12:00:00Z\"}\n```\n\n**Auth — HMAC, proving ownership, not a bearer token:** sign the raw request body and send it as `X-StaqOn-Signature`. Two formats are accepted (see \"Secret rotation\" and \"Two signature formats\" below for the full detail):\n- Old (bare hex): `hash_hmac('sha256', raw_body, secret)`\n- New (timestamped): `t=<unix>,v1=<hex>` where the hex is `hash_hmac('sha256', \"{timestamp}.{raw_body}\", secret)`\n\nThe `timestamp` field in the body itself is checked for a ±5 minute freshness window — StaqOn rejects both stale and future-dated requests, with the same generic denial as a bad signature (never distinguishable from the outside).\n\n**Response (Internal):**\n```json\n{\"ok\":true,\"surface\":\"internal\",\"is_active\":true,\"revoked_at\":null,\"last_used_at\":\"2026-08-30 15:12:15\",\"usage_count\":8126,\"authenticated_with\":\"current\",\"bearer_key_rotation\":{\"grace_period_active\":false},\"webhook\":{\"is_active\":true,\"failure_count\":0,\"last_triggered_at\":null},\"grace_period_active\":false}\n```\n\n**Honest limitation:** External/Internal use this endpoint by proving ownership of your team's **webhook secret** (`webhooks.secret`), not a secret on the api_keys row itself (api_keys has no HMAC secret of its own — it authenticates inbound bearer-key calls a different way entirely). **A team with an active api_keys row but no active webhook configured cannot use this endpoint** — there is no secret to sign with, and the denial is the same generic 401 as every other failure mode, by design.\n\n## Secret rotation (new)\n\nA new `rotate` action exists per surface (`api/api-keys.php`'s `rotate`/`webhook_rotate`, `api/chatbot-keys.php`'s `rotate`) — generates a brand-new secret/key immediately while keeping the **previous one valid for a real 15-minute grace window** (`previous_expires_at`, checked live against `NOW()` on every request — never a lagging cron). This replaces the old revoke-and-recreate pattern, which had zero overlap and would break an in-flight integration instantly.\n\n**Directional note:** the grace window only matters where StaqOn **verifies an inbound** signature/key — the health endpoint (this surface) and bearer-key auth (External/Internal). It does **not** apply to Chatbot's own outbound signing (`chatbot_call_crm()`) or to `trigger_webhook()` — those always sign with whatever secret is current, so rotating takes effect immediately for new outbound deliveries with no grace period needed on that side.\n\nThe health endpoint's response includes rotation state when relevant: `grace_period_active`, `previous_secret_expires_at`, `minutes_remaining`.\n\n## Two signature formats — old bare-hex, new timestamped (transition)\n\nEvery real signature check across all three surfaces now accepts **either** format:\n- **Old (bare hex):** exactly 64 lowercase hex chars — `hash_hmac('sha256', raw_body, secret)`. Still fully supported, indefinitely, for existing integrations.\n- **New (timestamped), recommended for new integrations:** `X-StaqOn-Signature: t=<unix_timestamp>,v1=<hex>` where hex is `hash_hmac('sha256', \"{timestamp}.{raw_body}\", secret)`. Rejected if the embedded timestamp is more than 5 minutes old.\n\nBoth formats are unambiguous by construction (the new format always begins with the literal `t=`, which can never appear in a 64-char hex string) — StaqOn detects which one you sent automatically. New keys/webhooks are given this new-format guidance in their creation/rotation response (`inbound_signature_format`, `inbound_signature_note`); existing secrets keep working with whichever format they were already built against. **This only affects requests you send to StaqOn** (e.g. this health endpoint) — it does not change how StaqOn signs the events/queries it sends you.\n\n## Cross-API trace IDs (new)\n\nEvery request across all three surfaces now supports **`X-StaqOn-Trace-Id`**: an optional request header, always present on the response.\n\n- **If you send it:** any non-empty value up to 100 characters, matching `^[A-Za-z0-9_.:-]+$` — StaqOn echoes it back verbatim and logs it against this event.\n- **If you don't:** StaqOn generates a fresh UUID v4 and echoes that instead.\n\n**Why this matters — linking a logical transaction across two separate API calls:** a member taps `/help` → the Chatbot API fires an outbound query to your CRM, tagged with a trace ID (both as a `trace_id` field in the JSON body and as the `X-StaqOn-Trace-Id` header) → if your CRM later posts a reply to that same member via the **External API**, include that same trace ID on your call. Both events are now queryable together by trace ID — the two separate API calls (different surface, different endpoint, different timing) are provably one logical transaction.\n\n\n\n---\n\n## Internal Chatbot API — documented separately\n\nThe **Internal Chatbot API** (Internal `/help`, real-time CRM-backed answers, added 2026-09-05) is NOT part of this collection. This collection is scoped to bearer-key calls **your CRM makes to StaqOn** (`status`, `message`) plus the reverse pull/health calls that share that same signing convention. The Internal Chatbot API is structurally the opposite direction — **StaqOn calls a webhook on your CRM**, synchronously, the same inbound shape as the External Chatbot API (which likewise has its own dedicated collection, not a folder inside the External API collection). For consistency with that existing precedent, the Internal Chatbot API has its own dedicated collection: **`staqon-internal-chatbot-api.postman_collection.json`**. See the full contract on the developer docs page under **Internal Chatbot API** (`#internal-chatbot-api`).\n",
  "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
 },
 "auth": {
  "type": "apikey",
  "apikey": [
   {
    "key": "key",
    "value": "X-API-Key",
    "type": "string"
   },
   {
    "key": "value",
    "value": "{{apiKey}}",
    "type": "string"
   },
   {
    "key": "in",
    "value": "header",
    "type": "string"
   }
  ]
 },
 "variable": [
  {
   "key": "baseUrl",
   "value": "https://staqon.one/api/v1/internal",
   "type": "string"
  },
  {
   "key": "apiKey",
   "value": "sk_live_your_internal_key_here",
   "type": "string"
  }
 ],
 "item": [
  {
   "name": "Context",
   "item": [
    {
     "name": "Status (key context + usage)",
     "request": {
      "method": "GET",
      "header": [
       {
        "key": "X-API-Key",
        "value": "{{apiKey}}",
        "type": "text"
       }
      ],
      "url": {
       "raw": "{{baseUrl}}/status.php",
       "host": [
        "{{baseUrl}}"
       ],
       "path": [
        "status.php"
       ]
      },
      "description": "Confirm an internal key works before attempting a link request. **Key-only**, read-only.\n\n**Params:** none.\n**Success 200:** `{ok, workspace, team, team_type:\"internal\", total_members, active_members, api_key_usage, rate_limit, rate_remaining}`.\n**Errors:** 401 invalid key · 400 `Linked team is not an active internal team`.\n\nNote: counts every team member with a workspace membership — there is no guest filter on this surface.\n\n**Rate limit:** see the top-level *Rate limiting* section above — 100 requests/minute per key, same limit for every endpoint on this surface."
     },
     "response": [
      {
       "name": "200 OK",
       "originalRequest": {
        "method": "GET",
        "header": [
         {
          "key": "X-API-Key",
          "value": "{{apiKey}}",
          "type": "text"
         }
        ],
        "url": {
         "raw": "{{baseUrl}}/status.php",
         "host": [
          "{{baseUrl}}"
         ],
         "path": [
          "status.php"
         ]
        }
       },
       "status": "OK",
       "code": 200,
       "_postman_previewlanguage": "json",
       "header": [
        {
         "key": "Content-Type",
         "value": "application/json"
        }
       ],
       "cookie": [],
       "body": "{\"ok\": true, \"workspace\": \"Acme Corp\", \"team\": \"RM Team\", \"team_type\": \"internal\", \"total_members\": 8, \"active_members\": 8, \"api_key_usage\": 12, \"rate_limit\": \"100/minute\", \"rate_remaining\": 99}"
      },
      {
       "name": "401 Invalid key",
       "originalRequest": {
        "method": "GET",
        "header": [
         {
          "key": "X-API-Key",
          "value": "{{apiKey}}",
          "type": "text"
         }
        ],
        "url": {
         "raw": "{{baseUrl}}/status.php",
         "host": [
          "{{baseUrl}}"
         ],
         "path": [
          "status.php"
         ]
        }
       },
       "status": "Unauthorized",
       "code": 401,
       "_postman_previewlanguage": "json",
       "header": [
        {
         "key": "Content-Type",
         "value": "application/json"
        }
       ],
       "cookie": [],
       "body": "{\"ok\": false, \"error\": \"Invalid API key\"}"
      }
     ]
    },
    {
     "name": "List members",
     "request": {
      "method": "GET",
      "header": [
       {
        "key": "X-API-Key",
        "value": "{{apiKey}}",
        "type": "text"
       }
      ],
      "url": {
       "raw": "{{baseUrl}}/members.php?page=1&limit=50",
       "host": [
        "{{baseUrl}}"
       ],
       "path": [
        "members.php"
       ],
       "query": [
        {
         "key": "page",
         "value": "1"
        },
        {
         "key": "limit",
         "value": "50"
        },
        {
         "key": "search",
         "value": "",
         "disabled": true
        }
       ]
      },
      "description": "Paginated list of the members in the key's internal team. **Read-only**, team-scoped by the key — a key only ever sees its own team.\n\n**Success 200:** `{ok:true, members:[{id, name, email, username, role, status, joined_at}], total, page, pages}`. `username` is the handle used to identify this member — pass it to `message` when sending a notification.\n\n**Two deliberate differences from the External API's list:** (1) **no guest filter** — internal members hold `member`/`admin`/`lead`, so the external guest-only filter would return zero; this lists them all. (2) **no `last_message_at` / `messages_sent`** — those count external-thread messages, and internal teams have no external thread. `role` is included instead (external teams are all-guest, so it carries no useful role).\n\n**Params:** `page` (default 1) · `limit` (default 50, max 200) · `search` (name/email/username).\n**Errors:** 400 `Linked team is not an active internal team` (e.g. an external key) · 401 invalid key.\n\n**Rate limit:** see the top-level *Rate limiting* section above — 100 requests/minute per key, same limit for every endpoint on this surface."
     },
     "response": [
      {
       "name": "200 OK",
       "originalRequest": {
        "method": "GET",
        "header": [
         {
          "key": "X-API-Key",
          "value": "{{apiKey}}",
          "type": "text"
         }
        ],
        "url": {
         "raw": "{{baseUrl}}/members.php?page=1&limit=50",
         "host": [
          "{{baseUrl}}"
         ],
         "path": [
          "members.php"
         ],
         "query": [
          {
           "key": "page",
           "value": "1"
          },
          {
           "key": "limit",
           "value": "50"
          },
          {
           "key": "search",
           "value": "",
           "disabled": true
          }
         ]
        }
       },
       "status": "OK",
       "code": 200,
       "_postman_previewlanguage": "json",
       "header": [
        {
         "key": "Content-Type",
         "value": "application/json"
        }
       ],
       "cookie": [],
       "body": "{\"ok\": true, \"members\": [{\"id\": 1, \"name\": \"Asha Rao\", \"email\": \"asha@acme.com\", \"username\": \"4821k9037\", \"role\": \"member\", \"status\": \"active\", \"joined_at\": \"2026-07-01 10:00:00\"}], \"total\": 8, \"page\": 1, \"pages\": 1}"
      },
      {
       "name": "401 Invalid key",
       "originalRequest": {
        "method": "GET",
        "header": [
         {
          "key": "X-API-Key",
          "value": "{{apiKey}}",
          "type": "text"
         }
        ],
        "url": {
         "raw": "{{baseUrl}}/members.php?page=1&limit=50",
         "host": [
          "{{baseUrl}}"
         ],
         "path": [
          "members.php"
         ],
         "query": [
          {
           "key": "page",
           "value": "1"
          },
          {
           "key": "limit",
           "value": "50"
          },
          {
           "key": "search",
           "value": "",
           "disabled": true
          }
         ]
        }
       },
       "status": "Unauthorized",
       "code": 401,
       "_postman_previewlanguage": "json",
       "header": [
        {
         "key": "Content-Type",
         "value": "application/json"
        }
       ],
       "cookie": [],
       "body": "{\"ok\": false, \"error\": \"Invalid API key\"}"
      }
     ]
    }
   ]
  },
  {
   "name": "Notifications",
   "item": [
    {
     "name": "Send a notification",
     "request": {
      "method": "POST",
      "header": [
       {
        "key": "X-API-Key",
        "value": "{{apiKey}}",
        "type": "text"
       },
       {
        "key": "Content-Type",
        "value": "application/json",
        "type": "text"
       }
      ],
      "url": {
       "raw": "{{baseUrl}}/message.php",
       "host": [
        "{{baseUrl}}"
       ],
       "path": [
        "message.php"
       ]
      },
      "description": "Deliver a **one-way** notification into the member's **API Messages** feed. Not a conversation: no reply, no reactions, no attachments, and nothing to read back.\n\n**Requires an approved link** — a `staqon_links` row for (workspace, member, `link_type='internal'`) must be `approved`. See the collection-level \"Consent\" section for the real, current state of how that happens. This is the key difference from the external surface.\n\n**Body:** `username` (required) · `message` (required, max 60000 chars, rejected not truncated) · `title` (optional, max 200) · `external_ref` (optional, idempotency; silently truncated to 190 characters, not rejected) · `link_url` (optional, http(s)) · `card` (optional card object — see below).\n**Success 200:** `{ok:true, id, delivered_at}`.\n\n**Card format (optional):** send a `card` object alongside `message` to render a structured card in the API feed. `message` remains the fallback text (used for push notifications and older clients).\n\n**Field names and hex codes are case-sensitive** — `Header_Color` or `#1f7a54` (lowercase) will not match and are rejected as invalid, not silently normalized.\n\nCard fields: `header_color` (required, one of 10 hex codes below) · `meta_title` (optional, ≤60 chars) · `title` (required, ≤200) · `body` (required, ≤2000, supports a small markdown subset — see below) · `sub_body` (optional, ≤2000, same markdown subset) · `icon` (optional, one of 10 icon names below) · `button` (optional object).\n\n**The 10 allowed hex codes** (for both `header_color` and `button.color`):\n\n| Hex (exact case) | Name |\n|---|---|\n| `#1F7A54` | brand green (default) |\n| `#059669` | success green |\n| `#D97706` | warning amber |\n| `#DC2626` | danger red |\n| `#2563EB` | info blue |\n| `#4B5563` | neutral grey |\n| `#7C3AED` | security |\n| `#0D9488` | account |\n| `#DB2777` | reassign |\n| `#0891B2` | update |\n\n**The 10 allowed icon names** (`card.icon`, case-sensitive): `check` (✓) · `warning` (⚠) · `document` (📄) · `security` (🔒) · `wallet` (💰) · `person` (👤) · `alert` (🔔) · `info` (ℹ) · `reassign` (🔄) · `pending` (⏳). `icon` is a recognised optional top-level field — sending it with a value not in this list is rejected.\n\n**Markdown subset supported in `body` / `sub_body`:** `**bold**` → `<b>bold</b>` · `*italic*` → `<i>italic</i>` · lines starting with `- ` are grouped into `<ul><li>` bullets.\n\n**Rendered on both web and Android.** `icon` and the markdown subset above are rendered client-side by the StaqOn web client (`renderCard()` in `assets/js/app.js`) and, as of this writing, by the Android app too — the two clients now have matching card-rendering support, so every recipient sees the same real presentation (bold/italic/bullets rendered, the real icon glyph shown) regardless of which client they're on. Storage is unaffected either way — `card_json` is saved as plain text exactly as sent; there is no server-side markdown-to-HTML conversion, each client renders it independently.\n\n**`button` object — exactly 3 allowed keys, nothing else:** `label` (required, ≤40 chars) · `url` (required, ≤2048 chars, **must start with `https://` — `http://` is rejected**) · `color` (optional, one of the 10 hex codes above). Any other key on `card` or on `card.button` (top-level or nested) is rejected — there is no silent-drop fallback. (`icon` is only valid at the top level of `card`, not inside `button`.)\n\n**Malformed card → the whole request is rejected with 400.** `validate_card()` returns a specific error string on the first invalid field it finds, and the calling endpoint returns it verbatim as `{\"ok\":false,\"error\":\"<message>\"}` with HTTP 400 — nothing is saved, and no fallback/plain-text-only save happens. Example error bodies: `{\"ok\":false,\"error\":\"card.header_color must be one of the 10 allowed hex codes\"}` · `{\"ok\":false,\"error\":\"card.button.url must be a valid https URL\"}` · `{\"ok\":false,\"error\":\"card.icon must be one of the 10 allowed icon names\"}`.\n**Errors:** 400 `username and message are required` / `message is too long (max 60000 chars)` / `title is too long (max 200 chars)` / `link_url must be a valid http(s) URL` · 404 `No member with that username` / `That member is not in your team` · 403 `Member has not approved API messages` · 409 duplicate `external_ref` — returns the original `id` + `delivered_at` instead of double-posting · 401 invalid key.\n\nThe member sees these under an **API Messages** sidebar section, which appears only while their team has an active internal key.\n\n**Getting 403?** No approved `staqon_links` row exists yet for this member on this workspace/surface — see the collection-level \"Consent\" section. `link-request.php` no longer exists; there is no request/expiry window on this surface any more.\n\n**Rate limit:** see the top-level *Rate limiting* section above — 100 requests/minute per key, same limit for every endpoint on this surface.",
      "body": {
       "mode": "raw",
       "raw": "{\n  \"username\": \"4821k9037\",\n  \"message\": \"Payout processed. \\u20b912,500 released to your registered bank account. Reference: PAYOUT-9001.\",\n  \"external_ref\": \"crm-9001\",\n  \"card\": {\n    \"header_color\": \"#059669\",\n    \"meta_title\": \"Payout\",\n    \"title\": \"\\u20b912,500 released\",\n    \"body\": \"Your September payout has been released to your registered bank account.\",\n    \"sub_body\": \"Reference: PAYOUT-9001 \\u00b7 Estimated arrival: 1-2 business days\",\n    \"button\": {\n      \"label\": \"View payout\",\n      \"url\": \"https://crm.example.com/payouts/9001\",\n      \"color\": \"#059669\"\n    }\n  }\n}",
       "options": {
        "raw": {
         "language": "json"
        }
       }
      }
     },
     "response": [
      {
       "name": "200 Delivered",
       "originalRequest": {
        "method": "POST",
        "header": [
         {
          "key": "X-API-Key",
          "value": "{{apiKey}}",
          "type": "text"
         },
         {
          "key": "Content-Type",
          "value": "application/json",
          "type": "text"
         }
        ],
        "url": {
         "raw": "{{baseUrl}}/message.php",
         "host": [
          "{{baseUrl}}"
         ],
         "path": [
          "message.php"
         ]
        },
        "body": {
         "mode": "raw",
         "raw": "{\n  \"username\": \"4821k9037\",\n  \"title\": \"Payout processed\",\n  \"message\": \"Your September payout has been released.\",\n  \"external_ref\": \"crm-9001\",\n  \"link_url\": \"https://crm.example.com/payouts/9001\"\n}",
         "options": {
          "raw": {
           "language": "json"
          }
         }
        }
       },
       "status": "OK",
       "code": 200,
       "_postman_previewlanguage": "json",
       "header": [
        {
         "key": "Content-Type",
         "value": "application/json"
        }
       ],
       "cookie": [],
       "body": "{\"ok\": true, \"id\": 98, \"delivered_at\": \"2026-07-02 09:15:00\"}"
      },
      {
       "name": "403 Not approved",
       "originalRequest": {
        "method": "POST",
        "header": [
         {
          "key": "X-API-Key",
          "value": "{{apiKey}}",
          "type": "text"
         },
         {
          "key": "Content-Type",
          "value": "application/json",
          "type": "text"
         }
        ],
        "url": {
         "raw": "{{baseUrl}}/message.php",
         "host": [
          "{{baseUrl}}"
         ],
         "path": [
          "message.php"
         ]
        },
        "body": {
         "mode": "raw",
         "raw": "{\n  \"username\": \"4821k9037\",\n  \"title\": \"Payout processed\",\n  \"message\": \"Your September payout has been released.\",\n  \"external_ref\": \"crm-9001\",\n  \"link_url\": \"https://crm.example.com/payouts/9001\"\n}",
         "options": {
          "raw": {
           "language": "json"
          }
         }
        }
       },
       "status": "Forbidden",
       "code": 403,
       "_postman_previewlanguage": "json",
       "header": [
        {
         "key": "Content-Type",
         "value": "application/json"
        }
       ],
       "cookie": [],
       "body": "{\"ok\": false, \"error\": \"Member has not approved API messages\"}"
      },
      {
       "name": "401 Invalid key",
       "originalRequest": {
        "method": "POST",
        "header": [
         {
          "key": "X-API-Key",
          "value": "{{apiKey}}",
          "type": "text"
         },
         {
          "key": "Content-Type",
          "value": "application/json",
          "type": "text"
         }
        ],
        "url": {
         "raw": "{{baseUrl}}/message.php",
         "host": [
          "{{baseUrl}}"
         ],
         "path": [
          "message.php"
         ]
        },
        "body": {
         "mode": "raw",
         "raw": "{\n  \"username\": \"4821k9037\",\n  \"title\": \"Payout processed\",\n  \"message\": \"Your September payout has been released.\",\n  \"external_ref\": \"crm-9001\",\n  \"link_url\": \"https://crm.example.com/payouts/9001\"\n}",
         "options": {
          "raw": {
           "language": "json"
          }
         }
        }
       },
       "status": "Unauthorized",
       "code": 401,
       "_postman_previewlanguage": "json",
       "header": [
        {
         "key": "Content-Type",
         "value": "application/json"
        }
       ],
       "cookie": [],
       "body": "{\"ok\": false, \"error\": \"Invalid API key\"}"
      },
      {
       "name": "200 OK — card example: danger red (#DC2626)",
       "originalRequest": {
        "method": "POST",
        "header": [
         {
          "key": "X-API-Key",
          "value": "{{apiKey}}",
          "type": "text"
         },
         {
          "key": "Content-Type",
          "value": "application/json",
          "type": "text"
         }
        ],
        "url": {
         "raw": "{{baseUrl}}/message.php",
         "host": [
          "{{baseUrl}}"
         ],
         "path": [
          "message.php"
         ]
        },
        "body": {
         "mode": "raw",
         "raw": "{\n  \"username\": \"4821k9037\",\n  \"message\": \"Payout failed \\u2014 please update your bank details.\",\n  \"external_ref\": \"crm-9002\",\n  \"card\": {\n    \"header_color\": \"#DC2626\",\n    \"title\": \"Payout failed\",\n    \"body\": \"Your September payout could not be processed. Please verify your bank details.\",\n    \"button\": {\n      \"label\": \"Update bank details\",\n      \"url\": \"https://crm.example.com/bank-details\",\n      \"color\": \"#DC2626\"\n    }\n  }\n}",
         "options": {
          "raw": {
           "language": "json"
          }
         }
        }
       },
       "status": "OK",
       "code": 200,
       "_postman_previewlanguage": "json",
       "header": [
        {
         "key": "Content-Type",
         "value": "application/json"
        }
       ],
       "cookie": [],
       "body": "{\"ok\": true, \"id\": 145, \"delivered_at\": \"2026-08-30 09:00:00\"}"
      },
      {
       "name": "200 OK — card example: warning amber (#D97706)",
       "originalRequest": {
        "method": "POST",
        "header": [
         {
          "key": "X-API-Key",
          "value": "{{apiKey}}",
          "type": "text"
         },
         {
          "key": "Content-Type",
          "value": "application/json",
          "type": "text"
         }
        ],
        "url": {
         "raw": "{{baseUrl}}/message.php",
         "host": [
          "{{baseUrl}}"
         ],
         "path": [
          "message.php"
         ]
        },
        "body": {
         "mode": "raw",
         "raw": "{\n  \"username\": \"4821k9037\",\n  \"message\": \"Your document review is pending.\",\n  \"external_ref\": \"crm-9003\",\n  \"card\": {\n    \"header_color\": \"#D97706\",\n    \"title\": \"Document review pending\",\n    \"body\": \"We're still reviewing your submitted documents. This usually takes 1-2 business days.\",\n    \"button\": {\n      \"label\": \"View documents\",\n      \"url\": \"https://crm.example.com/documents\",\n      \"color\": \"#D97706\"\n    }\n  }\n}",
         "options": {
          "raw": {
           "language": "json"
          }
         }
        }
       },
       "status": "OK",
       "code": 200,
       "_postman_previewlanguage": "json",
       "header": [
        {
         "key": "Content-Type",
         "value": "application/json"
        }
       ],
       "cookie": [],
       "body": "{\"ok\": true, \"id\": 146, \"delivered_at\": \"2026-08-30 09:05:00\"}"
      },
      {
       "name": "200 OK — card example: info blue (#2563EB)",
       "originalRequest": {
        "method": "POST",
        "header": [
         {
          "key": "X-API-Key",
          "value": "{{apiKey}}",
          "type": "text"
         },
         {
          "key": "Content-Type",
          "value": "application/json",
          "type": "text"
         }
        ],
        "url": {
         "raw": "{{baseUrl}}/message.php",
         "host": [
          "{{baseUrl}}"
         ],
         "path": [
          "message.php"
         ]
        },
        "body": {
         "mode": "raw",
         "raw": "{\n  \"username\": \"4821k9037\",\n  \"message\": \"New offer available on your account.\",\n  \"external_ref\": \"crm-9004\",\n  \"card\": {\n    \"header_color\": \"#2563EB\",\n    \"title\": \"New offer available\",\n    \"body\": \"A new pre-approved offer is available on your account.\",\n    \"button\": {\n      \"label\": \"View offer\",\n      \"url\": \"https://crm.example.com/offers/44\",\n      \"color\": \"#2563EB\"\n    }\n  }\n}",
         "options": {
          "raw": {
           "language": "json"
          }
         }
        }
       },
       "status": "OK",
       "code": 200,
       "_postman_previewlanguage": "json",
       "header": [
        {
         "key": "Content-Type",
         "value": "application/json"
        }
       ],
       "cookie": [],
       "body": "{\"ok\": true, \"id\": 147, \"delivered_at\": \"2026-08-30 09:10:00\"}"
      },
      {
       "name": "200 OK — card + link_url combined in one payload",
       "originalRequest": {
        "method": "POST",
        "header": [
         {
          "key": "X-API-Key",
          "value": "{{apiKey}}",
          "type": "text"
         },
         {
          "key": "Content-Type",
          "value": "application/json",
          "type": "text"
         }
        ],
        "url": {
         "raw": "{{baseUrl}}/message.php",
         "host": [
          "{{baseUrl}}"
         ],
         "path": [
          "message.php"
         ]
        },
        "body": {
         "mode": "raw",
         "raw": "{\n  \"username\": \"4821k9037\",\n  \"message\": \"Payout processed. \\u20b912,500 released.\",\n  \"external_ref\": \"crm-9005\",\n  \"link_url\": \"https://crm.example.com/payouts/9005/receipt\",\n  \"card\": {\n    \"header_color\": \"#0891B2\",\n    \"icon\": \"check\",\n    \"meta_title\": \"Payout\",\n    \"title\": \"\\u20b912,500 released\",\n    \"body\": \"Your **September payout** has been released.\\n\\n- Amount: \\u20b912,500\\n- Reference: PAYOUT-9005\",\n    \"sub_body\": \"*Estimated arrival: 1-2 business days*\",\n    \"button\": {\n      \"label\": \"View payout\",\n      \"url\": \"https://crm.example.com/payouts/9005\",\n      \"color\": \"#059669\"\n    }\n  }\n}",
         "options": {
          "raw": {
           "language": "json"
          }
         }
        }
       },
       "status": "OK",
       "code": 200,
       "_postman_previewlanguage": "json",
       "header": [
        {
         "key": "Content-Type",
         "value": "application/json"
        }
       ],
       "cookie": [],
       "body": "{\"ok\": true, \"id\": 148, \"delivered_at\": \"2026-08-30 09:15:00\"}"
      },
      {
       "name": "400 Bad Request — malformed card (wrong button key count)",
       "originalRequest": {
        "method": "POST",
        "header": [
         {
          "key": "X-API-Key",
          "value": "{{apiKey}}",
          "type": "text"
         },
         {
          "key": "Content-Type",
          "value": "application/json",
          "type": "text"
         }
        ],
        "url": {
         "raw": "{{baseUrl}}/message.php",
         "host": [
          "{{baseUrl}}"
         ],
         "path": [
          "message.php"
         ]
        },
        "body": {
         "mode": "raw",
         "raw": "{\n  \"username\": \"4821k9037\",\n  \"message\": \"fallback text\",\n  \"card\": {\n    \"header_color\": \"#1F7A54\",\n    \"title\": \"Test\",\n    \"body\": \"Test body\",\n    \"button\": {\n      \"label\": \"Click\",\n      \"url\": \"https://example.com\",\n      \"target\": \"_blank\"\n    }\n  }\n}",
         "options": {
          "raw": {
           "language": "json"
          }
         }
        }
       },
       "status": "Bad Request",
       "code": 400,
       "_postman_previewlanguage": "json",
       "header": [
        {
         "key": "Content-Type",
         "value": "application/json"
        }
       ],
       "cookie": [],
       "body": "{\"ok\": false, \"error\": \"card.button.target is not a recognised field\"}"
      }
     ]
    }
   ]
  },
  {
   "name": "Webhooks",
   "description": "# Webhooks (StaqOn → your server) — Internal surface\n\nAs of 2026-09-01, webhooks are configured as part of the workspace's **Link API** key — **one webhook per workspace**, shared across all of a workspace's teams (internal and external alike), not per team as this section used to describe. An internal team's activity does not currently emit any real webhook events of its own (internal-surface member/notification events are not part of the 6 real events below) — an internal team's workspace can still receive `member.*`/`escalation.*` events, but only if that workspace also has an active external team generating them, via the same shared webhook.\n\n⚠️ **Not (yet) listed here:** the old `link.approved` / `link.declined` / `link.revoked` trio, which this section used to describe as Internal's *only* events, is dead — their emitter has zero real callers left since the old account-linking flow was removed. `/link`'s own real events will be added here once its send/verify contract is actually built.\n\n## Delivery (identical to the External API — same code path)\n- Method **POST**, `Content-Type: application/json`.\n- Headers: `X-StaqOn-Event: <event>` and `X-StaqOn-Signature: <hex hmac>`.\n- Best-effort, **single attempt**, **5s** timeout, http/https only. Respond **2xx** to acknowledge.\n- **10 consecutive non-2xx** responses auto-disable the webhook (re-enable in Settings).\n\n## Signature — verify every request\nHMAC-**SHA256** of the **raw request body**, keyed by your webhook secret, **hex**-encoded (lowercase); compare constant-time against the `X-StaqOn-Signature` header. Reject if it doesn't match.\n\n## Envelope\n```\n{ \"event\": \"<name>\", \"timestamp\": \"<ISO-8601>\", \"data\": { … } }\n```\n\n## The 6 real events\n\nSee the External API collection's **Webhooks** section for the full table — the events themselves are identical (`member.joined`, `member.message`, `member.removed`, `member.left`, `escalation.created`, `escalation.resolved`), since the webhook is shared at the workspace level, not split by surface. `team_id` is included on every event's `data`.\n",
   "item": [
    {
     "name": "Webhook status (health)",
     "request": {
      "method": "GET",
      "header": [
       {
        "key": "X-API-Key",
        "value": "{{apiKey}}",
        "type": "text"
       }
      ],
      "url": {
       "raw": "{{baseUrl}}/webhook-status.php",
       "host": [
        "{{baseUrl}}"
       ],
       "path": [
        "webhook-status.php"
       ]
      },
      "description": "⚠️ **Legacy endpoint, not yet migrated.** This endpoint (`webhook-status.php`) still queries by the key's `team_id`, a leftover from the old, removed per-team webhook model — it has NOT been updated for the 2026-09-01 workspace-level Webhooks+Link merge and may return stale/empty results for a migrated workspace. Prefer the unified **`POST /api/webhook-health.php?action=check`** endpoint (see the collection-level \"Health endpoint\" section) for current, accurate webhook health.\n\n**Success 200 (as originally documented):** `{ok:true, webhooks:[{url, events, is_active, failure_count, last_triggered_at, created_at}]}`.\n\n**The signing `secret` is never returned.** **Errors:** 401 invalid key; 400 wrong surface for the key."
     },
     "response": [
      {
       "name": "200 One disabled hook",
       "originalRequest": null,
       "status": "OK",
       "code": 200,
       "_postman_previewlanguage": "json",
       "header": [
        {
         "key": "Content-Type",
         "value": "application/json"
        }
       ],
       "cookie": [],
       "body": "{\"ok\": true, \"webhooks\": [{\"url\": \"https://crm.example.com/hook\", \"events\": [\"member.joined\", \"escalation.created\"], \"is_active\": false, \"failure_count\": 10, \"last_triggered_at\": \"2026-07-02 09:00:00\", \"created_at\": \"2026-06-01 12:00:00\"}]}"
      },
      {
       "name": "200 No hooks",
       "originalRequest": null,
       "status": "OK",
       "code": 200,
       "_postman_previewlanguage": "json",
       "header": [
        {
         "key": "Content-Type",
         "value": "application/json"
        }
       ],
       "cookie": [],
       "body": "{\"ok\": true, \"webhooks\": []}"
      },
      {
       "name": "401 Invalid key",
       "originalRequest": null,
       "status": "Unauthorized",
       "code": 401,
       "_postman_previewlanguage": "json",
       "header": [
        {
         "key": "Content-Type",
         "value": "application/json"
        }
       ],
       "cookie": [],
       "body": "{\"ok\": false, \"error\": \"Invalid API key\"}"
      }
     ]
    }
   ]
  },
  {
   "name": "Internal Link API",
   "description": "# Internal Link API — pulling your linked staff\n\nA real, working, read-only endpoint (2026-09-02) — the reverse direction from your Internal Link webhook, direct structural mirror of the External Link API's own `link-list.php`: **you** call **StaqOn**, HMAC-signed the same way the health endpoint verifies your inbound calls. Use it to check a staff member's real-time link status from your own system, or to reconcile your own records against StaqOn's.\n\n**Scope — approved Internal links only.** Only rows with `status='approved'` and `link_type='internal'` are returned. Pending/declined/expired/revoked rows are omitted (this is a \"who's currently linked\" view, not an audit log), and External (customer) links are never included — deliberately, since this key only ever authenticates your Internal surface.\n\n**`crm_username`, `crm_email` AND `username` are all returned** — unlike External's `link-list.php`, which only ever returns `crm_email`. Internal's real identity pair is a CRM ID + email — `crm_username` here carries the real CRM ID your staff member entered, not a StaqOn username. `username` (added 2026-09-02) is StaqOn's own real username for that same person, exactly what you need to pass to `POST /message.php` to actually notify them. All three fields are always populated together on a real Internal approval; none is ever null on an approved row.\n\n## Signature\n\nCovers the raw request body: `hash_hmac('sha256', \"{unix_timestamp}.{raw_body}\", your_internal_link_secret)`, sent as `X-StaqOn-Signature: t=<unix_timestamp>,v1=<hex>`. The older bare-hex format (no `t=` prefix) is also accepted, matching every other signature check on StaqOn. `timestamp` in the body is an ISO-8601 string, checked for a real ±5 minute freshness window.\n\n## Errors\n\nBad signature, unknown `key_id`, a revoked/inactive key, and a stale timestamp all return the identical `401 {\"ok\":false,\"error\":\"Invalid signature or unknown key.\"}` — deliberately indistinguishable, so this endpoint can never be used to enumerate real `key_id`s. Genuinely isolated from the External Link API: an External key's secret can never authenticate this endpoint, and vice versa, since each only ever looks up its own real table. Rotation is supported: a request signed with your *previous* secret is still accepted for the real 15-minute grace window after rotating.\n",
   "item": [
    {
     "name": "Pull linked staff",
     "request": {
      "method": "POST",
      "header": [
       {
        "key": "Content-Type",
        "value": "application/json"
       },
       {
        "key": "X-StaqOn-Signature",
        "value": "t=<unix_timestamp>,v1=<hex>"
       }
      ],
      "body": {
       "mode": "raw",
       "raw": "{\n  \"key_id\": 15,\n  \"timestamp\": \"2026-09-02T06:32:44Z\"\n}",
       "options": {
        "raw": {
         "language": "json"
        }
       }
      },
      "url": {
       "raw": "https://staqon.one/api/internal-link-list.php",
       "protocol": "https",
       "host": [
        "staqon",
        "one"
       ],
       "path": [
        "api",
        "internal-link-list.php"
       ]
      },
      "description": "`key_id` is your Internal Link API key's numeric ID (shown on the Internal Link admin page once configured). Returns every approved, Internal-surface connection for your workspace — see the folder description above for the full scope note and signature contract."
     },
     "response": [
      {
       "name": "200 OK",
       "originalRequest": {
        "method": "POST",
        "header": [],
        "url": {
         "raw": "https://staqon.one/api/internal-link-list.php"
        }
       },
       "status": "OK",
       "code": 200,
       "_postman_previewlanguage": "json",
       "body": "{\"ok\": true, \"links\": [{\"crm_username\": \"CRM100\", \"crm_email\": \"staff@yourcompany.com\", \"username\": \"4821k9037\", \"status\": \"approved\", \"linked_at\": \"2026-09-02 16:29:42\"}]}"
      },
      {
       "name": "401 Invalid signature",
       "originalRequest": {
        "method": "POST",
        "header": [],
        "url": {
         "raw": "https://staqon.one/api/internal-link-list.php"
        }
       },
       "status": "Unauthorized",
       "code": 401,
       "_postman_previewlanguage": "json",
       "body": "{\"ok\": false, \"error\": \"Invalid signature or unknown key.\"}"
      }
     ]
    }
   ]
  }
 ]
}