API documentation

Antechamber API

Search Reddit evidence, hydrate the records a user opens, and create live streams for terms or fields that should keep matching over time.

Get started

Make the first useful request

The Antechamber API is organized around three actions: search for evidence, hydrate details by ID or username, and keep watching with streams.

Base URL
https://api.think-pol.com
Auth
Authorization: Bearer $THINKPOL_API_KEY
Format
JSON responses
Contract
/antechamber/swagger
  1. Call GET /v3/search with one or more terms. The response contains hydrated submissions and comments arrays that are ready to render in a results view.
  2. Use has_more and after to fetch the next search page. Do not build a client that tries to pull the complete archive in one run.
  3. When a user opens a result, use the v2 content routes to fetch the author, post, comment, replies, or subreddit context needed for that screen.
  4. When the same matcher should keep running, create a POST /v1/streams stream. Poll the stream inbox first; add a public URL only if you need push delivery.
Search for a term Use mode=word for token search and mode=phrase for exact contiguous text.
curl -sS "https://api.think-pol.com/v3/search?terms=acme&mode=word&type=comment&order=desc" \
  -H "Authorization: Bearer $THINKPOL_API_KEY"
API basics

Resources and response shapes

The API is resource-oriented, but the best entry point depends on what the user is doing.

Search results

/v3/search returns two arrays: submissions and comments. Each item includes Reddit IDs, author, subreddit, text/title fields, timestamps, and URLs where available.

Hydration routes

Use v2 routes after search gives you a username, submission ID, or comment ID. These routes are for opening a record, not for broad discovery.

Streams

Streams are persistent matchers. They push signed webhook deliveries by default when a URL is configured and write matches to a 30-day inbox only when inbox_enabled:true is set.

TaskUseNotes
Find evidenceGET /v3/searchSupports multiple terms, word or phrase mode, optional time window, and cursor pagination.
Chart volumeGET /v3/search/popularityAccepts exactly one single-token terms value. It returns counts, not evidence records.
Open a resultv2 user, post, comment, and subreddit routesUse IDs and usernames returned from search. Most list endpoints are timestamp-paginated.
Monitor future matches/v1/streamsAdd url for signed push delivery; set inbox_enabled:true for inbox polling or inbox-only streams.
Authentication

Send a bearer token on every request

All documented Antechamber routes require a valid API key in the Authorization header.

Authenticated request Keep API keys on your server. Do not include API keys in browser JavaScript.
curl -sS "https://api.think-pol.com/quota" \
  -H "Authorization: Bearer $THINKPOL_API_KEY"

401 vs 403

401 means the bearer token is missing or invalid. 403 means the token is valid but is not allowed to use that endpoint, for example stream endpoints without stream access.

Quota

GET /quota returns the remaining API quota for the authenticated key as a JSON integer.

Search

Search is the primary discovery endpoint. It searches submissions and comments, returns hydrated result arrays, and supports cursor-based pagination.

Use this endpoint for search result pages, investigative queries, and drill-down workflows that start from a term or phrase.

ParameterRequiredDescription
termsYesOne or more values that must all match the same post or comment. In phrase mode, keep each complete phrase in one value. See phrase examples.
fromNoUnix timestamp lower bound. Defaults to no lower bound.
toNoUnix timestamp upper bound. Defaults to current time.
typeNocomment or submission. Omit to search both.
modeNoword matches tokens. phrase requires each terms value to match as contiguous text, ignoring case. Default is word.
orderNodesc or asc by created_utc. Default is desc.
afterNoCursor timestamp from the previous page. In desc order it asks for older results; in asc order it asks for newer results.
limitNoMaximum number of submissions and comments combined per page. An integer from 1 to 50; defaults to 50 when omitted. Empty, invalid, or out-of-range values return 400.
Response fields

submissions and comments contain the matched records. limit, returned_count, has_more, after, and order describe the page.

To find a quotation, set mode=phrase and put the whole sentence in one terms value. Each value must match contiguous text, ignoring case. When you supply several values, every phrase must appear in the same post or comment; the phrases can appear in any order and at any distance from one another.

These examples show the parameter values. Send them as URL query parameters on a GET /v3/search request.

