Batch indexing and webhooks
Index thousands of photos at the batch rate, receive one signed webhook per photo, verify signatures, and recover from failures.
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"}'{
"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" }
]
}'{ "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:
{
"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:
| Header | Value |
|---|---|
X-SightRadar-Timestamp | Unix seconds when the delivery was signed. |
X-SightRadar-Signature | hex(HMAC-SHA256(secret, "<timestamp>.<raw body>")) |
X-SightRadar-Idempotency-Key | wh:<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.