Skip to main content

nfors.ai API reference

A REST/JSON API that lets operators sync active permits, manage whitelisted plates, look up matches in real time, and receive webhook events when parking charges, payments, and disputes happen.

Base URL
https://nfors.ai/api/v1

Authentication

Every request must send a bearer token in the Authorization header. Keys start with nfors_live_. Generate one under Settings → API keys; the full key is shown once at creation time.

Authorization: Bearer nfors_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx

Keys are operator-scoped — every object you create or read is automatically confined to your operator. Revoke a key at any time; requests using it will 401 immediately.

Errors, pagination, rate limits

All errors return a JSON envelope with a short machine-readable code and a human message:

{
  "error": {
    "code": "invalid_request",
    "message": "zone_id does not belong to your operator."
  }
}

Status codes in use: 400 invalid_request, 401 unauthorized, 403 forbidden, 404 not_found, 409 conflict (duplicate external_id), 422 unprocessable, 429 rate_limited, 500 internal_error.

List endpoints return up to limit items (default 50, max 200) and include has_more and next_cursor. Pass ?cursor=with the previous response’s next_cursor to paginate.

Rate limits are per API key, per scope, per second: writes default to 100/s and reads to 500/s, each overridable per key (rate_limit_per_sec_writes / rate_limit_per_sec_reads). A 429 carries Retry-After (seconds) plus X-RateLimit-Scope and X-RateLimit-Limit so you can back off against the right budget.

GET /me

Returns the operator your key belongs to. Use this to verify configuration.

curl -s https://nfors.ai/api/v1/me \
  -H "Authorization: Bearer $NFORS_API_KEY"

# {
#   "operator": { "id": "...", "slug": "level-parking", "company_name": "Level Parking", ... },
#   "api_key_id": "..."
# }

GET /zones

Lists every zone in your operator. Every zone has our id (UUID) and an optional operator-assigned external_id. You can reference zones by either in any request.

curl -s https://nfors.ai/api/v1/zones -H "Authorization: Bearer $NFORS_API_KEY"

# {
#   "data": [
#     {
#       "id": "2f27d3f2-…",
#       "external_id": "LOT-A",
#       "name": "North Lot",
#       "max_capacity": null,
#       "effective_max_capacity": 40,
#       "effective_enforcement_hours": {
#         "mode": "restricted",
#         "windows": [{ "days": ["mon","tue","wed","thu","fri"],
#                       "start": "09:00", "end": "17:00" }]
#       },
#       "effective_operating_hours": { "text": "Mon-Fri 7a-7p" },
#       "enforcement_hours_zone_override": false,
#       "operating_hours_zone_override": false,
#       "max_capacity_zone_override": false,
#       ...
#     }
#   ]
# }

The raw per-zone max_capacity, operating_hours, and enforcement_hours fields stay backward-compatible: they’re still whatever is stored on the zones row itself. The additive effective_*fields (P2.1, migration 0053) carry the value that actually applies to the zone after resolving against the parent location’s defaults:

  • effective_max_capacity, effective_operating_hours, effective_enforcement_hours— zone value when non-null; otherwise the parent location’s default; null when neither side is set.
  • max_capacity_zone_override, operating_hours_zone_override, enforcement_hours_zone_override — true iff the zone row itself carried the value. False for inherited-from-location or both-null.

Locations

A location is an organisational grouping above zones — a facility, a campus, a lot cluster. An operator may have many locations; each location may contain many zones. Compliance (state, signage attestation) stays at the zone level; locations are optional and purely organisational today.

Create

curl -s -X POST https://nfors.ai/api/v1/locations \
  -H "Authorization: Bearer $NFORS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "North Pensacola Campus",
    "external_id": "lp-campus-42",
    "address": "25 W Government St, Pensacola, FL 32502",
    "timezone": "America/Chicago"
  }'

List, fetch, update, delete

GET    https://nfors.ai/api/v1/locations
GET    https://nfors.ai/api/v1/locations/{id}
PATCH  https://nfors.ai/api/v1/locations/{id}
DELETE https://nfors.ai/api/v1/locations/{id}

