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

Integration setup

Built-in provider connections use scheduled polling. The current connectors are Level Parking, Parkmobile, ParkLync, PayByPhone, and Oobeo. Other accepted provider values identify data sources; they do not imply an available connector. See integration availability.

Configure four things under Settings → Integrations: provider credentials, connection on/off, polling cadence, and zone mappings. Cadence ranges from 60 seconds to 24 hours; the scheduler runs every minute. nfors manages complete refreshes and incremental polling where supported. Sync method is fixed by the connector. Pausing stops fetching and retains credentials.

Save credentials, map the provider’s zone codes, activate the connection, and verify a successful sync in the dashboard. Credentials alone do not start a connection. The public /zone-mappings API manages routing identities; saving a mapping does not activate polling or establish fresh coverage. New or changed mappings need a successful complete sync before their mirrored permit coverage can be trusted. A connection’s earlier success does not verify a newly mapped zone.

Custom clients can still write permits and whitelist entries directly through the authenticated public API. Those writes are separate from built-in connector polling. Inbound provider webhooks are retired. The outbound webhooks documented below deliver nfors events to your endpoints.

Authentication

Ordinary API requests 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. The support charge context route instead requires its separate bearer-only support credential.

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 dependency failure. Where a bounded retry delay is known, the response includes Retry-After in seconds. Follow the endpoint’s refusal and reconciliation contract before resubmitting a write; the status or header alone does not authorize it.

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 a details.hold_reason naming which gate held it and a Retry-After (seconds) derived from that reason — always within [60, 900]. The reason is a fixed enum — never a provider message, plate, zone, or operator value. These permit-safety checks apply across operators. They are separate from an accepted 202 coverage_pending receipt, which must be tracked through submission status rather than resubmitted.

For POST /plate-events, the closed refusal contract and exact-key reconciliation govern resubmission. A Retry-After header gives a delay; it does not grant authority to resend the observation.

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

The three reasons identify different recovery requirements. Branch on the value and the endpoint’s refusal contract; do not treat every 503 as permission to retry.

  • live_coverage_recheck_failed — we called the connected provider’s 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. Reconcile the provider’s reported session against the permit nfors holds for that plate, zone, and instant with your provider or nfors support. Retrying without changing anything reproduces the same disagreement. Retry-After: 300.
  • 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 sync catches up. Check the connection’s credentials, activation, mappings, and sync status; resolve provider or nfors failures with the relevant support team. Re-sending the same event on a short timer produces one 503 per attempt and changes nothing. Retry-After is the freshness horizon itself — 900 under the default 15-minute horizon — because the gate cannot re-open sooner than a successful sync.
  • 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 failure is usually transient and carries Retry-After: 60. For POST /plate-events, restored coverage and exact-key reconciliation authority are still required before resending the same sealed request. One additive exception: when the failed write is provably permanent on our side (for example a plate-identity conflict after a vehicle swap — a record only a manual repair can clear), the same hold also carries details.permanent: true and Retry-After: 900. Keep the event for repair; this permanent plate-event refusal is terminal and does not authorize an automatic resend. An absent permanent flag does not itself authorize a retry.

Which failures count as permanent is published, not prose. The contract artifact carries the closed permanent/transient partition of our permit-ingest failure classes at shared_contracts.permit_ingest_failure_classification, so you can bind your own taxonomy to ours mechanically instead of re-deriving it from this page. The partition is total and disjoint, and it is checked against the predicate that actually decides the flag, so the two cannot drift. Two notes on scope: the failure class itself is never returned to you — details.permanent is the answer, not the class — and classes whose futility we cannot prove (mixed, unknown) are published as transient on purpose. This classification does not grant plate-event resubmission authority.

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.

GET /zones/{id}/enforcement-readiness

Returns a configuration readiness receipt for one zone: the enforcement mode that actually applies, the signage attestation state, mailing enrollment per source, every provider identity mapping, and the operator’s violation reasons that carry an external_id. Use it before forwarding automated observations so a misconfigured zone is caught in configuration rather than as a refused event. The receipt describes configuration only; it does not promise that any particular observation will result in a charge.

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

