# Batch indexing and webhooks

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

Source: https://sightradar.com/docs/guides/batch-and-webhooks

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`.

```bash
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"}'
```

```json title="200 response"
{
  "webhook_endpoint_id": "whe_…",
  "url": "https://api.example.com/sightradar/webhook",
  "status": "active",
  "secret": "whsec_…",
  "note": "Store the secret now; it is not returned again."
}
```

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

### Submit the batch

```bash
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": "whe_…",
    "photos": [
      { "url": "https://cdn.example.com/1.jpg", "external_id": "1" },
      { "url": "https://cdn.example.com/2.jpg", "external_id": "2" }
    ]
  }'
```

```json title="202 response"
{ "batch_id": "bb237a15-…", "total_photos": 2, "status": "pending" }
```

`external_id` is echoed back on every result, so use your own photo key.

### Receive results

One `POST` per photo to your endpoint:

```json title="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": { "x": 10, "y": 8, "w": 60, "h": 60 } }],
    "model_version": "sr-recog-1"
  }
}
```

A failed photo has `status: "failed"`, `charged: false`, and an `error.code` such as `fetch_failed` or `decode_failed`. `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.

_Node_
```ts
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));
}
```

_Python_
```python
import hmac, hashlib, time

def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
    ts = headers.get("X-SightRadar-Timestamp", "")
    sig = headers.get("X-SightRadar-Signature", "")
    if abs(time.time() - int(ts or 0)) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(sig, 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. Use it to reconcile after a webhook outage, or skip webhooks entirely for small batches.

## When deliveries fail

After the retry budget is exhausted a 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](/docs/guides/deleting-data).
