Overview
A webhook destination lets Corvid deliver LPR alarm events to any external system by making an HTTP request when an alarm fires. Each destination has its own URL, authentication, HTTP method, and a payload template that lets you shape the JSON body to match exactly what the receiving system expects.
Create a webhook destination
Open Settings → Webhooks and click New Webhook.
Name — a label so you can recognize this destination in the list (e.g. "Partner prod", "Slack alerts").
URL — the full HTTPS endpoint that will receive the request.
HTTP method — usually
POST.PUT,PATCH,GET, andDELETEare also supported.Authentication — pick
None,Bearer,Basic, orHeader, and provide the credential when required. Credentials are stored securely and never appear in the payload body.Template (optional) — a JSON body with expressions inside
{{ }}. See below. If left empty, the raw event JSON is sent as-is.Enabled — turn on when you're ready to receive events. You can save a destination disabled and turn it on later.
How the template works
The template is a JSON object of your choosing. When an alarm fires, Corvid walks through every text value in the template and evaluates any expression written inside {{ }} against the alarm event. Everything else, the JSON shape, the keys, and text without expressions, is sent exactly as you wrote it.
Simple field:
{{ plateNumber }}.Nested field via a dot path:
{{ geometry.y }}.Unknown fields or evaluation errors render as an empty string, one bad expression will not fail the delivery.
All values are sent as strings inside the JSON. A confidence of 87 becomes
"87".Booleans and numbers written directly in the template (e.g.
false,0) are preserved as their original type.
Available fields
The most commonly used fields from an LPR alarm event. Fields marked (list) hold multiple values — use indexing or a filter such as join to render them, otherwise you will send an unhelpful Python-style list representation like "['STOLEN']" to your receiver.
{{ plateNumber }}— the recognized license plate.{{ state }}— two-letter US state code (e.g.CA).{{ confidence }}— recognition confidence, 0 to 100.{{ cameraName }}— the camera's display name.{{ cameraId }}— camera identifier.{{ location }}— the camera's address or location description.{{ geometry.y }}— latitude of the camera.{{ geometry.x }}— longitude of the camera.{{ processedAt }}— ISO 8601 timestamp of when the plate was processed.{{ createdAt }}— ISO 8601 timestamp of when the event was created.{{ overviewImageUrl }}— presigned URL to the full scene image (valid for 72 hours).{{ plateImageUrl }}— presigned URL to the plate crop (valid for 72 hours).{{ make }},{{ model }},{{ color }}— vehicle attributes when detected.{{ hotlistNames }}(list) — human-readable names of the hotlists the plate matched.{{ hotlistId }}(list) — stable identifiers of the matched hotlists.{{ reason }}(list) — the reason code configured on each matched hotlist entry (e.g.STOLEN). Empty strings appear when a hotlist entry has no reason set.{{ type }}— the alarm type, e.g.lpr.{{ suppressed }},{{ suppressionId }}— suppression state and the matching suppression rule (when applicable).
Other Template features
You can also index into lists, join them, format combined strings, and add simple conditionals, all inside the same {{ }} braces. Each example below assumes the event contains hotlistNames = ["Stolen Vehicles", "Amber Alert"] and reason = ["STOLEN", "AMBER"].
Read the first item of a list —
{{ hotlistNames[0] }}→"Stolen Vehicles". Any index works:{{ hotlistNames[1] }}→"Amber Alert".Join a list into one string —
{{ hotlistNames | join(", ") }}→"Stolen Vehicles, Amber Alert".First or last item —
{{ hotlistNames | first }}→"Stolen Vehicles";{{ hotlistNames | last }}→"Amber Alert".Combine values with literal text — use the
~operator to concatenate strings:{{ hotlistNames[0] ~ " (" ~ reason[0] ~ ")" }}→"Stolen Vehicles (STOLEN)".Fallback when a field is missing —
{{ cameraName | default("Unknown camera") }}→"Unknown camera"whencameraNameis absent, or the real camera name otherwise.Simple conditionals —
{% if suppressed %}suppressed{% else %}active{% endif %}→"suppressed"or"active"depending on the event.
Common patterns
Human-readable hit reason —
{{ hotlistNames[0] ~ " (" ~ reason[0] ~ ")" }}→"Stolen Vehicles (STOLEN)".All matched hotlists in one string —
{{ hotlistNames | join(", ") }}→"Stolen Vehicles, Amber Alert".Camera coordinates as a "lat,lon" pair —
{{ geometry.y ~ "," ~ geometry.x }}→"37.7749,-122.4194".Emit a value only when it's present —
{% if suppressionId %}Suppressed by rule {{ suppressionId }}{% endif %}→"Suppressed by rule sr-42"when the event has asuppressionId, or""when it doesn't.
Example
Simple Template
A minimal Slack-style notification body.
Template:
{
"text": "Plate {{ plateNumber }} ({{ state }}) detected at {{ cameraName }} with confidence {{ confidence }}%.",
"image_url": "{{ plateImageUrl }}"
}Rendered for an event with plateNumber="7ABC123", state="CA", cameraName="North Gate Entry", confidence=87, plateImageUrl="https://…plate.jpg?…":
{
"text": "Plate 7ABC123 (CA) detected at North Gate Entry with confidence 87%.",
"image_url": "https://…plate.jpg?…"
}
Detailed partner integration template
A richer body that maps LPR event fields into a nested structure a partner system might expect. Note how hit_reason uses the combined pattern above so the receiver gets a clean scalar string instead of a raw list.
Template:
{
"reads": [
{
"camera_name": "{{ cameraName }}",
"camera_id": "{{ cameraId }}",
"plate": "{{ plateNumber }}",
"state": "{{ state }}",
"confidence": "{{ confidence }}",
"lat": "{{ geometry.y }}",
"lon": "{{ geometry.x }}",
"overview_image": "{{ overviewImageUrl }}",
"plate_image": "{{ plateImageUrl }}",
"event_timestamp": "{{ processedAt }}",
"hit_reason": "{{ hotlistNames[0] ~ ' (' ~ reason[0] ~ ')' }}",
"is_hit": "true",
"details": {
"Speed": "0",
"Direction": "",
"is_image_local": false
}
}
]
}Rendered for the same event, extended with cameraId="cam-north-01", geometry={x: -122.4194, y: 37.7749}, processedAt="2026-08-28T14:32:09Z", hotlistNames=["Stolen Vehicles"], reason=["STOLEN"]:
{
"reads": [
{
"camera_name": "North Gate Entry",
"camera_id": "cam-north-01",
"plate": "7ABC123",
"state": "CA",
"confidence": "87",
"lat": "37.7749",
"lon": "-122.4194",
"overview_image": "https://…overview.jpg?…",
"plate_image": "https://…plate.jpg?…",
"event_timestamp": "2026-08-28T14:32:09Z",
"hit_reason": "Stolen Vehicles (STOLEN)",
"is_hit": "true",
"details": {
"Speed": "0",
"Direction": "",
"is_image_local": false
}
}
]
}
Troubleshooting
Empty values where you expected a plate or camera name — check the field spelling. Names are case-sensitive, and unknown or mistyped fields silently become empty strings.
Values look like
"['STOLEN']"with brackets and quotes — you referenced a list field directly (for example{{ reason }}). Use an index ({{ reason[0] }}) or a filter ({{ reason | join(", ") }}) to render list fields as clean strings.The receiver rejects the body as invalid JSON — double-check that quotes around each expression are preserved and no braces are missing. Also verify that any static values in the JSON are properly quoted.
The receiver rejects with a 4xx code — this is a receiver-side issue: authentication is wrong, the receiver's rate limit has been hit, or the payload shape doesn't match what the receiver expects. Corvid retries up to three times before giving up.
Nothing arrives at all — confirm the destination is Enabled, the URL is reachable from the public internet, and the alarm is firing (check the alarm's Last triggered timestamp in the Alarms list).

