blackhat.ink / docs

API & MCP

Blackhat is a database, not a scraper. Every row is an already-collected Instagram Reel with its verbatim hook, transcript, tags, and an outlier score — the video's views divided by that creator's own median. You query what is already there; when coverage is thin a collection job runs in the background and the library gets deeper for everyone.

The library is Instagram today. The schema, the filters and the API all take platform=tiktok — the collection side is built and tested — but no TikTok row has been ingested yet, so a TikTok filter returns nothing. TikTok coverage lands in v1.1 and needs no change on your side.

Base URL https://api.blackhat.ink. Two ways in: REST, and an MCP server you add to Claude Code or Claude Desktop in one line.

Access

API and MCP access are part of the Agency plan. Create a key in Settings → API. The key is shown once, at creation: Blackhat stores only a SHA-256 hash of it, so a lost key cannot be recovered — only revoked and replaced. Ten live keys per account.

Keys look like bh_live_… (a 32-character body). Send one as a bearer token on every request:

export BH_KEY=bh_live_YOUR_KEY

curl -s "https://api.blackhat.ink/v1/videos?q=design" \
  -H "Authorization: Bearer $BH_KEY"

A missing, malformed, unknown or revoked key is a 401 unauthenticated — always with the same message, so a guessed key learns nothing. A valid key on a plan without API access is a 403 forbidden. Revocation is immediate: the revoked flag is read on every request with no cache, over REST and MCP alike, because both go through one auth path.

GET /v1/videos

The read surface. Ranked hybrid search — keyword and semantic, blended — over every cached video, with filters and a keyset cursor.

ParameterTypeNotes
qstringSearch terms. Omit to browse by sort order alone.
platformtiktok | instagramRepeatable. Instagram only today — see above.
niche_tagstringRepeatable; matches any.
format_tagstringRepeatable; matches any.
outlier_onlybooleanOnly videos beating their creator baseline.
min_viewsinteger≥ 0.
max_duration_sinteger> 0.
posted_afterdateISO date, e.g. 2026-01-01.
posted_beforedateISO date.
sortoutlier | views | recent | engagement | relevanceDefault outlier.
limitintegerDefault 25, clamped to 100.
cursorstringFrom next_cursor. An invalid cursor is a 400, never page 1.
curl -s "https://api.blackhat.ink/v1/videos?q=ai%20tools&outlier_only=true&limit=2" \
  -H "Authorization: Bearer $BH_KEY"
{
  "data": [
    {
      "id": "vid_9c1f2ab7d4e04b3c8a1f6d2e0",
      "platform": "instagram",
      "url": "https://www.instagram.com/reel/Cx7yQ2mAbCd/",
      "creator": { "handle": "@example", "followers": 48200, "median_views": 21400 },
      "caption": "the 3 AI tools I actually pay for",
      "hook_text": "I cancelled 9 subscriptions and kept these three.",
      "transcript": "I cancelled nine subscriptions this month …",
      "views": 1012400, "likes": 84120, "comments": 1902, "shares": 6104,
      "engagement_rate": 0.0912,
      "outlier_score": 47.3, "is_outlier": true,
      "duration_s": 34,
      "posted_at": "2026-06-28T09:14:00.000Z",
      "format_tags": ["talking_head", "listicle"],
      "niche_tags": ["ai_tools", "productivity"],
      "thumbnail_url": "https://…blob.vercel-storage.com/thumbs/vid_9c1f….jpg"
    }
    // … one more row
  ],
  "next_cursor": "eyJzIjoiNDcuMyIsImkiOiJ2aWRfOWMxZjJhYjdkNGUwNGIzYzhhMWY2ZDJlMCJ9",
  "coverage": "cached"
}

url is the platform permalink and thumbnail_url is our re-hosted image. Blackhat never serves video files.

GET /v1/creators/:handle

One creator, their baseline, and their cached videos. The handle is case-insensitive and the leading @ is optional — @Example, example and %40example all resolve to the same creator. An uncached creator is a 404 not_found. If the same handle exists on both platforms you get a 400 invalid_request asking for ?platform= rather than a guess. Videos come back most-outlying first, with the same keyset next_cursor as /v1/videos.

curl -s "https://api.blackhat.ink/v1/creators/example?platform=instagram" \
  -H "Authorization: Bearer $BH_KEY"
{
  "creator": {
    "handle": "@example",
    "platform": "instagram",
    "display_name": "Example",
    "followers": 48200,
    "bio": "ai tools, weekly",
    "avatar_url": "https://…blob.vercel-storage.com/avatars/crt_1a2b….jpg",
    "median_views": 21400,
    "video_count_cached": 63,
    "last_scraped_at": "2026-07-19T02:11:00.000Z"
  },
  "videos": [ /* same row shape as GET /v1/videos */ ],
  "next_cursor": null
}

median_views is the denominator of every outlier score. It stays null until a creator has enough cached videos to have a real baseline — with two or three, a "47× outlier" would be noise, so we do not claim one.