# {
#   "data": {
#     "zone": {
#       "id": "2f27d3f2-…", "external_id": null, "name": "North Lot",
#       "status": "active", "timezone": "America/Chicago",
#       "state_code": "TX", "mailing_locked": false
#     },
#     "enforcement": {
#       "stored_mode": "enforce",
#       "effective_mode": "enforce",
#       "source": "legacy_zone_mode"
#     },
#     "signage": {
#       "attested_at": "2026-08-01T15:04:05.000Z",
#       "attestation_version": 1,
#       "ok": true,
#       "reason": null
#     },
#     "provider_mappings": [
#       { "provider": "PARKMOBILE", "source_zone_code": "44120" }
#     ],
#     "mailing": {
#       "operator_enrolled": true,
#       "operator_enabled": true,
#       "approval_required_default": true,
#       "mail_provider": "lob",
#       "sources": [
#         { "source": "lpr", "enabled": true,
#           "approval_required": null,
#           "approval_required_effective": true }
#       ]
#     },
#     "violation_reasons": [
#       { "id": "6c1e…", "external_id": "no-valid-session",
#         "active": true, "default_notice_type": "charge" }
#     ]
#   }
# }
  • enforcement.effective_mode is the mode that applies to new observations. When an enforcement rule profile is assigned, source is rule_binding and the stored zone column is not the effective mode. legacy_zone_mode means the stored column applies. unavailable means the rule binding could not be read; treat it as not ready.
  • signage.ok is false with reason never_attested, stale_version, or state_code_missing. Each of these refuses enforce-mode observations at intake.
  • mailing.sources[].approval_required_effective is the zone override when set, otherwise the operator default; this is exactly the value the mailing pipeline applies. Nothing mails unless operator_enabled is true and the relevant source row is enabled.
  • zone.status gates mailing enablement in the dashboard; it is not checked at observation intake.
  • Responses are Cache-Control: private, no-store. A zone outside your operator returns the same 404 not_found as a zone that does not exist.

Locations

A location groups zones at a facility, campus, or lot cluster. Its permit_sharing_mode controls whether payments and permits are shared across assigned zones for all enforcement (all_enforcement) or automated LPR enforcement only (lpr_only). With LPR-only sharing, walking agents enforce the selected zone and can see valid coverage elsewhere in the location. Their original dates, weekdays, hours, vehicle and space restrictions still apply. Locations are optional; compliance (state, signage attestation) stays at the zone level.

Adding, moving or removing a zone changes sharing from the time the assignment is saved. Changing the sharing mode also applies prospectively. Earlier parking observations keep the sharing rules that applied at that time.

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",
    "permit_sharing_mode": "lpr_only"
  }'

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}

Creation defaults to all_enforcement when the mode is omitted for compatibility. A PATCH that omits the mode preserves it. To change the mode, include expected_permit_sharing_revision from the current Location object. A stale revision returns 409; fetch the Location again before retrying.

Deleting a location ends its payment sharing from that point forward. Assigned zones remain and have their location_id cleared. Earlier parking observations retain their original sharing rules.

Location object

{
  "id": "uuid",
  "name": "North Pensacola Campus",
  "external_id": "lp-campus-42" | null,
  "address": "..." | null,
  "timezone": "America/Chicago" | null,   // IANA
  "notes": "..." | null,
  "permit_sharing_mode": "all_enforcement" | "lpr_only",
  "permit_sharing_revision": 1,
  "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 connectors and public API source-code references can route records correctly. This API manages the same mappings as the dashboard. Connection activation, credentials, and synchronization are separate; see integration setup before enabling automated enforcement.

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. Codes are trimmed and must contain 1–120 characters; LEVEL codes must be exactly five decimal digits, including any leading zeroes.

List, fetch, update, delete

# list (filters: zone_id, provider, source_zone_code, 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" | "OOBEO",
  "source_zone_code": "12345",
  "config": {},
  "created_at": "ISO 8601",
  "updated_at": "ISO 8601"
}

These are provider-scoped routing identities. LEVEL uses the same mapping for its polling connector and public API source-code references. MANUAL and DASHBOARD are local sources, so they have no provider zone namespace.

Metadata conventions

The metadata object 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_raw as 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_state as 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.

Unknown plate state