Deleting a location is safe: zones currently assigned here have their location_id cleared but are not deleted.

Location object

{
  "id": "uuid",
  "name": "North Pensacola Campus",
  "external_id": "lp-campus-42" | null,
  "address": "..." | null,
  "timezone": "America/Chicago" | null,   // IANA
  "notes": "..." | null,
  "enforcement_hours": {...} | null,       // P2.1 default; null = no default
  "operating_hours":   {...} | null,
  "max_capacity":      40   | null,
  "created_at": "ISO 8601",
  "updated_at": "ISO 8601"
}

The three override fields feed every zone assigned to this location whose own column is null. Zone-level values win over location defaults — see effective_* on GET /zones for the resolved shape.

Zone mappings (third-party zone codes)

When an operator uses a payment provider like Parkmobile, Passport, HONK, or T2, the provider has its own zone-code namespace. Zone mappings attach those upstream codes to your nfors zones so our pull-based adapters can route incoming permits and sessions correctly. An operator can toggle each integration on per-zone from the dashboard; this API is the programmatic equivalent.

Create

curl -s -X POST https://nfors.ai/api/v1/zone-mappings \
  -H "Authorization: Bearer $NFORS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "zone_external_id": "lot-A",
    "provider": "PARKMOBILE",
    "source_zone_code": "12345",
    "config": { "space_range": "A1-A50" }
  }'

source_zone_code is unique per (operator_id, provider) — one upstream code can only point to one nfors zone. config holds provider-specific extras (e.g. Parkmobile space_range).

List, fetch, update, delete

# list (filters: zone_id, provider, limit, cursor)
GET    https://nfors.ai/api/v1/zone-mappings?provider=PARKMOBILE

GET    https://nfors.ai/api/v1/zone-mappings/{id}
PATCH  https://nfors.ai/api/v1/zone-mappings/{id}
DELETE https://nfors.ai/api/v1/zone-mappings/{id}

Zone mapping object

{
  "id": "uuid",
  "zone_id": "uuid",
  "provider": "PARKMOBILE" | "PARKLYNC" | "PASSPORT" | "HONK" | "T2",
  "source_zone_code": "12345",
  "config": { "space_range": "A1-A50" },
  "created_at": "ISO 8601",
  "updated_at": "ISO 8601"
}

Only the four pull-based providers appear here. LEVEL, MANUAL, and DASHBOARDare push or in-app paths — they don’t need a mapping.

Metadata conventions

The metadataobject on permits, whitelist entries, and violations is free-form JSON; it’s passed through untouched and surfaced on the parker portal and in webhook payloads. Two conventions keep operator + integration data legible:

  • metadata.tenant, metadata.unit, metadata.reservation_id, etc. — operator-side context that helps your support team recognise a permit at a glance.
  • metadata.provider_raw— reserved for the verbatim upstream payload when data flows through a nfors provider adapter (Parkmobile, ParkLync, Passport, HONK, etc). Our pull-based adapters write the raw provider response here so dispute forensics can trace any permit back to its source of truth. If you’re pushing permits directly, treat provider_rawas reserved — don’t set it yourself unless you’re forwarding data from another system we haven’t adapted to yet.

Using your own IDs (external_id)

Every zone and charge reason can carry an operator-assigned external_id — the code your PMS, ERP, or reservation system uses for that record. Set it once in the dashboard (Manage → Zones / Charge reasons) and reference it from the API instead of our UUID. Same integration works across your portfolio without needing a UUID lookup step.

Every request that accepts zone_id also accepts zone_external_id. Same for violation_reason_id / violation_reason_external_id. Provide one, not both.

# Reference by external_id — no prior /zones call needed
curl -X POST https://nfors.ai/api/v1/permits \
  -H "Authorization: Bearer $NFORS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "zone_external_id": "LOT-A",
    "plate_number": "ABC1234",
    "plate_state": "FL",
    "valid_from": "2026-04-15T00:00:00Z",
    "valid_to":   "2026-05-15T00:00:00Z",
    "external_id": "pms-44817"
  }'