Find a complete sentence

{
  "mode": "phrase",
  "terms": ["i think this is interesting"]
}

Matches i think this is interesting with those words together in that order. Use this when looking for a copied sentence or quotation.

Require separate words

{
  "mode": "phrase",
  "terms": ["i", "think", "this", "is", "interesting"]
}

Requires all five separate terms somewhere in the same item. Their order and distance can differ from the sentence, so this expresses a broader search than the complete quotation.

Require two phrases in the same item

{
  "mode": "phrase",
  "terms": ["climate change", "renewable energy"]
}

Requires both climate change and renewable energy in the same post or comment. Each phrase stays together; either phrase can come first. Multiple values mean AND.

In Swagger: choose phrase for mode and enter the complete phrase in one terms array item. Add another item only for another required phrase. Enter the text itself, without surrounding quote characters. Spaces within a phrase and punctuation are matched literally; leading and trailing whitespace is trimmed.

In a URL, encode spaces and repeat terms for multiple values:

GET /v3/search?mode=phrase&terms=i%20think%20this%20is%20interesting

GET /v3/search?mode=phrase&terms=climate%20change&terms=renewable%20energy
Paginate search results Read after from the response and pass it to the next request while has_more is true.
FIRST=$(curl -sS "https://api.think-pol.com/v3/search?terms=acme&mode=word&order=desc" \
  -H "Authorization: Bearer $THINKPOL_API_KEY")

AFTER=$(printf "%s" "$FIRST" | jq -r '.after')

curl -sS "https://api.think-pol.com/v3/search?terms=acme&mode=word&order=desc&after=$AFTER" \
  -H "Authorization: Bearer $THINKPOL_API_KEY"
Popularity

Measure volume before retrieving evidence

Popularity returns a total or time histogram for one token. Use it to pick a time window, then call /v3/search for the matching posts and comments.

GET/v3/search/popularity

ParameterRequiredDescription
termsYesExactly one non-empty single-token term.
resolutionNoall, year, month, day, or hour. Default is month.
fromNoUnix timestamp start. Must be provided together with to.
toNoUnix timestamp end. Must be provided together with from.
bucketsNoPositive bucket count when from and to are omitted.
Do not use popularity as a result endpoint. It returns points and optional total, not submission or comment records.
Get monthly buckets The parameter is terms, matching the OpenAPI contract.
curl -sS "https://api.think-pol.com/v3/search/popularity?terms=acme&resolution=month&buckets=24" \
  -H "Authorization: Bearer $THINKPOL_API_KEY"
Pagination

Use the cursor style for the endpoint you called

Search, content lists, prefix searches, and stream inboxes all paginate, but they do not use the same cursor field.

Endpoint familyCursor requestCursor responseOrder
/v3/searchafterafter, has_morecreated_utc, default newest first
User and content listslast or sincelast, has_moreNewest first by created_utc
User/subreddit prefix searchafterNext cursor is the last returned nameLexicographic name order
Stream inboxafternext_after, has_moreAscending event_time,event_id
Totals are optional on content lists. Use include_total=true only when the UI needs a count. Otherwise rely on has_more.
Users and content

Open only the records the user needs

Use these endpoints after search returns a username, submission ID, or comment ID.

GET/v2/user/search

Autocomplete usernames by prefix. Use no_metadata=true for a fast list of names.

GET/v2/user/{username}

Fetch Reddit profile metadata. Add fetch_socials=true only when external socials are needed.

GET/v2/user/{username}/comments

GET/v2/user/{username}/subcomments

GET/v2/user/{username}/posts

List a user's top-level comments, replies, or submissions. These use limit, last, since, and optional include_total.

GET/v2/posts

Hydrate up to 100 comma-separated submission IDs with bounded comment previews.

GET/v2/posts/{id}

Fetch one post. By default, hydrate_comments=true attaches up to 100 recent comments.

GET/v2/posts/{id}/comments

GET/v2/comments/{id}

GET/v2/subcomments/{id}

Fetch a post's comments, one comment, or replies to a comment. Comment/reply lists use timestamp pagination.

