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.
- Call
GET /v3/searchwith one or moreterms. The response contains hydratedsubmissionsandcommentsarrays that are ready to render in a results view. - Use
has_moreandafterto fetch the next search page. Do not build a client that tries to pull the complete archive in one run. - 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.
- When the same matcher should keep running, create a
POST /v1/streamsstream. 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()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.
| 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. |
Authentication
Send a bearer token on every request
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 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
Find posts and comments
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.
| Parameter | Required | Description |
|---|---|---|
| terms | Yes | One or more values. Repeating terms ANDs the terms together. |
| 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 exact contiguous text. 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. |
Response fields
submissions and comments contain the matched records. limit, returned_count, has_more, after, and order describe the page.
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"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
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.
| 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"))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 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.Users and content
Open only the records the user needs
Use these endpoints after search returns a username, submission ID, or comment ID.
Autocomplete usernames by prefix. Use no_metadata=true for a fast list of names.
Fetch Reddit profile metadata. Add fetch_socials=true only when external socials are needed.
List a user's top-level comments, replies, or submissions. These use limit, last, since, and optional include_total.
Hydrate up to 100 comma-separated submission IDs with bounded comment previews.
Fetch one post. By default, hydrate_comments=true attaches up to 100 recent comments.
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"))Subreddits
Add community context
Use subreddit endpoints to autocomplete communities, display metadata and activity, and page through observed subreddit authors.
Search subreddit names by prefix with subreddit, optional after, limit, and no_metadata=true.
Fetch a subreddit overview. Short-window activity is included by default. Set fetch_activity=true for monthly activity buckets and top authors.
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 are customer-owned live matchers. 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 streams for the API key. | { streams: [...] }. Includes 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. |
DELETE /v1/streams/{streamID} | Soft-delete an active stream. | 204. |
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 opaque base64url data over event_time and event_id. Do not parse, edit, or synthesize it on the client.
- First poll: call
GET /v1/streams/{streamID}/events?limit=100withoutafter. - Process events in the returned order. Inbox pages are oldest-to-newest by
event_time,event_id. - When the response contains events, store
next_afterafter the events are processed. - If
has_more=true, request the next page immediately withafter=<next_after>. - If
has_more=false, keep the stored cursor and poll again later with the sameafter. - If
eventsis empty,next_afteris 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.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.
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://customer.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
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://customer.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" }
}
]
}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.
- Read the raw request body bytes before JSON parsing. Do not verify re-serialized JSON.
- Use
X-Thinkpol-Webhook-Idto look up the stream's savedwhsec_...signing secret. - Reject missing, malformed, or stale
X-Thinkpol-Timestampvalues. The recommended freshness tolerance is 5 minutes. - Compute HMAC-SHA256 over
v1.<timestamp>.<raw_json_body>using the full secret string as the key. - Accept the request if one comma-separated
v1=...value inX-Thinkpol-Signaturematches in constant time. - After verification, parse JSON and confirm body
webhook_idequals 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://customer.example/webhooks/thinkpol"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.
Returns the remaining API quota for the authenticated key as an integer.
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.
| 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 for this customer. | Stop polling that ID and refresh local state. |
| 503 | Backing store or stream inbox is unavailable. | Retry with backoff. |