# Bulk permits: mix UUIDs and external_ids per row
curl -X POST https://nfors.ai/api/v1/permits/bulk \
  -H "Authorization: Bearer $NFORS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "permits": [
      { "zone_external_id": "LOT-A", "plate_number": "AAA111", "plate_state": "FL", ... },
      { "zone_id": "2f27d3f2-...",    "plate_number": "BBB222", "plate_state": "FL", ... }
    ]
  }'

# Whitelist across multiple zones by external_id
curl -X POST https://nfors.ai/api/v1/whitelist \
  -H "Authorization: Bearer $NFORS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "zone_external_ids": ["LOT-A","LOT-B"],
    "plate_number": "VIP100",
    "plate_state": "FL"
  }'

Webhook payloads include zone_external_id and violation_reason_external_id (nullable) alongside the UUIDs, so your consumer can route on the IDs it already knows.

Permits

A permit authorizes a specific plate, in a specific zone, during a specific time window. Use permits for standard parkers (monthly passholders, resident spaces, reservations, event tickets).

Permits are zone-scoped. If you need a plate valid across every zone in your operator (ownership vehicles, service trucks, platform-wide VIPs), use the whitelist with all_zones: true instead. Posting a permit with a sentinel like zone_external_id: "ALL" returns a 400 pointing here.

Upsert a permit

curl -s -X POST https://nfors.ai/api/v1/permits \
  -H "Authorization: Bearer $NFORS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "zone_external_id": "lot-A",
    "plate_number": "ABC1234",
    "plate_state": "FL",
    "valid_from": "2026-04-14T00:00:00Z",
    "valid_to":   "2026-05-14T00:00:00Z",
    "permit_type": "monthly",
    "provider": "LEVEL",
    "channel": "SUBSCRIPTION",
    "space_number": "A12",
    "external_id": "lp-44817",
    "metadata": { "tenant": "Unit 204" }
  }'

If external_id is provided, the request is idempotent — sending the same external_id again updates the existing permit instead of creating a duplicate. Omit external_id for one-off inserts.

Bulk upsert (up to 1000 per request)

curl -s -X POST https://nfors.ai/api/v1/permits/bulk \
  -H "Authorization: Bearer $NFORS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "permits": [
      { "zone_id": "...", "plate_number": "AAA111", "plate_state": "FL",
        "valid_from": "...", "valid_to": "...", "external_id": "p-1" },
      { "zone_id": "...", "plate_number": "BBB222", "plate_state": "FL",
        "valid_from": "...", "valid_to": "...", "external_id": "p-2" }
    ]
  }'

# {
#   "data": [ /* created/updated permit objects */ ],
#   "accepted_count": 2,
#   "rejected": []
# }

Rows with an invalid zone_id are reported in rejectedalongside their index. The whole request only 4xx’s when nothing was valid.

List, fetch, update, delete

# list (filters: plate, state, zone_id, active_at, external_id,
#        provider, channel, space_number, status, limit, cursor)
GET https://nfors.ai/api/v1/permits?active_at=2026-04-14T18:00:00Z&provider=PARKMOBILE&limit=100

GET    https://nfors.ai/api/v1/permits/{id}
PATCH  https://nfors.ai/api/v1/permits/{id}
DELETE https://nfors.ai/api/v1/permits/{id}

Permit object

