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
Machine-readable surface: versioned, checked JSON contract

Authentication

Every request must send the API key either as a bearer token in the Authorization header or directly in X-API-Key. 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

# Equivalent alternative
X-API-Key: nfors_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx

If both headers are present, Authorization takes precedence. API routes derive operator scope from the key rather than accepting an operator override from the request. Revoke a key to reject subsequent requests that present it.

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": "The request is invalid. Review the validation details and try again.",
    "details": {
      "field_errors": ["zone_id"]
    }
  }
}

Detail shapes are endpoint-specific and closed. Validation failures expose field names with fixed text, while safety conflicts expose only documented reason enums, booleans, and bounded counts. Dependency responses never include provider or database messages.

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, and 503 upstream_unavailable for a retryable dependency failure. Where a bounded retry delay is known, the response includes Retry-After in seconds.

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. Treat cursors as opaque: current cursors encode a versioned stable tuple, while historical RFC3339 timestamp cursors remain accepted.

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.

At validated plate-write boundaries, nfors retains the submitted plate as source intent, then derives a separate effective identity for matching. The effective plate contains 2–10 uppercase ASCII letters or digits. Permit and whitelist responses expose the effective identity at the top level and provide a safe, opaque receipt describing whether a reviewed jurisdiction rule was applied; they never expose alternate candidates or internal rule provenance.

Enforcement holds (503 details.hold_reason)

POST /plate-events and POST /violations refuse to authorise enforcement when an unmatched plate cannot be proven unmatched. That refusal is a 503 upstream_unavailable with Retry-After: 30 and a details.hold_reason naming which gate held it. The reason is a fixed enum — never a provider message, plate, zone, or operator value.

{
  "error": {
    "code": "upstream_unavailable",
    "message": "A required service is temporarily unavailable. Retry later.",
    "details": { "hold_reason": "permit_mirror_stale" }
  }
}

The three reasons are not interchangeable, and only one of them makes retrying useful. Branch on the value; do not treat every 503 as the same backoff.

  • live_coverage_recheck_failed — we called your live coverage API, it reported the plate as covered, and our own canonical matcher could not reproduce that from the permit we hold. The two systems disagree. Actionable by you: reconcile the session your API reported against the permit you have pushed or we have polled for that plate, zone, and instant. Retrying without changing anything reproduces the same disagreement.
  • permit_mirror_stale— no live answer was available and the permit data we mirror for that zone is too old to enforce against, so an empty match cannot be trusted to mean “no permit”. Retrying will not help until your sync catches up. Only you can resolve it: resume the permit feed, or fix the failing provider connection. Re-sending the same event on a short timer produces one 503 per attempt and changes nothing.
  • live_coverage_not_persisted — coverage was detected but our own write of it did not land, so we will not enforce against a permit we failed to record. This one is ours, it is transient, and retry is reasonable — honour Retry-After and re-send the same body and external_id.

Why the distinction is published at all: one plate was re-sent every four minutes for 92 hours — 1,357 events — because the caller could not tell that retrying was pointless. The reason is also recorded on your /developer/errors row as a permit_hold:<reason> field-error label, so the same distinction is queryable after the fact.

hold_reason is reused by two other, separate vocabularies, so match on the value and ignore what you do not recognise rather than assuming a 503 is always an enforcement hold. Plate-identity resolution publishes hold_reason: runtime_failure at 503 (with Retry-After: 5) and identity_review_required, effective_identity_invalid, invalid_jurisdiction, or invalid_operation at 409; permit preview publishes permit_identity_ambiguous at 409. None of those are permit-safety holds, and an unrecognised reason is dropped rather than echoed.

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 /integration-profile

Returns a closed, authenticated deployment and key attestation. Machine clients should compare it with their deployment-bound profile before enabling writes or scheduled jobs. This endpoint uses the read scope and returns opaque identifiers — no operator contact data or key material.

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

# {
#   "service": "nfors",
#   "environment": "production",
#   "contract_version": "2026-07-31",
#   "api_version": "v1",
#   "operator_id": "uuid",
#   "api_key_id": "uuid",
#   "capabilities": ["violations:read"]
# }

