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 | Issues 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 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 the endpoints listed below. Those 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"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" |
/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.
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.approved, the member can revoke it anytime from Account settings → Linked Accounts, cutting off
messages/message delivery immediately (403).The two surfaces now work in opposite directions:
| External (invite token) | Internal (internal_call_crm()) | |
|---|---|---|
| Who starts it | You — POST /api/link-invite.php for a customer | The staff member, via /link |
| StaqOn calls your webhook to verify? | No — there is no verify call | Yes — {crm_id, email, username, trace_id}, you answer {"ok":true|false} |
| What proves identity | The single-use token you delivered to the customer, plus their logged-in StaqOn account | Your CRM matching the CRM ID + email |
| Result sent to you | link.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.
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 — 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). |
| 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: 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
| 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.e-StaqOn Verify
Start a Verification
context text is shown verbatim on their approval screen, so write it for the member to read. Poll Check with the returned verification_id.| Field | Type | Description |
|---|---|---|
| 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. |
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 }'
{"ok":true,"verification_id":"9f1c2e4a-0b7d-4c3e-8a15-6d2b7e9f0a31","expires_at":"2026-10-14T09:46:13+05:30"}{"ok":false,"error":"No approved link for this member"}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).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.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.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
verified:true means the member approved in time; anything else is a verified:false with a reason.| Field | Type | Description |
|---|---|---|
| verification_idreq | string | The id returned by Start. |
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" }'
{"ok":true,"verified":true}{"ok":false,"error":"verification_id not found"}{"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).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
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 | 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. |
{"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 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.
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. Since 2026-10-15 an External member is linked with a single-use invite token that your CRM requests from StaqOn and delivers to its own customer — the customer then confirms on StaqOn. One Link API key per workspace carries the workspace's webhook URL, its shared secret and its event subscriptions, all in one place.
X-StaqOn-Event: link.verify call with email / question / answer is gone — if your receiver still handles it, it simply never fires now). Members who were already linked are untouched: they stay linked, nothing to re-verify. Only new External links use the flow below. Internal Link is unchanged — see the Internal Link API.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. There is nothing else to configure: no question to set.
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 approval event failing. Verify it yourself (e.g. a manual curl) before relying on it.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. (The one exception is the approval event of an invite you issued — see below: it is always delivered.)
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.
| # | Who | What happens |
|---|---|---|
| 1 | Your CRM | POST /api/link-invite.php with the customer's email and your own external_ref for them (HMAC-signed). StaqOn returns a single-use token, a 6-character short_code (an easy-to-type alternative to the token — for SMS, WhatsApp or reading out) and a ready-made confirm_url. All three are the same one invite. |
| 2 | Your CRM | Delivers the confirm_url or the short_code (or the token) to the customer through your own channel (your email, SMS, in-app message…). StaqOn never emails, texts or pushes anything to the customer in this flow — it only receives your request, returns the codes, and receives the customer's answer. |
| 3 | Customer | Either opens confirm_url and, once logged in to StaqOn, types the email address you gave in step 1 on the page that opens; or, inside StaqOn, uses "Link this workspace" and enters the code and that same email together in one popup. Either way — and whichever of the link, token or short code they use — only a matching email moves on: they then see your workspace name and their own account email, and tap Yes or No. |
| 4 | StaqOn | On Yes the link becomes approved and StaqOn POSTs link.status_changed to your webhook (below). |
POST /api/link-invite.phpA you call StaqOn endpoint, HMAC-signed exactly like link-list.php — no X-API-Key. Only External Link keys can request invites.
POST /api/link-invite.php
Content-Type: application/json
X-StaqOn-Signature: t=<unix_timestamp>,v1=<hex>
{
"key_id": 24,
"timestamp": "2026-10-15T06:32:44Z",
"email": "customer@example.com",
"external_ref": "CRM-10492"
}| Field | Required | Notes |
|---|---|---|
| key_id | yes | Your Link API key's numeric ID (shown on the Link admin page once configured). |
| timestamp | yes | ISO-8601. Must be within ±5 minutes of StaqOn's clock. |
| yes | The customer's email — a valid email address, stored as the invite's crm_email. The customer must type this address to confirm, whether they use the link, the token or the short code; it is compared case-insensitively (surrounding spaces ignored). It is never used to find or create anything, only checked against what you set here, and it is not sent anywhere. | |
| external_ref | yes | Your own identifier for this customer (≤255 characters). Stored on the link and echoed back in the approval webhook and the Chatbot payload, so you can map the approval to your record. |
Signature. Same as link-list.php: hash_hmac('sha256', "{unix_timestamp}.{raw_body}", your_link_secret), sent as X-StaqOn-Signature: t=<unix_timestamp>,v1=<hex>; the bare-hex form (hash_hmac('sha256', raw_body, secret), no t=) is also accepted. The body's timestamp (and the t= value, when used) must be within ±5 minutes. During the 15-minute grace window after a secret rotation, a request signed with your previous secret is still accepted.
{
"ok": true,
"token": "lnk_3f9c…(64 hex characters)",
"short_code": "K7M2QX",
"expires_in": 3600,
"confirm_url": "https://staqon.one/app/link-confirm.php?token=lnk_3f9c…"
}short_code is 6 characters from an unambiguous 32-character set (A–Z and 2–9 without I L O — never 0 1 O I L); it is held only while the invite is pending and is unique among pending invites. The token, the short code and confirm_url are one invite, not three secrets: same expiry, and using any one of them (Yes or No) burns them all. A token is single-use and valid for 1 hour (expires_in is in seconds). Requesting a new invite for the same external_ref supersedes any earlier pending one — only the latest token works. Requesting an invite changes nothing on StaqOn's side beyond recording the pending invite: no link is created, no webhook fires, nothing is sent to anyone.| Status | When | error |
|---|---|---|
| 400 | Body isn't JSON | "Malformed JSON body." |
| 400 | key_id, timestamp or the signature header missing | "key_id, timestamp and X-StaqOn-Signature are all required." |
| 400 | Invalid or over-long email | "Enter a valid email." |
| 400 | external_ref empty or over 255 characters | "external_ref is required (max 255 characters)." |
| 401 | Unknown or revoked key_id, bad signature, stale/future timestamp — all identical on purpose | "Invalid signature or unknown key." |
| 404 | Any method other than POST | "Not found." |
| 429 | Rate limit exceeded (below) | "Rate limit exceeded — max 30 requests per minute per key." + Retry-After: 60 |
| 500 | The invite could not be saved | "Could not create the invite." |
key_id). Counted in one-minute windows, and only for validly-signed requests — bad-signature traffic cannot use up your quota. On breach you get 429 with Retry-After: 60. If StaqOn's rate-limit store is briefly unavailable the limit is skipped rather than blocking you./app/link-confirm.phpAn HTML page in the StaqOn app, not a JSON endpoint, so it isn't in the OpenAPI spec. The customer must be logged in to StaqOn and be a guest member of your workspace (sub-users cannot link). It works in two steps, and the invite is named either by the link (?token=…) or by the short code (?code=ABC123): step 1 asks for the email address you gave link-invite.php — nothing about the invite is shown yet; step 2, only if the email matches, names your workspace and shows the customer's own StaqOn account email with Yes and No. Yes approves the link and burns the token and short code; No declines the invite and burns them. Nothing is sent to you when the customer declines or the invite expires.
429 beyond that). If your external_ref is already linked to a different StaqOn account, the customer sees "Couldn't link" (409) and nothing is changed — one customer maps to one account.Entering a code instead of a link. "Link this workspace" and typing /link (or the first /help for an unlinked member) in chat no longer ask for an email and an answer. They open one popup with two fields — Link code and Email — submitted together. The Link code box accepts the 6-character short code (any case, spaces or dashes ignored), the long token, or the whole confirm_url. On Submit the app checks the pair with invite_lookup (the same call described below); a wrong code, a wrong email, or a used or expired code all show the same message in the popup — "That code and email don't match" — and consume nothing. When the pair matches, the popup moves on to your workspace name, the member's own account email and Yes / No, which call invite_confirm; the member never leaves the page. Someone who opens a shared confirm_url directly, without the popup, still gets the page described above: email first, then Yes / No.
/app/link-confirm.php needs the browser's cookie session, but the StaqOn mobile app authenticates every call with Authorization: Bearer <token> and has no cookies — so it can't use that page. For that case the same confirm step is exposed as two JSON actions on /api/link.php. They run exactly the same logic as the web page (one shared implementation), so a token behaves identically on both surfaces. These are member-side calls made by the StaqOn app, not something your CRM calls — your CRM only ever calls link-invite.php. Documented for completeness and for anyone building a client.
Common to both actions: POST /api/link.php?action=…&w=<workspace_slug>, JSON body, Authorization: Bearer <the member's app session token>. The caller must be an active guest (not a sub-user) of the workspace. Every request must carry email — the address the business gave (the invite's crm_email) — and one identifier: token or short_code. If both are sent they must belong to the same invite, or the request fails generically. The app sends the raw token (lnk_…) or the 6-character code (case-insensitive): a pasted confirm_url is rejected, so the client extracts the token (or code) value first. (Web-cookie callers also need a CSRF token; Bearer callers don't.) Changed 2026-10-15: email is now mandatory on every call — a call without it, or with one that doesn't match, gets the same generic 400 as an unknown code.
invite_lookup — show the member what they are about to confirmPOST /api/link.php?action=invite_lookup&w=acme-corp
Authorization: Bearer <member app-session token>
Content-Type: application/json
{ "email": "customer@example.com",
"token": "lnk_3f9c…(64 hex characters)" } // or "short_code": "K7M2QX" instead of "token"
200 { "ok": true,
"business_name": "Acme Corp",
"account_email": "customer@example.com",
"external_ref": "CRM-10492" }account_email is the member's own StaqOn account email. crm_email is intentionally not returned (the web page hides it too). A lookup changes nothing: the invite stays pending until invite_confirm.
invite_confirm — Yes or NoPOST /api/link.php?action=invite_confirm&w=acme-corp
Authorization: Bearer <member app-session token>
Content-Type: application/json
{ "email": "customer@example.com",
"short_code": "K7M2QX", // or "token": "lnk_3f9c…"
"decision": "yes" } // or "no"
200 { "ok": true, "status": "approved" } // or "declined"- Yes — the token is burned (single-use),
staqon_linksis set toapprovedwith the invite'sexternal_refandcrm_email, and onelink.status_changedwebhook goes to your External Link key regardless of its event subscription (see the approval webhook). - No — the token is burned, no link row is created and no webhook is sent.
- Conflict — if your
external_refis already linked to a different StaqOn account:409{"ok":false,"error":"Couldn't link: this customer is already linked to a different StaqOn account. Please contact the business."}. The invite stays pending and nothing changes. - Replay — confirming a token or code that was already used gets the same generic
400as an unknown one, with or without the correct email; a client cannot tell "already used" from "never existed".
| Status | When | error |
|---|---|---|
| 400 | Unknown, malformed (including a pasted URL), already-used, superseded, expired or wrong-workspace token or short code; a missing or wrong email (right code with the wrong email, right email with the wrong code); a token and a short_code that belong to different invites; neither identifier sent; a caller who isn't a guest of the invite's workspace, or is a sub-user; a missing or invalid decision — all identical on purpose. An email mismatch is not a separate error code — expect this same 400, never a distinct "wrong email" response. | "Couldn't find that link code." |
| 401 | No token, or an expired or invalid session | "Authentication required." |
| 403 | The caller is an internal (non-guest) member of the w workspace | "This flow is for external members only." |
| 403 | The caller isn't a member of the w workspace at all | "You are not a member of this workspace." |
| 404 | No active workspace with that slug | "Workspace not found." |
| 409 | invite_confirm yes: external_ref already linked to another account | "Couldn't link: this customer is already linked to a different StaqOn account. Please contact the business." |
| 429 | Too many failed lookups (below) | "Too many attempts. Please wait a minute and try again." + Retry-After: 60 |
| 500 | The approval couldn't be saved | "Something went wrong. Please try again." |
| 503 | The rate-limit store is unavailable — these actions fail closed | "Please try again shortly." |
429 with Retry-After: 60, and a second user on the same IP is limited too.w slug you pass — exactly like the web page. A guest of several workspaces can confirm an invite for whichever workspace it belongs to; w only has to be a workspace where the caller is a guest.link.status_changedWhen the customer taps Yes, StaqOn POSTs to your Link webhook URL (the same URL and secret as your other events, one attempt, 5s timeout, no retry):
POST <your webhook_url>
Content-Type: application/json
X-StaqOn-Signature: <hex>
X-StaqOn-Event: link.status_changed
{
"event": "link.status_changed",
"timestamp": "2026-10-15T12:04:11+05:30",
"data": {
"user_id": 2,
"username": "4821k9037",
"link_type": "external",
"status": "approved",
"workspace_id": 1,
"team_id": 0,
"external_ref": "CRM-10492",
"crm_email": "customer@example.com"
}
}external_ref and crm_email are new on this payload — present for approvals that come from an invite, and they are exactly what you sent to link-invite.php, so you can match the approval to your customer record. username is StaqOn's own username for the member, which is what message.php / comment.php need. Other link.status_changed events (a member revoking their link) carry the original fields without them. The signature is a bare-hex HMAC-SHA256 of the raw body, keyed by your Link secret, as for every webhook.external_ref is now populated. Links created through the invite flow store your external_ref (also returned to your External Chatbot webhook). Links created before 2026-10-15 have external_ref: null — see the chatbot payload tables.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 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.
| 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.