Curb v1.0 — full 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. Early-access onboarding, clean developer experience, OCPP 2.0.1 out of the box. Full capability set now in code: network hotlist, cross-operator roaming, audit-defensible dynamic pricing, and an ISO 15118 Plug-and-Charge bridge — 300 software tests passing.

≤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
300
Tests
+55 recent · all green

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 heavily maintaining ageing pay-station kiosks with 8% cash leakage. Wants to retire the hardware and move to plate-based access.

— 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,    # per-hour rate (cents)
  "daily_cap_cents":     2500,   # daily cap (cents)
  "minimum_cents":       200,    # minimum charge (cents)
  "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,    # per-kWh rate (cents)
  "ev_idle_per_min_cents": 25     # idle fee per minute (cents)
}

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

Periodic MeterValues (configurable interval)
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

Target charger compatibility at GA: ABB Terra, Wallbox Pulsar, ChargePoint Express, Tesla Wall Connector (Gen 3 with OCPP firmware), Enphase IQ, Schneider EVlink. Protocol compatibility validated in lab; production certifications pending. Adding a new model is a vendor-adapter PR — the gateway interface is stable from 1.0.

05 — Public REST API

Clean, consistent developer experience.

All endpoints under /v1, X-Curb-Api-Key on every request, tenant isolation enforced at every handler. OpenAPI 3.1 spec auto-generated by FastAPI at /docs — human-browsable and machine-consumable from the same surface. HMAC-signed webhooks use the same t=<ts>,v1=<hex> header format your team likely already verifies. All write endpoints are idempotent.

GET /v1/sites POST /v1/lanes POST /v1/plates/bulk GET /v1/plates/lookup GET /v1/sessions POST /v1/hotlist POST /v1/webhooks
# Base URL: https://api.curb.dev — replace with your provisioned endpoint during early access.

# 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

Four capabilities most mid-market parking SaaS doesn't include out of the box.

The full capability set is 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 with 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 → authorized → closed → settlement_pending → settled. find_agreement() + compute_split() helpers. Driver consent for roaming is a separate enrollment 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 software tests across test_hotlist.py, test_roaming.py, test_dynamic_pricing.py, test_iso15118.py (pipeline/integration — not a measure of recognition accuracy). Full capability surface now in code. Remaining milestones are commercial: PCI DSS L1 (in roadmap), SOC 2 Type I / II (in roadmap), 200 / 1000 paying-operator targets, 6-charger-model certification matrix.

09 — How to engage

Get started with your team.

Curb is available in early access — onboarding is handled directly with the team so your first deployment is set up for success from day one.

10 — Start

Retire the kiosk.
Request early access.

Curb is in early access — production deployments are onboarded with the team directly.