Curb M2 + M3 capability

Curb · plate-as-credential SaaS

One credential.
Every lot.
Every charger.

Plate-as-credential SaaS for independent parking operators, mid-tier EV charging networks, multi-family properties, and fleet depots. Self-serve onboarding, Stripe-style developer experience, OCPP 2.0.1 out of the box. Recently pulled forward: the M2 + M3 capability horizon — network hotlist, cross-operator roaming, audit-defensible dynamic pricing, and an ISO 15118 Plug-and-Charge bridge — all in code, with 212 tests passing in 4.2 s.

$29 /lane/mo
Per-lane base
Transparent — no "contact us" tier
$39 /chgr/mo
Per-charger base
EV Lane subscription
2.5%
+ $0.30 per txn
Stripe-grade pass-through
≤45s
Driver enrollment
Mobile-web, one-shot token
ISO 15118
Plug-and-Charge
eMAID → OCPP Authorize.req
+4
Capabilities
Hotlist · roaming · dyn pricing · ISO 15118
212
Tests
+55 recent · pass in 4.2 s

01 — Who it's for

Built for the operators Metropolis won't sell to.

The enterprise tier (Metropolis, Flash, Indigo, LAZ) is well-served. The long tail — sub-500-stall operators, mid-tier EV networks, residential garages, fleet depots — cannot justify those contracts but desperately needs to retire its cash kiosks. That's the Curb market.

— ICP · 01 · Independent parking

Priya, 44

12 surface lots, 800 stalls, southwest US. Spends $40K/yr maintaining $25K kiosks with 8% cash leakage. Wants to retire both and redirect the budget to a per-lane subscription.

— ICP · 02 · EV charging

Jordan, 38

60 chargers across 14 sites. Currently pays per-charger SaaS to a competitor. Wants Autocharge-class UX without locking drivers into one OEM ecosystem — OCPP, fewer apps.

— ICP · 03 · Multi-family

Chris, 52

480-unit rental, 60 EV chargers, 240 reserved stalls. Wants residents to park and charge with no decals, no cards, no app — plus sub-metering and guest enforcement.

— ICP · 04 · Fleet ops

Devon, 41

4 depots, 200 trucks each, mixed diesel and electric. Wants automated yard gate-in / gate-out for 200 drivers per shift, per-vehicle charging attribution, and Samsara sync.

02 — How it works

Plate in. Session, bill, receipt out.

The Curb edge agent runs at each lane or charger and posts detects to the control plane. The state machine opens a session on entry, accumulates charging activity if it's an EV lane, closes on exit, prices against the rate-card snapshot, and routes the invoice through Stripe Connect.

Step · 01

For any lane

Detect.

Edge agent reads the plate, posts to /v1/sessions or runs in-process through the SessionEngine. Plate, camera, timestamp, confidence — one POST per arrival.

SessionEngine.submit(Detect(...))
Step · 02

For noisy OCR environments

Match.

Exact then Damerau-Levenshtein fuzzy ≤ 1 edit. Ambiguous matches refuse to auto-bill — they queue for operator review rather than guessing.

match_plate(storage, operator_id, plate_text)
Step · 03

For dispute-defensible billing

Open session — rate-card snapshotted.

OPEN → IN_PROGRESS, with the rate-card snapshotted at open time. Future edits don't change closed sessions. That's what makes Curb's pricing defensible in a chargeback dispute.

SessionState.OPEN → IN_PROGRESS
Step · 04

For hourly, daily-cap, peak, EV

Exit and price.

Exit detect closes the session. Pricing engine applies hourly, daily cap, grace, rounding, peak windows, EV kWh, and idle fees — one JSON in, one breakdown out.

calculate_price(pricing, started_at, ended_at, kwh_used)
Step · 05

For operators using Stripe Connect

Invoice.

Stripe Connect Custom account; idempotent re-invoice. Zero-amount sessions short-circuit and flip to INVOICED without hitting Stripe. Payouts to the operator's bank, not ours.

invoice_session(session_id, storage, stripe)
Step · 06

For chargebacks & goodwill refunds

Disputes — 72-hour window.

72-hour window from session.ended_at. Resolve uphold (charge stands) or refund (session → VOID, invoice → REFUNDED). Every step is in the audit log.

open_dispute → resolve_dispute(uphold|refund)

03 — Pricing engine

One JSON. Every pricing model.

A rate card is a JSON document. It snapshots into the session at open time — so editing the card tomorrow never retroactively changes a closed session. The breakdown returned by calculate_price() surfaces every line item: parking_cents, ev_energy_cents, ev_idle_cents, rounded_minutes, peak_multiplier, daily_cap_applied, grace_applied.

# Rate card for a downtown parking garage with peak-hour pricing
# and a daily cap for monthly parkers.
{
  "currency": "USD",
  "hourly_cents":        500,    # $5.00 per hour
  "daily_cap_cents":     2500,   # never exceed $25/day
  "minimum_cents":       200,    # $2 minimum charge
  "grace_minutes":       5,      # first 5 min free
  "rounding_minutes":    15,     # round up to nearest 15

  "peak": {
    "windows": [
      {"days": [0,1,2,3,4],
       "start": "07:00", "end": "10:00",
       "multiplier": 1.5}     # weekday rush hour
    ]
  },

  "ev_kwh_cents":         35,    # $0.35/kWh dispensed
  "ev_idle_per_min_cents": 25     # $0.25/min occupancy when full
}

