Docs
View as Markdown

Cohorts

A cohort is a saved list of public accounts across platforms that you already decided matter: a creator roster, a panel, a customer list. You ask two kinds of question of it:

  • Who of these posted about my keywords lately? A query over the members' recent posts, run in the background, with a coverage record for every member.
  • What do these accounts look like? Followers, verified counts and coverage over a sample of the members, and the same for two cohorts side by side.

Cohorts is not audience discovery. It does not find people, infer demographics or score interests. It takes a list you already have and reports what those public accounts show.

What is stored#

Only three things per member: platform, handle (the public identity, lower-cased, with a leading @ removed; a profile URL is reduced to its last path segment) and, optionally, your own external_id, an opaque key echoed back so you can join results to your records. Anything else in a member object (an age, a segment, a purchase) is refused with INVALID_COHORT: there is no field to put it in.

A cohort and everything under it (members, queries, results) is deleted when its retention (retention_days, 7 to 90, default 30) elapses. Adding members or submitting a query renews the clock. DELETE removes everything immediately.

Identities are stored in the account's database tables, scoped to your account and never returned to another one. They are not encrypted with a per-account key; treat the list as you would any customer list you upload.

Supported platforms#

Platforms with a recent-posts feed: bluesky, devto, hackernews, instagram, lobsters, reddit, threads, twitch, x. Anything else is refused at upload with COHORT_IDENTITY_PLATFORM_UNSUPPORTED (400), so an unsupported identity can never cost you a query that returns nothing. GET /v1/cohorts lists the supported platforms.

The lifecycle#

StepCallCost
1POST /v1/cohortsfree
2PUT /v1/cohorts/{id}/members (repeat per 1,000)free
3POST /v1/cohorts/{id}/queries → 202per leg, like any call
4GET /v1/cohort-queries/{id}, then .../resultsfree

POST and PUT take an optional Idempotency-Key (a UUID): the same key and body replays the first answer (X-Idempotent-Replay: true), the same key with another body is IDEMPOTENCY_KEY_REUSED.

1. Create#

shell
curl -X POST "https://api.onekey.dev/v1/cohorts" \
  -H "x-api-key: YOUR_API_KEY" -H "content-type: application/json" \
  -d '{"name":"August panel","retention_days":30}'

2. Add members#

Up to 1,000 per call, 10,000 per cohort (COHORT_MEMBER_LIMIT).

shell
curl -X PUT "https://api.onekey.dev/v1/cohorts/{id}/members" \
  -H "x-api-key: YOUR_API_KEY" -H "content-type: application/json" \
  -d '{"members":[
        {"external_id":"buyer_01983","platform":"bluesky","handle":"ada.bsky.social"},
        {"external_id":"buyer_04711","platform":"reddit","handle":"spez"}]}'

The answer reports inserted, updated, unchanged and member_count. Re-sending an external_id you already uploaded updates its identity instead of adding a row, so a nightly full re-push is safe. Two different external_ids cannot claim the same platform and handle in one cohort: COHORT_IDENTITY_CONFLICT.

Related calls: GET /v1/cohorts/{id} (counts and metadata, no identities), GET /v1/cohorts/{id}/members (your list back, paged with cursor, optional platform filter), DELETE /v1/cohorts/{id}/members (body {"members":[{"platform","handle"} | {"external_id"}]}), GET /v1/cohorts (up to 50 cohorts per account, COHORT_LIMIT_REACHED).

3. Submit a query#

shell
curl -X POST "https://api.onekey.dev/v1/cohorts/{id}/queries" \
  -H "x-api-key: YOUR_API_KEY" -H "content-type: application/json" \
  -d '{"keywords":["acme","acme pro"],"date_from":"2026-08-01T00:00:00Z",
       "max_pages_per_identity":3,"max_items_per_identity":100,"max_credits":50}'
FieldDescription
keywords1 to 20 terms of 2 to 80 characters. Matching is literal, whole-word and case-insensitive, with no stemming or expansion.
date_fromA full RFC 3339 timestamp (not a bare date). Older posts are ignored.
max_pages_per_identityRequired (1 to 10). Accepted for compatibility: one page of each member's recent posts is read, so values above 1 do not fetch more.
max_items_per_identityRequired (1 to 1,000). Matches kept per member.
max_creditsRequired. Your own safety limit: if the worst-case price is above it the call is refused with COHORT_BUDGET_EXCEEDED before anything runs.
platformsOptional subset of the platforms present in the cohort.