A permit is never refused because of its plate_state. Send the two-letter code of the issuing state: one of the 50 states, DC or PR, in any case. Any other value is accepted and recorded as an unknown state: an unrecognized code such as UM-81 or XX, a province such as BC, a full name such as Florida, a blank, null, or an omitted field. nfors does not map or guess a state, and the value you sent is not stored. The permit is written with the plate number as sent, plate_state: "", your country, and plate_identity.outcome: insufficient_context with explanation_code: missing_scope_facts. This applies to POST /permits, POST /permits/bulk and PATCH /permits/{id}. A non-text plate_state, such as a number, is still a 400.

An unknown-state permit covers its exact plate characters for an observation in any state, but only while no other active registration of those characters names a state (permit match tier state_fallback). It never covers a look-alike plate.

The idempotency rules above still apply. Re-sending an existing (provider, external_id) with a different state, including one that now lands as unknown, changes its source identity and is a 409; change it with PATCH. In a PATCH, an omitted plate_state is unchanged and null sets it to unknown. Whitelist writes are unchanged and still require a recognized state.

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 rejected arrays 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; "" = unknown
  "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" | "OOBEO" | "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" | "OOBEO" | "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.

Requests with source="api_lpr" also require an active provider connection feeding the zone’s permit coverage. Without one, this endpoint returns HTTP 400 invalid_request. See the LPR connection prerequisite.

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_reason to 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 /support/charge-context

A read-only, staff-facing charge summary for support tools. Use a separately issued nfors_support_ bearer credential with exactly support:read. Ordinary API keys and X-API-Key are rejected; this credential cannot use the ordinary API routes. The operator must be active. The limit is 60 requests per minute per key, with Retry-After on 429.

curl -s -X POST https://nfors.ai/api/v1/support/charge-context \
  -H "Authorization: Bearer $NFORS_SUPPORT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"violationNumber":"NF-2026-000834"}'

Send exactly one unescaped violationNumber matching NF-YYYY-NNNNNN in at most 256 bytes of JSON. Query parameters, duplicate keys, extra selectors and operator overrides are rejected. A number is a locator, not customer identity proof. Lookup is an exact match within the credential's operator.

The data envelope contains schema version 1, case IDs, an opaque revision, status, zone/reason, entry/exit and notice timestamps, integer USD cent assessments, bounded adjustment, payment and dispute summaries, and evidence presence. See the checked JSON contract for the complete field map. Responses use Cache-Control: no-store. No full plate, contact details, evidence URLs or storage paths are returned, and no model is called. Treat this as agent review material, not a customer-ready response.

Histories are capped at 20 records each and evidence at 40 images; excess or inconsistent data returns 503 upstream_unavailable rather than a truncated result. The revision covers selected identity, evidence, captured policy and history from one database snapshot. Re-fetch it before relying on a previous review.

A found record does not validate the base charge or prove a drive-through exemption. remainingAssessmentCents is the recorded assessment basis, not a checkout quote. Existing adjustments are untyped: lateFeeWaiver is always not_determined. An adjustment amount or explanation does not prove a late-fee waiver. This endpoint makes no financial changes and grants no authority to waive, refund or cancel a charge.

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.

From manifest revision 2026-09-09.1, both permit-match routes evaluate the vehicle recorded in audited permit attachment history at the requested at instant. A vehicle change keeps the same permit id: the prior vehicle ends at an exclusive boundary and the replacement begins at an inclusive boundary. Grace does not cross a vehicle-change boundary. The returned valid_from/valid_to describe permit validity; those dates alone do not prove that a particular vehicle was covered throughout that interval.

Permit matches may include metadata.plate_identity_historywith changed_at (an audited vehicle-change timestamp, or null) and evaluated_historically: true. This optional metadata explains the match and grants no enforcement authority. It describes vehicle identity; it does not freeze every other permit attribute. Existing response snapshots are not rewritten.

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)

Since manifest 2026-10-21.1, repeated image_urls use the first occurrence of each exact normalized URL, including its role and sort order, for downstream evidence. The original request snapshot and hash retain every submitted image entry. This applies to all providers and operators; stored successful replay responses and first refusal receipts remain unchanged.