Paginate comments for a post Use the returned last timestamp to fetch older comments.
curl -sS "https://api.think-pol.com/v2/posts/abc123/comments?limit=100" \
  -H "Authorization: Bearer $THINKPOL_API_KEY"

curl -sS "https://api.think-pol.com/v2/posts/abc123/comments?limit=100&last=1779107696" \
  -H "Authorization: Bearer $THINKPOL_API_KEY"
Subreddits

Add community context

Use subreddit endpoints to autocomplete communities, display metadata and activity, and page through observed subreddit authors.

GET/v2/subreddit/search

Search subreddit names by prefix with subreddit, optional after, limit, and no_metadata=true.

GET/v2/subreddit/{subreddit_name}

Fetch a subreddit overview. Short-window activity is included by default. Set fetch_activity=true for monthly activity buckets and top authors.

GET/v2/subreddit/{subreddit_name}/users

Page through observed subreddit authors with limit and string after. The response uses { items, limit, has_more, after }.

Streams

Keep matching after the first search

Streams collect live matches for your account. One stream has one matcher type, one optional field, one delivery identity, and many selectors. URL-backed streams push signed webhook deliveries by default. Set inbox_enabled:true when you also want the inbox: a 30-day polling feed of matched comments and posts.

EndpointUseResponse
GET /v1/streamsList active and paused streams in your account.{ streams: [...] }. Includes paused, selector, and selector_count, but not signing secrets.
POST /v1/streamsCreate a stream matcher with one or more selectors.201. URL-backed creates return signing_secret once; inbox-only creates require inbox_enabled:true.
GET /v1/streams/{streamID}/selectorsPage through a stream's active selectors.{ selectors, next_after, has_more }.
POST /v1/streams/{streamID}/selectorsAdd selectors to an existing stream.Updated stream with selector_count.
DELETE /v1/streams/{streamID}/selectorsRemove selectors from an existing stream.Updated stream. Removing the last selector returns 400.
GET /v1/streams/{streamID}/eventsPoll the 30-day inbox.{ events, next_after, has_more }.
POST /v1/streams/{streamID}/rotate-secretRotate a URL-backed stream secret.New signing_secret. Old secret stops working immediately.
POST /v1/streams/{streamID}/pausePause collection for one stream. No request body.202 with the saved stream configuration and paused:true.
POST /v1/streams/{streamID}/resumeResume the same stream. No request body.202 with the saved stream configuration and paused:false.
DELETE /v1/streams/{streamID}Delete an active or paused stream.204. Deleted streams cannot resume.

Pause and resume

Pause preserves the stream ID, selectors, delivery settings, and signing secret. Other streams keep running. Collection stops asynchronously. Matches already being processed may still appear in the inbox or arrive by webhook after pausing. Existing inbox events remain readable with their normal 30-day retention.

Both actions are safe to repeat. A 202 response confirms the saved paused state; it does not confirm that collection has already stopped or restarted. Resume continues live collection without collecting historical matches from the paused period. In the Firehose demo, use the Pause/Resume button on the stream's card; its saved state survives a page reload.

What the inbox is

The inbox is an append-only polling feed for one stream. A match is written there only when the stream was created with inbox_enabled:true.

Each events[] item is one selector hit. If one Reddit row matches two selectors in the same stream, the feed returns two events with the same reddit_id and different match.selector.

Reading the inbox does not acknowledge, lock, or delete events. Events disappear only when the 30-day TTL removes old rows.

What the cursor means

next_after is the cursor after the last event in the response. It is not a page number and should be stored exactly as returned.

The cursor is an opaque pagination token. Pass next_after unchanged as after on the next request.

  1. First poll: call GET /v1/streams/{streamID}/events?limit=100 without after.
  2. Process events in the returned order. Inbox pages are oldest-to-newest by event_time,event_id.
  3. When the response contains events, store next_after after the events are processed.
  4. If has_more=true, request the next page immediately with after=<next_after>.
  5. If has_more=false, keep the stored cursor and poll again later with the same after.
  6. If events is empty, next_after is omitted. Keep your previous stored cursor.
Limits and retention. limit defaults to 100 and caps at 500. A malformed cursor returns 400. Inbox events are retained for 30 days, so poll often enough for your integration's recovery window.

