StaqOn API
Six real, distinct concepts share this page — see Which surface? for the full mental model. In short: the External API connects your CRM to partner-team threads — push messages in, read replies with consent, raise escalations. The Internal API (part of the Messaging API) notifies your own staff. The External Chatbot API answers real-time /help queries from your CRM for external members; the Internal Chatbot API does the same for your own staff's Internal /help. Link and Internal Link are separate, workspace-wide identity-verification mechanisms, not messaging surfaces. A Messaging/Chatbot key belongs to exactly one surface and one team; Link/Internal Link keys are one per workspace.
Authentication
Every request must send your API key in a header:
Base URL
Each surface has its own base. Endpoints below are relative to [base_url] for their surface:
curl to Invoke-WebRequest, which does not accept these flags. Use curl.exe explicitly and double quotes around the JSON body (escaping inner quotes as \"), or run the samples in WSL / Git Bash.Which surface?
There are six real, distinct concepts on this page, not two. Four are API surfaces your CRM calls (or is called by); two are identity-verification mechanisms, separate from messaging entirely. Mixing these up — especially the two pairs that share a word ("Internal", "Chatbot") — is the single most common source of confusion integrating against StaqOn.
| Group | Concept | What it's for |
|---|---|---|
| Messaging push/pull real conversation content |
External API | CRM ↔ partner/customer members, in a real chat thread |
| Internal API (part of the "Messaging API") | CRM → your own staff, one-way notifications only | |
| Automated answers | External Chatbot API | StaqOn calls your CRM, synchronously, to answer an external member's real-time /help query |
| Internal Chatbot API | StaqOn calls your CRM, synchronously, to answer your own staff's real-time Internal /help query | |
| Identity / consent no message content ever moves here |
Link API | Verifies an External member's identity before their staqon_links row is approved |
| Internal Link API | Verifies a staff member's identity before their staqon_links row is approved |
message.php tells the two resulting situations apart with two distinct, real responses — a 404 "Internal Link is not set up for this workspace" when the identity-verification capability doesn't exist at all for this workspace, versus the original 403 "Member has not approved API messages" when it's configured but this specific person just hasn't linked yet./help tap — sharing the same HMAC scheme (bare-hex X-StaqOn-Signature, no t= prefix), the same 3s/2s timeout budget, and the same 20/100 warn/disable failure thresholds. They differ only in who is asking and which real tables/gates are involved: the External Chatbot API answers a partner/customer guest inside an external-thread conversation, gated on a link_type='external' row in staqon_links; the Internal Chatbot API answers a genuine member of an internal team inside that team's own group conversation (icb_internal_team_conv_team_id()), gated on a link_type='internal' row instead. One real, deliberate behavioral difference: when a member types /help before linking, External's Stage 2 fires a real popup (bot-input-request, a one-shot form) because External's /link is itself a one-shot form — but Internal's Stage 2 instead re-triggers Internal's own genuinely chat-based two-message /link flow (CRM ID, then email, via link_flow_state), because that is what Internal's own /link actually looks like. Mirroring External's popup for Internal would have been visually consistent but functionally wrong for this surface.A key is bound to the surface of the team it was created against. This is the thing people get wrong first.
| External API | Internal API | |
|---|---|---|
| Who it talks to | Partner / customer members of an external team | Your own staff in an internal team |
| Direction | Two-way — write in, read replies back | One-way only — no reply, no read-back |
| Writing in | Key alone is enough (your workspace's authority) | Requires an approved link — staff can refuse |
| Reading back | /messages.php, needs an approved link | Not available — there is no internal messages.php |
| Webhook events | Not per-surface any more — one webhook per workspace, subscribed via the Link API, firing member.*/escalation.*. See Webhooks. | |
| Where it lands | The member's normal chat thread | A separate API Messages feed |
400 "Linked team is not an active external team"; an external key against an internal endpoint returns 400 "Linked team is not an active internal team". The surface is fixed when the key is created and cannot be changed — create a second key against a team of the other type instead.Everything is key-only except one endpoint per surface. Those two return 403
until the member's staqon_links row is approved, and again the moment it's revoked.
| Surface | Key-only (no link needed) | Needs an approved link |
|---|---|---|
| External | status · members · member · add-member · remove-member · message · comment · escalation |
messages (read the thread) → 403 "No approved link for this member" |
| Internal | status |
message (send a notification) → 403 "Member has not approved API messages" |
/link identity-linking system (2026-09-01)Consent is governed by staqon_links — one row per (workspace, member, surface) — StaqOn's
replacement for the old, removed per-team account-linking flow this section used to describe. The old
/link-request.php//link-status.php//link-revoke.php endpoints and the
Account settings → Linking Requests tab no longer exist.
/link — the real,
current member-facing mechanism (a member types /link in chat, or clicks the workspace's "Link this
workspace" button) — is genuinely built and shipped: External members provide their email + a required second
question; internal staff provide a CRM ID + email. Either way, StaqOn signs and verifies the submitted details
against your workspace's Link webhook, and the row only reaches approved on a real
{"ok":true} response — see the Link API and
Internal Link API sections for the full, real contract of each.approved, the member can revoke it anytime from Account settings → Linked Accounts, cutting off
messages/message delivery immediately (403).Both are outbound calls StaqOn makes to you (your Link/Internal Link webhook), bare-hex HMAC-signed, at the moment a member submits their /link details — not two views of the same payload, but two genuinely different shapes:
| Field | External (link_call_crm()) | Internal (internal_call_crm()) |
|---|---|---|
email | ✅ required | ✅ required |
question / answer | ✅ this workspace's real second-question text + the member's answer | — not present |
crm_id | — not present | ✅ required (the CRM ID the staff member typed in) |
team_context | ✅ always null today (reserved) | — not present |
username | ✅ added 2026-09-03 — StaqOn's real username | ✅ added 2026-09-03 — StaqOn's real username |
See the Link API and Internal Link API sections above for each payload's full, literal request example.
link-list.php / internal-link-list.phplink-list.php and internal-link-list.php have no rate limit and no 429 response at all — every call runs its query directly against staqon_links, live, uncached, with no server-side throttling. (The External Chatbot API's rate_limit_per_minute config isn't enforced either today — see the External Chatbot API section — so this is genuinely a two-way gap, not something unique to these endpoints.) Call them as often as you genuinely need to, but don't poll tightly in a loop expecting StaqOn to push back — nothing will, so a runaway integration bug on your side degrades quietly rather than failing loudly with a 429 you'd notice.| # | External | Internal |
|---|---|---|
| 1 | Create a key in Workspace → Settings → API against a team of the matching type. | |
| 2 | GET /status.php — confirm the key works and see the team it is bound to. | |
| 3 | POST /add-member.php — create the partner member (skip if they already exist). |
n/a — staff are already members of the internal team. |
| 4 | POST /message.php or /comment.php — write in. Works now, no link needed. |
n/a — nothing can be sent before consent. |
| 5 | Member's staqon_links row reaches approved — they self-serve via /link with email + second question, and your webhook confirms it (see "Consent" above). |
Member's staqon_links row reaches approved — they self-serve via /link with CRM ID + email, and your webhook confirms it (see "Consent" above). |
| 6 | GET /messages.php?after=<last_id> — poll for replies. 403 before approval. |
POST /message.php — deliver notifications. 403 before approval. |
| 7 | Subscribe to member.* / escalation.* webhooks (see Webhooks) to track activity — one webhook per workspace now, not per team. | |
| 8 | Member revokes anytime from Account settings → Linked Accounts — the only real revoke path today. | |
Rate limiting
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 both surfaces — there is no per-endpoint override, so it is documented once here rather than repeated on each endpoint below.
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.
status.php's api_key_usage field is a lifetime counter, not the current window — use rate_remaining for the live per-minute window figure. There is no equivalent live field for the daily window today.External API
Partner teams. Two-way. Writes need only the key. Base: https://staqon.one/api/v1/external
Members
Key context + usage
curl -X GET \ 'https://staqon.one/api/v1/external/status.php' \ -H 'X-API-Key: sk_live_xxx'
{"ok":true,"workspace":"…","team":"…","team_type":"external","total_members":42,"active_members":40,"api_key_usage":120,"rate_limit":"100/minute","rate_remaining":99}{"ok":false,"error":"key's team is not an active external team"}List members
| Field | Type | Description |
|---|---|---|
| page | integer | Page number. |
| limit | integer | Page size. |
| search | string | Filter by name/email. |
curl -X GET \ 'https://staqon.one/api/v1/external/members.php' \ -H 'X-API-Key: sk_live_xxx'
{"ok":true,"members":[{"id":1,"name":"Rahul","email":"rahul@abc.com","username":"4821k9037","status":"active","joined_at":"…","last_message_at":"…","messages_sent":12}],"total":42,"page":1,"pages":1}username is the stable, unique handle that identifies this member across StaqOn \u2014 used to read messages, and by the Internal API's message endpoint. Until now nothing returned one; these member payloads (and add-member on both its created and status:"existing" paths) are the source.add-member returns username immediately on create \u2014 no second call to resolve the handle.Get member
| Field | Type | Description |
|---|---|---|
| string | Member email. *Provide email OR member_id. | |
| member_id | integer | Numeric member id — takes precedence over email if both are sent. |
curl -X GET \ 'https://staqon.one/api/v1/external/member.php' \ -H 'X-API-Key: sk_live_xxx'
{"ok":true,"member":{"id":1,"name":"Rahul","email":"rahul@abc.com","username":"4821k9037","joined_at":"…","last_active":"…","messages_sent":12,"has_mpin":true}}{"ok":false,"error":"Member not found in this team"}Add member
| Field | Type | Description |
|---|---|---|
| namereq | string | Member display name. |
| emailreq | string | Member email. |
| phone | string | Ignored — accepted but not stored or returned. |
| metadata | object | Ignored — accepted but not stored or returned. |
curl -X POST \ 'https://staqon.one/api/v1/external/add-member.php' \ -H 'X-API-Key: sk_live_xxx' \ -H 'Content-Type: application/json' \ -d '{ "name": "Rahul Sharma", "email": "rahul@abc.com" }'
{"ok":true,"member_id":1,"username":"4821k9037","conversation_id":45,"status":"created","login_url":"…"}{"ok":false,"error":"name and email are required"}Remove member
| Field | Type | Description |
|---|---|---|
| emailreq | string | Member email. |
curl -X POST \ 'https://staqon.one/api/v1/external/remove-member.php' \ -H 'X-API-Key: sk_live_xxx' \ -H 'Content-Type: application/json' \ -d '{ "email": "rahul@abc.com" }'
{"ok":true,"removed":true,"member_id":1}{"ok":false,"error":"Member is not in this external team"}Messaging
Send message
| Field | Type | Description |
|---|---|---|
| emailreq | string | Member email. |
| messagereq | string | Message text — max 60000 chars. |
curl -X POST \ 'https://staqon.one/api/v1/external/message.php' \ -H 'X-API-Key: sk_live_xxx' \ -H 'Content-Type: application/json' \ -d '{ "email": "rahul@abc.com", "message": "Your loan is approved!" }'
{"ok":true,"message_id":123,"conversation_id":45}{"ok":false,"error":"message is too long (max 60000 chars)"}message is capped at 60000 characters. Over-limit requests are rejected with 400 'message is too long (max 60000 chars)' — never truncated.Post comment
source=api + your external_ref (Direction In).| Field | Type | Description |
|---|---|---|
| usernamereq | string | Member username (must be in your team). |
| commentreq | string | Comment text — max 60000 chars. |
| external_ref | string | Your id for this post (echo-dedup on read-back). Silently truncated to 190 characters, not rejected. |
curl -X POST \ 'https://staqon.one/api/v1/external/comment.php' \ -H 'X-API-Key: sk_live_xxx' \ -H 'Content-Type: application/json' \ -d '{ "username": "4821k9037", "comment": "Your documents are verified.", "external_ref": "crm-msg-1001" }'
{"ok":true,"message_id":123,"conversation_id":45,"sent_at":"2026-01-01 12:00:00"}{"ok":false,"error":"comment is too long (max 60000 chars)"}comment is capped at 60000 characters → 400 'comment is too long (max 60000 chars)'.Read conversation
| Field | Type | Description |
|---|---|---|
| usernamereq | string | Member username. |
| limit | integer | Page size. |
| before | integer | Older page cursor. |
| after | integer | Newer page cursor. |
curl -X GET \ 'https://staqon.one/api/v1/external/messages.php' \ -H 'X-API-Key: sk_live_xxx'
{"ok":true,"conversation_id":45,"member":{"username":"4821k9037","name":"Rahul"},"messages":[{"id":1,"sender_side":"member","sender_name":"Rahul","content":"…","content_type":"text","source":"app","external_ref":null,"sent_at":"…","edited":false,"deleted":false,"attachments":[]}],"paging":{"mode":"before","limit":50,"returned":1,"has_more":false,"next_before":null}}{"ok":false,"error":"No approved link for this member"}403 'No approved link for this member' until the member has a real, active staqon_links row (workspace × member × link_type='external', status='approved'), established via StaqOn's /link command — and again the instant it's revoked (kill-switch). Use before or after, not both./link is the real, live, self-service mechanism: the member enters their email + this workspace's configured second question (chat command or popup), StaqOn HMAC-signs and verifies both against your Link webhook, and the row only reaches approved on a real {"ok":true} from you — see the Link API section for the full contract.Escalations
Raise escalation
| Field | Type | Description |
|---|---|---|
| emailreq | string | Member email. |
| titlereq | string | Title (≥2 chars). |
| descriptionreq | string | Case description. |
| due_datereq | string | YYYY-MM-DD, at least tomorrow. |
| priority | string | low | medium | high | critical (default medium). |
curl -X POST \ 'https://staqon.one/api/v1/external/escalation.php' \ -H 'X-API-Key: sk_live_xxx' \ -H 'Content-Type: application/json' \ -d '{ "email": "rahul@abc.com", "title": "Missing documents", "description": "KYC docs not uploaded", "priority": "high", "due_date": "2026-12-31" }'
{"ok":true,"matter_id":15}{"ok":false,"error":"due_date must be a future date (YYYY-MM-DD, at least tomorrow)"}List / read escalations
| Field | Type | Description |
|---|---|---|
| page | integer | Page number. |
| limit | integer | Page size (max 200). |
| status | string | Filter: open / in_progress / resolved. |
| matter_id | integer | Single matter by numeric id (from create response / escalation.created webhook). |
curl -X GET \ 'https://staqon.one/api/v1/external/escalations.php' \ -H 'X-API-Key: sk_live_xxx'
{"ok":true,"matters":[{"uuid":"1d1ea5f6-…","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":"…","created_at":"…","target_username":"4821k9037"}],"total":1,"page":1,"pages":1}{"ok":false,"error":"Matter not found"}resolution_notes, created_by/resolved_by (staff identities), workspace_id, sequential id. Target member is target_username.?matter_id=<id> returns one; else a paginated list ordered by priority then recency.Internal API
Your own staff. One-way. Writes need an approved link. Base: https://staqon.one/api/v1/internal
Context
Key context + usage
curl -X GET \ 'https://staqon.one/api/v1/internal/status.php' \ -H 'X-API-Key: sk_live_xxx'
{"ok":true,"workspace":"Acme","team":"RM Team","team_type":"internal","total_members":8,"active_members":8,"api_key_usage":12,"rate_limit":"100/minute","rate_remaining":99}{"ok":false,"error":"Linked team is not an active internal team"}List members
| Field | Type | Description |
|---|---|---|
| page | integer | Page number. |
| limit | integer | Page size. |
| search | string | Filter by name, email or username. |
curl -X GET \ 'https://staqon.one/api/v1/internal/members.php' \ -H 'X-API-Key: sk_live_xxx'
{"ok":true,"members":[{"id":1,"name":"Asha","email":"asha@acme.com","username":"asha29104","role":"member","status":"active","joined_at":"\u2026"}],"total":8,"page":1,"pages":1}{"ok":false,"error":"Linked team is not an active internal team"}id, name, email, username, role, status, joined_at per member. username is the handle message requires.member/admin/lead, so the external guest-only filter would return zero; this lists them all. (2) No last_message_at / messages_sent \u2014 those count external-thread messages and internal teams have no external thread. role is included instead (external is all-guest, so it carries none).400.Notifications
Send a notification
| Field | Type | Description |
|---|---|---|
| usernamereq | string | Staff member username — must be in this key's internal team. |
| messagereq | string | Body text — max 60000 chars. Rejected, never truncated. |
| title | string | Optional heading — max 200 chars. |
| external_ref | string | Your own id. Makes the call idempotent. Silently truncated to 190 characters, not rejected. |
| link_url | string | Optional http(s) URL shown with the notification. |
curl -X POST \ 'https://staqon.one/api/v1/internal/message.php' \ -H 'X-API-Key: sk_live_xxx' \ -H 'Content-Type: application/json' \ -d '{ "username": "4821k9037", "title": "Payout processed", "message": "Your September payout has been released.", "external_ref": "crm-9001" }'
{"ok":true,"id":123,"delivered_at":"2026-01-01 12:00:00"}{"ok":false,"error":"Member has not approved API messages"}staqon_links row (workspace × member × link_type='internal', status='approved'), established via StaqOn's /link command, or you get 403 'Member has not approved API messages'. Revoking it stops delivery immediately./link is the real, live, self-service mechanism: the member provides a CRM ID + email (chat command or popup), StaqOn HMAC-signs and verifies both against your Internal Link webhook, and the row only reaches approved on a real {"ok":true} from you — see the Internal Link API section for the full contract.external_ref returns 409 carrying the original id and delivered_at rather than posting twice — safe to retry after a timeout.username globally first (SELECT id FROM users WHERE username=? — case-sensitive, unique, no team scoping at this step), then separately checks whether that user is a member of your key's specific team. Example: you call this endpoint with {"username":"4821k9037",...}. If no such username exists anywhere on StaqOn, you get 404 'No member with that username' immediately. If it exists but belongs to someone outside your key's team, you get 404 'That member is not in your team' — deliberately the same 404 shape as a nonexistent username, so this endpoint can never be used to fingerprint which real usernames exist on StaqOn outside your own team.Card format
Both comment.php (External) and message.php (Internal) accept an optional card object alongside the plain-text fallback (comment / message), rendering a structured card instead of plain text. The fallback text is still required and is used for push notifications and older clients.
Header_Color or a lowercase #1f7a54 will not match and are rejected as invalid — there is no case-normalization anywhere in validate_card().| Hex (exact case) | Name |
|---|---|
| #1F7A54 | brand green (default) |
| #059669 | success green |
| #D97706 | warning amber |
| #DC2626 | danger red |
| #2563EB | info blue |
| #4B5563 | neutral grey |
| #7C3AED | security |
| #0D9488 | account |
| #DB2777 | reassign |
| #0891B2 | update |
| Field | Required | Notes |
|---|---|---|
| header_color | yes | One of the 10 hex codes above. |
| meta_title | no | ≤60 chars. |
| title | yes | ≤200 chars. |
| body | yes | ≤2000 chars. Supports a small, fixed markdown subset — see below. |
| sub_body | no | ≤2000 chars. Same markdown subset as body. |
| icon | no | One of the 10 icon names below. |
| button | no | Object — see below. |
| Name (exact case) | Rendered as |
|---|---|
| check | ✓ |
| warning | ⚠ |
| document | 📄 |
| security | 🔒 |
| wallet | 💰 |
| person | 👤 |
| alert | 🔔 |
| info | ℹ |
| reassign | 🔄 |
| pending | ⏳ |
icon is now a recognised, optional top-level field (see table above). Any other unknown top-level key is still rejected outright — there is no silent-drop-and-continue.| Syntax | Renders as |
|---|---|
| **bold text** | bold text |
| *italic text* | italic text |
| - bullet line | Consecutive lines starting with - are grouped into a <ul><li> list. A line not starting with - ends the current list. |
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 still saved as plain text exactly as sent; there is no server-side markdown-to-HTML conversion, each client renders it independently.| Field | Required | Notes |
|---|---|---|
| label | yes | ≤40 chars. |
| url | yes | ≤2048 chars. Must start with https:// — http:// is rejected, even though link_url elsewhere on this API accepts both. |
| color | no | One of the 10 hex codes above. |
button either — target, icon, etc. are rejected the same way as an unknown top-level card field. (Note: icon is only valid at the top level of card, not inside button.)validate_card() returns a specific error string on the first invalid field it finds, and the endpoint returns it verbatim: {"ok":false,"error":"<message>"}. Real examples: {"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"}.{
"header_color": "#059669",
"title": "Approved — ₹5,000",
"body": "Your wallet topup has been approved and credited.",
"button": { "label": "View transaction", "url": "https://vistarkriya.com/wallet/txn/1234", "color": "#059669" }
}
{
"header_color": "#DC2626",
"title": "Payment failed",
"body": "Your last payment attempt did not go through.",
"button": { "label": "Retry payment", "url": "https://vistarkriya.com/wallet/retry/1002", "color": "#DC2626" }
}
{
"header_color": "#D97706",
"title": "KYC re-verification needed",
"body": "Your KYC documents expire in 5 days.",
"button": { "label": "Update KYC", "url": "https://vistarkriya.com/kyc/update", "color": "#D97706" }
}
{
"header_color": "#2563EB",
"title": "Application received",
"body": "Your loan application is now under review.",
"button": { "label": "Track status", "url": "https://vistarkriya.com/loan/LN-4521", "color": "#2563EB" }
}
{
"header_color": "#0891B2",
"icon": "pending",
"meta_title": "MLS lead",
"title": "Update \u2014 Applicant Name",
"body": "Reference: **MLS26-T064-DB585C**\nStatus: *InProgress*\n\n- Received by partner\n- Awaiting document review\n- Next update within 24h"
}{
"username": "4821k9037",
"message": "Payout processed. ₹12,500 released.",
"external_ref": "crm-9005",
"link_url": "https://crm.example.com/payouts/9005/receipt",
"card": {
"header_color": "#059669",
"meta_title": "Payout",
"title": "₹12,500 released",
"body": "Your September payout has been released to your registered bank account.",
"sub_body": "Reference: PAYOUT-9005",
"button": { "label": "View payout", "url": "https://crm.example.com/payouts/9005", "color": "#059669" }
}
}External Chatbot API
Real, live /help answers powered by your own CRM. Unlike the two surfaces above, this is an inbound contract from your side — StaqOn calls a webhook URL on your server, synchronously, while a real external member waits for the answer.
Chatbot query
The request StaqOn sends you
| Field | Type | Description |
|---|---|---|
| query_typereq | string | The tag the admin gave this button when building the /help tree (e.g. loan_status). Route internally on this field. <b>Free text by default</b> — an admin can now opt in to real, StaqOn-enforced validation by registering a list of valid values on the key; see the callout below for the full picture. |
| usernamereq | string | The external member's real StaqOn username — stable, unique. |
| external_refreq | string | null | Always <code>null</code> for links established via <code>/link</code> — the real, built self-service flow never sets a value here; <code>staqon_links.external_ref</code> is reserved for a possible future direct-API link path. Treat it as always null today. |
| team_idreq | integer | StaqOn's internal id for the external team this request belongs to. |
| workspace_idreq | 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. |
| user_inputreq | string | null | The member's validated free-text answer, present only when the tapped button had a prompt configured (see callout below). Null if no prompt was configured for this button. |
| timestampreq | string | ISO 8601 timestamp of when StaqOn sent the request. Informational only — not currently used for replay protection. |
{"query_type":"loan_status","username":"4821k9037","external_ref":null,"team_id":117,"workspace_id":1,"user_input":null,"timestamp":"2026-08-30T11:15:42+00:00"}{"ok":true,"answer":"Your loan application #LN-4521 is under review."}answer, so it never trips failure tracking, it just answers the wrong thing forever.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:"<value>" is not a registered query type for this CRM connection. Add it in "Registered query types" first, or use an existing one.
query_type value is accepted, no validation happens.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, e.g.:
| query_type value | Button name in /help tree | Handled in CRM? |
|---|---|---|
loan_status | "Check my loan status" | ✅ |
office_hours | "Office hours" | ✅ |
payment_due | "When is my payment due?" | ⬜ |
X-StaqOn-Signature. Reject if it doesn't match, using hash_equals(), not ==.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 on this page.$raw = file_get_contents('php://input');
$expected = hash_hmac('sha256', $raw, $yourChatbotSecret);
if (!hash_equals($expected, $_SERVER['HTTP_X_STAQON_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}ok, or no response within 3s — StaqOn collapses every one of these to the SAME real, uniform message on the external member's screen: "This needs a bit of setup on our end — please contact the team directly for now." It never reveals which specific thing went wrong; check your own server logs to debug.answer can contain real newline characters — a genuine \n 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.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, once your encoder serializes it, becomes the two-character escape sequence
\n on the wire (backslash + the letter n) — that's correct, standard JSON, and StaqOn's json_decode() turns it back into a real newline automatically. The bug we've seen integrators hit is different: manually concatenating a literal backslash and the letter "n" into the string content itself (hand-built JSON, or double-escaping an already-escaped string) sends that literal two-character sequence as part of the text, not as an escape — it renders as visible \n in the chat instead of a line break.Correct (encoder-produced newline, renders as a real line break):
{"ok": true, "answer": "Your loan application #LN-4521 is under review.\n\nExpected decision: 3-5 business days."}\n text):{"ok": true, "answer": "Your loan application #LN-4521 is under review.\\n\\nExpected decision: 3-5 business days."}\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.user_input.Internal Chatbot API
Real, live Internal /help answers powered by your own CRM (2026-09-05) — the same real, inbound mechanism as the External Chatbot API above, aimed at your own staff instead of external partner/customer members. StaqOn calls a webhook URL on your server, synchronously, while a real internal staff member waits for the answer in their team's own group conversation.
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 key that gates the External Chatbot API. The Internal Chatbot API never creates this link — it only ever reads whether one already exists.app/internal-chatbot-teams.php → app/internal-chatbot-edit.php) — one key per internal team, same real one-active-key-per-team mechanism as every other Chatbot/Link surface on StaqOn. The secret is shown exactly once, at generation time, and is never re-displayed — store it immediately.Internal Chatbot query
The request StaqOn sends you
link_type='internal') gates it.| Field | Type | Description |
|---|---|---|
| query_typereq | string | The tag the admin gave this button when building the Internal /help tree (e.g. leave_balance). Route internally on this field. <b>Free text by default</b> — an admin can now opt in to real, StaqOn-enforced validation by registering a list of valid values on the key; see the callout below for the full picture. |
| usernamereq | string | The staff member's real StaqOn username — stable, unique. |
| external_refreq | string | null | The linked staff member's <code>staqon_links.external_ref</code> (their CRM ID, captured at Internal <code>/link</code> time) — unlike External, where this is always <code>null</code> today, Internal's own <code>icb_resolve_api_answer()</code> actually looks up and forwards the real value on file. |
| team_idreq | integer | StaqOn's internal id for the internal team this request belongs to. |
| workspace_idreq | integer | StaqOn's internal id for the workspace this request belongs to — read directly off <code>internal_chatbot_keys.workspace_id</code>, no extra lookup needed on StaqOn's side. |
| user_inputreq | string | null | The staff member's validated free-text answer, present only when the tapped button had a prompt configured (see callout below). Null if no prompt was configured for this button. |
| trace_idreq | string | A fresh UUID v4, generated for this call — see the <a href="#trace-ids">Cross-API trace IDs</a> section. Sent both in the body and as the <code>X-StaqOn-Trace-Id</code> header. |
| timestampreq | string | ISO 8601 timestamp of when StaqOn sent the request. Informational only — not currently used for replay protection. |
{"query_type":"leave_balance","username":"asha29104","external_ref":"CRM100","team_id":58,"workspace_id":1,"user_input":null,"trace_id":"b3f1c2a4-7e5d-4a91-9c2e-1f6a8d0b3c5e","timestamp":"2026-09-05T09:20:11+00:00"}{"ok":true,"answer":"You have 6 days of paid leave remaining this year."}answer, so it never trips failure tracking, it just answers the wrong thing forever.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:"<value>" is not a registered query type for this CRM connection. Add it in "Registered query types" first, or use an existing one.
query_type value is accepted, no validation happens.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 (see the External Chatbot API's own callout for the table format) 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.
internal_chatbot_keys.secret), hex-encoded — sent as header X-StaqOn-Signature. Reject if it doesn't match, using hash_equals(), not ==. Bare hex only — no t= timestamp prefix, exactly like External Chatbot's own outbound call — confirmed byte-for-byte in internal_call_chatbot_crm(): hash_hmac('sha256', $payload, $secret), nothing else.$raw = file_get_contents('php://input');
$expected = hash_hmac('sha256', $raw, $yourInternalChatbotSecret);
if (!hash_equals($expected, $_SERVER['HTTP_X_STAQON_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}ok, or no response within 3s — StaqOn collapses every one of these to the SAME real, uniform message on the staff member's screen: "This needs a bit of setup on our end — please contact your admin directly for now." (ICB_SETUP_NEEDED_MESSAGE — deliberately never reveals which specific thing went wrong, same discipline as External's equivalent message, worded for an internal audience instead of an external one). Check your own server logs to debug.ICB_WARN_THRESHOLD) → the admin who set this up gets a warning email; the chatbot keeps trying. 100 consecutive failures (ICB_DISABLE_THRESHOLD) → permanently disabled for that team — there is no re-enable action. Recovery means the admin generates a brand-new key + secret from the Internal Chatbot API team list, so your stored secret needs replacing, not just re-enabling. Any single success resets the counter to zero.icb_validate_free_text(), byte-for-byte mirror of External's own validator). When 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 → the staff member sees a validation error and gets one retry. Invalid again → StaqOn gives up and 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.POST /api/webhook-health.php?action=check DOES accept "surface":"internal_chatbot" (2026-09-05) — its real allow-list is ['external', 'internal', 'chatbot', 'internal_chatbot'] (confirmed by reading api/webhook-health.php directly), authenticating against your internal_chatbot_keys row exactly like the External Chatbot branch does against chatbot_keys. This lets you self-service poll your Internal Chatbot key's live failure_count/is_active the same HMAC-authenticated way as every other surface — the admin-side team list (Workspace → Settings → API → Internal Chatbot API) remains available too, but is no longer the only option.Internal /help — the real 3-stage gate
A staff member types /help literally, inside their internal team's own group conversation (not a guest thread — internal teams have no guest concept; any genuine roster member typing it triggers this). api/messages.php resolves the conversation via icb_internal_team_conv_team_id() (must be a real type='group', is_external_thread=0 conversation belonging to a real, non-archived team_type='internal' team, and the sender must be on that team's roster) — anything else and /help falls straight through as an ordinary literal message, never a silent no-op. Structural mirror of External's own 3-stage gate, in the exact same order:
| Stage | Condition | Real response |
|---|---|---|
| 1 — Internal Link not configured | internal_link_keys has no active row for this workspace | json_err: "This isn't set up for your workspace yet — your admin hasn't connected Internal Link." |
| 2 — member not linked | no approved link_type='internal' row in staqon_links for this user | Real, chat-based flow (not a popup) — inserts/updates a link_flow_state row (step='awaiting_identifier') and sends the bot message "Link your account to use /help. What's your CRM ID?"; responds {"ok":true,"intercepted":"internal_help_needs_link"} |
| 3 — chatbot not configured for the team | Link passed, but no active internal_chatbot_keys row + root internal_chatbot_nodes for this team | json_err: "This isn't set up for your team yet — your admin hasn't connected the Internal Chatbot API." |
/help Stage 2 fires a real popup (bot-input-request on private-user-{id}) because External's /link is itself a one-shot form (email + second-question, submitted together). Internal's own /link is a genuinely different, two-message conversation — CRM ID first, then email, tracked via link_flow_state.step — so Internal's /help Stage 2 re-triggers that exact same real chat flow instead of a popup. A member typing /help before linking lands in the identical experience as if they'd typed /link themselves.All three stages passed → StaqOn builds a real bot_menu from this team's root internal_chatbot_nodes (icb_build_menu_from_nodes()), validates it (icb_validate_bot_menu() — byte-for-byte mirror of External's validate_bot_menu()), and sends it via icb_send_bot_message() into the team's own conversation, attributed to the triggering staff member (not the key's created_by) — responds {"ok":true,"intercepted":"internal_help"}.
Internal Chatbot API — admin setup
Configured from Workspace → Settings → API → Internal Chatbot API (app/internal-chatbot-teams.php, owner/admin only, gated by require_workspace_member() + role check — identical auth model to every other admin surface on this page). Every real internal team in the workspace is listed, configured or not (a genuine LEFT JOIN internal_chatbot_keys, not a filtered list) — an unconfigured team's card links straight to the edit page's first-time-setup form rather than a dead end.
api/internal-chatbot-keys.php: create (rejects a second active key for a team already configured — "This team already has an active chatbot key. Revoke it first."), list, rotate (15-minute grace period on secret only — the sk_bot_ bearer key/hash is untouched, since nothing verifies it inbound today), revoke. The webhook URL is validated as a well-formed public http(s):// address (SSRF guard against private/loopback ranges) but never actually called at creation time — same honest gap as Link/Internal Link's own webhook setup.
api/internal-chatbot-nodes.php: list/create/update/delete against internal_chatbot_nodes, a real self-referential adjacency-list table (parent_id → id, ON DELETE CASCADE). Each node carries: question (max 255 chars), answer_type (static or api), answer_text (required for static), link_url (optional, static-only, must be genuine https://, max 500 chars), query_type (required for api — this is the exact free-text tag your webhook receives; free text by default, optionally validated at save time against the key's registered list if one exists, see the callout above), prompt_text/validation_type/validation_length (optional, api-only free-text capture), and follow_up.
ON DELETE CASCADE). The real removed_count in the response is computed with a recursive CTE before the delete runs, walking parent_id downward from the target node — a truthful count, not a client-side guess.Set on an api-type node alongside a real prompt_text. Byte-for-byte mirror of External's own chatbot_validate_free_text(), confirmed by reading icb_validate_free_text() directly:
| Type | Rule | Real error message on failure |
|---|---|---|
| any | Non-empty content only | "Please enter a response." |
| numeric | ^\d+$ | "Please enter numbers only." |
| numeric_exact | ^\d+$ AND length === validation_length | "Please enter exactly {N} digits." |
| alphanumeric | ^[a-zA-Z0-9]+$ | "Please enter letters and numbers only." |
| alphanumeric_exact | ^[a-zA-Z0-9]+$ AND length === validation_length | "Please enter exactly {N} letters/numbers." |
FILTER_VALIDATE_EMAIL | "Please enter a valid email address." | |
| phone | ^\+?\d{7,15}$ | "Please enter a valid phone number." |
numeric_exact/alphanumeric_exact require a real validation_length. api/internal-chatbot-nodes.php's own icbn_validate_prompt() rejects saving either type without a positive-integer validation_length — you cannot save a broken exact-length rule from the tree editor.follow_up values| Value | Real behavior (icb_resolve_next_menu()) |
|---|---|
| show_more_options | Sends this node's real children as the next bot_menu (empty children → no next menu sent). |
| back_to_main_menu | Re-sends the team's real root nodes as "How can we help?" |
| end_conversation | No further menu is sent — the conversation ends here. |
| escalate | Vestigial — identical to end_conversation, confirmed by reading the code. See the callout below; do not select this expecting real escalation behavior. |
escalate is unwired here too — not fixed in the Internal build, honestly. icb_resolve_next_menu()'s switch statement has an explicit case 'escalate': that falls straight through to the same default: return null; branch as end_conversation — confirmed by reading the real code, not inferred. This is the exact same vestigial state as External's own chatbot_resolve_next_menu(), carried over unchanged rather than fixed during the Internal build. CBN_FOLLOW_UPS still lists it as a selectable option in the tree editor's dropdown, so nothing in the UI stops an admin from picking it — it just behaves identically to "End conversation" when picked, with no actual escalation raised anywhere.Link API
/link is StaqOn's workspace-wide identity-linking mechanism (2026-09-01), and the replacement for the old, per-team account-linking flow this documentation used to describe. It has since been merged with the general Webhooks mechanism — one Link API key per workspace now carries the workspace's webhook URL, its shared secret, and its event subscriptions, all in one place.
One key per workspace, generated from Workspace → Settings → API → Link (or the dedicated Link API admin page it links to). You provide a webhook URL; StaqOn generates the bearer key and the shared secret. The secret is shown exactly once, at generation time — store it immediately, it is never re-displayed.
http(s):// URL pointing at a public address (not localhost/private-range) — it never actually calls it. A URL that's unreachable, 404s, or requires auth you haven't configured is accepted without complaint; the first real signal anything's wrong is your first genuine verify attempt failing. Verify it yourself (e.g. a manual curl) before relying on it.second_question is capped at 255 characters (matches the real varchar(255) column) — anything longer is silently cut down to 255 chars, both at creation and when changed later, never an error. Same real pattern as Internal's crm_id below.The same real checkbox list as the Webhooks section below — pick which of the seven real events this workspace's webhook receives. Subscriptions can be changed at any time without rotating the key or the webhook URL.
Same grace-period pattern as every other surface on StaqOn: rotating generates a new secret immediately while the previous one stays valid for a real 15-minute window, so an in-flight integration is never broken by a rotation.
POST to your webhook URLReal, live, genuinely built (2026-09-02): the member types /link in chat, or clicks the workspace's "Link this workspace" button, and enters their email plus the answer to this workspace's configured second question (set on the Link admin page — required, not optional; it's the only thing that stops someone who merely knows a customer's email from impersonating them). StaqOn HMAC-signs and POSTs both values to your webhook URL. The member's staqon_links row is marked approved only if your webhook responds {"ok": true} — this is a genuine CRM-verified handshake, not an automatic approval.
POST <your webhook_url>
Content-Type: application/json
X-StaqOn-Signature: <hex>
X-StaqOn-Trace-Id: <uuid>
X-StaqOn-Event: link.verify
{
"email": "customer@example.com",
"question": "What's your registered mobile number?",
"answer": "9876543210",
"team_context": null,
"username": "4821k9037",
"trace_id": "b3f1c2a4-7e5d-4a91-9c2e-1f6a8d0b3c5e"
}The signature is bare hex — hash_hmac('sha256', raw_body, your_link_secret) — sent as X-StaqOn-Signature, no t=/timestamp component (this is an outbound call StaqOn makes to you, not an inbound one StaqOn verifies). question is this workspace's real, current second-question text; team_context is always null today (reserved).
X-StaqOn-Event. This webhook URL is the exact same one your general event notifications land on (see Webhooks — they share the URL and secret). X-StaqOn-Event (added 2026-09-03) is now sent on every real call to this URL, verify or event, with a distinct value each time: link.verify on this call, or the real event name (e.g. member.message) on a general notification. Check this header first — it's the cleanest, most reliable way to branch your receiver, more so than inferring from body shape.trace_id / X-StaqOn-Trace-Id (added 2026-09-03) — a fresh UUID v4, generated for this call, sent both in the body and as a header, matching the External Chatbot API's own convention. Log it on your side for easier cross-referencing during debugging; there's no earlier interaction to correlate it with here, so it's always freshly generated, never threaded in from something prior.username (added 2026-09-03) is StaqOn's own real username for the member currently linking — already resolved server-side before this call is made, so it costs nothing extra to include. Store it against the matched customer record at verify-time, so you have it ready for the External API's message.php/comment.php without a separate lookup after approval.{"ok": true}{"ok": false} — a genuine, honest non-match — is treated differently from your webhook being unreachable, timing out, or returning a non-2xx: only real technical failures count against your key's failure_count (10 consecutive failures auto-disables the key, same as every other surface). A clean "no" costs you nothing.2xx counts as success — StaqOn checks the actual HTTP status (200–299), not literally 200. {"ok": true} in the body is still what marks the link approved, but a 201/204 carrying that body works exactly the same as 200.verify_external itself — every submission runs the real CRM round-trip directly, live, uncached, same real gap already documented below for link-list.php, just on the write side instead of the read side.email is validated before this call is ever made. A malformed email is rejected with a real 400 at submission time, before link_call_crm() runs — your webhook will never receive an email value that isn't genuinely valid email format.staqon_links row — that table has no column for the question or answer at all, so there is nothing for a change to retroactively touch. Already-linked members stay linked; only the next person to verify sees the new question.POST /api/link-list.phpA real, working, read-only endpoint (2026-09-02) — the reverse direction from every other call on this page: 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.
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.POST /api/link-list.php
Content-Type: application/json
X-StaqOn-Signature: t=<unix_timestamp>,v1=<hex>
{
"key_id": 24,
"timestamp": "2026-09-02T06:32:44Z"
}key_id is your Link API key's numeric ID (shown on the Link admin page once configured). timestamp is an ISO-8601 string, checked for a real ±5 minute freshness window — same convention as the health endpoint. The signature covers the raw request body: hash_hmac('sha256', "{unix_timestamp}.{raw_body}", your_link_secret), sent as t=<unix_timestamp>,v1=<hex>. The older bare-hex format (no t= prefix) is also accepted, matching every other signature check on StaqOn.
{
"ok": true,
"links": [
{
"crm_email": "customer@example.com",
"link_type": "external",
"username": "4821k9037",
"status": "approved",
"linked_at": "2026-09-02 12:02:30"
}
]
}username is StaqOn's own real username for that same person — a genuinely different value from crm_email (which is whatever address you have on file for them). This is exactly what you need to pass to POST /message.php or POST /comment.php to actually reach this member through StaqOn itself; crm_email alone was never enough for that.linked_at (and every other timestamp on this page) is Asia/Kolkata (+05:30) — StaqOn's server timezone, set once at the application level, not UTC and not the requester's own timezone. Convert on your side if your CRM stores or displays in a different zone.{"ok": false, "error": "Invalid signature or unknown key."}key_id, a revoked/inactive key, or a stale timestamp all return the identical 401 above — deliberately indistinguishable, so this endpoint can never be used to enumerate real key_ids. Rotation is supported: a request signed with your previous secret is still accepted for the real 15-minute grace window after rotating.$keyId = 24; // your real Link API key's id
$secret = 'lnksec_...'; // your real Link API secret
$timestamp = gmdate('Y-m-d\TH:i:s\Z');
$body = json_encode(['key_id' => $keyId, 'timestamp' => $timestamp]);
$unix = time();
$sig = 't=' . $unix . ',v1=' . hash_hmac('sha256', $unix . '.' . $body, $secret);
$ch = curl_init('https://staqon.one/api/link-list.php');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-StaqOn-Signature: ' . $sig],
CURLOPT_RETURNTRANSFER => true,
]);
$response = json_decode(curl_exec($ch), true);
// $response['links'] — your workspace's real, current approved External connectionsInternal Link API
/link's Internal surface (2026-09-02) — a genuinely separate mechanism from the External Link API above, for linking your own staff's StaqOn identity to your CRM, rather than an external customer's. Its own dedicated table (internal_link_keys), its own admin page (Workspace → Settings → API → Link API → For Internal), its own key/secret/webhook/events, independent from External's.
One key per workspace, generated from the Internal Link admin page. You provide a webhook URL; StaqOn generates the bearer key and the shared secret. The secret is shown exactly once, at generation time — store it immediately, it is never re-displayed.
http(s):// URL at creation time, never actually calls it. Verify it yourself before relying on it.POST to your webhook URLA staff member types /link in chat (a real two-step conversation: CRM ID first, then email) or uses the direct-click popup (both fields in one submission). StaqOn HMAC-signs and POSTs both to your Internal Link webhook URL. The member's staqon_links row (link_type='internal') is marked approved only if your webhook responds {"ok": true} — a genuine CRM-verified handshake, matching External's own rigor, not an automatic approval on bare free text.
POST <your webhook_url>
Content-Type: application/json
X-StaqOn-Signature: <hex>
X-StaqOn-Trace-Id: <uuid>
X-StaqOn-Event: internal_link.verify
{
"crm_id": "CRM100",
"email": "staff@yourcompany.com",
"username": "4821k9037",
"trace_id": "b3f1c2a4-7e5d-4a91-9c2e-1f6a8d0b3c5e"
}The signature is bare hex — hash_hmac('sha256', raw_body, your_internal_link_secret) — sent as X-StaqOn-Signature, same outbound-call convention as External's own verify contract above (no t=/timestamp component — this is StaqOn calling you, not the reverse).
trace_id / X-StaqOn-Trace-Id (added 2026-09-03) — same real convention as External's Link API above, a fresh UUID v4 generated for this call, sent both in the body and as a header, matching External Chatbot's own pattern.X-StaqOn-Event. Same real convention as External's Link API above — this webhook URL shares its URL/secret with your general event notifications (see Webhooks). X-StaqOn-Event is sent on every real call here too: internal_link.verify on this call, or the real event name (e.g. link.status_changed) on a general notification.username (added 2026-09-03) is StaqOn's own real username for the staff member currently linking — already resolved server-side before this call is made, so it costs nothing extra to include. Store it against the matched staff record at verify-time, so you have it ready for the Internal API's message.php without a separate lookup after approval.{"ok": true}{"ok": false} — a genuine, honest non-match — does not count against your key's failure_count. Only real technical failures (timeout, non-2xx, malformed response) do; 10 consecutive real failures auto-disables the key, same threshold as every other surface.2xx counts as success — same rule as External: StaqOn checks the actual HTTP status (200–299), not literally 200.verify_internal itself either — every submission runs the real CRM round-trip directly, live, uncached.email is validated, crm_id is silently truncated — both before this call is ever made. A malformed email is rejected with a real 400 at submission time, so your webhook never receives an invalid email. crm_id, however, is silently truncated to 190 characters, not rejected — if your CRM ID scheme could ever exceed that, check for truncation on your side rather than assuming a rejection would have caught it.Internal Link keys carry their own independent events subscription list — genuinely separate from the External Link key's own subscriptions, even though both can subscribe to the same real event name. Today the only real event available here is link.status_changed, fired on a real Internal /link approval or revoke.
{
"event": "link.status_changed",
"timestamp": "2026-09-02T15:52:15+05:30",
"data": {
"user_id": 2,
"username": "4821k9037",
"link_type": "internal",
"status": "approved",
"workspace_id": 1,
"team_id": 0
}
}Same real delivery discipline as every other webhook on StaqOn: X-StaqOn-Signature is a bare-hex HMAC-SHA256 of the raw body, keyed by your Internal Link secret, plus an X-StaqOn-Event header. One delivery attempt, no retry, no queue. status is "approved" or "revoked"; team_id is always 0 — /link has no team concept, it's workspace-scoped. username (added 2026-09-03) is StaqOn's own real username for the linked person, already resolved before the event fires — no separate lookup needed.
Same real grace-period pattern as every other surface: rotating generates a new secret immediately while the previous one stays valid for a real 15-minute window.
POST /api/internal-link-list.phpA real, working, read-only endpoint (2026-09-02) — the reverse direction from your Internal Link webhook, direct structural mirror of the External Link API's own link-list.php: you call StaqOn, HMAC-signed the same way the health endpoint verifies your inbound calls. Use it to check a staff member's real-time link status from your own system, or to reconcile your own records against StaqOn's.
status='approved' and link_type='internal' are returned. Pending/declined/expired/revoked rows are omitted (this is a "who's currently linked" view, not an audit log), and External (customer) links are never included — deliberately, since this key only ever authenticates your Internal surface.crm_username, crm_email AND username are all returned — unlike External's link-list.php, which only ever returns crm_email. Internal's real identity pair is a CRM ID + email (see the verify contract above) — crm_username here carries the real CRM ID your staff member entered, not a StaqOn username. username (added 2026-09-02) is StaqOn's own real username for that same person — a genuinely different value from crm_username — and is exactly what you need to pass to POST /message.php to actually notify them; resolving it any other way would mean a second call to GET /members and a manual join on email. All three fields are always populated together on a real Internal approval; none is ever null on an approved row.linked_at is Asia/Kolkata (+05:30) — same real server timezone as every other timestamp on this page, not UTC. See the same note under External's Link API section.POST /api/internal-link-list.php
Content-Type: application/json
X-StaqOn-Signature: t=<unix_timestamp>,v1=<hex>
{
"key_id": 15,
"timestamp": "2026-09-02T06:32:44Z"
}key_id is your Internal Link API key's numeric ID (shown on the Internal Link admin page once configured). timestamp is an ISO-8601 string, checked for a real ±5 minute freshness window — same convention as every other real signature check on StaqOn. The signature covers the raw request body: hash_hmac('sha256', "{unix_timestamp}.{raw_body}", your_internal_link_secret), sent as t=<unix_timestamp>,v1=<hex>. The older bare-hex format (no t= prefix) is also accepted.
{
"ok": true,
"links": [
{
"crm_username": "CRM100",
"crm_email": "staff@yourcompany.com",
"username": "4821k9037",
"status": "approved",
"linked_at": "2026-09-02 16:29:42"
}
]
}{"ok": false, "error": "Invalid signature or unknown key."}key_id, a revoked/inactive key, or a stale timestamp all return the identical 401 above. An External Link key's secret can never authenticate this endpoint, and vice versa — not a shared-secret coincidence to avoid, a structural impossibility, since each endpoint only ever looks up its own real table (internal_link_keys here, link_keys for External). Rotation is supported: a request signed with your previous secret is still accepted for the real 15-minute grace window after rotating.$keyId = 15; // your real Internal Link API key's id
$secret = 'ilnksec_...'; // your real Internal Link API secret
$timestamp = gmdate('Y-m-d\TH:i:s\Z');
$body = json_encode(['key_id' => $keyId, 'timestamp' => $timestamp]);
$unix = time();
$sig = 't=' . $unix . ',v1=' . hash_hmac('sha256', $unix . '.' . $body, $secret);
$ch = curl_init('https://staqon.one/api/internal-link-list.php');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-StaqOn-Signature: ' . $sig],
CURLOPT_RETURNTRANSFER => true,
]);
$response = json_decode(curl_exec($ch), true);
// $response['links'] — your workspace's real, current approved Internal (staff) connectionsWebhooks
StaqOn 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.
team_id is always included. Every event's data now carries a team_id field, 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.X-StaqOn-Signature (plus X-StaqOn-Event). Reject if it doesn't match. The header is bare hex, with no sha256= prefix — compute hash_hmac('sha256', $raw, $secret) (or your language's equivalent) and compare directly.failure_count increments on every failure; at 10 consecutive failures the webhook is set inactive (is_active=0) permanently, with no automatic re-enable. Poll the health endpoint for failure_count/is_active if you need to detect this, and build your own reconciliation (e.g. periodic polling) for anything you cannot afford to lose.user_id + status + your own received-at timestamp).PHP:
$raw = file_get_contents('php://input');
$expected = hash_hmac('sha256', $raw, $yourWebhookSecret);
if (!hash_equals($expected, $_SERVER['HTTP_X_STAQON_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
Node.js:
const crypto = require('crypto');
const expected = crypto.createHmac('sha256', yourWebhookSecret).update(raw).digest('hex');
const sig = Buffer.from(req.headers['x-staqon-signature'] || '', 'utf8');
const exp = Buffer.from(expected, 'utf8');
const valid = sig.length === exp.length && crypto.timingSafeEqual(sig, exp);
if (!valid) return res.status(401).end();
Python:
import hmac, hashlib
expected = hmac.new(your_webhook_secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get('X-StaqOn-Signature', '')):
return Response(status=401){
"event": "<name>",
"timestamp": "<ISO-8601>",
"data": { }
}| Event | Fires when | data |
|---|---|---|
member.joined | external member self-joins a team | member_id, name, team_id |
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 (+ origin:"api", external_ref via Comment API), team_id |
member.removed | a member is removed from the team | member_id, email, team_id |
member.left | a member leaves the team on their own | member_id, name, team_id |
escalation.created | an escalation case is raised | matter_id, title, priority, status:"open", scope, target_member_id, target_email, due_date, team_id |
escalation.resolved | an escalation case is resolved | matter_id, status:"resolved" — resolved_by and resolution_notes are withheld (staff identity / internal notes), team_id |
Health endpoint
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.
{
"surface": "external",
"key_id": 123,
"timestamp": "2026-08-30T12:00:00Z"
}X-StaqOn-Signature. Either signature format is accepted (see below) — the timestamp field is checked for a ±5-minute freshness window, and a stale/future value is rejected with the same generic denial as a bad signature.{
"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
}{
"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
}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 key 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. External Chatbot has no such limitation — it authenticates with chatbot_keys.secret, the same secret already used to verify chatbot_call_crm()'s outbound signature.surface's real allow-list is ['external', 'internal', 'chatbot', 'internal_chatbot'] (confirmed by reading api/webhook-health.php directly) — the fourth value authenticates against your own internal_chatbot_keys row, the same shape/response as the External Chatbot branch. You can now poll an Internal Chatbot key's real failure_count/is_active status the same self-service HMAC-authenticated way as every other surface, in addition to the admin-side team list in the StaqOn UI.Secret rotation
A rotate action now 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. This replaces the old revoke-and-recreate pattern, which had zero overlap and would break an in-flight integration instantly.
previous_expires_at is compared against NOW() fresh on every single request — expiry is exact to the second.chatbot_call_crm(), trigger_webhook(), or /link's own link_call_crm()/internal_call_crm() — all four always sign with whatever secret is currently on file, so rotating takes effect immediately for new outbound deliveries with no grace period needed, and your webhook never needs to try both the old and new secret to verify a call StaqOn sends you.The health endpoint's response includes rotation state when relevant: grace_period_active, previous_secret_expires_at, minutes_remaining. External/Internal additionally carry a separate bearer_key_rotation block, since the bearer key (api_keys) and the webhook secret (webhooks) rotate independently of each other.
| Format | Shape | Notes |
|---|---|---|
| Old (bare hex) | 64 lowercase hex chars | hash_hmac('sha256', raw_body, secret) — still fully supported, indefinitely, for existing integrations. |
| New (timestamped) | t=<unix>,v1=<hex> | hex is hash_hmac('sha256', "{timestamp}.{raw_body}", secret). Rejected if the embedded timestamp is more than 5 minutes old. Recommended for new integrations. |
t=, never a valid hex digit) — StaqOn detects which one you sent automatically, with zero risk of misclassification. New keys/webhooks are given the 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.Cross-API trace IDs
Every request across all three surfaces now supports X-StaqOn-Trace-Id: an optional request header, always present on the response.
| Behavior | |
|---|---|
| If you send it | Any non-empty value up to 100 chars, matching ^[A-Za-z0-9_.:-]+$ — echoed back verbatim, logged against this event. |
| If you don't | StaqOn generates a fresh UUID v4 and echoes that instead. |
link-list.php/internal-link-list.php), and every Messaging/External Chatbot API reply. We never read or require a trace ID on your webhook's response to our outbound /link verify calls — your response only needs the real {"ok": bool} body; no headers required. The trace_id we send you on that outbound call (body + X-StaqOn-Trace-Id header) is purely for your own logging/correlation — nothing you do with it changes how StaqOn processes your response.A member taps /help → the External 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) → your CRM later posts a reply to that same member via the External API, including that same trace ID on the call. Both real events are now queryable together by trace ID — the two separate API calls (different surface, different endpoint, different timing) are provably one logical transaction.
Postman collections
One collection per surface — deliberately separate, so a request can't be copied from one surface to the other and fail with a 400. Import, set apiKey + baseUrl once, and run every endpoint.