{
  "id": "uuid",
  "zone_id": "uuid",
  "plate_number": "ABC1234",     // always uppercase, whitespace stripped
  "plate_state": "FL",            // uppercase 2-letter code
  "country": "US",                // ISO-3166 alpha-2, defaults to "US"
  "make_model": "Honda Civic" | null,
  "valid_from": "ISO 8601",
  "valid_to":   "ISO 8601",
  "provider": "PARKMOBILE" | "PARKLYNC" | "PASSPORT" | "HONK" | "T2" | "LEVEL" | "MANUAL" | "DASHBOARD",
  "channel": "SUBSCRIPTION" | "PM_SESSION" | "GUESTPAY" | ... | null,
  "space_number": "A12" | null,
  "status": "ACTIVE" | "ENDED" | "VOIDED" | "SUPERSEDED",
  "voided_reason": "REFUND" | "DISPUTE" | "ADMIN_VOID" | "PROVIDER_VOID" | "REPLACED" | null,
  "ended_at": "ISO 8601" | null,  // set when observed terminated; null while ACTIVE
  "supersedes_permit_id": "uuid" | null,
  "source": "PMS" | null,         // deprecated — prefer provider/channel
  "permit_type": "monthly" | "hourly" | "event" | "employee" | "visitor" | "resident" | "other" | null,
  "metadata": { ... },
  "external_id": "your-id" | null,
  "created_via": "api" | "dashboard",
  "created_at": "ISO 8601",
  "updated_at": "ISO 8601"
}

Provider + channel split the legacy free-form source into two orthogonal fields. A Parkmobile hourly session and a Level Parking GuestPay session are different providers but similar channels(both transient paid). Separating them lets you query “all Parkmobile activity” independently of “all transient-paid activity.”

Space number lets you attach permits to a specific numbered stall in a lot. Match-time lookups can filter to the exact space or fall back to zone-wide permits.

Idempotency is scoped to (operator_id, provider, external_id). Parkmobile “12345” and Passport “12345” are distinct rows — they do not collide.

Legacy source is still accepted on write and will be translated to provider/channel when recognisable. The source field is deprecated and will be removed in a future major version.

Whitelist

A whitelist entry authorizes a plate across multiple zones (or every zone) for an optional time window. Use it for cross-property passes, employee vehicles, VIP passes, and any plate that should always match when a camera or agent sees it.

Operator-wide passes (Super VIP / ownership / service fleet / law-enforcement plates) are a whitelist entry with all_zones: true, valid_from/valid_to both null, and a descriptive metadata.reason. The plate-event decision tree matches these on every zone in your operator and returns decision: “no_action” with matched_kind: “whitelist”— never a violation.

Upsert across specific zones

curl -s -X POST https://nfors.ai/api/v1/whitelist \
  -H "Authorization: Bearer $NFORS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "zone_ids": ["zone-1-uuid", "zone-2-uuid"],
    "plate_number": "VIP100",
    "plate_state": "FL",
    "valid_to": "2027-01-01T00:00:00Z",
    "permit_type": "employee",
    "external_id": "hrms-3319"
  }'

Upsert across every zone

curl -s -X POST https://nfors.ai/api/v1/whitelist \
  -H "Authorization: Bearer $NFORS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "all_zones": true,
    "plate_number": "OWNER1",
    "plate_state": "FL"
  }'

Exactly one of zone_ids or all_zones: true must be provided. valid_from / valid_to are both optional; omit both for a permanent entry.

Bulk + CRUD

POST   https://nfors.ai/api/v1/whitelist/bulk   # up to 1000 entries
GET    https://nfors.ai/api/v1/whitelist         # filters: plate, state, external_id,
                                 #   provider, channel
GET    https://nfors.ai/api/v1/whitelist/{id}
PATCH  https://nfors.ai/api/v1/whitelist/{id}
DELETE https://nfors.ai/api/v1/whitelist/{id}

Whitelist object

{
  "id": "uuid",
  "zone_ids": ["uuid", ...] | null,   // null = applies to every zone
  "all_zones": boolean,
  "plate_number": "VIP100",
  "plate_state": "FL",
  "valid_from": "ISO 8601" | null,
  "valid_to":   "ISO 8601" | null,
  "provider": "PARKMOBILE" | "PARKLYNC" | "PASSPORT" | "HONK" | "T2" | "LEVEL" | "MANUAL" | "DASHBOARD",
  "channel": "EMPLOYEE" | "OWNER" | ... | null,
  "source": "legacy-hint" | null,     // deprecated — prefer provider/channel
  "permit_type": "employee" | "visitor" | ... | null,
  "metadata": { ... },
  "external_id": "your-id" | null,
  "created_via": "api" | "dashboard",
  "created_at": "ISO 8601",
  "updated_at": "ISO 8601"
}

