Overview

PlateDetect is local-first. Scanning, OCR, history, watchlists, vehicle color/type estimates, plate classification, and exports work on the iPhone. Bring Your Own Backend sync is optional and only runs after the user enters an HTTPS endpoint and API token in the app.

Use this page to build the API that receives saved detections from PlateDetect. QnSub does not receive scan data unless the user intentionally points the app at a QnSub-controlled endpoint.

What you need

  • An HTTPS base URL, for example https://api.example.com/platedetect.
  • A private API token sent as Authorization: Bearer <token>.
  • GET /health and POST /detections.
  • Optional GET /watchlist for hot-list import.
  • Optional POST /detections/{id}/media for snapshots and plate crops.

Connect the app

  1. Open PlateDetect.
  2. Go to Settings, Integrations, then Bring Your Own Backend.
  3. Enter the Server URL. Use the base URL only; do not include /detections.
  4. Enter the API token. PlateDetect stores it in the iOS Keychain.
  5. Tap Test connection. PlateDetect calls GET /health.
  6. Turn on Send media if you want image upload.
  7. Turn on Enable sync and accept the disclosure.

Request rules

  • Every request includes Authorization: Bearer <token>.
  • Detection and media uploads include Idempotency-Key: <detection id>.
  • POST /detections uses Content-Type: application/json.
  • Any 2xx response is treated as success.
  • 401 or 403 pauses sync and shows an auth error.
  • Network errors and other non-2xx responses remain queued for retry.

Endpoints

Method Path Required Purpose
GET/healthYesUsed by Test connection. Return any 2xx.
POST/detectionsYesReceives one saved detection as JSON.
POST/detections/{id}/mediaNoReceives JPEG snapshot and/or crop files.
GET/watchlistNoReturns hot-list plates for local matching.

Detection payload

PlateDetect sends one JSON object per saved detection. Nullable fields may be omitted or sent as null, depending on what was captured.

{
  "id": "F2C9A7E0-1234-49AB-9C3D-7A1B2C3D4E5F",
  "plate_text": "ABC 1234",
  "plate_normalized": "ABC1234",
  "det_confidence": 0.97,
  "ocr_confidence": 0.91,
  "format_valid": true,
  "region_guess": "AAA ####",
  "captured_at": "2026-09-01T04:30:00Z",
  "source": "live",
  "session_id": "D1E71597-32A0-4954-955E-72E16E87C47A",
  "review_state": "confirmed",
  "duplicate_count": 1,
  "best_frame_score": 0.88,
  "save_mode": "cropOnly",
  "tags": ["visitor"],
  "note": "front gate",
  "location": {
    "lat": 37.7749,
    "lon": -122.4194,
    "region": "San Francisco, CA"
  },
  "watchlist_hit": false,
  "access_rule_state": "restricted",
  "access_rule_name": "Monday morning restriction",
  "access_rule_summary": "Restrict endings 1, 2 on Monday, 7:00 AM-10:00 AM",
  "plate_country": "US",
  "plate_jurisdiction": "California candidate",
  "plate_category": "Passenger",
  "plate_classification_confidence": 0.66,
  "plate_classification_note": "Confirm state from the image when needed.",
  "vehicle_color": "White",
  "vehicle_color_confidence": 0.74,
  "vehicle_type": "SUV",
  "vehicle_type_confidence": 0.62,
  "media_hash": "sha256:...",
  "app_version": "1.0.1",
  "model_version": "plate-yolov8n-2026.05"
}

Make id unique in your database. If the same id arrives again, return 200 or 202 without creating a duplicate.

Media upload

When Send media is enabled, PlateDetect sends POST /detections/{id}/media after the detection upload succeeds. The request is multipart/form-data and may include:

  • snapshot: JPEG full saved image or blurred image, depending on privacy mode.
  • crop: JPEG plate crop.

Return any 2xx. If media upload fails, PlateDetect keeps the detection pending and retries.

Watchlist / hot-list import

PlateDetect can fetch hot-list plates from your backend. Matching still happens on device.

["ABC1234", "NKT2345"]

Object arrays are also accepted:

[
  { "plate": "ABC1234", "label": "Staff", "note": "front gate", "enabled": true },
  { "plate_normalized": "NKT2345", "label": "Visitor" }
]

Wrapped responses can use items or watchlist.

Minimal Express server

import express from "express";

const app = express();
app.use(express.json({ limit: "2mb" }));

const TOKEN = process.env.PLATEDETECT_TOKEN || "change-me";
const detections = new Map();

function requireAuth(req, res, next) {
  if (req.get("authorization") !== `Bearer ${TOKEN}`) {
    return res.status(401).json({ error: "unauthorized" });
  }
  next();
}

app.get("/health", requireAuth, (_req, res) => {
  res.json({ ok: true });
});

app.post("/detections", requireAuth, (req, res) => {
  const detection = req.body;
  if (!detection?.id || !detection?.plate_normalized) {
    return res.status(400).json({ error: "missing id or plate_normalized" });
  }

  if (!detections.has(detection.id)) {
    detections.set(detection.id, detection);
    console.log("PlateDetect:", detection.plate_normalized, detection.captured_at);
  }

  res.status(202).json({ accepted: true, id: detection.id });
});

app.get("/watchlist", requireAuth, (_req, res) => {
  res.json({
    items: [
      { plate: "ABC1234", label: "Staff", enabled: true },
      { plate: "NKT2345", label: "Visitor", enabled: true }
    ]
  });
});

app.listen(8080, () => {
  console.log("PlateDetect API listening on port 8080");
});

Put the server behind HTTPS with Caddy, Nginx, a load balancer, or a secure tunnel. PlateDetect requires a valid certificate.

AI coding agent prompt

Copy this single prompt into Codex, Cursor, Claude Code, or another AI coding agent to generate a compatible backend.

cURL checks

curl -H "Authorization: Bearer change-me" \
  https://api.example.com/platedetect/health

curl -X POST https://api.example.com/platedetect/detections \
  -H "Authorization: Bearer change-me" \
  -H "Idempotency-Key: test-1" \
  -H "Content-Type: application/json" \
  -d '{"id":"test-1","plate_text":"ABC 1234","plate_normalized":"ABC1234","det_confidence":0.97,"ocr_confidence":0.91,"format_valid":true,"captured_at":"2026-09-01T04:30:00Z","source":"test","review_state":"confirmed","duplicate_count":1,"save_mode":"cropOnly","tags":[],"watchlist_hit":false,"app_version":"1.0.1"}'

Troubleshooting

  • Test connection fails: check the URL, HTTPS certificate, /health, and token.
  • Auth failed: the server returned 401 or 403; save the same token in the app and server.
  • Pending count grows: check server logs and firewall rules for non-2xx responses.
  • Duplicate rows: use id or Idempotency-Key for idempotency.
  • No images arrive: turn on Send media and implement multipart upload.