Migrating from Captur to VerifyAI
This is a pragmatic, code-first playbook for moving your end-of-ride parking verification off Captur and onto VerifyAI. It is written for the engineer who owns the scanner integration — not for procurement. We'll map endpoints one-to-one, diff the request and response shapes, give you a drop-in TypeScript adapter, and walk a zero-downtime shadow-mode cutover that flips traffic only once the two systems agree.
The wedge is narrow on purpose: micromobility parking and photo verification for scooter and e-bike operators. If that's your use case, the migration is small — one HTTP call changes, one response gets re-shaped, and your branching logic stays almost identical.
Why teams switch
Operators leave Captur for four concrete reasons, in roughly this order:
- Cost. Captur is sold as an annual platform deal — public sources put the base around ~$40,000/yr before per-verification overage. VerifyAI is $0.008 per verification, no annual contract, no minimums. For most fleets that is the entire conversation. See the cost calculator to plug in your own volume.
- Per-verification pricing instead of seat or platform fees. You pay for the verifications you actually run. Sandbox usage is free with a $5 starting credit and no card, so you can run the shadow-mode comparison below at zero cost before committing a dollar.
- Edge and offline. VerifyAI ships an on-device ML path in the
React Native and Flutter SDKs, so a rider in a parking garage or a
dead-zone alley still gets a verdict. The same
verify()call routes to the cloud VLM or the on-device model and reports which one decided viaevaluation_source. - Policy-as-code. Parking rules are versioned, structured policies you can read, diff, and fetch at runtime — not a config screen owned by a vendor. New city rolling out a no-park zone on tactile paving? That's a policy change you ship, reviewed like any other code. See city parking policy-as-code.
Everything below assumes scooter and e-bike end-of-ride parking checks. The same VerifyAI endpoint handles other photo-verification workloads, but the policy IDs, categories, and branching examples here are tuned for parking. If you're verifying something else, the endpoint and adapter still apply — swap the policy.
Endpoint mapping
The integration surface collapses to a single call. Captur's
verification submit becomes one POST to VerifyAI:
| Captur | VerifyAI | Notes |
| ----------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------- |
| POST image/parking submission | POST /v1/verify | One synchronous call returns the verdict. |
| API key header | X-API-Key: vai_your_api_key | Issued in the dashboard; sandbox and live keys are distinct. |
| Project / model selector | policy= form field | Pick the parking policy, e.g. pol_forest1. |
| Custom fields / tags | metadata={...} form field (JSON string) | Free-form; carry your trip_id, user_id, GPS, etc. |
| Result polling / callback | Synchronous response body | The verdict is in the POST response. Optional webhook_url. |
| Per-category result codes | category + violation_reasons[] | Stable string IDs you branch on (covered below). |
Base URL for all examples:
https://verify.switchlabs.dev/api/v1Request schema diff
VerifyAI's verify endpoint accepts multipart/form-data (recommended
for mobile — no base64 bloat) or a JSON body. The multipart fields are:
| Field | Required | Type | Notes |
| -------------------- | -------- | ----------- | ------------------------------------------------------------------ |
| image | yes | file | The end-of-ride photo. JPEG or PNG, up to 10 MB. |
| policy | yes | string | Policy ID, e.g. pol_forest1. This replaces Captur's model field. |
| metadata | no | JSON string | Your join keys: trip_id, user_id, GPS, etc. |
| provider | no | string | openai, anthropic, or gemini. Omit to use the default. |
| include_image_data | no | string | "true" to echo the stored image back in the response. |
The mechanical change from Captur is small: your image part stays an
image part, your tags become the metadata JSON string, and the model
selector becomes the policy field.
curl -X POST https://verify.switchlabs.dev/api/v1/verify \
-H "X-API-Key: vai_your_api_key" \
-F "image=@end_of_ride.jpg" \
-F "policy=pol_forest1" \
-F 'metadata={"trip_id":"trip_123","user_id":"usr_456","gps":"51.5074,-0.1278"}'Pass an Idempotency-Key header on the verify call and a retry with
the same key and body returns the cached verdict instead of running
(and billing) a second verification. Use it on the flaky-network retry
path your rider app already has.
Response shape diff
A 200 from POST /v1/verify returns a single JSON object. The fields
you'll actually branch on:
| Field | Type | What it's for |
| ------------------- | ---------------- | ----------------------------------------------------------------------------- |
| is_compliant | boolean | The headline verdict. true means the parking passed. |
| category | string | The structured bucket, e.g. good_parking, bad_parking, poor_photo. |
| violation_reasons | string[] | Stable IDs of each failed criterion, e.g. not_on_tactile_paving. |
| confidence | number | 0–1 model confidence. Threshold it for auto-approve vs. review. |
| feedback | string | Rider-facing message. Show it on the retry screen. |
| id | string | The verification ID. Log it; it's your evidence handle. |
| created_at | string | ISO timestamp. |
| policy | string | Echoes the policy ID you submitted. |
| image_url | string | null | Signed URL to the stored capture (if storage is on). |
| evaluation_source | string | cloud_vlm or on_device — which path produced the verdict. |
A compliant end-of-ride looks like:
{
"id": "ver_8f3c1a2b",
"created_at": "2026-06-01T14:22:09.182Z",
"status": "success",
"is_compliant": true,
"category": "good_parking",
"confidence": 0.96,
"policy": "pol_forest1",
"violation_reasons": [],
"feedback": "Bike parked correctly. Ride ended.",
"metadata": { "trip_id": "trip_123", "user_id": "usr_456" },
"image_url": "https://.../signed.jpg",
"evaluation_source": "cloud_vlm"
}A violation looks like:
{
"id": "ver_aa91d004",
"status": "success",
"is_compliant": false,
"category": "bad_parking",
"confidence": 0.91,
"policy": "pol_forest1",
"violation_reasons": ["not_on_tactile_paving", "not_blocking_pedestrian"],
"feedback": "Please move your bike off the tactile paving and away from the pedestrian path.",
"metadata": { "trip_id": "trip_123" },
"evaluation_source": "cloud_vlm"
}The structural difference from Captur worth internalizing: branch on
category, not just is_compliant. A false verdict that's
bad_parking (rider should reposition) is a different UX than one that's
poor_photo (rider should retake) or no_bike (escalate to support).
Collapsing all of those to "failed" is the most common mistake in a
naive port.
A TypeScript adapter
If your codebase is full of calls to a capturVerify(...) helper, the
fastest path is an adapter that keeps your old call sites intact and
re-shapes VerifyAI's response into whatever your Captur wrapper returned.
Adjust the CapturResult type to match your existing contract.
// verify-ai-adapter.ts
// Drop-in replacement for your existing Captur client.
const VERIFY_AI_URL = "https://verify.switchlabs.dev/api/v1/verify";
// The shape your app already consumes from the old Captur client.
export interface CapturResult {
passed: boolean;
failureCodes: string[];
score: number;
message: string;
reference: string;
}
export interface VerifyAIResult {
id: string;
is_compliant: boolean;
category?: string;
confidence: number;
violation_reasons: string[];
feedback: string;
evaluation_source?: string;
}
export async function verifyParking(
image: Blob,
policy: string,
metadata: Record<string, unknown> = {},
): Promise<CapturResult> {
const form = new FormData();
form.append("image", image, "end_of_ride.jpg");
form.append("policy", policy);
form.append("metadata", JSON.stringify(metadata));
const res = await fetch(VERIFY_AI_URL, {
method: "POST",
headers: { "X-API-Key": process.env.VERIFY_AI_KEY ?? "" },
body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error ?? `VerifyAI returned ${res.status}`);
}
const r: VerifyAIResult = await res.json();
// Re-shape into your existing Captur contract so call sites don't change.
return {
passed: r.is_compliant,
failureCodes: r.violation_reasons,
score: r.confidence,
message: r.feedback,
reference: r.id,
};
}With that in place, your call sites stay the same; only the import
changes. Once the migration settles, delete the adapter and consume the
native VerifyAI fields directly so you can branch on category and read
evaluation_source.
Zero-downtime shadow-mode cutover
Don't flip a switch. Run both systems in parallel, measure agreement on live traffic, and only cut over once they agree at a threshold you're comfortable with. Because VerifyAI sandbox traffic is free, the shadow period costs you nothing.
1. Run both, serve Captur
Keep Captur authoritative. Call VerifyAI in the background (fire-and-forget, never block the rider), and log both verdicts keyed by the same photo.
async function shadowVerify(image: Blob, tripId: string) {
// Captur stays authoritative and drives the rider's UX.
const captur = await capturVerify(image, tripId);
// VerifyAI runs in the shadow. Never blocks, never throws upstream.
verifyParking(image, "pol_forest1", { trip_id: tripId })
.then((vai) => {
logAgreement({
tripId,
capturPassed: captur.passed,
verifyAiPassed: vai.passed,
agreed: captur.passed === vai.passed,
});
})
.catch((e) => logShadowError(tripId, e));
return captur; // rider sees Captur's verdict during the shadow period
}2. Measure agreement
Watch the agreement rate and, more importantly, the disagreements. A raw agreement percentage hides which side is right — pull the actual photos for the cases where they differ and eyeball them.
select
count(*) as total,
count(*) filter (where agreed) as agreed,
round(100.0 * count(*) filter (where agreed) / count(*), 2) as agreement_pct,
count(*) filter (where not agreed and verify_ai_passed and not captur_passed) as vai_lenient,
count(*) filter (where not agreed and captur_passed and not verify_ai_passed) as vai_strict
from shadow_agreement_log
where created_at > now() - interval '7 days';When you find a disagreement, decide who was right by looking at the photo. If VerifyAI was correct on the cases that mattered, that's your signal — not the headline percentage.
3. Flip at high agreement
Once agreement holds above your threshold (most teams use ~98%+ over a representative week, including peak hours and bad-weather days), make VerifyAI authoritative and demote Captur to the shadow slot. Run that way for a few days as a safety net, then remove the Captur call entirely.
At no point does a rider experience two verdicts or a slower end-of-ride. The shadow call is fire-and-forget. If VerifyAI is down or slow during the shadow period, the rider never notices — Captur is still authoritative. You only flip after the data says it's boring.
Pricing comparison
The math is the entire reason most teams start this migration.
| Plan | VerifyAI | Captur (estimate, public sources) | | ------------------- | ------------------------------------------ | ----------------------------------------- | | Pricing model | Per-verification | Annual platform deal + overage | | Headline price | $0.008 / verification | ~$40,000 / yr base | | Annual contract | None | Typically required | | Minimums | None | Platform minimum | | Sandbox | Free, $5 credit, no card | Sales-gated |
At $0.008/verification, 1,000,000 verifications/yr costs $8,000 — roughly a fifth of a typical Captur base before overage. Run your own numbers in the cost calculator.
Captur does not publish list pricing. The ~$40k/yr base and the annual-contract structure are estimates compiled from public sources and operator reports, and your quote may differ. VerifyAI's $0.008/verification is the published standard rate.
Testing checklist
Before you flip authority to VerifyAI, walk this list:
- [ ] Sandbox key works end-to-end with a real end-of-ride photo.
- [ ] Multipart upload path tested from the actual mobile client (not just cURL).
- [ ]
metadatacarries yourtrip_id/user_idand round-trips in the response. - [ ] Branching covers
good_parking,bad_parking,poor_photo, andno_bike— not justis_compliant. - [ ] Retry path uses an
Idempotency-Keyso flaky-network retries don't double-bill. - [ ] Confidence threshold for auto-approve vs. manual review tuned against your shadow data.
- [ ] Exhaustion path (max attempts) closes the ride and flags for review — riders are never held hostage.
- [ ] Offline / dead-zone path verified: confirm
evaluation_sourcereportson_devicewhen the network is unavailable. - [ ] Shadow-mode agreement above threshold for a full representative week, peak hours included.
- [ ] Image-size guard: captures over 10 MB are downscaled client-side before upload.
FAQ
Do I have to re-train or re-label anything to move policies over?
No. VerifyAI parking policies are structured, versioned rule sets you
read and ship as code. You pick an existing policy (or author one) and
reference it by ID in the policy field. There's no model-training step
on your side for the cloud VLM path.
Can I run VerifyAI and Captur side by side during migration?
Yes — that's the recommended cutover. Run VerifyAI in shadow mode (fire-and-forget, Captur authoritative), measure agreement on live traffic, and flip only when the data is boring. Sandbox traffic is free, so the shadow period costs nothing.
What happens to the rider experience during the cutover?
Nothing changes. The shadow call never blocks the end-of-ride flow and never surfaces a second verdict. The rider sees exactly one decision the entire time.
How does offline verification work if Captur was cloud-only?
The React Native and Flutter SDKs include an on-device ML path. The same
verify() call routes to the on-device model when the network is
unavailable and reports evaluation_source: "on_device", so a rider in a
garage or dead zone still gets a verdict.
How do I map Captur's result codes to VerifyAI's?
Captur's per-category codes map to VerifyAI's category plus the
violation_reasons[] array of stable string IDs (e.g.
not_on_tactile_paving). Branch on category for the coarse decision
and use violation_reasons for rider messaging and analytics.
Is there a webhook if I don't want a synchronous response?
The verdict is returned synchronously in the POST body, which is what
most end-of-ride flows want. You can also pass a webhook_url to receive
the result out of band.
What's next
- Quickstart — your first verification in a few minutes.
- City parking policy-as-code — version and ship parking rules per city.
- Verifying parked vehicles — the full end-to-end parking check with retries and branching.
- Captur alternative — the side-by-side positioning for your stakeholders.
- VerifyAI vs Captur — feature and pricing comparison.
- Captur vs VerifyAI cost calculator — plug in your volume and see the annual delta.