Batch endpoints
Six POST endpoints that take an array and return one row per item, with per-item isolation, per-row billing, and automatic refunds for the rows that did not resolve.
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
| 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
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.
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 four fan-out endpoints (post-stats, comment-lookup, profiles, transcripts) return per-row results plus a summary:
{
"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
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
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
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
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
| 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
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: trueandX-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 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 so you can start processing rows the moment they land instead of waiting for the whole batch.
Streaming a batch
post-stats, comment-lookup, profiles, and transcripts stream when you send Accept: text/event-stream:
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) for the full frame contract.
POST /v1/youtube/videos and POST /v1/youtube/channels do not stream.
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, which fans out across endpoints rather than across items.
- The same lookup on a schedule — a Monitor, which runs a recipe for you and delivers the result by webhook.
Where to go next
- Endpoint pricing for the exact per-endpoint figures behind the per-row costs.
- Errors for the
error.typevalues a row can carry. - Rate limits — a batch is one request against the 600/min and 50-concurrent budgets, however many items it carries.
- Choosing endpoints for picking between the single, batch, and composite forms.
