/ API docs
REST · JSON · v1

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:

X-API-Key: sk_live_xxx

Base URL

Each surface has its own base. Endpoints below are relative to [base_url] for their surface:

External
https://staqon.one/api/v1/external
Internal
https://staqon.one/api/v1/internal
🪟
Windows users. PowerShell aliases 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.

The 6 concepts, grouped by what they actually do
GroupConceptWhat it's for
Messaging
push/pull real conversation content
External APICRM ↔ 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 APIStaqOn calls your CRM, synchronously, to answer an external member's real-time /help query
Internal Chatbot APIStaqOn calls your CRM, synchronously, to answer your own staff's real-time Internal /help query
Identity / consent
no message content ever moves here
Link APIIssues single-use invite tokens that link an External member to your CRM record (their staqon_links row is approved when the member confirms)
Internal Link APIVerifies a staff member's identity before their staqon_links row is approved
✅
Naming, clarified. The Messaging API (External + Internal — where a message actually goes) and Link API / Internal Link API (who the person on the other end really is) are two completely separate mechanisms answering two different questions. Neither implies the other: a workspace can have an active Internal Messaging API key with no Internal Link key configured at all, or vice versa. 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.
✅
Internal Chatbot vs External Chatbot, clarified. Both are the exact same mechanism in shape — StaqOn calls a webhook on your server, synchronously, to answer a real-time /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.
The two messaging surfaces, in depth

A key is bound to the surface of the team it was created against. This is the thing people get wrong first.

