Put the AI chat inside your mobile app
https://api.agenticos.tech/api/v1/omnichat1. Overview
Every endpoint the app calls is public and authorised by the embed token alone — no JWT. Three values are involved, each with its own job:
| Value | Where to find it | Keep it in |
|---|---|---|
| Embed token | OmniChat → Channels → your web channel → embed code (the data-omnichat-token value) | The app (it is public) |
| Identity key | Same page → “Connect your site's member login” (optional) | Your server only |
| Webhook signing key | Same page → “Mobile app notifications on reply” (optional) | Your server only |
Before you start: what to ask the platform for
These values and settings live in the customer organisation's AgenticOS account; an organisation admin (owner or admin) provides them. Collect them before you start coding.
| Ask for | Used for | Where the admin finds it | Needed |
|---|---|---|---|
| A web channel with the bot set up | So the bot answers: the channel must be bound to a desk whose AI agent has auto-reply on and a knowledge base attached | OmniChat → Channels → Add channel → Website | Yes |
| Embed token | Sent with every call from the app | Web channel → embed code → the data-omnichat-token value | Yes |
| Identity key | Tying chats to member accounts (2.2) | Web channel → Connect your site's member login → Create key | If the app has logins |
| Webhook signing key | Verifying the push webhook (section 4) | Web channel → Mobile app notifications on reply → enter the URL the dev sent, then copy the key | If you want push |
| Messages-per-minute cap | Default 20 per token per IP — raise it if many users share one IP (office Wi-Fi, for example) | OmniChat settings → Web chat protection → Message rate limit | If needed |
| Pre-chat form: on or off | If on, the app needs a form screen (3.5) | Web channel → Require contact details before chatting | Good to know |
Hi — our app team is connecting the AgenticOS chat bot to our mobile app. Could you send:
1. The embed token of the web channel that has the bot set up (OmniChat → Channels → Website → embed code)
2. The identity key (web channel → Connect your site's member login → Create key) — via a password manager
3. Please enter this webhook URL: https://<our-server>/omnichat/reply and send back the signing key — via a password manager
4. Whether the pre-chat form is on, and please raise the message cap to __ per minute (if needed)Shortcut: use a WebView (no chat screen to build)
If you don't need a native chat screen yet, open AgenticOS's hosted chat in a WebView and get every web-chat feature at once (knowledge-base answers, suggestion chips, photos, the pre-chat form, product cards, payment links). Two ways:
| Option | What to load | Member identity | Best for |
|---|---|---|---|
| A · Full-page chat | https://app.agenticos.tech/chat/<embed token> | No — everyone is anonymous | Apps without logins, or the quickest start |
| B · Your HTML page + the inline widget | A tiny page your server renders with the user's user_id and user_hash | Yes — the chat follows the member across devices, and push works | Apps with logins (recommended) |
Option B: the page your server gives the WebView
Your server renders this per user, computing user_hash server-side (2.2); the app opens its URL in the WebView. The CSS makes the chat fill the screen (the inline widget is 480px tall by default).
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
html, body { margin: 0; height: 100%; }
#omnichat-chat { height: 100%; }
.ocw-inline { height: 100% !important; border: 0 !important; border-radius: 0 !important; }
</style>
</head>
<body>
<div id="omnichat-chat"></div>
<script src="https://app.agenticos.tech/widget.js"
data-omnichat-token="EMBED_TOKEN"
data-omnichat-inline="omnichat-chat"
data-omnichat-user-id="{{ user.id }}"
data-omnichat-user-hash="{{ user_hash }}"
data-omnichat-user-name="{{ user.name }}"
async></script>
</body>
</html>- When the user signs out of the app, call
omnichatEndSession()in the page (evaluateJavascript, for example) or clear the WebView's data before someone else signs in on the same device - Push only works with option B: the webhook carries user_id so your server can find the user's device. Option A has no identity to push to.
WebView settings the chat needs
| What | iOS (WKWebView) | Android (WebView) |
|---|---|---|
| JavaScript and localStorage (holds the chat session) | On by default; use WKWebsiteDataStore.default() so the session survives app restarts | javaScriptEnabled = true · domStorageEnabled = true |
| Sending photos and files | Built in; add NSPhotoLibraryUsageDescription and NSCameraUsageDescription to Info.plist | Implement onShowFileChooser in your WebChromeClient |
| Payment links, attachments and outside links (open as _blank) | Implement createWebViewWith in WKUIDelegate and open them with UIApplication.shared.open | In shouldOverrideUrlLoading, send hosts other than the chat to the external browser |
| Keyboard covering the input | Use the normal safe area | android:windowSoftInputMode="adjustResize" |
// iOS — open _blank links (payment, attachments) outside the chat
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
if let url = navigationAction.request.url { UIApplication.shared.open(url) }
return nil
}// Android
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, req: WebResourceRequest): Boolean {
val host = req.url.host ?: return false
if (host.endsWith("agenticos.tech")) return false // stay in the chat
startActivity(Intent(Intent.ACTION_VIEW, req.url)); return true
}
}
webView.webChromeClient = object : WebChromeClient() {
override fun onShowFileChooser(view: WebView, cb: ValueCallback<Array<Uri>>,
params: FileChooserParams): Boolean {
filePathCallback = cb
fileChooser.launch(params.createIntent()) // registerForActivityResult(StartActivityForResult)
return true
}
}
webView.loadUrl(chatPageUrl)2. Who the user is
2.1 Anonymous
Generate a session_id once per install and keep it in the Keychain or EncryptedSharedPreferences.
- Use a cryptographic random generator — the session_id is the only thing protecting an anonymous transcript.
- 8–128 characters, and never starting with
usr:(reserved — you'd get 400 reserved_session_id) - The website widget uses
wsx-followed by 32 hex characters, e.g.wsx-1ecb99647586f8100cb4b7ca25d51ac9 - Downside: reinstalling or changing phones starts a new conversation.
2.2 Tied to a member account (recommended)
Send user_id and user_hash as well. The conversation then belongs to the member, follows them across devices, and your team sees their real name.
user_hash = hex( HMAC-SHA256( key = identity key, message = user_id ) ) // lowercase hex, 64 chars- Compute it on your server only: the app asks your backend for user_hash after login. Never ship the identity key in the app.
- A wrong or missing user_hash, or a channel without an identity key, is not an error: the user is treated as anonymous and talks in the session_id thread.
- Keep sending session_id on every call.
const userHash = crypto.createHmac("sha256", process.env.OMNICHAT_IDENTITY_KEY)
.update(String(user.id)).digest("hex");user_hash = hmac.new(IDENTITY_KEY.encode(), str(user.id).encode(), hashlib.sha256).hexdigest()$userHash = hash_hmac('sha256', (string)$user->id, OMNICHAT_IDENTITY_KEY);3. Endpoints
Every endpoint also answers at /web/... in place of /webhooks/web/....
GET/webhooks/web/config
What the chat screen shows. Call it once when the screen opens, with ?token=<embed token>.
{
"name": "Website chat",
"avatarUrl": "https://…/avatar.png",
"style": { "color": "#6366f1", "color2": "#22d3ee", "gradient": true, "title": "…", "placeholder": "…" },
"prechat": null,
"chatUrl": "https://app.agenticos.tech/chat/<token>",
"suggestions": ["What can you do?", "Pricing", "Talk to our team"],
"followup": true
}| key | Use |
|---|---|
name avatarUrl | The bot's name and picture (avatarUrl may be null) |
suggestions | Suggested-question chips, up to 6. Tapping one sends it as a normal message. |
prechat | The pre-chat form, if enabled (see 3.5). null means none. |
style | The website widget's colours and copy — optional for an app. |
An unknown token returns 200 with {"name": null, "avatarUrl": null, "style": null}, not 401.
POST/webhooks/web
Send a message (JSON).
{
"token": "<embed token>",
"session_id": "wsx-1ecb99647586f8100cb4b7ca25d51ac9",
"text": "Machine 3 at the Lat Phrao branch is not working",
"user_id": "member-42",
"user_hash": "<64 hex>",
"user_name": "Somying Jaidee",
"user_phone": "0812345678",
"user_email": "[email protected]"
}| field | Required | Limits |
|---|---|---|
token | ✓ | 8–200 |
session_id | ✓ | 6–128 (8–128 recommended), not starting with usr: |
text | ✓ | 1–4000 characters |
visitor_name | ≤120, display name for an anonymous user | |
user_id user_hash | ≤100, ≤64 — see 2.2 | |
user_name user_email user_phone | ≤120, ≤200, ≤40 — used only with a valid user_hash |
| Status | body | Meaning |
|---|---|---|
| 200 | {"ok": true, "conversation_id": "<uuid>"} | Accepted. The bot answers within 1–10 s — poll for it. |
| 401 | {"ok": false} | Unknown token, or the channel was removed |
| 429 | {"ok": false, "error": "rate_limited"} | Too many messages (see 5) |
| 400 | {"ok": false, "error": "reserved_session_id"} | session_id starts with usr: |
| 409 | {"ok": false, "error": "prechat_required"} | The pre-chat form must be sent first (3.5) |
| 422 | {"detail": [...]} | A field is malformed or too long (FastAPI's standard error) |
GET/webhooks/web/poll
?token=…&session_id=…&after=<cursor>&user_id=…&user_hash=…
{
"messages": [
{
"direction": "out",
"sender_type": "ai",
"text": "Hi! How can I help?",
"created_at": "2026-09-30T08:15:02.418221+00:00",
"attachment_url": null,
"attachment_kind": null,
"attachment_name": null,
"card": null,
"checkout_url": null
}
],
"cursor": "2026-09-30T08:15:02.418221+00:00",
"prechat_done": true
}| Message key | Value |
|---|---|
direction | "in" from the user · "out" from the business |
sender_type | "customer" · "ai" (bot) · "human" (your team) |
text | The text; may carry light Markdown such as **bold** and 1. lists |
created_at | ISO-8601 with timezone |
attachment_url attachment_kind attachment_name | An attachment; kind is "image" or "file" |
card | A product card {"kind": "products", "items": [...]} |
checkout_url | A payment link found in the text, if any — render it as a button |
Cursor rules
- First call: leave after out to get the history, at most 50 messages per call, oldest first.
- Then send after = the latest cursor, URL-encoded (it contains +).
- No new messages: messages is empty and cursor echoes what you sent.
- Got exactly 50: poll again straight away with the new cursor until fewer come back.
- Messages have no id. To de-duplicate, key on
created_at|direction|text|attachment_url, as the website widget does. - The user's own message also comes back (direction "in"); drop it with the key above if you already showed it optimistically.
Suggested polling rhythm
- While the chat screen is open: every 4 s.
- For 20 s after the user sends: every 1 s, with the first poll ~600 ms after sending.
- App in the background: stop polling and rely on push (section 4).
- There is no typing state in the API; show your own indicator after sending, for up to 30 s or until the reply arrives.
POST/webhooks/web/upload
Send a photo or file as multipart/form-data.
| field | Required | Notes |
|---|---|---|
token session_id | ✓ | |
file | ✓ | Up to 20 MB, not empty, any type (image/* shows as a photo) |
text | Caption | |
user_id user_hash | Required when tied to a member |
Success: 200 {"ok": true, "conversation_id": "<uuid>", "attachment_url": "<url>"} · errors: 401, 429, 400 reserved_session_id, 400 empty file, 413 file too large, 409 prechat_required
POST/webhooks/web/prechat
The pre-chat form, when config.prechat is not null. Members with a valid user_hash skip it.
{ "token": "…", "session_id": "…", "name": "Somying", "phone": "0812345678", "email": "", "company": "", "consent": true }- config.prechat.fields says whether each field is off, optional or required.
- If config.prechat.consent_text is set, send consent: true.
- Phone needs at least 9 digits; email needs an @ and a domain.
- Returns: 200 {"ok": true, "conversation_id"} · 200 {"ok": true, "skipped": true} when the form is off · 422 {"ok": false, "error": "invalid", "field", "reason"}
3.6 Talking to a person
There is no separate endpoint: send a normal message such as “I'd like to talk to someone”, or make a Help button that sends it. The bot hands over to your team, who reply in the same conversation (sender_type "human").
4. Notify on reply (webhook → push)
When the app is closed it isn't polling. Set a webhook and AgenticOS calls your server on every reply from the bot or your team; your server sends the FCM/APNs push. AgenticOS never holds device tokens.
4.1 Set up
- OmniChat → Channels → your web channel → “Mobile app notifications on reply”: enter your server's URL (https, public host) and save. A signing key is created.
- Copy the signing key to your server.
- Press “Send a test event” — you should see your server answered 200.
4.2 What your server receives
| header | Value |
|---|---|
X-AgenticOS-Event | message.created (or test from the test button) |
X-AgenticOS-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
User-Agent | AgenticOS-Webhook/1.0 |
{
"event": "message.created",
"channel_id": "75538105-e14a-4f76-bb2d-dd1cc048b0bc",
"conversation_id": "183b01f7-fd8e-4951-a07f-d323fbf6cfac",
"user_id": "member-42",
"session_id": null,
"message": {
"id": "77072727-82a3-4497-ad6d-7582175cf8a4",
"sender": "bot",
"sender_name": "Assistant",
"preview": "The payback period depends on several things…",
"has_attachment": false,
"created_at": "2026-09-30T08:15:02.418221+00:00"
},
"sent_at": "2026-09-30T08:15:04.102118+00:00",
"visitor_active": false
}| key | Meaning |
|---|---|
user_id / session_id | Who it's for — exactly one is set. Use it to find the user's device tokens. |
message.sender | "bot" or "agent" (your team) |
message.preview | Up to 200 characters — use it as the notification body |
message.id | Use it to collapse duplicates |
visitor_active | true when the user polled in the last ~100 s — they probably have the app open, so you may skip the push |
- It is a signal, not the transcript — the app reads the full messages through poll.
- Several replies within ~1.5 s arrive as one event.
- Answer 2xx within 5 s. On a timeout or 5xx we retry twice (after 2 s and 8 s); a 4xx is not retried.
- Order isn't guaranteed, duplicates can happen, and redirects are not followed.
4.3 Verify the signature (required)
- Split t and v1 out of the header.
- Compute HMAC-SHA256(signing key, "<t>." + raw body) as hex, on the raw body before parsing JSON.
- Compare with v1 in constant time; answer 401 on mismatch.
- Reject a t older than 5 minutes (replay).
app.post("/omnichat/reply", express.raw({ type: "application/json" }), (req, res) => {
const sig = Object.fromEntries(String(req.get("X-AgenticOS-Signature") || "")
.split(",").map((p) => p.split("=", 2)));
const expected = crypto.createHmac("sha256", process.env.OMNICHAT_PUSH_KEY)
.update(`${sig.t}.`).update(req.body).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(sig.t)) < 300;
if (!fresh || !sig.v1 || sig.v1.length !== expected.length
|| !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig.v1)))
return res.sendStatus(401);
const evt = JSON.parse(req.body);
res.sendStatus(200); // answer first, then push
if (evt.event === "message.created" && !evt.visitor_active) {
sendPush(evt.user_id ?? evt.session_id, {
title: evt.message.sender_name || "New message",
body: evt.message.preview || "📎 Attachment",
collapseKey: evt.message.id,
data: { conversation_id: evt.conversation_id },
});
}
});@app.post("/omnichat/reply")
async def omnichat_reply(request: Request):
raw = await request.body()
parts = dict(p.split("=", 1) for p in request.headers.get("X-AgenticOS-Signature", "").split(","))
expected = hmac.new(PUSH_KEY.encode(), f"{parts.get('t')}.".encode() + raw, hashlib.sha256).hexdigest()
if abs(time.time() - int(parts.get("t", 0))) > 300 or not hmac.compare_digest(expected, parts.get("v1", "")):
raise HTTPException(401)
evt = json.loads(raw)
# … push to evt["user_id"] or evt["session_id"]
return {"ok": True}Register device tokens with your own server, keyed by user_id (or session_id for anonymous users).
5. Rate limit
| Bucket | endpoint | Limit |
|---|---|---|
| Messages | POST /webhooks/web /prechat | Set per organisation — default 20/min per token per IP |
| Uploads | /upload | Same limit, counted separately |
| poll | /poll | 180/min per token per IP |
| config | /config | None |
On 429, wait 2–5 s and try again.
6. Suggested app flow
- Open the chat screen: GET /config.
- If signed in: fetch user_hash from your backend.
- GET /poll without after to show the history; keep the cursor.
- If prechat is set and prechat_done is false: show the form, then POST /prechat.
- The user types: show it at once, POST /webhooks/web, then poll every 1 s for 20 s.
- Leaving the screen or backgrounding the app: stop polling.
- A push arrives: open the chat and GET /poll?after=<saved cursor>.
7. Troubleshooting
| Symptom | Usual cause |
|---|---|
| Messages send but no reply shows | Poll isn't sending the same user_id/user_hash, or the cursor isn't URL-encoded |
| The user shows as anonymous | user_hash is computed wrong, or no identity key has been created |
| 409 prechat_required | Send the pre-chat form, or tie the user to a member to skip it |
| No webhook arrives | The URL isn't https, resolves to a private IP, or your server doesn't answer 2xx — try “Send a test event” |
| Signature mismatch | The body was re-serialised instead of using the raw bytes, or the wrong key was used (it must be the signing key) |