Selectors

Create streams with selectors. Inputs are trimmed, de-duplicated case-insensitively, capped at 100,000 active selectors per stream, and limited to 256 characters each.

text_contains

Matches selectors as case-insensitive substrings in normalized comment text and submission title/text. Omit field.

field_equals

Matches selectors as case-insensitive exact field values. field must be author, subreddit, or kind.

Delivery mode

Add a public HTTPS url for signed webhook push delivery. Add inbox_enabled:true for inbox recovery, or omit url with inbox_enabled:true for inbox-only polling.

Push batching

batch_max_size is the maximum number of matched events sent in one webhook request. A Reddit row that matches three selectors contributes three events. It defaults to 1 and caps at 100.

batch_max_delay_seconds flushes a non-empty batch after that many seconds. It defaults to 0, caps at 60, and 0 disables the timer.

Batching only affects URL push delivery. Inbox polling still uses the limit parameter on /events.

Create and manage streams Use batch_max_size and batch_max_delay_seconds for URL push; use next_after for inbox polling.
curl -sS -X POST "https://api.think-pol.com/v1/streams" \
  -H "Authorization: Bearer $THINKPOL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type":"text_contains","selectors":["acme breach","acme leak"],"inbox_enabled":true}'

curl -sS "https://api.think-pol.com/v1/streams" \
  -H "Authorization: Bearer $THINKPOL_API_KEY"

curl -sS -X POST "https://api.think-pol.com/v1/streams" \
  -H "Authorization: Bearer $THINKPOL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type":"text_contains","selectors":["acme breach","acme leak"],"url":"https://your-app.example/webhooks/thinkpol","batch_max_size":10,"batch_max_delay_seconds":5}'

STREAM_ID="7b98c8c8-1111-4222-8333-123456789abc"

curl -sS -X POST "https://api.think-pol.com/v1/streams/$STREAM_ID/selectors" \
  -H "Authorization: Bearer $THINKPOL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"selectors":["acme lawsuit","acme investigation"]}'

curl -sS "https://api.think-pol.com/v1/streams/$STREAM_ID/selectors?limit=100" \
  -H "Authorization: Bearer $THINKPOL_API_KEY"

curl -sS -X DELETE "https://api.think-pol.com/v1/streams/$STREAM_ID/selectors" \
  -H "Authorization: Bearer $THINKPOL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"selectors":["acme leak"]}'

PAGE=$(curl -sS "https://api.think-pol.com/v1/streams/$STREAM_ID/events?limit=100" \
  -H "Authorization: Bearer $THINKPOL_API_KEY")

printf "%s" "$PAGE" | jq '.events[] | {event_id, kind, reddit_id, match}'
AFTER=$(printf "%s" "$PAGE" | jq -r '.next_after // empty')

if [ "$(printf "%s" "$PAGE" | jq -r '.has_more')" = "true" ]; then
  curl -sS "https://api.think-pol.com/v1/streams/$STREAM_ID/events?limit=100&after=$AFTER" \
    -H "Authorization: Bearer $THINKPOL_API_KEY"
fi

# Pause collection, keeping the stream configuration.
curl -sS -X POST "https://api.think-pol.com/v1/streams/$STREAM_ID/pause" \
  -H "Authorization: Bearer $THINKPOL_API_KEY"

# Later, resume the same stream.
curl -sS -X POST "https://api.think-pol.com/v1/streams/$STREAM_ID/resume" \
  -H "Authorization: Bearer $THINKPOL_API_KEY"

# Permanently remove the stream when it is no longer needed.
curl -sS -X DELETE "https://api.think-pol.com/v1/streams/$STREAM_ID" \
  -H "Authorization: Bearer $THINKPOL_API_KEY"
