Guide · in-app chat
Put the same chat your customers use on your website inside your iOS or Android app. When they close the app with a reply still unread, your server is told — and sends the push to their phone.
1 · The shape of it
Your app shows the chat in a WebView, with your customer already signed in. Everything works as on your website: the AI Agent, buttons and lists, photos and voice notes, and a person from your team taking over in Live chat.
What an app needs and a website does not is a way to reach the customer when the app is closed. So when a signed-in customer has not seen a message for a few seconds, octwin.ai sends your server a signed alert, and your server sends the push through the Firebase or Apple setup it already has. We never hold your push keys or your customers’ device tokens.
| Who | Decides |
|---|---|
| The chat in your app | Whether the customer has seen a message. |
| octwin.ai | When a message has waited unseen long enough to alert. |
| Your server | How to reach the phone — and whether to at all. |
The same customer, one conversation. A customer who chats on your website and later in your app is one contact with one history, as long as your server signs them in with the same id on both.
2 · When an alert goes out
Every message starts a short timer. If the customer sees it, the timer stops. If they do not, your server hears about it.
10:00:00 Your team replies to customer user-123 → a 15-second timer starts
A) they are looking at the chat
10:00:02 the chat reports “seen” → the timer stops, no alert
B) the app is closed, in the background, or on another screen
10:00:15 nothing was reported → octwin.ai alerts your server → your server sends the push
Seen means the chat is open, the app is in front, and it has been on screen for a moment. The delay is yours to choose, from 5 to 300 seconds. Messages that arrive while an alert waits join it — the alert counts them and quotes the newest — and a customer gets at most one alert a minute.
Why not just check whether the chat is connected? Because a phone keeps a connection open after the app goes to the background, and sometimes keeps one that has quietly died. “Connected” does not mean “looking”, and a check built on it fails without anyone noticing: no push, a missed reply. This rule fails the other way — when in doubt, it alerts. The worst case is one push too many.
3 · For operators
Early access. In-app chat is part of the Pro and Enterprise plans and is in early access — ask us to turn it on for your workspace.
seen is the feature working), or failed.Choose “No message text” if your customers would not want the words of a message on their lock screen. The alert then says only that a message is waiting.
4 · For app developers
Open one address in a WebView. Your server gives you that address with your customer already signed in.
https://octwin.ai/c/app/YOUR_WIDGET_ID#from=FROM&identity=IDENTITY&identity_sig=SIGNATURE
Everything after # stays in the WebView: it is never sent to a server, so the
signature lands in no log. The page reads it and removes it from the address. Optional
extras: &name= for the customer’s display name, and
&active=0 to open the page while the chat is not yet on screen.
A WebView cannot always tell that your app went to the background, and a chat that believes it is on screen reports messages as seen — so no push goes out. Call:
window.octwin.setActive(false) // the app went to the background, or the customer left the chat screen
window.octwin.setActive(true) // the chat is on screen again
The page posts a JSON string to your app: {"source":"octwin","type":"ready"}
once the chat is loaded, and {"source":"octwin","type":"identity_refused"}
when a signature has expired. Then ask your server for a fresh one and hand it over
without a reload:
window.octwin.setIdentity({ from, identity, identity_sig })
Already have your own page? Load /widget.js on it and call
PlatformChat.mount(element, { tenantSlug, projectSlug, widgetId, from, identity,
identitySig }). It returns the same setActive, and an
onIdentityRefused option tells you when to re-sign.
5 · Per platform
Working samples for all four live in one public repository — github.com/Cequens/octwin-in-app-chat — with a sample server beside them.
| Platform | The bridge | Off screen | Sample |
|---|---|---|---|
| Android | WebViewCompat.addWebMessageListener, named OctwinBridge |
onPause — and call webView.onPause() too |
android/ |
| iOS | A WKScriptMessageHandler named OctwinBridge |
sceneWillResignActive — not later |
ios/ |
| React Native | react-native-webview’s onMessage |
AppState → background |
react-native/ |
| Flutter | A JavaScriptChannel named OctwinBridge |
didChangeAppLifecycleState → paused |
flutter/ |
setActive(true) when the chat shows again.onShowFileChooser on Android).RECORD_AUDIO and
onPermissionRequest on Android; NSMicrophoneUsageDescription on iOS).6 · For server developers
Sign a small JSON text with the widget’s identity secret and build the
page address. Sign the exact text you send, and encode each value with
encodeURIComponent — not URLSearchParams, which writes a space
as + and would break a phone number like +20….
const identity = JSON.stringify({ from: `user-${user.id}`, phone: user.phone, exp: now + 3600 })
const sig = crypto.createHmac('sha256', IDENTITY_SECRET).update(identity).digest('base64url')
const url = `https://octwin.ai/c/app/${WIDGET_ID}#from=${encodeURIComponent(`user-${user.id}`)}` +
`&identity=${encodeURIComponent(identity)}&identity_sig=${encodeURIComponent(sig)}`
Each alert is one POST in the Standard Webhooks format, which has official libraries in nine languages. Verify it with the alert secret over the raw body, answer quickly, then send the push.
import { Webhook } from 'standardwebhooks'
const wh = new Webhook(ALERT_SECRET)
app.post('/octwin/unread', express.raw({ type: 'application/json' }), (req, res) => {
const alert = wh.verify(req.body, req.headers) // throws when the signature is wrong
res.sendStatus(204) // answer first
sendPush(alert.data.visitor.from, alert.data.preview, alert.data.conversation_id)
})
{ "type": "message.unread",
"data": { "visitor": { "from": "user-123", "claims": { } },
"conversation_id": "…", "unread_count": 2,
"preview": "Your order has shipped", "author": "person" } }
webhook-id header. A retry carries the same id
and the same body.collapse_key on Android,
apns-collapse-id on iOS), so a second alert replaces the first notification.author is ai_agent, person (your team, in Live chat)
or platform. preview is null when the widget sends no
message text.7 · When it does not work
| What you see | Why | What to do |
|---|---|---|
| No push when the app is closed | The app never calls setActive(false), so the chat counts messages as seen. |
Wire the off-screen call for your platform (section 5). |
| Two pushes for one message | Your server handled a retry as a new alert. | Dedupe on webhook-id, and answer within 5 seconds. |
| The chat forgets who the customer is | The signature expired, or the identity secret was rotated. | On identity_refused, fetch a fresh signature and call setIdentity. |
| unread_alert_rejected on the widget | Your server answered 401 or 403 — usually the alert secret does not match. | Copy the alert secret again, then press Send test. |
| Send test fails | The address is wrong, is not public HTTPS, or the server answers slowly. | Read the answer under the button; it names the status or the error. |
Check it on a real phone before going live: put the app in the background, have your team reply in Live chat — one push should arrive. Tap it — the chat should open on that reply. With the chat open and in front — no push.
8 · Words used above
user-123. The alert names it, so your server knows whose phone to reach.message.unread POST your server receives when a message stays unseen.