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 via evaluation_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.
Scope this guide to micromobility

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:

plaintext
https://verify.switchlabs.dev/api/v1

Request 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"}'
Idempotency on retries

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:

json
{
  "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:

json
{
  "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.

ts
// 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.

ts
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.

sql
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.

Why this is safe

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.

Competitor figures are estimates

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).
  • [ ] metadata carries your trip_id / user_id and round-trips in the response.
  • [ ] Branching covers good_parking, bad_parking, poor_photo, and no_bike — not just is_compliant.
  • [ ] Retry path uses an Idempotency-Key so 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_source reports on_device when 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

Get in Touch

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