Web SDKDocument Capture

Document Capture

Render a fullscreen capture component that instructs the user to photograph their document.

On this page · 15 sections

Document Capture

Render a fullscreen capture component that instructs the user to photograph their document.

Open interactive examples →

Auto-capture (proof of address)

Auto-capture watches the camera and fires the shutter itself once the whole page is framed, in focus and holding still. It exists to stop a very specific failure: photos cropped to the address block rather than the whole sheet.

It is assistance, not enforcement. If it cannot get a good frame it hands the shutter back rather than trapping the applicant.

No generated props table covers this. autoCapture lives inside documents[], and the generated table only lists flow-level props. This page is the documentation.

Turning it on

Presence of the autoCapture object is the opt-in. There is no enabled flag — an empty object means "on, with the tuned defaults".

<DocumentCapture
  sessionId={sessionId}
  documents={[
    {
      title: 'Proof of address',
      aspectRatio: 1 / 1.414, // A4
      autoCapture: {},
    },
  ]}
/>

Omitting autoCapture leaves the flow exactly as it was: a manual Capture button, no models downloaded, no behaviour change.

Options

Every field is optional, and the defaults were measured on real handheld captures. Change them only with a reason.

fielddefaultwhat it does
targetLabels['POA', 'Single page', 'Document', 'Document back']Which detected labels framing may be measured on, in priority order. The first label with a detection above minScore wins, so POA is used whenever it is confident enough and the rest only catch frames that would otherwise have no target. Real POA sheets often read as Document back.
minScore0.55Confidence a detection must clear before its geometry is trusted at all.
stabilityFrames12Length of both the hold-still window and the framing window, in frames (~0.7 s at 17 fps).
manualFallbackAfterMs30000How long the applicant may struggle before the Capture button appears alongside auto-capture. The budget is reset by a retry; the button, once earned, stays. 0 never shows it.
burstWindowMs300How long to keep looking for a sharper frame after the checks first pass, before taking the best one seen. 0 fires on the first good frame.
manualBurstWindowMs400The same, for the applicant's own tap: how long to keep looking after they press the button. 0 saves the frame the tap landed on.

thresholds also exists and is deliberately undocumented: those four numbers are an operating point measured on real captures, not a customisation surface.

What the applicant sees

While auto-capture is guiding there is no Capture button — the screen is the camera, the guide box, and one instruction at a time:

the SDK saysit means
Document not detectedNothing recognisable in frame — bad light, dark paper, or far too far away.
Move back, make sure all 4 edges of the page are visible.The page is cropped. This is the failure the feature exists to prevent.
Move closer, fill the frame with the whole page.The page is too small to be legible.
Please hold still...Motion. Also shown when the image is soft, because on paper that is the same problem.
Document detected, hold still...Every check passed; the shutter is firing.

All of these are overridable through verbiage, and all are translated into the eight supported languages.

The screen also sets the browser's bar colours. iOS 26 Safari tints its top and bottom bars from fixed elements on the page edges rather than from theme-color, so while the guided camera screen or the review is showing, the SDK pins a thin strip to each edge in that screen's colour — dark for the camera, light for the review. They are removed when the screen goes, which hands the bars back to your page. Nothing to configure; worth knowing if you embed the flow in something smaller than the viewport, since the bars will still follow the capture screen.

Three ways out, and you should know all three

  1. Do not bind autoCapture. The fastest and most complete switch: nothing downloads, nothing changes, and it takes effect on the next session. If you gate it behind a feature flag, this is the flag.
  2. The in-session escape. After about thirty seconds of sustained failure the Capture button appears, with a line beside it saying automatic capture is having trouble. There is nothing to confirm and nothing to dismiss: the button is an addition, auto-capture keeps running behind it, and whichever of the two gets a frame first wins. Once the button has appeared it stays for that document, including across a retry — a retry is more struggle, not a reason to take it away. It does not drop the framing checks, and a photo the applicant chooses to submit is recorded as a manual capture, which is what keeps the measurement honest.
  3. A failed model load. If the detector cannot load, the flow falls back to the ordinary Capture button. There is no error screen and nothing for the applicant to do differently.

Two things that will bite you if you do not know them

autoCapture is read once, when the flow mounts. The controller has no reconfigure path, so a value that arrives later — a feature flag that resolves after the screen renders, for example — is ignored for that applicant's whole session. Mount the flow only once the value is known. In development the SDK logs a warning when it sees the value change after mount.

A document with content never runs auto-capture. Supplying a blob skips capture entirely, so autoCapture on that document is inert. This is silent rather than warned, because a mixed array — some documents chosen from the gallery, some captured — is a normal and supported case.

It takes the sharpest frame, not the first one

When every check passes, the SDK does not fire immediately — it keeps watching for another burstWindowMs and then submits the sharpest frame it saw.