environment is one of production, staging, preview, development, test, or unknown. Treat any profile mismatch as a deployment configuration error and keep writes disabled.

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 Level, Parkmobile, ParkLync, Passport, or another parking provider, that 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"
  }'

source_zone_code is unique per (operator_id, provider) — one upstream code can only point to one nfors zone. Each nfors zone can likewise have only one mapping for a given provider, matching the integration-card model in the dashboard.

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" | "LEVEL" | "SPOTHERO" |
              "PAYBYPHONE" | "FLOWBIRD" | "PARKINGBOSS" |
              "PASSPORT" | "HONK" | "T2",
  "source_zone_code": "12345",
  "config": {},
  "created_at": "ISO 8601",
  "updated_at": "ISO 8601"
}

These are provider-scoped routing identities. LEVEL supports both push and pull workflows and uses mappings too. MANUAL and DASHBOARD are local sources, so they have no provider zone namespace.

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. It is durable application data, not a private logging channel. Never place credentials, bearer tokens, signing secrets, private headers, signed URLs, or unnecessary personal data in metadata. Prefer the typed contract fields and send only the minimum operational context you are authorized to retain and disclose. 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 may retain an approved provider record here so dispute forensics can trace a permit back to its source of truth; retention and disclosure rules still apply. 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.

Optional operator/API aliases (external_id)

A zone or charge reason may 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.

This alias is not a Level, Parkmobile, or other provider zone code. Provider-owned codes belong in that provider’s zone mapping and are scoped by provider. Zones referenced only by their nfors UUID and provider mappings do not need an external_id.

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.

Source intent and effective plate identity

Permit and whitelist writes treat plate_number and plate_stateas source intent. nfors retains that submitted identity in an immutable resolution receipt, then returns the effective matching identity in the response’s top-level plate_number and plate_state. A deterministic, published rule can therefore rewrite a plate; a merely plausible alternative never replaces it.

You may supply plate_identity_context with any of plate_class, issuer_product_code, design_code, serialized_segment, and plate_issue_date (YYYY-MM-DD). Do not send provenance: nfors assigns it from the authenticated workflow, and omitted facts remain unknown rather than being inferred from a default.

# Source intent
{
  "plate_number": "FL-O0",
  "plate_state": "FL",
  "plate_identity_context": {
    "plate_class": "personalized",
    "serialized_segment": "personalized_message"
  }
}

# The parent response carries the effective identity plus safe metadata only.
{
  "plate_number": "FL00",
  "plate_state": "FL",
  "plate_identity": {
    "outcome": "rewritten",
    "explanation_code": "...",
    "receipt_reference": "opaque-reference"
  }
}

plate_identity.outcome is one of unchanged, rewritten, candidate_only, insufficient_context, ambiguous_scope, rules_unavailable, or legacy_unversioned. Candidate, insufficient-context, ambiguous, and unavailable outcomes are suppressive: they cannot authorize a rewrite or let an alternative justify a money action, and the source identity remains effective. explanation_code is a safe machine-readable reason and can be null on a historical parent projection; receipt_reference is opaque and can be null for a legacy row. Responses do not include candidate values, source/effective pairs, rule IDs, or provenance.

Idempotency is identity-safe. Reusing the same (provider, external_id) with a changed source plate or scope is a conflict (409 for a single write; a rejected row in bulk). Validity-window, lifecycle, and metadata updates retain the original receipt.

The first writer channel also owns that natural key. A provider-managed row cannot be taken over implicitly by an API writer (or vice versa), even with the same plate: the write is a 409 until an explicit, audited channel migration is performed.

Ordinary updates cannot add, remove, or replace an external_id. They also cannot change provider on an externally keyed row. Those changes alter receipt ownership and require the same audited handoff; an ID-owned row without an external key may re-resolve provider context normally.

