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.
https://nfors.ai/api/v1Authentication
Every request must send a bearer token in the Authorization header. Keys start with nfors_live_. Generate one under Settings → API keys; the full key is shown once at creation time.
Authorization: Bearer nfors_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
Keys are operator-scoped — every object you create or read is automatically confined to your operator. Revoke a key at any time; requests using it will 401 immediately.
Errors, pagination, rate limits
All errors return a JSON envelope with a short machine-readable code and a human message:
{
"error": {
"code": "invalid_request",
"message": "zone_id does not belong to your operator."
}
}Status codes in use: 400 invalid_request, 401 unauthorized, 403 forbidden, 404 not_found, 409 conflict (duplicate external_id), 422 unprocessable, 429 rate_limited, 500 internal_error.
List endpoints return up to limit items (default 50, max 200) and include has_more and next_cursor. Pass ?cursor=with the previous response’s next_cursor to paginate.
Rate limits are per API key, per scope, per second: writes default to 100/s and reads to 500/s, each overridable per key (rate_limit_per_sec_writes / rate_limit_per_sec_reads). A 429 carries Retry-After (seconds) plus X-RateLimit-Scope and X-RateLimit-Limit so you can back off against the right budget.
GET /me
Returns the operator your key belongs to. Use this to verify configuration.
curl -s https://nfors.ai/api/v1/me \
-H "Authorization: Bearer $NFORS_API_KEY"
# {
# "operator": { "id": "...", "slug": "level-parking", "company_name": "Level Parking", ... },
# "api_key_id": "..."
# }GET /zones
Lists every zone in your operator. Every zone has our id (UUID) and an optional operator-assigned external_id. You can reference zones by either in any request.
curl -s https://nfors.ai/api/v1/zones -H "Authorization: Bearer $NFORS_API_KEY"
# {
# "data": [
# {
# "id": "2f27d3f2-…",
# "external_id": "LOT-A",
# "name": "North Lot",
# "max_capacity": null,
# "effective_max_capacity": 40,
# "effective_enforcement_hours": {
# "mode": "restricted",
# "windows": [{ "days": ["mon","tue","wed","thu","fri"],
# "start": "09:00", "end": "17:00" }]
# },
# "effective_operating_hours": { "text": "Mon-Fri 7a-7p" },
# "enforcement_hours_zone_override": false,
# "operating_hours_zone_override": false,
# "max_capacity_zone_override": false,
# ...
# }
# ]
# }The raw per-zone max_capacity, operating_hours, and enforcement_hours fields stay backward-compatible: they’re still whatever is stored on the zones row itself. The additive effective_*fields (P2.1, migration 0053) carry the value that actually applies to the zone after resolving against the parent location’s defaults:
effective_max_capacity,effective_operating_hours,effective_enforcement_hours— zone value when non-null; otherwise the parent location’s default; null when neither side is set.max_capacity_zone_override,operating_hours_zone_override,enforcement_hours_zone_override— true iff the zone row itself carried the value. False for inherited-from-location or both-null.
Locations
A location is an organisational grouping above zones — a facility, a campus, a lot cluster. An operator may have many locations; each location may contain many zones. Compliance (state, signage attestation) stays at the zone level; locations are optional and purely organisational today.
Create
curl -s -X POST https://nfors.ai/api/v1/locations \
-H "Authorization: Bearer $NFORS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "North Pensacola Campus",
"external_id": "lp-campus-42",
"address": "25 W Government St, Pensacola, FL 32502",
"timezone": "America/Chicago"
}'List, fetch, update, delete
GET https://nfors.ai/api/v1/locations
GET https://nfors.ai/api/v1/locations/{id}
PATCH https://nfors.ai/api/v1/locations/{id}
DELETE https://nfors.ai/api/v1/locations/{id}Deleting a location is safe: zones currently assigned here have their location_id cleared but are not deleted.
Location object
{
"id": "uuid",
"name": "North Pensacola Campus",
"external_id": "lp-campus-42" | null,
"address": "..." | null,
"timezone": "America/Chicago" | null, // IANA
"notes": "..." | null,
"enforcement_hours": {...} | null, // P2.1 default; null = no default
"operating_hours": {...} | null,
"max_capacity": 40 | null,
"created_at": "ISO 8601",
"updated_at": "ISO 8601"
}The three override fields feed every zone assigned to this location whose own column is null. Zone-level values win over location defaults — see effective_* on GET /zones for the resolved shape.
Zone mappings (third-party zone codes)
When an operator uses a payment provider like Parkmobile, Passport, HONK, or T2, the provider has its own zone-code namespace. Zone mappings attach those upstream codes to your nfors zones so our pull-based adapters can route incoming permits and sessions correctly. An operator can toggle each integration on per-zone from the dashboard; this API is the programmatic equivalent.
Create
curl -s -X POST https://nfors.ai/api/v1/zone-mappings \
-H "Authorization: Bearer $NFORS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"zone_external_id": "lot-A",
"provider": "PARKMOBILE",
"source_zone_code": "12345",
"config": { "space_range": "A1-A50" }
}'source_zone_code is unique per (operator_id, provider) — one upstream code can only point to one nfors zone. config holds provider-specific extras (e.g. Parkmobile space_range).
List, fetch, update, delete
# list (filters: zone_id, provider, limit, cursor)
GET https://nfors.ai/api/v1/zone-mappings?provider=PARKMOBILE
GET https://nfors.ai/api/v1/zone-mappings/{id}
PATCH https://nfors.ai/api/v1/zone-mappings/{id}
DELETE https://nfors.ai/api/v1/zone-mappings/{id}Zone mapping object
{
"id": "uuid",
"zone_id": "uuid",
"provider": "PARKMOBILE" | "PARKLYNC" | "PASSPORT" | "HONK" | "T2",
"source_zone_code": "12345",
"config": { "space_range": "A1-A50" },
"created_at": "ISO 8601",
"updated_at": "ISO 8601"
}Only the four pull-based providers appear here. LEVEL, MANUAL, and DASHBOARDare push or in-app paths — they don’t need a mapping.
Metadata conventions
The metadataobject on permits, whitelist entries, and violations is free-form JSON; it’s passed through untouched and surfaced on the parker portal and in webhook payloads. Two conventions keep operator + integration data legible:
metadata.tenant,metadata.unit,metadata.reservation_id, etc. — operator-side context that helps your support team recognise a permit at a glance.metadata.provider_raw— reserved for the verbatim upstream payload when data flows through a nfors provider adapter (Parkmobile, ParkLync, Passport, HONK, etc). Our pull-based adapters write the raw provider response here so dispute forensics can trace any permit back to its source of truth. If you’re pushing permits directly, treatprovider_rawas reserved — don’t set it yourself unless you’re forwarding data from another system we haven’t adapted to yet.
Using your own IDs (external_id)
Every zone and charge reason can carry an operator-assigned external_id — the code your PMS, ERP, or reservation system uses for that record. Set it once in the dashboard (Manage → Zones / Charge reasons) and reference it from the API instead of our UUID. Same integration works across your portfolio without needing a UUID lookup step.
Every request that accepts zone_id also accepts zone_external_id. Same for violation_reason_id / violation_reason_external_id. Provide one, not both.
# Reference by external_id — no prior /zones call needed
curl -X POST https://nfors.ai/api/v1/permits \
-H "Authorization: Bearer $NFORS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"zone_external_id": "LOT-A",
"plate_number": "ABC1234",
"plate_state": "FL",
"valid_from": "2026-04-15T00:00:00Z",
"valid_to": "2026-05-15T00:00:00Z",
"external_id": "pms-44817"
}'
# Bulk permits: mix UUIDs and external_ids per row
curl -X POST https://nfors.ai/api/v1/permits/bulk \
-H "Authorization: Bearer $NFORS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"permits": [
{ "zone_external_id": "LOT-A", "plate_number": "AAA111", "plate_state": "FL", ... },
{ "zone_id": "2f27d3f2-...", "plate_number": "BBB222", "plate_state": "FL", ... }
]
}'
# Whitelist across multiple zones by external_id
curl -X POST https://nfors.ai/api/v1/whitelist \
-H "Authorization: Bearer $NFORS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"zone_external_ids": ["LOT-A","LOT-B"],
"plate_number": "VIP100",
"plate_state": "FL"
}'Webhook payloads include zone_external_id and violation_reason_external_id (nullable) alongside the UUIDs, so your consumer can route on the IDs it already knows.
Permits
A permit authorizes a specific plate, in a specific zone, during a specific time window. Use permits for standard parkers (monthly passholders, resident spaces, reservations, event tickets).
Permits are zone-scoped. If you need a plate valid across every zone in your operator (ownership vehicles, service trucks, platform-wide VIPs), use the whitelist with all_zones: true instead. Posting a permit with a sentinel like zone_external_id: "ALL" returns a 400 pointing here.
Upsert a permit
curl -s -X POST https://nfors.ai/api/v1/permits \
-H "Authorization: Bearer $NFORS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"zone_external_id": "lot-A",
"plate_number": "ABC1234",
"plate_state": "FL",
"valid_from": "2026-04-14T00:00:00Z",
"valid_to": "2026-05-14T00:00:00Z",
"permit_type": "monthly",
"provider": "LEVEL",
"channel": "SUBSCRIPTION",
"space_number": "A12",
"external_id": "lp-44817",
"metadata": { "tenant": "Unit 204" }
}'If external_id is provided, the request is idempotent — sending the same external_id again updates the existing permit instead of creating a duplicate. Omit external_id for one-off inserts.
Bulk upsert (up to 1000 per request)
curl -s -X POST https://nfors.ai/api/v1/permits/bulk \
-H "Authorization: Bearer $NFORS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"permits": [
{ "zone_id": "...", "plate_number": "AAA111", "plate_state": "FL",
"valid_from": "...", "valid_to": "...", "external_id": "p-1" },
{ "zone_id": "...", "plate_number": "BBB222", "plate_state": "FL",
"valid_from": "...", "valid_to": "...", "external_id": "p-2" }
]
}'
# {
# "data": [ /* created/updated permit objects */ ],
# "accepted_count": 2,
# "rejected": []
# }Rows with an invalid zone_id are reported in rejectedalongside their index. The whole request only 4xx’s when nothing was valid.
List, fetch, update, delete
# list (filters: plate, state, zone_id, active_at, external_id,
# provider, channel, space_number, status, limit, cursor)
GET https://nfors.ai/api/v1/permits?active_at=2026-04-14T18:00:00Z&provider=PARKMOBILE&limit=100
GET https://nfors.ai/api/v1/permits/{id}
PATCH https://nfors.ai/api/v1/permits/{id}
DELETE https://nfors.ai/api/v1/permits/{id}Permit object
{
"id": "uuid",
"zone_id": "uuid",
"plate_number": "ABC1234", // always uppercase, whitespace stripped
"plate_state": "FL", // uppercase 2-letter code
"country": "US", // ISO-3166 alpha-2, defaults to "US"
"make_model": "Honda Civic" | null,
"valid_from": "ISO 8601",
"valid_to": "ISO 8601",
"provider": "PARKMOBILE" | "PARKLYNC" | "PASSPORT" | "HONK" | "T2" | "LEVEL" | "MANUAL" | "DASHBOARD",
"channel": "SUBSCRIPTION" | "PM_SESSION" | "GUESTPAY" | ... | null,
"space_number": "A12" | null,
"status": "ACTIVE" | "ENDED" | "VOIDED" | "SUPERSEDED",
"voided_reason": "REFUND" | "DISPUTE" | "ADMIN_VOID" | "PROVIDER_VOID" | "REPLACED" | null,
"ended_at": "ISO 8601" | null, // set when observed terminated; null while ACTIVE
"supersedes_permit_id": "uuid" | null,
"source": "PMS" | null, // deprecated — prefer provider/channel
"permit_type": "monthly" | "hourly" | "event" | "employee" | "visitor" | "resident" | "other" | null,
"metadata": { ... },
"external_id": "your-id" | null,
"created_via": "api" | "dashboard",
"created_at": "ISO 8601",
"updated_at": "ISO 8601"
}Provider + channel split the legacy free-form source into two orthogonal fields. A Parkmobile hourly session and a Level Parking GuestPay session are different providers but similar channels(both transient paid). Separating them lets you query “all Parkmobile activity” independently of “all transient-paid activity.”
Space number lets you attach permits to a specific numbered stall in a lot. Match-time lookups can filter to the exact space or fall back to zone-wide permits.
Idempotency is scoped to (operator_id, provider, external_id). Parkmobile “12345” and Passport “12345” are distinct rows — they do not collide.
Legacy source is still accepted on write and will be translated to provider/channel when recognisable. The source field is deprecated and will be removed in a future major version.
Whitelist
A whitelist entry authorizes a plate across multiple zones (or every zone) for an optional time window. Use it for cross-property passes, employee vehicles, VIP passes, and any plate that should always match when a camera or agent sees it.
Operator-wide passes (Super VIP / ownership / service fleet / law-enforcement plates) are a whitelist entry with all_zones: true, valid_from/valid_to both null, and a descriptive metadata.reason. The plate-event decision tree matches these on every zone in your operator and returns decision: “no_action” with matched_kind: “whitelist”— never a violation.
Upsert across specific zones
curl -s -X POST https://nfors.ai/api/v1/whitelist \
-H "Authorization: Bearer $NFORS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"zone_ids": ["zone-1-uuid", "zone-2-uuid"],
"plate_number": "VIP100",
"plate_state": "FL",
"valid_to": "2027-01-01T00:00:00Z",
"permit_type": "employee",
"external_id": "hrms-3319"
}'Upsert across every zone
curl -s -X POST https://nfors.ai/api/v1/whitelist \
-H "Authorization: Bearer $NFORS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"all_zones": true,
"plate_number": "OWNER1",
"plate_state": "FL"
}'Exactly one of zone_ids or all_zones: true must be provided. valid_from / valid_to are both optional; omit both for a permanent entry.
Bulk + CRUD
POST https://nfors.ai/api/v1/whitelist/bulk # up to 1000 entries
GET https://nfors.ai/api/v1/whitelist # filters: plate, state, external_id,
# provider, channel
GET https://nfors.ai/api/v1/whitelist/{id}
PATCH https://nfors.ai/api/v1/whitelist/{id}
DELETE https://nfors.ai/api/v1/whitelist/{id}Whitelist object
{
"id": "uuid",
"zone_ids": ["uuid", ...] | null, // null = applies to every zone
"all_zones": boolean,
"plate_number": "VIP100",
"plate_state": "FL",
"valid_from": "ISO 8601" | null,
"valid_to": "ISO 8601" | null,
"provider": "PARKMOBILE" | "PARKLYNC" | "PASSPORT" | "HONK" | "T2" | "LEVEL" | "MANUAL" | "DASHBOARD",
"channel": "EMPLOYEE" | "OWNER" | ... | null,
"source": "legacy-hint" | null, // deprecated — prefer provider/channel
"permit_type": "employee" | "visitor" | ... | null,
"metadata": { ... },
"external_id": "your-id" | null,
"created_via": "api" | "dashboard",
"created_at": "ISO 8601",
"updated_at": "ISO 8601"
}Whitelist idempotency is scoped to (operator_id, provider, external_id) — same as permits — so two providers can both push the same upstream ID without colliding.
POST /files
Upload an image before creating a parking charge. Max 10 MB per file; accepted types: image/jpeg, image/png, image/webp, image/heic. Returns a file_id you reference from POST /violations. Files that aren’t attached to a parking charge within 24h are garbage-collected.
curl -X POST https://nfors.ai/api/v1/files \
-H "Authorization: Bearer $NFORS_API_KEY" \
-F "file=@/path/to/plate.jpg"
# {
# "data": { "id": "3f2b…", "size": 214300, "mime_type": "image/jpeg", "created_at": "..." }
# }POST /violations (LPR & programmatic issuance)
Create a parking charge from an external system (LPR cameras, reservation software, manual tools). The charge flows through the same lifecycle as agent-issued ones: parker portal, Stripe payment, dispute, refund.
Two ways to attach images
Supply either file_ids (from POST /files) or image_urls (publicly fetchable HTTPS URLs — often signed URLs from your LPR storage). Mix and match up to 5 images total. URL fetches reject redirects and any host that resolves to a private IP.
End-to-end LPR submission
# 1. Upload the plate close-up and wide shot.
FILE1=$(curl -s -X POST https://nfors.ai/api/v1/files \
-H "Authorization: Bearer $NFORS_API_KEY" \
-F "file=@plate.jpg" | jq -r .data.id)
FILE2=$(curl -s -X POST https://nfors.ai/api/v1/files \
-H "Authorization: Bearer $NFORS_API_KEY" \
-F "file=@vehicle.jpg" | jq -r .data.id)
# 2. Create the violation.
curl -X POST https://nfors.ai/api/v1/violations \
-H "Authorization: Bearer $NFORS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"zone_id": "2f27d3f2-...",
"violation_reason_id": "a1b2c3d4-...",
"plate_number": "ABC1234",
"plate_state": "FL",
"latitude": 30.4213,
"longitude": -87.2169,
"issued_at": "2026-04-15T14:32:07Z",
"source": "api_lpr",
"provider": "LEVEL",
"channel": "FIELD_LPR",
"external_id": "lpr-batch-7781",
"notes": "Vehicle in fire lane",
"metadata": {
"camera_id": "lot-a-north",
"lpr_confidence": 0.982,
"detection_time": "2026-04-15T14:32:04Z",
"make_model": "Toyota Camry / silver"
},
"file_ids": ["'"$FILE1"'", "'"$FILE2"'"]
}'About the three similar-sounding fields:source is the capture method (api_lpr | api_manual | api); provider is which system originated the data (your operator code or an upstream integration); channel is what kind of interaction it is (e.g. FIELD_LPR, GATE_LPR). Both provider and channel are optional; sensible defaults apply (MANUAL, null). Violations idempotency is scoped to (operator_id, provider, external_id) so two upstream systems can both reuse the same ID.
Or skip the upload step with image_urls
curl -X POST https://nfors.ai/api/v1/violations \
-H "Authorization: Bearer $NFORS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"zone_id": "...",
"violation_reason_id": "...",
"plate_number": "ABC1234",
"plate_state": "FL",
"latitude": 30.4213,
"longitude": -87.2169,
"image_urls": [
"https://lpr.level-parking.com/signed/plate-7781.jpg",
"https://lpr.level-parking.com/signed/wide-7781.jpg"
]
}'URLs must be https://, return an image/*content-type, be at most 10 MB, and not redirect. nfors fetches once and stores the bytes.
Idempotency
Supply external_id to make the request idempotent — replays return the original charge with idempotent_replay: true instead of creating a duplicate.
Permit-match guard
If the plate has an active permit or whitelist entry in the zone at issued_at, the POST returns 409 conflict with the matching record. Resend with permit_override_reasonto issue anyway (recorded in the charge’s notes for audit).
Response
{
"data": {
"id": "...", "violation_number": "NF-2026-000834",
"status": "issued", "source": "api_lpr", "external_id": "lpr-batch-7781",
"plate_number": "ABC1234", "plate_state": "FL",
"base_charge_amount": 7500, "total_amount": 7500,
"latitude": 30.4213, "longitude": -87.2169,
"issued_at": "2026-04-15T14:32:07Z",
"metadata": { "camera_id": "...", "lpr_confidence": 0.982, ... },
"images": [
{ "storage_path": "...", "url": "https://signed.supabase.co/..." }
]
}
}Signed image URLs expire in ~1 hour. Use GET /violations/{id} to get a fresh set.
GET /permit-match
Check whether a plate has an active permit or whitelist entry in a zone right now (or at a specific timestamp). This is the same lookup your agents see in the field.
curl -s "https://nfors.ai/api/v1/permit-match?plate=ABC1234&state=FL&zone_external_id=lot-A&space_number=A12" \
-H "Authorization: Bearer $NFORS_API_KEY"
# {
# "valid": true,
# "matches": [
# { "kind": "permit", "id": "...", "valid_from": "...", "valid_to": "...",
# "provider": "PARKMOBILE", "channel": "PM_SESSION", "space_number": "A12",
# "status": "ACTIVE",
# "source": "legacy-hint", "permit_type": "monthly", "metadata": { ... } }
# ]
# }Query params: plate and state are required. Provide either zone_id or zone_external_id. Optional space_number narrows matches to permits on that exact stall (plus any zone-wide permits without a stall number). Optional at (ISO datetime) lets you query a specific point in time; defaults to now. Optional grace_seconds(0–900) extends a permit’s upper validity boundary at match-time — useful for LPR systems absorbing small clock skew with the upstream provider without false negatives on just-expired sessions. The permit row itself isn’t modified.
Batch lookup (up to 200 plates per request)
curl -s -X POST https://nfors.ai/api/v1/permit-match/batch \
-H "Authorization: Bearer $NFORS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"at": "2026-04-15T19:00:00Z",
"grace_seconds": 30,
"queries": [
{ "plate": "ABC1234", "state": "FL", "zone_external_id": "lot-A" },
{ "plate": "XYZ7890", "state": "FL", "zone_external_id": "lot-A", "space_number": "A12" }
]
}'
# {
# "count": 2,
# "valid_count": 1,
# "error_count": 0,
# "results": [
# { "index": 0, "valid": true, "matches": [{ "kind": "permit", "provider": "PARKMOBILE", ... }], "error": null },
# { "index": 1, "valid": false, "matches": [], "error": null }
# ]
# }Top-level at and grace_seconds apply as defaults; any query can override its own. Unknown zone_id or zone_external_id does not fail the whole request — the offending row carries an error string and others proceed.
POST /plate-events (raw LPR observations)
Send a raw plate observation and let nfors adjudicate it, instead of deciding to issue yourself. The decision branches on the zone’s enforcement_mode: a permit match returns no_action; an unmatched plate in an alert_only zone is logged (and queued through the legal grace window); an unmatched plate in an enforce zone creates a charge with source="api_lpr". The permit match runs the same three tiers as everywhere else (exact → state-fallback → confusable), and state grace is applied to the parking duration (exit_at - entry_at when present).
curl -X POST https://nfors.ai/api/v1/plate-events \
-H "Authorization: Bearer $NFORS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"zone_external_id": "crystal-tower",
"plate_number": "JP6WZ4",
"plate_state": "FL",
"entry_at": "2026-06-08T14:02:00Z",
"exit_at": "2026-06-08T15:30:00Z",
"external_id": "lpr-88213"
}'Every decision fires a violation.evaluated webhook with a match_reasoning block (including a state_fallback or confusable_fallback sub-object when a fuzzy tier matched). Replays on the same (operator, external_id) are idempotent. 4xx bodies carry a details.grace_window diagnostic (timestamps only, no PII).
Enforcement block-outs
Schedule a time-bounded enforcement pause — “don’t ticket during tonight’s event, 6–9 PM.” Scope is zone XOR location XOR operator-wide, and the window has a 14-day ceiling. While a block-out is active, LPR plate-events return no_action and /violationscreates auto-void. Optionally bulk-void existing in-window charges — paid ones are surfaced for manual review, never auto-voided.
# Schedule
curl -X POST https://nfors.ai/api/v1/blockouts \
-H "Authorization: Bearer $NFORS_API_KEY" \
-d '{
"scope": "zone",
"zone_id": "...",
"starts_at": "2026-06-12T22:00:00-05:00",
"ends_at": "2026-06-13T01:00:00-05:00",
"void_existing_violations": true,
"external_id": "wedding-0612"
}'
# List with scope/status/active_at filters + cursor — GET /blockouts
# Cancel — POST /blockouts/{id}/cancelTimestamps are RFC3339 with offset. external_id makes POST idempotent (a replay returns 200 with voided_count: 0). Scheduling and cancelling fire blockout.created / blockout.cancelled webhooks.
Webhooks
Configure endpoints under Settings → Webhooks. We’ll POST a JSON event to each matching endpoint and retry on non-2xx responses with exponential backoff (1m, 5m, 30m, 2h, 6h, 12h, 24h — up to 8 attempts over ~48 hours).
Event envelope
{
"type": "violation.issued",
"created_at": "2026-04-14T18:42:03.004Z",
"data": { "violation_id": "...", "violation_number": "NF-2026-000042", "zone_id": "...", "plate_number": "ABC1234", "plate_state": "FL" }
}Signature verification
Every delivery includes a nfors-signature header in the form t=<unix-seconds>,v1=<hex-hmac>. Compute HMAC-SHA256 of <t>.<raw-body>with your endpoint’s signing secret (whsec_..., shown once at creation) and compare against v1.
// Node 20+
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map(p => {
const i = p.indexOf('='); return [p.slice(0, i), p.slice(i + 1)];
}));
const t = Number(parts.t);
if (Math.abs(Date.now()/1000 - t) > 300) return false; // 5-min replay window
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
const got = Buffer.from(parts.v1, 'hex');
return got.length === expected.length && timingSafeEqual(expected, got);
}Read the raw request body before JSON-parsing for verification to succeed byte-for-byte.
Event types
permit.created/permit.updated/permit.deletedwhitelist.created/whitelist.updated/whitelist.deletedviolation.issued— a new parking charge was issuedviolation.voided— a supervisor voided a chargeviolation.reduced— a supervisor reduced a charge’s amountviolation.paid— a charge was paid in full, by Stripe Checkout or a recorded off-platform paymentpayment.succeeded— a payment was received: Stripe Checkout, or an operator-recorded check / money order / cash. A partial off-platform payment fires this withoutviolation.paidpayment.refunded— refund processed. Payload carriesrefund_reason:dispute_approved|duplicate_payment|operator_error|provider_error|goodwill|other|null(unknown; set it by passingmetadata.reasononstripe.refunds.create). A reversed off-platform payment also fires this, withreversed: true.dispute.created— parker submitted a disputedispute.resolved— dispute approved or deniedviolation.evaluated— every/plate-eventsdecision (issued / no-action / alert-logged), with amatch_reasoningblockviolation.corrected— a supervisor corrected a charge’s plate or vehicle; payload carriesfields_changed,plate_changed,void_cascadedqueue.opened/queue.ready/queue.resolved— grace-period queue lifecycle (resolvedcarriesresolution.kind: violation / paid / departed / void / expired)blockout.created/blockout.cancelled— an enforcement block-out was scheduled or cancelled
Register endpoints via the API
Manage endpoints programmatically instead of the dashboard. POST /webhook-endpoints returns { id, signing_secret } on a fresh create (the secret is shown once); a repeat of the same (operator, url) returns { id, idempotent: true } with no secret. DELETE /webhook-endpoints/{id} deregisters it (tenant-scoped 404; cascades pending deliveries). Rotate a secret with a DELETE + POST cycle.
curl -X POST https://nfors.ai/api/v1/webhook-endpoints \
-H "Authorization: Bearer $NFORS_API_KEY" \
-d '{ "url": "https://example.com/hooks/nfors" }'Redelivery
Every delivery (succeeded, failed, abandoned) is kept for audit under Settings → Webhooks. Click Redeliver on any row to retry.
POST /reports/execute
Run any Reports v2 query as JSON. The request body is the same declarative shape the dashboard Builder emits — pick a subject, filters, group-by, and metric, and the cube returns the uniform aggregated row set.
Request
POST /api/v1/reports/execute
Authorization: Bearer nfors_live_...
Content-Type: application/json
{
"subject": "violations",
"filters": {
"date_range": { "kind": "preset", "preset": "last_30d" },
"zone_ids": ["<zone-uuid>"]
},
"group_by": "agent",
"metric": "count"
}Subjects, group-bys, and metrics
violations— group byday/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_ratepayments— group byday/week/month/status/refund_reason/zone/location/agent/none; metrics:count/sum_total/sum_platform/sum_operator/sum_refundcheck_ins— group byday/week/month/agent/zone/hour_of_day/day_of_week/none; metrics:count/distinct_zones/distinct_agentsdisputes— group byday/week/month/status/reviewer/zone/agent/none; metrics:count/approval_rate/denial_rate/avg_resolution_days/on_time_ratecollections— group byday/week/month/status/source/zone/agent/none; metrics:count/cure_rate/letters_per_case/mailed_avg
Date range
Either a preset (last_7d, last_30d, last_90d, month_to_date, quarter_to_date, year_to_date) or a custom ISO-8601 pair:
"date_range": {
"kind": "custom",
"from": "2026-04-01T00:00:00.000Z",
"to": "2026-04-30T23:59:59.999Z"
}Response
{
"rows": [
{ "dim_key": "<agent-uuid>", "dim_label": "Alex Officer", "metric_value": 47 },
{ "dim_key": "<agent-uuid>", "dim_label": "Priya Lopez", "metric_value": 31 }
],
"from": "2026-03-24T00:00:00.000Z",
"to": "2026-04-23T23:59:59.999Z",
"metric": { "key": "count", "label": "Violations", "shape": "count" },
"query": { ...echoed back for audit/caching... }
}rows is sorted by dim_key. Money metrics return cents (integer). Percent metrics return a 0..1 ratio. The metric.shape field tells you which formatter to apply.
Scope & rate limit
Scope is derived from the API key — the operator_id in the body cannot widen it. Counts against the read rate budget (500/s default per key; overridable per-key via rate_limit_per_sec_reads).
Idempotency & ordering
For permits and whitelist entries, pass an external_id to make writes idempotent: subsequent POSTs with the same external_id update the existing row. Webhook deliveries include a unique nfors-event-id header — store it and reject replays on your side for exactly-once handling.
We do not guarantee delivery order for webhooks; use the created_at field in the payload when ordering matters.