Mobile Integration
Integrate AI agents into iOS, Android, Flutter, and React Native apps
Integrate Arcanflows AI agents into your iOS, Android, Flutter, or React Native app using the Embed REST API — no iframe or browser required.
Overview
The Arcanflows Embed API is a pure REST + SSE interface that lets any HTTP client send messages to a published agent and receive responses. It uses an Embed API Key for authentication, making it ideal for mobile apps where cookie/session-based auth is impractical.
What you can do:
- Send user messages and receive AI responses (sync or streaming)
- Maintain conversation history across app sessions
- Retrieve past conversations by session
- Fetch agent configuration (name, avatar, welcome message)
Prerequisites
- A published agent — create one in the Arcanflows console
- An Embed API Key — generate one from your agent's Embed Settings tab
- Your base URL — e.g.
https://dev6.arcanflows.comor your production domain
Important: When creating the API key, set Allowed Domains to empty or
*so requests from mobile apps (which have noOriginheader) are not blocked.
Authentication
Every request to the public chat endpoints must include your Embed API Key. Three methods are supported:
| Method | Header / Param | Example |
|---|---|---|
| Bearer token | Authorization: Bearer <key> | Authorization: Bearer sk_pub_abc123 |
| Custom header | X-API-Key: <key> | X-API-Key: sk_pub_abc123 |
| Query parameter | ?api_key=<key> | /chat?api_key=sk_pub_abc123 |
Security: Never hard-code the API key in client-side source that ships to app stores. Use your backend to proxy requests, or store the key in a secure enclave / Keychain / EncryptedSharedPreferences.
API Endpoints
Replace {agent_id} with your agent's UUID.
Send Message (Synchronous)
POST /api/v1/public/agents/{agent_id}/chat
Request Body:
json{ "session_id": "unique-session-id", "message": "Hello, I need help with my order", "conversation_id": "optional-uuid", "visitor_id": "optional-visitor-id", "visitor_name": "John Doe", "visitor_email": "[email protected]" }
| Field | Required | Description |
|---|---|---|
session_id | Yes | Unique session identifier. Persist this across app launches. |
message | Yes | The user's message text. |
conversation_id | No | UUID of an existing conversation. Omit to start a new one. |
visitor_id | No | Your internal user ID. |
visitor_name | No | Display name for analytics. |
visitor_email | No | Visitor email for analytics. |
Response (200):
json{ "conversation_id": "550e8400-e29b-41d4-a716-446655440000", "user_message": { "id": "msg-uuid-1", "role": "user", "content": "Hello, I need help with my order", "created_at": "2026-02-25T10:30:00Z" }, "assistant_reply": { "id": "msg-uuid-2", "role": "assistant", "content": "Hi! I'd be happy to help with your order. Could you share your order number?", "input_tokens": 45, "output_tokens": 120, "created_at": "2026-02-25T10:30:01Z" } }
Send Message (Streaming via SSE)
POST /api/v1/public/agents/{agent_id}/chat/stream
Same request body as the synchronous endpoint. The response is a Server-Sent Events stream:
data: {"type":"metadata","conversation_id":"uuid","sources":["doc1.pdf"]}
data: {"type":"chunk","delta":"Hi! ","thinking":null,"model":"gpt-4o"}
data: {"type":"chunk","delta":"I'd be happy to help.","thinking":null}
data: {"type":"done","content":"Hi! I'd be happy to help."}
| Event type | Description |
|---|---|
metadata | First event — contains conversation_id and RAG sources |
chunk | Token-by-token streaming. Append delta to your UI buffer. |
done | Final event with the complete content. |
error | Error event if something goes wrong. |
Get Conversations by Session
GET /api/v1/public/agents/{agent_id}/conversations?session_id={session_id}
Returns the conversation linked to the given session, including all messages.
Get Specific Conversation
GET /api/v1/public/agents/{agent_id}/conversations/{conversation_id}
Returns a single conversation with all its messages.
Get Agent Configuration
GET /api/v1/public/agents/{agent_id}/embed
No authentication required. Returns agent display info for your UI:
json{ "id": "agent-uuid", "name": "Customer Support Bot", "avatar_url": "https://example.com/avatar.png", "embed_settings": { "enabled": true, "theme": "light", "welcome_message": "Hello! How can I help you today?" } }
Swift (iOS)
Synchronous Chat
swiftimport Foundation struct ChatRequest: Codable { let sessionId: String let message: String let conversationId: String? enum CodingKeys: String, CodingKey { case sessionId = "session_id" case message case conversationId = "conversation_id" } } struct ChatMessage: Codable { let id: String let role: String let content: String } struct ChatResponse: Codable { let conversationId: String let assistantReply: ChatMessage enum CodingKeys: String, CodingKey { case conversationId = "conversation_id" case assistantReply = "assistant_reply" } } class ArcanflowsClient { let baseURL: String let agentId: String let apiKey: String init(baseURL: String, agentId: String, apiKey: String) { self.baseURL = baseURL self.agentId = agentId self.apiKey = apiKey } func sendMessage( sessionId: String, message: String, conversationId: String? = nil ) async throws -> ChatResponse { let url = URL(string: "\(baseURL)/api/v1/public/agents/\(agentId)/chat")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body = ChatRequest( sessionId: sessionId, message: message, conversationId: conversationId ) request.httpBody = try JSONEncoder().encode(body) let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { throw URLError(.badServerResponse) } return try JSONDecoder().decode(ChatResponse.self, from: data) } }
SSE Streaming (iOS)
swiftfunc streamMessage( sessionId: String, message: String, onChunk: @escaping (String) -> Void, onDone: @escaping (String) -> Void ) async throws { let url = URL(string: "\(baseURL)/api/v1/public/agents/\(agentId)/chat/stream")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body = ChatRequest(sessionId: sessionId, message: message, conversationId: nil) request.httpBody = try JSONEncoder().encode(body) let (bytes, _) = try await URLSession.shared.bytes(for: request) for try await line in bytes.lines { guard line.hasPrefix("data: ") else { continue } let jsonStr = String(line.dropFirst(6)) guard let data = jsonStr.data(using: .utf8), let event = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let type = event["type"] as? String else { continue } switch type { case "chunk": if let delta = event["delta"] as? String { onChunk(delta) } case "done": if let content = event["content"] as? String { onDone(content) } default: break } } }
Kotlin (Android)
Synchronous Chat
kotlinimport okhttp3.* import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import com.google.gson.Gson import com.google.gson.annotations.SerializedName data class ChatRequest( @SerializedName("session_id") val sessionId: String, val message: String, @SerializedName("conversation_id") val conversationId: String? = null ) data class ChatMessage( val id: String, val role: String, val content: String ) data class ChatResponse( @SerializedName("conversation_id") val conversationId: String, @SerializedName("assistant_reply") val assistantReply: ChatMessage ) class ArcanflowsClient( private val baseURL: String, private val agentId: String, private val apiKey: String ) { private val client = OkHttpClient() private val gson = Gson() private val json = "application/json".toMediaType() fun sendMessage( sessionId: String, message: String, conversationId: String? = null ): ChatResponse { val body = gson.toJson(ChatRequest(sessionId, message, conversationId)) .toRequestBody(json) val request = Request.Builder() .url("$baseURL/api/v1/public/agents/$agentId/chat") .post(body) .addHeader("Authorization", "Bearer $apiKey") .build() client.newCall(request).execute().use { response -> if (!response.isSuccessful) throw Exception("HTTP ${response.code}") return gson.fromJson(response.body?.string(), ChatResponse::class.java) } } }
SSE Streaming (Android)
kotlinfun streamMessage( sessionId: String, message: String, onChunk: (String) -> Unit, onDone: (String) -> Unit ) { val body = gson.toJson(ChatRequest(sessionId, message)) .toRequestBody(json) val request = Request.Builder() .url("$baseURL/api/v1/public/agents/$agentId/chat/stream") .post(body) .addHeader("Authorization", "Bearer $apiKey") .build() client.newCall(request).execute().use { response -> val source = response.body?.source() ?: return while (!source.exhausted()) { val line = source.readUtf8Line() ?: break if (!line.startsWith("data: ")) continue val event = gson.fromJson(line.removePrefix("data: "), Map::class.java) when (event["type"]) { "chunk" -> (event["delta"] as? String)?.let { onChunk(it) } "done" -> (event["content"] as? String)?.let { onDone(it) } } } } }
Flutter / Dart
Synchronous Chat
dartimport 'dart:convert'; import 'package:http/http.dart' as http; class ArcanflowsClient { final String baseURL; final String agentId; final String apiKey; ArcanflowsClient({ required this.baseURL, required this.agentId, required this.apiKey, }); Future<Map<String, dynamic>> sendMessage({ required String sessionId, required String message, String? conversationId, }) async { final uri = Uri.parse('$baseURL/api/v1/public/agents/$agentId/chat'); final response = await http.post( uri, headers: { 'Authorization': 'Bearer $apiKey', 'Content-Type': 'application/json', }, body: jsonEncode({ 'session_id': sessionId, 'message': message, if (conversationId != null) 'conversation_id': conversationId, }), ); if (response.statusCode != 200) { throw Exception('HTTP ${response.statusCode}: ${response.body}'); } return jsonDecode(response.body); } }
SSE Streaming (Flutter)
dartStream<String> streamMessage({ required String sessionId, required String message, }) async* { final request = http.Request( 'POST', Uri.parse('$baseURL/api/v1/public/agents/$agentId/chat/stream'), ); request.headers['Authorization'] = 'Bearer $apiKey'; request.headers['Content-Type'] = 'application/json'; request.body = jsonEncode({ 'session_id': sessionId, 'message': message, }); final response = await http.Client().send(request); await for (final chunk in response.stream.transform(utf8.decoder)) { for (final line in chunk.split('\n')) { if (!line.startsWith('data: ')) continue; final data = jsonDecode(line.substring(6)); if (data['type'] == 'chunk') { yield data['delta'] as String; } } } }
React Native
Synchronous Chat
javascriptconst BASE_URL = 'https://dev6.arcanflows.com'; const AGENT_ID = 'your-agent-id'; const API_KEY = 'sk_pub_your_key'; async function sendMessage(sessionId, message, conversationId = null) { const response = await fetch( \`\${BASE_URL}/api/v1/public/agents/\${AGENT_ID}/chat\`, { method: 'POST', headers: { 'Authorization': \`Bearer \${API_KEY}\`, 'Content-Type': 'application/json', }, body: JSON.stringify({ session_id: sessionId, message, ...(conversationId && { conversation_id: conversationId }), }), } ); if (!response.ok) { throw new Error(\`HTTP \${response.status}\`); } return response.json(); }
SSE Streaming (React Native)
javascriptasync function streamMessage(sessionId, message, onChunk, onDone) { const response = await fetch( \`\${BASE_URL}/api/v1/public/agents/\${AGENT_ID}/chat/stream\`, { method: 'POST', headers: { 'Authorization': \`Bearer \${API_KEY}\`, 'Content-Type': 'application/json', }, body: JSON.stringify({ session_id: sessionId, message }), } ); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (!line.startsWith('data: ')) continue; const event = JSON.parse(line.slice(6)); if (event.type === 'chunk') onChunk(event.delta); if (event.type === 'done') onDone(event.content); } } }
Session Management
Persist the session_id so returning users resume their conversation:
| Platform | Storage |
|---|---|
| iOS | UserDefaults or Keychain |
| Android | SharedPreferences or EncryptedSharedPreferences |
| Flutter | shared_preferences package |
| React Native | AsyncStorage |
Also persist the conversation_id from the first response so subsequent messages continue the same thread.
Error Handling
| Status | Meaning | Action |
|---|---|---|
401 | Missing API key | Add the key via header or query param |
403 | Invalid API key, or domain restriction | Check key is correct, active, and domains allow mobile |
429 | Rate limit exceeded | Back off and retry with exponential delay |
500 | Server-side error | Retry after a short delay |
Recommended retry strategy: wait 1s, 2s, 4s, then give up after 4 attempts.
Domain Restrictions
Mobile apps typically don't send an Origin header. When creating your API key, set Allowed Domains to empty (no restrictions) or * to explicitly allow all origins.
Next Steps
- Deployment - Other deployment options
- API Reference - Full API documentation
- Workflows - Integrate with automations