Extending a receipt-backed permit or whitelist validity window commits the parent change and its receipt-retention extension atomically; it does not create or replace the original receipt. A receipt-backed row with an external_id cannot be hard-deleted. DELETE returns 409 with details.schema: coverage_parent_delete and a replacement of end_permit or expire_whitelist. Use the corresponding lifecycle update through PATCH. Legacy and ID-owned delete behavior is unchanged.

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": 1,
#   "rejected": [
#     { "index": 1, "error": "Plate identity could not be resolved." }
#   ],
#   "results": [
#     { "index": 0, "status": "accepted", "outcome": "unchanged",
#       "explanation_code": "...", "receipt_reference": "opaque-reference" },
#     { "index": 1, "status": "rejected",
#       "error": "Plate identity could not be resolved." }
#   ]
# }

Bulk writes support partial success. results has one index-preserving entry per submitted row. Accepted entries expose only outcome, explanation_code, and receipt_reference; rejected entries expose one safe error. The compatibility data and rejectedarrays remain available. Retry only rejected indices after correcting the input. 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",     // effective matching identity
  "plate_state": "FL",            // effective issuing jurisdiction
  "plate_identity": {
    "outcome": "unchanged" | "rewritten" | "candidate_only" |
               "insufficient_context" | "ambiguous_scope" |
               "rules_unavailable" | "legacy_unversioned",
    "explanation_code": "machine-readable-reason" | null,
    "receipt_reference": "opaque-reference" | null
  },
  "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" | "SPOTHERO" | "PAYBYPHONE" |
              "FLOWBIRD" | "PARKINGBOSS" | "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"
  }'

Choose one zone identity

Provide exactly one of zone_ids, zone_external_ids, zone_source_codes, or all_zones: true. Operator aliases in zone_external_ids must all resolve. Provider-owned zone_source_codes require an explicit provider; nfors resolves them through that provider’s zone mappings and applies the entry to the mapped subset, returning 400 only when none resolve. Provider source codes are supported by the single-entry POST /whitelist route; the bulk route does not resolve this identity form.

# Operator-owned zone aliases
{ "zone_external_ids": ["LOT-A", "LOT-B"], ... }

# Provider-owned zone codes
{
  "provider": "LEVEL",
  "zone_source_codes": ["10001", "10002"],
  ...
}

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 zone-identity form 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",
  "plate_identity": {
    "outcome": "unchanged" | "rewritten" | "candidate_only" |
               "insufficient_context" | "ambiguous_scope" |
               "rules_unavailable" | "legacy_unversioned",
    "explanation_code": "machine-readable-reason" | null,
    "receipt_reference": "opaque-reference" | null
  },
  "valid_from": "ISO 8601" | null,
  "valid_to":   "ISO 8601" | null,
  "provider": "PARKMOBILE" | "PARKLYNC" | "SPOTHERO" | "PAYBYPHONE" |
              "FLOWBIRD" | "PARKINGBOSS" | "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.

Reusing that key with a different source plate or zone scope returns 409. Validity, lifecycle, and metadata-only changes update the existing entry while retaining its original plate-identity receipt.

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, and image/heif. Returns a file_id you reference from POST /violations. Files that aren’t attached to a parking charge within 24h are garbage-collected.

A 400 invalid_request includes one closed details.reason: multipart_required, form_unreadable, file_required, file_empty, file_too_large, mime_unsupported, size_mismatch, or content_mismatch. The response never includes storage or parser error text.

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 or non-financial warning from an external system (LPR cameras, reservation software, manual tools). The selected reason controls the outcome: omit notice_type to use its configured fixed warning or charge type. An explicit notice_type is a confirmation and must match the reason; it cannot upgrade a warning or downgrade a charge. Reasons managed by a repeat-offender policy require the reviewed agent issuance flow and are rejected by this endpoint.

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-...",
    "notice_type": "charge",
    "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",
    "external_id": "lpr-read-7781",
    "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 JPEG, PNG, WebP, HEIC, or HEIF content of at most 10 MB, and not redirect. nfors verifies the file signature rather than trusting the declared content-type, then stores the bytes.

Idempotency

