Skip to content

Mobile apps

Run face liveness, document and selfie capture inside your iOS, Android, React Native or Flutter app, and ship it to production.

View as Markdown

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.

PlatformPackageBuilt on
React Native@speedmap/capture-react-nativereact-native-webview
Flutterspeedmap_capturewebview_flutter, permission_handler
iOS (Swift)Copy-paste view controller belowWKWebView
Android (Kotlin)Copy-paste activity belowWebView
Web@speedmap/capture or a redirect to the hosted pageBrowser
  1. 1. Create a capture session on your server

    Call POST /v1/capture-sessions with your secret key. Keep the id; send the url to 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. 2. Open the capture URL in the SDK

    The response has url, for example https://speedmap-delta.vercel.app/capture/jw2tHht1.... Pass it to the SDK component, view controller or activity. For a liveness session (POST /v1/liveness/sessions) use capture_url.

  3. 3. Handle the result

    The SDK reports complete with the session id. Your server then reads the outcome with GET /v1/capture-sessions/{id} or the capture_session.completed webhook. The app never receives images, identity numbers or verification outcomes.

React Native

Requires React Native 0.72 or later and react-native-webview 13.6 or later.

Install
npm install @speedmap/capture-react-native react-native-webview
cd ios && pod install
Camera permission
<key>NSCameraUsageDescription</key>
<string>We use the camera to confirm your identity with a short face check and a photo of your ID.</string>
VerifyScreen.tsx
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.0
capture_screen.dart
import '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: onPermissionRequest on Android (RESOURCE_VIDEO_CAPTURE), requestMediaCapturePermissionFor on iOS 15+, plus the app-level camera permission.
  • Navigation kept on the capture origin.
  • A bridge named ReactNativeWebView, webkit.messageHandlers.speedmap, SpeedmapAndroid or SpeedmapFlutter with a postMessage(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.

ParameterValuesEffect
embed1Hides the web header and footer and lays the page out full-bleed for a WebView.
langen, pcm, ha, yo, igLanguage. Defaults to the session locale, then the device language.
themelight, dark, autoColour scheme. Default: the device setting.
appearanceURL-encoded JSONWhite-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.

  • sourcestringrequired

    Always speedmap. Ignore anything else.

  • versionnumberrequired

    Schema version, currently 1.

  • typestringrequired

    ready, event, complete, error or cancel.

  • flowstringrequired

    capture for capture-session links (/capture/{token}), verify for verification-session links (/verify/{token}).

  • session_idstring

    The session id (cap_… or vs_…), once the session has loaded.

  • statusstring

    Session status as the page last saw it, e.g. created, completed, expired.

  • stepsstring[]

    ready only: the steps the person will be asked for.

  • eventstring

    event only: step.start, step.complete, camera.start, camera.stop, retry or upload.complete.

  • stepstring

    The step concerned, e.g. liveness, document, selfie.

  • reference_idstring

    On step.complete when available: the stored check, spm_…. Look it up with your API key.

  • codestring

    error only. See the table below.

  • recoverableboolean

    error only. true when the page already offers the person a retry; keep the screen open.

Examples
{
  "source": "speedmap",
  "version": 1,
  "type": "ready",
  "flow": "capture",
  "session_id": "cap_9d9d78ebe9f53966",
  "status": "created",
  "steps": [
    "liveness",
    "document"
  ]
}
codeMeaningRecoverable
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/capture in 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.