Whitelist idempotency is scoped to (operator_id, provider, external_id) — same as permits — so two providers can both push the same upstream ID without colliding.

POST /files

Upload an image before creating a parking charge. Max 10 MB per file; accepted types: image/jpeg, image/png, image/webp, image/heic. Returns a file_id you reference from POST /violations. Files that aren’t attached to a parking charge within 24h are garbage-collected.

curl -X POST https://nfors.ai/api/v1/files \
  -H "Authorization: Bearer $NFORS_API_KEY" \
  -F "file=@/path/to/plate.jpg"

# {
#   "data": { "id": "3f2b…", "size": 214300, "mime_type": "image/jpeg", "created_at": "..." }
# }

POST /violations (LPR & programmatic issuance)

Create a parking charge from an external system (LPR cameras, reservation software, manual tools). The charge flows through the same lifecycle as agent-issued ones: parker portal, Stripe payment, dispute, refund.

Two ways to attach images

Supply either file_ids (from POST /files) or image_urls (publicly fetchable HTTPS URLs — often signed URLs from your LPR storage). Mix and match up to 5 images total. URL fetches reject redirects and any host that resolves to a private IP.

End-to-end LPR submission

# 1. Upload the plate close-up and wide shot.
FILE1=$(curl -s -X POST https://nfors.ai/api/v1/files \
  -H "Authorization: Bearer $NFORS_API_KEY" \
  -F "file=@plate.jpg" | jq -r .data.id)
FILE2=$(curl -s -X POST https://nfors.ai/api/v1/files \
  -H "Authorization: Bearer $NFORS_API_KEY" \
  -F "file=@vehicle.jpg" | jq -r .data.id)

# 2. Create the violation.
curl -X POST https://nfors.ai/api/v1/violations \
  -H "Authorization: Bearer $NFORS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "zone_id": "2f27d3f2-...",
    "violation_reason_id": "a1b2c3d4-...",
    "plate_number": "ABC1234",
    "plate_state": "FL",
    "latitude": 30.4213,
    "longitude": -87.2169,
    "issued_at": "2026-04-15T14:32:07Z",
    "source": "api_lpr",
    "provider": "LEVEL",
    "channel": "FIELD_LPR",
    "external_id": "lpr-batch-7781",
    "notes": "Vehicle in fire lane",
    "metadata": {
      "camera_id": "lot-a-north",
      "lpr_confidence": 0.982,
      "detection_time": "2026-04-15T14:32:04Z",
      "make_model": "Toyota Camry / silver"
    },
    "file_ids": ["'"$FILE1"'", "'"$FILE2"'"]
  }'

About the three similar-sounding fields:source is the capture method (api_lpr | api_manual | api); provider is which system originated the data (your operator code or an upstream integration); channel is what kind of interaction it is (e.g. FIELD_LPR, GATE_LPR). Both provider and channel are optional; sensible defaults apply (MANUAL, null). Violations idempotency is scoped to (operator_id, provider, external_id) so two upstream systems can both reuse the same ID.

Or skip the upload step with image_urls

curl -X POST https://nfors.ai/api/v1/violations \
  -H "Authorization: Bearer $NFORS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "zone_id": "...",
    "violation_reason_id": "...",
    "plate_number": "ABC1234",
    "plate_state": "FL",
    "latitude": 30.4213,
    "longitude": -87.2169,
    "image_urls": [
      "https://lpr.level-parking.com/signed/plate-7781.jpg",
      "https://lpr.level-parking.com/signed/wide-7781.jpg"
    ]
  }'

URLs must be https://, return an image/*content-type, be at most 10 MB, and not redirect. nfors fetches once and stores the bytes.