external_id remains optional for v1 compatibility, but integrations should always supply a durable, unique value. It prevents a replay from creating a second notice and lets nfors verify or repair manifest-backed image attachment and its deterministic webhook outbox entry. Successful replays return the original notice with idempotent_replay: true. Requests without external_id cannot be correlated after response loss. Collection enqueue and the overall multi-table ingest are still not one atomic database transaction. A replay of a legacy notice whose original request or frozen collection intent cannot be verified returns 409 with details.ingest_state set to legacy_request_unverifiable or legacy_collection_intent_unverifiable, plus repairable_automatically: false. The response does not disclose the existing notice ID. A validated retry whose decision fields differ instead returns details.ingest_state="request_mismatch". If a manifest-backed replay cannot complete evidence verification or repair, the response instead carries details.ingest_state="evidence_incomplete"; follow the HTTP status and Retry-After semantics without assuming a second notice should be created.

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 a closed preview containing reason="active_permit_match", a bounded match_count, and the match-tier enum when available. It does not disclose a permit, whitelist, plate, or customer record. Resend with permit_override_reasonto issue anyway (recorded in the charge’s notes for audit).

When the plate matches nothing, nfors additionally proves the miss is trustworthy before issuing. If it cannot, the POST returns 503 upstream_unavailable with a details.hold_reason and no charge is created. Two of the three reasons are yours to clear and are not fixed by retrying — see Enforcement holds.

Response

{
  "data": {
    "id": "...", "violation_number": "NF-2026-000834",
    "notice_type": "charge",
    "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.

Same-stay suppression

A monetary Level LPR request that collides with an existing protected same-stay claim does not create a second charge. It returns HTTP 200 with this redacted terminal outcome; callers must handle it as success, not retry it as an issuance failure:

{
  "data": null,
  "decision": "no_action",
  "decision_reason": "existing_charge_same_stay"
}

Fetch a current violation

GET /violations/{id} uses the read scope and is tenant-scoped; an unknown or other-operator ID returns 404 not_found. The response refreshes expiring signed image URLs and returns the current notice, amount, delivery, status, metadata, and image fields.

curl -s https://nfors.ai/api/v1/violations/{id} \
  -H "Authorization: Bearer $NFORS_API_KEY"

GET /violations/lookup

Read-only charge discovery for an approved integrator. The key needs the violations:read capability in addition to read scope; otherwise the endpoint returns 403 forbidden. Query by violation_number (alias charge) or by plate + state. An exact charge number wins when both forms are supplied.

curl -s "https://nfors.ai/api/v1/violations/lookup?plate=ABC1234&state=FL" \
  -H "Authorization: Bearer $NFORS_API_KEY"

# {
#   "data": {
#     "query_kind": "plate_state_match",
#     "state_fallback": null,
#     "count": 1,
#     "matches": [{
#       "id": "uuid", "violation_number": "NF-2026-000834",
#       "record_path": "/charge/uuid", "plate_masked": "***1234",
#       "total_amount_cents": 7500, "status": "issued", ...
#     }]
#   }
# }

This contract returns masked plate and parker-facing notice data only. It never returns a full plate, registered-owner, or DMV record. A state-relaxed result is identified by state_fallback.from_state.

POST /violations/{id}/void

Voids an already-issued violation. The key needs write scope and the violations:void capability; an ordinary ingest key receives 403 forbidden. This operation never reissues a notice — a corrected citation must be a new plate event with a new external ID.

curl -s -X POST https://nfors.ai/api/v1/violations/{id}/void \
  -H "Authorization: Bearer $NFORS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "reason": "Upstream plate correction",
    "external_id": "void-lpr-7781",
    "correction": {
      "corrected_plate_number": "ABC1234",
      "corrected_plate_state": "FL",
      "source": "level_reconciliation"
    }
  }'

reason (1–500 characters) and external_id are required; correction is optional audit context. A successful fresh, already-voided, or idempotently replayed request returns 200 with the violation ID/number, status: "voided", void timestamp/reason, collection and money disposition, warnings, andidempotent_replay. Unknown or other-operator IDs return404. Paid or disputed notices return 409 conflict; refund authority stays outside this endpoint.

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,
#   "match_via": "normal",
#   "stacked_prefix": null,
#   "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 both validity boundaries at match-time: a hit requires at ∈ [valid_from - grace, valid_to + grace). This absorbs small clock skew before a just-started or after a just-expired session. The permit row itself isn’t modified.

Every 200 identifies its path with match_via: "normal" or "stacked_prefix". A stacked-prefix match also returns stacked_prefix: { observed_plate, matched_core }; otherwise that field is null. If multiple protected identities make a match unsafe, the endpoint returns 409 conflict with hold_reason: permit_identity_ambiguous and bounded identity_count/match_tier. An indeterminate dependency returns 503 upstream_unavailable; neither outcome grants enforcement authority.

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", ... }],
#       "stacked_prefix": null, "error": null, "error_code": null },
#     { "index": 1, "valid": false, "matches": [],
#       "stacked_prefix": null, "error": null, "error_code": 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. Its stable companion error_code is invalid_request, not_found, upstream_unavailable, or permit_identity_ambiguous; both fields are null when the row was evaluated normally. A successful stacked fallback carries the same { observed_plate, matched_core } in stacked_prefix.

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 standard permit match runs exact → state-fallback → confusable. Where guarded stacked-prefix recovery is enabled for the zone, a standard miss also checks the stripped plate core; a unique match can only suppress issuance, while ambiguity or dependency uncertainty remains non-authorizing. 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 '{
    "provider": "LEVEL",
    "zone_source_code": "10001",
    "plate_number": "JP6WZ4",
    "plate_state": "FL",
    "entry_at": "2026-06-08T14:02:00Z",
    "exit_at":  "2026-06-08T15:30:00Z",
    "external_id": "lpr-88213"
  }'

