# WaveSurge API reference

Instagram raw search, profiles, posts and pagination for caller-side screening, plus the project, Campaign and outreach APIs.

API v1 · 2026-10-10

## API conventions

1. Every endpoint lives under https://wavesurge.ai/api/v1 (for example https://wavesurge.ai/api/v1/auth/me).
2. Credential: your signed-in session token. Send it as the header Cookie: kolagent_token=$WAVESURGE_TOKEN; every user endpoint accepts this form. Most endpoints also accept Authorization: Bearer <token>, but the chat endpoints (/codex-chat/*) accept only the Cookie form.
3. Paid actions take two steps: request a quote card (it returns credits and is valid for 15 minutes), show the credits to a human and get an explicit yes, then confirm.
4. All prices are in credits. The real price of a paid action is the credits on its quote card; never compute it yourself.
5. Instagram search, profile, post-list and post-detail endpoints return raw results synchronously; the caller deduplicates, screens and analyses them.
6. Long-running work is asynchronous: take the returned id and poll with GET or subscribe to SSE. A GET only reads; it never advances a task.
7. Every POST that writes takes a caller-generated idempotency key (commandId as a UUID, requestId as any stable string). Reuse the same value on retry.
8. Outreach really contacts creators. Endpoints that move real money (funding, payouts) are for reading only — never execute them.

## Authentication: getting a token

1. Sign in to https://wavesurge.ai in your browser.
2. Open developer tools → Application → Cookies → wavesurge.ai and copy the value of kolagent_token.
3. Set it as the environment variable WAVESURGE_TOKEN for your agent. The token is valid for 30 days and is equivalent to your login — never paste it into chats, logs or repositories. Copy it again after signing out.

Check that the token works:

```bash
export WAVESURGE_TOKEN='<value of kolagent_token>'
curl -s https://wavesurge.ai/api/v1/auth/me -H "Cookie: kolagent_token=$WAVESURGE_TOKEN"
# => {"user":{"id":"...","email":"...","name":"...","role":"user"}}
```

Response: GET /auth/me:

```json
{
  "user": {
    "id": "4c865faf-0000-4000-8000-000000000007",
    "email": "you@example.com",
    "name": "Your Name",
    "avatarUrl": "https://…",
    "role": "user"
  }
}
```

| Response | Meaning |
| --- | --- |
| 401 Authentication required / Invalid token | The token is missing, expired or truncated — sign in again and copy it again |
| 401 A signed-in browser session is required. | A chat endpoint did not receive the token in the Cookie form |
| 403 | Signed in, but not a member of this project / Campaign |

## Errors, idempotency and pagination

- Instagram raw search keeps each version's raw response and the upstream error body, and has its own response, error and pagination rules. The formats below apply to the business endpoints.
- Business errors: { "error": "<readable sentence>", "category": "user|transient|env|internal", "code": "<stable machine code>" }. category=user: fix the input or supply what is missing; transient: back off and retry; anything else: hand it to a human. Branch on code, never on the error sentence.
- Payment endpoints use an envelope: success { data, asOf, ... }, failure { error: { code, retryable, userAction, copy } }.
- Request bodies are always application/json, up to 10 MB; many endpoints reject unknown fields (400).
- Pagination is per endpoint: the credit ledger uses limit + cursor and returns nextCursor; the creator pool uses limit; the Campaign list uses page / limit.

| Status | Meaning | What to do |
| --- | --- | --- |
| 400 | Invalid input | Fix the input according to code |
| 401 | Not signed in, or invalid token | Get a fresh token; chat endpoints need the Cookie form |
| 402 | Not enough credits | Do not retry; read GET /credits/pending-work and tell the human the shortfall |
| 403 | No permission | Ask the project Owner to invite you as a Manager |
| 404 | Does not exist, or is not yours | Check the id and the account |
| 409 | State conflict, confirmation required, or idempotency-key conflict | Re-read the current state before deciding; never retry with a new key to force it. creator_search_questions_open: the sentence names the brief facts still missing — save them (chat or PATCH /brief) and request the quote again |
| 412 | A precondition is missing (e.g. no contactable creators) | Tell the human what is missing; these checks run before any charge |
| 429 | Rate limited | Back off and retry |

Error example: 404 (the common business-error format):

```json
{
  "error": "No crawl request with that id.",
  "category": "user",
  "code": "crawl_request_not_found"
}
```

Error example: 402 not enough credits:

```json
{
  "error": "insufficient_credits",
  "balance": 120,
  "required": 312,
  "actionLabel": "Reserve creator search credits"
}
```

Error example: 409 quote card no longer valid:

```json
{
  "error": "This action is not available yet. Review the current task or requirements.",
  "category": "user",
  "code": "codex_confirmation_invalid"
}
```

Error example: 409 the creator-search brief is not complete enough to quote:

```json
{
  "error": "Ask the customer first: the budget for creator fees; where the creators' followers should be; which language the creators post in.",
  "category": "user",
  "code": "creator_search_questions_open"
}
```

## Instagram raw user search API

### Overview

Searches Instagram users by keyword and returns the raw results synchronously. The API does not verify profiles, apply filters, or deliver a curated list; the caller is responsible for deduplication, filtering and verification. The HTTP status code, response body and Content-Type are returned as-is, with no response wrapper. A search result on its own does not prove that an account meets any criteria such as follower count, region, category or contact information.

| Environment | Base URL |
| --- | --- |
| Production | https://wavesurge.ai |
| Shared test | https://test.wavesurge.ai |

All endpoints use the `GET` method. Query parameters are UTF-8 URL-encoded, and each parameter accepts exactly one string value. The curl examples use `--get --data-urlencode` to encode parameters into the URL; a GET request body is not read as query parameters. Do not pass the same parameter in both the URL and the request body.

### Authentication

Available to every signed-in user. Use either of the following:

| Method | Request header |
| --- | --- |
| Bearer JWT | Authorization: Bearer <login JWT> |
| Login cookie | Cookie: kolagent_token=<login JWT> |

Only a WaveSurge login session credential is required.

### Endpoints

Endpoint status (verified 2026-11-06): V1, V2 and V3 all answer HTTP 200. Any of them can be used for keyword user search; to try pagination, use V3, and request the next page only when the response returns a non-empty rank_token. Upstream availability can change; each response status code and error body are always returned as-is.

| Endpoint | Path | Search parameter | User list location | Pagination parameter |
| --- | --- | --- | --- | --- |
| V1 | /api/v1/instagram/v1/fetch_search | query | data.users[].user | None |
| V2 | /api/v1/instagram/v2/search_users | keyword | data.data.items[] | None |
| V3 | /api/v1/instagram/v3/search_users | query | data.users[] | rank_token |

The response examples below show only the main fields. Actual responses contain every field returned by the upstream; the number of results and the optional fields vary with the search results.

### V1 user search

`GET /api/v1/instagram/v1/fetch_search`

Status: available again (verified 2026-11-06; it was unavailable from 2026-10-10). The response also carries `data.has_more` and `data.page_token`; this endpoint takes no pagination parameter.

### Request parameters

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| query | query | string | Yes | Search keyword, e.g. instagram |
| select | query | string | No | Search type: users, hashtags or places. Omit to return all types. This parameter does not accept natural-language filter criteria |

### Request example

Request example:

```bash
curl --get 'https://wavesurge.ai/api/v1/instagram/v1/fetch_search' \
  --header "Cookie: kolagent_token=$WAVESURGE_TOKEN" \
  --data-urlencode 'query=instagram' \
  --data-urlencode 'select=users'
```

### Response example

Response example:

```json
{
  "code": 200,
  "data": {
    "status": "ok",
    "users": [
      {
        "position": 0,
        "user": {
          "username": "instagram",
          "full_name": "Instagram",
          "pk": "25025320",
          "id": "25025320",
          "is_verified": true
        }
      }
    ]
  }
}
```

User objects are at `data.users[].user`. This endpoint has no pagination parameter.

### V2 user search

`GET /api/v1/instagram/v2/search_users`

### Request parameters

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| keyword | query | string | Yes | Search keyword, e.g. instagram |

### Request example

Request example:

```bash
curl --get 'https://wavesurge.ai/api/v1/instagram/v2/search_users' \
  --header "Cookie: kolagent_token=$WAVESURGE_TOKEN" \
  --data-urlencode 'keyword=instagram'
```

### Response example

Response example:

```json
{
  "code": 200,
  "data": {
    "data": {
      "count": 1,
      "items": [
        {
          "username": "instagram",
          "full_name": "Instagram",
          "id": "25025320",
          "is_verified": true,
          "is_private": false
        }
      ]
    }
  }
}
```

User objects are at `data.data.items[]`; `data.data.count` is the number returned. This endpoint has no pagination parameter.

### V3 user search

`GET /api/v1/instagram/v3/search_users`

### Request parameters

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| query | query | string | Yes | Search keyword, e.g. instagram |
| rank_token | query | string | No | Non-empty token returned by the previous response; omit on the first request |

### Request example

Request example:

```bash
curl --get 'https://wavesurge.ai/api/v1/instagram/v3/search_users' \
  --header "Cookie: kolagent_token=$WAVESURGE_TOKEN" \
  --data-urlencode 'query=instagram'
```

### Response example

Response example:

```json
{
  "code": 200,
  "data": {
    "count": 1,
    "rank_token": null,
    "users": [
      {
        "username": "instagram",
        "full_name": "Instagram",
        "pk": "25025320",
        "id": "25025320",
        "is_verified": true,
        "is_private": false
      }
    ]
  }
}
```

User objects are at `data.users[]`. The response may also contain the same list as `data.items`; use only one of the two lists to avoid double counting.

### Pagination

`rank_token` is forwarded to the upstream unchanged. Only when the response returns a non-empty `data.rank_token`, send the follow-up request with the same `query` and that token. When the token is `null`, an empty string or absent, no pagination token is available. The API provides no `page`, `page_size` or `offset` parameter.

Pagination:

```bash
export RANK_TOKEN='<non-empty data.rank_token from the previous response>'
curl --get 'https://wavesurge.ai/api/v1/instagram/v3/search_users' \
  --header "Cookie: kolagent_token=$WAVESURGE_TOKEN" \
  --data-urlencode 'query=instagram' \
  --data-urlencode "rank_token=$RANK_TOKEN"
```

Whether a pagination token is available depends on the response; continuous pagination is not guaranteed for every keyword.

### Response fields

### Common fields

| Field | Type | Description |
| --- | --- | --- |
| code | integer | Business status code |
| data | object | Search results; see each version's response example for the nested structure |
| request_id | string | Upstream request identifier; present only when the upstream returns it |

### User fields

| Field | Type | Description |
| --- | --- | --- |
| username | string | Instagram username |
| full_name | string | User display name |
| id | string | User ID; store it as a string |
| pk | string | User primary key; returned by some versions |
| is_verified | boolean | Whether the account is verified |
| is_private | boolean | Whether the account is private; returned by some versions |
| profile_pic_url | string | Profile picture URL; may expire |

These are common fields, not a fixed field allowlist. When the upstream adds, omits or changes optional fields, the API preserves the raw response.

### Error responses

| HTTP status | Source | Description |
| --- | --- | --- |
| 400 | This API | Duplicate query parameter, or an array or object was passed |
| 401 | This API | Login credential is missing or invalid, or the corresponding user does not exist |
| 502 | This API | Could not obtain an upstream response |
| 503 | This API | Upstream service credential is not configured |
| 504 | This API | Upstream request timed out |
| Other upstream status codes | Upstream | Upstream status code and error body are preserved, e.g. parameter errors, rate limiting or service failures |

The upstream may also return the same status codes listed in the table; the response body states the actual cause. An error body may not be JSON, so clients should handle it according to `Content-Type`.

Error responses:

```json
{
  "error": "Authentication required"
}
```

### Call conventions

- Each call sends at most one upstream HTTP request, with no automatic retry and no switching to another endpoint version.
- A single upstream request times out after 60 seconds; time spent queued for rate limiting is not included.
- Responses are not cached. If the upstream body reflects a server-side credential, that credential is replaced with `[redacted]`.

## Instagram account details

`GET /api/v1/instagram/v1/fetch_user_info_by_username_v2`

Fetches an account's raw details by Instagram username. Available to every signed-in user, with the same authentication as the search API. No project or Campaign is required.

### Request parameters

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| username | query | string | Yes | Instagram username without @, e.g. natgeo |

### Request example

Request example:

```bash
curl --get 'https://wavesurge.ai/api/v1/instagram/v1/fetch_user_info_by_username_v2' \
  --header "Cookie: kolagent_token=$WAVESURGE_TOKEN" \
  --data-urlencode 'username=natgeo'
```

### Response example

The example below shows only the main fields; the API returns every field of the raw response.

Response example:

```json
{
  "code": 200,
  "data": {
    "id": "787132",
    "username": "natgeo",
    "full_name": "National Geographic",
    "biography": "Example biography",
    "edge_followed_by": { "count": 268409149 },
    "profile_pic_url": "https://images.example/avatar.jpg",
    "profile_pic_url_hd": "https://images.example/avatar-hd.jpg",
    "is_private": false,
    "is_verified": true
  }
}
```

### Field mapping

| Purpose | Raw field | Description |
| --- | --- | --- |
| User ID | data.id | The user_id for subsequent post requests; keep it as a string |
| Bio | data.biography | The account's public bio; an empty string means this response provided no bio text |
| Follower count | data.edge_followed_by.count | Follower count returned by this query |
| Profile picture | data.profile_pic_url, data.profile_pic_url_hd | Standard or HD profile picture URL; the field may be absent |

This endpoint has no pagination parameter.

## Instagram recent posts list

`GET /api/v1/instagram/v1/fetch_user_posts`

Fetches an account's post list by user ID. Omit `max_id` on the first request; subsequent requests use the `data.next_max_id` returned in the response. The API returns posts, image resources and engagement data as-is, and does not generate `photo_urls`, an engagement rate or content tags.

### Request parameters

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| user_id | query | string | Yes | The data.id returned by account details |
| count | query | integer | No | Items per page, range 1–50, default 12; sent as a string in the URL. A very small value can come back as an empty page with more_available false for some accounts (measured 2026-11-06: count=3 on natgeo returned 0 items, count=5 returned 5), so keep the default or use 5 or more |
| max_id | query | string | No | Non-empty data.next_max_id returned by the previous page; omit on the first request |

### First request

First request:

```bash
curl --get 'https://wavesurge.ai/api/v1/instagram/v1/fetch_user_posts' \
  --header "Cookie: kolagent_token=$WAVESURGE_TOKEN" \
  --data-urlencode 'user_id=787132' \
  --data-urlencode 'count=12'
```

### Response example

Response example:

```json
{
  "code": 200,
  "data": {
    "items": [
      {
        "id": "4000000000000000001_787132",
        "media_type": 1,
        "taken_at": 1791504000,
        "caption": { "text": "Example caption" },
        "image_versions2": {
          "candidates": [
            { "width": 1080, "height": 1350, "url": "https://images.example/photo.jpg" }
          ]
        },
        "like_count": 1200,
        "comment_count": 30
      }
    ],
    "more_available": true,
    "next_max_id": "opaque-cursor"
  }
}
```

### Post fields

| Raw field | Type | Description |
| --- | --- | --- |
| data.items[] | array | Posts on this page |
| data.items[].id | string | Post ID, kept as the raw value; prefer the string ID for deduplication |
| data.items[].media_type | integer | 1 = photo, 2 = video, 8 = carousel |
| data.items[].caption.text | string | Post text; may be absent or null |
| data.items[].taken_at | integer | Publish time, Unix seconds |
| data.items[].image_versions2.candidates[] | array | Image resource versions at different sizes; for a video post this is usually the cover |
| data.items[].carousel_media[] | array | Photos or videos inside the carousel; check each item's media_type and image resources |
| data.items[].like_count | integer | Like count; may be affected by account or post settings |
| data.items[].comment_count | integer | Comment count; may be affected by account or post settings |
| data.more_available | boolean | Whether more pages are available |
| data.next_max_id | string/null | Next-page cursor; pass it unchanged as max_id |

The `candidates` of one image are different resolutions of the same image and must not be treated as multiple photos. For a photo post, pick one URL of a suitable size; for a carousel post, pick one per item. Mark video covers separately and do not treat them as photo posts. Image and profile picture URLs may expire.

The raw response may contain numeric IDs beyond JavaScript's safe integer range. Prefer the string `id`; when you need to read a numeric ID, use lossless JSON parsing to avoid losing precision.

### Next-page request

Next-page request:

```bash
export NEXT_MAX_ID='<non-empty data.next_max_id from the previous response>'
curl --get 'https://wavesurge.ai/api/v1/instagram/v1/fetch_user_posts' \
  --header "Cookie: kolagent_token=$WAVESURGE_TOKEN" \
  --data-urlencode 'user_id=787132' \
  --data-urlencode 'count=12' \
  --data-urlencode "max_id=$NEXT_MAX_ID"
```

Continue requesting only when `data.more_available` is true and `data.next_max_id` is non-empty. Keep `user_id` unchanged; stop when there are no more pages or no valid cursor. Deduplicate by the post's string ID, since pinned content and similar factors may affect the returned order; pagination availability and the historical range depend on the actual response.

### Caller-side calculation and analysis

- `engagement_rate`: the average (likes + comments) over the N most recent posts, divided by the follower count; multiply by 100% when displaying a percentage, and record the sample size N. Mark it as unknown when counts are missing or hidden, or there is no valid follower count.
- `content_tags`: AI classification performed by the caller from the bio, post text and images; the account's self-declared category is not an AI content tag.
- `photo_urls`: extracted by the caller from the image resources of photo posts or carousel photos; the API does not add this field.

Both endpoints follow the same error and pass-through conventions as the search API: the upstream status code, response body and Content-Type are preserved; no caching, no automatic retry, and no automatic filtering or added fields. Upstream errors for a missing parameter or an invalid `count` are returned as-is; a private or unavailable account may return an error, an empty list or absent fields, so rely on the response.

## Instagram post details

`GET /api/v1/instagram/v2/fetch_post_info`

Fetches the raw details of a single post by its Instagram shortcode or full URL. Available to every signed-in user, authenticated with a cookie or a Bearer JWT. The request writes nothing to the business database.

### Request parameters

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| code_or_url | query | string | Yes | Post shortcode or full URL, e.g. DeR4RetDg3c or https://www.instagram.com/p/DeR4RetDg3c/ |

### Request example

Lookup by shortcode:

```bash
curl --get 'https://wavesurge.ai/api/v1/instagram/v2/fetch_post_info' \
  --header "Authorization: Bearer $WAVESURGE_JWT" \
  --data-urlencode 'code_or_url=DeR4RetDg3c'
```

Lookup by full URL:

```bash
curl --get 'https://wavesurge.ai/api/v1/instagram/v2/fetch_post_info' \
  --header "Authorization: Bearer $WAVESURGE_JWT" \
  --data-urlencode 'code_or_url=https://www.instagram.com/p/DeR4RetDg3c/'
```

### Response example

The example below shows the main fields; the full response is returned as-is. Actual content and counts are as given in each response.

Response example:

```json
{
  "code": 200,
  "data": {
    "data": {
      "id": "3680000000000000000_787132",
      "code": "DeR4RetDg3c",
      "media_type": 8,
      "caption": {
        "text": "Example caption"
      },
      "user": {
        "id": "787132",
        "username": "natgeo"
      },
      "metrics": {
        "like_count": 153277,
        "comment_count": 351,
        "view_count": null
      },
      "image_versions": {
        "items": [
          {
            "url": "https://images.example/cover.jpg",
            "width": 1080,
            "height": 1350
          }
        ]
      },
      "carousel_media": [
        {
          "media_type": 1,
          "image_versions": {
            "items": [
              {
                "url": "https://images.example/photo.jpg",
                "width": 1080,
                "height": 1350
              }
            ]
          }
        },
        {
          "media_type": 2,
          "thumbnail_url": "https://images.example/video-cover.jpg",
          "video_url": "https://videos.example/video.mp4"
        }
      ]
    }
  }
}
```

### Response fields

| Content | Field path | Description |
| --- | --- | --- |
| Post ID / shortcode | data.data.id, data.data.code | Keep the ID as a string; a list item's code can be used as code_or_url |
| Caption | data.data.caption.text | May be empty or absent |
| Author | data.data.user | Contains raw fields such as username and id |
| Likes / comments | data.data.metrics.like_count, data.data.metrics.comment_count | A number is the returned count; null or absent means not provided and must not be treated as 0 |
| Image resources | data.data.image_versions.items[].url | Photo image or video cover; determine which by media_type |
| Carousel resources | data.data.carousel_media[] | Photos use image_versions.items[].url; videos use video_url / video_versions[].url |
| Thumbnail / video | data.data.thumbnail_url, data.data.video_url | Fields vary by media type and may be absent |

Single-post details have no pagination parameter. To page through recent posts, use fetch_user_posts with max_id. This endpoint returns the comment count, not the full comment list. Media type is usually 1 (photo), 2 (video) or 8 (carousel). A video cover must not be treated as a photo post. Media URLs may expire; rely on the latest response.

The raw HTTP status code, body and Content-Type are preserved; no caching, no automatic retry, and no automatic filtering or engagement-rate calculation. An unauthenticated request returns 401. Errors such as a missing or invalid code_or_url or an unavailable post are returned as the raw response.

## Workflow 1: projects and chat (recommended entry point)

Create projects, write requirements, generate reports and find creators — all from here.

1. Create a project: POST /codex-chat/conversations, body { title? }, returns 201 { id, title, updatedAt }; id is the projectId.
2. Send a message: POST /codex-chat/conversations/:projectId/turns, body { commandId: <uuid>, text }, returns 202 { turnId }; the reply is generated in the background. If a reply is already being generated for the project you get 409 chat_turn_active — wait for it to finish, then send.
3. Each chat turn is billed in credits at its actual model and third-party cost times the global cost multiplier, on the terms in force when the turn is accepted. Nothing extra goes in the request, and there is no separate approval step in the web app.
4. Read the result: GET /codex-chat/conversations/:projectId returns a snapshot { messages, brief, actionReadiness, tasks, outputs, confirmation, activeTurn, campaignId }. activeTurn is null once the reply is complete. You can also subscribe to SSE at GET …/events (type is snapshot / text_delta / tool / turn_completed); trust the snapshot taken after turn_completed.
5. Start paid work: check that actionReadiness.<action>.canRequestConfirmation is true; POST …/confirmations with body { action: "creator_search" | "strategy_report" | "competitor_report" } to get a quote card { id, credits, payerName, expiresAt, summary }; show the credits to the human and, after an explicit yes, POST …/confirmations/:confirmationId/confirm.
6. A creator_search quote needs five facts in the brief: which platforms and how many creators on each (creatorSearch.platformCounts), the budget for creator fees (creatorBudget { amount, currency }), what kind of creators (creatorSearch.persona), where their followers should be (creatorSearch.audienceRegions) and which language they post in (creatorSearch.language). While any of them is missing, actionReadiness.creator_search.canRequestConfirmation is false and a request answers 409 creator_search_questions_open naming the open ones; write them through chat or PATCH /brief first.
7. If the quote card is no longer valid (requirements or price changed, or it expired), confirm returns 409 codex_confirmation_invalid — just request a new one. Confirming twice never places a second order.
8. Track progress with the snapshot's tasks[] (status is working / needs_you / done, with deliveredCount and targetCount) and outputs[] (links to the Campaign, the creator list and so on).
9. Stop the current reply: POST …/interrupt with body { turnId }. This does not cancel paid work that was already confirmed.

Create a project → send requirements → read the snapshot:

```bash
BASE=https://wavesurge.ai/api/v1
H="Cookie: kolagent_token=$WAVESURGE_TOKEN"

curl -s -X POST $BASE/codex-chat/conversations -H "$H" \
  -H 'Content-Type: application/json' -d '{"title":"Acme US launch"}'
# => {"id":"<projectId>", ...}

curl -s -X POST $BASE/codex-chat/conversations/<projectId>/turns -H "$H" \
  -H 'Content-Type: application/json' \
  -d '{"commandId":"'$(uuidgen)'","text":"Website https://acme.com, US market, budget 5000 USD, find 30 TikTok beauty creators with 10K-100K followers"}'
# => 202 {"turnId":"..."}

curl -s $BASE/codex-chat/conversations/<projectId> -H "$H"
```

Two-step confirmation (finding creators):

```bash
curl -s -X POST $BASE/codex-chat/conversations/<projectId>/confirmations -H "$H" \
  -H 'Content-Type: application/json' -d '{"action":"creator_search"}'
# => {"id":"<confirmationId>","credits":<n>,"payerName":"...","expiresAt":"..."}
# -- show credits and payerName to the human and get an explicit yes --
curl -s -X POST $BASE/codex-chat/conversations/<projectId>/confirmations/<confirmationId>/confirm -H "$H"
# => {"ok":true}
```

### Request and response examples

Request: POST /codex-chat/conversations:

```json
{
  "title": "Glowkit US launch"
}
```

Response: 201:

```json
{
  "id": "9b1d2c3e-0000-4000-8000-000000000001",
  "title": "Glowkit US launch",
  "updatedAt": "2026-10-09T09:17:40.112Z"
}
```

Request: POST /codex-chat/conversations/:projectId/turns:

```json
{
  "commandId": "5f0e6a8b-0000-4000-8000-000000000008",
  "text": "Website https://glowkit.co, US market, total budget 15000 USD. Find 10 skincare creators, 5 on TikTok and 5 on Instagram, 10K-100K followers, must be contactable"
}
```

Response: 202:

```json
{
  "turnId": "0f8cd46b-0000-4000-8000-000000000003"
}
```

Response: GET /codex-chat/conversations/:projectId (excerpt):

```json
{
  "id": "9b1d2c3e-0000-4000-8000-000000000001",
  "title": "Glowkit",
  "campaignId": "6c2e4a10-0000-4000-8000-000000000002",
  "messages": [
    {
      "id": "0f8cd46b-0000-4000-8000-000000000003:user",
      "role": "user",
      "text": "What else can you do?",
      "createdAt": "2026-10-07T09:17:52.892Z"
    },
    {
      "id": "0f8cd46b-0000-4000-8000-000000000003:assistant",
      "role": "assistant",
      "text": "I can keep going: review the 10 delivered creators, generate a strategy report, generate a competitor report, or refine the collaboration rules…",
      "createdAt": "2026-10-07T09:18:09.976Z"
    }
  ],
  "permissions": {
    "view": true,
    "manage": true,
    "owner": true,
    "canChat": true,
    "canSaveBrief": true,
    "canRunPaidOperation": true
  },
  "briefRevision": 3,
  "brief": {
    "website": "https://glowkit.co",
    "regions": [
      "United States"
    ],
    "budgetUsd": 15000,
    "creatorBudget": {
      "amount": 15000,
      "currency": "USD"
    },
    "platforms": [
      "tiktok",
      "instagram"
    ],
    "creatorSearch": {
      "count": 10,
      "persona": "Skincare creators",
      "minFollowers": 10000,
      "maxFollowers": 100000,
      "platformCounts": {
        "tiktok": 5,
        "instagram": 5
      },
      "contactRequired": true,
      "audienceRegions": [
        "United States"
      ],
      "language": [
        "English"
      ]
    }
  },
  "actionReadiness": {
    "creator_search": {
      "state": "ready",
      "canRequestConfirmation": true,
      "missingCount": 0
    },
    "strategy_report": {
      "state": "ready",
      "canRequestConfirmation": true,
      "missingCount": 0
    },
    "competitor_report": {
      "state": "needs_input",
      "canRequestConfirmation": false,
      "missingCount": 1
    }
  },
  "tasks": [
    {
      "id": "abbde687-0000-4000-8000-000000000006",
      "kind": "creator_search",
      "createdAt": "2026-09-22T16:51:51.156Z",
      "title": "Finding creators",
      "status": "done",
      "isActive": false,
      "deliveredCount": 10,
      "targetCount": 10,
      "nextActions": [
        "view_result",
        "continue_chat"
      ]
    }
  ],
  "outputs": [
    {
      "id": "6c2e4a10-0000-4000-8000-000000000002",
      "title": "Continue collaboration",
      "url": "/app/p/6c2e4a10-0000-4000-8000-000000000002/campaigns",
      "kind": "campaign"
    },
    {
      "id": "6c2e4a10-0000-4000-8000-000000000002:creators",
      "title": "View creators",
      "url": "/app/p/6c2e4a10-0000-4000-8000-000000000002/discover",
      "kind": "creators"
    }
  ],
  "confirmation": null,
  "activeTurn": null
}
```

Request: POST /codex-chat/conversations/:projectId/confirmations:

```json
{
  "action": "creator_search"
}
```

Response: quote card:

```json
{
  "id": "c0ffee00-0000-4000-8000-000000000004",
  "action": "creator_search",
  "title": "Find creators",
  "payerName": "Your Name",
  "credits": 312,
  "briefVersion": 4,
  "expiresAt": "2026-10-09T09:35:00.000Z",
  "summary": [
    "Coffee review and pour-over tutorial creators",
    "Creator location: United States",
    "tiktok: 7 creators",
    "instagram: 3 creators",
    "Total: 10 creators",
    "20K+ followers",
    "under 150K followers",
    "must be contactable",
    "posts in English"
  ]
}
```

Response: POST …/confirmations/:confirmationId/confirm:

```json
{
  "ok": true
}
```

### Attachments

1. POST /codex-chat/conversations/:projectId/attachments with body { filename, sizeBytes } returns { id, uploadUrl, expiresAt } (up to 100 MB per file).
2. PUT the raw file bytes to <uploadUrl>, with a Content-Type matching the file extension.
3. POST …/attachments/:attachmentId/commit.
4. Include attachmentIds: [<id>] when sending a message; up to 5 per message.

### Project read endpoints

| Method | Path | Description |
| --- | --- | --- |
| GET | /codex-chat/conversations | Recent project conversations |
| GET | /strategy-projects/:projectId/brief | Current brief, field states, version and creator-search target |
| PATCH | /strategy-projects/:projectId/brief | body { commandId, expectedBriefRevision, patch } writes the brief directly (free) |
| GET | /strategy-projects/:projectId/campaign/current | Current Campaign |
| GET | /strategy-projects/:projectId/reports | Report list; /reports/:operationId/result reads one report |
| GET | /strategy-projects/:projectId/creator-search/current | Creator-search state: per-platform target, delivered and shortfall |
| GET | /strategy-projects/:projectId/creator-search/requests/:taskId/results | Results of one search |
| GET | /strategy-agent/deck.html?kind=brand\|competitor&artifactId= | Downloadable web page of a report |

Response: GET /strategy-projects/:projectId/brief (excerpt):

```json
{
  "project": {
    "id": "9b1d2c3e-0000-4000-8000-000000000001",
    "title": "Glowkit",
    "campaignId": "6c2e4a10-0000-4000-8000-000000000002",
    "updatedAt": "2026-10-09T13:59:15.678Z"
  },
  "brief": {
    "website": "https://glowkit.co",
    "regions": [
      "United States"
    ],
    "budgetUsd": 15000,
    "creatorBudget": {
      "amount": 15000,
      "currency": "USD"
    },
    "platforms": [
      "tiktok",
      "instagram"
    ],
    "creatorSearch": {
      "count": 10,
      "persona": "Skincare creators",
      "minFollowers": 10000,
      "maxFollowers": 100000,
      "platformCounts": {
        "tiktok": 5,
        "instagram": 5
      },
      "contactRequired": true,
      "audienceRegions": [
        "United States"
      ],
      "language": [
        "English"
      ]
    }
  },
  "briefVersion": 3,
  "briefRevision": 3,
  "creatorSearch": {
    "status": "done",
    "summary": "10 creators delivered",
    "requested": 10,
    "delivered": 10,
    "operationId": "abbde687-0000-4000-8000-000000000006",
    "operationState": "succeeded",
    "taskId": "aaf279f0-0000-4000-8000-000000000005",
    "failure": null,
    "updatedAt": "2026-10-09T13:59:15.678Z"
  },
  "asOf": "2026-10-09T14:14:06.689Z"
}
```

Response: GET /strategy-projects/:projectId/creator-search/current (excerpt):

```json
{
  "state": "ready_to_confirm",
  "asOf": "2026-10-09T14:14:06.689Z",
  "briefVersion": 3,
  "briefRevision": 3,
  "criteria": {
    "count": 10,
    "persona": "Skincare creators",
    "minFollowers": 10000,
    "maxFollowers": 100000,
    "platformCounts": {
      "tiktok": 5,
      "instagram": 5
    },
    "contactRequired": true,
    "creatorLocations": [
      "United States"
    ],
    "platforms": [
      "tiktok",
      "instagram"
    ]
  },
  "missing": []
}
```

## Workflow 2: creator lists and Campaigns

| Method | Path | Description |
| --- | --- | --- |
| GET | /discover/db?platform=tiktok,instagram&campaignId=&limit=&sort= | Read a Campaign's creator list. platform is required (tiktok / instagram / youtube / twitter, comma-separated); sort is fit_desc / followers_desc / followers_asc; each creator carries audience (audience profile) |
| POST | /discover/confirm-removal | Remove creators from the list (free) |
| POST | /creator-list/parse → /creator-list/commit | Upload your own creator list (CSV / Excel), free |
| GET | /kol/campaigns | Campaign list; GET /kol/campaigns/:id for details |
| GET | /kol/campaigns/:id/kols | Campaign roster, with outreach status |
| GET | /kol/campaigns/:campaignId/outreach-progress | Replied / not yet replied |
| GET | /kol/campaigns/:campaignId/send-progress | Send progress |

Response: GET /discover/db?platform=tiktok,instagram&campaignId=… (excerpt, one creator shown):

```json
{
  "summary": {
    "total": 10,
    "byPlatform": {
      "tiktok": 5,
      "instagram": 5
    },
    "emailUsable": 10,
    "emailQualified": 10,
    "emailAutoSendable": 10,
    "contactable": 10,
    "nonEmailContactable": 6
  },
  "target": 15,
  "fulfilled": 10,
  "latestCreatorSearchTaskId": "aaf279f0-0000-4000-8000-000000000005",
  "creators": [
    {
      "id": "db83ae10-0000-4000-8000-000000000009",
      "platform": "tiktok",
      "handle": "example_creator",
      "displayName": "Example Creator",
      "region": "United States",
      "followerCount": 21897,
      "engagementRate": null,
      "tier": "micro",
      "origin": "system",
      "foundAt": "2026-09-22T17:17:24.288Z",
      "emailStatus": "email_ready",
      "email": null,
      "emailUsable": true,
      "emailOwnerType": "creator",
      "contactable": true,
      "contactChannels": [],
      "searchKind": "committed",
      "recommendationKind": "delivered",
      "searchMatchTier": "precise",
      "audienceStatus": "profiled",
      "audience": {
        "availability": "available",
        "basis": "Sampled 25 of their most recent followers.",
        "capturedAt": "2026-09-22T17:06:09.378Z",
        "rows": [
          {
            "key": "gender",
            "label": "Gender",
            "value": "Female 64% · Male 4% · Unknown 32%",
            "hint": "Estimated from 25 accounts."
          },
          {
            "key": "age",
            "label": "Age",
            "value": "19-29 28% · 30-39 16% · >=40 4% · +1 more · Unknown 52%",
            "hint": "Estimated from 25 accounts."
          }
        ]
      }
    }
  ]
}
```

## Workflow 3: outreach (sends for real)

> **These endpoints really contact creators and charge credits**: Before calling them, tell the human how many creators, what it costs and what will be sent, and get an explicit yes.

| Method | Path | Description |
| --- | --- | --- |
| POST | /discover/start-outreach | body { campaignId } starts outreach, charging contactable creators × 6 credits. 409 outreach_already_started means it already started; 412 no_send_channel means no one is contactable (no charge); 402 means not enough credits |
| POST | /kol/campaigns/:campaignId/pause | Pause all sending |
| POST | /kol/campaigns/:campaignId/resume | Resume the sends cancelled by the pause |
| POST | /kol/campaign-kols/:id/send-message | Send one message to a creator |

## Credits and pricing

| Method | Path | Description |
| --- | --- | --- |
| GET | /credits/balance | { balance } |
| GET | /credits/ledger?limit=&cursor= | Credit activity; returns nextCursor |
| GET | /credits/pending-work | Paid work waiting on credits, and the shortfall |
| GET | /pricing | Public price list; no sign-in needed |

Response: GET /credits/balance:

```json
{
  "balance": 2058
}
```

Response: GET /credits/ledger?limit=1:

```json
{
  "entries": [
    {
      "id": "4960641a-0000-4000-8000-00000000000a",
      "action": "strategy_operation_refund",
      "cost": 148,
      "balanceAfter": 2058,
      "refId": "abbde687-0000-4000-8000-000000000006",
      "refType": "StrategyOperation",
      "createdAt": "2026-09-22T17:17:24.369Z",
      "ref": {
        "name": "Creator search"
      },
      "actor": null
    }
  ],
  "nextCursor": "MjAyNi0wOS0yMlQxNzoxNzoyNC4zNTcwMDBafDE4N2Ex…"
}
```

Response: GET /credits/pending-work:

```json
{
  "items": [],
  "totalCredits": 0,
  "balance": 2058,
  "shortfall": 0,
  "held": [],
  "heldCredits": 0
}
```

| Action | Price (credits) |
| --- | --- |
| Strategy report | 40 per report |
| Competitor report | 100 per report |
| Finding creators | 10 per creator delivered + the real call costs of the run + 4 per delivered creator with an email; without one 2 + the lookup's cost (at most 4) instead of 10; reserved first, settled once at the end |
| First outreach | 6 per contactable creator |
| Follow-up / AI negotiation reply | 1 per message / 1 per reply |
| Chat, editing requirements, creating a Campaign, uploads, reads, removing creators | Free |

Buying credits must be done by a human on the Money page; an agent should never pay on the user's behalf.

## Rules for agents

- Before any call that spends credits, contacts creators or moves money, show the human the quote or the impact and act only after an explicit yes.
- The saved brief is the source of truth for requirements: write them through chat or PATCH /brief first, then request a quote. Finding creators quotes only once platforms and counts, creator budget, persona, follower region and post language are all saved.
- On 409, re-read the current state before deciding what to do; on 402, do not retry.
- The *CostUsd fields under usage are internal cost telemetry — never report them as prices, and never convert credits to dollars.
- Never put the token in logs, URLs, chat transcripts or repositories.