Idempotency

Supply external_id to make the request idempotent — replays return the original charge with idempotent_replay: true instead of creating a duplicate.

Permit-match guard

If the plate has an active permit or whitelist entry in the zone at issued_at, the POST returns 409 conflict with the matching record. Resend with permit_override_reasonto issue anyway (recorded in the charge’s notes for audit).

Response

{
  "data": {
    "id": "...", "violation_number": "NF-2026-000834",
    "status": "issued", "source": "api_lpr", "external_id": "lpr-batch-7781",
    "plate_number": "ABC1234", "plate_state": "FL",
    "base_charge_amount": 7500, "total_amount": 7500,
    "latitude": 30.4213, "longitude": -87.2169,
    "issued_at": "2026-04-15T14:32:07Z",
    "metadata": { "camera_id": "...", "lpr_confidence": 0.982, ... },
    "images": [
      { "storage_path": "...", "url": "https://signed.supabase.co/..." }
    ]
  }
}

Signed image URLs expire in ~1 hour. Use GET /violations/{id} to get a fresh set.

GET /permit-match

Check whether a plate has an active permit or whitelist entry in a zone right now (or at a specific timestamp). This is the same lookup your agents see in the field.

curl -s "https://nfors.ai/api/v1/permit-match?plate=ABC1234&state=FL&zone_external_id=lot-A&space_number=A12" \
  -H "Authorization: Bearer $NFORS_API_KEY"

# {
#   "valid": true,
#   "matches": [
#     { "kind": "permit", "id": "...", "valid_from": "...", "valid_to": "...",
#       "provider": "PARKMOBILE", "channel": "PM_SESSION", "space_number": "A12",
#       "status": "ACTIVE",
#       "source": "legacy-hint", "permit_type": "monthly", "metadata": { ... } }
#   ]
# }

Query params: plate and state are required. Provide either zone_id or zone_external_id. Optional space_number narrows matches to permits on that exact stall (plus any zone-wide permits without a stall number). Optional at (ISO datetime) lets you query a specific point in time; defaults to now. Optional grace_seconds(0–900) extends a permit’s upper validity boundary at match-time — useful for LPR systems absorbing small clock skew with the upstream provider without false negatives on just-expired sessions. The permit row itself isn’t modified.

Batch lookup (up to 200 plates per request)

curl -s -X POST https://nfors.ai/api/v1/permit-match/batch \
  -H "Authorization: Bearer $NFORS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "at": "2026-04-15T19:00:00Z",
    "grace_seconds": 30,
    "queries": [
      { "plate": "ABC1234", "state": "FL", "zone_external_id": "lot-A" },
      { "plate": "XYZ7890", "state": "FL", "zone_external_id": "lot-A", "space_number": "A12" }
    ]
  }'

# {
#   "count": 2,
#   "valid_count": 1,
#   "error_count": 0,
#   "results": [
#     { "index": 0, "valid": true,  "matches": [{ "kind": "permit", "provider": "PARKMOBILE", ... }], "error": null },
#     { "index": 1, "valid": false, "matches": [], "error": null }
#   ]
# }

Top-level at and grace_seconds apply as defaults; any query can override its own. Unknown zone_id or zone_external_id does not fail the whole request — the offending row carries an error string and others proceed.

POST /plate-events (raw LPR observations)

Send a raw plate observation and let nfors adjudicate it, instead of deciding to issue yourself. The decision branches on the zone’s enforcement_mode: a permit match returns no_action; an unmatched plate in an alert_only zone is logged (and queued through the legal grace window); an unmatched plate in an enforce zone creates a charge with source="api_lpr". The permit match runs the same three tiers as everywhere else (exact → state-fallback → confusable), and state grace is applied to the parking duration (exit_at - entry_at when present).

