Embed Inspections Into Your Native App
Embed Vision in your native app to capture verified photos and videos with minimal development.
Bring Vision’s inspection experience into your native app with a WebView. Your backend creates the inspection through the Vision API, and your app opens the returned inspection link so the user can capture photos and videos without leaving your app.
This works whether you use Objective-C or Swift for iOS, Java or Kotlin for Android, or a cross-platform framework like React Native. Vision provides the inspection experience, while your app controls when users enter it and what happens after they finish.
Inspections can include photos, videos, short-answer questions, and multiple-choice questions. Submitted photos, videos, and answers appear in the Vision Dashboard.
In this guide, you will:
- Create the inspection through the API.
- Add camera, location, and microphone permissions.
- Add a WebView to render Vision.
- Customize the inspection experience.
- Handle a finished inspection.
If you prefer to use an in-app browser, see Alternative: In-app browser for a simpler integration with different permission and interface tradeoffs.
1. Create the inspection through the API
When you create the inspection for an embedded app flow, set send_methods.email and send_methods.text to false so Truepic does not send notifications to the user. Your app is responsible for telling the user that an inspection is ready and getting them into the embedded experience.
A typical flow looks like this:
- From your backend, create the inspection through the Vision API with email and text messaging disabled.
- Store the returned inspection
idandinspection_linkin your backend, and pass the link to your mobile app. The finished callback includes this ID asinspection_id, allowing you to match it to the inspection you created. - Open that link inside the WebView in your app when the user is ready to begin the inspection.
Guide the user into the inspection
Your app can bring the user into the inspection in a few ways:
- Open the inspection as part of an existing flow in your app, such as filing a claim or submitting an application.
- Show an in-app message that the inspection is ready.
- Send a push notification or other communication that brings the user back into your app.
2. Add camera, location, and microphone permissions
Next, add camera, location, and microphone permissions to your native app. Vision needs these permissions to securely capture photos and videos during the inspection.
Example: iOS
For iOS, add the following to Info.plist:
<key>NSCameraUsageDescription</key>
<string>We use your camera to capture photos and videos within your virtual inspection.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We associate your location with the photos and videos you capture within your virtual inspection.</string>
<key>NSMicrophoneUsageDescription</key>
<string>We use your microphone to record audio with the videos you capture within your virtual inspection.</string>You can adjust these descriptions to fit your brand and style.
Example: Android
For Android, add the following to AndroidManifest.xml:
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>On Android 12 and later, users can choose approximate or precise location. Declare both ACCESS_COARSE_LOCATION and ACCESS_FINE_LOCATION, and request them together through your native permission API. Confirm that ACCESS_FINE_LOCATION was granted. Requesting fine location alone is ignored on some Android 12 releases. Declaring coarse location supports this permission flow, but Vision still requires precise location. See Android’s location permission guidance.
Example: React Native
Your cross-platform framework may allow you to add these permissions through a configuration file. For example, Expo for React Native supports them in app.json:
{
"expo": {
"ios": {
"infoPlist": {
"NSCameraUsageDescription": "We use your camera to capture photos and videos within your virtual inspection.",
"NSLocationWhenInUseUsageDescription": "We associate your location with the photos and videos you capture within your virtual inspection.",
"NSMicrophoneUsageDescription": "We use your microphone to record audio with the videos you capture within your virtual inspection."
}
},
"android": {
"permissions": [
"android.permission.CAMERA",
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS"
]
}
}
}Permission requests and delegation
Your native app can request camera, microphone, and location permissions before opening the WebView. It may already request these permissions for other features. Once the user grants access, your app can delegate that access to the WebView through the platform’s permission handlers.
If access has not already been granted, the user will be prompted when Vision requests the required permissions within the WebView, provided your integration handles those requests. The prompts depend on the platform and the permissions still needed; do not assume a single combined prompt. If the user has denied access and the system no longer allows another prompt, they must enable it in Settings. Precise location must be enabled.
On Android, your WebView integration handles camera and microphone requests through WebChromeClient.onPermissionRequest and location requests through WebChromeClient.onGeolocationPermissionsShowPrompt. Your app should request native location access before opening the WebView, following the Android guidance above, rather than relying on the WebView’s fallback request.
On iOS, native location permission alone does not eliminate the separate WebView location prompt, which may display the Vision web address. On iOS 27 or later, your app can delegate location access using Apple’s WKUIDelegate method for geolocation permission requests. Your WebView integration must implement this delegation; upgrading iOS alone does not enable it.
3. Add a WebView to render Vision
The next step is to add a WebView to your native app that renders the inspection link returned by the API. The implementation varies by platform, but each WebView needs the same input: the inspection URL.
Configure the WebView before loading the inspection. The native permission handlers below assume your app has already obtained the required native permissions described in section 2. Only grant access to the trusted inspection origin. The native examples assume you add the WebView to your screen’s layout.
Find the WebView for your platform
Most platforms have a WebView class, framework, or library. Here are some common options:
Example: iOS
Enable inline video playback when creating the WebView and assign your view controller as its UI and navigation delegate. Have the controller conform to WKUIDelegate and WKNavigationDelegate:
import WebKit
let configuration = WKWebViewConfiguration()
configuration.allowsInlineMediaPlayback = true
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.uiDelegate = self
webView.navigationDelegate = selfAdd the media permission handler to your controller. Store the host from your trusted inspection link as inspectionHost before loading it:
@available(iOS 15.0, *)
func webView(
_ webView: WKWebView,
requestMediaCapturePermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo,
type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void
) {
let isInspectionOrigin = origin.protocol == "https"
&& origin.host == inspectionHost
&& (origin.port == 0 || origin.port == 443)
decisionHandler(isInspectionOrigin ? .grant : .deny)
}To also delegate location on iOS 27 or later, add this method to the same controller. Earlier versions retain the WebView location prompt:
@available(iOS 27.0, *)
func webView(
_ webView: WKWebView,
requestGeolocationPermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo,
decisionHandler: @escaping (WKPermissionDecision) -> Void
) {
let isInspectionOrigin = origin.protocol == "https"
&& origin.host == inspectionHost
&& (origin.port == 0 || origin.port == 443)
decisionHandler(isInspectionOrigin ? .grant : .deny)
}After adding these handlers and the navigation handler shown in section 5, load the inspection:
webView.load(URLRequest(url: URL(string: "{{ Inspection link returned by the API }}")!))Example: Android
Create the WebView from your activity, enable JavaScript, storage, and geolocation, then attach a WebChromeClient. Store the HTTPS origin of your trusted inspection link, including any non-default port, as inspectionOrigin (for example, https://example.com). This example grants only resources for which your app already has native permission:
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import android.view.View
import android.webkit.GeolocationPermissions
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebView
import androidx.core.content.ContextCompat
val webView = WebView(this)
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.settings.setGeolocationEnabled(true)
webView.overScrollMode = View.OVER_SCROLL_NEVER
webView.webChromeClient = object : WebChromeClient() {
private fun hasPermission(permission: String): Boolean =
ContextCompat.checkSelfPermission(webView.context, permission) ==
PackageManager.PERMISSION_GRANTED
private fun isInspectionOrigin(origin: Uri): Boolean {
val expected = Uri.parse(inspectionOrigin)
val port = if (origin.port == -1) 443 else origin.port
val expectedPort = if (expected.port == -1) 443 else expected.port
return origin.scheme == "https" && origin.host == expected.host &&
port == expectedPort
}
override fun onPermissionRequest(request: PermissionRequest) {
if (!isInspectionOrigin(request.origin)) {
request.deny()
return
}
val allowed = request.resources.filter { resource ->
when (resource) {
PermissionRequest.RESOURCE_VIDEO_CAPTURE ->
hasPermission(Manifest.permission.CAMERA)
PermissionRequest.RESOURCE_AUDIO_CAPTURE ->
hasPermission(Manifest.permission.RECORD_AUDIO)
else -> false
}
}
if (allowed.isEmpty()) {
request.deny()
} else {
request.grant(allowed.toTypedArray())
}
}
override fun onGeolocationPermissionsShowPrompt(
origin: String,
callback: GeolocationPermissions.Callback
) {
val allowed = isInspectionOrigin(Uri.parse(origin)) &&
hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)
callback.invoke(origin, allowed, false)
}
}If your inspections allow uploading existing photos or videos, also implement onShowFileChooser. Android WebView cancels file requests by default, so the upload button will not open a picker without this handler.
Add these imports, then declare the callback and launcher as properties of your AndroidX ComponentActivity or AppCompatActivity:
import android.content.ActivityNotFoundException
import android.webkit.ValueCallback
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
private var filePathCallback: ValueCallback<Array<Uri>>? = null
private val fileChooser = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val callback = filePathCallback
filePathCallback = null
callback?.onReceiveValue(
WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data)
)
}Add this method to the same WebChromeClient as the permission handlers above. It returns the selected files to the WebView, or null if the user cancels or no compatible picker is available:
override fun onShowFileChooser(
webView: WebView,
callback: ValueCallback<Array<Uri>>,
params: WebChromeClient.FileChooserParams
): Boolean {
filePathCallback?.onReceiveValue(null)
filePathCallback = callback
try {
fileChooser.launch(params.createIntent())
} catch (_: ActivityNotFoundException) {
filePathCallback = null
callback.onReceiveValue(null)
Toast.makeText(webView.context, "No file picker is available.", Toast.LENGTH_SHORT).show()
}
return true
}Attach a WebViewClient to handle navigation and the finished callback, as shown in section 5, then load the inspection:
webView.loadUrl("{{ Inspection link returned by the API }}")Example: React Native
Pass the inspection link to the WebView and configure inline playback, location access, scrolling, and media permissions:
<ReactNativeWebView
source={{
uri: '{{ Inspection link returned by the API }}',
}}
// Allow inline video playback on iOS instead of full-screen playback.
allowsInlineMediaPlayback
// Enable location access on Android, where it is disabled by default.
geolocationEnabled={true}
// Prevent overscrolling on Android.
overScrollMode="never"
// On iOS, grant WebView camera and microphone requests.
// Native permissions are still required.
mediaCapturePermissionGrantType="grant"
/>4. Customize the inspection experience
Customize the inspection experience by adding a JSON configuration object to the inspection link’s vision-config query parameter. Use your platform’s URL utilities to encode the configuration while preserving the link’s existing query parameters and fragment.
Supply the intended configuration each time your app opens an inspection. Vision retains the URL configuration for the current WebView session so it survives navigation and reloads. A new vision-config object replaces the retained configuration.
Options
| Option | Type | Default | Description |
|---|---|---|---|
onboarding | String | "long" | Choose "long" or "short" onboarding. Missing or unrecognized values use "long". |
showMenu | Boolean | true | Show the header menu for support, terms and conditions, and other links. Set to false to hide it. |
styles | String or null | null | CSS to customize the inspection’s background and font. See Customize styles for supported variables and custom fonts. |
finishedCallbackURL | String or null | null | The URL to open when the inspection is finished. Vision appends inspection_id and _ query parameters. When null, Vision does not redirect to a callback URL. See Handle a finished inspection. |
Customize styles
Set styles to a CSS string in your vision-config object. Define the supported variables on :root:
| CSS variable | Default | Description |
|---|---|---|
--vision-color-background | #f0f2f5 / transparent | Background color for inspection lists, sheets, and question content. Lists and sheets default to #f0f2f5; question content defaults to transparent. Accepts a CSS color value. |
--vision-font-family | 'Inter Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif | Font-family stack for inspection text and the camera interface. Accepts a CSS font-family value, including fallback fonts. |
For example, set styles to this CSS to change the background and use a system font:
:root {
--vision-color-background: #f5f5f5;
--vision-font-family: Arial, sans-serif;
}To load a custom font, include an @font-face declaration in the same CSS string and reference its family name in --vision-font-family:
@font-face {
font-family: 'Roboto Slab Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/roboto-slab:vf@latest/latin-wght-normal.woff2) format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
:root {
--vision-color-background: #f5f5f5;
--vision-font-family: 'Roboto Slab Variable', serif;
}To use your own font, replace the family name, font URL, weight range, and character range to match your font file. Provide additional @font-face declarations for other weights, or use a variable font with its supported weight range. Your font host must allow the font to load from the inspection page’s origin. Include fallback fonts for characters your custom font does not cover.
Assign the CSS as the styles string before serializing your configuration to JSON. The examples below show this assignment in Swift, Kotlin, and JavaScript.
Example: iOS
Build the query parameter with URLComponents and JSONSerialization. Run it in a throwing function or handle serialization errors with do/catch:
import Foundation
let visionConfig: [String: Any] = [
"finishedCallbackURL": "yourapp://inspection-finished",
"onboarding": "short",
"showMenu": false,
"styles": """
:root {
--vision-color-background: #f5f5f5;
--vision-font-family: Arial, sans-serif;
}
"""
]
let configData = try JSONSerialization.data(withJSONObject: visionConfig)
let configJSON = String(decoding: configData, as: UTF8.self)
var components = URLComponents(string: "{{ Inspection link returned by the API }}")!
var queryItems = (components.queryItems ?? []).filter { $0.name != "vision-config" }
queryItems.append(URLQueryItem(name: "vision-config", value: configJSON))
components.queryItems = queryItems
components.percentEncodedQuery = components.percentEncodedQuery?
.replacingOccurrences(of: "+", with: "%2B")
let url = components.url!
webView.load(URLRequest(url: url))Example: Android
Use Uri.Builder and JSONObject. Preserve existing parameters and replace any previous vision-config value:
import android.net.Uri
import org.json.JSONObject
val visionConfig = JSONObject()
.put("finishedCallbackURL", "yourapp://inspection-finished")
.put("onboarding", "short")
.put("showMenu", false)
.put("styles", """
:root {
--vision-color-background: #f5f5f5;
--vision-font-family: Arial, sans-serif;
}
""".trimIndent())
val original = Uri.parse("{{ Inspection link returned by the API }}")
val builder = original.buildUpon().clearQuery()
for (name in original.queryParameterNames) {
if (name != "vision-config") {
for (value in original.getQueryParameters(name)) {
builder.appendQueryParameter(name, value)
}
}
}
val url = builder
.appendQueryParameter("vision-config", visionConfig.toString())
.build()
webView.loadUrl(url.toString())Example: React Native
Build the configuration and URL in JavaScript:
const visionConfig = {
finishedCallbackURL: 'yourapp://inspection-finished',
onboarding: 'short',
showMenu: false,
styles: `
:root {
--vision-color-background: #f5f5f5;
--vision-font-family: Arial, sans-serif;
}
`,
}
const url = new URL('{{ Inspection link returned by the API }}')
url.searchParams.set('vision-config', JSON.stringify(visionConfig))In the WebView from section 3, replace source.uri with url.toString().
5. Handle a finished inspection
Set finishedCallbackURL to a URL your native app handles, replacing yourapp://inspection-finished with your own callback URL.
When the inspection is finished, Vision navigates to this URL with two query parameters:
inspection_id: The ID of the finished inspection._: A timestamp in milliseconds that makes each callback URL distinct.
For example:
yourapp://inspection-finished?inspection_id=12345&_=1790870400000If you need inspection details, retrieve them through your backend using the inspection ID.
The callback can also occur when an already finished inspection loads. Make your handler safe to run more than once.
Example: iOS
A native WKWebView does not automatically pass the callback to the operating system. Intercept the navigation, cancel it, and handle the callback URL directly in your WKNavigationDelegate:
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
guard let url = navigationAction.request.url,
url.scheme == "yourapp",
url.host == "inspection-finished" else {
decisionHandler(.allow)
return
}
decisionHandler(.cancel)
let inspectionID = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.first { $0.name == "inspection_id" }?.value
// Use inspectionID to continue the flow in your native app.
}Example: Android
A native Android WebView does not automatically pass the callback to the operating system. Intercept the navigation and handle the callback URL directly, returning true to prevent the WebView from loading it. Add this method to a WebViewClient and assign that client to webView.webViewClient:
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url
if (url.scheme != "yourapp" || url.host != "inspection-finished") {
return false
}
val inspectionID = url.getQueryParameter("inspection_id")
// Use inspectionID to continue the flow in your native app.
return true
}Example: React Native
React Native WebView passes URLs outside its originWhitelist (by default, anything other than http and https) to the operating system, so the callback reaches your app’s incoming-link handler. Register the callback scheme with your app and read inspection_id in that handler:
function handleURL(incomingURL) {
const url = new URL(incomingURL)
if (url.protocol !== 'yourapp:' || url.host !== 'inspection-finished') {
return
}
const inspectionID = url.searchParams.get('inspection_id')
// Use inspectionID to continue the flow in your native app.
}6. Alternative: In-app browser
You can open the inspection in an in-app browser instead of embedding a WebView. This presents a browser screen over your app, using Safari’s SFSafariViewController on iOS or a browser Custom Tab on Android.
This approach requires less native integration: the browser handles camera, microphone, and location permissions rather than delegating your app’s permissions to a WebView. You do not need to add the native permissions from section 2 for this integration, though your app may need them for other features.
Users may still see prompts to allow the browser or the Vision page to access their camera, microphone, and location. These permissions belong to the browser experience, not your native app, so access already granted to your app does not automatically carry over.
The tradeoff is visible browser controls and the inspection’s web address, with less control over the surrounding interface. You can still customize Vision through vision-config and return to your app through finishedCallbackURL.
Use the configured url from section 4 with your platform’s browser API instead of loading it in a WebView. Register the callback URL scheme and handle incoming links in your app; the WebView navigation delegates above do not apply to an in-app browser.
Closing the browser manually does not mean the inspection is finished. Use the finished callback to continue your app’s flow.
Example: iOS
Present a SFSafariViewController from your view controller:
import SafariServices
let browser = SFSafariViewController(url: url)
present(browser, animated: true)In your finished callback handler, dismiss the SFSafariViewController before continuing your app’s flow. It stays open over your app unless you dismiss it.
Example: Android
Use AndroidX Browser’s CustomTabsIntent from your activity:
import androidx.browser.customtabs.CustomTabsIntent
CustomTabsIntent.Builder().build().launchUrl(this, url)Example: React Native
Use Expo’s expo-web-browser to open the configured url from section 4 when the user is ready to begin:
import { openBrowserAsync } from 'expo-web-browser'
await openBrowserAsync(url.toString())On iOS, call dismissBrowser() from expo-web-browser in your finished callback handler to close the browser before continuing your app’s flow.
7. Wrapping up
Your app can now create an inspection through your backend, guide the user into Vision, and respond when the inspection is finished. You can customize onboarding, menu visibility, and styling to fit the experience around it.
Before rolling out your integration, test the full flow on both iOS and Android, including cases where permissions are already granted, requested for the first time, or denied. Confirm that your app handles the finished callback and takes the user to the right next step.
Please reach out with any questions along the way—we’re happy to help!
Updated 8 days ago

