# 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) 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) 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), which fans out across endpoints rather than across items.
* **The same lookup on a schedule** — a [Monitor](/docs/webhooks), 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) for the exact per-endpoint figures behind the per-row costs.
* [Errors](/docs/errors) for the `error.type` values a row can carry.
* [Rate limits](/docs/rate-limits) — a batch is one request against the 600/min and 50-concurrent budgets, however many items it carries.
* [Choosing endpoints](/docs/choosing-endpoints) for picking between the single, batch, and composite forms.
