API reference
One key. Five sources. Ranked results, one credit per qualified lead.
Overview
A search API for Reddit, X, Bluesky, LinkedIn, and YouTube. POST a query, GET-poll for results.
JSON in, JSON out. Each platform returns as it finishes, so the first results land in a few seconds and the slowest source takes 30 to 90 seconds.
Base URL:
https://usegorilla.app/v1/
Output shape
The search endpoint (Response) returns a fixed structured JSON schema — ranked result rows in a stable shape you map into your own model on your side.
If you also want findings in your own shape, pass a custom_schema on the same search call: a caller-supplied JSON Schema. When the search completes, the poll's data field holds an object constrained to your schema (OpenAI Structured Outputs, strict mode), filled only from the real results the search found. Same call, same credits — no extra charge.
Get an API key
- Sign in at platform.usegorilla.app.
- Open the side menu, choose API Keys, click Create.
- Copy the key. It's shown once, prefixed
grla_.
New accounts get 100 credits to start on the free tier, one-time. No card required.
Your first call
Two requests. POST kicks off the search. GET polls for results.
curl -X POST https://usegorilla.app/v1/v2-search-stream \
-H "x-api-key: grla_..." \
-H "Content-Type: application/json" \
-d '{"query": "people looking for an AI meal planner", "since": "180d"}'
Response (immediate):
{ "search_id": "7a91-...", "status": "running" }
Then poll every 1.5s until status is no longer "running". See Streaming for the loop.
Authentication
Send your API key on every request:
-H "x-api-key: grla_..."
Keys are scoped to your user account and revocable. They don't expire. Manage them at platform.usegorilla.app/api-keys/.
Keys are secrets. Don't ship them in a public frontend, proxy through your backend instead.
Endpoints
| Endpoint | What it does | Latency |
|---|---|---|
POST /v2-search-stream | Kick off a multi-source search. Returns a search_id + initial state. | <1s |
GET /v2-search-stream?id=… | Poll a running search for the latest per-source state — and the data object when a custom_schema was sent. Free, no credits charged. | <1s |
GET /billing-status | Plan, credit balance, API-key state. Authenticated users only. | <1s |
Each endpoint accepts the methods shown — GET and POST only. Any other method returns 405 method_not_allowed.
Request
{
"query": "string", // required, max 500 chars; ; / newlines = parallel sub-queries
"since": "180d", // <n>h | <n>d | <n>w | <n>mo | all | ISO date
"limit": 50, // 1–200, default 50
"sources": ["reddit", "twitter", "bluesky"], // optional, default = all 5
"channels": { // optional, see Channel filtering
"reddit": ["cooking", "MealPrepSunday"],
"twitter": ["sarahbuilds"]
},
"custom_schema": { // optional — a JSON Schema; fills the poll's `data` field
"type": "object",
"properties": {
"pain_points": { "type": "array", "items": { "type": "string" } },
"summary": { "type": "string" }
}
}
}
| Field | Type | Notes |
|---|---|---|
query | string | Required. Max 500 chars. Sent to each platform. Multi-term: separate terms with ; (semicolons) or newlines and each term runs as its own sub-query in parallel across the sources; the results are merged and deduped into one ranked list. A single term searches as before. |
since | string | Time window as <n><unit> where unit is h (hours), d (days), w (weeks), or mo (months) — e.g. 24h, 7d, 2w, 6mo. Also accepts all (no floor) or an ISO 8601 date / datetime (2026-05-01). mo is approximated as 30 days. Reddit + X apply it in the upstream query. Bluesky, LinkedIn, YouTube filter post-fetch. |
limit | int | 1 to 200. Default 50. Caps the returned list. Billing uses the full unsliced count. |
sources | string[] | Subset of reddit | twitter | bluesky | linkedin | youtube. Default fans out to all five. Every source — including LinkedIn (posts + people) — is available on all plans. |
channels | object | Per-source narrow scope. See Channel filtering. |
custom_schema | object | Optional. A JSON Schema for findings in your own shape. When set, the search runs as normal AND — once results are finalized — one extra model pass (OpenAI Structured Outputs, strict) fills your schema from the real top results, returned as data on the poll. Objects, arrays, strings, numbers, booleans and nesting are supported; you don't need to set required or additionalProperties — the server normalizes for strict mode (all properties become required, and the model returns empty strings / arrays for fields the results don't support rather than fabricating). No extra charge — the search credits cover it. A malformed schema returns 400 invalid_param before any credits are reserved. |
Multi-term query
A query can carry several terms at once. Separate them with ; (semicolons) or newlines, and each term is split out and run as its own sub-query in parallel across every selected source. The sub-query results are merged into one list and deduped, then ranked and returned exactly like a single-term search — one search_id, one billable count. Use it to cover synonyms, competitor names, or distinct pain points in a single call.
// three terms, fanned out in parallel and merged
{
"query": "AI meal planner; meal prep app recommendations; grocery list automation",
"since": "180d"
}
// newlines work too
{
"query": "switching off Calendly\nCalendly alternatives\nself-hosted scheduling"
}
Response
200 OK response shape:
{
"search_id": "7a91-...",
"status": "completed",
"results": [...], // sorted by result_score desc, sliced to limit
"total": 47,
"buckets": { "hot": 12, "warm": 18, "cold": 17 },
"done_sources": ["reddit", "twitter", "bluesky", "linkedin", "youtube"],
"people": [...], // Person rows — accounts, see Streaming
"communities": [...], // channels behind the results
"errors": {}, // per-source error map, if any
"credits_charged": 30, // 12 hot + 18 warm; the 17 cold are free
"credits_remaining": 1970,
"data": { ... } // present ONLY when you sent a custom_schema — see below
}
Custom schema output (data)
When you send a custom_schema on the search, the completed poll includes a data object constrained to your schema. It's filled from the search's real top results by one model pass under OpenAI Structured Outputs (strict mode), so the keys and types are guaranteed to match and every field is grounded in actual posts — the model is instructed to return empty strings / arrays rather than invent content. Omitted entirely when you don't send a custom_schema (the rest of the response is unchanged), so always handle both the missing-data and empty-field cases.
// for custom_schema { pain_points: string[], summary: string }
"data": {
"pain_points": ["Most planners can't build a grocery list from the meal plan..."],
"summary": "Across the results, people want diet-aware planning plus auto grocery lists."
}
No extra credits are charged for the data pass — it rides on the search you already paid for. If the schema fill fails for any reason, the search still completes normally and data is simply omitted.
Result row
{
"id": "abc123",
"source": "reddit",
"channel": "MealPrepSunday", // bare subreddit slug / X handle / channel name
"title": "Need an app that plans meals + builds the grocery list",
"url": "https://reddit.com/r/MealPrepSunday/...",
"author": "u/sunday_prepper", // post author (u/username, @handle, etc.)
"body_snippet": "My partner and I waste 30 min every Sunday...",
"score": 142, // upstream engagement (upvotes / likes)
"num_comments": 28,
"created_utc": 1716480000,
"result_score": 0.94, // 0–1 relevance, ranks the list
"validation_score": 0.81, // 0–1 buyer-signal strength
"matched_signals": ["first_person_demand", "competitor_mention"],
"tier": "hot", // "hot" | "warm" | "cold" (matches buckets)
"archetype": "acute_pain", // lead persona
"archetype_confidence": 0.72, // 0–1 confidence in archetype
"metadata": {} // source-specific extras (object)
}
score is the upstream engagement metric (Reddit upvotes, X likes). result_score is the 0-1 relevance score the list is ranked by, see Scoring. validation_score grades buyer-signal strength and matched_signals lists the cues behind it. tier mirrors the buckets split (hot / warm / cold). archetype and archetype_confidence classify the lead's persona. metadata is a source-specific object (may be empty). search_id is your polling key and your billing audit reference.
Errors
HTTP status code + a machine-readable code field. Branch on code, show error to humans.
| Status | Code | When |
|---|---|---|
| 400 | missing_query | query empty or missing. |
| 400 | invalid_param | Malformed JSON or out-of-range value. |
| 401 | missing_auth | No x-api-key header. |
| 402 | insufficient_credits | Out of credits (balance below 1). Body includes balance. |
| 403 | invalid_auth | Key is well-formed but unrecognized or revoked. |
| 404 | invalid_param | Polled search_id not found or owned by another user. |
| 405 | method_not_allowed | Method not supported on this path. The search endpoint is GET + POST only. |
| 429 | rate_limit | Too many calls in the rolling hour window. See Limits. |
| 500 | internal | Unexpected. Include the search_id in bug reports. |
Per-source errors
A single platform timing out or erroring does not fail the search. The start (POST) and poll (GET) path never returns 502 upstream_error or 504 upstream_timeout as an HTTP response. Instead, the failing source's message is folded into the response's errors map — a Record<source, string> — and that source is marked done (status failed for that source). The other sources still return normally, and the search settles as completed.
The per-source cap is 90s; a source that hits it lands in errors as a timeout. Branch on errors if you want to surface partial-failure to the user.
{
"status": "completed",
"done_sources": ["reddit", "twitter", "linkedin"],
"errors": {
"bluesky": "upstream_timeout",
"youtube": "rate limited by provider"
}
}
HTTP-level errors below apply to the request itself (auth, validation, credits, rate limit), not to an individual upstream source.
Example body:
{
"error": "Out of credits. Subscribe for 2,000 credits a month.",
"code": "insufficient_credits",
"balance": 0,
"required": 1
}
Streaming
POST kicks off the search. GET-poll until status flips. Each poll returns every source that has finished so far, so the client can render results progressively.
1. Kick off
curl -X POST https://usegorilla.app/v1/v2-search-stream \
-H "x-api-key: grla_..." \
-H "Content-Type: application/json" \
-d '{"query": "people looking for an AI meal planner", "since": "180d"}'
Returns 202 immediately:
{
"search_id": "" ,
"status": "running",
"requested_sources": ["reddit", "twitter", ...],
"suggested_interval_ms": 1500
}
2. Poll
curl "https://usegorilla.app/v1/v2-search-stream?id=" \
-H "x-api-key: grla_..."
{
"search_id": "" ,
"status": "running", // running | completed | failed
"query": "people looking for an AI meal planner",
"requested_sources": ["reddit", "twitter", "bluesky", "linkedin", "youtube"],
"done_sources": ["reddit", "twitter"],
"pending_sources": ["bluesky", "linkedin", "youtube"],
"results": [...], // Result rows, see Response
"total": 23, // grows each poll until status=completed
"buckets": { "hot": 6, "warm": 9, "cold": 8 },
"people": [...], // Person rows (see below)
"communities": [...], // subreddits / handles surfaced by results
"errors": {}, // per-source error map, see Errors
"credits_charged": null, // populated when status=completed
"credits_remaining": null,
"started_at": "2026-06-04T12:00:00Z",
"completed_at": null, // ISO timestamp once status=completed
"data": { ... } // present once completed IF you sent a custom_schema — see Response
}
Poll every 1.5s. State persists for 1 hour, so a client that crashes mid-poll can resume by id. If you sent a custom_schema, the completed poll also carries a data object filled from the final results — no extra credits.
requested_sources is the resolved source set — all five by default, on every plan. pending_sources is requested_sources minus done_sources. communities lists the channels behind the results so you can scope a follow-up search.
Person row
Each entry of people[] is a discovered account — LinkedIn ICP matches plus the authors of hot/warm posts. Separate from results.
{
"platform": "linkedin", // "linkedin" | "twitter" | "reddit" | "bluesky"
"handle": "sarah-builds", // publicId / @handle / u/username
"name": "Sarah Chen", // optional
"title": "Head of Product", // optional, LinkedIn job title
"company": "Acme", // optional
"headline": "Building meal-planning tools", // optional, headline / bio
"location": "Austin, TX", // optional
"url": "https://linkedin.com/in/sarah-builds",
"reason": "Posted about switching meal-prep apps", // why surfaced
"score": 0.86 // 0–1 relevance / intent
}
Billing: one charge per search. Reservation on POST, settled on completion. Failed searches and zero-result searches refund automatically.
Channel filtering
Scope the search to specific subreddits or X handles:
{
"query": "meal planner alternative",
"channels": {
"reddit": ["cooking", "MealPrepSunday", "EatCheapAndHealthy"],
"twitter": ["sarahbuilds", "nytfood"]
}
}
- Reddit: each query runs against each subreddit. Fan-out is subs × queries, capped at 10 subs.
- X:
from:<handle>gets prepended to the query for each handle. Capped at 10. - Bluesky, LinkedIn, YouTube: ignored. Passing the field is always safe.
Setting channels.reddit without listing reddit in sources still searches Reddit. The channel scope itself counts as opting in.
Scoring
Every result gets a result_score from 0 to 1, weighted across three signals:
| Signal | Weight | How |
|---|---|---|
| Relevance | 60% | LLM topic-match between the query and the post body. Each result is scored individually. |
| Recency | 25% | 1.0 for posts ≤24h old, linear decay to 0 at 90 days. Unknown dates use 0.3. |
| Engagement | 15% | Log-scaled likes + comments. Floors at 0.1. |
Buckets:
| Bucket | Range | Meaning |
|---|---|---|
| Hot | ≥ 0.7 | Direct match. First-person demand or perfect competitor mention. |
| Warm | 0.4 to 0.7 | Topical adjacency. Good for audience research. |
| Cold | < 0.4 | Loose match. Filter client-side if you only want signal. |
Retries
POST is not idempotent. Each call kicks off a new search and reserves new credits.
Safe to retry
- 429: back off and retry after the suggested window.
- 402: subscribe to keep searching. The free 100 credits are one-time and do not renew; subscribers get fresh credits each billing cycle.
- GET
/v2-search-stream?id=…: pure read, retry as often as needed.
Don't retry blindly
- POST after a 5xx. If the failed response returned a
search_id, poll it. Otherwise wait 90s and check the ledger before issuing a new POST.
Failed searches refund automatically. When status flips to failed or every source returns zero results, the reservation is released with no charge recorded.
MCP server
Drop the config in, restart your agent. Three tools become available: search, get_search, billing_status.
Claude Desktop / Claude Code
Edit ~/.claude.json:
{
"mcpServers": {
"gorilla-mcp": {
"command": "npx",
"args": ["@usegorilla/mcp"],
"env": { "GORILLA_API_KEY": "grla_..." }
}
}
}
Cursor
Edit .cursor/mcp.json (same shape):
{
"mcpServers": {
"gorilla-mcp": {
"command": "npx",
"args": ["@usegorilla/mcp"],
"env": { "GORILLA_API_KEY": "grla_..." }
}
}
}
Codex / OpenAI agents
Edit ~/.codex/config.toml:
[mcp_servers.gorilla]
command = "npx"
args = ["@usegorilla/mcp"]
env = { GORILLA_API_KEY = "grla_..." }
Any MCP-compatible client
GORILLA_API_KEY=grla_... npx @usegorilla/mcp
The search tool POST-and-polls /v2-search-stream internally. If polling exceeds 5 minutes, it returns the search_id so the agent can recover with get_search later. Source at github.com/opusforge/gorilla-mcp.
Code samples
TypeScript
const res = await fetch("https://usegorilla.app/v1/v2-search-stream", {
method: "POST",
headers: {
"x-api-key": process.env.GORILLA_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: "people looking for an AI meal planner",
since: "7d",
limit: 50,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(`Gorilla ${res.status} (${err.code}): ${err.error}`);
}
const { results, credits_remaining } = await res.json();
console.log(`${results.length} results, ${credits_remaining} credits left`);
Streaming (TypeScript)
async function streamSearch(query: string) {
const headers = {
"x-api-key": process.env.GORILLA_API_KEY!,
"Content-Type": "application/json",
};
const start = await fetch(
"https://usegorilla.app/v1/v2-search-stream",
{ method: "POST", headers, body: JSON.stringify({ query, since: "7d" }) },
).then(r => r.json());
while (true) {
await new Promise(r => setTimeout(r, start.suggested_interval_ms));
const poll = await fetch(
`https://usegorilla.app/v1/v2-search-stream?id=${start.search_id}`,
{ headers },
).then(r => r.json());
if (poll.status !== "running") return poll;
}
}
Python
import os, requests
resp = requests.post(
"https://usegorilla.app/v1/v2-search-stream",
headers={
"x-api-key": os.environ["GORILLA_API_KEY"],
"Content-Type": "application/json",
},
json={"query": "people looking for an AI meal planner", "since": "7d", "limit": 50},
)
if not resp.ok:
err = resp.json()
raise RuntimeError(f"Gorilla {resp.status_code} ({err['code']}): {err['error']}")
data = resp.json()
print(f"{len(data['results'])} results, {data['credits_remaining']} credits left")
Pricing
One credit per qualified lead. Low-relevance results are free. No caps. Failed searches refund.
| Bucket | Score | Credits |
|---|---|---|
| Hot | ≥ 0.7 | 1 |
| Warm | 0.4 to 0.7 | 1 |
| Cold | < 0.4 | free |
You only pay for results worth acting on. A typical multi-platform query returns 30 to 90 qualified leads, so 30 to 90 credits.
Tiers
| Tier | Price | Credits | Notes |
|---|---|---|---|
| Free | $0 | 100 once | One-time trial. No card. 100 free qualified leads to start. |
| Monthly | $14.99 / month | 2,000 / month | +2,000 credits each month. Unused credits roll over. |
| Lifetime | one-time | granted on redeem | Lifetime access plan. Reported as plan: "lifetime" in billing-status; treated as a paid plan. |
Manage at platform.usegorilla.app/billing/.
All five sources — Reddit, X, Bluesky, LinkedIn (posts + people), and YouTube — are standard on every plan. Plans differ only in credit volume.
billing-status response
GET /billing-status (authenticated) returns your plan and credit balance:
{
"plan": "lifetime", // "free" | "monthly" | "lifetime"
"balance": { "tier": 0, "pack": 0, "overage": 0, "total": 1840 },
"has_api_keys": true,
"stripe_customer_id": "cus_...", // null if none
"stripe_subscription_id": null
}
balance.total is the spendable credit count. Branch on plan if you surface plan-specific UI; all five sources are available regardless of plan.
Limits
| Limit | Value | Notes |
|---|---|---|
| Rate limit | 20 requests / hour / user | Rolling hour, per user, on v2-search-stream. 429 rate_limit on exceed. (billing-status is capped separately at 60/min.) |
| Query length | 500 chars | 400 with invalid_param if exceeded. |
| Channels per source | 10 | Extras silently truncated. |
| Per-source timeout | 90s | The source lands in the response's errors map as upstream_timeout; the search still completes. Not a top-level HTTP error. |
| Search lifetime | 1 hour | Row cleaned up after an hour. POST again to start a new one. |
Include the search_id from the response in bug reports. Every search is queryable in your dashboard.