Quick start
Three steps from zero to your first plate read. There is no SDK to install — any HTTP client that can send a multipart form works, from curl to a camera firmware script.
1. Get an API key
Request access and we provision a key the same day. The free tier includes 150 API calls per month — no credit card required.
Request API access2. Send an image
POST a photo as a multipart form field named file, with your key in the Authorization header. The API finds every plate in the frame and reads it in a single pass.
curl -X POST "https://api.example.com/v1/detect" \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@car_image.jpg"3. Parse the JSON
Each plate comes back with a bounding box, the decoded text, per-character confidence and country attributes — ready to match against your allow-list or billing system.
{
"success": true,
"plates_found": 1,
"plates": [
{
"plate_id": 0,
"position": { "x1": 120, "y1": 340, "x2": 280, "y2": 390 },
"confidence": 0.95,
"text": { "full": "7TRX294" },
"attributes": { "country": "us" }
}
],
"cost": 0.02,
"timestamp": "2026-07-08T09:12:44.412633"
}Authentication
Every request is authenticated with a bearer token. Pass your API key in the Authorization header — no request signing, no OAuth dance, no session management.
Authorization: Bearer YOUR_API_KEYKeep your API key server-side. Never embed it in client-side JavaScript, mobile apps or public repositories — anyone holding the key can spend your wallet balance. If a key leaks, rotate it from the dashboard; the old key is invalidated immediately.
Endpoints
The API is a small, predictable surface: two GET endpoints and four POST endpoints. Every image endpoint accepts multipart form data with a file field and returns JSON.
All endpoints are served over HTTPS from a single base URL: https://api.example.com
| Method | Endpoint | Description |
|---|---|---|
| GET | /health | Service and model status — free, no authentication |
| POST | /v1/detect | Detect and read every plate in an image, plus optional car analysis |
| POST | /v1/detect/{country} | Detection hinted to one country for faster, more accurate reads |
| POST | /v1/car | Car brand, model, body type, color and view angle — no plate reading |
| POST | /v1/body | Vehicle body classification with full class probabilities |
| POST | /v1/read | OCR a plate you have already cropped |
| GET | /v1/usage | Your request volume, spend and remaining balance |
/health
Returns the status of the service and of each model in the inference pipeline. Useful for uptime monitors and readiness probes. This endpoint is free and requires no authentication.
Request
curl https://api.example.com/healthResponse
{
"status": "healthy",
"models": {
"detection": true,
"classification": true,
"ocr": true
},
"storage": true,
"timestamp": "2026-07-08T09:12:44.412633"
}/v1/detect
The main endpoint. Upload a photo and get every license plate in the frame — localized, cropped and read in one pass — plus optional vehicle analysis (brand, model, body type, color, view angle) in the same response. Typical end-to-end latency is under 200ms.
Query parameters
camera_id | Optional camera identifier stored with the detection and forwarded to your webhooks — useful when many cameras share one API key. |
plate_only | Skip vehicle analysis and return plates only. Roughly 2.5x faster — use it whenever you only need the plate text. |
main_only | Return only the most prominent plate in the frame instead of every detection. |
Request
curl -X POST "https://api.example.com/v1/detect?camera_id=cam-north-01" \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@car_image.jpg" \
-F 'params={"location":"gate-1","lane":"entry"}'Response — plate found, with car analysis
{
"success": true,
"plates_found": 1,
"plates": [
{
"plate_id": 0,
"position": { "x1": 120, "y1": 340, "x2": 280, "y2": 390 },
"confidence": 0.95,
"text": {
"full": "7TRX294",
"characters": [
{ "class": "7", "confidence": 0.98 },
{ "class": "T", "confidence": 0.97 },
{ "class": "R", "confidence": 0.97 },
{ "class": "X", "confidence": 0.96 },
{ "class": "2", "confidence": 0.98 },
{ "class": "9", "confidence": 0.97 },
{ "class": "4", "confidence": 0.98 }
]
},
"attributes": { "country": "us" }
}
],
"body": {
"label": "suv",
"confidence": 0.87,
"probs": {
"sedan": 0.04, "hatchback": 0.01, "wagon": 0.01, "coupe": 0.02,
"convertible": 0.00, "suv": 0.87, "pickup": 0.04, "minivan": 0.01
},
"car_bbox": { "x1": 120, "y1": 240, "x2": 980, "y2": 880 },
"car_confidence": 0.94,
"color": { "name": "white", "hex": "#f2f2f2", "rgb": [242, 242, 242] },
"position": {
"label": "front",
"confidence": 0.97,
"probs": { "back": 0.01, "front": 0.97, "semi_side": 0.02, "side": 0.0 }
},
"brand": {
"label": "Toyota",
"confidence": 0.84,
"probs": { "Toyota": 0.84, "Honda": 0.05, "Lexus": 0.04, "Subaru": 0.04, "Mazda": 0.03 }
},
"model": {
"label": "Toyota|RAV4",
"confidence": 0.74,
"probs": { "Toyota|RAV4": 0.74, "Toyota|Highlander": 0.09, "Honda|CR-V": 0.07, "Subaru|Forester": 0.05, "Mazda|CX-5": 0.03 }
},
"make_model": {
"brand": "Toyota",
"brand_confidence": 0.84,
"brand_source": "stage2",
"model": "RAV4",
"model_full": "Toyota|RAV4",
"model_confidence": 0.74,
"brand_uncertain": false,
"decision": "agreement"
}
},
"cost": 0.02,
"timestamp": "2026-07-08T09:12:44.412633"
}Response — no plates found
{
"success": true,
"plates_found": 0,
"plates": [],
"cost": 0.0,
"timestamp": "2026-07-08T09:12:44.412633"
}A frame with no readable plate still returns success true with plates_found 0 — check plates_found, not the HTTP status. Requests that find no plates are not billed.
/v1/detect/{country}
If you know which market your cameras operate in, pass the country code in the path. The pipeline applies that market format priors — stacked characters, state and province layouts, character sets — which improves both speed and read accuracy on hard plates.
curl -X POST "https://api.example.com/v1/detect/us?plate_only=true" \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@car_image.jpg"Supported country codes
United States
Canada
United Kingdom
Australia
New Zealand
Kazakhstan
Plate format details for each market are covered in the per-country guides:
/v1/car
Vehicle analysis without plate reading: brand across 181 makes, model across 2,003 variants spanning 145 brands, body type, dominant color and view angle. Every classifier can be toggled off individually so you never pay latency for signals you do not use.
Query toggles — all default to true
body_type | Body type across 8 classes: sedan, hatchback, wagon, coupe, convertible, suv, pickup, minivan |
color | Dominant vehicle color as a human-readable name plus hex value and RGB triple |
position | View angle across 4 classes: front, back, side, semi_side |
brand | Car make across 181 brands |
car_model | Car model across 2,003 models spanning 145 brands |
Request
curl -X POST "https://api.example.com/v1/car?brand=true&car_model=true&color=false" \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@car_image.jpg"Response
{
"success": true,
"body": {
"label": "coupe",
"confidence": 0.7519,
"probs": {
"sedan": 0.037, "hatchback": 0.0268, "wagon": 0.0068, "coupe": 0.7519,
"convertible": 0.0668, "suv": 0.059, "pickup": 0.0128, "minivan": 0.039
},
"car_bbox": { "x1": 234, "y1": 616, "x2": 1166, "y2": 1071 },
"car_confidence": 0.956,
"color": { "name": "silver", "hex": "#b9c2cb", "rgb": [185, 194, 203] },
"position": {
"label": "semi_side",
"confidence": 0.9998,
"probs": { "back": 0.0, "front": 0.0002, "semi_side": 0.9998, "side": 0.0 }
},
"brand": {
"label": "Porsche",
"confidence": 0.7751,
"probs": { "Porsche": 0.7751, "Hyundai": 0.0037, "Volkswagen": 0.0034, "Honda": 0.0033, "Toyota": 0.003 }
},
"model": {
"label": "Porsche|911",
"confidence": 0.6876,
"probs": { "Porsche|911": 0.6876, "Porsche|Boxster": 0.0042, "Porsche|Cayman": 0.0031, "Volkswagen|beetle": 0.0023, "Audi|TT": 0.0014 }
},
"make_model": {
"brand": "Porsche",
"brand_confidence": 0.7751,
"brand_source": "stage2",
"model": "911",
"model_full": "Porsche|911",
"model_confidence": 0.6876,
"brand_uncertain": false,
"decision": "agreement"
}
},
"cost": 0.02,
"timestamp": "2026-07-08T09:12:44.412633"
}The make_model object is the field to consume in production. Brand and model come from two independent networks with calibrated confidence gates — when they disagree, the API returns null with brand_uncertain true and a decision field explaining why, instead of guessing. Honest answers, not guesses.
/v1/body
A focused endpoint that runs the vehicle pipeline and returns the classification block on its own. The response includes the full probability distribution across all 8 body classes, so you can apply your own thresholds instead of trusting a single top label.
Request
curl -X POST "https://api.example.com/v1/body" \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@car_image.jpg"Response
{
"success": true,
"body": {
"label": "pickup",
"confidence": 0.91,
"probs": {
"sedan": 0.01,
"hatchback": 0.00,
"wagon": 0.01,
"coupe": 0.01,
"convertible": 0.00,
"suv": 0.05,
"pickup": 0.91,
"minivan": 0.01
},
"car_bbox": { "x1": 88, "y1": 210, "x2": 1012, "y2": 860 },
"car_confidence": 0.95
},
"cost": 0.02,
"timestamp": "2026-07-08T09:12:44.412633"
}The classifier automatically localizes and crops the most prominent vehicle before classifying. If no vehicle is confidently found, car_bbox and car_confidence come back null and the full frame is classified instead.
Read the probabilities honestly: confidence spread across several classes means the photo is genuinely ambiguous — an SUV shot from the front can look like a minivan. Filter on confidence rather than acting on every top label.
/v1/read
If you already run your own detector, send a tightly cropped plate image and get OCR only. This skips detection entirely — the lowest-latency path when your camera or edge device produces plate crops itself.
Request
curl -X POST "https://api.example.com/v1/read" \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@plate_crop.jpg"Response
{
"success": true,
"text": {
"full": "7TRX294",
"characters": [
{ "class": "7", "confidence": 0.98 },
{ "class": "T", "confidence": 0.97 },
{ "class": "R", "confidence": 0.97 },
{ "class": "X", "confidence": 0.96 },
{ "class": "2", "confidence": 0.98 },
{ "class": "9", "confidence": 0.97 },
{ "class": "4", "confidence": 0.98 }
]
},
"attributes": { "country": "us" },
"cost": 0.02,
"timestamp": "2026-07-08T09:12:44.412633"
}/v1/usage
Query your own consumption programmatically: requests this month, plates detected, spend and a per-day breakdown. Use the days query parameter to set the reporting window. Useful for dashboards and monthly reconciliation.
Request
curl "https://api.example.com/v1/usage?days=30" \
-H "Authorization: Bearer YOUR_API_KEY"Response — free tier
{
"tier": "free",
"credits_remaining": null,
"estimated_requests_remaining": null,
"requests_this_month": 3,
"plates_detected_this_month": 2,
"cost_this_month": 0.0,
"daily_usage": [
{ "date": "2026-07-08", "requests": 3, "plates": 2, "cost": 0.0 }
]
}Response — paid tier
{
"tier": "growth",
"credits_remaining": 112.50,
"estimated_requests_remaining": 7500,
"requests_this_month": 2500,
"plates_detected_this_month": 2210,
"cost_this_month": 37.50,
"daily_usage": [
{ "date": "2026-07-07", "requests": 320, "plates": 291, "cost": 4.80 },
{ "date": "2026-07-08", "requests": 180, "plates": 164, "cost": 2.70 }
]
}Image requirements
The API accepts standard camera output. Anything outside these bounds is rejected with a 413 or 422 before any model runs, so you are never billed for an unusable frame.
| Formats | JPEG, PNG or WebP |
| Maximum file size | 10 MB |
| Minimum resolution | 640 x 480 pixels |
Send the sharpest frame you have. Higher resolution helps as long as the plate itself is legible — avoid heavy compression, motion blur and crops that cut off plate edges. If you control the camera, a snapshot at the moment the vehicle slows near the gate beats a frame grabbed at speed.
Webhooks
Instead of polling, register an HTTPS endpoint from the dashboard and the API will POST to it in real time whenever a detection completes. You can register up to 10 webhooks, subscribe each to specific events, and toggle, test or rotate them independently.
Delivery payload
Each delivery is a JSON POST carrying the event name, a timestamp and the full detection result — the same object the synchronous API returned. Any custom params attached to the original request are forwarded untouched at the top level.
Content-Type: application/json
X-Webhook-Signature: sha256=a1b2c3d4...
X-Webhook-Timestamp: 2026-07-08T11:30:00.123456
X-Webhook-Attempt: 1{
"event": "plate_detected",
"timestamp": "2026-07-08T11:30:00.123456",
"data": {
"success": true,
"plates_found": 1,
"plates": [
{
"plate_id": 0,
"position": { "x1": 120, "y1": 340, "x2": 280, "y2": 390 },
"confidence": 0.95,
"text": { "full": "7TRX294" },
"attributes": { "country": "us" }
}
],
"cost": 0.02,
"timestamp": "2026-07-08T11:30:00"
},
"params": {
"camera_id": "cam-north-01",
"location": "gate-1",
"lane": "entry"
}
}Verifying signatures
Every delivery is signed with HMAC SHA-256 using your webhook secret — shown once at creation, rotatable at any time. Compute the digest of the raw request body and compare it to the X-Webhook-Signature header before trusting the payload.
import hmac, hashlib
def verify(secret: str, raw_body: bytes, signature_header: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)Retry policy
- — Attempt 1 fires immediately when the event occurs
- — Attempt 2 retries after 5 seconds, attempt 3 after 30 seconds
- — A delivery counts as successful on any 2xx response from your server
- — If all 3 attempts fail, the webhook stays active and fires again on the next event — every attempt is logged in the delivery history
Custom parameters
Attach your own metadata — camera IDs, gate numbers, session IDs — to any detection request as a params form field containing a JSON object. The API never stores it; it rides along verbatim to your webhook so your server knows the context of each event.
cURL
curl -X POST "https://api.example.com/v1/detect" \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "[email protected]" \
-F 'params={"location":"gate-1","lane":"entry","site":"warehouse-a"}'JavaScript
const form = new FormData();
form.append("file", imageFile);
form.append("params", JSON.stringify({ location: "gate-1", lane: "entry" }));
await fetch("https://api.example.com/v1/detect", {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}` },
body: form,
});Python
import json, requests
resp = requests.post(
"https://api.example.com/v1/detect",
headers={"Authorization": f"Bearer {api_key}"},
files={"file": open("photo.jpg", "rb")},
data={"params": json.dumps({"location": "gate-1", "lane": "entry"})},
)- — params must be a valid JSON object — not an array, string or number
- — Values can be anything valid in JSON: strings, numbers, booleans, nested objects, arrays
- — Keep it small — a few kilobytes at most
- — Params are never stored server-side; they are only forwarded to your webhooks
When no params are sent, the params field is omitted from the payload entirely — not set to null — so existing integrations are unaffected.
Error handling
The API uses conventional HTTP status codes. Errors return a JSON body with a single detail field describing exactly what went wrong — no error code lookup tables required.
| Code | Meaning |
|---|---|
| 400 | Bad request — the image could not be decoded or a parameter is malformed |
| 401 | Unauthorized — missing or invalid API key |
| 402 | Insufficient balance — your wallet cannot cover this request; top up to continue |
| 413 | Payload too large — the image exceeds the 10 MB limit |
| 422 | Unprocessable — unsupported image format or missing file field |
| 429 | Rate limited — you exceeded your plan per-minute rate or monthly volume |
| 500 | Server error — rare; safe to retry with exponential backoff |
Example error responses
401 Unauthorized
{"detail": "Missing Authorization header"}
{"detail": "Invalid API key"}402 Insufficient Balance
{"detail": "Insufficient balance. Required: $0.0100, Available: $0.0000. Please top up your wallet."}413 Payload Too Large
{"detail": "Image too large. Maximum file size is 10MB."}422 Unprocessable Entity
{"detail": "Unsupported image format. Use JPEG, PNG, or WebP."}429 Rate Limited
{"detail": "Rate limit exceeded. Maximum 60 requests per minute for your plan."}
{"detail": "Monthly limit exceeded. Maximum 150 requests per month for free tier."}Rate limits and billing
Billing is pay-as-you-go from a prepaid USD wallet — no subscriptions, no seat fees, no expiring credits. Your per-request price and rate limit follow your monthly volume tier.
| Plan | Rate limit | Monthly volume | Price per request |
|---|---|---|---|
| Free | Fair-use | 150 requests / month | Free |
| Pro | 60 req / min | Up to 10,000 | $0.02 |
| Growth | 100 req / min | 10,000 to 50,000 | $0.015 |
| Professional | 200 req / min | 50,000 to 200,000 | $0.01 |
| Enterprise | Custom | 200,000+ | From $0.007 |
- — Top-ups are one-time wallet loads: minimum $100 on Pro, $150 on Growth, $500 on Professional. Wallet credit never expires.
- — When the free tier is exhausted, additional recognitions bill at $0.01 each from your wallet.
- — Email alerts fire at 80% and 100% of your balance, and you can set a hard spend limit so a runaway script cannot drain the wallet.
- — A 429 response includes a detail message naming the exact limit you hit — back off and retry after the window resets.