📻
In plain terms: think of a team as a walkie-talkie channel. Only people tuned to the same channel as your key can hear it. A key created for the "Sales" channel can't reach someone only tuned to "RM," even if that person is otherwise reachable — they're just on a different channel.
External APIInternal API
Who it talks toPartner / customer members of an external teamYour own staff in an internal team
DirectionTwo-way — write in, read replies backOne-way only — no reply, no read-back
Writing inKey alone is enough (your workspace's authority)Requires an approved link — staff can refuse
Reading back/messages.php, needs an approved linkNot available — there is no internal messages.php
Webhook eventsNot per-surface any more — one webhook per workspace, subscribed via the Link API, firing member.*/escalation.*. See Webhooks.
Where it landsThe member's normal chat threadA separate API Messages feed
⚠️
Keys cannot cross. An internal key against an external endpoint returns 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.
Which endpoints need an approved link

Everything is key-only except the endpoints listed below. Those return 403 until the member's staqon_links row is approved, and again the moment it's revoked.

SurfaceKey-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"
staqon-verify-start (ask a member to confirm an action) → 403 "No approved link for this member"
Internal status message (send a notification) → 403 "Member has not approved API messages"
↔️
Note the inversion. On external, writing is key-only and reading needs consent. On internal, there is nothing to read and writing needs consent. Sending a message to a partner is your workspace acting on its own behalf; notifying a colleague is automation aimed at a person.
Consent — StaqOn's /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.

✅
Two ways in, both real and live. External members (2026-10-15): your CRM requests a single-use invite token for the customer via POST /api/link-invite.php, delivers it to them, and the customer confirms on StaqOn — the row reaches approved when they tap Yes, and StaqOn sends you link.status_changed. Internal staff: they type /link in chat or use the "Link this workspace" button and provide a CRM ID + email; StaqOn signs and verifies those details against your Internal 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 contract of each.
✅
Revocation is real and already works. Once a row is approved, the member can revoke it anytime from Account settings → Linked Accounts, cutting off messages/message delivery immediately (403).
External vs. Internal — how a link gets approved

The two surfaces now work in opposite directions:

External (invite token)Internal (internal_call_crm())
Who starts itYou — POST /api/link-invite.php for a customerThe staff member, via /link
StaqOn calls your webhook to verify?No — there is no verify callYes — {crm_id, email, username, trace_id}, you answer {"ok":true|false}
What proves identityThe single-use token you delivered to the customer, plus their logged-in StaqOn accountYour CRM matching the CRM ID + email
Result sent to youlink.status_changed with external_ref + crm_email (always delivered)link.status_changed if subscribed

See the Link API and Internal Link API sections above for each flow's full, literal request examples.

Rate limiting and caching — link-list.php / internal-link-list.php
⚠️
Neither exists today, honestly. Unlike the Messaging API (External/Internal — 100 requests/minute per key, genuinely enforced, see Rate limiting), link-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.
End-to-end sequence
#ExternalInternal
1Create a key in Workspace → Settings → API against a team of the matching type.
2GET /status.php — confirm the key works and see the team it is bound to.
3POST /add-member.php — create the partner member (skip if they already exist). n/a — staff are already members of the internal team.
4POST /message.php or /comment.php — write in. Works now, no link needed. n/a — nothing can be sent before consent.
5Member's staqon_links row reaches approved — your CRM requests an invite token (POST /api/link-invite.php), delivers it, and the member confirms on StaqOn — you receive link.status_changed (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).
6GET /messages.php?after=<last_id> — poll for replies. 403 before approval. POST /message.php — deliver notifications. 403 before approval.
7Subscribe to member.* / escalation.* webhooks (see Webhooks) to track activity — one webhook per workspace now, not per team.
8Member revokes anytime from Account settings → Linked Accounts — the only real revoke path today.
ℹ️
Why internal is stricter. Writing to a partner is your workspace acting on its own behalf. Notifying a colleague is automation aimed at a person, so it is consent-gated: they approve the link, and revoking it stops delivery immediately.

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

Returns the workspace, team, and current rate-limit context for your API key.
GET[base_url]/status.php
Sample request
curl -X GET \
  'https://staqon.one/api/v1/external/status.php' \
  -H 'X-API-Key: sk_live_xxx'
Sample response 200
{"ok":true,"workspace":"…","team":"…","team_type":"external","total_members":42,"active_members":40,"api_key_usage":120,"rate_limit":"100/minute","rate_remaining":99}
Error response
{"ok":false,"error":"key's team is not an active external team"}

List members

Paginated list of the members in your external team.
GET[base_url]/members.php
Query fields
FieldTypeDescription
page integer Page number.
limit integer Page size.
search string Filter by name/email.
Sample request
curl -X GET \
  'https://staqon.one/api/v1/external/members.php' \
  -H 'X-API-Key: sk_live_xxx'
Sample response 200
{"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}
ℹ️
\u26a0 This is where you get the username. 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 it too. add-member returns username immediately on create \u2014 no second call to resolve the handle.

Get member

Fetch a single member by email.
GET[base_url]/member.php
Query fields
FieldTypeDescription
email string Member email. *Provide email OR member_id.
member_id integer Numeric member id — takes precedence over email if both are sent.
Sample request
curl -X GET \
  'https://staqon.one/api/v1/external/member.php' \
  -H 'X-API-Key: sk_live_xxx'
Sample response 200
{"ok":true,"member":{"id":1,"name":"Rahul","email":"rahul@abc.com","username":"4821k9037","joined_at":"…","last_active":"…","messages_sent":12,"has_mpin":true}}
Error response
{"ok":false,"error":"Member not found in this team"}

Add member

Add a member to your external team.
POST[base_url]/add-member.php
Request fields
FieldTypeDescription
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.
Sample request
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"
}'
Sample response 200
{"ok":true,"member_id":1,"username":"4821k9037","conversation_id":45,"status":"created","login_url":"…"}
Error response
{"ok":false,"error":"name and email are required"}

Remove member

Remove a member from the team.
POST[base_url]/remove-member.php
Request fields
FieldTypeDescription
emailreq string Member email.
Sample request
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"
}'
Sample response 200
{"ok":true,"removed":true,"member_id":1}
Error response
{"ok":false,"error":"Member is not in this external team"}

Messaging

Send message

Send a message into a member's thread — attributed to your workspace (Direction In).
POST[base_url]/message.php
Request fields
FieldTypeDescription
emailreq string Member email.
messagereq string Message text — max 60000 chars.
Sample request
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!"
}'
Sample response 200
{"ok":true,"message_id":123,"conversation_id":45}
Error response
{"ok":false,"error":"message is too long (max 60000 chars)"}
ℹ️
Length limit. message is capped at 60000 characters. Over-limit requests are rejected with 400 'message is too long (max 60000 chars)' — never truncated.
ℹ️
Scheduled or recurring messages. StaqOn doesn't have a built-in scheduler, and you don't need one — just call this endpoint from your own system's cron job whenever you want the message sent. This is actually the better approach for reminders tied to real, changing conditions (e.g. "loan docs still pending") since your system always has the current truth; a StaqOn-side scheduler would just blindly repeat the same message regardless of whether the situation has changed.