A local finalizer validation fault returns 500 internal_error. When the existing database charge fence refuses an incomplete stay, the response is 400 invalid_request with details.schema=v1_validation, details.field=exit_at, and details.field_errors=["exit_at"]. Both refusals are terminal. Neither correcting image handling nor receiving a terminal validation error authorizes replay of an earlier uncertain request.

Every error response publishes a closed details.schema/ details.state pair, details.artifact (none, stored, or unknown), and details.retry_class (terminal, after_review, or after_coverage). The full HTTP-status and detail tuple registry is published as shared_contracts.plate_event_refusals in the API manifest. Classify these fields together; human messages are not retry instructions. Ordinary errors put these fields in error.details; legacy terminal quarantine responses retain their body and add root details with plate_event_lifecycle / dismissed (410) or ambiguous (409), always terminal. Artifact describes an enforcement or custody effect, excluding the refusal receipt and identity/observation tracking. Unknown evidence never proves absence.

plate_identity_review / identity_review_required remains fail-closed and needs operator review. A 409 ingest_replay_conflict additively includes stored_request_sha256 (nullable when the stored request hash is unverifiable or unavailable). A schema-valid, keyed, hashable authenticated refusal is recorded durably; invalid bodies and authentication failures cannot create a per-key receipt. A retry class is conditional guidance; use exact-key reconciliation and its authority before resending the same sealed request.

A generic 503 upstream_unavailable refusal is terminal. Neither HTTP status nor Retry-After grants resubmission authority. Repairing the dependency does not make a terminal receipt retryable. Before any eligible resend, use exact-key reconciliation with the original external_id and matching request_sha256, and require resubmission_authorized=true together with the applicable review or coverage prerequisite. An accepted pending observation remains in nfors custody and must not be resubmitted.

Published Enforcement Rules are evaluated separately from permit coverage. Location rules and explicit zone exceptions use the original observation time and verified parking area. A routing zone or source label does not establish which space the camera observed. Rule holds retain alert evidence without automatically issuing a charge; decision_reason is enforcement_rules_blocked, enforcement_rules_review_required, or enforcement_rules_unavailable. Direct POST /violationsrefusals return the same value in error.details.reason: HTTP 400 for blocked or review-required decisions, and HTTP 503 when the rules service is unavailable. New rule assignments apply prospectively.

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 and permit coverage: a covering 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 can create a charge with source="api_lpr" after the remaining enforcement checks pass. The standard permit match runs exact → state-fallback → confusable. The platform applies guarded stacked-prefix recovery across all operators and zones: after the standard and eligible candidate matches miss, it 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).

For a completed stay in an enforce zone, an entry-time permit match alone does not establish coverage through exit_at. nfors evaluates the whole observed interval, including renewals and interior gaps. A proven uncovered portion can proceed to overstay adjudication after freshness and other enforcement checks; uncertain coverage cannot authorize a charge. This behavior applies across operators and enforce zones.

Automatic LPR charges require an active provider connection. In an enforce zone whose permit coverage no active provider connection feeds (no provider mapping, or every mapped connection paused or missing), an unmatched plate is handled as in an alert_only zone: the synchronous response is HTTP 200 with decision: alert_logged, and normal alert-only agent-dispatch settings apply. The outbound violation.evaluated event records no_active_provider_connection in match_reasoning.decision_reason; that reason is not a field in the synchronous alert response. A direct POST /violations with source="api_lpr" in such a zone is refused with HTTP 400 invalid_request. Agent issuance and non-LPR API sources are unchanged.

Coverage can include sibling zones at the same location; the connection need not be mapped directly to the observed zone if it feeds that zone’s coverage. Supplying permits only through the public API or dashboard does not satisfy the active-connection prerequisite. If the connection check is unavailable, both LPR endpoints return HTTP 503 upstream_unavailable with Retry-After: 5. For POST /plate-events, the refusal and reconciliation contract above still governs any resend; this header does not authorize one.

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, refuses issuance; use its closed refusal tuple and reconciliation result to determine whether any later submission is authorized.

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.

