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:

  1. A structured policy authored as a single JSON document — categories, criteria, retry behaviour, and scanner copy.
  2. That policy attached to your account under a stable policy ID.
  3. 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:

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

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

json
[
  {
    "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
  }
]
Treat descriptions as prompts

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 — after maxAttempts failures 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:

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

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

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

bash
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

bash
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:

json
{
  "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 description is 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_parking instead of poor_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:

sql
select category, count(*)
from verify_ai_verifications
where policy_id = 'pol_acme_scooter1'
  and metadata->>'test' = 'true'
group by category
order by count(*) desc;
Roll out gradually

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

Get in Touch

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