That is worth knowing because it is the difference between a readable capture and a blurry one. Two of the three checks are averaged over about a dozen frames, so they settle and stay settled; sharpness is judged per frame. Firing on the first frame where everything agreed therefore fired at the moment sharpness first scraped past its threshold, which is close to the worst frame that could still qualify.

The window is measured in time rather than frames, so the pause is the same on a fast phone and a slow one. It costs about a third of a second, and it does not restart if the applicant wobbles mid-window — whatever was banked still gets submitted.

The applicant's tap gets the same treatment, and needs it more. Pressing a phone's screen moves the phone, so the frame a tap lands on is one of the worst frames available — the shake has not damped yet. Tapping therefore opens its own window (manualBurstWindowMs, 400ms) and submits the sharpest frame in it. The shutter holds a pressed state for the duration so the wait reads as the button working rather than as a missed tap.

The window only ever looks FORWARD from the tap. Frames from before it are sharper still, and deliberately out of scope: the tap is the applicant choosing a moment, and submitting pixels from before it would be a different photo than the one they took. Auto-capture cannot fire inside the window either — the tap has taken that shot, and the result stays recorded as a manual capture.

If captures look soft, give the camera more pixels

The submitted image is the whole camera frame. On the default 1920-wide 16:9 stream an A4 page that fills the portrait guide box spans only about 764x1080 pixels of it, so the sheet lands near 90 DPI — headings read cleanly, small print does not. That is a sampling limit rather than blur, and no amount of focus tuning recovers it.

Raising the stream fixes it, and the SDK deliberately does not do so on your behalf:

<DocumentCapture camera={{ maxVideoWidth: 2560 }} … />

2560 gives the same page about 1018x1440. The reason it is not a default is that it is not free: the detection loop draws every frame at the stream's size, and both gate windows are counted in frames (stabilityFrames, twelve by default) rather than in time, so a slower loop stretches the hold-still the applicant has to sustain. Some devices also answer a larger width with a lower-frame-rate capture mode. On the hardware where this was first tried it made captures noticeably harder to land.

Turn it on per integration, and watch the frame rate on the tuning overlay before you ship it.

The image you receive is the whole camera frame

The guide box tells the applicant how to frame the page, and the "too far" check measures fill against it. The saved image is not cropped to it. On a portrait phone the guide box spans the camera's full width but leaves a band of view above and below it, and a page that spills into that band is still whole in the camera frame — so the frame is what you get, with some background around the page. Auto-capture will not fire on a page that runs past the edge of the camera frame itself; a manual capture is the applicant's call and is not checked.

The "too close" check follows the same logic and is measured against the whole camera frame, so a page may fill more of the view than the guide box. The guide box sets how small the page may be — "too far" is still measured against it.

What it reports back

Every capture reports how it happened, on the captured document itself:

onDocumentCaptured={(document) => {
  document.captureMetadata.captureMode        // 'Auto' | 'Manual' | 'Upload'
  document.captureMetadata.autoCaptureStatus  // 'engaged', 'timedOutToManual', …
  document.captureMetadata.attemptCount       // including retries
  document.captureMetadata.bestFocusScore     // null when no engine ran
}}

This is emitted for every capture, including gallery uploads and including documents with auto-capture switched off — so a rollout has a baseline to compare against rather than silence.

Fields that only auto-capture can produce are null, never zero, and the key is always present — on the object and in the JSON that rides with the upload. That distinction is the point: 0 would read as "the detector looked and saw nothing", which is a different and false claim from "no detector ran".

Example

import { DocumentCapture } from 'idmission-web-sdk'

async function getSessionId() {
  const resp = await fetch("/api/get-session");
  return (await resp.json()).id;
}

export default function App() {
  return (
    <DocumentCapture
      sessionId={getSessionId}
      onComplete={(submissionResponse) => {
        alert('Your document has been captured, proceed to the next checkpoint.')
      }}
      onExitAfterFailure={(submissionResponse) => {
        alert('Your document could not be captured, you shall not pass!')
      }}
    />
  )
}
function getSessionId() {
  return fetch('/api/get-session').then(resp => resp.json()).then(resp => resp.id)
}

IDmissionSDK.renderDocumentCapture('#target-element', {
  sessionId: getSessionId,
  onComplete: (submissionResponse) => {
    alert('Your document has been captured, proceed to the next checkpoint.')
  },
  onExitAfterFailure: (submissionResponse) => {
    alert('Your document could not be captured, you shall not pass!')
  }
})

Props

