API reference
SocialHolmes API
Request a scrape, then poll the run or let us call you. One set of endpoints serves every platform — pick one to see what it accepts.
Authentication
Every request needs a key, sent as a bearer token. Create one under API keys — the secret is shown once and never again.
Authorization: Bearer lb_<your key>
How a run works
A run takes minutes, not milliseconds, so the API is asynchronous by nature — there is no synchronous "fetch me this profile". You get a run id immediately, and then either poll it or have us post it to you when it is done.
-
POST
/scrape/:platform/:endpoint→ 202 with a run id -
GET
/runs/:id→ queued, running, succeeded, failed or cancelled -
GET
/runs/:id/results→ the parsed results
Each endpoint returns only what it names. A profile run gives you one profile, not the post grid the page happened to load beside it — you are billed per result, so a run never hands back what you did not ask for.
Lists come back flat and deduplicated. GET /runs/:id/comments is one comment list in one shape — not something you have to stitch together or de-overlap yourself.
Webhooks
Rather than asking every few seconds for minutes, give us an address and we will post the run to it the moment it finishes. Register one under Webhooks, or from code:
curl -X POST https://<host>/api/public/v1/webhooks \
-H "Authorization: Bearer lb_<your key>" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/runs", "name": "Production"}'
The response is the only place the signing secret appears. A single run can also name its own address, and then it goes there and nowhere else: {"webhook_url": "https://…"} alongside your scrape parameters.
What arrives
A POST with the same run object GET /runs/:id returns. Results are not inlined — they are paginated and can be large — so fetch them the way you would have anyway.
{
"id": "evt_9f2c…",
"type": "run.succeeded",
"created_at": "2026-08-22T09:14:03Z",
"data": { "run": { "id": "…", "status": "succeeded", "result_count": 45, … } },
"links": { "results": "https://<host>/api/public/v1/runs/<id>/results" }
}
One of run.succeeded, run.failed, run.empty or run.cancelled — the four ways a run ends. There is no event for a run starting; the response to your POST already told you that.
Check the signature
Your endpoint is open to the internet, so anything can post a convincing-looking run to it. Every request carries X-SocialHolmes-Signature: HMAC-SHA256 over "{t}.{raw body}" with your secret. Reject anything older than 5 minutes.
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(
secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
Sign the bytes you received, not a re-encoded copy of the parsed JSON — re-serialising changes the whitespace and the signature stops matching.
Retries, and why you still poll
-
Answer
2xxquickly. Anything else, or a timeout after 10 seconds, counts as not delivered. - We try up to 6 times over about seven hours, backing off as we go. Every attempt is visible on the webhooks screen with a button to send it again.
-
idis stable across every retry and every manual resend — deduplicate on it, because at-least-once is the promise, not exactly-once. - Ten dead deliveries in a row switch an endpoint off, and we say so on the dashboard.
-
A webhook is the fast path, never the only one. If your server was down for the whole retry window,
GET /runs/:idis still the truth — keep a reconciliation sweep for anything you did not hear about.
Errors
Every failure uses one shape, so you can handle them in one place.
{
"error": {
"code": "missing_parameters",
"message": "Missing required parameter(s): username.",
"details": { "required": ["username"], "missing": ["username"] }
}
}