Mobile Integration

Flow doesn't currently have mobile-specific SDKs, but with our hosted verification system, verifying users with an embedded WebView inside of your native application is extremely easy.

Hosted verifications are Cognito-hosted URLs that contain your full verification experience. These hosted verifications can be generated via API and then the URLs for these verifications can be presented by your mobile app. Via API, you’re able to send a request from your backend that looks approximately like this:

POST /flow_sessions?idempotent=true HTTP/1.1

{
  "shareable": true,
  "template_id": "flwtmp_11111111111111", // Replace with your template ID
  "user": {
    // Read this for more context: https://cognitohq.com/docs/flow/trying-your-flow#customer-references
    "customer_reference": "your-users-internal-database-id-here",

    // Optional, but useful for fraud signals: https://cognitohq.com/docs/flow/trying-your-flow#supplementing-email
    "email": "your.users.email@example.com"
  }
}

We’ve omitted the other required headers from that example for brevity. You can read about our authentication system on our docs.

One additional note about the query parameter:

You can optionally supply ?idempotent=true for this specific request. With idempotency enabled, we will respond with a 201 Created HTTP status code the first time you send us the associated (customer_reference, template_id) pair. After that, you can always send the same (customer_reference, template_id) pair again and we will not create a new session, instead returning a 200 OK status code with the previously created session resource in the body.

In general, Flow is designed so that everything can be configured and managed without code changes. Part of this includes not forcing you to persist the flwses_111... IDs we return, instead using your internal identifier. We provide this idempotency feature so that you can, for example, have your backend do POST /flow_sessions?idempotent=true every time a user enters your KYC flow on mobile, without having to worry about either doing a POST or GET depending on their past interactions.


The above request returns a full FlowSession resource that will include a shareable_url entry:

{
  "id": "flwses_42424242424242",
  "customer_reference": "your-users-internal-database-id-here",
  "shareable_url": "https://dashboard.cognitohq.com/verify/flwses_42424242424242?key=069d2f5f8f03c6e4c1f2287049037450",
  "template": {
    "id": "flwtmp_11111111111111",
    "version": 7
  }

  // Trimmed for brevity
  // Full docs on this response payload here: https://cognitohq.com/docs/reference#flow_get_flow_session
}

That shareable_url is unique to this session and should only be opened by the customer associated with the customer_reference and email you provided.

For your mobile application, you can then embed this shareable_url in a WebView and everything should just work from there. Once you have it configured via our dashboard, you’ll receive webhooks as the user completes each step of their session, ending with a webhook letting you know if they passed or failed the session.


The implementation within your app should be fairly straightforward, but one additional note just to help make sure your integration is secure:

Within your app, when your user clicks the UI element for verifying their identity, your application should either POST or GET to an internal API of yours without any payload in the body. Meaning that the customer_reference and email you provide from your backend to our API should not be controllable by the end user. This is important to the product’s security model, since we use the (customer_reference, template_id) pair’s uniqueness to enforce that a user can only make one verification attempt until you manually authorize a retry.


Camera access in iOS

Flow uses the device's camera to capture images for document verification and selfie verification. Make sure that NSCameraUsageDescription is defined in your app's plist.

The selfie check uses an HTML5 video element. Set allowsInlineMediaPlayback to prevent the video element from going full-screen, which obscures the on-screen instructions to the user.

  func makeUIView(context: Context) -> WKWebView  {
    let config = WKWebViewConfiguration()
    config.allowsInlineMediaPlayback = true
    return WKWebView(frame: .zero, configuration: config)
  }

Camera access in Android

Flow uses the device's camera to capture images for document verification and selfie verification. Make sure that the CAMERA permission is requested in your app's manifest.

The selfie check uses WebRTC. Because Flow is within a WebView, you must grant RESOURCE_VIDEO_CAPTURE permission for the WebChromeClient. There is no need to grant permission for RESOURCE_AUDIO_CAPTURE, as the selfie check is video only.

  override fun onPermissionRequest(request: PermissionRequest?) {
    request?.resources?.forEach { r ->
      if (r == PermissionRequest.RESOURCE_VIDEO_CAPTURE) {
        request.grant(arrayOf(r))
        return
      }
    }
    super.onPermissionRequest(request)
  }

Listening for Flow Events in iOS

If you are embedding hosted Flows inside of a webview, you can attach a WKUserContentController to a WKWebView to receive events. A bare bones implementation might look like this:

import SwiftUI
import WebKit