Manifest revision 2026-09-10.1 adds a separate durable coverage hold when historical permit coverage is unknown. The response is HTTP 202 with decision: coverage_pendingand an adjudication using plate_event_coverage_hold_v1. This pending decision creates no charge and does not authorize another POST. Keep the exact external ID and use the submission status GET below. Existing quarantine acknowledgements and stored response snapshots retain their original contracts. Coverage holds apply across operators and provider codes. A pending receipt does not enqueue a violation.evaluated event; use GET /plate-events/status to follow the held submission.

Source uncertainty uses plate_event_coverage_hold_v4 withreason: provider_source_coverage_unknown and a closedsource_hold object containing original causes, current causes, and recovery status. Advertise X-Nfors-Coverage-Contract: plate_event_coverage_hold_v4only after implementing that decoder. Acceptance is durable before HTTP 202. Poll the existing status or reconciliation endpoint; pending never authorizes resubmission. A corrected but uncovered stay remains held. Only verified coverage of the original full stay can automatically close this source hold.

A caller may send X-Caller-Receipt-Binding containing exactly 64 lowercase hexadecimal characters to bind its own durable receipt. NFORS returns that opaque value as caller_binding; it is not payment, notice, or replay authority. The field is null when the caller supplied no binding.

# HTTP 202, inside data:
{
  "decision": "coverage_pending",
  "external_id": "lpr-88213",
  "evaluated_at": "2026-09-10T14:00:00Z",
  "adjudication": {
    "contract_version": "plate_event_coverage_hold_v1",
    "state": "pending",
    "revision": 1,
    "provenance": "ingest",
    "reason": "historical_coverage_unknown"
  }
}
# Adjudication excerpt; its full field contract is in the checked manifest.
# POST responses never include request_snapshot.

For final issued, no-action, and alert-logged decisions, 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). On every match-suppressed no_action (each active_permit_matched* reason — exact, state fallback, confusable fallback, candidate, stacked prefix, and live-lookup recoveries), match_reasoning additionally carries a matched_permit object — { provider, channel, kind, permit_id, match_tier } — naming which system’s permit or whitelist row covered the parker (e.g. “covered by PARKLYNC”). provider, channel, and match_tier are null when the source row lacks them; the block is absent on true no-match decisions. Additive only — no existing field changed. Replays return stored response snapshots, so only decisions evaluated after this ships carry it. 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. After a refusal or lost response, use exact-key reconciliation before any resend. Preserve the original body and external_id; require explicit resubmission authority and satisfy the refusal’s review or coverage prerequisite. 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.

Recapture replays (::recaptureN external_id suffix)

When nfors recaptures a previously suppressed or held observation, it replays that event’s stored request snapshot under a fresh idempotency identity: the original external_id with exactly one ::recaptureN suffix appended, where N is a positive decimal attempt number (1, 2, …; no leading zeroes), incremented when an attempt burns a suffix on a non-final answer. The full grammar, anchored:

^(?:(?!::recapture).)+::recapture[1-9][0-9]*$

lpr-event-88213               # original event
lpr-event-88213::recapture1   # first recapture attempt
lpr-event-88213::recapture12  # twelfth attempt

The suffix appears at most once — never nested (::recapture1::recapture2 is not a valid identity) — and the suffixed identity fits the endpoint’s normal 120-character external_id bound. The original external_id remains the session-binding identity: consumers strip the trailing ::recapture[1-9][0-9]* to bind a recaptured decision back to its original session. You will see the suffixed form in violation.evaluated and violation.issued webhook payloads and in plate-event responses for recaptured events; nfors originates these replays, so integrations never need to construct the suffix themselves.

This convention is a versioned part of the machine-readable v1 contract (shared_contracts.plate_event_recapture_suffix) — the same family as Retry-After and the hold_reason codes. The grammar changes only via a manifest revision bump, never silently, so a parser that strips the suffix stays correct.

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/recapture-proof

Reconcile an existing recapture with its original submission using its full external_id, including the documented::recaptureN suffix. This authenticated read compares the complete stored requests, preserving the original zone identity mode, and returns the latest finalized matching recapture and its existing notice status. A family whose finalized results are allno_action returns null current_notice; a later no-action result cannot erase an earlier issued notice. It never creates or retries a submission.

The plate_event_recapture_proof_v1 response is wrapped indata. Its outcome is verified orinconclusive; an inconclusive result has nullproof. Scope is explicitly latest_finalized, which does not prove the absence of a pending or future operation. A matching source snapshot establishes a data relationship, not a historical human approval record.

