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:

  1. Rider takes a photo. The scanner uploads it to /v1/verify and shows processingMessage.
  2. Pass. If the result is compliant, the scanner shows successMessage and resolves with the verification — your success callback fires.
  3. Fail, attempts remaining. The scanner shows failureMessage, then retryMessage with 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.
  4. Fail, no attempts remaining. The scanner is out of attempts. What happens next depends on autoApproveOnExhaust:
    • true → the scanner shows exhaustedMessage, 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.

The exhaustion path is your enforcement, not a failure

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

Get in Touch

Questions about pricing, integrations, or custom deployments? We'd love to hear from you.