struct WebView : UIViewRepresentable {
  let request: URLRequest
  let contentController = ContentController()

  func makeUIView(context: Context) -> WKWebView  {
    let config = WKWebViewConfiguration()
    config.allowsInlineMediaPlayback = true
    return WKWebView(frame: .zero, configuration: config)
  }

  func updateUIView(_ uiView: WKWebView, context: Context) {
    uiView.configuration.userContentController.add(contentController, name: "cognito")
    uiView.load(request)
  }

  class ContentController: NSObject, WKScriptMessageHandler {
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
      print("Received a Flow event")
      print(message.body)
    }
  }
}

struct ContentView: View {
  @State var showSafariView = false
  let flowUrl: URL

  @ViewBuilder var body: some View {
    Button("Launch flow", action: { showSafariView = true }).sheet(isPresented: $showSafariView) {
      WebView(request: URLRequest(url: flowUrl))
    }
  }
}

Listening for Flow Events in Android

If you are embedding hosted Flows inside of a webview, you can use a WebMessagePort via createWebMessageChannel() to receive events. A bare bones implementation might look like this:

class ExampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_example)

        val webView: WebView = findViewById(R.id.webview)
        webView.loadUrl(flowUrl)

        webView.settings.javaScriptEnabled = true

        webView.webViewClient = object : WebViewClient() {
            override fun onPageFinished(view: WebView?, url: String?) {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                    initializeWebMessages(webView)
                }
            }
        }
    }

    @RequiresApi(Build.VERSION_CODES.M)
    private fun initializeWebMessages(webView: WebView) {
        val webMessageChannel = webView.createWebMessageChannel()
        val (receiver, sender) = webMessageChannel

        receiver.setWebMessageCallback(object : WebMessagePort.WebMessageCallback() {
            override fun onMessage(port: WebMessagePort, message: WebMessage?) {
                Log.i("success_example", "Received ${message!!.data}")
            }
        })

        webView.postWebMessage(WebMessage("subscribeToFlowWebMessagePort", arrayOf(sender)), Uri.EMPTY)
    }
}

Don't forget to close() the receiver port when you are done using it. A logical place to do that would be in the onDestroy() method of your activity. This will free up resources and prevent memory leaks. You cannot close the sender port because it was transferred to Javascript.


HTML input element in Android

Flow uses <input type="file" accept="image/jpeg" capture /> elements to capture images for things like document verification and selfie verification. Mobile browsers open the native camera app for those inputs, but Android's WebView does not. So when you are embedding hosted Flows inside of a WebView, your code needs to open the camera by overriding onShowFileChooser() and starting an activity with an image capture intent. You also need to return the captured image as a URI to the WebView. A bare bones implementation might look like this:

class ExampleActivity : AppCompatActivity() {
    val REQUEST_IMAGE_CAPTURE = 1

    var uploadMessage: ValueCallback<Array<Uri?>?>? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_example)

        val webView: WebView = findViewById(R.id.webview)
        webView.loadUrl(flowUrl)

        webView.settings.javaScriptEnabled = true

        webView.webChromeClient = object : WebChromeClient() {
            override fun onShowFileChooser(
                webView: WebView?,
                valueCallback: ValueCallback<Array<Uri?>?>,
                fileChooserParams: FileChooserParams?
            ): Boolean {
                if (uploadMessage != null) {
                    uploadMessage!!.onReceiveValue(null)
                    uploadMessage = null
                }
                uploadMessage = valueCallback

                val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
                try {
                    startActivityForResult(takePictureIntent, REQUEST_IMAGE_CAPTURE)
                } catch (e: ActivityNotFoundException) {
                    // display error to the user
                }
                return true
            }
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        if (requestCode == REQUEST_IMAGE_CAPTURE && resultCode == RESULT_OK) {
            if (uploadMessage != null && data != null) {
                val bitmapUri = getImageUri(this.applicationContext, data.extras?.get("data") as Bitmap)
                uploadMessage?.onReceiveValue(arrayOf(bitmapUri))
                uploadMessage = null
            }
        }
        super.onActivityResult(requestCode, resultCode, data)
    }

    private fun getImageUri(inContext: Context, image: Bitmap): Uri? {
        val bytes = ByteArrayOutputStream()
        image.compress(Bitmap.CompressFormat.JPEG, 100, bytes)
        val path = MediaStore.Images.Media.insertImage(inContext.getContentResolver(), image, "", null)
        return Uri.parse(path)
    }
}