Retain the parent, requested and effective receipt descriptors.operation_sha256 seals the original parent and effective result, while observation_sha256 separately binds the requested receipt and current notice status as ofobserved_at. A paid or voided notice remains the same original operation. Verify your own source ownership using the typedsource_binding and opaque source_context. Provider hashes do not replace a consumer’s own request signature.

The machine-readable contract defines the exact fields and canonical hash inputs. Conflicting owners, changed source evidence or an oversized family remain inconclusive. All responses use private no-store caching. resubmission_authorized is always false; this proof never authorizes a new charge, payment, notice or replay.

GET /plate-events/status

Track a submitted observation without implementing permit review logic. NFORS owns the held observation, evidence, review queue, and decision. Permit-data providers continue to supply permit updates; they do not need to participate in this workflow.

Submit an observation with a stable external_id and retain its response. HTTP 202 means NFORS durably accepted the observation for further work; it is not a final parking decision. Use this authenticated, read-only endpoint to check the result. The key selects the operator; no provider-specific session format, receipt digest, or review-evidence parser is required.

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

{
  "data": {
    "contract_version": "plate_event_submission_status_v1",
    "external_id": "visit-88213",
    "state": "pending",
    "decision": null,
    "notice": null,
    "resubmission_authorized": false
  }
}
  • pending: NFORS retains the observation for further processing or review. Keep the submission pending in your system; do not create another submission to bypass the hold.
  • final: apply the returned decision once to the matching submission. no_action means this submission produced no new enforcement action. It neither voids an existing notice nor establishes that a historical permit covered the vehicle. A final submission result does not describe the later payment or dispute lifecycle.
  • unknown: this lookup cannot establish a result. Keep any earlier acknowledgment you received. Absence, a timeout, or an unknown result is never permission to create another submission, issue a notice, or charge a customer.

Send exactly one query key, external_id. Responses use read scope and private/no-store caching. The compact result omits vehicle plates, permit identifiers, original requests, and reviewer evidence. Unknown contract versions or states must remain unresolved in the consuming system.

If different providers have active quarantined observations with the same external ID, the lookup returns unknown. Resolved quarantine records without an authoritative final receipt also remain unknown. Preserve the original 202 acknowledgment and use NFORS's operator workflow to resolve those exceptions.

A verified violation.evaluated webhook can prompt a status read for its matching external ID. Alternatively, poll outstanding submissions with bounded backoff and honor Retry-After. Repeated or delayed notifications must not duplicate local actions or move a completed submission back to pending. NFORS retains its detailed reconciliation endpoint below for integrations with additional audit requirements.

GET /plate-events/reconciliation

Read-only, exact-ID evidence for a plate-event response-loss investigation. Send exactly one external_id and, optionally, one request_sha256 containing exactly 64 lowercase hexadecimal characters. Duplicate or unknown query keys are invalid. The lookup uses read scope, is restricted to the authenticated operator, and returns private/no-store responses. It never submits or replays an event. A stored receipt binds notice lookup to its stored provider; a legacy receipt without that binding is answered from the receipt alone. Without a receipt, an unambiguous api_lpr notice from any provider can be evidence. Corrupt or ambiguous evidence fails closed.

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" | "refused" | "absent",
#     "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" | "coverage_hold" | "refusal_receipt",
#     "resubmission_authorized": false | true
#   }
# }

notice_found and no_notice are closed evidence outcomes. inconclusive means 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.

Manifest revision 2026-10-01.1 adds refused and absent. A refused result carriesschema, state, stored_request_sha256, artifact, and retry_class from the durable refusal receipt, with evidence=refusal_receipt. absent means no receipt, observation, violation, or other durable owner exists for this key across providers in the authenticated operator. An observation without a refusal receipt remains inconclusive.

resubmission_authorized=true is limited to an absent key, or a refused key whose retry_class is after_review or after_coverage, whose stored hash exactly matches the supplied request_sha256, and which has no response, violation, alert, coverage custody, or quarantine owner. Missing or mismatched hashes, terminal refusals, and every other outcome return false. This permits only the same sealed submission; it does not replace operator review, restored coverage, or the normal enforcement checks. Identity-review refusals require operator review before any resend.