curl -X POST https://nfors.ai/api/v1/plate-events \
  -H "Authorization: Bearer $NFORS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "zone_external_id": "crystal-tower",
    "plate_number": "JP6WZ4",
    "plate_state": "FL",
    "entry_at": "2026-06-08T14:02:00Z",
    "exit_at":  "2026-06-08T15:30:00Z",
    "external_id": "lpr-88213"
  }'

Every decision fires a violation.evaluated webhook with a match_reasoning block (including a state_fallback or confusable_fallback sub-object when a fuzzy tier matched). Replays on the same (operator, external_id) are idempotent. 4xx bodies carry a details.grace_window diagnostic (timestamps only, no PII).

Enforcement block-outs

Schedule a time-bounded enforcement pause — “don’t ticket during tonight’s event, 6–9 PM.” Scope is zone XOR location XOR operator-wide, and the window has a 14-day ceiling. While a block-out is active, LPR plate-events return no_action and /violationscreates auto-void. Optionally bulk-void existing in-window charges — paid ones are surfaced for manual review, never auto-voided.

# Schedule
curl -X POST https://nfors.ai/api/v1/blockouts \
  -H "Authorization: Bearer $NFORS_API_KEY" \
  -d '{
    "scope": "zone",
    "zone_id": "...",
    "starts_at": "2026-06-12T22:00:00-05:00",
    "ends_at":   "2026-06-13T01:00:00-05:00",
    "void_existing_violations": true,
    "external_id": "wedding-0612"
  }'

# List with scope/status/active_at filters + cursor — GET /blockouts
# Cancel — POST /blockouts/{id}/cancel

Timestamps are RFC3339 with offset. external_id makes POST idempotent (a replay returns 200 with voided_count: 0). Scheduling and cancelling fire blockout.created / blockout.cancelled webhooks.

Webhooks

Configure endpoints under Settings → Webhooks. We’ll POST a JSON event to each matching endpoint and retry on non-2xx responses with exponential backoff (1m, 5m, 30m, 2h, 6h, 12h, 24h — up to 8 attempts over ~48 hours).

Event envelope

{
  "type": "violation.issued",
  "created_at": "2026-04-14T18:42:03.004Z",
  "data": { "violation_id": "...", "violation_number": "NF-2026-000042", "zone_id": "...", "plate_number": "ABC1234", "plate_state": "FL" }
}

Signature verification

Every delivery includes a nfors-signature header in the form t=<unix-seconds>,v1=<hex-hmac>. Compute HMAC-SHA256 of <t>.<raw-body>with your endpoint’s signing secret (whsec_..., shown once at creation) and compare against v1.

// Node 20+
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(',').map(p => {
    const i = p.indexOf('='); return [p.slice(0, i), p.slice(i + 1)];
  }));
  const t = Number(parts.t);
  if (Math.abs(Date.now()/1000 - t) > 300) return false;  // 5-min replay window
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  const got = Buffer.from(parts.v1, 'hex');
  return got.length === expected.length && timingSafeEqual(expected, got);
}

Read the raw request body before JSON-parsing for verification to succeed byte-for-byte.

Event types

  • permit.created / permit.updated / permit.deleted
  • whitelist.created / whitelist.updated / whitelist.deleted
  • violation.issued — a new parking charge was issued
  • violation.voided — a supervisor voided a charge
  • violation.reduced — a supervisor reduced a charge’s amount
  • violation.paid — a charge was paid in full, by Stripe Checkout or a recorded off-platform payment
  • payment.succeeded — a payment was received: Stripe Checkout, or an operator-recorded check / money order / cash. A partial off-platform payment fires this without violation.paid
  • payment.refunded — refund processed. Payload carries refund_reason: dispute_approved | duplicate_payment | operator_error | provider_error | goodwill | other | null (unknown; set it by passing metadata.reason on stripe.refunds.create). A reversed off-platform payment also fires this, with reversed: true.
  • dispute.created — parker submitted a dispute
  • dispute.resolved — dispute approved or denied
  • violation.evaluated — every /plate-events decision (issued / no-action / alert-logged), with a match_reasoning block
  • violation.corrected — a supervisor corrected a charge’s plate or vehicle; payload carries fields_changed, plate_changed, void_cascaded
  • queue.opened / queue.ready / queue.resolved — grace-period queue lifecycle (resolved carries resolution.kind: violation / paid / departed / void / expired)
  • blockout.created / blockout.cancelled — an enforcement block-out was scheduled or cancelled