Provide exactly one of zone_id, zone_external_id, or zone_source_code. Source codes resolve inside the supplied provider namespace; plate events retain the historical LEVEL default whenprovider is omitted, but integrations should send it explicitly. Existing zone_external_id payloads remain supported for backward compatibility.

In an enforce zone, an unmatched observation must supply one of violation_reason_id or violation_reason_external_id. The reason must be a fixed positive charge reason: policy-managed reasons, warning-default reasons, and zero-value reasons are rejected for automatic issuance. Disabled, block-out, permit-matched, and alert_only decisions do not require a charge reason.

Event chronology is evaluated against one server-owned request receipt instant. Integrations must keep their UTC clocks NTP-synchronized: entry_at must be no later than exit_at (when present), and neither edge may be more than two minutes later than receipt. Accepted positive clock skew is clamped to the receipt instant before adjudication, persistence, or queue reconciliation. Normal delivery accepts a latest observed edge up to 24 hours old (the ingress edge for an open stay, the exit edge for a completed stay). Older corrections require the API key capability plate-events:historical, which is not granted by this endpoint and must be approved during integration rollout. An authorized historical correction is always marked for human collection approval. Monetary adjudication that takes more than 30 seconds, or whose current block-out policy cannot be reverified, fails retryably without issuing a charge.

An admissible observation with a repairable configuration or data defect returns HTTP 202 and is durably quarantined; do not submit it again. Quarantine identity includes the provider, so another provider using the same external ID cannot absorb the Level event. If that exact Level ID was already terminally dismissed, a later POST returns the same closed dismissed receipt with HTTP 410 and creates no new held row. A replay tombstone with no authoritative artifact returns a closed ambiguous receipt with HTTP 409. Neither response authorizes a retry. Both receipts contain only the contract version and opaque external ID, never the plate or quarantined payload. The 202 receipt's message is non-authoritative human-readable copy; consumers must bind to the closed status, defect, repair path, and field-name contract rather than exact-match that prose.