A coverage-custody result additively includes adjudication. While state=pending, it has revision 1, outcome=inconclusive, a null decision, andevidence=coverage_hold. Verified coverage produces revision 2 with state=final, outcome=no_notice, and decision=no_action. An exact POST replay of a finalized ingest hold returns HTTP 200 with the saved no-action decision. An inconclusive lookup, a 404, or the passage of time never authorizes resubmission.

The adjudication carries its UUID, provenance, reason, caller_binding, request_sha256, the exact request_binding (zone, plate/state, entry and optional exit), nullable matched_permit, and finalized_at. Pending ingest holds have no matched permit, finalization time, or review. Final ingest holds use verified_permit_coverage; their review remains null. A matched permit identifies its ID, permit/whitelist kind, provider, channel, and validity interval.

Every adjudication includes frozen identity evidence: its resolution ID, source_number/source_state, effective_number/effective_state, unchanged/rewritten outcome, and source_workflow. Bind the original wire identity to this resolution, then bind the effective identity torequest_binding; a legitimate canonical rewrite does not make the original request HMAC invalid.

Final adjudications also require coverage with outcome=covered, the frozen entry/exit interval, andgrace_seconds (an integer from 0 through 900). Ingest uses basis=full_stay_evaluation; reviewed history uses basis=reviewed_current_terms. Pending coverage is null. Consumers must verify this full-stay proof and its identity; comparing permit validity dates alone would lose access-window policy and legitimate grace. Reviewed current terms remain distinct from proof of the missing historical membership.

Only this authenticated, tenant-scoped GET returns request_snapshot: the original JSON object captured before validation or normalization. A consumer can verify its original request HMAC without reconstructing a request from current vehicle or membership data. The snapshot is absent from POST responses and webhooks; do not copy it into operational logs or client-side telemetry. request_sha256 and the caller's receipt HMAC are distinct bindings.

Final coverage decisions enqueue violation.evaluatedwith decision=no_action and the additive adjudication without its request snapshot. Pending coverage holds do not emit a final decision webhook. Keep the authenticated GET as the source of original request evidence for reconciliation.

A service-only reviewed historical decision uses distinct provenance=reviewed_historical, final revision 2, and reason=reviewed_authorization. It requires a caller binding and a review containing its ID, reviewer ID, reviewed timestamp, evidence_sha256, and the exact caller_receipt identity. Its request_snapshot and request_sha256 are null: reviewed evidence never invents a missing original request. This path can only suppress a notice and cannot override an existing notice or response receipt. Consumers must verify the review's receipt identity and evidence rather than applying the ingest-snapshot HMAC check to this distinct provenance.

Manifest revision 2026-09-10.2 also defines plate_event_coverage_hold_v2 for an explicit reviewed decision to take no enforcement action on an existing ingest hold. It retains provenance=ingest and the original request evidence, with reason=reviewed_no_action, final revision 2, and a review recording the reviewer, evidence and plan digests, disposition code, and authority=discretionary_no_action. Both coverage and matched_permit are null: the decision makes no assertion that a historical permit covered this stay. Reconciliation returns outcome=no_notice and decision=no_action; it does not authorize resubmission. Original snapshots remain exclusive to this authenticated GET. Consumers must support this variant before service review is enabled. Consumers using the compact submission status contract receive its stable final / no_action result; they do not need to parse the optional adjudication evidence. Integrations that use that evidence must validate its declared version.

Manifest revision 2026-09-17.1 defines plate_event_coverage_hold_v3 for an attested operator review that issues a notice for an existing ingest hold. It retains provenance=ingest and the original request evidence, with reason=reviewed_notice_issued, final revision 2, a notice carrying the notice ID and number, and a review recording the reviewer, evidence and plan digests, disposition_code=operator_attested_no_coverage, and authority=reviewed_notice_issuance. Both coverage and matched_permit are null: the operator attested that no permit covered the stay; no historical coverage is asserted. The exact POST replay returns HTTP 200 with decision=violation_issued, the notice ID, number and amount, and this adjudication. A notice can be issued only while the stay has a recorded departure and the current time is more than 24 hours before the Florida statutory mailing deadline for that departure; the deadline is written to the collection case. Reconciliation with an authoritative, provider-bound notice record uses evidence=violation_record without an adjudication field. This includes unambiguous notices from any provider when no response receipt exists. Otherwise, coverage custody returns outcome=notice_found, decision=violation_issued and evidence=coverage_hold. The compact submission status is final / violation_issued with the notice identity. Both violation.evaluated (without the original snapshot) and violation.issued are delivered. A consumer that does not support a declared adjudication version must answer with a non-2xx response rather than accept it.

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, mailing timestamps, and the resolver-selected statutory initial-notice deposit fact; it never infers a mailed letter from case approval or provider acceptance.

