{
	"info": {
		"name": "StaqOn External API",
		"_postman_id": "staqon-external-api-v1",
		"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
		"description": "# StaqOn External API\n\nIntegrate an external system (e.g. a CRM) with a StaqOn **external team**. Authenticate every request with the API key issued for that team (StaqOn → Workspace Settings → API).\n\n## A note on encrypted workspaces\n\nSome workspaces have encryption at rest enabled for stored messages. This is entirely transparent to the API — message content is automatically decrypted before being returned in any response. You do not need to do anything differently; content/message fields always contain the real, readable text, regardless of whether the underlying workspace has encryption enabled.\n\nIf content is ever unavailable due to a temporary internal issue, it will be returned as the literal string \"[unable to decrypt]\" rather than an error or a silently empty value — this is rare and indicates a transient problem worth retrying, not something for your integration to handle differently.\n\n## Two-gate model\n\n- **Key-gated (writes + directory):** `add-member`, `remove-member`, `message`, `comment`, `escalation`, `members`, `member`, `status` need only a valid **X-API-Key** whose team the member belongs to. Writing *into* a member's thread is the workspace's own authority.\n- **Consent-gated (chat read):** `messages` (reading a member's chat history *out*) additionally requires a real, active `staqon_links` row (workspace × member × `link_type='external'`, `status='approved'`), established via StaqOn's `/link` command. Once approved, the member can **revoke** anytime (read access cuts off immediately → 403).\n\n## Typical flow\n\n1. `add-member` — onboard the member into your external team.\n2. `message` / `comment` — push updates into their StaqOn thread (Direction In, key-only).\n3. Get the member linked via StaqOn's `/link` identity-linking system → their `staqon_links` row reaches `approved`.\n4. `messages?username=…&after=<last_id>` — pull new replies back (Direction Out, poll the delta).\n5. Member can **revoke** consent anytime → `messages` returns 403.\n\n## Auth\n\nSet the collection variables once: **`apiKey`** (your `sk_live_…` key) and **`baseUrl`** (`https://staqon.one/api/v1/external`). Every request sends `X-API-Key: {{apiKey}}`.\n\n_(Note: `announce.php` is retired and omitted.)_\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`, `members`, `member`, `add-member`, `remove-member`, `message`, `comment`, `escalation`, `messages`) — 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` | `messages` (reading the thread) |\n| `members` | `staqon-verify-start` (asking a member to confirm an action) |\n| `member` | |\n| `add-member` | |\n| `remove-member` | |\n| `message` | |\n| `comment` | |\n| `escalation` | |\n\n`messages` and `staqon-verify-start` return `403 \"No approved link for this member\"` until the member's `staqon_links` row (workspace × member × `link_type='external'`) is `approved`, and again the moment it's revoked.\n\nNote the inversion versus the Internal API: here **writing is key-only and reading 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='external'`) — 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\nExternal members are linked with a **single-use invite token** (2026-10-15). **You** call `POST /api/link-invite.php` (HMAC-signed, see the Link API folder) with the customer's `email` and your own `external_ref`; StaqOn returns a `token` and `confirm_url`; **you** deliver them to the customer through your own channel — StaqOn never emails, texts or pushes anything to the customer. The customer, logged in to StaqOn as a guest of your workspace, opens `confirm_url` (or enters the code under \"Link this workspace\" / `/link`), sees your workspace name and their own account email, and taps **Yes** or **No**. On **Yes** the row is marked `approved` and StaqOn POSTs `link.status_changed` to your Link webhook.\n\nThe email + second-question verification (and the `link.verify` call to your webhook) that this replaces is **retired**; members who were already linked are unchanged.\n\n**Approval webhook payload (`link.status_changed`, invite-originated approvals):**\n```json\n{\n  \"event\": \"link.status_changed\",\n  \"timestamp\": \"2026-10-15T12:04:11+05:30\",\n  \"data\": {\n    \"user_id\": 2,\n    \"username\": \"4821k9037\",\n    \"link_type\": \"external\",\n    \"status\": \"approved\",\n    \"workspace_id\": 1,\n    \"team_id\": 0,\n    \"external_ref\": \"CRM-10492\",\n    \"crm_email\": \"customer@example.com\"\n  }\n}\n```\nSent with `X-StaqOn-Signature` (bare-hex HMAC-SHA256 of the raw body, keyed by your Link secret) and `X-StaqOn-Event: link.status_changed`. `external_ref` and `crm_email` are exactly what you sent to `link-invite.php`. For an invite approval this event is delivered **whether or not** you subscribed to `link.status_changed`. `username` is StaqOn's own username for the member — what `message.php`/`comment.php` need.\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 `messages` immediately (403).\n\n**Operational details, confirmed against real code:**\n- **`link-invite.php` is rate limited:** 30 requests/minute per `key_id` (`429` + `Retry-After: 60`); customer-side token lookups are limited to 10 failures/minute per user and per IP.\n- **No retry on your webhook's failure** — 5s call timeout, 3s connect timeout, one attempt, full stop. A non-2xx or timeout counts against the key's failure count (10 consecutive failures auto-disable it).\n- **Any real 2xx counts as success** — StaqOn checks the actual HTTP status (200–299), not literally 200; the body is not read.\n- **`email` and `external_ref` are validated by `link-invite.php`** — an invalid email or an empty/over-255-character `external_ref` is rejected with a real 400 before any invite is created.\n- **`linked_at` (and every timestamp on this page) is `Asia/Kolkata` (+05:30)**, StaqOn's server timezone — not UTC.\n- **No proactive deactivation notice** — when the 10-failure auto-disable fires, nothing is emailed or pushed to you; polling the health endpoint is the only way to detect it (unlike the Chatbot API, which does send a warning email at 20 failures).\n- **No formal ordering/idempotency guarantee on `link.status_changed`** — events fire synchronously per request (so same-request order is preserved) but there's no event/delivery ID for dedup, and no documented guarantee across concurrent requests.\n- **Webhook URL is never tested for reachability at creation** — only validated as a well-formed public `http(s)://` URL; verify it yourself before relying on it.\n- **Tokens are single-use and expire after 1 hour** — only a SHA-256 hash is stored; a newer invite for the same `external_ref` supersedes an older pending one.\n- **Rotation is genuinely safe on the outbound side** — `trigger_webhook()` (like `chatbot_call_crm()`) always signs with whatever secret is currently on file; your webhook never needs to try both the old and new secret to verify a call StaqOn sends you. On the inbound side, `link-invite.php` honours your previous secret for 15 minutes after a rotation.\n\n## End-to-end sequence\n\n1. Create a key in **Workspace → Settings → API** against an **external** team.\n2. `GET /status.php` — confirm the key and its team.\n3. `POST /add-member.php` — create the partner member (skip if they exist).\n4. `POST /message.php` or `/comment.php` — write in. **Works immediately, no link needed.**\n5. Member's `staqon_links` row reaches `approved` — you request an invite token (`POST /api/link-invite.php`), deliver it, the member confirms on StaqOn, and you receive `link.status_changed` (see \"Consent\" above).\n6. `GET /messages.php?after=<last_id>` — poll for replies. `403` before approval.\n7. 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. 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 (e.g. `member.joined`). Compute the same HMAC over the exact raw bytes you received (before any JSON re-serialization, which can reorder keys or change whitespace) and compare with a constant-time comparison.\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');\n// raw must be the exact request body bytes/string — read before any JSON.parse\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.** `trigger_webhook()` makes exactly **one attempt** per event (5s timeout, 3s connect timeout). On a non-2xx response or any curl-level failure, StaqOn does **not** retry, queue, or re-send — the event is dropped on your side for good. 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. If your endpoint has downtime, you will silently miss every event fired during that window; if this matters to you, poll the health endpoint (below) for `failure_count` and `is_active`, and design your own reconciliation (e.g. periodic `messages` polling) rather than relying on webhooks alone for anything you can't afford to lose.\n\n## One-line clarifications\n\n- **`team_id` is always included.** Every event's `data` now carries `team_id`, regardless of which team the underlying activity happened in — since a workspace's webhook is no longer team-scoped, this is how you route an incoming event to the right team on your side.\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\": \"external\", \"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 (External):**\n```json\n{\"ok\":true,\"surface\":\"external\",\"is_active\":true,\"revoked_at\":null,\"last_used_at\":\"2026-08-30 11:27:24\",\"usage_count\":881,\"authenticated_with\":\"current\",\"bearer_key_rotation\":{\"grace_period_active\":false},\"webhook\":{\"is_active\":true,\"failure_count\":0,\"last_triggered_at\":\"2026-08-27 11:45:55\"},\"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"
	},
	"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/external",
			"type": "string"
		},
		{
			"key": "apiKey",
			"value": "sk_live_your_key_here",
			"type": "string"
		},
		{
			"key": "linkKeyId",
			"value": "24",
			"type": "string"
		},
		{
			"key": "linkSecret",
			"value": "lnksec_your_secret_here",
			"type": "string"
		}
	],
	"item": [
		{
			"name": "Members",
			"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": "Returns this API key's context and usage. **Key-only.**\n\n**Params:** none.\n**Success 200:** `{ok, workspace, team, team_type, total_members, active_members, api_key_usage, rate_limit, rate_remaining}`.\n**Errors:** 401 invalid key · 400 key's team is not an active external team.\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\": \"Partners\", \"team_type\": \"external\", \"total_members\": 42, \"active_members\": 40, \"api_key_usage\": 120, \"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&search=",
							"host": [
								"{{baseUrl}}"
							],
							"path": [
								"members.php"
							],
							"query": [
								{
									"key": "page",
									"value": "1"
								},
								{
									"key": "limit",
									"value": "50"
								},
								{
									"key": "search",
									"value": ""
								}
							]
						},
						"description": "Paginated member directory for your external team. **Key-only.** Metadata only (no message content).\n\n**Query:** `page` (default 1) · `limit` (default 50, max 200) · `search` (optional, name/email).\n**Success 200:** `{ok, members:[{id,name,email,username,status,joined_at,last_message_at,messages_sent}], total, page, pages}`.\n**Errors:** 401 · 400 (non-external key).\n\n**`username`** is now in the response — the stable, unique handle that identifies this member across StaqOn (used to read `messages`, and by the Internal API's `message` endpoint). `add-member` returns it on both the created and `status:\"existing\"` paths, so you never need a second call to resolve a handle.\n\n**`search`** matches **name** and **email** (not username, 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}}/members.php?page=1&limit=50&search=",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"members.php"
									],
									"query": [
										{
											"key": "page",
											"value": "1"
										},
										{
											"key": "limit",
											"value": "50"
										},
										{
											"key": "search",
											"value": ""
										}
									]
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": true, \"members\": [{\"id\": 1, \"name\": \"Rahul Sharma\", \"email\": \"rahul@abc.com\", \"username\": \"4821k9037\", \"status\": \"active\", \"joined_at\": \"2026-07-01 10:00:00\", \"last_message_at\": \"2026-07-02 09:12:00\", \"messages_sent\": 12}], \"total\": 42, \"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&search=",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"members.php"
									],
									"query": [
										{
											"key": "page",
											"value": "1"
										},
										{
											"key": "limit",
											"value": "50"
										},
										{
											"key": "search",
											"value": ""
										}
									]
								}
							},
							"status": "Unauthorized",
							"code": 401,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": false, \"error\": \"Invalid API key\"}"
						}
					]
				},
				{
					"name": "Get member",
					"request": {
						"method": "GET",
						"header": [
							{
								"key": "X-API-Key",
								"value": "{{apiKey}}",
								"type": "text"
							}
						],
						"url": {
							"raw": "{{baseUrl}}/member.php?email=rahul@abc.com",
							"host": [
								"{{baseUrl}}"
							],
							"path": [
								"member.php"
							],
							"query": [
								{
									"key": "email",
									"value": "rahul@abc.com"
								}
							]
						},
						"description": "Single member lookup by email (or `member_id`). **Key-only.**\n\n**Query:** `email` (or `member_id`).\n**Success 200:** `{ok, member:{id,name,email,username,joined_at,last_active,messages_sent,has_mpin}}`.\n**Errors:** 404 'Member not found in this team' · 401 · 400.\n\n**`username`** is now in the response — the stable, unique handle that identifies this member across StaqOn (used to read `messages`, and by the Internal API's `message` endpoint). `add-member` returns it on both the created and `status:\"existing\"` paths, so you never need a second call to resolve a handle.\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}}/member.php?email=rahul@abc.com",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"member.php"
									],
									"query": [
										{
											"key": "email",
											"value": "rahul@abc.com"
										}
									]
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": true, \"member\": {\"id\": 1, \"name\": \"Rahul Sharma\", \"email\": \"rahul@abc.com\", \"username\": \"4821k9037\", \"joined_at\": \"2026-07-01 10:00:00\", \"last_active\": \"2026-07-02 09:12:00\", \"messages_sent\": 12, \"has_mpin\": true}}"
						},
						{
							"name": "404 Not in team",
							"originalRequest": {
								"method": "GET",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/member.php?email=rahul@abc.com",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"member.php"
									],
									"query": [
										{
											"key": "email",
											"value": "rahul@abc.com"
										}
									]
								}
							},
							"status": "Not Found",
							"code": 404,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": false, \"error\": \"Member not found in this team\"}"
						},
						{
							"name": "401 Invalid key",
							"originalRequest": {
								"method": "GET",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/member.php?email=rahul@abc.com",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"member.php"
									],
									"query": [
										{
											"key": "email",
											"value": "rahul@abc.com"
										}
									]
								}
							},
							"status": "Unauthorized",
							"code": 401,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": false, \"error\": \"Invalid API key\"}"
						}
					]
				},
				{
					"name": "Add member",
					"request": {
						"method": "POST",
						"header": [
							{
								"key": "X-API-Key",
								"value": "{{apiKey}}",
								"type": "text"
							},
							{
								"key": "Content-Type",
								"value": "application/json",
								"type": "text"
							}
						],
						"url": {
							"raw": "{{baseUrl}}/add-member.php",
							"host": [
								"{{baseUrl}}"
							],
							"path": [
								"add-member.php"
							]
						},
						"description": "Add an external member to your team (creates the user + isolated thread if new). **Key-only.**\n\n**Body:** `name` (required) · `email` (required) · `phone?` · `metadata?`.\n**Success 200:** `{ok, member_id, username, conversation_id, status, login_url}` — or `{ok, member_id, username, status:'existing', message}` if already in the team.\n**Errors:** 400 'name and email are required' · 400 'Invalid email format' · 401.\n\n**`username`** is now in the response — the stable, unique handle that identifies this member across StaqOn (used to read `messages`, and by the Internal API's `message` endpoint). `add-member` returns it on both the created and `status:\"existing\"` paths, so you never need a second call to resolve a handle.\n\n**Only `name` and `email` are used.** `phone` and `metadata`, if sent, are currently **ignored** by this endpoint (accepted without error, neither stored nor returned).\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  \"name\": \"Rahul Sharma\",\n  \"email\": \"rahul@abc.com\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						}
					},
					"response": [
						{
							"name": "200 Created",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									},
									{
										"key": "Content-Type",
										"value": "application/json",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/add-member.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"add-member.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"name\": \"Rahul Sharma\",\n  \"email\": \"rahul@abc.com\"\n}",
									"options": {
										"raw": {
											"language": "json"
										}
									}
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": true, \"member_id\": 1, \"username\": \"4821k9037\", \"conversation_id\": 45, \"status\": \"invited\", \"login_url\": \"https://staqon.one/login.php\"}"
						},
						{
							"name": "200 Already in team",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									},
									{
										"key": "Content-Type",
										"value": "application/json",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/add-member.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"add-member.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"name\": \"Rahul Sharma\",\n  \"email\": \"rahul@abc.com\"\n}",
									"options": {
										"raw": {
											"language": "json"
										}
									}
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": true, \"member_id\": 1, \"username\": \"4821k9037\", \"status\": \"existing\", \"message\": \"Member already in this team\"}"
						},
						{
							"name": "400 Bad email",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									},
									{
										"key": "Content-Type",
										"value": "application/json",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/add-member.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"add-member.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"name\": \"Rahul Sharma\",\n  \"email\": \"rahul@abc.com\"\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\": \"Invalid email format\"}"
						},
						{
							"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}}/add-member.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"add-member.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"name\": \"Rahul Sharma\",\n  \"email\": \"rahul@abc.com\"\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": "Remove member",
					"request": {
						"method": "POST",
						"header": [
							{
								"key": "X-API-Key",
								"value": "{{apiKey}}",
								"type": "text"
							},
							{
								"key": "Content-Type",
								"value": "application/json",
								"type": "text"
							}
						],
						"url": {
							"raw": "{{baseUrl}}/remove-member.php",
							"host": [
								"{{baseUrl}}"
							],
							"path": [
								"remove-member.php"
							]
						},
						"description": "Remove a member from your team (archives their thread, keeps history). **Key-only.**\n\n**Body:** `email` (required).\n**Success 200:** `{ok, removed:true, member_id}`.\n**Errors:** 400 'email is required' · 404 'Member not found' · 400 'Member is not in this external team' · 401.\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  \"email\": \"rahul@abc.com\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						}
					},
					"response": [
						{
							"name": "200 OK",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									},
									{
										"key": "Content-Type",
										"value": "application/json",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/remove-member.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"remove-member.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"email\": \"rahul@abc.com\"\n}",
									"options": {
										"raw": {
											"language": "json"
										}
									}
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": true, \"removed\": true, \"member_id\": 1}"
						},
						{
							"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}}/remove-member.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"remove-member.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"email\": \"rahul@abc.com\"\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": "Messaging",
			"item": [
				{
					"name": "Send message (Direction In)",
					"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": "Send a message into a member's external thread, attributed to your workspace. **Key-only** (no approved link needed).\n\n**Body:** `email` (required) · `message` (required, max 60000).\n**Success 200:** `{ok, message_id, conversation_id}`.\n**Errors:** 400 'email and message are required' · 400 'message is too long (max 60000 chars)' · 404 'Member not found' · 404 'No active thread for this member' · 401.\n\nFires the `member.message` webhook + delivers to the member (in-app + FCM + real-time).\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.\n\n**Scheduled or recurring messages:** StaqOn doesn't have a built-in scheduler, and you don't need one — just call this endpoint from your own system's cron job whenever you want the message sent. This is actually the better approach for reminders tied to real, changing conditions (e.g. \"loan docs still pending\") since your system always has the current truth; a StaqOn-side scheduler would just blindly repeat the same message regardless of whether the situation has changed.",
						"body": {
							"mode": "raw",
							"raw": "{\n  \"email\": \"rahul@abc.com\",\n  \"message\": \"Your loan is approved!\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						}
					},
					"response": [
						{
							"name": "200 OK",
							"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  \"email\": \"rahul@abc.com\",\n  \"message\": \"Your loan is approved!\"\n}",
									"options": {
										"raw": {
											"language": "json"
										}
									}
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": true, \"message_id\": 123, \"conversation_id\": 45}"
						},
						{
							"name": "404 Member not found",
							"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  \"email\": \"rahul@abc.com\",\n  \"message\": \"Your loan is approved!\"\n}",
									"options": {
										"raw": {
											"language": "json"
										}
									}
								}
							},
							"status": "Not Found",
							"code": 404,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": false, \"error\": \"Member not found\"}"
						},
						{
							"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  \"email\": \"rahul@abc.com\",\n  \"message\": \"Your loan is approved!\"\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": "Post comment (Direction In, tagged api)",
					"request": {
						"method": "POST",
						"header": [
							{
								"key": "X-API-Key",
								"value": "{{apiKey}}",
								"type": "text"
							},
							{
								"key": "Content-Type",
								"value": "application/json",
								"type": "text"
							}
						],
						"url": {
							"raw": "{{baseUrl}}/comment.php",
							"host": [
								"{{baseUrl}}"
							],
							"path": [
								"comment.php"
							]
						},
						"description": "Post a comment into a member's external thread by **username**, tagged `source=api` + your `external_ref` (for echo-dedup on read-back). **Key-only write** — NO approved link required (writing in is the workspace's authority). Delivered identically to a human message (in-app + FCM + Soketi real-time) and fires `member.message` (origin:api).\n\n**Body:** `username` (required) · `comment` (required, max 60000) · `external_ref?` (your id for this post; silently truncated to 190 characters, not rejected) · `card?` (optional card object — see below).\n**Success 200:** `{ok, message_id, conversation_id, sent_at}`.\n**Errors:** 400 'username and comment are required' · 400 'comment is too long (max 60000 chars)' · 400 <card validation error> · 404 'No member with that username' · 404 'That member is not in your team' · 409 'Duplicate external_ref' (retry with same ref — original message_id + sent_at returned) · 401.\n\n**Card format (optional):** send a `card` object alongside `comment` to render a structured card in the thread. `comment` 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\n**Rate limit:** see the top-level *Rate limiting* section above — 100 requests/minute per key, same limit for every endpoint on this surface.\n\n**Scheduled or recurring messages:** StaqOn doesn't have a built-in scheduler, and you don't need one — just call this endpoint from your own system's cron job whenever you want the message sent. This is actually the better approach for reminders tied to real, changing conditions (e.g. \"loan docs still pending\") since your system always has the current truth; a StaqOn-side scheduler would just blindly repeat the same message regardless of whether the situation has changed.",
						"body": {
							"mode": "raw",
							"raw": "{\n  \"username\": \"4821k9037\",\n  \"comment\": \"Wallet topup approved \\u2014 \\u20b95,000 credited. UTR HDFC240727001234. New balance \\u20b912,500.\",\n  \"external_ref\": \"crm-1001\",\n  \"card\": {\n    \"header_color\": \"#059669\",\n    \"meta_title\": \"Wallet topup\",\n    \"title\": \"Approved \\u2014 \\u20b95,000\",\n    \"body\": \"Your wallet topup of \\u20b95,000 has been approved and credited to your Core wallet.\",\n    \"sub_body\": \"UTR: HDFC240727001234 \\u00b7 New balance: \\u20b912,500\",\n    \"button\": {\n      \"label\": \"View transaction\",\n      \"url\": \"https://vistarkriya.com/wallet/txn/1234\",\n      \"color\": \"#059669\"\n    }\n  }\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						}
					},
					"response": [
						{
							"name": "200 OK",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									},
									{
										"key": "Content-Type",
										"value": "application/json",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/comment.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"comment.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"username\": \"4821k9037\",\n  \"comment\": \"Your documents are verified.\",\n  \"external_ref\": \"crm-1001\"\n}",
									"options": {
										"raw": {
											"language": "json"
										}
									}
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": true, \"message_id\": 124, \"conversation_id\": 45, \"sent_at\": \"2026-07-02 09:15:00\"}"
						},
						{
							"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}}/comment.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"comment.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"username\": \"4821k9037\",\n  \"comment\": \"Your documents are verified.\",\n  \"external_ref\": \"crm-1001\"\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}}/comment.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"comment.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"username\": \"4821k9037\",\n  \"comment\": \"Payment failed \\u2014 please retry.\",\n  \"external_ref\": \"crm-1002\",\n  \"card\": {\n    \"header_color\": \"#DC2626\",\n    \"title\": \"Payment failed\",\n    \"body\": \"Your last payment attempt of \\u20b95,000 did not go through.\",\n    \"button\": {\n      \"label\": \"Retry payment\",\n      \"url\": \"https://vistarkriya.com/wallet/retry/1002\",\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, \"message_id\": 130, \"conversation_id\": 45, \"sent_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}}/comment.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"comment.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"username\": \"4821k9037\",\n  \"comment\": \"Action needed on your KYC.\",\n  \"external_ref\": \"crm-1003\",\n  \"card\": {\n    \"header_color\": \"#D97706\",\n    \"title\": \"KYC re-verification needed\",\n    \"body\": \"Your KYC documents expire in 5 days. Please re-upload to avoid a service pause.\",\n    \"button\": {\n      \"label\": \"Update KYC\",\n      \"url\": \"https://vistarkriya.com/kyc/update\",\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, \"message_id\": 131, \"conversation_id\": 45, \"sent_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}}/comment.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"comment.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"username\": \"4821k9037\",\n  \"comment\": \"Your loan is under review.\",\n  \"external_ref\": \"crm-1004\",\n  \"card\": {\n    \"header_color\": \"#2563EB\",\n    \"title\": \"Application received\",\n    \"body\": \"Your loan application #LN-4521 is now under review. We'll notify you once a decision is made.\",\n    \"button\": {\n      \"label\": \"Track status\",\n      \"url\": \"https://vistarkriya.com/loan/LN-4521\",\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, \"message_id\": 132, \"conversation_id\": 45, \"sent_at\": \"2026-08-30 09:10:00\"}"
						},
						{
							"name": "400 Bad Request — malformed card (unrecognised field)",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									},
									{
										"key": "Content-Type",
										"value": "application/json",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/comment.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"comment.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"username\": \"4821k9037\",\n  \"comment\": \"fallback text\",\n  \"card\": {\n    \"header_color\": \"#1F7A54\",\n    \"title\": \"Test\",\n    \"body\": \"Test body\",\n    \"icon\": \"warning\"\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.icon is not a recognised field\"}"
						}
					]
				},
				{
					"name": "Read conversation (Direction Out, LINK-GATED)",
					"request": {
						"method": "GET",
						"header": [
							{
								"key": "X-API-Key",
								"value": "{{apiKey}}",
								"type": "text"
							}
						],
						"url": {
							"raw": "{{baseUrl}}/messages.php?username=4821k9037&limit=50",
							"host": [
								"{{baseUrl}}"
							],
							"path": [
								"messages.php"
							],
							"query": [
								{
									"key": "username",
									"value": "4821k9037"
								},
								{
									"key": "limit",
									"value": "50"
								}
							]
						},
						"description": "Read the full chat thread between your team and a member. **Requires an APPROVED link (member consent)** — a `staqon_links` row for (workspace, member, `link_type='external'`) must be `approved`. See the collection-level \"Consent\" section for the real, current state of how that happens. Returns 403 until the member approves, and again the moment they revoke (kill-switch).\n\n**Query:** `username` (required) · `limit` (default 50, max 200) · `before=<id>` (older page, newest-first) **or** `after=<id>` (incremental delta, oldest-first) — mutually exclusive.\n**Success 200:** `{ok, conversation_id, member:{username,name}, messages:[{id, sender_side(member|team|system), sender_name, content, content_type, card, source, external_ref, sent_at, edited, deleted, attachments:[{name,mime,size,is_image,url,url_available}]}], paging:{mode,limit,returned,has_more,next_before|next_after}}`.\ncontent is always the real, decrypted message text — encryption at rest (where enabled) is handled transparently server-side.\n**Attachments:** `url` is a 1-day signed CDN link when `url_available:true`; deleted messages have blanked content, empty attachments, and null card.\n**Card:** `card` is the decoded card object (same shape as sent) when the message was sent as a card, else `null`. Card is blanked on deleted messages.\n**Errors:** 400 'username is required' · 400 'use either before or after, not both' · 404 'No member with that username' · 404 'That member is not in your team' · **403 'No approved link for this member'** · 401.\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."
					},
					"response": [
						{
							"name": "200 OK",
							"originalRequest": {
								"method": "GET",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/messages.php?username=4821k9037&limit=50",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"messages.php"
									],
									"query": [
										{
											"key": "username",
											"value": "4821k9037"
										},
										{
											"key": "limit",
											"value": "50"
										}
									]
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": true, \"conversation_id\": 45, \"member\": {\"username\": \"4821k9037\", \"name\": \"Rahul Sharma\"}, \"messages\": [{\"id\": 50, \"sender_side\": \"team\", \"sender_name\": \"Acme Bot\", \"content\": \"Your documents are verified.\", \"content_type\": \"text\", \"source\": \"api\", \"external_ref\": \"crm-1001\", \"sent_at\": \"2026-07-02 09:15:00\", \"edited\": false, \"deleted\": false, \"attachments\": []}], \"paging\": {\"mode\": \"before\", \"limit\": 50, \"returned\": 1, \"has_more\": false, \"next_before\": null}}"
						},
						{
							"name": "403 No approved link",
							"originalRequest": {
								"method": "GET",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/messages.php?username=4821k9037&limit=50",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"messages.php"
									],
									"query": [
										{
											"key": "username",
											"value": "4821k9037"
										},
										{
											"key": "limit",
											"value": "50"
										}
									]
								}
							},
							"status": "Forbidden",
							"code": 403,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": false, \"error\": \"No approved link for this member\"}"
						},
						{
							"name": "401 Invalid key",
							"originalRequest": {
								"method": "GET",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/messages.php?username=4821k9037&limit=50",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"messages.php"
									],
									"query": [
										{
											"key": "username",
											"value": "4821k9037"
										},
										{
											"key": "limit",
											"value": "50"
										}
									]
								}
							},
							"status": "Unauthorized",
							"code": 401,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": false, \"error\": \"Invalid API key\"}"
						}
					]
				}
			]
		},
		{
			"name": "Escalations",
			"item": [
				{
					"name": "Raise escalation",
					"request": {
						"method": "POST",
						"header": [
							{
								"key": "X-API-Key",
								"value": "{{apiKey}}",
								"type": "text"
							},
							{
								"key": "Content-Type",
								"value": "application/json",
								"type": "text"
							}
						],
						"url": {
							"raw": "{{baseUrl}}/escalation.php",
							"host": [
								"{{baseUrl}}"
							],
							"path": [
								"escalation.php"
							]
						},
						"description": "Raise an escalation case against a team member (on behalf of the workspace owner). **Key-only.**\n\n**Body:** `email` (required) · `title` (required, ≥2 chars) · `description` (required) · `due_date` (required, YYYY-MM-DD, ≥ tomorrow) · `priority?` (low|medium|high|critical, default medium).\n**Success 200:** `{ok, matter_id}`.\n**Errors:** 400 'email, title and description are required' · 400 'due_date must be a future date (YYYY-MM-DD, at least tomorrow)' · 404 'Member not found in this team' · 401.\n\nResolution follows the workspace's escalation resolve-policy (Settings → Escalation).\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  \"email\": \"rahul@abc.com\",\n  \"title\": \"Missing documents\",\n  \"description\": \"KYC docs not uploaded\",\n  \"priority\": \"high\",\n  \"due_date\": \"2026-12-31\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						}
					},
					"response": [
						{
							"name": "200 OK",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									},
									{
										"key": "Content-Type",
										"value": "application/json",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/escalation.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"escalation.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"email\": \"rahul@abc.com\",\n  \"title\": \"Missing documents\",\n  \"description\": \"KYC docs not uploaded\",\n  \"priority\": \"high\",\n  \"due_date\": \"2026-12-31\"\n}",
									"options": {
										"raw": {
											"language": "json"
										}
									}
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": true, \"matter_id\": 15}"
						},
						{
							"name": "400 Missing fields",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									},
									{
										"key": "Content-Type",
										"value": "application/json",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/escalation.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"escalation.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"email\": \"rahul@abc.com\",\n  \"title\": \"Missing documents\",\n  \"description\": \"KYC docs not uploaded\",\n  \"priority\": \"high\",\n  \"due_date\": \"2026-12-31\"\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\": \"email, title and description are required\"}"
						},
						{
							"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}}/escalation.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"escalation.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"email\": \"rahul@abc.com\",\n  \"title\": \"Missing documents\",\n  \"description\": \"KYC docs not uploaded\",\n  \"priority\": \"high\",\n  \"due_date\": \"2026-12-31\"\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": "List escalations (read-back)",
					"request": {
						"method": "GET",
						"header": [
							{
								"key": "X-API-Key",
								"value": "{{apiKey}}",
								"type": "text"
							}
						],
						"url": {
							"raw": "{{baseUrl}}/escalations.php?page=1&limit=50",
							"host": [
								"{{baseUrl}}"
							],
							"path": [
								"escalations.php"
							],
							"query": [
								{
									"key": "page",
									"value": "1"
								},
								{
									"key": "limit",
									"value": "50"
								},
								{
									"key": "status",
									"value": "open",
									"disabled": true
								},
								{
									"key": "matter_id",
									"value": "15",
									"disabled": true
								}
							]
						},
						"description": "**Read back escalations you raised** against your team's members — `escalation.php` was write-only until now.\n\n**Scope:** matters whose target member is in **your** team. You **cannot** see matters raised against another team's members, nor workspace- or team-scoped matters created in the StaqOn UI — only the member-scoped ones the API creates.\n\n**Success 200 (list):** `{ok:true, matters:[{uuid, title, description, status, priority, due_date, resolved_at, comment_count, last_activity_at, created_at, target_username}], total, page, pages}`.\n\n**Single matter:** add `?matter_id=<id>` (the integer `matter_id` from the create response / `escalation.created` webhook) -> `{ok:true, matter:{...same fields...}}`, or 404 if it isn't one of your team's matters.\n\n**Params:** `page` (default 1) - `limit` (default 50, max 200) - `status` (open/in_progress/resolved) - `matter_id` (single lookup). Ordered by priority (critical->low) then most-recent activity.\n\n**Deliberately not returned:** `resolution_notes` (internal handling notes), `created_by`/`resolved_by` (staff identities), `workspace_id`, and the sequential id. The target member is given as `target_username`.\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 List",
							"originalRequest": null,
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": true, \"matters\": [{\"uuid\": \"1d1ea5f6-859e-11f1-932c-00163e47fc9e\", \"title\": \"Missing documents\", \"description\": \"KYC docs not uploaded\", \"status\": \"open\", \"priority\": \"high\", \"due_date\": \"2026-12-31\", \"resolved_at\": null, \"comment_count\": 0, \"last_activity_at\": \"2026-07-02 09:15:00\", \"created_at\": \"2026-07-02 09:00:00\", \"target_username\": \"4821k9037\"}], \"total\": 1, \"page\": 1, \"pages\": 1}"
						},
						{
							"name": "200 Single (matter_id)",
							"originalRequest": null,
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": true, \"matter\": {\"uuid\": \"1d1ea5f6-859e-11f1-932c-00163e47fc9e\", \"title\": \"Missing documents\", \"description\": \"KYC docs not uploaded\", \"status\": \"open\", \"priority\": \"high\", \"due_date\": \"2026-12-31\", \"resolved_at\": null, \"comment_count\": 0, \"last_activity_at\": \"2026-07-02 09:15:00\", \"created_at\": \"2026-07-02 09:00:00\", \"target_username\": \"4821k9037\"}}"
						},
						{
							"name": "404 Not in your team",
							"originalRequest": null,
							"status": "Not Found",
							"code": 404,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": false, \"error\": \"Matter not found\"}"
						},
						{
							"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": "e-StaqOn Verify",
			"item": [
				{
					"name": "e-StaqOn Verify — Start",
					"request": {
						"method": "POST",
						"header": [
							{
								"key": "X-API-Key",
								"value": "{{apiKey}}",
								"type": "text"
							},
							{
								"key": "Content-Type",
								"value": "application/json",
								"type": "text"
							}
						],
						"url": {
							"raw": "{{baseUrl}}/staqon-verify-start.php",
							"host": [
								"{{baseUrl}}"
							],
							"path": [
								"staqon-verify-start.php"
							]
						},
						"description": "Ask a member to confirm a sensitive action. StaqOn sends them a push; they approve or decline in the StaqOn app. **Needs an approved link.**\n\n**Body:** `username` (required) · `action` (required, max 100 chars) · `context` (required, max 500 chars — shown to the member verbatim on their approval screen) · `ttl_seconds?` (default 30, clamped 1–120).\n**Success 200:** `{ok, verification_id, expires_at}` — keep `verification_id` and poll **Check**.\n**Errors:** 400 'username, action and context are required' · 400 'context is too long (max 500 chars)' · 404 'No member with that username' · 404 'That member is not in your team' · 403 'No approved link for this member' · 429 'Too many verification requests for this user. Try again in a minute.' · 401.\n\nThe member must have an approved, unexpired external-team link. At most **5 requests per minute per member**.\n\n**What the member sees:** a push (and an on-screen prompt in the StaqOn web app) titled \"Verify it's you\" showing your `context` verbatim, the **name of your workspace** (`workspace_name`) and a countdown to `expires_at`. Those are member-side fields — your Start and Check responses do not include `workspace_name`. When the member answers on one device, their other devices close the prompt automatically. The member-side calls (Resolve and the open-requests list) are **app-session only** — an API key cannot call them.\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\": \"member_username\",\n  \"action\": \"bank_detail_change\",\n  \"context\": \"Approve updating bank account details for ACME Corp?\",\n  \"ttl_seconds\": 30\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						}
					},
					"response": [
						{
							"name": "200 OK",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									},
									{
										"key": "Content-Type",
										"value": "application/json",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/staqon-verify-start.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"staqon-verify-start.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"username\": \"member_username\",\n  \"action\": \"bank_detail_change\",\n  \"context\": \"Approve updating bank account details for ACME Corp?\",\n  \"ttl_seconds\": 30\n}",
									"options": {
										"raw": {
											"language": "json"
										}
									}
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": true, \"verification_id\": \"9f1c2e4a-0b7d-4c3e-8a15-6d2b7e9f0a31\", \"expires_at\": \"2026-10-14T09:46:13+05:30\"}"
						},
						{
							"name": "403 No approved link",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									},
									{
										"key": "Content-Type",
										"value": "application/json",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/staqon-verify-start.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"staqon-verify-start.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"username\": \"member_username\",\n  \"action\": \"bank_detail_change\",\n  \"context\": \"Approve updating bank account details for ACME Corp?\",\n  \"ttl_seconds\": 30\n}",
									"options": {
										"raw": {
											"language": "json"
										}
									}
								}
							},
							"status": "Forbidden",
							"code": 403,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": false, \"error\": \"No approved link for this member\"}"
						},
						{
							"name": "429 Too many requests",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									},
									{
										"key": "Content-Type",
										"value": "application/json",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/staqon-verify-start.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"staqon-verify-start.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"username\": \"member_username\",\n  \"action\": \"bank_detail_change\",\n  \"context\": \"Approve updating bank account details for ACME Corp?\",\n  \"ttl_seconds\": 30\n}",
									"options": {
										"raw": {
											"language": "json"
										}
									}
								}
							},
							"status": "Too Many Requests",
							"code": 429,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": false, \"error\": \"Too many verification requests for this user. Try again in a minute.\"}"
						},
						{
							"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}}/staqon-verify-start.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"staqon-verify-start.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"username\": \"member_username\",\n  \"action\": \"bank_detail_change\",\n  \"context\": \"Approve updating bank account details for ACME Corp?\",\n  \"ttl_seconds\": 30\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": "e-StaqOn Verify — Check",
					"request": {
						"method": "POST",
						"header": [
							{
								"key": "X-API-Key",
								"value": "{{apiKey}}",
								"type": "text"
							},
							{
								"key": "Content-Type",
								"value": "application/json",
								"type": "text"
							}
						],
						"url": {
							"raw": "{{baseUrl}}/staqon-verify-check.php",
							"host": [
								"{{baseUrl}}"
							],
							"path": [
								"staqon-verify-check.php"
							]
						},
						"description": "Check the outcome of a verification from **Start**. **Key-only.**\n\n**Body:** `verification_id` (required — the id returned by Start; paste it into the body, this collection does not chain variables).\n**Success 200 — one of four shapes:** `{ok:true, verified:true}` (approved in time) · `{ok:true, verified:false, reason:'pending'}` (not answered yet) · `{ok:true, verified:false, reason:'declined'}` · `{ok:true, verified:false, reason:'timeout'}` (expired unanswered). Only `verified:true` means approved.\n**Errors:** 400 'verification_id is required' · 404 'verification_id not found' (unknown, or not your workspace's) · 401.\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  \"verification_id\": \"9f1c2e4a-0b7d-4c3e-8a15-6d2b7e9f0a31\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						}
					},
					"response": [
						{
							"name": "200 Approved",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									},
									{
										"key": "Content-Type",
										"value": "application/json",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/staqon-verify-check.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"staqon-verify-check.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"verification_id\": \"9f1c2e4a-0b7d-4c3e-8a15-6d2b7e9f0a31\"\n}",
									"options": {
										"raw": {
											"language": "json"
										}
									}
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": true, \"verified\": true}"
						},
						{
							"name": "200 Pending",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									},
									{
										"key": "Content-Type",
										"value": "application/json",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/staqon-verify-check.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"staqon-verify-check.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"verification_id\": \"9f1c2e4a-0b7d-4c3e-8a15-6d2b7e9f0a31\"\n}",
									"options": {
										"raw": {
											"language": "json"
										}
									}
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": true, \"verified\": false, \"reason\": \"pending\"}"
						},
						{
							"name": "200 Declined",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									},
									{
										"key": "Content-Type",
										"value": "application/json",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/staqon-verify-check.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"staqon-verify-check.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"verification_id\": \"9f1c2e4a-0b7d-4c3e-8a15-6d2b7e9f0a31\"\n}",
									"options": {
										"raw": {
											"language": "json"
										}
									}
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": true, \"verified\": false, \"reason\": \"declined\"}"
						},
						{
							"name": "200 Timeout",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									},
									{
										"key": "Content-Type",
										"value": "application/json",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/staqon-verify-check.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"staqon-verify-check.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"verification_id\": \"9f1c2e4a-0b7d-4c3e-8a15-6d2b7e9f0a31\"\n}",
									"options": {
										"raw": {
											"language": "json"
										}
									}
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": true, \"verified\": false, \"reason\": \"timeout\"}"
						},
						{
							"name": "404 Unknown id",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "X-API-Key",
										"value": "{{apiKey}}",
										"type": "text"
									},
									{
										"key": "Content-Type",
										"value": "application/json",
										"type": "text"
									}
								],
								"url": {
									"raw": "{{baseUrl}}/staqon-verify-check.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"staqon-verify-check.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"verification_id\": \"9f1c2e4a-0b7d-4c3e-8a15-6d2b7e9f0a31\"\n}",
									"options": {
										"raw": {
											"language": "json"
										}
									}
								}
							},
							"status": "Not Found",
							"code": 404,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": false, \"error\": \"verification_id not found\"}"
						},
						{
							"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}}/staqon-verify-check.php",
									"host": [
										"{{baseUrl}}"
									],
									"path": [
										"staqon-verify-check.php"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"verification_id\": \"9f1c2e4a-0b7d-4c3e-8a15-6d2b7e9f0a31\"\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": "e-StaqOn Verify — Resolve (APP SESSION ONLY — not an API-key call)",
					"request": {
						"method": "POST",
						"header": [
							{
								"key": "Content-Type",
								"value": "application/json",
								"type": "text"
							}
						],
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "<member app-session token — not an API key>",
									"type": "string"
								}
							]
						},
						"url": {
							"raw": "https://staqon.one/api/staqon-verify-resolve.php?action=resolve",
							"protocol": "https",
							"host": [
								"staqon",
								"one"
							],
							"path": [
								"api",
								"staqon-verify-resolve.php"
							],
							"query": [
								{
									"key": "action",
									"value": "resolve"
								}
							]
						},
						"description": "**APP SESSION ONLY — NOT an External API call.** Included for reference only: this is the endpoint the StaqOn app calls when the member taps Approve or Decline. **A tenant's `X-API-Key` cannot call it** — it needs a `Bearer` token from a real logged-in member session, and it only ever resolves that member's own request.\n\n**Body:** `verification_id` (required) · `decision` (required, `approved` | `declined`).\n**Success 200:** `{ok, status}`.\n**Errors:** 400 (missing id / invalid decision) · 404 'Verification request not found.' (unknown, or not the caller's) · 409 `{reason:'already_resolved', status}` · 410 `{reason:'expired'}` · 401.\n\n**After a first-time 200** StaqOn tells the member's *other* sessions/devices (realtime event `staqon-verify-resolved` `{verification_id, decision, resolved_at}` plus a silent push `type=staqon_verify_resolved`) so no stale prompt stays open; nothing is sent for 400/404/409/410. Tenants read the outcome with **Check**.",
						"body": {
							"mode": "raw",
							"raw": "{\n  \"verification_id\": \"9f1c2e4a-0b7d-4c3e-8a15-6d2b7e9f0a31\",\n  \"decision\": \"approved\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						}
					},
					"response": [
						{
							"name": "200 OK",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "Content-Type",
										"value": "application/json",
										"type": "text"
									}
								],
								"url": {
									"raw": "https://staqon.one/api/staqon-verify-resolve.php?action=resolve",
									"protocol": "https",
									"host": [
										"staqon",
										"one"
									],
									"path": [
										"api",
										"staqon-verify-resolve.php"
									],
									"query": [
										{
											"key": "action",
											"value": "resolve"
										}
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"verification_id\": \"9f1c2e4a-0b7d-4c3e-8a15-6d2b7e9f0a31\",\n  \"decision\": \"approved\"\n}",
									"options": {
										"raw": {
											"language": "json"
										}
									}
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": true, \"status\": \"approved\"}"
						},
						{
							"name": "409 Already resolved",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "Content-Type",
										"value": "application/json",
										"type": "text"
									}
								],
								"url": {
									"raw": "https://staqon.one/api/staqon-verify-resolve.php?action=resolve",
									"protocol": "https",
									"host": [
										"staqon",
										"one"
									],
									"path": [
										"api",
										"staqon-verify-resolve.php"
									],
									"query": [
										{
											"key": "action",
											"value": "resolve"
										}
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"verification_id\": \"9f1c2e4a-0b7d-4c3e-8a15-6d2b7e9f0a31\",\n  \"decision\": \"approved\"\n}",
									"options": {
										"raw": {
											"language": "json"
										}
									}
								}
							},
							"status": "Conflict",
							"code": 409,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": false, \"reason\": \"already_resolved\", \"status\": \"approved\"}"
						},
						{
							"name": "410 Expired",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "Content-Type",
										"value": "application/json",
										"type": "text"
									}
								],
								"url": {
									"raw": "https://staqon.one/api/staqon-verify-resolve.php?action=resolve",
									"protocol": "https",
									"host": [
										"staqon",
										"one"
									],
									"path": [
										"api",
										"staqon-verify-resolve.php"
									],
									"query": [
										{
											"key": "action",
											"value": "resolve"
										}
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"verification_id\": \"9f1c2e4a-0b7d-4c3e-8a15-6d2b7e9f0a31\",\n  \"decision\": \"approved\"\n}",
									"options": {
										"raw": {
											"language": "json"
										}
									}
								}
							},
							"status": "Gone",
							"code": 410,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": false, \"reason\": \"expired\"}"
						}
					]
				},
				{
					"name": "e-StaqOn Verify — Pending list (APP SESSION ONLY — not an API-key call)",
					"request": {
						"method": "GET",
						"header": [],
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "<member app-session token — not an API key>",
									"type": "string"
								}
							]
						},
						"url": {
							"raw": "https://staqon.one/api/staqon-verify-pending.php?action=list",
							"protocol": "https",
							"host": [
								"staqon",
								"one"
							],
							"path": [
								"api",
								"staqon-verify-pending.php"
							],
							"query": [
								{
									"key": "action",
									"value": "list"
								}
							]
						},
						"description": "**APP SESSION ONLY — NOT an External API call.** Included for reference only: this is how the StaqOn app/web recovers a push or realtime event it missed. **A tenant's `X-API-Key` cannot call it** — it needs a `Bearer` token from a real logged-in member session and returns only that member's own open requests. (Also accepts `POST` with no body; read-only, so no CSRF token.)\n\n**Success 200:** `{ok, requests:[{verification_id, action, context, workspace_name, expires_at, server_now}]}` — only `pending`, not-yet-expired requests, oldest first (`[]` when none). `expires_at` and `server_now` are Unix epoch seconds from the same database clock, so a countdown is `expires_at − server_now` without trusting the device clock. Overdue rows are marked expired before listing.\n**Errors:** 401 'Authentication required.' · 404 'Unknown action.' (any `?action=` other than `list`)."
					},
					"response": [
						{
							"name": "200 OK",
							"originalRequest": {
								"method": "GET",
								"header": [],
								"url": {
									"raw": "https://staqon.one/api/staqon-verify-pending.php?action=list",
									"protocol": "https",
									"host": [
										"staqon",
										"one"
									],
									"path": [
										"api",
										"staqon-verify-pending.php"
									],
									"query": [
										{
											"key": "action",
											"value": "list"
										}
									]
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": true, \"requests\": [{\"verification_id\": \"9f1c2e4a-0b7d-4c3e-8a15-6d2b7e9f0a31\", \"action\": \"bank_detail_change\", \"context\": \"Approve updating bank account details for ACME Corp?\", \"workspace_name\": \"ACME Workspace\", \"expires_at\": 1791951373, \"server_now\": 1791951343}]}"
						},
						{
							"name": "200 Nothing pending",
							"originalRequest": {
								"method": "GET",
								"header": [],
								"url": {
									"raw": "https://staqon.one/api/staqon-verify-pending.php?action=list",
									"protocol": "https",
									"host": [
										"staqon",
										"one"
									],
									"path": [
										"api",
										"staqon-verify-pending.php"
									],
									"query": [
										{
											"key": "action",
											"value": "list"
										}
									]
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": true, \"requests\": []}"
						},
						{
							"name": "401 Not signed in",
							"originalRequest": {
								"method": "GET",
								"header": [],
								"url": {
									"raw": "https://staqon.one/api/staqon-verify-pending.php?action=list",
									"protocol": "https",
									"host": [
										"staqon",
										"one"
									],
									"path": [
										"api",
										"staqon-verify-pending.php"
									],
									"query": [
										{
											"key": "action",
											"value": "list"
										}
									]
								}
							},
							"status": "Unauthorized",
							"code": 401,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"cookie": [],
							"body": "{\"ok\": false, \"error\": \"Authentication required.\"}"
						}
					]
				}
			]
		},
		{
			"name": "Webhooks",
			"description": "# Webhooks (StaqOn → your server)\n\nStaqOn POSTs a JSON callback to your subscribed URL when a workspace event fires. As of 2026-09-01, webhooks are configured as part of your **Link API** key — **one webhook per workspace** (not per team, as this section used to describe), using the same URL and shared secret as your Link key. Pick which events you want at **Workspace → Settings → API → Link**.\n\n## Delivery\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, no redirects (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:\n```\nexpected = hmac_sha256(raw_body, webhook_secret)      # lowercase hex\nvalid    = constant_time_equals(expected, X-StaqOn-Signature header)\n```\nReject the request if it doesn't match.\n\n## Envelope (all events)\n```\n{ \"event\": \"<name>\", \"timestamp\": \"<ISO-8601>\", \"data\": { … } }\n```\n\n## The 6 events + their `data`\n\n`team_id` is now included on **every** event, regardless of which team the underlying activity happened in — since a workspace's webhook is no longer team-scoped, this is how you route an incoming event to the right team on your side.\n\n| event | fires when | data fields |\n|---|---|---|\n| `member.joined` | external member self-joins a team | `member_id`, `name`, `team_id` |\n| `member.message` | a message is posted into a member's external thread (outbound to you) | `member_id`, `email` **or** `username`, `message`, `direction:\"outbound\"`, `message_id`, `conversation_id`, `team_id` — plus `origin:\"api\"` + `external_ref` when posted via the Comment API |\n| `member.removed` | a member is removed from the team | `member_id`, `email`, `team_id` |\n| `member.left` | a member leaves the team on their own | `member_id`, `name`, `team_id` |\n| `escalation.created` | an escalation case is raised | `matter_id`, `title`, `priority`, `status:\"open\"`, `scope`, `target_member_id`, `target_email`, `due_date`, `team_id` |\n| `escalation.resolved` | an escalation case is resolved | `matter_id`, `status:\"resolved\"`, `team_id` — `resolved_by` and `resolution_notes` are withheld (staff identity / internal notes) |\n\n⚠️ **Not (yet) listed here:** the old `link.approved` / `link.declined` / `link.revoked` trio 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 (see the collection-level \"Consent\" section) — nothing invented in advance.\n\n## Example payloads\n**member.message** (via Comment API — tagged origin:api):\n```\n{ \"event\":\"member.message\", \"timestamp\":\"2026-07-13T13:00:00+00:00\",\n  \"data\":{ \"member_id\":42, \"username\":\"8225d3073\", \"message\":\"Docs received, thanks.\",\n           \"direction\":\"outbound\", \"origin\":\"api\", \"external_ref\":\"crm-9931\", \"message_id\":7781, \"conversation_id\":45, \"team_id\":117 } }\n```\n**escalation.created**:\n```\n{ \"event\":\"escalation.created\", \"timestamp\":\"2026-07-13T13:00:00+00:00\",\n  \"data\":{ \"matter_id\":15, \"title\":\"Docs pending\", \"priority\":\"high\", \"status\":\"open\",\n           \"scope\":\"member\", \"target_member_id\":42, \"target_email\":\"rahul@abc.com\", \"due_date\":\"2026-12-31\", \"team_id\":117 } }\n```\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": "Link API",
			"description": "# Link API — inviting and pulling linked members\n\n## Requesting an invite token (2026-10-15)\n\n**You** call **StaqOn** to get a single-use token for one of your customers, then deliver it to them yourself. StaqOn never contacts the customer. The customer opens `confirm_url` (or enters the code under \"Link this workspace\"), is logged in to StaqOn, and taps **Yes** — StaqOn then POSTs `link.status_changed` (now including `external_ref` and `crm_email`) to your Link webhook, whether or not you subscribed to that event. Each invite also comes with a 6-character `short_code` (easy to type or read out). **The customer must type the invite's `email` to confirm — by link, token or short code alike**; a wrong email is rejected with the same generic error as a wrong code. Tokens are single-use and valid for 1 hour; a newer invite for the same `external_ref` supersedes an older pending one. Rate limit: 30 requests/minute per `key_id` (`429` + `Retry-After: 60`). The email + second-question verification this replaces is retired; members already linked are unaffected.\n\nThe **Request invite token** request below has a pre-request script that fills `linkTimestamp` and `linkSignature` for you — set the `linkKeyId` and `linkSecret` collection variables to your Link key ID and secret.\n\n## Confirming from the mobile app (Bearer-authenticated)\n\n`/app/link-confirm.php` needs a browser cookie session; the StaqOn app authenticates with `Authorization: Bearer <token>` and has no cookies, so the same confirm step is also exposed as two JSON actions on `/api/link.php` — **the Bearer-auth app equivalents of the cookie-based web confirm page**, running the same shared logic. They are **member-side** calls made by the StaqOn app with the logged-in member's own app-session token (not an API key, and not something your CRM calls): **Confirm invite — lookup (app)** and **Confirm invite — decide (app)** below. The caller must be an active guest of the workspace; send `email` (mandatory) plus either the raw `lnk_…` `token` or the 6-character `short_code` (a pasted `confirm_url` is rejected). Failed lookups are limited to 10/minute per user and per IP, sharing counters with the web page (`429` + `Retry-After: 60`). The invite's own workspace decides who may act, not strictly the `w` slug.\n\n## Pulling your linked members\n\nA real, working, read-only endpoint (2026-09-02) — the reverse direction from every other request in this collection: **you** call **StaqOn**, HMAC-signed the same way the health endpoint verifies your inbound calls. Use it to show your own customers their real-time connection status on your own site, or to reconcile your own records against StaqOn's.\n\n**Scope — approved External links only.** Only rows with `status='approved'` and `link_type='external'` are returned. Pending/declined/expired/revoked rows are omitted (this is a \"who's currently connected\" view, not an audit log), and Internal (staff) links are never included — deliberately, since this is meant for a customer-facing list.\n\n## Signature\n\nCovers the raw request body: `hash_hmac('sha256', \"{unix_timestamp}.{raw_body}\", your_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. Rotation is supported: a request signed with your *previous* secret is still accepted for the real 15-minute grace window after rotating.\n\n**`username`** is now included in every returned link — StaqOn's own real username for that person, a genuinely different value from `crm_email`. This is exactly what `POST /message.php` or `POST /comment.php` actually need to notify this member; `crm_email` alone was never enough to reach them through StaqOn itself.\n",
			"item": [
				{
					"name": "Request invite token",
					"event": [
						{
							"listen": "prerequest",
							"script": {
								"type": "text/javascript",
								"exec": [
									"// Signs the request exactly as StaqOn verifies it: HMAC-SHA256 of \"{unix_timestamp}.{raw_body}\" keyed by your Link secret,",
									"// sent as X-StaqOn-Signature: t=<unix_timestamp>,v1=<hex>. The body's timestamp is an ISO-8601 string within +/-5 min of server time.",
									"const secret = pm.collectionVariables.get('linkSecret');",
									"const t = Math.floor(Date.now() / 1000);",
									"pm.variables.set('linkTimestamp', new Date(t * 1000).toISOString().replace(/\\.\\d{3}Z$/, 'Z'));",
									"const raw = pm.variables.replaceIn(pm.request.body.raw);   // body exactly as it will be sent",
									"const hex = CryptoJS.HmacSHA256(t + '.' + raw, secret).toString(CryptoJS.enc.Hex);",
									"pm.variables.set('linkSignature', 't=' + t + ',v1=' + hex);"
								]
							}
						}
					],
					"request": {
						"method": "POST",
						"header": [
							{
								"key": "Content-Type",
								"value": "application/json"
							},
							{
								"key": "X-StaqOn-Signature",
								"value": "{{linkSignature}}",
								"description": "Filled by the pre-request script: t=<unix_timestamp>,v1=<hex HMAC-SHA256 of \"{unix_timestamp}.{raw_body}\">. Bare hex of the raw body alone is also accepted."
							}
						],
						"body": {
							"mode": "raw",
							"raw": "{\n  \"key_id\": {{linkKeyId}},\n  \"timestamp\": \"{{linkTimestamp}}\",\n  \"email\": \"customer@example.com\",\n  \"external_ref\": \"CRM-10492\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						},
						"url": {
							"raw": "https://staqon.one/api/link-invite.php",
							"protocol": "https",
							"host": [
								"staqon",
								"one"
							],
							"path": [
								"api",
								"link-invite.php"
							]
						},
						"description": "Request a single-use link token for one of your customers.\n\n**Body:** `key_id` (your Link API key ID) · `timestamp` (ISO-8601, ±5 min) · `email` (valid email, stored as the link's `crm_email`) · `external_ref` (your own customer ID, ≤255 chars).\n\n**Success 200:** `{ok, token, short_code, expires_in: 3600, confirm_url}` — the token is shown once (StaqOn stores only its hash). `short_code` is a 6-character code (letters A–Z and digits 2–9, never 0/O/1/I/L) you can send by SMS/WhatsApp or read out instead of the long token. The token, the short code and `confirm_url` are ONE invite (same expiry; using one burns all). Deliver them to the customer through your own channel; StaqOn sends them nothing. **The customer must also type the `email` you set here to confirm** (case-insensitive), whichever of the three they use.\n\n**Errors:** `400` malformed JSON / missing `key_id`, `timestamp` or signature / invalid `email` / missing or over-long `external_ref` · `401` \"Invalid signature or unknown key.\" (bad signature, unknown or revoked key, stale timestamp — indistinguishable) · `404` non-POST · `429` rate limit (30/min per `key_id`, `Retry-After: 60`) · `500` \"Could not create the invite.\"\n\nRotation: a request signed with your previous secret is accepted for 15 minutes after rotating."
					},
					"response": [
						{
							"name": "200 OK",
							"originalRequest": {
								"method": "POST",
								"header": [],
								"url": {
									"raw": "https://staqon.one/api/link-invite.php"
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"body": "{\"ok\": true, \"token\": \"lnk_3f9c0b8e1d2a4c6e7f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6\", \"short_code\": \"K7M2QX\", \"expires_in\": 3600, \"confirm_url\": \"https://staqon.one/app/link-confirm.php?token=lnk_3f9c0b8e1d2a4c6e7f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6\"}"
						},
						{
							"name": "400 Invalid email",
							"originalRequest": {
								"method": "POST",
								"header": [],
								"url": {
									"raw": "https://staqon.one/api/link-invite.php"
								}
							},
							"status": "Bad Request",
							"code": 400,
							"_postman_previewlanguage": "json",
							"body": "{\"ok\": false, \"error\": \"Enter a valid email.\"}"
						},
						{
							"name": "401 Invalid signature",
							"originalRequest": {
								"method": "POST",
								"header": [],
								"url": {
									"raw": "https://staqon.one/api/link-invite.php"
								}
							},
							"status": "Unauthorized",
							"code": 401,
							"_postman_previewlanguage": "json",
							"body": "{\"ok\": false, \"error\": \"Invalid signature or unknown key.\"}"
						},
						{
							"name": "429 Rate limit exceeded",
							"originalRequest": {
								"method": "POST",
								"header": [],
								"url": {
									"raw": "https://staqon.one/api/link-invite.php"
								}
							},
							"status": "Too Many Requests",
							"code": 429,
							"header": [
								{
									"key": "Retry-After",
									"value": "60"
								}
							],
							"_postman_previewlanguage": "json",
							"body": "{\"ok\": false, \"error\": \"Rate limit exceeded — max 30 requests per minute per key.\"}"
						}
					]
				},
				{
					"name": "Confirm invite — lookup (app)",
					"request": {
						"method": "POST",
						"header": [
							{
								"key": "Content-Type",
								"value": "application/json",
								"type": "text"
							}
						],
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "<member app-session token — not an API key>",
									"type": "string"
								}
							]
						},
						"body": {
							"mode": "raw",
							"raw": "{\n  \"email\": \"customer@example.com\",\n  \"token\": \"lnk_3f9c0b8e1d2a4c6e7f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						},
						"url": {
							"raw": "https://staqon.one/api/link.php?action=invite_lookup&w=your-workspace-slug",
							"protocol": "https",
							"host": [
								"staqon",
								"one"
							],
							"path": [
								"api",
								"link.php"
							],
							"query": [
								{
									"key": "action",
									"value": "invite_lookup"
								},
								{
									"key": "w",
									"value": "your-workspace-slug"
								}
							]
						},
						"description": "**APP SESSION ONLY — member-side, not an API-key call.** Shows the member what they are about to confirm. `Authorization: Bearer <member app-session token>`; the caller must be an active guest (not a sub-user) of the workspace in `w`.\n\n**Body:** `email` (REQUIRED — the address the business gave; compared case-insensitively with the invite's `crm_email`) plus ONE identifier: `token` (the raw `lnk_…` token) **or** `short_code` (the 6-character code, any case). If you send both they must belong to the same invite. A pasted `confirm_url` is rejected, extract the token first.\n\n**Success 200:** `{ok, business_name, account_email, external_ref}` — `account_email` is the member's own StaqOn email; `crm_email` is intentionally not returned. Changes nothing (the invite stays pending).\n\n**Errors:** `400` \"Couldn't find that link code.\" for every failure case (unknown, malformed, already used, superseded, expired, wrong workspace, sub-user, non-guest of the invite's workspace, **a missing or wrong `email`**, a token and short_code from different invites) — an email mismatch is NOT a separate error code — deliberately indistinguishable · `401` \"Authentication required.\" · `403` \"This flow is for external members only.\" (internal member) or \"You are not a member of this workspace.\" · `404` \"Workspace not found.\" · `429` rate limit — 10 failed lookups/min per user and per IP (email mismatches count), shared with the web confirm page, `Retry-After: 60` · `503` \"Please try again shortly.\" (rate-limit store unavailable; fails closed)."
					},
					"response": [
						{
							"name": "200 OK",
							"originalRequest": {
								"method": "POST",
								"header": [],
								"url": {
									"raw": "https://staqon.one/api/link.php?action=invite_lookup&w=your-workspace-slug"
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"body": "{\"ok\": true, \"business_name\": \"Acme Corp\", \"account_email\": \"customer@example.com\", \"external_ref\": \"CRM-10492\"}"
						},
						{
							"name": "400 Couldn't find that link code",
							"originalRequest": {
								"method": "POST",
								"header": [],
								"url": {
									"raw": "https://staqon.one/api/link.php?action=invite_lookup&w=your-workspace-slug"
								}
							},
							"status": "Bad Request",
							"code": 400,
							"_postman_previewlanguage": "json",
							"body": "{\"ok\": false, \"error\": \"Couldn't find that link code.\"}"
						},
						{
							"name": "400 Email doesn't match the invite (same generic error)",
							"originalRequest": {
								"method": "POST",
								"header": [],
								"url": {
									"raw": "https://staqon.one/api/link.php"
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"email\": \"someone.else@example.com\",\n  \"short_code\": \"K7M2QX\"\n}"
								}
							},
							"status": "Bad Request",
							"code": 400,
							"_postman_previewlanguage": "json",
							"body": "{\"ok\": false, \"error\": \"Couldn't find that link code.\"}"
						},
						{
							"name": "401 Authentication required",
							"originalRequest": {
								"method": "POST",
								"header": [],
								"url": {
									"raw": "https://staqon.one/api/link.php?action=invite_lookup&w=your-workspace-slug"
								}
							},
							"status": "Unauthorized",
							"code": 401,
							"_postman_previewlanguage": "json",
							"body": "{\"ok\": false, \"error\": \"Authentication required.\"}"
						},
						{
							"name": "403 Internal member",
							"originalRequest": {
								"method": "POST",
								"header": [],
								"url": {
									"raw": "https://staqon.one/api/link.php?action=invite_lookup&w=your-workspace-slug"
								}
							},
							"status": "Forbidden",
							"code": 403,
							"_postman_previewlanguage": "json",
							"body": "{\"ok\": false, \"error\": \"This flow is for external members only.\"}"
						},
						{
							"name": "429 Too many attempts",
							"originalRequest": {
								"method": "POST",
								"header": [],
								"url": {
									"raw": "https://staqon.one/api/link.php?action=invite_lookup&w=your-workspace-slug"
								}
							},
							"status": "Too Many Requests",
							"code": 429,
							"_postman_previewlanguage": "json",
							"body": "{\"ok\": false, \"error\": \"Too many attempts. Please wait a minute and try again.\"}",
							"header": [
								{
									"key": "Retry-After",
									"value": "60"
								}
							]
						}
					]
				},
				{
					"name": "Confirm invite — decide (app)",
					"request": {
						"method": "POST",
						"header": [
							{
								"key": "Content-Type",
								"value": "application/json",
								"type": "text"
							}
						],
						"auth": {
							"type": "bearer",
							"bearer": [
								{
									"key": "token",
									"value": "<member app-session token — not an API key>",
									"type": "string"
								}
							]
						},
						"body": {
							"mode": "raw",
							"raw": "{\n  \"email\": \"customer@example.com\",\n  \"short_code\": \"K7M2QX\",\n  \"decision\": \"yes\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						},
						"url": {
							"raw": "https://staqon.one/api/link.php?action=invite_confirm&w=your-workspace-slug",
							"protocol": "https",
							"host": [
								"staqon",
								"one"
							],
							"path": [
								"api",
								"link.php"
							],
							"query": [
								{
									"key": "action",
									"value": "invite_confirm"
								},
								{
									"key": "w",
									"value": "your-workspace-slug"
								}
							]
						},
						"description": "**APP SESSION ONLY — member-side, not an API-key call.** Applies the member's Yes/No. Same auth, token and rate-limit rules as the lookup.\n\n**Body:** `email` (REQUIRED, must match the invite's `crm_email`, case-insensitive) · ONE identifier: `token` (raw `lnk_…`) **or** `short_code` (6 characters) · `decision` (`\"yes\"` | `\"no\"`).\n\n**Yes → 200** `{ok:true, status:\"approved\"}`: the token is burned (single-use), `staqon_links` is set to approved with the invite's `external_ref` and `crm_email`, and ONE `link.status_changed` webhook (incl. `external_ref` + `crm_email`) is sent to your External Link key regardless of its event subscription.\n**No → 200** `{ok:true, status:\"declined\"}`: token burned, no link row, no webhook.\n\n**Errors:** `400` generic \"Couldn't find that link code.\" (also for a replay of a used token or code — with or without the right email —, for a missing or wrong `email`, and for a missing/invalid `decision`; an email mismatch is NOT a separate error code) · `401`/`403`/`404` as for the lookup · `409` \"Couldn't link: this customer is already linked to a different StaqOn account. Please contact the business.\" (invite stays pending) · `429` rate limit (shared with the web page, `Retry-After: 60`) · `500` \"Something went wrong. Please try again.\" · `503` \"Please try again shortly.\".\n\nThe invite's own workspace decides who may act, not strictly the `w` slug — as on the web page."
					},
					"response": [
						{
							"name": "200 OK — approved",
							"originalRequest": {
								"method": "POST",
								"header": [],
								"url": {
									"raw": "https://staqon.one/api/link.php?action=invite_confirm&w=your-workspace-slug"
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"body": "{\"ok\": true, \"status\": \"approved\"}"
						},
						{
							"name": "200 OK — declined",
							"originalRequest": {
								"method": "POST",
								"header": [],
								"url": {
									"raw": "https://staqon.one/api/link.php?action=invite_confirm&w=your-workspace-slug"
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"body": "{\"ok\": true, \"status\": \"declined\"}"
						},
						{
							"name": "400 Couldn't find that link code",
							"originalRequest": {
								"method": "POST",
								"header": [],
								"url": {
									"raw": "https://staqon.one/api/link.php?action=invite_confirm&w=your-workspace-slug"
								}
							},
							"status": "Bad Request",
							"code": 400,
							"_postman_previewlanguage": "json",
							"body": "{\"ok\": false, \"error\": \"Couldn't find that link code.\"}"
						},
						{
							"name": "400 Email doesn't match the invite (same generic error)",
							"originalRequest": {
								"method": "POST",
								"header": [],
								"url": {
									"raw": "https://staqon.one/api/link.php"
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"email\": \"someone.else@example.com\",\n  \"token\": \"lnk_3f9c0b8e1d2a4c6e7f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6\",\n  \"decision\": \"yes\"\n}"
								}
							},
							"status": "Bad Request",
							"code": 400,
							"_postman_previewlanguage": "json",
							"body": "{\"ok\": false, \"error\": \"Couldn't find that link code.\"}"
						},
						{
							"name": "409 Already linked to another account",
							"originalRequest": {
								"method": "POST",
								"header": [],
								"url": {
									"raw": "https://staqon.one/api/link.php?action=invite_confirm&w=your-workspace-slug"
								}
							},
							"status": "Conflict",
							"code": 409,
							"_postman_previewlanguage": "json",
							"body": "{\"ok\": false, \"error\": \"Couldn't link: this customer is already linked to a different StaqOn account. Please contact the business.\"}"
						},
						{
							"name": "429 Too many attempts",
							"originalRequest": {
								"method": "POST",
								"header": [],
								"url": {
									"raw": "https://staqon.one/api/link.php?action=invite_confirm&w=your-workspace-slug"
								}
							},
							"status": "Too Many Requests",
							"code": 429,
							"_postman_previewlanguage": "json",
							"body": "{\"ok\": false, \"error\": \"Too many attempts. Please wait a minute and try again.\"}",
							"header": [
								{
									"key": "Retry-After",
									"value": "60"
								}
							]
						}
					]
				},
				{
					"name": "Pull linked members",
					"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\": 24,\n  \"timestamp\": \"2026-09-02T06:32:44Z\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						},
						"url": {
							"raw": "https://staqon.one/api/link-list.php",
							"protocol": "https",
							"host": [
								"staqon",
								"one"
							],
							"path": [
								"api",
								"link-list.php"
							]
						},
						"description": "`key_id` is your Link API key's numeric ID (shown on the Link admin page once configured). Returns every approved, External-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/link-list.php"
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"body": "{\"ok\": true, \"links\": [{\"crm_email\": \"customer@example.com\", \"link_type\": \"external\", \"status\": \"approved\", \"linked_at\": \"2026-09-02 12:02:30\", \"username\": \"4821k9037\"}]}"
						},
						{
							"name": "401 Invalid signature",
							"originalRequest": {
								"method": "POST",
								"header": [],
								"url": {
									"raw": "https://staqon.one/api/link-list.php"
								}
							},
							"status": "Unauthorized",
							"code": 401,
							"_postman_previewlanguage": "json",
							"body": "{\"ok\": false, \"error\": \"Invalid signature or unknown key.\"}"
						}
					]
				}
			]
		}
	]
}