Post comment

Post a comment by username, tagged source=api + your external_ref (Direction In).
POST[base_url]/comment.php
Request fields
FieldTypeDescription
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.
Sample request
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"
}'
Sample response 200
{"ok":true,"message_id":123,"conversation_id":45,"sent_at":"2026-01-01 12:00:00"}
Error response
{"ok":false,"error":"comment is too long (max 60000 chars)"}
ℹ️
Length limit. comment is capped at 60000 characters → 400 'comment is too long (max 60000 chars)'.
ℹ️
Card format and rate limiting. See the Card format and Rate limiting sections — both apply identically to this endpoint.
ℹ️
Scheduled or recurring messages. StaqOn doesn't have a built-in scheduler, and you don't need one — just call this endpoint from your own system's cron job whenever you want the message sent. This is actually the better approach for reminders tied to real, changing conditions (e.g. "loan docs still pending") since your system always has the current truth; a StaqOn-side scheduler would just blindly repeat the same message regardless of whether the situation has changed.

Read conversation

Read the chat thread (Direction Out). Link-gated — requires an approved account link (member consent).
GET[base_url]/messages.php
Query fields
FieldTypeDescription
usernamereq string Member username.
limit integer Page size.
before integer Older page cursor.
after integer Newer page cursor.
Sample request
curl -X GET \
  'https://staqon.one/api/v1/external/messages.php' \
  -H 'X-API-Key: sk_live_xxx'
Sample response 200
{"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}}
Error response
{"ok":false,"error":"No approved link for this member"}
ℹ️
Consent required. Returns 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.
ℹ️
Self-service, real and live. /link is the real, live, self-service mechanism: your CRM requests a single-use invite token for the customer (POST /api/link-invite.php) and delivers it, the customer confirms on StaqOn, and the row reaches approved — you receive link.status_changed. See the Link API section for the full contract.

Escalations

Raise escalation

Raise an escalation case against a member.
POST[base_url]/escalation.php
Request fields
FieldTypeDescription
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).
Sample request
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"
}'
Sample response 200
{"ok":true,"matter_id":15}
Error response
{"ok":false,"error":"due_date must be a future date (YYYY-MM-DD, at least tomorrow)"}

List / read escalations

Read back escalations you raised against your team's members (write path was one-way until now).
GET[base_url]/escalations.php
Query fields
FieldTypeDescription
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).
Sample request
curl -X GET \
  'https://staqon.one/api/v1/external/escalations.php' \
  -H 'X-API-Key: sk_live_xxx'
Sample response 200
{"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}
Error response
{"ok":false,"error":"Matter not found"}
ℹ️
Scope. Only matters whose target member is in your team. Not another team's member matters, nor workspace/team-scoped UI matters — only the member-scoped ones the API creates. Scoping is by target-in-team, not team_id (null on API matters).
ℹ️
Never returned. resolution_notes, created_by/resolved_by (staff identities), workspace_id, sequential id. Target member is target_username.
ℹ️
Single vs list. ?matter_id=<id> returns one; else a paginated list ordered by priority then recency.

e-StaqOn Verify

Start a Verification

Ask a member to confirm a sensitive action. StaqOn sends them a push and they approve or decline in the StaqOn app. The context text is shown verbatim on their approval screen, so write it for the member to read. Poll Check with the returned verification_id.
POST[base_url]/staqon-verify-start.php
Request fields
FieldTypeDescription
usernamereq string Member username (must be in your team).
actionreq string Short label for what is being approved (max 100 chars).
contextreq string What the member is approving, shown to them as written (max 500 chars).
ttl_seconds integer How long the request stays open. Default 30, clamped to 1–120.
Sample request
curl -X POST \
  'https://staqon.one/api/v1/external/staqon-verify-start.php' \
  -H 'X-API-Key: sk_live_xxx' \
  -H 'Content-Type: application/json' \
  -d '{
  "username": "member_username",
  "action": "bank_detail_change",
  "context": "Approve updating bank account details for ACME Corp?",
  "ttl_seconds": 30
}'
Sample response 200
{"ok":true,"verification_id":"9f1c2e4a-0b7d-4c3e-8a15-6d2b7e9f0a31","expires_at":"2026-10-14T09:46:13+05:30"}
Error response
{"ok":false,"error":"No approved link for this member"}
ℹ️
Consent required. Returns 403 'No approved link for this member' unless the member has an approved, unexpired external-team link (staqon_links, link_type='external') — the same consent as messages, plus an expiry check. The member must also be in your team (404 otherwise).
ℹ️
Rate limit. At most 5 verification requests per minute per member. The sixth returns 429 'Too many verification requests for this user. Try again in a minute.' This is on top of the per-key limit in Rate limiting.
ℹ️
Very short TTLs. Each request takes network time to reach StaqOn, so a ttl_seconds of only a second or two can run out before your first Check. Keep the default unless you have a reason not to.
ℹ️
What the member sees. A push (and an on-screen prompt in the StaqOn web app) titled “Verify it's you” showing your context verbatim, the name of your workspace (workspace_name) and a countdown to expires_at. These are member-side fields — your Start and Check responses do not include workspace_name. If the member answers on one device, their other devices close the prompt automatically. The approve/decline call and the member's open-requests list are app-session only — an API key cannot call them; read the outcome with Check.