Every found response carries mailing.initial_notice_deposit. It is null exactly when collection is null. For a collection with no qualifying immutable sequence-1 event it is {deadline_relation: "unknown", evidence: null}. In this null-evidence arm, unknown means only that nfors has no qualifying event in this snapshot, never that the notice was not deposited. Follow-up, unbound, quarantined, and missing-callback evidence cannot satisfy the field.

deadline_relation: "unknown" also has a positive-evidence arm. When the resolver selects a qualifying sequence-1 deposit event but the current statutory obligation has no authoritative deadline, evidence is the complete non-null object shown below. In that arm, unknown means only that the deadline comparison is unavailable; the event still positively records deposit. It must not be read as missing or negative deposit evidence.

"initial_notice_deposit": {
  "deadline_relation": "on_time",
  "evidence": {
    "event_id": "...",
    "provider": "lob",
    "source_kind": "provider_callback",
    "time_basis": "exact_instant",
    "reported_on": null,
    "reported_timezone": null,
    "occurred_not_before_at": "2026-08-24T14:32:01.123456Z",
    "occurred_not_after_at": "2026-08-24T14:32:01.123456Z",
    "recorded_at": "2026-08-24T14:32:02.000000Z",
    "evidence_rule": { "key": "lob_mailed_tracking_event_time", "version": 1 },
    "binding_rule": { "key": "lob_collection_letter_sequence", "version": 1 }
  }
}

The four time_basis shapes are closed. exact_instant has finite microsecond bounds with the lower equal to the upper and both reported fields null. reported_date_named_timezone has ordered finite bounds, an ISO date, and a valid IANA zone; reported_date_unknown_timezone has the same date and bounds but a null timezone. signed_receipt_upper_bound has no finite lower bound, a finite upper bound, and null reported fields. Every finite occurrence bound is RFC3339 UTC with exactly six fractional digits.

deadline_relation is one of on_time, late, indeterminate, or unknown. It compares the shared resolver's selected evidence bounds with the authoritative deadline; it is not sweep, enforceability, or void authority. ParkPliant's timezone-less reported date retains its full UTC+14 through UTC-12 interval, so a deadline-boundary overlap remains permanently indeterminate. LOB's authenticated single Mailed event is an exact instant with stored microseconds preserved. Existing first_mailed_at semantics remain frozen as the minimum operational collection_letters.mailed_at; it is independent from legal deposit evidence in both directions. The complete deposit block is included in source_snapshot_sha256.

HTTP 200 with collection: null proves 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

Dashboard edits emit blockout.updated with the current block-out using the same serializer as create and cancel events. The ID, external ID and scope stay the same. Edits affect future enforcement only; previously voided charges stay voided. No-op saves emit no event. The public API has no edit operation.

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_request before 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, dashboard editing and cancelling attempt to enqueue blockout.created / blockout.updated / 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 — a final /plate-events decision event (issued / no-action / alert-logged), with a match_reasoning block. Pending coverage receipts do not enqueue this event; consumers must use submission status to track held work and must not use webhooks 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.updated / blockout.cancelled — an enforcement block-out was scheduled, edited, 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"
}

Source filter

Set filters.source to agent for agent-issued notices or lpr for LPR cameras. Omit it or use all to include every source, including other API imports. Payments, disputes, and collections use the linked notice's source; their existing date windows still apply. Compliance uses the recorded capture mode, and check-ins have no LPR rows. For lpr, agent_ids is ignored so a saved agent selection cannot hide camera results. Zone and location filters still apply.

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.