No cap has a default. The answer is 202 with the query_id, status, member_count, estimated_credits (list price of every leg), reserved_credits (the worst case it was checked against), status_url and result_url. A query that cannot be paid for is refused first with INSUFFICIENT_CREDITS or KEY_BUDGET_EXCEEDED; nothing is held or run.

Each member is one call to the platform's recent-posts feed through the ordinary pipeline, so caching, refunds on failure, the per-key budget and the request log are those of a normal call. Cache hits are free; a member whose feed fails or is not found is refunded.

4. Poll, then read the results#

GET /v1/cohort-queries/{id} returns status (queued, running, succeeded, failed), progress (members_completed, pages_succeeded, pages_failed, matches), credits_charged and, on a failure, error. A query that was running when the server restarted is reported failed with error.type: INTERRUPTED; legs already run were settled and billed, so submit it again.

GET /v1/cohort-queries/{id}/results answers once the query has succeeded (otherwise COHORT_QUERY_NOT_READY). Page with limit (1 to 500 members, default 100) and next_cursor; a page never exceeds 1 MB and may be shorter than limit for that reason.

json
{"items": [{"external_id": "buyer_01983", "platform": "bluesky", "content_id": "3k...", "canonical_url": "https://bsky.app/...",
            "published_at": "2026-09-10T00:00:00Z", "text_excerpt": "Trying acme pro today",
            "matched_keywords": ["acme", "acme pro"], "route": "bluesky.posts", "retrieved_at": "2026-09-11T08:00:00Z"}],
 "coverage": [{"external_id": "buyer_01983", "platform": "bluesky", "handle": "ada.bsky.social", "status": "complete", "pages": 1,
               "oldest_seen": "2026-06-01T00:00:00Z", "window_complete": true, "route_errors": [], "posts_seen": 20, "undated_skipped": 0}],
 "next_cursor": null}

Coverage is the important field#

coverage has one record per member, matched or not, so a partial read never looks like "nobody talked about you".

  • window_complete: true: the page reached your date_from (oldest_seen is at or before it), or the feed was empty. Everything in the window is in items.
  • window_complete: false: the page did not reach date_from, so there may be older matching posts in the window that were not read.
  • status: complete, not_found (the handle does not resolve) or failed (route_errors names the error type).
  • undated_skipped: posts with no date cannot be placed in the window and are not matched.

DELETE /v1/cohort-queries/{id} cancels a running query (legs already settled stay billed) and deletes its results. Queries and results are kept as long as the cohort's retention.

Stats and compare#

GET /v1/cohorts/{id}/stats runs the profile endpoint of each sampled member (the same legs as /v1/creator/card) and aggregates: status counts, followers (total, mean, median, min, max, and known_on: how many members' platforms reported one), verified counts, by_platform and top_by_followers. The sample is the first limit members (1 to 100, default 25) by when they were added, optionally one platform. A member whose platform has no profile endpoint shows no_profile_endpoint. Every figure is a platform field summed or ranked; nothing is scored or inferred about a member.

GET /v1/cohorts/compare?a={id}&b={id} does the same for two cohorts and adds difference (b minus a, null where a side has no figure). Both are billed as the profile legs they run, with the usual worst-case check, Idempotency-Key and dry_run=true (the legs and their worst case for 0 credits).

Errors#

COHORT_NOT_FOUND, COHORT_QUERY_NOT_FOUND, COHORT_LIMIT_REACHED, COHORT_MEMBER_LIMIT, COHORT_IDENTITY_CONFLICT, COHORT_IDENTITY_PLATFORM_UNSUPPORTED, COHORT_BUDGET_EXCEEDED, COHORT_QUERY_NOT_READY and INVALID_COHORT. None of them is charged.

Limits#

  • 10,000 members per cohort, 1,000 per call, 50 cohorts per account.
  • A query reads one page of each member's feed, not their history.
  • No per-account encryption of identities and no audience discovery.