POST /v1/research

Ask for videos we do not have yet. Asynchronous by nature — collection takes minutes — so you get a job id back immediately and poll it. Counts against your plan's monthly collection allowance; searching does not.

FieldTypeNotes
query_typecompetitor | niche | hashtag | brandRequired.
querystringRequired. A handle, term, hashtag or brand. Max 200 chars.
platformsarrayOptional. Defaults to both.
curl -s -X POST "https://api.blackhat.ink/v1/research" \
  -H "Authorization: Bearer $BH_KEY" -H "Content-Type: application/json" \
  -d '{"query_type":"competitor","query":"@example","platforms":["instagram"]}'
{
  "job_id": "job_9xQ2c1f7a0b4d2e6c8a13",
  "status": "queued",
  "deduped": false,
  "poll_url": "https://api.blackhat.ink/v1/research/job_9xQ2c1f7a0b4d2e6c8a13",
  "estimated_seconds": 420
}

Poll GET /v1/research/:id every 10 seconds and give up at 15 minutes. status moves queued → scraping → enriching → done, and videos carries the enriched rows in the /v1/videos shape once it is done. A failed job is status: "failed" with a human-readable status_detail — never a 500, and the response still parses.

Repeat requests do not double-charge. An identical request while an equivalent job of yours is still running returns that job's id with deduped: true and consumes no additional allowance, so a client retry loop cannot burn your month.

curl -s "https://api.blackhat.ink/v1/research/job_9xQ2c1f7a0b4d2e6c8a13" \
  -H "Authorization: Bearer $BH_KEY"
{
  "job_id": "job_9xQ2c1f7a0b4d2e6c8a13",
  "status": "enriching",
  "status_detail": "Transcribing 118/300 videos",
  "video_count": 300,
  "created_at": "2026-07-21T02:11:04.000Z",
  "completed_at": null,
  "videos": []
}

POST /v1/chat

A grounded research answer over the database. It runs the same tool loop the MCP server exposes — search, aggregate metrics, creator lookup, collection requests — and answers only from what those tools returned. Typical questions come back in 10–40 seconds.

FieldTypeNotes
questionstringRequired (or the last user message in `messages`).
chat_idstringOptional. Continues an existing conversation.
scopeobjectOptional. Filters pinned onto every search this turn.
response_fieldsobjectOptional. Field name → plain-English description.
streambooleanOptional. Or send Accept: text/event-stream.
curl -s -X POST "https://api.blackhat.ink/v1/chat" \
  -H "Authorization: Bearer $BH_KEY" -H "Content-Type: application/json" \
  -d '{
    "question": "What hooks are working for AI tools on Instagram right now?",
    "scope": { "platform": "instagram", "niche_tag": "ai_tools" },
    "response_fields": {
      "top_hooks": "array of the 5 best verbatim hooks",
      "pattern": "one sentence naming the pattern they share",
      "median_outlier_score": "number"
    }
  }'
{
  "chat_id": "cht_4b1c9d2e0f7a3b5c8d1e6",
  "answer": "Three hooks are carrying this niche …",
  "tools_used": [ { "tool": "search_videos", "result_count": 25 }, { "tool": "query_metrics" } ],
  "videos_cited": ["vid_9c1f2ab7d4e04b3c8a1f6d2e0"],
  "fields": {
    "top_hooks": ["I cancelled 9 subscriptions and kept these three.", "…"],
    "pattern": "A concrete number in the first three words.",
    "median_outlier_score": 22.4
  }
}

response_fields maps a field name to a plain-English description; the type is read from the description ("array of…" → array, "number" → number, otherwise a string). A field the data does not support comes back null — never an invented value.

With stream: true you get Server-Sent Events: tool_use and tool_result as the model works, text deltas as it writes, and a final done carrying the identical JSON envelope, so one parser handles both transports. A failure mid-stream arrives as an error event with the standard error body. The loop is capped at eight tool round-trips; hitting the cap sets truncated: true rather than stopping silently.

Reports

POST /v1/reports generates a research report over a set of cached videos and returns 202 with a report_id and a public share URL; poll GET /v1/reports/:id for status, the structured report_json and the rendered report_md. Reports read the existing library and never trigger collection: if coverage is thin the report fails with status_detail: "thin_coverage" rather than quietly spending your collection allowance. To report on fresh data, compose POST /v1/research → wait for donePOST /v1/reports with the job_id.

Another account's report_id is a 404, not a 403 — the API will not confirm that an id exists.

Pagination

Keyset cursors, not page numbers: pass next_cursor back as cursor and keep going until it comes back null. Rows inserted while you page cannot duplicate or skip results the way an OFFSET would. Cursors encode the sort you started with — do not change sort or the filters mid-pagination.

