# Monthly brand reaction (/docs/recipes/monthly-brand-reaction) One Discover run collects what people said about a brand and its competitors last month, across social platforms and the news. You will collect every post and article about a brand and its competitors from last month, kept per brand and with duplicates dropped, from one `GET /v1/prism/discover` run. It is the input a monthly client report starts from. **Cost per run:** At most 150 credits (max_credits caps the whole run; each search bills at its platform's price only when it returns rows, so it settles 0-150; nothing beyond the judging bound is held up front). Measured on 25/10/2026: 35 credits in 5 calls (the quote held 150). 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** ```typescript title="TypeScript" // How people reacted to a brand and its competitors last month // // Price for a brand and its competitors, one month: at most 150 credits (max_credits caps the whole run; each search bills at its platform's price only when it returns rows, so it settles 0-150; nothing beyond the judging bound is held up front). // Measured on 25/10/2026: 35 credits in 5 calls (the quote held 150). 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): // brands: The brand and its competitors, comma-separated // exclude_terms: Words that mark another meaning of a brand name, comma-separated // // 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 reactions = await sc("prism/discover", { brands: "aruba,curacao,bonaire", period: "last_month", exclude_terms: "networks", max_credits: "150" }); const totalCredits = creditsSpent + [...runCredits.values()].reduce((a, b) => a + b, 0); console.log(`Spent ${totalCredits} credits in ${callCount} calls.`); const rowCounts = { reactions: rowsIn(reactions) }; console.log( "RECIPE_RESULT " + JSON.stringify({ recipe: "monthly-brand-reaction", calls: callCount, credits_used: totalCredits, rows: rowCounts, created: createdPaths, }), ); ``` **Python** ```python title="Python" # How people reacted to a brand and its competitors last month # # Price for a brand and its competitors, one month: at most 150 credits (max_credits caps the whole run; each search bills at its platform's price only when it returns rows, so it settles 0-150; nothing beyond the judging bound is held up front). # Measured on 25/10/2026: 35 credits in 5 calls (the quote held 150). 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): # brands: The brand and its competitors, comma-separated # exclude_terms: Words that mark another meaning of a brand name, comma-separated # # 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")) reactions = sc("prism/discover", {"brands": "aruba,curacao,bonaire", "period": "last_month", "exclude_terms": "networks", "max_credits": "150"}) total_credits = credits_spent + sum(run_credits.values()) print(f"Spent {total_credits} credits in {call_count} calls.") row_counts = {"reactions": rows_in(reactions)} print( "RECIPE_RESULT " + json.dumps( { "recipe": "monthly-brand-reaction", "calls": call_count, "credits_used": total_credits, "rows": row_counts, "created": created_paths, } ) ) ``` **n8n** ```json title="n8n workflow" { "name": "SocialCrawl: How people reacted to a brand and its competitors last month", "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": "reactions", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "parameters": { "method": "GET", "url": "https://www.socialcrawl.dev/v1/prism/discover?brands=aruba%2Ccuracao%2Cbonaire&period=last_month&exclude_terms=networks&max_credits=150", "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": "reactions: finished?", "type": "n8n-nodes-base.if", "typeVersion": 2, "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "loose" }, "conditions": [ { "id": "reactions-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": "reactions: wait", "type": "n8n-nodes-base.wait", "typeVersion": 1.1, "parameters": { "resume": "timeInterval", "amount": 5, "unit": "seconds" } }, { "id": "node-5", "position": [ 1300, 0 ], "name": "reactions: 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": "reactions: 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": "## How people reacted to a brand and its competitors last month\n\nPrice for a brand and its competitors, one month: at most 150 credits (max_credits caps the whole run; each search bills at its platform's price only when it returns rows, so it settles 0-150; nothing beyond the judging bound is held up front).\n\nMeasured on 25/10/2026: 35 credits in 5 calls (the quote held 150). 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 brands: The brand and its competitors, comma-separated\n\n exclude_terms: Words that mark another meaning of a brand name, comma-separated\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": "reactions", "type": "main", "index": 0 } ] ] }, "reactions": { "main": [ [ { "node": "reactions: finished?", "type": "main", "index": 0 } ] ] }, "reactions: finished?": { "main": [ [ { "node": "reactions: result", "type": "main", "index": 0 } ], [ { "node": "reactions: wait", "type": "main", "index": 0 } ] ] }, "reactions: wait": { "main": [ [ { "node": "reactions: follow the run", "type": "main", "index": 0 } ] ] }, "reactions: follow the run": { "main": [ [ { "node": "reactions: 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 | | ----- | ---------- | ------- | | `brands` | The brand and its competitors, comma-separated | `aruba,curacao,bonaire` | | `exclude_terms` | Words that mark another meaning of a brand name, comma-separated | `networks` | ## How does it work? 1. `GET /v1/prism/discover`: One run searches every brand on the default platforms and the news for the whole of last month, keeps the posts about each brand and drops duplicates. A 202 answer keeps running in the background: wait on data.next.status_url and read every row at data.next.results_url. ## What to watch for - Name every brand in brands: each brand is its own group in one run, so the rows come back per brand. - A brand name that is also a word or another company (Aruba the island and Aruba the network vendor) needs exclude_terms, or rows about the other meaning are fetched and judged before they are dropped. - period=last_month is the last full calendar month in UTC; add tz= (an IANA zone such as America/New_York) for the month in another time zone. - max_credits caps the whole run, and a request expected to cost more is refused free with the estimate: raise it only after the user approves. - A run that answers 202 keeps going in the background: never resubmit it, follow data.next.status_url. ## Cheaper or deeper - **Cheaper:** `GET /v1/search/multi`. Each platform's own search once per brand, with since= for the month, when you judge and de-duplicate the rows yourself. - **Deeper:** `GET /v1/prism/share-of-voice`. Reach and share for two to five brands as one figure, beside the posts this run keeps. ## Related - [Brand report with Discover](/docs/prism/discover-brand-report.md): Every parameter of a brand run, and how to read its rows. - [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.