Check a Verification

Check the outcome of a request from Start. Only verified:true means the member approved in time; anything else is a verified:false with a reason.
POST[base_url]/staqon-verify-check.php
Request fields
FieldTypeDescription
verification_idreq string The id returned by Start.
Sample request
curl -X POST \
  'https://staqon.one/api/v1/external/staqon-verify-check.php' \
  -H 'X-API-Key: sk_live_xxx' \
  -H 'Content-Type: application/json' \
  -d '{
  "verification_id": "9f1c2e4a-0b7d-4c3e-8a15-6d2b7e9f0a31"
}'
Sample response 200
{"ok":true,"verified":true}
Error response
{"ok":false,"error":"verification_id not found"}
ℹ️
The four outcomes. Approved: {"ok":true,"verified":true}. Otherwise verified:false with reason: "pending" (not answered yet — keep polling until the request expires), "declined" (the member chose No), or "timeout" (it expired unanswered).
ℹ️
Scope. A verification_id only resolves for the workspace whose key created it; any other id — unknown, or another workspace's — returns 404 'verification_id not found'.

Internal API

Your own staff. One-way. Writes need an approved link. Base: https://staqon.one/api/v1/internal

Context

Key context + usage

Confirm an internal key works before attempting a link request. Read-only.
GET[base_url]/status.php
Sample request
curl -X GET \
  'https://staqon.one/api/v1/internal/status.php' \
  -H 'X-API-Key: sk_live_xxx'
Sample response 200
{"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}
Error response
{"ok":false,"error":"Linked team is not an active internal team"}
ℹ️
Counts all members. Unlike the external surface there is no guest filter — every team member holding a workspace membership row is counted.

List members

Paginated list of the members in the key's internal team. Read-only, team-scoped by the key.
GET[base_url]/members.php
Query fields
FieldTypeDescription
page integer Page number.
limit integer Page size.
search string Filter by name, email or username.
Sample request
curl -X GET \
  'https://staqon.one/api/v1/internal/members.php' \
  -H 'X-API-Key: sk_live_xxx'
Sample response 200
{"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}
Error response
{"ok":false,"error":"Linked team is not an active internal team"}
ℹ️
Returns. id, name, email, username, role, status, joined_at per member. username is the handle message requires.
ℹ️
Two differences from the external list. (1) No guest filter \u2014 internal members hold member/admin/lead, so the external guest-only filter would return zero; this lists them all. (2) No last_message_at / messages_sent \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).
ℹ️
Scope. A key only ever sees its own team. An external key here returns 400.

Notifications

Send a notification

Deliver a one-way notification into the member's API Messages feed. Not a conversation — no reply, no reactions, no attachments.
POST[base_url]/message.php
Request fields
FieldTypeDescription
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.
Sample request
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"
}'
Sample response 200
{"ok":true,"id":123,"delivered_at":"2026-01-01 12:00:00"}
Error response
{"ok":false,"error":"Member has not approved API messages"}
ℹ️
Consent is required. This is the key difference from the external surface. External writes are key-only — your workspace's own authority. Here the member must have a real, active 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.
ℹ️
Self-service, real and live. /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.
ℹ️
Duplicate handling. Repeating an external_ref returns 409 carrying the original id and delivered_at rather than posting twice — safe to retry after a timeout.
ℹ️
Where it lands. The member sees these under an API Messages section in their sidebar, which appears only while their team has an active internal key.
ℹ️
⚠ Never self-notify. If the staff member whose own action triggered this call is also the person you're about to notify, you must filter them out before calling this endpoint — StaqOn does not detect or guard against this on the send side. Sending a notification about a staff member's own action back to that same staff member will be delivered exactly like any other.
ℹ️
username → StaqOn account resolution (worked example). StaqOn resolves 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 and rate limiting. See the Card format and Rate limiting sections — both apply identically to this endpoint.

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.