Register endpoints via the API

Manage endpoints programmatically instead of the dashboard. POST /webhook-endpoints returns { id, signing_secret } on a fresh create (the secret is shown once); a repeat of the same (operator, url) returns { id, idempotent: true } with no secret. DELETE /webhook-endpoints/{id} deregisters it (tenant-scoped 404; cascades pending deliveries). Rotate a secret with a DELETE + POST cycle.

curl -X POST https://nfors.ai/api/v1/webhook-endpoints \
  -H "Authorization: Bearer $NFORS_API_KEY" \
  -d '{ "url": "https://example.com/hooks/nfors" }'

Redelivery

Every delivery (succeeded, failed, abandoned) is kept for audit under Settings → Webhooks. Click Redeliver on any row to retry.

POST /reports/execute

Run any Reports v2 query as JSON. The request body is the same declarative shape the dashboard Builder emits — pick a subject, filters, group-by, and metric, and the cube returns the uniform aggregated row set.

Request

POST /api/v1/reports/execute
Authorization: Bearer nfors_live_...
Content-Type: application/json

{
  "subject": "violations",
  "filters": {
    "date_range": { "kind": "preset", "preset": "last_30d" },
    "zone_ids": ["<zone-uuid>"]
  },
  "group_by": "agent",
  "metric": "count"
}

Subjects, group-bys, and metrics

  • violations — group by day/week/month/agent/zone/location/reason/status/plate_state/hour_of_day/day_of_week/none; metrics: count/sum_total/sum_base/sum_late_fee/paid_count/paid_rate/photo_coverage_rate
  • payments — group by day/week/month/status/refund_reason/zone/location/agent/none; metrics: count/sum_total/sum_platform/sum_operator/sum_refund
  • check_ins — group by day/week/month/agent/zone/hour_of_day/day_of_week/none; metrics: count/distinct_zones/distinct_agents
  • disputes — group by day/week/month/status/reviewer/zone/agent/none; metrics: count/approval_rate/denial_rate/avg_resolution_days/on_time_rate
  • collections — group by day/week/month/status/source/zone/agent/none; metrics: count/cure_rate/letters_per_case/mailed_avg

Date range

Either a preset (last_7d, last_30d, last_90d, month_to_date, quarter_to_date, year_to_date) or a custom ISO-8601 pair:

"date_range": {
  "kind": "custom",
  "from": "2026-04-01T00:00:00.000Z",
  "to":   "2026-04-30T23:59:59.999Z"
}

Response

{
  "rows": [
    { "dim_key": "<agent-uuid>", "dim_label": "Alex Officer", "metric_value": 47 },
    { "dim_key": "<agent-uuid>", "dim_label": "Priya Lopez", "metric_value": 31 }
  ],
  "from":   "2026-03-24T00:00:00.000Z",
  "to":     "2026-04-23T23:59:59.999Z",
  "metric": { "key": "count", "label": "Violations", "shape": "count" },
  "query":  { ...echoed back for audit/caching... }
}

rows is sorted by dim_key. Money metrics return cents (integer). Percent metrics return a 0..1 ratio. The metric.shape field tells you which formatter to apply.

Scope & rate limit

Scope is derived from the API key — the operator_id in the body cannot widen it. Counts against the read rate budget (500/s default per key; overridable per-key via rate_limit_per_sec_reads).

Idempotency & ordering

For permits and whitelist entries, pass an external_id to make writes idempotent: subsequent POSTs with the same external_id update the existing row. Webhook deliveries include a unique nfors-event-id header — store it and reject replays on your side for exactly-once handling.

We do not guarantee delivery order for webhooks; use the created_at field in the payload when ordering matters.