The Antechamber API is organized around three actions: search for evidence, hydrate details by ID or username, and keep watching with streams.
https://api.think-pol.comAuthorization: Bearer $THINKPOL_API_KEYJSON responses/antechamber/swaggerGET /v3/search with one or more terms. The response contains hydrated submissions and comments arrays that are ready to render in a results view.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.POST /v1/streams stream. Poll the stream inbox first; add a public URL only if you need push delivery.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"const url = new URL("https://api.think-pol.com/v3/search");
url.searchParams.set("terms", "acme");
url.searchParams.set("mode", "word");
url.searchParams.set("type", "comment");
url.searchParams.set("order", "desc");
const page = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.THINKPOL_API_KEY}` }
}).then((response) => response.json());
console.log(page.comments.length, page.has_more, page.after);import json, os, urllib.parse, urllib.request
query = urllib.parse.urlencode({
"terms": "acme",
"mode": "word",
"type": "comment",
"order": "desc",
})
request = urllib.request.Request(
f"https://api.think-pol.com/v3/search?{query}",
headers={"Authorization": f"Bearer {os.environ['THINKPOL_API_KEY']}"},
)
with urllib.request.urlopen(request, timeout=30) as response:
page = json.loads(response.read())
print(len(page["comments"]), page["has_more"], page.get("after"))req, _ := http.NewRequest("GET", "https://api.think-pol.com/v3/search?terms=acme&mode=word&type=comment&order=desc", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("THINKPOL_API_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()The API is resource-oriented, but the best entry point depends on what the user is doing.
/v3/search returns two arrays: submissions and comments. Each item includes Reddit IDs, author, subreddit, text/title fields, timestamps, and URLs where available.
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 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.
| Task | Use | Notes |
|---|---|---|
| Find evidence | GET /v3/search | Supports multiple terms, word or phrase mode, optional time window, and cursor pagination. |
| Chart volume | GET /v3/search/popularity | Accepts exactly one single-token terms value. It returns counts, not evidence records. |
| Open a result | v2 user, post, comment, and subreddit routes | Use IDs and usernames returned from search. Most list endpoints are timestamp-paginated. |
| Monitor future matches | /v1/streams | Add url for signed push delivery; set inbox_enabled:true for inbox polling or inbox-only streams. |
All documented Antechamber routes require a valid API key in the Authorization header.
curl -sS "https://api.think-pol.com/quota" \
-H "Authorization: Bearer $THINKPOL_API_KEY"const quota = await fetch("https://api.think-pol.com/quota", {
headers: { Authorization: `Bearer ${process.env.THINKPOL_API_KEY}` }
}).then((response) => response.json());request = urllib.request.Request(
"https://api.think-pol.com/quota",
headers={"Authorization": f"Bearer {os.environ['THINKPOL_API_KEY']}"},
)req, _ := http.NewRequest("GET", "https://api.think-pol.com/quota", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("THINKPOL_API_KEY"))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.
GET /quota returns the remaining API quota for the authenticated key as a JSON integer.
Search is the primary discovery endpoint. It searches submissions and comments, returns hydrated result arrays, and supports cursor-based pagination.
/v3/searchUse this endpoint for search result pages, investigative queries, and drill-down workflows that start from a term or phrase.
| Parameter | Required | Description |
|---|---|---|
| terms | Yes | One 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. |
| from | No | Unix timestamp lower bound. Defaults to no lower bound. |
| to | No | Unix timestamp upper bound. Defaults to current time. |
| type | No | comment or submission. Omit to search both. |
| mode | No | word matches tokens. phrase requires each terms value to match as contiguous text, ignoring case. Default is word. |
| order | No | desc or asc by created_utc. Default is desc. |
| after | No | Cursor timestamp from the previous page. In desc order it asks for older results; in asc order it asks for newer results. |
| limit | No | Maximum 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. |
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.
{
"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.
{
"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.
{
"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.
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%20energyafter 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"async function searchPage(after) {
const url = new URL("https://api.think-pol.com/v3/search");
url.searchParams.set("terms", "acme");
url.searchParams.set("order", "desc");
if (after) url.searchParams.set("after", after);
return fetch(url, {
headers: { Authorization: `Bearer ${process.env.THINKPOL_API_KEY}` }
}).then((response) => response.json());
}
const first = await searchPage();
const second = first.has_more ? await searchPage(first.after) : null;def search_page(after=None):
params = {"terms": "acme", "order": "desc"}
if after:
params["after"] = str(after)
request = urllib.request.Request(
"https://api.think-pol.com/v3/search?" + urllib.parse.urlencode(params),
headers={"Authorization": f"Bearer {os.environ['THINKPOL_API_KEY']}"},
)
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read())
first = search_page()
second = search_page(first["after"]) if first["has_more"] else Nonefunc searchURL(after string) string {
u, _ := url.Parse("https://api.think-pol.com/v3/search")
q := u.Query()
q.Set("terms", "acme")
q.Set("order", "desc")
if after != "" {
q.Set("after", after)
}
u.RawQuery = q.Encode()
return u.String()
}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.
/v3/search/popularity| Parameter | Required | Description |
|---|---|---|
| terms | Yes | Exactly one non-empty single-token term. |
| resolution | No | all, year, month, day, or hour. Default is month. |
| from | No | Unix timestamp start. Must be provided together with to. |
| to | No | Unix timestamp end. Must be provided together with from. |
| buckets | No | Positive bucket count when from and to are omitted. |
points and optional total, not submission or comment records.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"const popularity = await fetch("https://api.think-pol.com/v3/search/popularity?terms=acme&resolution=month&buckets=24", {
headers: { Authorization: `Bearer ${process.env.THINKPOL_API_KEY}` }
}).then((response) => response.json());request = urllib.request.Request(
"https://api.think-pol.com/v3/search/popularity?terms=acme&resolution=month&buckets=24",
headers={"Authorization": f"Bearer {os.environ['THINKPOL_API_KEY']}"},
)req, _ := http.NewRequest("GET", "https://api.think-pol.com/v3/search/popularity?terms=acme&resolution=month&buckets=24", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("THINKPOL_API_KEY"))Search, content lists, prefix searches, and stream inboxes all paginate, but they do not use the same cursor field.
| Endpoint family | Cursor request | Cursor response | Order |
|---|---|---|---|
/v3/search | after | after, has_more | created_utc, default newest first |
| User and content lists | last or since | last, has_more | Newest first by created_utc |
| User/subreddit prefix search | after | Next cursor is the last returned name | Lexicographic name order |
| Stream inbox | after | next_after, has_more | Ascending event_time,event_id |
include_total=true only when the UI needs a count. Otherwise rely on has_more.Use these endpoints after search returns a username, submission ID, or comment ID.
/v2/user/searchAutocomplete usernames by prefix. Use no_metadata=true for a fast list of names.
/v2/user/{username}Fetch Reddit profile metadata. Add fetch_socials=true only when external socials are needed.
/v2/user/{username}/comments/v2/user/{username}/subcomments/v2/user/{username}/postsList a user's top-level comments, replies, or submissions. These use limit, last, since, and optional include_total.
/v2/postsHydrate up to 100 comma-separated submission IDs with bounded comment previews.
/v2/posts/{id}Fetch one post. By default, hydrate_comments=true attaches up to 100 recent comments.
/v2/posts/{id}/comments/v2/comments/{id}/v2/subcomments/{id}Fetch a post's comments, one comment, or replies to a comment. Comment/reply lists use timestamp pagination.
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"const first = await fetch("https://api.think-pol.com/v2/posts/abc123/comments?limit=100", {
headers: { Authorization: `Bearer ${process.env.THINKPOL_API_KEY}` }
}).then((response) => response.json());
const older = first.has_more
? await fetch(`https://api.think-pol.com/v2/posts/abc123/comments?limit=100&last=${first.last}`, {
headers: { Authorization: `Bearer ${process.env.THINKPOL_API_KEY}` }
}).then((response) => response.json())
: null;request = urllib.request.Request(
"https://api.think-pol.com/v2/posts/abc123/comments?limit=100",
headers={"Authorization": f"Bearer {os.environ['THINKPOL_API_KEY']}"},
)req, _ := http.NewRequest("GET", "https://api.think-pol.com/v2/posts/abc123/comments?limit=100", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("THINKPOL_API_KEY"))Use subreddit endpoints to autocomplete communities, display metadata and activity, and page through observed subreddit authors.
/v2/subreddit/searchSearch subreddit names by prefix with subreddit, optional after, limit, and no_metadata=true.
/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.
/v2/subreddit/{subreddit_name}/usersPage through observed subreddit authors with limit and string after. The response uses { items, limit, has_more, after }.
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.
| Endpoint | Use | Response |
|---|---|---|
GET /v1/streams | List active and paused streams in your account. | { streams: [...] }. Includes paused, selector, and selector_count, but not signing secrets. |
POST /v1/streams | Create 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}/selectors | Page through a stream's active selectors. | { selectors, next_after, has_more }. |
POST /v1/streams/{streamID}/selectors | Add selectors to an existing stream. | Updated stream with selector_count. |
DELETE /v1/streams/{streamID}/selectors | Remove selectors from an existing stream. | Updated stream. Removing the last selector returns 400. |
GET /v1/streams/{streamID}/events | Poll the 30-day inbox. | { events, next_after, has_more }. |
POST /v1/streams/{streamID}/rotate-secret | Rotate a URL-backed stream secret. | New signing_secret. Old secret stops working immediately. |
POST /v1/streams/{streamID}/pause | Pause collection for one stream. No request body. | 202 with the saved stream configuration and paused:true. |
POST /v1/streams/{streamID}/resume | Resume 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 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.
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.
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.
GET /v1/streams/{streamID}/events?limit=100 without after.event_time,event_id.next_after after the events are processed.has_more=true, request the next page immediately with after=<next_after>.has_more=false, keep the stored cursor and poll again later with the same after.events is empty, next_after is omitted. Keep your previous stored cursor.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.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_containsMatches selectors as case-insensitive substrings in normalized comment text and submission title/text. Omit field.
field_equalsMatches selectors as case-insensitive exact field values. field must be author, subreddit, or kind.
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.
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.
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"const stream = await fetch("https://api.think-pol.com/v1/streams", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.THINKPOL_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ type: "text_contains", selectors: ["acme breach", "acme leak"], inbox_enabled: true })
}).then((response) => response.json());
const pushStream = await fetch("https://api.think-pol.com/v1/streams", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.THINKPOL_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
type: "text_contains",
selectors: ["acme breach", "acme leak"],
url: "https://your-app.example/webhooks/thinkpol",
batch_max_size: 10,
batch_max_delay_seconds: 5
})
}).then((response) => response.json());
await fetch(`https://api.think-pol.com/v1/streams/${stream.id}/selectors`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.THINKPOL_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ selectors: ["acme lawsuit", "acme investigation"] })
});
let after = loadStoredCursor(stream.id);
for (;;) {
const url = new URL(`https://api.think-pol.com/v1/streams/${stream.id}/events`);
url.searchParams.set("limit", "100");
if (after) url.searchParams.set("after", after);
const page = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.THINKPOL_API_KEY}` }
}).then((response) => response.json());
for (const event of page.events) await processEvent(event);
if (page.next_after) {
after = page.next_after;
saveStoredCursor(stream.id, after);
}
if (!page.has_more) break;
}import json
import os
import urllib.parse
import urllib.request
def poll_stream(stream_id, after=None):
query = {"limit": "100"}
if after:
query["after"] = after
url = f"https://api.think-pol.com/v1/streams/{stream_id}/events?{urllib.parse.urlencode(query)}"
request = urllib.request.Request(url, headers={"Authorization": f"Bearer {os.environ['THINKPOL_API_KEY']}"})
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read())
stream_id = "7b98c8c8-1111-4222-8333-123456789abc"
after = load_stored_cursor(stream_id)
while True:
page = poll_stream(stream_id, after)
for event in page["events"]:
process_event(event)
if page.get("next_after"):
after = page["next_after"]
save_stored_cursor(stream_id, after)
if not page["has_more"]:
breaktype inboxPage struct {
Events []json.RawMessage `json:"events"`
NextAfter string `json:"next_after"`
HasMore bool `json:"has_more"`
}
streamID := "7b98c8c8-1111-4222-8333-123456789abc"
after := loadStoredCursor(streamID)
for {
u, _ := url.Parse("https://api.think-pol.com/v1/streams/" + streamID + "/events")
q := u.Query()
q.Set("limit", "100")
if after != "" {
q.Set("after", after)
}
u.RawQuery = q.Encode()
req, _ := http.NewRequest("GET", u.String(), nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("THINKPOL_API_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
var page inboxPage
if err := json.NewDecoder(resp.Body).Decode(&page); err != nil {
resp.Body.Close()
panic(err)
}
resp.Body.Close()
for _, event := range page.Events {
processEvent(event)
}
if page.NextAfter != "" {
after = page.NextAfter
saveStoredCursor(streamID, after)
}
if !page.HasMore {
break
}
}{
"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_id": "stream_123",
"stream_id": "stream_123",
"delivered_at": "2026-05-20T12:00:00Z",
"events": [
{
"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_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" }
}
]
}URL-backed streams send signed batched deliveries. Verify the exact raw request body with the stream signing secret returned at create or rotate time.
X-Thinkpol-Webhook-Id to look up the stream's saved whsec_... signing secret.X-Thinkpol-Timestamp values. The recommended freshness tolerance is 5 minutes.v1.<timestamp>.<raw_json_body> using the full secret string as the key.v1=... value in X-Thinkpol-Signature matches in constant time.webhook_id equals the header stream id.| Header | Description |
|---|---|
| X-Thinkpol-Webhook-Id | Stream id associated with the delivery. |
| X-Thinkpol-Timestamp | Unix timestamp in seconds. Use the exact string in the signed payload prefix. |
| X-Thinkpol-Signature | One or more comma-separated signatures, formatted as v1=<hex_hmac_sha256>. |
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);
});
}import hashlib, hmac, time
def verify_thinkpol_webhook(headers, raw_body, secret):
timestamp = headers.get("X-Thinkpol-Timestamp", "")
try:
if abs(time.time() - int(timestamp)) > 300:
return False
except ValueError:
return False
signed = b"v1." + timestamp.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
for part in headers.get("X-Thinkpol-Signature", "").split(","):
value = part.strip()
if value.startswith("v1=") and hmac.compare_digest(value[3:].lower(), expected):
return True
return Falsefunc verifyThinkpolWebhook(header http.Header, rawBody []byte, secret string) bool {
timestamp := header.Get("X-Thinkpol-Timestamp")
seconds, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || math.Abs(time.Since(time.Unix(seconds, 0)).Seconds()) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte("v1." + timestamp + "."))
mac.Write(rawBody)
expected := mac.Sum(nil)
for _, part := range strings.Split(header.Get("X-Thinkpol-Signature"), ",") {
value := strings.TrimSpace(part)
if strings.HasPrefix(value, "v1=") {
candidate, err := hex.DecodeString(strings.TrimPrefix(value, "v1="))
if err == nil && hmac.Equal(candidate, expected) {
return true
}
}
}
return false
}# curl can inspect a delivery during testing, but signature verification belongs in server code.
curl -i "https://your-app.example/webhooks/thinkpol"Use quota for account state. Use analysis when the product needs a generated user profile with optional sources.
/quotaReturns the remaining API quota for the authenticated key as an integer.
/analyze/{username}Returns a generated profile. Optional query parameters include model, latest, refresh, sources, and use_case=law_enforcement.
Swagger is the final source for response schemas and endpoint-specific status codes. These are the statuses integrations should handle globally.
| Status | Meaning | Client behavior |
|---|---|---|
| 400 | Invalid parameter, malformed cursor, invalid matcher, or invalid webhook URL. | Fix the request. Do not retry unchanged. |
| 401 | Missing or invalid bearer token. | Ask for a valid API key or refresh server configuration. |
| 403 | The key is valid but not allowed to use the endpoint. | Show an access message or contact support for stream access. |
| 404 | Requested stream or resource was not found in your account. | Stop polling that ID and refresh local state. |
| 503 | Backing store or stream inbox is unavailable. | Retry with backoff. |