sessionId#
A session identifier generated with your IDmission credentials.
apiUrl#
Portal API base URL. Supplies defaults for the three URLs below.
submissionUrl#
URL used for submission requests.
authUrl#
URL to validate the session against.
documentServiceUrl#
URL to upload captured documents to via Tus.
loadingSpinner#
React component rendered in place of the SDK's own loading indicator, on every loading surface a flow shows: session validation, model loading, the stage-transition screen, and submission. It receives a `stage` saying which, and — for `model-loading`, the only determinate one — a `progress` percentage, because the built-in ring it replaces there is driven by real model-download progress. Rendering `null` falls back to the SDK's indicator rather than showing nothing, so an unhandled stage cannot leave a loading screen bare.
clientRequestID#
The ClientRequestID field is typically populated with the Form ID and is used to link records together - in this case an id validation record to the original form
jobId#
The ID of the job to associate with the submission.
taskId#
The ID of the task to associate with the submission.
sendBase64DocumentsInSwaggerProxy#
Boolean flag to enable download and replace behavior for documents in the Swagger Proxy API. Defaults to false.
onBeforeDocumentUpload#
Callback function that fires immediately prior to each document upload, giving the client application an opportunity to specify custom asynchronous logic that captures the document contents before it is exchanged for a document token. The document content and metadata are passed in as parameters and a promise resolving to void or false should be returned. If the returned promise resolves to false, the document will not be uploaded.
onDocumentUploadProgress#
Callback function that fires when progress information is available for a document upload. The progress info and metadata are passed in as parameters.
onDocumentUploaded#
Callback function that fires immediately after each document upload, giving the client application an opportunity to specify custom asynchronous logic that captures the document token. The document token and metadata are passed in as parameters.
onDocumentUploadFailed#
Callback function that fires when a document failed to upload. The error and metadata are passed in as parameters.
onAuthError#
Callback function that fires when the session creation or validation fails.
enableTamperDetection#
When true, integrity checks run and may block the camera.
mediaIntegrityToken#
An opaque token created by `captureMediaIntegrity()` at boot time.
onUserCancel#
Callback function that fires when the user clicks the exit button from the loading overlay, declining to engage with IDmission. Binding this callback results in the cancel button being rendered on the loading overlay.
onCameraAccessDenied#
Callback function that fires when the camera access is denied.
onCameraTamperingDetected#
Callback function that fires when the camera tampering is detected.
onExitAfterTamperSealPopped#
Callback function that fires when the user clicks the exit button on the tamper detection screen. Only renders the button when bound.
lang#
Language code to use. Supported values: 'auto' (detect based on user's OS), 'en' (English), 'es' (Spanish). Defaults to auto.
theme#
Name of an included theme or object containing theme properties. *
geolocationEnabled#
Boolean flag to indicate whether users should be prompted to share their location. Default is `true`.
geolocationRequired#
Boolean flag to indicate whether users should be blocked from proceeding if they block location access. Default is `false`.
debugMode#
Boolean flag to enable on-screen prediction information output. Default is `false`.
remoteDiagnostics#
Opt-in remote diagnostics: crash/breadcrumb capture and reporting.
captureProgress#
Optional capture-progress task, created with `createCaptureProgress({ sessionId, authUrl }).task(context)`. Reports capture progress to Portal where Portal enables it; capture never depends on it.
useDocumentServiceForLivenessChecks#
Boolean flag to indicate whether the liveness check photos should be uploaded to the document service, rather than sent as base64. Defaults to false.
onComplete#
Callback function that fires when the user completes the DocumentCapture flow and every document has uploaded. Arguments: the captured documents, as an array of `CapturedDocument`.
onRequestFailure#
Callback function that fires when an error occurs during the submission process. Arguments: the Error object is passed as an argument to the customer's application for further handling.
onExitCapture#
Callback function that fires when the user clicks the exit button during ID or selfie capture.
documents#
aspectRatio#
cameraFeedMode#
instructions#
classNames#
Class names for the component's inner elements, keyed as in 2.x (`DocumentCaptureClassNames`). Keys marked deprecated have no effect in 3.0.
verbiage#
Object containing any verbiage overrides.

Class Names

container#
cameraFeedWrapper#
cameraFeed#
previewImage#
captureContainer#
headingRow#
heading#
footerRow#
instructions#
buttonsRow#
retryCameraAccessBtn#
retryCaptureBtn#
uploadBtn#
uploadProgressBtn#
captureBtn#
cameraStoreProvider.cameraTamperSeal.container#
cameraStoreProvider.cameraTamperSeal.inner#
cameraStoreProvider.cameraTamperSeal.heading#
cameraStoreProvider.cameraTamperSeal.message#
cameraStoreProvider.cameraTamperSeal.exitButton#

Verbiage

headingText#
loadingBtnText#
retryCameraAccessBtnText#
captureBtnText#
capturingBtnText#
retryCaptureBtnText#
uploadBtnText#
uploadingBtnText#
retryUploadBtnText#
successBtnText#
cameraStoreProvider.cameraTamperSeal.headingText#
cameraStoreProvider.cameraTamperSeal.messageText#
cameraStoreProvider.cameraTamperSeal.exitButtonText#
Report a docs issue

Copy this context into your team’s issue tracker or an email to support@idmission.com.