# 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`](https://onekey.dev/docs/errors.md#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`](https://onekey.dev/docs/errors.md#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

| Step | Call | Cost |
| --- | --- | --- |
| 1 | `POST /v1/cohorts` | free |
| 2 | `PUT /v1/cohorts/{id}/members` (repeat per 1,000) | free |
| 3 | `POST /v1/cohorts/{id}/queries` → `202` | per leg, like any call |
| 4 | `GET /v1/cohort-queries/{id}`, then `.../results` | free |

`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`](https://onekey.dev/docs/errors.md#idempotency-key-reused).

### 1. Create

```bash
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`](https://onekey.dev/docs/errors.md#cohort-member-limit)).

```bash
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_id`s cannot claim the same platform and handle in one cohort: [`COHORT_IDENTITY_CONFLICT`](https://onekey.dev/docs/errors.md#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`](https://onekey.dev/docs/errors.md#cohort-limit-reached)).

### 3. Submit a query

```bash
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}'
```

| Field | Description |
| --- | --- |
| `keywords` | 1 to 20 terms of 2 to 80 characters. Matching is literal, whole-word and case-insensitive, with no stemming or expansion. |
| `date_from` | A full RFC 3339 timestamp (not a bare date). Older posts are ignored. |
| `max_pages_per_identity` | Required (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_identity` | Required (1 to 1,000). Matches kept per member. |
| `max_credits` | Required. Your own safety limit: if the worst-case price is above it the call is refused with [`COHORT_BUDGET_EXCEEDED`](https://onekey.dev/docs/errors.md#cohort-budget-exceeded) before anything runs. |
| `platforms` | Optional 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`](https://onekey.dev/docs/errors.md#insufficient-credits) or [`KEY_BUDGET_EXCEEDED`](https://onekey.dev/docs/errors.md#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`](https://onekey.dev/docs/errors.md#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`](https://onekey.dev/docs/search-multi.md)) 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`](https://onekey.dev/docs/errors.md#cohort-not-found), [`COHORT_QUERY_NOT_FOUND`](https://onekey.dev/docs/errors.md#cohort-query-not-found), [`COHORT_LIMIT_REACHED`](https://onekey.dev/docs/errors.md#cohort-limit-reached), [`COHORT_MEMBER_LIMIT`](https://onekey.dev/docs/errors.md#cohort-member-limit), [`COHORT_IDENTITY_CONFLICT`](https://onekey.dev/docs/errors.md#cohort-identity-conflict), [`COHORT_IDENTITY_PLATFORM_UNSUPPORTED`](https://onekey.dev/docs/errors.md#cohort-identity-platform-unsupported), [`COHORT_BUDGET_EXCEEDED`](https://onekey.dev/docs/errors.md#cohort-budget-exceeded), [`COHORT_QUERY_NOT_READY`](https://onekey.dev/docs/errors.md#cohort-query-not-ready) and [`INVALID_COHORT`](https://onekey.dev/docs/errors.md#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.

## Related

- [Reports](https://onekey.dev/docs/reports.md) and [More reports](https://onekey.dev/docs/reports/composites.md) for one-off questions about a brand, a creator or a topic.
- [Batch](https://onekey.dev/docs/batch.md) and [Background jobs](https://onekey.dev/docs/jobs.md) for lists of arbitrary calls.
- [Monitors](https://onekey.dev/docs/monitors.md) to be told when numbers move.
