Handling retries
A failed verification is the normal case, not the exception. A rider parks badly, the photo is too cropped, the bike is half in frame. The SDK scanners handle this for you: when a photo fails, they show the rider what's wrong and let them retake — up to a limit you control — before the flow resolves. This guide covers exactly how that loop runs and how to wire your app into it.
The two knobs
Retry behaviour is driven by two fields on the policy, surfaced through the scanner's config:
| Field | Type | What it does |
| ---------------------- | --------- | ------------------------------------------------------------------------- |
| maxAttempts | number | How many photos the scanner accepts before giving up. |
| autoApproveOnExhaust | boolean | What happens after the last attempt fails — block, or close-and-flag. |
Both come from the policy by default (the SDK fetches them from
GET /api/v1/policies/:id/config). You
can override maxAttempts and autoApproveOnExhaust locally on the
scanner overlay config if you need different behaviour on a specific
screen, but the policy default is almost always the right answer — keep
the retry contract in one place.
The retry loop
The scanner runs the loop automatically. Using pol_forest1
(maxAttempts: 3, autoApproveOnExhaust: true) as the example:
- Rider takes a photo. The scanner uploads it to
/v1/verifyand showsprocessingMessage. - Pass. If the result is compliant, the scanner shows
successMessageand resolves with the verification — your success callback fires. - Fail, attempts remaining. The scanner shows
failureMessage, thenretryMessagewith the live count interpolated into{remaining}("2 attempts remaining"), and returns the rider to the camera. No callback fires yet — this is still in-flight. - Fail, no attempts remaining. The scanner is out of attempts. What
happens next depends on
autoApproveOnExhaust:true→ the scanner showsexhaustedMessage, resolves the flow, and reports the final (still non-compliant) verification through the exhaustion callback. The flow completes.false→ the scanner stays on the failure card and the flow does not resolve. The rider is blocked until a compliant photo lands.
The key thing to internalize: a retake is not a callback. Your success and exhaustion handlers only fire on terminal states. Everything between is the scanner's job.
autoApproveOnExhaust: don't trap the rider
autoApproveOnExhaust is the single most important UX decision in the
retry flow.
Set it to true for any flow a real end user drives — end-of-ride
parking checks especially. A rider standing on a street corner cannot
be held hostage by a model that won't pass them. After maxAttempts,
the scanner closes the ride and flags the verification for manual
review; your review queue catches the genuine violations after the
fact, and operations can warn or fine the rider later. The rider's
experience stays smooth; your enforcement stays intact.
Set it to false only for supervised or back-office flows where a stuck
user is acceptable — a depot inspection by your own staff, for example,
where retaking until it passes is the whole point.
When autoApproveOnExhaust fires, the verification is still
is_compliant: false. You are not approving the parking — you're
unblocking the user and deferring the decision to review. Make sure
your exhaustion handler tags the record for review (e.g.
metadata.manualReview = true) and that something actually drains
that queue. An exhausted verification that nobody looks at is a
silently waived violation.
Wiring your app in
Drop in the scanner and handle the two terminal callbacks: success and exhaustion. The scanner owns everything in between.
import { useVerifyAI } from "@switchlabs/verify-ai-react-native";
import { VerifyAIScanner } from "@switchlabs/verify-ai-react-native/scanner";
function EndRideScreen({ tripId, userId }: { tripId: string; userId: string }) {
const { verify } = useVerifyAI({
apiKey: process.env.EXPO_PUBLIC_VERIFY_AI_KEY!,
});
return (
<VerifyAIScanner
policy="pol_forest1"
// maxAttempts + autoApproveOnExhaust come from the policy.
// Override here only if this screen genuinely needs different behaviour:
// overlay={{ maxAttempts: 3, autoApproveOnExhaust: true }}
onCapture={(base64) =>
verify({
image: base64,
policy: "pol_forest1",
metadata: { trip_id: tripId, user_id: userId },
})
}
onResult={(result) => {
// Fires only on a COMPLIANT terminal result.
// Retries between attempts do not call this.
if (result.is_compliant) {
endRide(tripId, { verificationId: result.id });
}
}}
onExhausted={(result) => {
// Fires when all attempts are spent and autoApproveOnExhaust is true.
// result.is_compliant is still false here.
endRide(tripId, {
manualReview: true,
verificationId: result.id,
reasons: result.violation_reasons,
});
}}
/>
);
}The two SDKs mirror each other: React Native splits the terminal states
into onResult (compliant) and onExhausted (exhausted), while Flutter
returns a single result object you branch on with isCompliant and
exhausted. In both, a dismissed scanner means the user backed out
before any terminal state — the ride stays open and nothing is approved.
Picking maxAttempts
Three is the right default for consumer parking flows. It gives a rider two genuine retries after their first miss, which is enough to fix almost any real framing or repositioning problem, without grinding them through an endless loop on a model that simply disagrees.
- One or two if your photos are easy and you'd rather route edge cases to review fast.
- Three for standard end-of-ride parking. The default.
- Four or five only for high-stakes flows where a false reject is expensive and you'd rather give the user more rope before exhausting.
Whatever you pick, write a clear retryMessage that tells the rider
what to change — "step back so the whole scooter is in frame" beats
"please try again." The {remaining} count sets expectations; the
instruction is what actually fixes the next shot.
Don't build your own retry loop
It's tempting to call /v1/verify directly and roll your own retry
handling around it. Don't, for the SDK scanner flows. The scanner
already coordinates the camera, the upload, the per-attempt messaging,
the attempt counter, and the exhaustion path — re-implementing that on
top of the raw API is where bugs creep in (the classic one: a
non-compliant capture with retries left getting resolved as a success).
Use the scanner for end-user capture; reserve direct /v1/verify calls
for server-side or batch flows where there's no human to retry.
What's next
- Custom policy creation — set
maxAttemptsandautoApproveOnExhauston your own policy. - Verifying parked vehicles — the full end-of-ride flow this retry loop sits inside.
- Concepts: Policies — where the retry knobs are defined.