Matched event shape Inbox polling and webhook push use the same per-event model. Webhook deliveries wrap matched events in one stream-level request.
{
  "events": [
    {
      "event_id": "evt_bitcoin",
      "stream_id": "stream_123",
      "webhook_id": "stream_123",
      "event_time": "2026-05-20T12:00:00Z",
      "source_created_utc": 1779278400,
      "kind": "comments",
      "reddit_id": "c1",
      "author": "alice",
      "subreddit": "CryptoCurrency",
      "permalink": "/r/CryptoCurrency/comments/post/c1",
      "match": { "type": "text_contains", "field": "", "selector": "bitcoin" },
      "payload": { "id": "c1", "author": "alice", "subreddit": "CryptoCurrency", "text": "bitcoin and ethereum are moving" }
    },
    {
      "event_id": "evt_ethereum",
      "stream_id": "stream_123",
      "webhook_id": "stream_123",
      "event_time": "2026-05-20T12:00:00.000000001Z",
      "source_created_utc": 1779278400,
      "kind": "comments",
      "reddit_id": "c1",
      "author": "alice",
      "subreddit": "CryptoCurrency",
      "permalink": "/r/CryptoCurrency/comments/post/c1",
      "match": { "type": "text_contains", "field": "", "selector": "ethereum" },
      "payload": { "id": "c1", "author": "alice", "subreddit": "CryptoCurrency", "text": "bitcoin and ethereum are moving" }
    }
  ],
  "next_after": "opaque-cursor",
  "has_more": false
}
Webhook signatures

Verify push deliveries before parsing them

URL-backed streams send signed batched deliveries. Verify the exact raw request body with the stream signing secret returned at create or rotate time.

  1. Read the raw request body bytes before JSON parsing. Do not verify re-serialized JSON.
  2. Use X-Thinkpol-Webhook-Id to look up the stream's saved whsec_... signing secret.
  3. Reject missing, malformed, or stale X-Thinkpol-Timestamp values. The recommended freshness tolerance is 5 minutes.
  4. Compute HMAC-SHA256 over v1.<timestamp>.<raw_json_body> using the full secret string as the key.
  5. Accept the request if one comma-separated v1=... value in X-Thinkpol-Signature matches in constant time.
  6. After verification, parse JSON and confirm body webhook_id equals the header stream id.
HeaderDescription
X-Thinkpol-Webhook-IdStream id associated with the delivery.
X-Thinkpol-TimestampUnix timestamp in seconds. Use the exact string in the signed payload prefix.
X-Thinkpol-SignatureOne or more comma-separated signatures, formatted as v1=<hex_hmac_sha256>.
Verify a callback The HMAC key is the full whsec_... string, including the prefix.
import crypto from "node:crypto";

export function verifyThinkpolWebhook(headers, rawBody, secret) {
  const timestamp = String(headers["x-thinkpol-timestamp"] || "");
  const signature = String(headers["x-thinkpol-signature"] || "");
  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(ageSeconds) || ageSeconds > 300) return false;

  const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody);
  const signed = Buffer.concat([Buffer.from(`v1.${timestamp}.`), body]);
  const expected = Buffer.from(crypto.createHmac("sha256", secret).update(signed).digest("hex"), "hex");

  return signature.split(",").some((part) => {
    const value = part.trim();
    if (!/^v1=[0-9a-f]{64}$/i.test(value)) return false;
    const candidate = Buffer.from(value.slice(3), "hex");
    return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected);
  });
}
Analyze and quota

Check account state and generate profiles

Use quota for account state. Use analysis when the product needs a generated user profile with optional sources.

GET/quota

Returns the remaining API quota for the authenticated key as an integer.

GET/analyze/{username}

Returns a generated profile. Optional query parameters include model, latest, refresh, sources, and use_case=law_enforcement.

Errors

Handle the common failure modes

Swagger is the final source for response schemas and endpoint-specific status codes. These are the statuses integrations should handle globally.

StatusMeaningClient behavior
400Invalid parameter, malformed cursor, invalid matcher, or invalid webhook URL.Fix the request. Do not retry unchanged.
401Missing or invalid bearer token.Ask for a valid API key or refresh server configuration.
403The key is valid but not allowed to use the endpoint.Show an access message or contact support for stream access.
404Requested stream or resource was not found in your account.Stop polling that ID and refresh local state.
503Backing store or stream inbox is unavailable.Retry with backoff.
For exact request bodies, enums, examples, and try-it-out calls, use /antechamber/swagger.