⚠️
Field names and hex codes are case-sensitive. Header_Color or a lowercase #1f7a54 will not match and are rejected as invalid — there is no case-normalization anywhere in validate_card().
The 10 allowed hex codes (header_color and button.color)
Hex (exact case)Name
#1F7A54brand green (default)
#059669success green
#D97706warning amber
#DC2626danger red
#2563EBinfo blue
#4B5563neutral grey
#7C3AEDsecurity
#0D9488account
#DB2777reassign
#0891B2update
Card top-level fields
FieldRequiredNotes
header_coloryesOne of the 10 hex codes above.
meta_titleno≤60 chars.
titleyes≤200 chars.
bodyyes≤2000 chars. Supports a small, fixed markdown subset — see below.
sub_bodyno≤2000 chars. Same markdown subset as body.
iconnoOne of the 10 icon names below.
buttonnoObject — see below.
The 10 allowed icon names (card.icon)
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.
Markdown subset supported in body / sub_body
SyntaxRenders as
**bold text**bold text
*italic text*italic text
- bullet lineConsecutive lines starting with - are grouped into a <ul><li> list. A line not starting with - ends the current list.
✅
Rendered on both web and Android. icon and the markdown subset above are rendered client-side by the StaqOn web client (renderCard() in assets/js/app.js) and, as of this writing, by the Android app too — the two clients now have matching card-rendering support, so every recipient sees the same real presentation (bold/italic/bullets rendered, the real icon glyph shown) regardless of which client they're on. Storage is unaffected either way — card_json is still saved as plain text exactly as sent; there is no server-side markdown-to-HTML conversion, each client renders it independently.
button object — exactly 3 allowed keys
FieldRequiredNotes
labelyes≤40 chars.
urlyes≤2048 chars. Must start with https:// — http:// is rejected, even though link_url elsewhere on this API accepts both.
colornoOne of the 10 hex codes above.
⚠️
No other key is accepted on 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.)
🚫
A malformed card rejects the whole request with 400 — never a silent drop or a plain-text-only fallback save. 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"}.
Examples — one per allowed color
{
  "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"
}
Card + link_url combined (Internal only — External's comment.php has no link_url field)
{
  "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

Fires once per tap on an API-based /help menu button, synchronously — a real external member is waiting. Timeout 3s. No retry — exactly one attempt per tap.
POST(your webhook URL)
Request body fields
FieldTypeDescription
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 The <code>external_ref</code> your CRM passed to <code>/api/link-invite.php</code> when this member's link was created (links created from 2026-10-15). <code>null</code> for links created earlier through the old self-service flow — treat <code>null</code> as "not set", not as an error.
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.
Request StaqOn sends you
{"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"}
Response StaqOn expects 200
{"ok":true,"answer":"Your loan application #LN-4521 is under review."}
ℹ️
⚠️ query_type validation — opt in for real, or fall back to a written checklist. By default, query_type is free text with zero validation — exactly as before. A typo between what the admin typed into the /help tree editor 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 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.
A 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.

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 valueButton name in /help treeHandled in CRM?
loan_status"Check my loan status"✅
office_hours"Office hours"✅
payment_due"When is my payment due?"⬜
ℹ️
Verify every request. HMAC-SHA256 of the raw request body, keyed by your chatbot secret, hex-encoded — sent as header 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;
}
ℹ️
Any failure looks identical to the member. A non-2xx status, malformed JSON, a missing/falsy 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.
ℹ️
Multi-line answers are fully supported. 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."}
Broken (literal backslash + "n" typed into the string, renders as visible \n text):
{"ok": true, "answer": "Your loan application #LN-4521 is under review.\\n\\nExpected decision: 3-5 business days."}
If you're seeing 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.
ℹ️
Failure tracking, one shared counter per team. 20 consecutive failures → the admin who set this up gets a warning email; the chatbot keeps trying. 100 consecutive failures → permanently disabled for that team — there is no re-enable action. Recovery means the admin generates a brand-new key + secret from the teams list, so your stored secret needs replacing, not just re-enabling. Any single success resets the counter to zero.
ℹ️
Rate limits are not currently enforced. A rate-limit field exists in the configuration (defaulting to 100/minute) but nothing in the outbound-call code reads or applies it today. Make sure your endpoint can handle real, unthrottled volume from your team's actual /help usage.
ℹ️
Optional: asking the member a question first. An 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. When configured, tapping the button sends the 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 member sees a validation error and gets one retry. Invalid again → StaqOn gives up and falls back to the main /help menu; your webhook is never called for that attempt. Valid → your webhook is called normally with the answer in 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.

🔒
Prerequisite — Internal Link, not Link. The staff member asking 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 key that gates the External Chatbot API. The Internal Chatbot API never creates this link — it only ever reads whether one already exists.
✅
Getting your credentials. A workspace admin/owner generates a webhook URL + API key + secret from Workspace → Settings (or the Internal Chatbot API admin page) → an internal team's card (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

Fires once per tap on an API-based Internal /help menu button, synchronously — a real internal staff member is waiting. Timeout 3s. No retry — exactly one attempt per tap. Structurally identical to the External Chatbot API's own outbound call — same function shape, same HMAC scheme, same timeout/threshold constants — the only difference is which table/team-type it reads from and which staqon_links row (link_type='internal') gates it.
POST(your webhook URL)
Request body fields
FieldTypeDescription
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.
Request StaqOn sends you
{"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"}
Response StaqOn expects 200
{"ok":true,"answer":"You have 6 days of paid leave remaining this year."}
ℹ️
⚠️ query_type validation — opt in for real, or fall back to a written checklist. By default, query_type is free text with zero validation — exactly as before. A typo between what the admin typed into the Internal /help tree editor 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 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.
A 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.

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.
ℹ️
Verify every request — identical HMAC scheme to External. HMAC-SHA256 of the raw request body, keyed by your Internal Chatbot secret (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;
}
ℹ️
Any failure looks identical to the staff member. A non-2xx status, malformed JSON, a missing/falsy 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.
ℹ️
Failure tracking, one shared counter per team — identical thresholds to External. 20 consecutive failures (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.
ℹ️
Optional: asking the staff member a question first. An 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). 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.
ℹ️
✅ Health-endpoint support, confirmed. 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.
ℹ️
Rate limits are not currently enforced. Same real gap as External — nothing in the outbound-call code applies a per-minute limit today. Make sure your endpoint can handle real, unthrottled volume from your team's actual Internal /help usage.

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:

StageConditionReal response
1 — Internal Link not configuredinternal_link_keys has no active row for this workspacejson_err: "This isn't set up for your workspace yet — your admin hasn't connected Internal Link."
2 — member not linkedno approved link_type='internal' row in staqon_links for this userReal, 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 teamLink passed, but no active internal_chatbot_keys row + root internal_chatbot_nodes for this teamjson_err: "This isn't set up for your team yet — your admin hasn't connected the Internal Chatbot API."
↔️
Stage 2 is genuinely different from External's Stage 2 — not a shortcut, a real necessity. External's /help Stage 2 fires a real popup (bot-input-request on private-user-{id}) because External's /link is itself a one-shot step (the customer enters the invite code, and the real linking happens on the confirm page). 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.

Keys — one active key per internal team

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.

The tree editor

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.

⚠️
Delete cascades, and the response tells you the real count first. Deleting a node removes its entire sub-tree (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.
The 7 real free-text validation types

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:

TypeRuleReal error message on failure
anyNon-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."
emailFILTER_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.
The 4 real follow_up values
ValueReal behavior (icb_resolve_next_menu())
show_more_optionsSends this node's real children as the next bot_menu (empty children → no next menu sent).
back_to_main_menuRe-sends the team's real root nodes as "How can we help?"
end_conversationNo further menu is sent — the conversation ends here.
escalateVestigial — 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.

Webhooks

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.
ℹ️
Verify every request. HMAC-SHA256 of the raw request body, keyed by your webhook secret, hex-encoded — sent as header 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.
⚠️
No retry — plan for delivery loss. StaqOn makes exactly one delivery attempt per event (5s timeout, 3s connect timeout). On any non-2xx response or connection failure, the event is not retried, queued, or re-sent — it is dropped on your side for good. A shared per-workspace 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.
⚠️
No proactive deactivation notice, honestly. When the 10-failure auto-disable above fires, nothing is emailed or pushed to you or to the workspace admin — this is different from the External Chatbot API, which does send a real warning email at 20 consecutive failures (see the External Chatbot API section). Link and Internal Link have no equivalent warning step today; the health endpoint's polling is genuinely the only way to detect it.
ℹ️
Ordering and idempotency — no formal guarantee on either. Events are fired synchronously, inline, immediately after the triggering write, in the same request — so events from one request are delivered in the order they occur, but there's no documented guarantee across concurrent requests (e.g. two members linking at the same moment), and no event/delivery ID in the payload for you to deduplicate against. In practice a given real event fires at most once per underlying write, since there's no retry/requeue to double-send it — but if you need a hard ordering or dedup guarantee, build it defensively on your side (e.g. keying off user_id + status + your own received-at timestamp).
Verifying the signature — copy-paste
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)
Envelope (all events)
{
  "event": "<name>",
  "timestamp": "<ISO-8601>",
  "data": { }
}
Events
EventFires whendata
member.joinedexternal member self-joins a teammember_id, name, team_id
member.messagea 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.removeda member is removed from the teammember_id, email, team_id
member.lefta member leaves the team on their ownmember_id, name, team_id
escalation.createdan escalation case is raisedmatter_id, title, priority, status:"open", scope, target_member_id, target_email, due_date, team_id
escalation.resolvedan escalation case is resolvedmatter_id, status:"resolved" &mdash; 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.

Request body
{
  "surface": "external",
  "key_id": 123,
  "timestamp": "2026-08-30T12:00:00Z"
}
🔐
Auth is HMAC, not a bearer token. Sign the raw request body and send it as 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.
Response — External / Internal
{
  "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
}
Response — External Chatbot
{
  "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
}
⚠️
Honest limitation — External/Internal need an active webhook. These two surfaces prove ownership using your team's webhook secret (webhooks.secret), not a secret on the api_keys row itself — api_keys has no HMAC secret of its own; it authenticates inbound bearer-key calls a different way entirely. A team with an active 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.
✅
The Internal Chatbot API IS wired into this endpoint (2026-09-05). 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.

⏱️
The grace window is a live check, not a lagging cron. previous_expires_at is compared against NOW() fresh on every single request — expiry is exact to the second.
↔️
Directional note. The grace window only matters where StaqOn verifies an inbound signature/key — this health endpoint, and bearer-key auth on External/Internal. It does not apply to any of StaqOn's own outbound signing — chatbot_call_crm(), trigger_webhook(), or Internal Link's internal_call_crm() — all three 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.

Two signature formats — old bare-hex, new timestamped
FormatShapeNotes
Old (bare hex)64 lowercase hex charshash_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.
ℹ️
Both formats are unambiguous by construction (the new format always begins with the literal 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 itAny non-empty value up to 100 chars, matching ^[A-Za-z0-9_.:-]+$ — echoed back verbatim, logged against this event.
If you don'tStaqOn generates a fresh UUID v4 and echoes that instead.
↔️
This convention is one-directional. StaqOn only ever expects and reads a trace ID on responses we send back to you — health checks, pull endpoints (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.
Linking a logical transaction across two separate API calls — worked example

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.

External API collection Internal API collection External Chatbot API collection Internal Chatbot API collection API Explorer (Swagger) — test the API directly in your browser
A project by the Company