SightRadardocs

Batch indexing and webhooks

Index thousands of photos at the batch rate, receive one signed webhook per photo, verify signatures, and recover from failures.

Last updated

Batch is how you load a gallery. One POST /v1/batches call takes up to 1,000 URL-only photos, processes them asynchronously at the batch tier (62 credits per photo, versus 93 real-time), and delivers one webhook per photo as results land. Inline images are rejected in batch; use the real-time endpoints for those.

Register a webhook endpoint

An HTTPS URL on your side. Public hosts only; private and non-HTTPS URLs are rejected with 400.

curl -X POST "$SR_BASE/v1/webhooks" \
  -H "Authorization: Bearer $SR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://api.example.com/sightradar/webhook"}'
200 response
{
  "webhook_endpoint_id": "4c1d8e2a-…",
  "url": "https://api.example.com/sightradar/webhook",
  "status": "active",
  "secret": "whsec_…",
  "note": "store this secret now; it will not be shown again. …"
}

If you omit secret, one is generated and returned once. Store it: every delivery is signed with it.

Submit the batch

curl -X POST "$SR_BASE/v1/batches" \
  -H "Authorization: Bearer $SR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "collection_id": "event-2026",
    "op": "index",
    "webhook_endpoint_id": "4c1d8e2a-…",
    "photos": [
      { "url": "https://cdn.example.com/1.jpg", "external_id": "1" },
      { "url": "https://cdn.example.com/2.jpg", "external_id": "2" }
    ]
  }'
202 response
{ "batch_id": "bb237a15-…", "total_photos": 2, "status": "processing", "message": "batch accepted; results delivered per-photo via webhook or GET /v1/batches/{id}" }

external_id is echoed back on every result, so use your own photo key. The collection does not have to exist yet; a new collection_id is created on submit. A soft-deleted or purged collection returns 409 with code: collection_not_writable: restore the first, or bring the second back empty with POST /v1/collections/{id}/recreate, then resubmit. Submitting more than 1,000 photos, an empty list, or a base64 image returns 400. URLs are not validated at submit time: an unreachable or non-HTTPS URL fails per photo later.

Receive results

One POST per photo to your endpoint:

BatchWebhookEvent
{
  "batch_id": "bb237a15-…",
  "external_id": "1",
  "photo_index": 0,
  "status": "succeeded",
  "charged": true,
  "processed_at": "2026-07-12T09:14:03Z",
  "result": {
    "face_count": 2,
    "detected_face_count": 3,
    "faces": [{ "face_index": 0, "point_id": "…", "det_score": 0.91, "min_px": 140, "bbox": { "left": 0.12, "top": 0.10, "width": 0.05, "height": 0.12 } }],
    "model_version": "sr-recog-1"
  }
}

A failed photo has status: "failed", charged: false, and an error.code from: insufficient_credits, image_fetch_failed (the URL could not be fetched: the host answered 404 or 410, or is not allowed or does not resolve), image_access_denied (403 or an expired signed URL), image_corrupt (the file is not a decodable image), image_too_large, image_invalid (any other rejected URL, including other 4xx answers such as 401), invalid_collection_id, already_indexed, index_in_progress, retry_exhausted (transient engine errors outlasted the retries), cancelled_collection_deleted (the collection was deleted before the photo ran) or internal_error. Treat any other value as a generic failure; new codes may be added. A 5xx or timeout from the image host is retried and ends as retry_exhausted if it persists. The real-time endpoints name a bad URL differently: a URL the host answers 4xx for is a 400 with reason_code: "invalid_url" there, and image_fetch_failed in a batch. Some deliveries currently carry the code as a top-level error_code string instead, so read error.code and fall back to error_code. face_count: 0 with status: "succeeded" is a valid image with no indexable face.

Verify the signature

Three headers accompany every delivery:

HeaderValue
X-SightRadar-TimestampUnix seconds when the delivery was signed.
X-SightRadar-Signaturehex(HMAC-SHA256(secret, "<timestamp>.<raw body>"))
X-SightRadar-Idempotency-Keywh:<job-id>, stable per result. Dedupe on it.

Compute the HMAC over the raw request body, compare in constant time, and reject timestamps older than a few minutes.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody: string, headers: Headers, secret: string): boolean {
  const ts = headers.get("x-sightradar-timestamp") ?? "";
  const sig = headers.get("x-sightradar-signature") ?? "";
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
  return sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

Return a 2xx once you have persisted the result. Delivery is at least once, so the same X-SightRadar-Idempotency-Key can arrive twice; make your handler idempotent on it.

Polling instead of, or as well as, webhooks

GET /v1/batches/{id} returns progress counts. GET /v1/batches/{id}/photos is the authoritative per-photo record, keyset-paginated on photo_index: follow next_after_index while has_more is true. Rows of an op=index batch carry face_count; rows of an op=match batch carry matches and photo_ids instead and have no face_count. Use it to reconcile after a webhook outage, or skip webhooks entirely for small batches.

When deliveries fail

Each delivery POST has a 5-second timeout. A timeout, any 5xx and most 4xx responses are retried with exponential backoff (capped at 15 minutes between attempts; a Retry-After on your 429 or 503 is honoured up to that cap) for up to 9 attempts, roughly an hour. A 400, 405, 410, 413, 415, 422 or 451 is treated as permanent and dead-letters on the first response, so answer 503 while your handler is temporarily unable to accept. Once retries are used up the delivery is dead-lettered. GET /v1/webhooks/dead-letters lists them (metadata only, never payloads or secrets), and POST /v1/webhooks/dead-letters/replay requeues selected or all deliveries with their original idempotency keys.

When photos fail

Photos that failed with insufficient_credits, internal_error or retry_exhausted can be retried as one bounded wave with POST /v1/batches/failed/requeue: call once for a ten-minute preview (count, estimated credits, current balance), then again with the preview_id and execute: true. Charged photos and photos already in flight are excluded automatically.

Deletion events

The same endpoint also receives collection.deletion.completed events after a collection's vectors are verified gone. See deleting data.

On this page