→ operator (Priya Parking · plan_tier=lane) — api_key → site (Lot A, address, tz) → zone → lane (kind=entry|exit|combo|ev) → camera + charger · Every row carries an operator_id; tenant isolation is enforced at every endpoint.

04 — EV charging

OCPP 2.0.1 first-class. No charger lock-in.

Curb speaks OCPP 2.0.1 (and 1.6 for legacy chargers) directly to the charge-point. Authorize requests resolve against the operator's plate_account table — hotlisted plates are blocked at the gateway regardless of account status. TransactionEvents auto-link to the parking session on the same lane, so the bill is one line item, not two.

OCPP · Authorize

For any compliant charger

idToken → ACCEPTED / BLOCKED / INVALID / UNKNOWN.

Hotlist trumps account status. Plate-as-idToken means the driver doesn't pull out a card or open an app — the car authenticates itself.

OcppGateway.authorize(AuthorizeRequest)
OCPP · TransactionEvent.Started

For auto-linked parking + charging

Linked to the parking session.

Opens an ocpp_session row, links to the open parking session on the same lane (if any), records meter_start_kwh. One arrival event in the operator log, not two.

curb.ocpp.transaction_started
OCPP · TransactionEvent.Updated

For live dashboard kWh

Periodic MeterValues.

Real-time meter_stop_kwh updates so dashboards see the kWh dispensed as it happens. Drivers can watch the energy delivered through the operator's own portal.

MeterValues every 30s
OCPP · TransactionEvent.Ended

For final billing reconciliation

Final meter + stop reason.

kwh_used = stop − start. Adds charger.session.started / .stopped events to the parking session log. The receipt shows the actual energy delivered, not the requested.

curb.ocpp.transaction_ended

Compatible chargers tested at GA (M3): ABB Terra, Wallbox Pulsar, ChargePoint Express, Tesla Wall Connector (Gen 3 with OCPP firmware), Enphase IQ, Schneider EVlink. Adding a new model is a vendor-adapter PR — the gateway interface is stable from 1.0.

05 — Public REST API

Stripe-grade developer experience.

All endpoints under /v1, X-Curb-Api-Key on every request, tenant isolation enforced at every handler. The OpenAPI 3.1 spec is generated by FastAPI at /docs — same surface human-browsable and machine-consumable.

GET /v1/sites POST /v1/lanes POST /v1/plates/bulk GET /v1/plates/lookup GET /v1/sessions POST /v1/hotlist POST /v1/webhooks
# 1. Mint a session by enrolling 50 employee plates in one POST.
curl -X POST https://api.curb.dev/v1/plates/bulk \
  -H "X-Curb-Api-Key: sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"csv_text":"plate,email,tag\nEMP1001,a@co,employee\nEMP1002,b@co,employee\n…"}'

# Response: 200
{ "enrolled": 50, "skipped": 0, "errors": [] }


# 2. Real-time plate lookup with fuzzy fall-back.
curl "https://api.curb.dev/v1/plates/lookup?plate=abc1z3" \
  -H "X-Curb-Api-Key: sk_live_…"

{
  "confidence": "fuzzy",
  "account": {
    "id": 42,
    "primary_plate": "ABC123",
    "email": "driver@example.com",
    "billing_status": "active"
  },
  "candidates": [42]
}


# 3. List today's disputed sessions for triage.
curl "https://api.curb.dev/v1/sessions?status=disputed" \
  -H "X-Curb-Api-Key: sk_live_…"

06 — Webhooks

Same scheme your team already verifies.

Curb signs every webhook with HMAC-SHA256 in the same t=<ts>,v1=<hex> header format Stripe uses. If your team already verifies Stripe-Signature, verifying Curb-Signature is five lines.

session.opened session.closed session.invoiced session.disputed session.refunded plate.enrolled payment.failed charger.session.started charger.session.stopped hotlist.hit
# Inside your webhook handler:
from curb.webhooks import verify_signature

def webhook_handler(request):
    body = request.body.decode("utf-8")
    sig  = request.headers["Curb-Signature"]
    if not verify_signature(WEBHOOK_SECRET, body, sig,
                            max_age_seconds=300):
        return Response(status=401)

    payload = json.loads(body)
    # … process payload.session_id, payload.amount_cents, …
    return Response(status=200)

07 — Operator console + driver mobile-web

Two UIs. One ASGI app.

The operator console is server-rendered FastAPI + HTMX — no SPA build, no Node toolchain, runs anywhere Python runs. The driver-facing mobile-web is a single touch-friendly form that completes enrollment in under 45 seconds.

View · Dashboard

Plate accounts, hotlist, sessions.

Plate accounts, hotlisted plates, recent sessions table, plan tier banner. Everything an operator needs in the first 10 seconds after login.

View · Sessions

Filter, drill in, replay.