For each decision, nfors attempts to enqueue a violation.evaluated event for active endpoints subscribed to that event. Its payload includes a match_reasoning block (including a state_fallback or confusable_fallback sub-object when a fuzzy tier matched). Plate-event webhooks include the canonical zone_id, nullable operator zone_external_id, nullable provider zone_source_code, and provider. When a legacy request uses zone_external_id, nfors enriches the outbound source code from the current provider mapping when one exists. Level webhook consumers should first use provider="LEVEL" + zone_source_code, then canonical zone_id, and only then the nullable zone_external_id as a temporary legacy fallback. Never infer identity from a zone display name. A successful request binds (operator, external_id) to the canonical JSON request body. An exact replay returns the persisted primary decision; reusing the ID with semantically different content returns 409 conflict. Retry transient 503 responses with the same body and external_id except when the body carries a details.hold_reason of permit_mirror_stale or live_coverage_recheck_failed, which retrying cannot clear because only your permit sync or your live coverage API can. See Enforcement holds for what each reason means. Webhook consumers must still tolerate missing, delayed, and repeated delivery. 4xx bodies carry a details.grace_window diagnostic with reference (exit_at or now), bounded integer observed_window_seconds and required_window_seconds, plus boolean entry_at_valid and exit_at_valid. It contains no raw timestamps or PII.

Every current and future zone with a LEVEL mapping is enrolled in the Level same-stay owner. A complete-stay claim requires a monetary Level LPR observation, ordered entry_at/exit_at, and a 24-character lowercase-hex Level stay ID in metadata.lp_lpr_session_id. For that valid identity, the database atomically suppresses a new Level LPR charge when the same canonical plate has an overlapping active agent or Level LPR claim in that operator and zone; a new agent charge is likewise blocked by an active Level LPR claim. Plate state is deliberately not an ownership discriminator for this safety check. The other eleven provider codes are explicitly unsupported/unclaimed for provider-specific same-stay identity; they do not silently inherit Level semantics, and an unclaimed result neither rejects nor authorizes the surrounding request. The redacted HTTP 200 response is { decision: "no_action", decision_reason: "existing_charge_same_stay", external_id, evaluated_at }; it reveals no notice ID, peer source, plate, or state. A retry with the same external_id replays that persisted response idempotently. The insert-time database exclusion remains authoritative for concurrent requests.

GET /plate-events/reconciliation

Read-only, exact-ID evidence for a Level plate-event response-loss investigation. Current notice-record evidence is restricted to LEVEL / api_lpr rows. Send exactly one query key, external_id. The lookup uses read scope, is restricted to the authenticated operator, and returns private/no-store responses. It never submits or replays an event and never grants retry or charge authority.

curl -s "https://nfors.ai/api/v1/plate-events/reconciliation?external_id=lpr-88213" \
  -H "Authorization: Bearer $NFORS_API_KEY"

# {
#   "data": {
#     "contract_version": "plate_event_reconciliation_v1",
#     "external_id": "lpr-88213",
#     "outcome": "notice_found" | "no_notice" | "inconclusive",
#     "decision": "violation_issued" | "no_action" | "alert_logged" | "disabled" | null,
#     "evaluated_at": "ISO 8601" | null,
#     "notice": { "id": "uuid" | null, "number": "NF-2026-000834" | null } | null,
#     "alert_id": "uuid" | null,
#     "evidence": "violation_record" | "response_receipt" | "none",
#     "resubmission_authorized": false
#   }
# }

notice_found and no_notice are closed evidence outcomes. inconclusivemeans no authoritative record proves the result; it is not permission to send another POST. Apply the caller’s independently approved recovery policy before any further action.

Level legacy-stay release

This first-party, capability-gated rail releases one immutable Level paired stay after Level has durably proven that the stay was unpaid and uncharged at its mapped zone’s local end of day. It is not a generic plate-event or violation endpoint. Send the exact legacy_stay_release_v1 body to POST /legacy-stay-releases with capability legacy-stay-releases:write. The stable external ID is lpr-event-{canonical exit event id}; reusing it with different canonical request bytes returns a conflict.

