Kindly Web View SDK, Authentication Reference
How an app tells the chat who its user is, on all four platforms. Companion to SKILL.md. The guide page is Authentication
The app gives start a provider. Whenever the chat needs credentials, the SDK calls it with the chat id and the provider returns short-lived credentials for the user signed in to the app. The Kindly platform verifies them in one of three ways, set per bot:
Strategy | What the provider returns | Verified by |
|---|---|---|
Kindly JWT | A JWT the app's backend signs | The public key held by Kindly |
Identity provider | A JWT from the identity provider, alone or with its access token. An access token alone works too | The identity provider's JWKS |
Introspection | An opaque bearer token, with its issuer | Introspection against the identity provider |
The first is one string, so a token provider is enough. The other two can need more than one value, which is what the credentials provider is for
When the SDK asks
- After the chat connects, for a new or a restored conversation
- When the app calls authenticate()
- When the current credentials are within 30 seconds of expiry
The chat id comes from the chat page and the SDK cannot verify it. Issue credentials only for the user signed in to the app, and never treat the chat id as proof of who the user is or what they may see. Credentials come from the app's backend or identity provider: never put a signing key or another long-lived secret in the app
Token provider
iOS, token provider
import KindlyWebView
KindlyWebViewSDK.start(botKey: "YOUR_BOT_KEY", market: "YOUR_MARKET", authTokenProvider: { chatId in
try await MyBackend.kindlyToken(chatId: chatId)
})The type is KindlyWebViewAuthTokenProvider, @Sendable (String) async throws -> String. Read shared state through an actor or another thread-safe type
Android, token provider
import ai.kindly.webview.KindlyAuthTokenProvider
import ai.kindly.webview.KindlyWebViewSDK
KindlyWebViewSDK.start(
application = this,
botKey = "YOUR_BOT_KEY",
market = "YOUR_MARKET",
authTokenProvider = KindlyAuthTokenProvider { request ->
myBackend.kindlyToken(request.chatId)
},
)fetchToken is a suspend function that the SDK calls from a background coroutine. request is an AuthTokenRequest with chatId and reason, AuthTokenRequest.Reason.INITIAL_CONNECT or AuthTokenRequest.Reason.EXPIRED
Flutter, token provider
await KindlyWebViewSDK.start(
botKey: 'YOUR_BOT_KEY',
market: 'YOUR_MARKET',
authTokenCallback: (String chatId) => myBackend.kindlyToken(chatId),
);The type is KindlyWebViewAuthTokenCallback, Future<String?> Function(String chatId). Return null to keep the chat anonymous
React Native, token provider
await KindlyWebViewSDK.start({
botKey: 'YOUR_BOT_KEY',
market: 'YOUR_MARKET',
authTokenCallback: async (chatId) => fetchKindlyToken(chatId),
});The callback returns a string, null or undefined, or a promise of one. null and undefined keep the chat anonymous
MyBackend, myBackend and fetchKindlyToken stand for the app's own call to its backend
Credentials provider
Use it when the identity provider issues more than one token, only an access token, or an opaque token whose expiry only the app knows
iOS, credentials provider
KindlyWebViewSDK.start(botKey: "YOUR_BOT_KEY", market: "YOUR_MARKET", authCredentialsProvider: { chatId in
let tokens = try await MyIdentityProvider.tokens()
return KindlyAuthCredentials(
idToken: tokens.idToken,
accessToken: tokens.accessToken,
issuer: "https://idp.example.com",
expiresAt: tokens.expiry
)
})start takes one provider, so this is a separate overload. expiresAt is a Date; KindlyAuthCredentials(idToken:accessToken:issuer:expiresAtUnixSeconds:) takes Unix seconds instead
Android, credentials provider
import ai.kindly.webview.KindlyAuthCredentials
import ai.kindly.webview.KindlyAuthCredentialsProvider
KindlyWebViewSDK.start(
application = this,
botKey = "YOUR_BOT_KEY",
market = "YOUR_MARKET",
authCredentialsProvider = KindlyAuthCredentialsProvider { request ->
val tokens = myIdentityProvider.tokens()
KindlyAuthCredentials(
idToken = tokens.idToken,
accessToken = tokens.accessToken,
issuer = "https://idp.example.com",
expiresAt = tokens.expiresAtUnixSeconds,
)
},
)The credentials provider wins when both providers are set. expiresAt is a Long of Unix seconds
Flutter, credentials provider
await KindlyWebViewSDK.start(
botKey: 'YOUR_BOT_KEY',
market: 'YOUR_MARKET',
authCredentialsCallback: (String chatId) async => KindlyWebViewAuthCredentials(
idToken: idToken,
accessToken: accessToken,
issuer: 'https://idp.example.com',
expiresAt: expiryInUnixSeconds,
),
);The credentials callback wins when both callbacks are given. expiresAt is an int of Unix seconds, and toString() never prints a token
React Native, credentials provider
await KindlyWebViewSDK.start({
botKey: 'YOUR_BOT_KEY',
market: 'YOUR_MARKET',
authCredentialsCallback: async (chatId) => ({
idToken,
accessToken,
issuer: 'https://idp.example.com',
expiresAt: expiryInUnixSeconds,
}),
});The value has the shape KindlyWebViewAuthCredentials. The credentials callback wins when both callbacks are given. expiresAt is a number of Unix seconds
Which token becomes the bearer
Credentials need at least one of idToken and accessToken:
idToken | accessToken | What the SDK sends |
|---|---|---|
Set | Not set | The idToken as the bearer |
Not set | Set | The accessToken as the bearer |
Set | Set, different | The idToken as the bearer and the accessToken as its companion token |
Set | Set, the same | The idToken as the bearer, without a companion |
Not set | Not set | Nothing. The chat stays anonymous, and Flutter and React Native log one warning |
An empty string counts as not set. The backend checks a companion token against the id token's at_hash and rejects a pair that does not match, so send the access token from the same token response as the id token. On iOS and Android, bearerToken, companionAccessToken and hasToken on KindlyAuthCredentials show what the SDK will send
Issuer and expiry
- Set issuer for opaque tokens and for bots with more than one identity provider. The backend cannot read the issuer out of a token it cannot decode
- Set expiresAt when the expiry cannot be read from the token, as with an opaque token. Credentials without a known expiry are never refreshed ahead of time
- An expiresAt above 1e12 is a millisecond value and is converted to seconds, on every platform
Rules the SDK applies
Rule | Value |
|---|---|
Native wait for a provider | 15 seconds, then the chat continues anonymously and a late answer is discarded |
Flutter and React Native wait for the callback | 12 seconds: KindlyWebViewSDK.authCallbackTimeout in Flutter, KindlyWebViewSDK.authCallbackTimeoutMs in React Native |
Calls per minute | At most six |
Calls at a time | One per chat id. Requests for the same chat id while one runs share its answer |
Token | Visible ASCII without spaces, at most 16 KiB. The same for the access token |
Issuer | Printable ASCII, at most 2,048 characters |
Thrown error, empty token, null | The chat continues anonymously |
Credentials that break the token or issuer rules leave the chat anonymous too
On Android, React Native pauses JavaScript timers while the chat screen is in front, so neither the package's own 12-second limit nor a setTimeout in the callback fires until the chat is hidden. The native 15 seconds apply there. fetch and promises keep working while the chat is shown
Signing in and out
iOS, sign in and out
try await KindlyWebViewSDK.authenticate() // after the user signs in
try await KindlyWebViewSDK.deauthenticate() // after the user signs outAndroid, sign in and out
lifecycleScope.launch {
KindlyWebViewSDK.authenticate().await() // after the user signs in
KindlyWebViewSDK.deauthenticate().await() // after the user signs out
}Flutter and React Native, sign in and out
await KindlyWebViewSDK.authenticate(); // after the user signs in
await KindlyWebViewSDK.deauthenticate(); // after the user signs outThe React Native calls are the same, and return promises
What they do:
- authenticate() makes a loaded chat authenticate again with the provider and keeps the conversation. Without a loaded page, the next page authenticates as it loads. It also lifts a deauthenticate()
- deauthenticate() makes the chat anonymous and keeps the conversation. The SDK refuses token requests, without calling the provider, until the next authenticate(), and new chat pages load without authentication
- While the chat page is still loading, both take effect once it has loaded, in call order. If that page fails to load, they fail with the chat-not-ready error
- authenticate() without a provider fails: KindlyWebViewError.noAuthProvider on iOS, KindlyWebViewException.NoAuthProvider on Android, nativeError in Flutter and NATIVE_ERROR in React Native
When a user signs out of the app, call endChat() as well. deauthenticate() keeps the conversation, and endChat() deletes it from the device
Changing the provider
- start with the same bot key and market replaces the provider and keeps the live chat. A call without a provider removes it, so pass the provider on every start call
- start with another bot key or market closes the chat and starts over with the provider given
- kill() forgets the provider. Pass it to the next start again
Flutter and React Native answer from one place:
- Flutter: the callbacks reach the Flutter engine whose Dart code last called start. An engine that never calls start, such as the background engine a messaging or work manager plugin creates, never receives them. After a hot restart, call start again. When the engine that called start goes away, the chat gets no token until another engine calls start
- React Native: the package answers from the JavaScript runtime that is running. After a reload, call start again, as the app's startup code normally does. Until then the chat gets no token
End-to-end pattern
- The app's backend exposes an endpoint that returns Kindly credentials for the signed-in user, never for an arbitrary chat id
- The app passes a provider that calls that endpoint to every start
- The provider throws, or returns null in Flutter and React Native, while nobody is signed in, so the chat runs anonymously
- After sign-in, the app calls authenticate()
- After sign-out, the app calls deauthenticate() and endChat()
Debugging
Turn on the debug logs (KindlyWebViewSDK.verboseLogging = true on iOS and Android, setVerboseLogging(true) in Flutter and React Native). Each token request then logs its outcome code, never the token: on iOS under the subsystem ai.kindly.webview, on Android under the Logcat tag KindlyWebView
Outcome code | Meaning |
|---|---|
no_provider | start got no provider |
suspended | deauthenticate() ran, and authenticate() has not since |
provider_threw | The provider threw |
provider_returned_empty | The provider returned an empty token, or credentials without a token |
provider_timeout | The provider took longer than 15 seconds |
invalid_credentials | A token or the issuer broke the rules above |
invalid_chat_id | The chat page asked for a chat id that is empty or longer than 128 characters |
rate_limited | More than six requests in a minute |
Symptom | Likely cause |
|---|---|
The chat stays anonymous and the log says no_provider | start got no provider, or a later start call left it out |
provider_timeout | The backend call takes too long, or in React Native on Android the callback waits on a setTimeout while the chat is shown |
The chat stays anonymous in Flutter after a hot restart, or in React Native after a reload | start has not run again in the new runtime |
The chat stays anonymous although the credentials callback answered, and Flutter or React Native warned once | The credentials carry neither idToken nor accessToken |
Authentication works with the id token alone and fails with the access token added | The two tokens do not come from the same token response |
An opaque token is never refreshed | Set expiresAt |
authenticate() fails with the no-provider error | start got no provider |
The chat stays anonymous after the user signs in | authenticate() was not called after sign-in |
Kindly's backend answers the credentials themselves. When the provider returns a token and the chat still is not authenticated, check the bot's authentication settings in the Kindly platform against the token's issuer, audience and signing key
Security notes
- Mint short-lived credentials on the backend. Never put a signing key or another long-lived secret in the app
- Scope every token to the user signed in to the app. The chat id the provider receives comes from the chat page, which can name any chat id
- The SDK never logs or stores a token. The chat page keeps its own session for chat.kindlycdn.com in the app's web view storage, per bot and market, until endChat() or kill() deletes it
- Script in the chat page can read the credentials the SDK hands the widget, as on any website that embeds the chat