Custom policy creation
The built-in policies (scooter_parking, bike_parking, pol_forest1,
and the Forest bay variants) cover the most common micromobility
end-of-ride checks. But your operations rules are yours — a custom
policy lets you codify exactly what "good" looks like for your fleet,
your cities, and your liability posture, without us writing code on
your behalf.
By the end of this guide you'll have:
- A structured policy authored as a single JSON document — categories, criteria, retry behaviour, and scanner copy.
- That policy attached to your account under a stable
policyID. - A test loop that exercises the policy against real photos before you point production traffic at it.
If you haven't read Concepts: Policies yet, skim it first — this guide assumes you know what a category and a criterion are.
1. The policy document
A structured policy is a single object. The reference shape is
PolicyConfig:
interface PolicyConfig {
mode: "structured" | "advanced";
categories: ComplianceCategory[];
criteria: PolicyCriterion[];
maxAttempts?: number;
autoApproveOnExhaust?: boolean;
uiCopy?: UICopyConfig;
}Everything below lives inside one of those four sections. The worked example throughout this guide is a parking policy for an e-scooter operator that wants to enforce "in a painted bay, kickstand down, not blocking the curb ramp."
2. Define your categories
A category is an outcome bucket. The model picks exactly one per
photo, and that choice is the verification's verdict. Two fields do the
work: isCompliant decides whether the verification passes, and color
drives the result-screen color in the scanner overlay.
Keep the set small and unambiguous. Four buckets is the sweet spot: one pass state, one or two fail states, and a "can't tell from this photo" bucket so a blurry shot doesn't get mislabeled as a violation.
[
{
"id": "parked_ok",
"label": "Parked correctly",
"color": "#22c55e",
"isCompliant": true,
"description": "Scooter is in a permitted bay, upright, kickstand down, not blocking access."
},
{
"id": "bad_parking",
"label": "Parking violation",
"color": "#ef4444",
"isCompliant": false,
"description": "One or more parking rules failed."
},
{
"id": "poor_photo",
"label": "Photo unclear",
"color": "#f97316",
"isCompliant": false,
"description": "Image is too cropped, dark, or blurry to assess parking."
},
{
"id": "no_scooter",
"label": "No scooter visible",
"color": "#6b7280",
"isCompliant": false,
"description": "No scooter from our fleet is visible in the frame."
}
]Exactly one category should have isCompliant: true. If you set the
flag on more than one, the first compliant category in the list wins on
ties — but don't rely on that; design for a single pass state.
3. Write your criteria
A criterion is one rule the model evaluates. Each criterion returns a pass or fail, and the combination of failures (weighted by severity) is what resolves the category. The fields:
| Field | Description |
| ------------- | -------------------------------------------------------------------- |
| id | Stable identifier. This is what appears in violation_reasons. |
| label | Short human-readable name for dashboards and analytics. |
| description | The actual rule, read verbatim by the model. Be specific. |
| severity | critical, warning, or info. Drives category resolution. |
| required | If true, the criterion participates in category resolution. |
Severity and required semantics
The two flags answer two different questions. required answers "does
this rule count toward the verdict at all?" severity answers "how bad
is it if this rule fails?"
| severity | required | Effect when this criterion fails |
| ---------- | ---------- | ----------------------------------------------------------------------------------- |
| critical | true | Forces a non-compliant category (your bad_parking / unsafe bucket). |
| warning | true | Contributes a soft fail. If only warnings fail, you land in the "improvable" bucket. |
| info | true | Recorded in violation_reasons for analytics, but doesn't change the verdict. |
| any | false | Evaluated and logged, but excluded from category resolution entirely. |
The mental model: any critical failure on a required criterion
downgrades the verification. Warnings are nudges. info criteria and
non-required criteria are telemetry you can chart without gating the
ride on them.
A pair of criteria from the worked example:
[
{
"id": "in_painted_bay",
"label": "In a painted parking bay",
"description": "Look at the surface directly under the scooter wheels. PASS only if the wheels are inside a painted rectangle, a marked corral, or a sign-posted parking zone. FAIL if the scooter is on an open sidewalk, in the gutter, on grass, or on a road surface with no parking markings. If you cannot see the ground around the wheels, do not fail this criterion — that is a photo-quality problem, not a parking violation.",
"severity": "critical",
"required": true
},
{
"id": "kickstand_down",
"label": "Kickstand deployed",
"description": "The scooter must be upright and self-supporting on its kickstand. FAIL if the scooter is lying down, leaning against a wall or pole, or being held up by a person. If the scooter is upright and the kickstand is plausibly down even if partially occluded, PASS.",
"severity": "warning",
"required": true
}
]The model reads each description verbatim. Write it like a prompt
fragment: name the visual features explicitly, state what PASSES and
what FAILS, and disambiguate the edge cases (occlusion, lighting,
look-alikes). Vague rules like "parked safely" produce vague,
inconsistent verdicts. The most common authoring mistake is conflating
a photo-quality failure with a violation — always tell the model to
defer to the "can't tell" bucket when the relevant feature isn't
visible.
4. Add retry behaviour
maxAttempts controls how many photos the SDK scanner will accept
before it gives up and surfaces the exhausted state. autoApproveOnExhaust
decides what happens at that point:
autoApproveOnExhaust: false— the scanner blocks. The flow can't complete until a compliant photo lands. Use this only when you can tolerate a stuck user.autoApproveOnExhaust: true— aftermaxAttemptsfailures the scanner closes the flow anyway and flags the verification for manual review. The user is never held hostage by a model that won't pass them. This is what every production micromobility policy uses.
For the worked example, three attempts then auto-approve:
{
"maxAttempts": 3,
"autoApproveOnExhaust": true
}See Handling retries for the SDK-side behaviour and the UX guidance around it.
5. Customize the scanner copy
uiCopy lets you rewrite the scanner overlay strings without shipping a
new mobile build. The strings ship to the SDK through the policy config
endpoint, so editing them takes effect on the next scanner launch. All
strings support {remaining} interpolation for the live retry count.
{
"scannerTitle": "End-of-ride photo",
"scannerInstructions": "Step back and capture the whole scooter and the ground it's parked on.",
"processingMessage": "Checking your parking...",
"successMessage": "Parking verified — thanks!",
"failureMessage": "We spotted a parking issue.",
"retryMessage": "Reposition the scooter or retake the photo. {remaining} attempts left.",
"exhaustedMessage": "Photo sent for review. Your ride is complete."
}Account-level defaults are merged in automatically, so you only have to override the strings you actually want to change.
6. The full policy document
Putting it together, here's the complete custom policy:
{
"mode": "structured",
"categories": [
{ "id": "parked_ok", "label": "Parked correctly", "color": "#22c55e", "isCompliant": true, "description": "Scooter is in a permitted bay, upright, kickstand down, not blocking access." },
{ "id": "bad_parking", "label": "Parking violation", "color": "#ef4444", "isCompliant": false, "description": "One or more parking rules failed." },
{ "id": "poor_photo", "label": "Photo unclear", "color": "#f97316", "isCompliant": false, "description": "Image is too cropped, dark, or blurry to assess parking." },
{ "id": "no_scooter", "label": "No scooter visible","color": "#6b7280", "isCompliant": false, "description": "No scooter from our fleet is visible in the frame." }
],
"criteria": [
{
"id": "scooter_visible",
"label": "Scooter visible",
"description": "A scooter must be clearly visible in the frame. FAIL (routes to no_scooter) if no scooter is present.",
"severity": "critical",
"required": true
},
{
"id": "enough_context",
"label": "Enough context",
"description": "Enough of the surroundings must be visible to judge where the scooter is parked. FAIL (routes to poor_photo) if the photo is a tight crop of the scooter with no ground or surroundings.",
"severity": "critical",
"required": true
},
{
"id": "in_painted_bay",
"label": "In a painted parking bay",
"description": "Look at the surface directly under the scooter wheels. PASS only if the wheels are inside a painted rectangle, a marked corral, or a sign-posted parking zone. FAIL if the scooter is on an open sidewalk, in the gutter, on grass, or on a road surface with no parking markings. If you cannot see the ground around the wheels, do not fail this criterion.",
"severity": "critical",
"required": true
},
{
"id": "not_blocking_ramp",
"label": "Not blocking a curb ramp",
"description": "FAIL if any part of the scooter overlaps a curb ramp, tactile paving, or an accessible path. PASS otherwise.",
"severity": "critical",
"required": true
},
{
"id": "kickstand_down",
"label": "Kickstand deployed",
"description": "The scooter must be upright and self-supporting on its kickstand. FAIL if it is lying down or leaning on something.",
"severity": "warning",
"required": true
},
{
"id": "helmet_visible",
"label": "Helmet visible (telemetry)",
"description": "Note whether a helmet is visible hanging on the scooter. Informational only.",
"severity": "info",
"required": false
}
],
"maxAttempts": 3,
"autoApproveOnExhaust": true,
"uiCopy": {
"scannerTitle": "End-of-ride photo",
"scannerInstructions": "Step back and capture the whole scooter and the ground it's parked on.",
"processingMessage": "Checking your parking...",
"successMessage": "Parking verified — thanks!",
"failureMessage": "We spotted a parking issue.",
"retryMessage": "Reposition the scooter or retake the photo. {remaining} attempts left.",
"exhaustedMessage": "Photo sent for review. Your ride is complete."
}
}7. Attach the policy to your account
Custom policies are scoped to your customer account and get a stable
policy ID you pass on every /v1/verify call. During the current
rollout, policy creation goes through your account contact or
enterprise sales — send the JSON document and we mint the
ID (for example pol_acme_scooter1) and bind it to your account. The
self-serve policy editor in the dashboard is the next surface to land;
when it ships, this section will point at it.
Once the policy is attached, confirm its runtime config the same way the SDK fetches it:
curl https://verify.switchlabs.dev/api/v1/policies/pol_acme_scooter1/config \
-H "X-API-Key: vai_your_api_key"The response returns the resolved categories, maxAttempts,
autoApproveOnExhaust, and the merged uiCopy — exactly what the
scanner will use. If a string or a category looks wrong here, fix the
source document; the SDK only ever sees what this endpoint returns.
8. Test before you cut over
Don't point production traffic at a fresh policy. Run it against real photos first, both passing and failing cases.
Smoke-test a single photo
curl -X POST https://verify.switchlabs.dev/api/v1/verify \
-H "X-API-Key: vai_your_api_key" \
-F "image=@test_parked_ok.jpg" \
-F "policy=pol_acme_scooter1" \
-F 'metadata={"test":true}'The response is a standard Verification object:
{
"id": "ver_8x92m4k9",
"status": "success",
"is_compliant": true,
"confidence": 0.93,
"policy": "pol_acme_scooter1",
"category": "parked_ok",
"violation_reasons": [],
"feedback": "Scooter is parked in a marked bay with the kickstand down.",
"evaluation_source": "cloud_vlm"
}Build a small labeled set
Gather 20 to 40 photos you already know the answer to — a mix of clean
parks, each violation type, blurry shots, and frames with no scooter.
Run them through the policy and compare category and
violation_reasons against your labels. Watch for two failure modes:
- A criterion is too strict. Clean parks getting flagged usually
means a
descriptionis over-fitting to one visual cue. Loosen the wording and add an explicit PASS example. - A photo-quality problem reads as a violation. If blurry shots
land in
bad_parkinginstead ofpoor_photo, add the "if you cannot see X, do not fail this criterion" clause to the relevant criteria.
Tag every test request with metadata.test = true so you can exclude
them from analytics later:
select category, count(*)
from verify_ai_verifications
where policy_id = 'pol_acme_scooter1'
and metadata->>'test' = 'true'
group by category
order by count(*) desc;When the labeled set looks right, route a single city or a small percentage of rides to the new policy before flipping everyone over. A custom policy is a prompt — small wording changes can shift the verdict distribution more than you expect, and the cheapest place to catch that is a canary, not your whole fleet.
What's next
- Concepts: Policies — the structured policy shape in full.
- Handling retries — how the scanner
drives
maxAttemptsandautoApproveOnExhaust. - Verifying parked vehicles — an end-to-end integration using a real parking policy.