Filter by status (open / in_progress / closed / invoiced / disputed / void). Click into per-session event log with full payload pretty-print.

View · Plates

Single-row + bulk CSV.

Single-row form to add a plate account. Bulk CSV import via the REST API at /v1/plates/bulk. Both surfaces share the same idempotency rules.

View · Hotlist

Operator-scoped, with reason + expiry.

Add a plate to the operator-scoped hotlist with reason. Optional expiry — useful for "30-day banned" rules without a manual cleanup task.

View · Lanes

Site / zone / lane hierarchy.

Site / zone / lane hierarchy with one-click QR-mint for driver enrollment at any lane. The QR is a one-shot token; the URL is short enough to print on a sign.

View · Driver enroll

Mobile-web, under 45 seconds.

Mobile-web form behind a one-shot token. Plate + optional email. "You're set" confirmation with a dashed-border plate display. No app install, no account creation friction.

08 — Capability horizon · M2 / M3 / Post-1.0, pulled forward

Four capabilities the incumbents quietly don't ship.

PRD-02's M2 + M3 + Post-1.0 capability features are now in code: a consent-gated network hotlist, reciprocal cross-operator plate roaming with revenue-share, audit-defensible dynamic pricing, and an ISO 15118 Plug-and-Charge bridge over the existing OCPP layer. Real Protocol architecture + deterministic stub adapters; production integrations plug behind the same Protocols.

01 · hotlist

vs covert cross-operator hotlist exchange

Network hotlist — consent + warrant gated.

Closed HotlistReason enum (chronic_non_payment, incident_damage, incident_theft, incident_harassment, expired_due_recovery, operator_request) — no free-text reasons. Plate values are SHA-256 hashed; cross-operator adoption requires the originating operator to opt the flag into the network and the adopting operator to explicitly affirm. check_plate() returns reason-keyed action — every match is audit-chained.

curb.hotlist · check_plate
02 · roaming

vs per-operator lock-in (TollPass, ParkMobile, ChargePoint)

Reciprocal plate roaming — signed revenue-share.

RoamingAgreement between two operators (signed + counter-signed, explicit revenue-share %). RoamingSession lifecycle: opened → roaming_auth_requested → authorised → closed → settlement_pending → settled. find_agreement() + compute_split() helpers. Driver consent for roaming is a separate enrolment flag from the basic Curb signup.

curb.roaming · RoamingAgreement
03 · dynamic pricing

vs opaque "surge" multipliers

Predictive dynamic pricing — every line audit-defensible.

DynamicPricingEngine composes time-of-day rules, occupancy surges / discounts, and event-window overrides. Closed AdjustmentReason enum so every price line on the receipt is audit-defensible (no opaque "surge" numbers). Hard ceiling + floor multipliers enforce consumer-protection bounds.

curb.dynamic_pricing · DynamicPricingEngine
04 · ISO 15118

vs Autocharge MAC-spoofing (ineligible for many CSMS)

Plug-and-Charge bridge — eMAID → OCPP Authorize.req.

ContractResolver Protocol + StubContractResolver for cert → customer lookup. to_ocpp_authorize() translates a successful PnC handshake into an OCPP 1.6 / 2.0.1 Authorize.req payload (idTokenType eMAID with MO-Contract + Cert-SHA256 additional info) so the existing CSMS layer (curb.ocpp) doesn't need PnC awareness. Certificate body is not retained — only the fingerprint hash.

curb.iso15118 · to_ocpp_authorize

+55 new tests across test_hotlist.py, test_roaming.py, test_dynamic_pricing.py, test_iso15118.py. PRD-02 §9 M2 + M3 + Post-1.0 capability surface now in code. Remaining milestones are commercial: PCI DSS L1, SOC 2 Type I / II, 200 / 1000 paying-operator targets, 6-charger-model certification matrix.

09 — Pricing

Transparent. Per lane, per charger.

No enterprise sales. No "contact us" tier hiding the only pricing that matters. Lane is the entry tier — single gate, unlimited sessions, REST + webhooks. EV Lane adds OCPP for chargers. Business and Enterprise tiers add SSO, white-label, and a dedicated CSM.

EV Lane

EV networks + multi-family

$39 /chgr/mo

2.5% + $0.30 per txn

  • Everything in Lane
  • OCPP 2.0.1 + 1.6
  • kWh + idle billing
  • Charger compatibility matrix

Business

≥ 20 lanes

$1,200 +/mo

Custom take rate

  • White-label dashboard
  • SAML SSO (Okta, Azure AD)
  • Priority support
  • Custom domain

Enterprise

≥ 200 lanes

$10K +/mo

Custom take rate + SLA

  • Dedicated CSM
  • 99.95% uptime SLA
  • Quarterly business reviews
  • Compliance pack (DPA, SOC 2)

Hardware add-on: $499 one-time per lane kit (Intel mini-PC + 2 ONVIF/RTSP cameras, pre-configured). All tiers ship Stripe Connect Custom payouts to your bank.

10 — Start

Retire the kiosk.
Start with Lane.

$29 per gate per month, unlimited sessions. The same REST surface that scales to 4,000 operators at GA.