{
	"info": {
		"name": "StaqOn Chatbot API",
		"_postman_id": "staqon-chatbot-api-v1",
		"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
		"description": "# StaqOn Chatbot API\n\nUnlike the External and Internal API collections, this one documents an **inbound** contract from your side: StaqOn calls a webhook URL on *your* server, synchronously, while a real external member waits in their `/help` chat menu for the answer.\n\nThis collection's single request is a **simulator** — point `crmWebhookUrl` at your own local/dev endpoint and send it yourself to test your handler against the exact real request shape StaqOn sends, headers and signature included.\n\n## Prerequisite\n\nThe external member asking a question must already have a real, active `staqon_links` row (workspace × member × `link_type='external'`, `status='approved'`) — StaqOn's real, current identity-linking table, established via the `/link` command in chat. The Chatbot API never creates this link — it only ever reads whether one already exists.\n\n## Getting your credentials\n\nAn admin generates a webhook URL + API key + secret from inside StaqOn (**Workspace settings → API → Chatbot API**), one set per external team. The secret is shown once, at generation time, and is never re-displayed — store it immediately.\n\n## ⚠️ `query_type` validation — opt in for real, or fall back to a written checklist\n\n**By default, `query_type` is free text with zero validation** — exactly as before. `query_type` is whatever the admin typed into the /help tree editor's button config, verbatim. A typo between what the admin typed and what your CRM code checks for causes a **silent, permanent failure with no error surfaced anywhere** — your webhook still returns 200 with a real `answer`, so it never trips failure tracking, it just answers the wrong (or a default/fallback) thing forever.\n\n**You can now opt in to real, StaqOn-enforced validation.** In the /help tree editor (**Workspace → Settings → API → Chatbot API** → a team's edit page), the **\"Registered query types\"** section lets an admin register a list of valid `query_type` values for that key — editable anytime, no key rotation needed. Once a list is registered, StaqOn itself rejects any node saved with a `query_type` not on that list, with this real, visible error at save time:\n\n```\n\\\"<value>\\\" is not a registered query type for this CRM connection. Add it in \\\"Registered query types\\\" first, or use an existing one.\n```\n\nA key with **no registered list** (the default — every key created before this feature existed, and any new key until an admin opts in) behaves exactly as documented above: any `query_type` value is accepted, no validation happens.\n\n**Recommended:** register your list in the admin UI so StaqOn catches a mismatch the moment the tree is edited, rather than relying solely on an out-of-band agreement. A written checklist remains a useful fallback for a key that hasn't registered a list yet, or as a shared reference between whoever builds the tree and whoever writes the CRM handler:\n\n| `query_type` value | Button name in the /help tree | Handled in CRM? |\n|---|---|---|\n| `loan_status` | \\\"Check my loan status\\\" | ✅ |\n| `office_hours` | \\\"Office hours\\\" | ✅ |\n| `payment_due` | \\\"When is my payment due?\\\" | ⬜ |\n\nRe-check this table any time either side adds, renames, or retypes a button.\n\n## The request StaqOn sends you\n\n**Method:** `POST` · **Content-Type:** `application/json` · **Timeout:** StaqOn waits **3 seconds**. Respond fast, or fail fast.\n\nEvery request carries `X-StaqOn-Signature`: a hex-encoded HMAC-SHA256 of the **raw JSON body**, keyed by your secret. Compute the same HMAC and compare with `hash_equals()` before trusting the request.\n\n**This is bare hex only — no `t=` timestamp prefix, unlike the health endpoint's inbound verification.** Always `hash_hmac('sha256', $raw_body, $your_chatbot_secret)`, nothing else — this endpoint never sends the `t=<unix>,v1=<hex>` timestamped format documented elsewhere in this collection.\n\n```php\n$raw = file_get_contents('php://input');\n$expected = hash_hmac('sha256', $raw, $yourChatbotSecret);\nif (!hash_equals($expected, $_SERVER['HTTP_X_STAQON_SIGNATURE'] ?? '')) {\n    http_response_code(401);\n    exit;\n}\n```\n\n## The response StaqOn expects\n\n`{\"ok\": true, \"answer\": \"...\"}` — HTTP 200, valid JSON, `ok` truthy, `answer` a non-empty string. Anything else (non-200, malformed JSON, missing/falsy `ok`, timeout, unreachable) counts as one failure. **There is no retry** — exactly one attempt per tap.\n\n## Multi-line answers\n\n`answer` fully supports multi-line text — a genuine `\\n` (newline) character inside your JSON string renders as a real line break in the member's chat, on both web and app. No special formatting or markup needed; plain text with real newlines is enough.\n\n**The one thing to get right: use your JSON library's own string encoding, never hand-build the JSON string yourself.** A real newline byte, when your JSON encoder serializes it, becomes the two-character escape sequence `\\n` *on the wire* (backslash followed by the letter n) — that's correct, standard JSON, and StaqOn's `json_decode()` turns it back into a real newline on our end automatically. The bug we've seen integrators hit is different: manually concatenating a literal backslash and the letter \"n\" into the string content itself (e.g. building the JSON by hand, or double-escaping an already-escaped string) sends that literal two-character sequence as part of the *text*, not as an escape — StaqOn has no way to tell that apart from two real characters the member is meant to read, so it renders as visible `\\n` in the chat instead of a line break.\n\n**Correct** — encoder-produced newline, renders as a real line break:\n```json\n{\"ok\": true, \"answer\": \"Your loan application #LN-4521 is under review.\\n\\nExpected decision: 3-5 business days.\"}\n```\n\n**Broken** — a literal backslash + \"n\" typed into the string, renders as visible `\\n` text in the chat instead of a line break:\n```json\n{\"ok\": true, \"answer\": \"Your loan application #LN-4521 is under review.\\\\n\\\\nExpected decision: 3-5 business days.\"}\n```\n\nIf your integration is showing literal `\\n` characters in a member's chat instead of line breaks, this is almost always the cause — confirm your JSON encoding library (not string concatenation) is producing the `answer` value.\n\n## What the external member sees on any failure\n\nEvery failure, whatever the cause, shows the identical message on their end — StaqOn never reveals why it failed:\n\n> \"This needs a bit of setup on our end — please contact the team directly for now.\"\n\n## Failure tracking\n\nOne shared counter per team, reset to zero on any single success:\n\n- **20 consecutive failures** → the admin who set this up gets a warning email. The chatbot keeps trying — nothing else changes.\n- **100 consecutive failures** → the chatbot is **permanently disabled** for that team. There is no re-enable action — recovery means the admin generates a **brand-new key and secret** from the teams list, so your stored secret will need replacing, not just re-enabling.\n\n## Rate limits\n\n**Not currently enforced.** A rate-limit field exists in the configuration (defaulting to 100/minute) but nothing in the current outbound-call code reads or applies it — StaqOn will call your endpoint as often as real `/help` traffic generates taps, with no ceiling today. Make sure your endpoint can handle real, unthrottled volume.\n\n## 9. Optional: asking the member a question first\n\nAn API-based button can be configured (in the /help tree editor) with a **prompt** plus one of 7 fixed validation types: any text, numeric, numeric exact-length, alphanumeric, alphanumeric exact-length, email, or phone.\n\nWhen configured, tapping the button doesn't call your webhook right away — StaqOn first sends the member the prompt text and waits for their next chat message as the answer. StaqOn validates that answer **itself, before ever calling your webhook**:\n\n- Invalid answer (1st attempt) → the member sees a validation error and gets one retry.\n- Invalid answer (2nd attempt) → StaqOn gives up and falls back to showing the main /help menu again. Your webhook is never called for this attempt.\n- Valid answer → your webhook is called normally, with the validated answer included as the new `user_input` field in the request body (see below).\n\nIf the button has no prompt configured, this step is skipped entirely and `user_input` is always `null`.\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\": \"chatbot\", \"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 (Chatbot):**\n```json\n{\"ok\":true,\"surface\":\"chatbot\",\"is_active\":true,\"failure_count\":0,\"warned_at\":null,\"last_used_at\":\"2026-08-30 14:34:30\",\"revoked_at\":null,\"authenticated_with\":\"current\",\"grace_period_active\":false}\n```\n\n**Honest limitation:** Chatbot's health check is authenticated with `chatbot_keys.secret` — the same secret already used to verify `chatbot_call_crm()`'s outbound signature — so every active chatbot key can use this endpoint with no additional setup.\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"
	},
	"variable": [
		{
			"key": "crmWebhookUrl",
			"value": "https://your-crm.example.com/staqon-chatbot",
			"type": "string"
		},
		{
			"key": "chatbotSecret",
			"value": "cbsec_xxx",
			"type": "string"
		}
	],
	"item": [
		{
			"name": "External Chatbot query",
			"item": [
				{
					"name": "Simulate StaqOn's request to your webhook",
					"request": {
						"method": "POST",
						"header": [
							{
								"key": "Content-Type",
								"value": "application/json",
								"type": "text"
							},
							{
								"key": "X-StaqOn-Signature",
								"value": "{{chatbotSignature}}",
								"type": "text",
								"description": "Hex-encoded HMAC-SHA256 of the raw request body, keyed by your chatbot secret. Compute this yourself in a pre-request script if testing against a real signature check."
							}
						],
						"url": {
							"raw": "{{crmWebhookUrl}}",
							"host": [
								"{{crmWebhookUrl}}"
							]
						},
						"body": {
							"mode": "raw",
							"raw": "{\n  \"query_type\": \"loan_status\",\n  \"username\": \"4821k9037\",\n  \"external_ref\": null,\n  \"team_id\": 117,\n  \"workspace_id\": 1,\n  \"user_input\": null,\n  \"trace_id\": \"c4d8e2a1-6f3b-4a91-9c2e-1f6a8d0b3c5e\",\n  \"timestamp\": \"2026-08-30T11:15:42+00:00\"\n}",
							"options": {
								"raw": {
									"language": "json"
								}
							}
						},
						"description": "This is the exact real shape StaqOn sends to your `webhook_url`, one attempt per tap, no retry.\n\n**Body fields:**\n- `query_type` (string) — the tag the admin gave this button when building the /help tree. Route on this field. **Free text by default** — an admin can now opt in to real, StaqOn-enforced validation by registering a list of valid values on the key; see the warning above for the full picture.\n- `username` (string) — the external member's real StaqOn username, stable and unique.\n- `external_ref` (string | **null**) — always `null` for links established via `/link` — the real, built self-service flow never sets a value here; `staqon_links.external_ref` is reserved for a possible future direct-API link path. Treat it as always null today.\n- `team_id` (integer) — StaqOn's internal id for the external team this request belongs to.\n- `workspace_id` (integer) — StaqOn's internal id for the workspace this request belongs to (added 2026-09-03) — useful for a CRM managing multiple StaqOn workspaces, since `team_id` alone doesn't identify which workspace it lives in without a separate lookup.\n- `user_input` (string | **null**) — the member's validated free-text answer, only present when the tapped button had a prompt configured (see Section 9 above). **Null** if no prompt was configured for this button.\n- `timestamp` (string, ISO 8601) — informational only, not currently used for replay protection.\n\n**Verify the signature before trusting the request:**\n```php\n$expected = hash_hmac('sha256', $rawRequestBody, $yourSecret);\nif (!hash_equals($expected, $_SERVER['HTTP_X_STAQON_SIGNATURE'] ?? '')) {\n    http_response_code(401);\n    exit;\n}\n```"
					},
					"response": [
						{
							"name": "200 OK — real answer",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "Content-Type",
										"value": "application/json"
									},
									{
										"key": "X-StaqOn-Signature",
										"value": "{{chatbotSignature}}"
									}
								],
								"url": {
									"raw": "{{crmWebhookUrl}}",
									"host": [
										"{{crmWebhookUrl}}"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"query_type\": \"loan_status\",\n  \"username\": \"4821k9037\",\n  \"external_ref\": \"crm-1001\",\n  \"team_id\": 117,\n  \"workspace_id\": 1,\n  \"user_input\": null,\n  \"trace_id\": \"c4d8e2a1-6f3b-4a91-9c2e-1f6a8d0b3c5e\",\n  \"timestamp\": \"2026-08-30T11:15:42+00:00\"\n}"
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"body": "{\n  \"ok\": true,\n  \"answer\": \"Your loan application #LN-4521 is under review.\"\n}"
						},
						{
							"name": "200 OK — external_ref null case",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "Content-Type",
										"value": "application/json"
									},
									{
										"key": "X-StaqOn-Signature",
										"value": "{{chatbotSignature}}"
									}
								],
								"url": {
									"raw": "{{crmWebhookUrl}}",
									"host": [
										"{{crmWebhookUrl}}"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"query_type\": \"office_hours\",\n  \"username\": \"5764p6295\",\n  \"external_ref\": null,\n  \"team_id\": 3,\n  \"workspace_id\": 2,\n  \"user_input\": null,\n  \"trace_id\": \"7b2e5f9a-3c1d-4a91-9c2e-1f6a8d0b3c5e\",\n  \"timestamp\": \"2026-08-30T11:31:34+00:00\"\n}"
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"body": "{\n  \"ok\": true,\n  \"answer\": \"Monday to Saturday, 10 AM to 6 PM.\"\n}"
						},
						{
							"name": "200 OK — free-text answer captured (user_input)",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "Content-Type",
										"value": "application/json"
									},
									{
										"key": "X-StaqOn-Signature",
										"value": "{{chatbotSignature}}"
									}
								],
								"url": {
									"raw": "{{crmWebhookUrl}}",
									"host": [
										"{{crmWebhookUrl}}"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"query_type\": \"pan_verification\",\n  \"username\": \"4821k9037\",\n  \"external_ref\": \"crm-1001\",\n  \"team_id\": 117,\n  \"workspace_id\": 1,\n  \"user_input\": \"ABCDE1234F\",\n  \"trace_id\": \"e91a4c6d-2b8f-4a91-9c2e-1f6a8d0b3c5e\",\n  \"timestamp\": \"2026-08-30T11:40:07+00:00\"\n}"
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"body": "{\n  \"ok\": true,\n  \"answer\": \"PAN ABCDE1234F is verified and on file.\"\n}"
						},
						{
							"name": "Any failure — StaqOn's uniform member-facing fallback",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "Content-Type",
										"value": "application/json"
									},
									{
										"key": "X-StaqOn-Signature",
										"value": "{{chatbotSignature}}"
									}
								],
								"url": {
									"raw": "{{crmWebhookUrl}}",
									"host": [
										"{{crmWebhookUrl}}"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"query_type\": \"loan_status\",\n  \"username\": \"4821k9037\",\n  \"external_ref\": \"crm-1001\",\n  \"team_id\": 117,\n  \"workspace_id\": 1,\n  \"user_input\": null,\n  \"trace_id\": \"c4d8e2a1-6f3b-4a91-9c2e-1f6a8d0b3c5e\",\n  \"timestamp\": \"2026-08-30T11:15:42+00:00\"\n}"
								}
							},
							"status": "Internal Server Error",
							"code": 500,
							"_postman_previewlanguage": "text",
							"header": [],
							"body": "(any non-2xx status, malformed JSON, missing/falsy ok, or no response within 3s all collapse to the SAME real member-facing text: \"This needs a bit of setup on our end — please contact the team directly for now.\" StaqOn never surfaces your actual error to the end user — check your own server logs.)"
						}
					]
				}
			]
		}
	]
}