cursor=""
while :; do
  page=$(curl -s "https://api.blackhat.ink/v1/videos?q=design&cursor=$cursor" -H "Authorization: Bearer $BH_KEY")
  echo "$page" | jq -r '.data[].url'
  cursor=$(echo "$page" | jq -r '.next_cursor // empty')
  [ -z "$cursor" ] && break
done

Coverage & backfill

Every search response carries a coverage field. cached means we answered entirely from the library. backfill_running means your query hit a thin patch, so a collection job started in the background — you get what exists now, and the same query in about ten minutes returns more.

{ "coverage": "backfill_running",
  "backfill": { "job_id": "job_7d2e…", "started_at": "2026-07-21T04:02:11.000Z" } }

This is the compounding part of the product, and it costs you nothing: backfills are on us, not on your collection allowance. Searching a niche nobody has searched makes the database better for every account, including yours the next time you ask.

Rate limits

600 reads per hour per key on a sliding one-hour window, and 60 chat messages per hour per key. Sliding, not bucketed: you cannot burst 600 at :59 and 600 more at :01. Limits are per key rather than per account, so keys are independent budgets — a leaked key burns its own and revoking it restores capacity instantly.

HeaderMeaning
X-RateLimit-LimitThe ceiling for this kind of request.
X-RateLimit-RemainingSlots left in the current window, after this request.
X-RateLimit-ResetUnix seconds when the next slot frees up.
Retry-AfterSeconds to wait. Sent only on a 429.

The first three are on every response, not only on rejections, so a well-behaved client paces itself instead of discovering the limit by hitting it. Retry-After is computed from the oldest request still inside your window — it is the exact second a slot frees, not a guessed constant. Over MCP the same condition arrives as a tool error the agent can back off from, without dropping the session.

Limits & plans

FreeOperator ($49/mo)Agency ($99/mo)
searches15 / dayunlimitedunlimited
results per search12unlimitedunlimited
reports1, ever10 / month40 / month
collection requests10 / month40 / month
chat messages200 / month600 / month
collections & CSVyesyes
API + MCPyes

Monthly caps run on UTC calendar months. At a cap you get a 402 plan_limit naming the cap and the plan — never a silent truncation.

Errors

One shape, everywhere, REST and MCP:

{ "error": {
    "code": "plan_limit",
    "message": "Operator plans include 10 reports per month.",
    "docs": "https://blackhat.ink/docs#limits"
} }
CodeHTTPMeaning
unauthenticated401Missing, malformed, unknown or revoked key.
invalid_request400A parameter is wrong. The message says which.
plan_limit402A plan cap is reached.
forbidden403Authenticated, but this plan has no API access.
not_found404No such video, creator, job, chat or report.
rate_limited429Too many requests. See Retry-After.
provider_unavailable503An upstream provider is down. Retry.
internal500Ours. Retry, then tell us.

Those eight codes are the whole vocabulary. Branch on error.code, never on the wording of error.message — messages get clearer over time, codes do not change.

Versioning

/v1 is frozen and changes are additive only: new endpoints, new optional parameters, new response fields, new enum values in responses, relaxed limits. Nothing inside /v1 is ever removed, renamed or retyped — a breaking change would be /v2, mounted alongside, with /v1 still working.

Your side of the contract: tolerate unknown fields. New keys appear in responses without notice, and a client that rejects them is a client we broke by adding something. Do not depend on key order, on the exact wording of a message, or on the opaque contents of a cursor.

Nothing is deprecated today. If anything ever is, it gets a changelog entry, Deprecation and Sunset response headers, an email to every account with a recently created key, and a minimum of 90 days before removal.

MCP

Add Blackhat to Claude and ask what is going viral in your niche. The MCP server is the same database, the same key and the same tools the product itself uses, over Streamable HTTP at https://api.blackhat.ink/mcp.

Claude Code, one line:

claude mcp add --transport http blackhat https://api.blackhat.ink/mcp \
  --header "Authorization: Bearer bh_live_YOUR_KEY"

Claude Desktop, or ~/.claude.json — add this inside mcpServers:

{
  "mcpServers": {
    "blackhat": {
      "type": "http",
      "url": "https://api.blackhat.ink/mcp",
      "headers": { "Authorization": "Bearer bh_live_YOUR_KEY" }
    }
  }
}
ToolWhat it does
search_videosRanked search over the library. Start here for “what’s working in X”.
get_creatorA creator’s profile, median views and cached videos with outlier scores.
query_metricsOne read-only SELECT over the public videos_public view, for aggregates.
request_scrapeCollect fresh videos when coverage is thin. Async; returns a job_id.

Try this first, in a fresh session:

What's working on Instagram for AI tools right now — find the outliers,
tell me which creator is beating their own baseline hardest, and check
whether short videos out-perform long ones in that niche.

Two differences from REST, both deliberate: MCP results are capped at 50 videos and transcripts are clipped to 600 characters, because an agent's context window is the scarce resource. Full transcripts are always available over GET /v1/videos. A revoked key stops working over MCP the same instant it stops working over REST.