Mobile apps
Run face liveness, document and selfie capture inside your iOS, Android, React Native or Flutter app, and ship it to production.
Speedmap capture runs in your own app, not only from a link you send. The SDKs load the Speedmap hosted capture page in a WebView, give it the camera, and pass progress back to your code. The camera screens, liveness checks and uploads are the same ones the hosted link uses, so improvements reach your app without an app release.
| Platform | Package | Built on |
|---|---|---|
| React Native | @speedmap/capture-react-native | react-native-webview |
| Flutter | speedmap_capture | webview_flutter, permission_handler |
| iOS (Swift) | Copy-paste view controller below | WKWebView |
| Android (Kotlin) | Copy-paste activity below | WebView |
| Web | @speedmap/capture or a redirect to the hosted page | Browser |
1. Create a capture session on your server
Call
POST /v1/capture-sessionswith your secret key. Keep theid; send theurlto the app. Never put an API key in an app.curl -X POST "http://localhost:4000/v1/capture-sessions" \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "steps": [ "liveness", "document" ], "reason": "financialProducts", "brand": { "name": "Skaikru Lending", "color": "#0F766E" } }'2. Open the capture URL in the SDK
The response has
url, for examplehttps://speedmap-delta.vercel.app/capture/jw2tHht1.... Pass it to the SDK component, view controller or activity. For a liveness session (POST /v1/liveness/sessions) usecapture_url.3. Handle the result
The SDK reports
completewith the session id. Your server then reads the outcome withGET /v1/capture-sessions/{id}or thecapture_session.completedwebhook. The app never receives images, identity numbers or verification outcomes.
npm install @speedmap/capture-react-native react-native-webview
cd ios && pod install<key>NSCameraUsageDescription</key>
<string>We use the camera to confirm your identity with a short face check and a photo of your ID.</string>import { SafeAreaView } from "react-native";
import { SpeedmapCapture } from "@speedmap/capture-react-native";
export function VerifyScreen({ url, navigation }) {
// url: the capture URL your server got from POST /v1/capture-sessions
return (
<SafeAreaView style={{ flex: 1 }}>
<SpeedmapCapture
url={url}
locale="en"
theme="auto"
appearance={{ colors: { accent: "#0F766E" }, radius: 12 }}
onComplete={({ sessionId }) => {
// Tell your server; it fetches the result with its API key.
api.post("/kyc/complete", { sessionId });
navigation.replace("Submitted");
}}
onCancel={() => navigation.goBack()}
onError={(e) => {
// recoverable: the page already shows a retry button.
if (!e.recoverable) navigation.replace("VerificationProblem", { code: e.code });
}}
/>
</SafeAreaView>
);
}On Android the component asks for the camera permission before it loads the page. On iOS it sets allowsInlineMediaPlayback and grants the camera to the capture origin on iOS 15 and later. With Expo, use a development or EAS build, install the WebView with npx expo install react-native-webview, and add the permission strings to app.json; expo-camera is not needed.
useSpeedmapCapture() tracks the status (loading, ready, in_progress, completed, error, cancelled) if you prefer state to callbacks.
Flutter
Requires Flutter 3.19 or later, iOS 15 or later and Android 7 (API 24) or later. Add the same Info.plist and AndroidManifest entries as above, and enable the camera permission for permission_handler in your Podfile.
dependencies:
speedmap_capture: ^0.1.0import 'package:speedmap_capture/speedmap_capture.dart';
class CaptureScreen extends StatelessWidget {
const CaptureScreen({super.key, required this.url});
final String url; // from your server
@override
Widget build(BuildContext context) => Scaffold(
body: SafeArea(
child: SpeedmapCapture(
url: url,
locale: 'en',
theme: SpeedmapTheme.auto,
onComplete: (m) => Navigator.of(context).pop(m.sessionId),
onCancel: (_) => Navigator.of(context).pop(),
onError: (e) {
if (!e.recoverable) Navigator.of(context).pop();
},
),
),
);
}The widget grants the camera (never the microphone) only to the capture origin, and receives messages on the SpeedmapFlutter JavaScript channel.
iOS (Swift)
No package to install: add this view controller to your app. It needs iOS 14.5 or later (15 or later for the automatic camera grant) and NSCameraUsageDescription in Info.plist.
import AVFoundation
import UIKit
import WebKit
/// Presents the Speedmap capture page for one capture session.
final class SpeedmapCaptureViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler {
var onComplete: ((_ sessionId: String?) -> Void)?
var onCancel: (() -> Void)?
var onError: ((_ code: String, _ recoverable: Bool) -> Void)?
private let captureURL: URL
private var webView: WKWebView!
/// captureURL: the url your server got from POST /v1/capture-sessions.
init(captureURL: URL) {
var parts = URLComponents(url: captureURL, resolvingAgainstBaseURL: false)!
parts.queryItems = (parts.queryItems ?? []) + [URLQueryItem(name: "embed", value: "1")]
self.captureURL = parts.url!
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func loadView() {
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true // camera preview inside the page
config.mediaTypesRequiringUserActionForPlayback = []
config.userContentController.add(WeakMessageHandler(self), name: "speedmap")
webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = self
webView.uiDelegate = self
webView.scrollView.contentInsetAdjustmentBehavior = .never // the page applies safe-area insets itself
view = webView
}
override func viewDidLoad() {
super.viewDidLoad()
// Ask for the camera first so the system prompt appears over your app, not mid-flow.
AVCaptureDevice.requestAccess(for: .video) { _ in
DispatchQueue.main.async { self.webView.load(URLRequest(url: self.captureURL)) }
}
}
deinit { webView?.configuration.userContentController.removeScriptMessageHandler(forName: "speedmap") }
// iOS 15+: give the capture page the camera without a second, web-style prompt.
@available(iOS 15.0, *)
func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void) {
decisionHandler(origin.host == captureURL.host && type == .camera ? .grant : .deny)
}
// Stay on the capture origin; anything else (for example your success_url) is not followed.
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
let url = action.request.url
let allowed = url?.host == captureURL.host || url?.scheme == "about" || url?.scheme == "blob"
decisionHandler(allowed ? .allow : .cancel)
}
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
guard message.frameInfo.isMainFrame,
message.frameInfo.securityOrigin.host == captureURL.host,
let body = message.body as? String,
let msg = try? JSONDecoder().decode(SpeedmapMessage.self, from: Data(body.utf8)),
msg.source == "speedmap" else { return }
switch msg.type {
case "complete": onComplete?(msg.session_id)
case "cancel": onCancel?()
case "error": onError?(msg.code ?? "internal", msg.recoverable ?? false)
default: break // "ready" and "event": progress, e.g. msg.event == "step.complete"
}
}
}
struct SpeedmapMessage: Decodable {
let source: String
let version: Int
let type: String // ready | event | complete | error | cancel
let flow: String?
let session_id: String?
let status: String?
let steps: [String]?
let event: String?
let step: String?
let reference_id: String?
let code: String?
let recoverable: Bool?
}
/// WKUserContentController retains its handlers; this breaks the cycle.
private final class WeakMessageHandler: NSObject, WKScriptMessageHandler {
weak var target: WKScriptMessageHandler?
init(_ target: WKScriptMessageHandler) { self.target = target }
func userContentController(_ c: WKUserContentController, didReceive m: WKScriptMessage) {
target?.userContentController(c, didReceive: m)
}
}Android (Kotlin)
Add this activity to your app and declare it in AndroidManifest.xml together with the CAMERA and INTERNET permissions. It needs androidx.appcompat and androidx.activity.
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.webkit.*
import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import org.json.JSONObject
/** Shows the Speedmap capture page for one capture session. Start with EXTRA_URL = the session url. */
class SpeedmapCaptureActivity : AppCompatActivity() {
companion object {
const val EXTRA_URL = "speedmap_url"
const val RESULT_SESSION_ID = "session_id"
}
private lateinit var webView: WebView
private lateinit var captureUrl: Uri
private var pendingRequest: PermissionRequest? = null
private val cameraPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
pendingRequest?.let { if (granted) it.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) else it.deny() }
pendingRequest = null
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
captureUrl = Uri.parse(intent.getStringExtra(EXTRA_URL)).buildUpon()
.appendQueryParameter("embed", "1").build()
webView = WebView(this)
setContentView(webView)
// Edge-to-edge (Android 15+): keep the page clear of the status and navigation bars.
ViewCompat.setOnApplyWindowInsetsListener(webView) { v, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
v.setPadding(bars.left, bars.top, bars.right, bars.bottom)
WindowInsetsCompat.CONSUMED
}
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
mediaPlaybackRequiresUserGesture = false // camera preview starts without a tap
allowFileAccess = false
allowContentAccess = false
}
webView.addJavascriptInterface(Bridge(), "SpeedmapAndroid")
webView.webViewClient = object : WebViewClient() {
// Stay on the capture origin; anything else (for example your success_url) is not followed.
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) =
request.url.host != captureUrl.host
}
webView.webChromeClient = object : WebChromeClient() {
// getUserMedia from the page: camera only, capture origin only.
override fun onPermissionRequest(request: PermissionRequest) = runOnUiThread {
val camera = PermissionRequest.RESOURCE_VIDEO_CAPTURE
if (request.origin.host != captureUrl.host || !request.resources.contains(camera)) {
request.deny()
} else if (ContextCompat.checkSelfPermission(this@SpeedmapCaptureActivity, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
request.grant(arrayOf(camera))
} else {
pendingRequest = request
cameraPermission.launch(Manifest.permission.CAMERA)
}
}
}
onBackPressedDispatcher.addCallback(this) { setResult(RESULT_CANCELED); finish() }
webView.loadUrl(captureUrl.toString())
}
override fun onDestroy() {
webView.destroy()
super.onDestroy()
}
private inner class Bridge {
/** Called by the page on a background thread with one JSON message. */
@JavascriptInterface
fun postMessage(json: String) = runOnUiThread {
// Only trust messages while the capture origin is loaded.
if (Uri.parse(webView.url ?: "").host != captureUrl.host) return@runOnUiThread
val msg = runCatching { JSONObject(json) }.getOrNull() ?: return@runOnUiThread
if (msg.optString("source") != "speedmap") return@runOnUiThread
when (msg.optString("type")) {
"complete" -> {
setResult(RESULT_OK, Intent().putExtra(RESULT_SESSION_ID, msg.optString("session_id")))
finish()
}
"cancel" -> { setResult(RESULT_CANCELED); finish() }
"error" -> if (!msg.optBoolean("recoverable")) { setResult(RESULT_CANCELED); finish() }
else -> Unit // "ready" and "event": progress
}
}
}
}addJavascriptInterface exposes the bridge to whatever page the WebView shows, which is why the activity never leaves the capture origin and checks the loaded URL before trusting a message. On AndroidX WebKit you can use WebViewCompat.addWebMessageListener with the name SpeedmapAndroid and your capture origin instead; the page uses either.
Any other WebView
The SDKs above are thin wrappers. Any WebView works if it meets these requirements:
- JavaScript and DOM storage enabled, and the page served over https.
- Inline media playback allowed, with no user gesture required for playback.
- Camera access granted to the capture origin:
onPermissionRequeston Android (RESOURCE_VIDEO_CAPTURE),requestMediaCapturePermissionForon iOS 15+, plus the app-level camera permission. - Navigation kept on the capture origin.
- A bridge named
ReactNativeWebView,webkit.messageHandlers.speedmap,SpeedmapAndroidorSpeedmapFlutterwith apostMessage(string)method, if you want progress messages.
In-app browser tabs (SFSafariViewController, Chrome Custom Tabs) also work and need no camera setup, but have no message bridge. Set success_url on the session to a link that returns the person to your app, and confirm the result on your server.
Camera access in WebViews needs iOS 14.5 or later and an up-to-date Android System WebView or Chrome. Older devices see a clear message asking them to update or use another device.
URL parameters
The SDKs add these to the capture URL. You can add them yourself when you open it in a WebView or browser.
| Parameter | Values | Effect |
|---|---|---|
embed | 1 | Hides the web header and footer and lays the page out full-bleed for a WebView. |
lang | en, pcm, ha, yo, ig | Language. Defaults to the session locale, then the device language. |
theme | light, dark, auto | Colour scheme. Default: the device setting. |
appearance | URL-encoded JSON | White-label look for the capture screens (colours, radius, font). Invalid values are ignored. |
Message schema
Every bridge receives the same JSON string. Messages carry progress only: never images, names, identity numbers or outcomes.
sourcestringrequiredAlways
speedmap. Ignore anything else.versionnumberrequiredSchema version, currently
1.typestringrequiredready,event,complete,errororcancel.flowstringrequiredcapturefor capture-session links (/capture/{token}),verifyfor verification-session links (/verify/{token}).session_idstringThe session id (
cap_…orvs_…), once the session has loaded.statusstringSession status as the page last saw it, e.g.
created,completed,expired.stepsstring[]readyonly: the steps the person will be asked for.eventstringeventonly:step.start,step.complete,camera.start,camera.stop,retryorupload.complete.stepstringThe step concerned, e.g.
liveness,document,selfie.reference_idstringOn
step.completewhen available: the stored check,spm_…. Look it up with your API key.codestringerroronly. See the table below.recoverablebooleanerroronly.truewhen the page already offers the person a retry; keep the screen open.
{
"source": "speedmap",
"version": 1,
"type": "ready",
"flow": "capture",
"session_id": "cap_9d9d78ebe9f53966",
"status": "created",
"steps": [
"liveness",
"document"
]
}| code | Meaning | Recoverable |
|---|---|---|
invalid_link | The token in the URL does not match a session. | No |
session_expired | The session expired. Create a new one. | No |
load_failed | The session could not be loaded (network or server). | Yes |
camera_denied | The person or the system refused the camera. | Yes |
no_camera | No usable camera was found. | Yes |
unsupported_browser | The WebView cannot open the camera (old OS or WebView). | No |
retry_limit | Too many failed attempts at a step. | Yes |
attempts_exhausted | A verification-session step used all its attempts. | No |
upload_failed | A capture could not be uploaded. | Yes |
internal | Anything else. | Yes |
cancel means the person closed the camera or cancelled the session. complete is sent once, when every step has been received; it is a signal, not proof. Confirm the outcome on your server.
Security
- Create sessions on your server. The capture URL is a single-use credential for one person: do not log it, cache it or share it.
- Trust messages only from the capture origin, and only in the main frame.
- The hosted pages cannot be framed by other websites. On the web, redirect to the capture URL or use
@speedmap/capturein your page.
Testing
Sessions created with an sk_test_ key run in the sandbox: the page shows a test-mode banner and nothing is billed. To test on a device against a local server, use your computer's LAN address (the Android emulator reaches the host at 10.0.2.2); the SDKs accept http only for local and LAN addresses.
Questions? Email developers@speedmap.ng. This page is also available as Markdown.