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

  1. Sign in at platform.usegorilla.app.
  2. Open the side menu, choose API Keys, click Create.
  3. 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

EndpointWhat it doesLatency
POST /v2-search-streamKick 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-statusPlan, 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" }
    }
  }
}
FieldTypeNotes
querystringRequired. 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.
sincestringTime 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.
limitint1 to 200. Default 50. Caps the returned list. Billing uses the full unsliced count.
sourcesstring[]Subset of reddit | twitter | bluesky | linkedin | youtube. Default fans out to all five. Every source — including LinkedIn (posts + people) — is available on all plans.
channelsobjectPer-source narrow scope. See Channel filtering.
custom_schemaobjectOptional. 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.

StatusCodeWhen
400missing_queryquery empty or missing.
400invalid_paramMalformed JSON or out-of-range value.
401missing_authNo x-api-key header.
402insufficient_creditsOut of credits (balance below 1). Body includes balance.
403invalid_authKey is well-formed but unrecognized or revoked.
404invalid_paramPolled search_id not found or owned by another user.
405method_not_allowedMethod not supported on this path. The search endpoint is GET + POST only.
429rate_limitToo many calls in the rolling hour window. See Limits.
500internalUnexpected. 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:

SignalWeightHow
Relevance60%LLM topic-match between the query and the post body. Each result is scored individually.
Recency25%1.0 for posts ≤24h old, linear decay to 0 at 90 days. Unknown dates use 0.3.
Engagement15%Log-scaled likes + comments. Floors at 0.1.

Buckets:

BucketRangeMeaning
Hot≥ 0.7Direct match. First-person demand or perfect competitor mention.
Warm0.4 to 0.7Topical adjacency. Good for audience research.
Cold< 0.4Loose 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.

BucketScoreCredits
Hot≥ 0.71
Warm0.4 to 0.71
Cold< 0.4free

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

TierPriceCreditsNotes
Free$0100 onceOne-time trial. No card. 100 free qualified leads to start.
Monthly$14.99 / month2,000 / month+2,000 credits each month. Unused credits roll over.
Lifetimeone-timegranted on redeemLifetime 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

LimitValueNotes
Rate limit20 requests / hour / userRolling hour, per user, on v2-search-stream. 429 rate_limit on exceed. (billing-status is capped separately at 60/min.)
Query length500 chars400 with invalid_param if exceeded.
Channels per source10Extras silently truncated.
Per-source timeout90sThe source lands in the response's errors map as upstream_timeout; the search still completes. Not a top-level HTTP error.
Search lifetime1 hourRow 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.