NFORS independently rechecks current permit identity, same-stay ownership across the primary and ordered alternate OCR plates, mapped-zone timezone, and the versioned five-business-day notice deadline. A fresh charge is created only as an unscheduled awaiting_approval collection case. No letter, DMV lookup, usage charge, provider submission, or webhook is initiated by this request. Exact immutable receipts are available from GET /legacy-stay-releases/reconciliation?external_id=.... A no-action receipt has no violation/case/approval identifiers; an awaiting-approval receipt has both UUIDs, approval required, no schedule, and only the closed post-cutoff authorization review annotation when applicable.

GET /level-enforcement-lifecycle

A capability-gated, PII-free lifecycle receipt for one exact Level external ID. Capability level-enforcement-lifecycle:read is required. Base legacy IDs and bounded -corrN correction IDs are supported without normalization. The response reports authoritative adjudication, violation, collection approval, provider-submission, and mailing timestamps; it never infers a mailed letter from case approval or provider acceptance.

HTTP 200 with collection: nullproves that no case currently exists. HTTP 202 means the exact event was accepted and is durably quarantined for operator repair, but has not been adjudicated and must not be submitted again. Its closed response reports only the external ID, defect class, repair path, and safe field names — never the plate or held payload. HTTP 410 is a terminal operator dismissal and also must not be resubmitted. A compact PII-free lifecycle tombstone keeps that terminal result after the quarantined request payload reaches its retention limit. HTTP 404 therefore means genuinely unknown, 409 means stored evidence is contradictory or ambiguous (including a replay stamp with no authoritative adjudication), and 503 means the authoritative lookup is unavailable. Responses are private and no-store.

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 with a bounded blockout object, while direct POST /violations is rejected with 400 invalid_requestbefore any charge is created. Optionally bulk-void existing in-window charges when scheduling the block-out — 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" \
  -H "Content-Type: application/json" \
  -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
# Fetch one (tenant-scoped) — GET /blockouts/{id}

# Cancel (forward-looking; reason is required)
curl -X POST https://nfors.ai/api/v1/blockouts/{id}/cancel \
  -H "Authorization: Bearer $NFORS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "cancel_reason": "Event ended early" }'

Timestamps are RFC3339 with offset. external_id makes create idempotent: a fresh schedule returns 201; a replay returns 200 with voided_count: 0. List and fetch use read scope; schedule and cancel use write scope. List status filters are scheduled, active, past, and cancelled. Cancellation requires a nonempty cancel_reason of at most 500 characters and returns 200; an already-cancelled row returns 409, and an unknown or other-operator ID returns 404. Cancellation is forward-looking — prior no-action decisions and voids remain final. Scheduling and cancelling attempt to enqueue blockout.created / blockout.cancelled events for subscribed endpoints.

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
  • warning.issued — a non-financial parking warning was issued; it is never sent as violation.issued
  • notice.delivered — an agent confirmed the printed notice or reusable QR card was placed on the vehicle
  • violation.voided — a charge was voided; newly queued payloads add nullable provider, zone_source_code, canonical zone_id, and legacy zone_external_id without changing existing correlation fields
  • 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 — an enqueued /plate-events decision event (issued / no-action / alert-logged), with a match_reasoning block; consumers must not use it as the sole record of the synchronous API decision
  • 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; payloads always carry canonical zone_id and LPR-origin rows also carry nullable provider + zone_source_code. Level correlates by provider/source first, canonical UUID second, and the legacy alias only as a temporary fallback. resolved carries resolution.kind: violation / paid / departed / void / expired
  • blockout.created / blockout.cancelled — an enforcement block-out was scheduled or cancelled

Every event name above, including violation.corrected, is valid in a registration’s events array. Use * to subscribe to every current and future event type.

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" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "Production decisions",
    "url": "https://example.com/hooks/nfors",
    "events": ["violation.evaluated", "violation.corrected"]
  }'

Redelivery

Retained delivery records, including succeeded, failed, and abandoned attempts, are available for operational review under Settings → Webhooks. Click Redeliver on an available 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 make your consumer transactionally idempotent. Delivery is at least once; nfors does not promise exactly-once processing in the receiver.

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