# Batch endpoints (/docs/batch) Batch endpoints [#batch-endpoints] Most of the API is a GET per item. When you need the same lookup for a list — a morning refresh of 400 post URLs, 60 handles to vet, 100 transcripts for a corpus — six endpoints take the whole array in one `POST` and return one row per input item. Batch is not a different product. Each row is byte-identical to what the matching single endpoint returns; you are trading N requests for one, and paying for the rows that actually resolved. The six endpoints [#the-six-endpoints] | Endpoint | Cap per call | Body array | Billing | | ------------------------------- | ------------ | ---------- | ---------------------------------------------------------------------------------------------------------- | | `POST /v1/prism/post-stats` | 100 URLs | `urls` | Per successful URL at that platform's own rate (1 credit on most, 5 for Instagram and LinkedIn post stats) | | `POST /v1/prism/comment-lookup` | 25 items | `items` | Per `found` item (TikTok 2, Instagram 5; `deep_scan` raises it). Whole batch holds at most 100 credits | | `POST /v1/prism/profiles` | 50 items | `items` | Per successful row at each platform's tier (most 1, LinkedIn 5) | | `POST /v1/youtube/transcripts` | 100 ids | `ids` | 3 credits per successful transcript | | `POST /v1/youtube/videos` | 1000 ids | `ids` | 5 credits per 50-id chunk — `ceil(ids / 50) × 5` | | `POST /v1/youtube/channels` | 1000 ids | `ids` | 5 credits per 50-id chunk — `ceil(ids / 50) × 5` | Authentication is the same `x-api-key` header as every other endpoint. These responses are never cached: `cached` is always `false`, because a batch refresh exists to read the live number. Making a request [#making-a-request] Send a JSON body. The request body ceiling is **1 MB** — a larger body is rejected with `413 PAYLOAD_TOO_LARGE` before anything is parsed or charged. ```bash curl -X POST 'https://www.socialcrawl.dev/v1/prism/profiles' \ -H 'x-api-key: sc_...' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: vetting-2026-07-04-batch-1' \ -d '{ "items": [ { "platform": "tiktok", "handle": "@scout2015" }, { "platform": "linkedin", "handle": "williamhgates", "custom_id": "vet-1" }, { "platform": "instagram", "handle": "@nosuchaccount" } ] }' ``` Items are **not** de-duplicated. Two identical URLs are two rows and two charges — de-duplicate before you send, and use `Idempotency-Key` to make retries safe. The response [#the-response] The four fan-out endpoints (`post-stats`, `comment-lookup`, `profiles`, `transcripts`) return per-row results plus a summary: ```json { "success": true, "platform": "prism", "endpoint": "/v1/prism/profiles", "data": { "results": [ { "index": 0, "target": { "platform": "tiktok", "handle": "@scout2015" }, "platform": "tiktok", "status": "ok", "data": { "author": { "username": "scout2015", "followers": 5300 } }, "cost": 1, "fetched_at": "2026-07-04T09:00:03.128Z" }, { "index": 1, "custom_id": "vet-1", "target": { "platform": "linkedin", "handle": "williamhgates" }, "platform": "linkedin", "status": "ok", "data": { "author": { "username": "williamhgates", "followers": 36000000 } }, "cost": 5, "fetched_at": "2026-07-04T09:00:03.771Z" }, { "index": 2, "target": { "platform": "instagram", "handle": "@nosuchaccount" }, "platform": "instagram", "status": "not_found", "cost": 0, "ext": { "reason": "empty_or_private" }, "error": { "type": "NOT_FOUND", "message": "The profile returned no data (private, suspended, or deleted)." }, "fetched_at": "2026-07-04T09:00:02.904Z" } ], "summary": { "total": 3, "ok": 2, "not_found": 1, "unsupported": 0, "error": 0, "deferred": 0, "coverage": 0.6666666666666666, "credits_charged": 6, "credits_refunded": 1 }, "legs": [] }, "credits_used": 6, "credits_remaining": 4521, "request_id": "req-abc123", "cached": false } ``` Rows come back **in input order**, always all N of them, never paginated. `custom_id` is echoed verbatim when you supply it, so you can join rows back to your own records without relying on position. `legs` carries per-upstream call telemetry where the endpoint fans out; `comment-lookup` returns `results` and `summary` only. Row statuses [#row-statuses] | `status` | Meaning | Charged | | ------------- | --------------------------------------------------------------------------- | ------- | | `ok` | Item fetched, `data` present. `comment-lookup` calls this `found`. | Yes | | `not_found` | Upstream authoritatively 404'd — deleted, private, or never existed. | No | | `unsupported` | The item could not be routed to any endpoint (unknown host or path shape). | No | | `error` | Upstream or internal failure after retries; `error.type` explains it. | No | | `deferred` | The 45-second batch deadline hit before this item started. Never attempted. | No | Per-item isolation is the point: one dead link never fails the batch. Endpoint-specific detail arrives in `ext` rather than as new statuses — a caption-less video, for example, is `not_found` with `ext.reason: "no_captions"`. The summary [#the-summary] `summary` is the same nine fields on every fan-out batch, and the same object appears in the SSE `done` event, so the two can never disagree. `coverage` is the **row** ratio `ok / total`, not a credit ratio. `credits_charged` always equals the envelope's `credits_used`. The YouTube id batches [#the-youtube-id-batches] `POST /v1/youtube/videos` and `POST /v1/youtube/channels` are chunk-metered rather than per-row, so they return the ordinary list shape — `data.items` and `data.total`, each item identical to the matching single endpoint — with no `results` or `summary` block. They are billed by chunk: 5 credits per 50 ids, charged in full on a 200 because the upstream chunk is consumed whether or not every id in it resolves. A batch that resolves **nothing** returns `200` with `{ "items": [], "total": 0 }` and is fully refunded, and a total upstream failure is fully refunded too. Billing, worked [#billing-worked] `POST /v1/youtube/videos` with 1000 ids: `ceil(1000 / 50) = 20` chunks × 5 credits = **100 credits held upfront**. If the upstream returns anything at all, that is the settled charge. If it resolves zero items, all 100 come back. `POST /v1/prism/profiles` with 40 TikTok handles and 10 LinkedIn handles: the hold is `40 × 1 + 10 × 5 = 90` credits. If 3 TikTok handles are private and 1 LinkedIn handle is gone, the settled charge is `37 × 1 + 9 × 5 = 82`, and 8 credits are refunded — visible as `credits_refunded: 8` in the summary. The pattern is the same everywhere: hold the worst case, settle to the rows that succeeded, refund the difference. You never pay for a `not_found`, `unsupported`, `error`, or `deferred` row. HTTP status codes [#http-status-codes] | Condition | Status | | ---------------------------------------------------- | ------------------------------------------------------------- | | At least one row succeeded | `200` | | Zero rows succeeded but at least one `error` | `502` with `data.results` still present, full refund | | Zero rows succeeded, all `not_found` / `unsupported` | `200` with `coverage: 0`, charged per model (usually nothing) | | Body invalid, array empty, or over the cap | `400 INVALID_REQUEST`, nothing charged | | Balance cannot cover the hold | `402 INSUFFICIENT_CREDITS`, nothing charged | | Body over 1 MB | `413 PAYLOAD_TOO_LARGE`, nothing parsed or charged | A `502` still carries the per-row detail, so you can see which items failed and why rather than getting a bare error. Idempotency and retrying the tail [#idempotency-and-retrying-the-tail] Every batch endpoint honours an optional `Idempotency-Key` request header, checked **before** the credit hold. Keys live for 24 hours. * **Same key, same body** — the stored response replays with `X-Idempotent-Replay: true` and `X-Credits-Used: 0`. Nothing runs again, nothing is charged again. * **Same key, different body** — `422`. The key is bound to the exact request it first saw. * **Same key, different account** — `409`. That makes the natural workflow safe: when a response comes back with `deferred` or `error` rows, send a **new** request under a **new** key containing only those items. Retrying the whole batch under the original key replays; retrying only the tail costs only the tail. The 45-second deadline [#the-45-second-deadline] The fan-out endpoints run under a 45-second budget. Items still queued when it expires come back `deferred` at 0 credits — their upstream was never touched. This is a pressure valve, not an error: re-request the deferred items and they will run. If you routinely see `deferred` rows, either lower the item count per call or [stream the response](/docs/streaming.md) so you can start processing rows the moment they land instead of waiting for the whole batch. Streaming a batch [#streaming-a-batch] `post-stats`, `comment-lookup`, `profiles`, and `transcripts` stream when you send `Accept: text/event-stream`: ```bash curl -N -X POST 'https://www.socialcrawl.dev/v1/prism/post-stats' \ -H 'x-api-key: sc_...' \ -H 'Content-Type: application/json' \ -H 'Accept: text/event-stream' \ -d '{"urls": ["https://www.youtube.com/watch?v=dQw4w9WgXcQ"]}' ``` Each row is emitted the moment it settles, as a `result` event keyed by row type (`stat`, `item`, `profile`, or `transcript`), followed by a terminal `done` event carrying the same nine-field summary as the sync response. Billing is identical either way, and an SSE batch is stored for idempotent replay as its sync-JSON equivalent — so a retry with the same key replays whichever wire format it asks for. See [Streaming (SSE)](/docs/streaming.md) for the full frame contract. `POST /v1/youtube/videos` and `POST /v1/youtube/channels` do not stream. When to use batch instead [#when-to-use-batch-instead] * **One item** — call the single GET endpoint. Batch has no advantage and a heavier response. * **A list of the same lookup** — batch. One request slot, one round trip, per-row billing. * **Different lookups combined into one answer** — a [Prism composite](/docs/prism.md), which fans out across endpoints rather than across items. * **The same lookup on a schedule** — a [Monitor](/docs/webhooks.md), which runs a recipe for you and delivers the result by webhook. Where to go next [#where-to-go-next] * [Endpoint pricing](/docs/endpoint-pricing.md) for the exact per-endpoint figures behind the per-row costs. * [Errors](/docs/errors.md) for the `error.type` values a row can carry. * [Rate limits](/docs/rate-limits.md) — a batch is one request against the 600/min and 50-concurrent budgets, however many items it carries. * [Choosing endpoints](/docs/choosing-endpoints.md) for picking between the single, batch, and composite forms.