# Accounts behind a topic (/docs/recipes/accounts-behind-a-topic) One Discover run finds the public accounts posting about a topic, with each account's profile and the posts that matched. You will build a list of the public accounts posting about a topic, one row per account with its profile and the posts that matched, from one `GET /v1/prism/discover` run with `view=accounts`. **Cost per run:** At most 400 credits (max_credits caps the whole run; each search bills at its platform's price only when it returns rows, so it settles 0-400; nothing beyond the judging bound is held up front). Measured on 25/10/2026: 24 credits in 1 call (the quote held 400). The TypeScript and Python programs both ran. ## The code Set `SOCIALCRAWL_API_KEY` to your key, then run the TypeScript with `npx tsx recipe.ts` (Node 22 or newer) or the Python with `python3 recipe.py` (3.9 or newer, nothing to install). In n8n, import the workflow and pick a Header Auth credential named `SocialCrawl API key` on each request node. Each program follows the run to its end and prints what it spent. `FRESH=1` skips the cache. ```typescript title="TypeScript" // Find the accounts behind a topic: sellers, creators, pages and channels // // Price for the accounts behind one topic: at most 400 credits (max_credits caps the whole run; each search bills at its platform's price only when it returns rows, so it settles 0-400; nothing beyond the judging bound is held up front). // Measured on 25/10/2026: 24 credits in 1 call (the quote held 400). The TypeScript and Python programs both ran. // // FRESH=1 skips the cache and bills every call; a cache hit is free. // Inputs, set to a working example (change them to yours): // keywords: Two to six topic words or phrases, comma-separated // context: Who the accounts are, in one sentence // // Run it with Node 22 or newer: SOCIALCRAWL_API_KEY=sc_... npx tsx recipe.ts const API_KEY = process.env.SOCIALCRAWL_API_KEY; if (!API_KEY) throw new Error("Set SOCIALCRAWL_API_KEY to your SocialCrawl API key"); const BASE_URL = (process.env.SOCIALCRAWL_BASE_URL ?? "https://www.socialcrawl.dev").replace(/\/+$/, ""); const TIMEOUT_MS = 300_000; // the longest route time limit, per attempt const MAX_RETRIES = 3; // FRESH=1 skips the cache: every call is fetched again and billed normally (a cache hit is free). const FRESH = process.env.FRESH === "1"; const WAIT_LIMIT_MS = 15 * 60_000; // how long to follow one run or job const RUNNING = new Set(["queued","running","pending"]); // A job read answers the job's status; read it again until it is terminal. const JOB_READ = /^(web\/jobs|cohort-queries)\/[^/]+$/; let callCount = 0; let creditsSpent = 0; const runCredits = new Map(); const createdPaths: string[] = []; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // Exponential backoff with jitter: about 1 s, 2 s, then 4 s. const backoff = (attempt: number) => 2 ** attempt * (500 + Math.random() * 500); interface CallOptions { method?: string; body?: unknown; idempotencyKey?: string; } // One request. Retries a network error and an error the API marks retryable // (Retry-After wins), but a POST only when it carries an Idempotency-Key, so // a retry never runs a paid write twice. Returns the parsed body, or null for // RESOURCE_NOT_FOUND (refunded: nothing to read). Throws on any other error. async function send(url: string, opts: CallOptions = {}): Promise { const method = opts.method ?? "GET"; const retries = method === "GET" || opts.idempotencyKey ? MAX_RETRIES : 0; for (let attempt = 0; ; attempt++) { let res: Response; let body: any; try { res = await fetch(url, { method, headers: { "x-api-key": API_KEY!, accept: "application/json", ...(FRESH ? { "cache-control": "no-cache" } : {}), ...(opts.body === undefined ? {} : { "content-type": "application/json" }), ...(opts.idempotencyKey ? { "idempotency-key": opts.idempotencyKey } : {}), }, body: opts.body === undefined ? undefined : JSON.stringify(opts.body), signal: AbortSignal.timeout(TIMEOUT_MS), }); body = await res.json(); } catch (err) { if (attempt >= retries) throw err; await sleep(backoff(attempt)); continue; } callCount++; if (res.ok && body?.success !== false) { // A run (prism/discover) reports its whole spend on data.run.credits.spent. const run = body?.data?.run; if (run?.run_id && typeof run.credits?.spent === "number") runCredits.set(run.run_id, run.credits.spent); else creditsSpent += body?.credits_used ?? 0; return body; } const error = body?.error ?? { type: `HTTP_${res.status}`, message: res.statusText }; // Branch on error.type, never on the message text. if (error.type === "RESOURCE_NOT_FOUND") { console.warn(`Skipped, not found (refunded): ${url}`); return null; } const retryAfter = res.headers.get("retry-after"); if (!error.retryable || attempt >= retries) throw new Error(`${error.type}: ${error.message} (request_id ${body?.request_id ?? "none"})`); await sleep(retryAfter ? Number(retryAfter) * 1000 : backoff(attempt)); } } // What a run created and keeps (a monitor, a cohort), printed with how to delete it. function noteCreated(path: string, method: string | undefined, body: any) { if (method !== "POST" || !body) return; const id = path === "monitors" ? body.monitor?.id : path === "cohorts" ? body.data?.id : path === "web/monitors" ? body.data?.monitor_id : undefined; if (!id) return; createdPaths.push(`${path}/${id}`); console.log(`Created ${path}/${id}. Delete it with DELETE /v1/${path}/${id} when you no longer need it.`); } // One API call by its path under /v1. Follows an async run (202) and a job to its end. async function sc(path: string, params: Record = {}, opts: CallOptions = {}): Promise { const url = new URL(`${BASE_URL}/v1/${path}`); for (const [key, value] of Object.entries(params)) if (value !== undefined && value !== null) url.searchParams.set(key, String(value)); let body = await send(url.toString(), opts); const deadline = Date.now() + WAIT_LIMIT_MS; // An async run answers 202 and keeps going: follow data.next.status_url until it ends. let statusUrl = body?.data?.next?.status_url; while (statusUrl && RUNNING.has(body?.data?.run?.status)) { if (Date.now() > deadline) throw new Error(`${path} is still running: follow ${statusUrl}`); await sleep(1000); body = await send(statusUrl); statusUrl = body?.data?.next?.status_url ?? statusUrl; } while (JOB_READ.test(path) && RUNNING.has(body?.data?.status)) { if (Date.now() > deadline) throw new Error(`${path} is still ${body.data.status}: read it again later`); await sleep((body.data.poll_after_s ?? 5) * 1000); body = await send(url.toString()); } noteCreated(path, opts.method, body); return body; } // Rows in a step's result: a page's items, or the answers of a step run once per row. function rowsIn(value: any): number { if (Array.isArray(value)) return value.filter((v) => v != null).length; if (!value) return 0; return Array.isArray(value.data?.items) ? value.data.items.length : 1; } const ITEMS = Number(process.env.ITEMS ?? 1); const accounts = await sc("prism/discover", { view: "accounts", keywords: "replica rolex dubai,fake watches uae", context: "accounts selling counterfeit luxury watches in the UAE", period: "90d", max_credits: "400" }); const totalCredits = creditsSpent + [...runCredits.values()].reduce((a, b) => a + b, 0); console.log(`Spent ${totalCredits} credits in ${callCount} calls.`); const rowCounts = { accounts: rowsIn(accounts) }; console.log( "RECIPE_RESULT " + JSON.stringify({ recipe: "accounts-behind-a-topic", calls: callCount, credits_used: totalCredits, rows: rowCounts, created: createdPaths, }), ); ``` ```python title="Python" # Find the accounts behind a topic: sellers, creators, pages and channels # # Price for the accounts behind one topic: at most 400 credits (max_credits caps the whole run; each search bills at its platform's price only when it returns rows, so it settles 0-400; nothing beyond the judging bound is held up front). # Measured on 25/10/2026: 24 credits in 1 call (the quote held 400). The TypeScript and Python programs both ran. # # FRESH=1 skips the cache and bills every call; a cache hit is free. # Inputs, set to a working example (change them to yours): # keywords: Two to six topic words or phrases, comma-separated # context: Who the accounts are, in one sentence # # Run it with Python 3.9 or newer, nothing to install: SOCIALCRAWL_API_KEY=sc_... python3 recipe.py import json import os import random import re import sys import time import http.client import urllib.error import urllib.parse import urllib.request import uuid API_KEY = os.environ.get("SOCIALCRAWL_API_KEY") or sys.exit("Set SOCIALCRAWL_API_KEY to your SocialCrawl API key") BASE_URL = os.environ.get("SOCIALCRAWL_BASE_URL", "https://www.socialcrawl.dev").rstrip("/") TIMEOUT_S = 300 # the longest route time limit, per attempt MAX_RETRIES = 3 # FRESH=1 skips the cache: every call is fetched again and billed normally (a cache hit is free). FRESH = os.environ.get("FRESH") == "1" WAIT_LIMIT_S = 15 * 60 # how long to follow one run or job RUNNING = {"queued", "running", "pending"} # A job read answers the job's status; read it again until it is terminal. JOB_READ = re.compile(r"^(web/jobs|cohort-queries)/[^/]+$") call_count = 0 credits_spent = 0 run_credits = {} created_paths = [] def dig(value, *keys): for key in keys: if not isinstance(value, dict): return None value = value.get(key) return value def backoff(attempt): # Exponential backoff with jitter: about 1 s, 2 s, then 4 s. return 2 ** attempt * (0.5 + random.random() / 2) def send(url, method="GET", body=None, idempotency_key=None): # One request. Retries a network error and an error the API marks retryable # (Retry-After wins), but a POST only when it carries an Idempotency-Key, so # a retry never runs a paid write twice. Returns the parsed body, or None for # RESOURCE_NOT_FOUND (refunded: nothing to read). Exits on any other error. global call_count, credits_spent headers = {"x-api-key": API_KEY, "accept": "application/json"} if FRESH: headers["Cache-Control"] = "no-cache" data = None if body is not None: data = json.dumps(body).encode() headers["content-type"] = "application/json" if idempotency_key: headers["Idempotency-Key"] = idempotency_key retries = MAX_RETRIES if method == "GET" or idempotency_key else 0 for attempt in range(retries + 1): request = urllib.request.Request(url, data=data, method=method, headers=headers) try: with urllib.request.urlopen(request, timeout=TIMEOUT_S) as res: status, retry_after, payload = res.status, res.headers.get("Retry-After"), json.loads(res.read() or b"null") except urllib.error.HTTPError as err: status, retry_after = err.code, err.headers.get("Retry-After") try: payload = json.loads(err.read() or b"null") except (ValueError, OSError, http.client.HTTPException): payload = None # A network error, a timeout, a connection cut mid-body, or a body that is not JSON. except (OSError, http.client.HTTPException, ValueError): if attempt == retries: raise time.sleep(backoff(attempt)) continue call_count += 1 if 200 <= status < 300 and dig(payload, "success") is not False: # A run (prism/discover) reports its whole spend on data.run.credits.spent. run = dig(payload, "data", "run") or {} spent = dig(run, "credits", "spent") if run.get("run_id") and isinstance(spent, (int, float)): run_credits[run["run_id"]] = spent elif isinstance(payload, dict): credits_spent += payload.get("credits_used") or 0 return payload # A gateway page that is not our JSON (a 502 or 503 in front of the API) is retryable. error = dig(payload, "error") or {"type": f"HTTP_{status}", "message": "no error body", "retryable": status >= 500} # Branch on error type, never on the message text. if error.get("type") == "RESOURCE_NOT_FOUND": print(f"Skipped, not found (refunded): {url}", file=sys.stderr) return None if not error.get("retryable") or attempt == retries: sys.exit(f"{error.get('type')}: {error.get('message')} (request_id {dig(payload, 'request_id')})") time.sleep(float(retry_after) if retry_after else backoff(attempt)) def note_created(path, method, body): # What a run created and keeps (a monitor, a cohort), printed with how to delete it. if method != "POST" or not body: return ids = {"monitors": dig(body, "monitor", "id"), "cohorts": dig(body, "data", "id"), "web/monitors": dig(body, "data", "monitor_id")} created = ids.get(path) if created: created_paths.append(f"{path}/{created}") print(f"Created {path}/{created}. Delete it with DELETE /v1/{path}/{created} when you no longer need it.") def sc(path, params=None, method="GET", body=None, idempotency_key=None): # One API call by its path under /v1. Follows an async run (202) and a job to its end. url = f"{BASE_URL}/v1/{path}" query = {k: (str(v).lower() if isinstance(v, bool) else str(v)) for k, v in (params or {}).items() if v is not None} if query: url += "?" + urllib.parse.urlencode(query) result = send(url, method, body, idempotency_key) deadline = time.time() + WAIT_LIMIT_S # An async run answers 202 and keeps going: follow data.next.status_url until it ends. status_url = dig(result, "data", "next", "status_url") while status_url and dig(result, "data", "run", "status") in RUNNING: if time.time() > deadline: sys.exit(f"{path} is still running: follow {status_url}") time.sleep(1) result = send(status_url) status_url = dig(result, "data", "next", "status_url") or status_url while JOB_READ.match(path) and dig(result, "data", "status") in RUNNING: if time.time() > deadline: sys.exit(f"{path} is still running: read it again later") wait_s = dig(result, "data", "poll_after_s") time.sleep(5 if wait_s is None else wait_s) result = send(url) note_created(path, method, result) return result def rows_in(value): # Rows in a step's result: a page's items, or the answers of a step run once per row. if isinstance(value, list): return len([v for v in value if v is not None]) if not value: return 0 items = dig(value, "data", "items") return len(items) if isinstance(items, list) else 1 ITEMS = int(os.environ.get("ITEMS", "1")) accounts = sc("prism/discover", {"view": "accounts", "keywords": "replica rolex dubai,fake watches uae", "context": "accounts selling counterfeit luxury watches in the UAE", "period": "90d", "max_credits": "400"}) total_credits = credits_spent + sum(run_credits.values()) print(f"Spent {total_credits} credits in {call_count} calls.") row_counts = {"accounts": rows_in(accounts)} print( "RECIPE_RESULT " + json.dumps( { "recipe": "accounts-behind-a-topic", "calls": call_count, "credits_used": total_credits, "rows": row_counts, "created": created_paths, } ) ) ``` ```json title="n8n workflow" { "name": "SocialCrawl: Find the accounts behind a topic: sellers, creators, pages and channels", "nodes": [ { "id": "node-1", "position": [ 260, 0 ], "name": "Run recipe", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "parameters": {} }, { "id": "node-2", "position": [ 520, 0 ], "name": "accounts", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "parameters": { "method": "GET", "url": "https://www.socialcrawl.dev/v1/prism/discover?view=accounts&keywords=replica%20rolex%20dubai%2Cfake%20watches%20uae&context=accounts%20selling%20counterfeit%20luxury%20watches%20in%20the%20UAE&period=90d&max_credits=400", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "options": { "timeout": 300000 } }, "credentials": { "httpHeaderAuth": { "name": "SocialCrawl API key" } }, "executeOnce": true, "retryOnFail": true, "maxTries": 4, "waitBetweenTries": 2000 }, { "id": "node-3", "position": [ 780, 0 ], "name": "accounts: finished?", "type": "n8n-nodes-base.if", "typeVersion": 2, "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "loose" }, "conditions": [ { "id": "accounts-finished", "leftValue": "={{ !['queued','running','pending'].includes($json.data.run.status) }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} } }, { "id": "node-4", "position": [ 1040, 0 ], "name": "accounts: wait", "type": "n8n-nodes-base.wait", "typeVersion": 1.1, "parameters": { "resume": "timeInterval", "amount": 5, "unit": "seconds" } }, { "id": "node-5", "position": [ 1300, 0 ], "name": "accounts: follow the run", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "parameters": { "method": "GET", "url": "={{ $json.data.next.status_url }}", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "options": { "timeout": 300000 } }, "credentials": { "httpHeaderAuth": { "name": "SocialCrawl API key" } }, "executeOnce": true, "retryOnFail": true, "maxTries": 4, "waitBetweenTries": 2000 }, { "id": "node-6", "position": [ 1560, 0 ], "name": "accounts: result", "type": "n8n-nodes-base.code", "typeVersion": 2, "parameters": { "jsCode": "// The finished answer.\nreturn $input.all();" } }, { "id": "note", "name": "How this recipe works", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ 0, -460 ], "parameters": { "content": "## Find the accounts behind a topic: sellers, creators, pages and channels\n\nPrice for the accounts behind one topic: at most 400 credits (max_credits caps the whole run; each search bills at its platform's price only when it returns rows, so it settles 0-400; nothing beyond the judging bound is held up front).\n\nMeasured on 25/10/2026: 24 credits in 1 call (the quote held 400). The TypeScript and Python programs both ran.\n\nFRESH=1 skips the cache and bills every call; a cache hit is free.\n\nInputs, set to a working example (change them to yours):\n\n keywords: Two to six topic words or phrases, comma-separated\n\n context: Who the accounts are, in one sentence\n\n**Key:** create a Header Auth credential named SocialCrawl API key, with Name x-api-key and your API key as Value, and pick it on every request node.\n\n**Rows:** a step that runs once per row takes the first 1 rows (change the number in its rows node).\n\n**Errors:** a request node stops the workflow on an API error. Read error.type in its output; retry only when error.retryable is true.", "width": 520, "height": 420 } } ], "connections": { "Run recipe": { "main": [ [ { "node": "accounts", "type": "main", "index": 0 } ] ] }, "accounts": { "main": [ [ { "node": "accounts: finished?", "type": "main", "index": 0 } ] ] }, "accounts: finished?": { "main": [ [ { "node": "accounts: result", "type": "main", "index": 0 } ], [ { "node": "accounts: wait", "type": "main", "index": 0 } ] ] }, "accounts: wait": { "main": [ [ { "node": "accounts: follow the run", "type": "main", "index": 0 } ] ] }, "accounts: follow the run": { "main": [ [ { "node": "accounts: finished?", "type": "main", "index": 0 } ] ] } }, "settings": {} } ``` ## What you set Each input is set to a working example in the code. Change it to yours. | Input | What it is | Example | | ----- | ---------- | ------- | | `keywords` | Two to six topic words or phrases, comma-separated | `replica rolex dubai,fake watches uae` | | `context` | Who the accounts are, in one sentence | `accounts selling counterfeit luxury watches in the UAE` | ## How does it work? 1. `GET /v1/prism/discover`: One run searches the topic on Instagram, YouTube, Facebook, LinkedIn, X, Reddit, Telegram and TikTok, keeps the posts about it, and returns one row per account behind them, judged against context, with its profile and the posts that matched. ## What to watch for - Put the topic words in keywords and the user's question in context: context is what each account is judged against, never a search phrase. - role=seller,reseller (or promoter, reviewer) keeps only accounts in those roles and adds the role judgment. - targets_country=AE keeps accounts that target that market; country=AE keeps rows whose post or author country is AE. - Followers is a number on each account row, not a follower list: no follower list is fetched. - A large run keeps going in the background: follow data.next.status_url, and read every account at data.next.results_url or as JSON lines at its results.jsonl. ## Cheaper or deeper - **Cheaper:** `GET /v1/search/multi`. Each platform's own search for the topic, when you group the posts by author yourself. - **Deeper:** `GET /v1/prism/profiles`. Full profiles for the accounts you keep, many in one batch call. ## Related - [Accounts with Discover](/docs/prism/discover-accounts.md): Roles, markets and every field on an account row. - [Recipes](/docs/recipes.md): Every recipe, with what it costs. - [Async jobs](/docs/async.md): How a run that keeps going in the background is followed.