{
	"info": {
		"name": "StaqOn Internal Chatbot API",
		"_postman_id": "staqon-internal-chatbot-api-v1",
		"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
		"description": "# StaqOn Internal Chatbot API\n\nStructural mirror of the **StaqOn Chatbot API** collection, aimed at your own staff instead of external partner/customer members. Like that collection, this documents an **inbound** contract from your side: StaqOn calls a webhook URL on *your* server, synchronously, while a real internal staff member waits in their team's own group conversation for the answer to their Internal `/help` tap.\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 — Internal Link, not Link\n\nThe staff member asking a question must already have a real, active `staqon_links` row (workspace × member × `link_type='internal'`, `status='approved'`) — established via the Internal surface's own `/link` flow, gated through `internal_link_keys` (genuinely separate from the External `link_keys` that gates the External Chatbot API). The Internal Chatbot API never creates this link — it only ever reads whether one already exists.\n\n## Getting your credentials\n\nA workspace admin/owner generates a webhook URL + API key + secret from **Workspace → Settings → API → Internal Chatbot API** (an internal team's card, `app/internal-chatbot-teams.php` → `app/internal-chatbot-edit.php`), one set per internal 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, same as External. `query_type` is whatever the admin typed into the Internal /help tree editor's button config, verbatim. A mismatch between what the admin typed and what your CRM code checks for is **silent and permanent** — your webhook still returns 200 with a real `answer`, so it never trips failure tracking, it just answers the wrong thing forever.\n\n**You can now opt in to real, StaqOn-enforced validation.** In the Internal /help tree editor (**Workspace → Settings → API → Internal 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 (see the External Chatbot API's own callout for a written-checklist format as a fallback for keys that haven't registered a list yet).\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 Internal Chatbot secret (`internal_chatbot_keys.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**, confirmed byte-for-byte identical to External Chatbot's own outbound convention by reading `internal_call_chatbot_crm()` directly: always `hash_hmac('sha256', $payload, $secret)`, nothing else.\n\n```php\n$raw = file_get_contents('php://input');\n$expected = hash_hmac('sha256', $raw, $yourInternalChatbotSecret);\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## What the staff 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 your admin directly for now.\"\n\n(`ICB_SETUP_NEEDED_MESSAGE` — same uniform-failure discipline as External, worded for an internal audience.)\n\n## Failure tracking\n\nOne shared counter per team, reset to zero on any single success — **identical thresholds to External**, confirmed by reading `ICB_WARN_THRESHOLD`/`ICB_DISABLE_THRESHOLD` in `includes/internal_chatbot.php`:\n\n- **20 consecutive failures** → the admin who set this up gets a warning email. The chatbot keeps trying.\n- **100 consecutive failures** → permanently disabled for that team. Recovery means the admin generates a brand-new key + secret from the Internal Chatbot API team list.\n\n## Rate limits\n\n**Not currently enforced** — same real gap as External. Make sure your endpoint can handle real, unthrottled volume from your team's actual Internal /help usage.\n\n## Optional: asking the staff member a question first\n\nAn API-based button can be configured (in the Internal /help tree editor) with a **prompt** plus one of the same 7 fixed validation types as External: any text, numeric, numeric exact-length, alphanumeric, alphanumeric exact-length, email, or phone (`icb_validate_free_text()` — byte-for-byte mirror of External's own validator, confirmed by reading the code).\n\nWhen configured, tapping the button sends the staff member the prompt and waits for their next chat message as the answer — StaqOn validates it itself, before ever calling your webhook. Invalid on the 1st try → one retry. Invalid again → StaqOn falls back to the main Internal /help menu; your webhook is never called for that attempt. Valid → your webhook is called normally with the answer in `user_input`.\n\n## ⚠️ Health endpoint — NOT supported for this surface, honestly\n\nUnlike External/Internal/External-Chatbot, `POST /api/webhook-health.php?action=check` does **not** accept `\"surface\":\"internal_chatbot\"` — its real allow-list is exactly `['external', 'internal', 'chatbot']` (confirmed by reading `api/webhook-health.php` directly). There is genuinely no self-service HMAC-authenticated way to poll your Internal Chatbot key's live `failure_count`/`is_active` today. The admin-side team list in the StaqOn UI (**Workspace → Settings → API → Internal Chatbot API**) is the only place that status is currently visible.\n\n## Secret rotation\n\nSame real grace-period pattern as every other surface: rotating (`api/internal-chatbot-keys.php`'s `rotate` action) generates a brand-new `secret` immediately while the previous one stays valid for a real **15-minute** grace window. Note this grace window only matters for *inbound* verification (e.g. a future health-check call) — `internal_call_chatbot_crm()`'s own outbound signing always uses whatever `secret` is currently on file, so no grace period is needed on that side; your webhook never needs to try both secrets.\n\n## Cross-API trace IDs\n\nEvery request carries a fresh **`trace_id`** (UUID v4) both in the JSON body and as the `X-StaqOn-Trace-Id` header — generated fresh for each call by `internal_call_chatbot_crm()`. Log it on your side for cross-referencing during debugging.\n"
	},
	"variable": [
		{
			"key": "crmWebhookUrl",
			"value": "https://your-crm.example.com/staqon-internal-chatbot",
			"type": "string"
		},
		{
			"key": "internalChatbotSecret",
			"value": "cbsec_xxx",
			"type": "string"
		}
	],
	"item": [
		{
			"name": "Internal 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": "{{internalChatbotSignature}}",
								"type": "text",
								"description": "Hex-encoded HMAC-SHA256 of the raw request body, keyed by your Internal Chatbot secret. Compute this yourself in a pre-request script if testing against a real signature check."
							},
							{
								"key": "X-StaqOn-Trace-Id",
								"value": "b3f1c2a4-7e5d-4a91-9c2e-1f6a8d0b3c5e",
								"type": "text"
							}
						],
						"url": {
							"raw": "{{crmWebhookUrl}}",
							"host": [
								"{{crmWebhookUrl}}"
							]
						},
						"body": {
							"mode": "raw",
							"raw": "{\n  \"query_type\": \"leave_balance\",\n  \"username\": \"asha29104\",\n  \"external_ref\": \"CRM100\",\n  \"team_id\": 58,\n  \"workspace_id\": 1,\n  \"user_input\": null,\n  \"trace_id\": \"b3f1c2a4-7e5d-4a91-9c2e-1f6a8d0b3c5e\",\n  \"timestamp\": \"2026-09-05T09:20:11+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 Internal /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 staff member's real StaqOn username, stable and unique.\n- `external_ref` (string | **null**) — the linked staff member's `staqon_links.external_ref` (their CRM ID, captured at Internal `/link` time). Unlike External (always `null` today), Internal's `icb_resolve_api_answer()` actually looks up and forwards the real value on file.\n- `team_id` (integer) — StaqOn's internal id for the internal team this request belongs to.\n- `workspace_id` (integer) — StaqOn's internal id for the workspace this request belongs to — read directly off `internal_chatbot_keys.workspace_id`.\n- `user_input` (string | **null**) — the staff member's validated free-text answer, only present when the tapped button had a prompt configured. **Null** if no prompt was configured for this button.\n- `trace_id` (string) — a fresh UUID v4 for this call, also sent as the `X-StaqOn-Trace-Id` header.\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": "{{internalChatbotSignature}}"
									}
								],
								"url": {
									"raw": "{{crmWebhookUrl}}",
									"host": [
										"{{crmWebhookUrl}}"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"query_type\": \"leave_balance\",\n  \"username\": \"asha29104\",\n  \"external_ref\": \"CRM100\",\n  \"team_id\": 58,\n  \"workspace_id\": 1,\n  \"user_input\": null,\n  \"trace_id\": \"b3f1c2a4-7e5d-4a91-9c2e-1f6a8d0b3c5e\",\n  \"timestamp\": \"2026-09-05T09:20:11+00:00\"\n}"
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"body": "{\n  \"ok\": true,\n  \"answer\": \"You have 6 days of paid leave remaining this year.\"\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": "{{internalChatbotSignature}}"
									}
								],
								"url": {
									"raw": "{{crmWebhookUrl}}",
									"host": [
										"{{crmWebhookUrl}}"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"query_type\": \"payslip_month\",\n  \"username\": \"asha29104\",\n  \"external_ref\": \"CRM100\",\n  \"team_id\": 58,\n  \"workspace_id\": 1,\n  \"user_input\": \"08-2026\",\n  \"trace_id\": \"9a12f0e3-1234-4a91-9c2e-1f6a8d0b3c5e\",\n  \"timestamp\": \"2026-09-05T09:25:44+00:00\"\n}"
								}
							},
							"status": "OK",
							"code": 200,
							"_postman_previewlanguage": "json",
							"header": [
								{
									"key": "Content-Type",
									"value": "application/json"
								}
							],
							"body": "{\n  \"ok\": true,\n  \"answer\": \"Your August 2026 payslip has been emailed to you.\"\n}"
						},
						{
							"name": "Any failure — StaqOn's uniform staff-facing fallback",
							"originalRequest": {
								"method": "POST",
								"header": [
									{
										"key": "Content-Type",
										"value": "application/json"
									},
									{
										"key": "X-StaqOn-Signature",
										"value": "{{internalChatbotSignature}}"
									}
								],
								"url": {
									"raw": "{{crmWebhookUrl}}",
									"host": [
										"{{crmWebhookUrl}}"
									]
								},
								"body": {
									"mode": "raw",
									"raw": "{\n  \"query_type\": \"leave_balance\",\n  \"username\": \"asha29104\",\n  \"external_ref\": \"CRM100\",\n  \"team_id\": 58,\n  \"workspace_id\": 1,\n  \"user_input\": null,\n  \"trace_id\": \"b3f1c2a4-7e5d-4a91-9c2e-1f6a8d0b3c5e\",\n  \"timestamp\": \"2026-09-05T09:20:11+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 staff-facing text: \"This needs a bit of setup on our end — please contact your admin directly for now.\" StaqOn never surfaces your actual error to the end user — check your own server logs.)"
						}
					]
				}
			]
		}
	]
}
