# RunDossier API

Generate executive pre-meeting dossiers programmatically. The RunDossier API lets your own tools and AI agents create dossiers, poll their status, and fetch the finished briefing without a browser session.

- **Base URL** `https://rundossier.com/api/v1`
- **Authentication** `Authorization: Bearer dsk_live_...`
- **Format** JSON request and response bodies, snake_case field names

Every endpoint is server-to-server, stateless, and pollable — the primary consumer of this API is an autonomous agent.

## Getting started

1. Open **Settings → API Keys** and create a key. The full key (starting with `dsk_live_`) is shown **once** at creation — copy it immediately; it cannot be retrieved again.
2. Send it as a Bearer token on every request. A quick check against your credit balance:

```bash
curl https://rundossier.com/api/v1/credits \
  -H "Authorization: Bearer dsk_live_your_key_here"
```

3. Create your first dossier and poll it to completion — see the [full example](#full-example) below.

## Authentication

Every request must carry your key in the `Authorization` header:

```http
Authorization: Bearer dsk_live_...
```

Keys are scoped to your user account and draw from your own credit balance. A missing, malformed, unknown, or revoked key returns a single generic `401 unauthorized` — the API never reveals which of those conditions failed.

Creating, listing, and revoking keys is a browser-only action (see [Managing API keys](#managing-api-keys)); an API key can never manage keys.

## The dossier lifecycle

A dossier moves through these states:

| Status | Meaning |
|--------|---------|
| `pending` | Accepted and queued for generation. |
| `processing` | The worker is researching and writing the dossier. |
| `awaiting_selection` | Several LinkedIn profiles matched the subject. The response includes a `candidate_profiles` array; resolve it before generation continues. |
| `complete` | Finished. `result_markdown`, `google_doc_url`, `word_doc_url`, and `photo_url` are populated. |
| `error` | Generation failed. `error_message` explains why. |

The lifecycle is `pending → processing → (awaiting_selection) → complete | error`. The terminal states are `complete` and `error`.

**Polling.** After creating a dossier, poll `GET /dossiers/{id}` every 5–10 seconds until `status` is `complete` or `error`. Most dossiers finish in about 90 seconds.

**Handling `awaiting_selection`.** When a dossier reaches `awaiting_selection`, inspect `candidate_profiles`, choose the correct `url`, and POST it to the [select endpoint](#resolve-a-disambiguation). Passing `selected_url: null` instead cancels generation and refunds the credit.

## Credits and billing

- Each dossier costs **1 credit**, deducted when you call `POST /dossiers` — identical to a web generation.
- Credits come from your own balance (a monthly allowance plus any top-ups). Check it with `GET /credits`.
- At a zero balance, `POST /dossiers` returns `402 insufficient_credits`.
- Cancelling an `awaiting_selection` dossier (`selected_url: null`) refunds the credit.

Manage your balance in **Settings → Billing**.

## Rate limits

| Scope | Limit | Applies to |
|-------|-------|------------|
| Per IP (pre-auth) | 30 requests / 60s | Every `/api/v1/*` request, before authentication |
| Per key — writes | 10 requests / minute | `POST /dossiers`, `POST /dossiers/{id}/select` |
| Per key — reads | 60 requests / minute | `GET /dossiers`, `GET /dossiers/{id}`, `GET /credits` |

Exceeding a limit returns `429 rate_limited` with a `Retry-After` header giving the number of seconds to wait. Honor it before retrying.

## Errors

Every error uses the same envelope:

```json
{
  "error": {
    "code": "insufficient_credits",
    "message": "You have no credits remaining. Purchase more to continue."
  }
}
```

| Status | `code` | Meaning |
|--------|--------|---------|
| 400 | `invalid_request` | A body or query parameter is missing or invalid. |
| 400 | `invalid_state` | The dossier is not in a state that allows this action (e.g. selecting on a dossier that is not `awaiting_selection`). |
| 401 | `unauthorized` | The API key is missing, malformed, unknown, or revoked. |
| 402 | `insufficient_credits` | Your credit balance is zero. |
| 404 | `not_found` | No dossier with that id belongs to you. |
| 429 | `rate_limited` | Too many requests; see `Retry-After`. |
| 500 | `internal_error` | Unexpected server error; safe to retry. |

## Endpoints

### Create a dossier

```http
POST /dossiers
```

Deducts one credit, creates the dossier, and dispatches it for generation. Returns immediately with status `pending`.

**Request body**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `subject_name` | string | Yes | The person to research. 1–200 characters. |
| `context` | string | No | Extra context to focus or disambiguate the research (role, company, etc.). Up to 1000 characters. |

```json
{
  "subject_name": "Jane Doe",
  "context": "VP of Engineering at Acme"
}
```

**Response** `201 Created`

```json
{
  "id": "b3f1c2a4-6d7e-4f89-a012-3456789abcde",
  "status": "pending"
}
```

**Errors** `400 invalid_request`, `401 unauthorized`, `402 insufficient_credits`, `429 rate_limited`.

### Retrieve a dossier

```http
GET /dossiers/{id}
```

Returns the current state of one of your dossiers. Poll this to track progress. `word_doc_url` is a temporary signed download link valid for **5 minutes** — request the dossier again to get a fresh one.

**Response** `200 OK`

```json
{
  "id": "b3f1c2a4-6d7e-4f89-a012-3456789abcde",
  "status": "complete",
  "subject_name": "Jane Doe",
  "status_message": null,
  "error_message": null,
  "result_markdown": "# Jane Doe\n\n## Quick Facts\n...",
  "google_doc_url": "https://docs.google.com/document/d/abc123/edit",
  "word_doc_url": "https://dossier-bot-photos.s3.amazonaws.com/...&X-Amz-Expires=300",
  "photo_url": "https://dossier-bot-photos.s3.amazonaws.com/photos/jane-doe.jpg",
  "created_at": "2026-07-02T14:11:05.000Z",
  "duration_ms": 88213
}
```

When `status` is `awaiting_selection`, the response also includes a `candidate_profiles` array:

```json
{
  "id": "b3f1c2a4-6d7e-4f89-a012-3456789abcde",
  "status": "awaiting_selection",
  "subject_name": "Jane Doe",
  "status_message": "Multiple profiles found",
  "error_message": null,
  "result_markdown": null,
  "google_doc_url": null,
  "word_doc_url": null,
  "photo_url": null,
  "created_at": "2026-07-02T14:11:05.000Z",
  "duration_ms": null,
  "candidate_profiles": [
    {
      "url": "https://www.linkedin.com/in/janedoe",
      "title": "Jane Doe - VP Engineering - Acme",
      "headline": "VP Engineering at Acme",
      "location": "San Francisco, CA",
      "current_role": "VP Engineering",
      "current_company": "Acme"
    }
  ]
}
```

Within a candidate, fields other than `url` and `title` are omitted when unknown.

**Errors** `401 unauthorized`, `404 not_found`, `429 rate_limited`.

### Resolve a disambiguation

```http
POST /dossiers/{id}/select
```

Resolves a dossier in the `awaiting_selection` state. Provide the `url` of the chosen candidate to continue generation, or `null` to cancel and refund the credit.

**Request body**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `selected_url` | string \| null | Yes | A candidate `url` to proceed with, or `null` to cancel and refund. |

```json
{
  "selected_url": "https://www.linkedin.com/in/janedoe"
}
```

**Response** `200 OK` — selecting a profile returns `processing`; cancelling (`null`) returns `error`.

```json
{
  "id": "b3f1c2a4-6d7e-4f89-a012-3456789abcde",
  "status": "processing"
}
```

**Errors** `400 invalid_request` (body invalid, or `selected_url` is not one of the candidate URLs), `400 invalid_state` (dossier is not awaiting selection), `401 unauthorized`, `404 not_found`, `429 rate_limited`.

### List dossiers

```http
GET /dossiers
```

Lists your dossiers, newest first. Cursor-paginated.

**Query parameters**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `limit` | integer | 20 | Maximum results to return. Capped at 50. |
| `before` | string | — | ISO 8601 timestamp; returns dossiers created strictly before it. Pass the previous response's `next_before` to page. |

**Response** `200 OK`

```json
{
  "dossiers": [
    {
      "id": "b3f1c2a4-6d7e-4f89-a012-3456789abcde",
      "status": "complete",
      "subject_name": "Jane Doe",
      "error_message": null,
      "created_at": "2026-07-02T14:11:05.000Z",
      "duration_ms": 88213
    }
  ],
  "next_before": "2026-07-02T14:11:05.000Z"
}
```

`next_before` is `null` when there are no more pages.

**Errors** `400 invalid_request` (invalid `before`), `401 unauthorized`, `429 rate_limited`.

### Get credit balance

```http
GET /credits
```

Returns your current credit balance.

**Response** `200 OK`

```json
{
  "monthly": 42,
  "topup": 10,
  "total": 52
}
```

`monthly` is your plan's recurring allowance, `topup` is purchased credits, and `total` is the sum — the pool a new dossier draws from.

**Errors** `401 unauthorized`, `429 rate_limited`.

### Managing API keys

Keys are created, listed, and revoked from the browser only — an API key can never manage keys. These endpoints require a signed-in session (not a Bearer key) and are documented here for completeness:

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/api-keys` | List your keys (active and revoked). |
| POST | `/api/api-keys` | Create a named key; returns the plaintext once. Max 10 active. |
| POST | `/api/api-keys/{id}/revoke` | Revoke a key immediately. Idempotent. |

Create and revoke keys in **Settings → API Keys**.

## Full example

A complete create → poll → fetch-result loop, including `awaiting_selection` handling.

**curl**

```bash
export DOSSIER_API_KEY="dsk_live_your_key_here"

# 1. Create a dossier
curl -X POST https://rundossier.com/api/v1/dossiers \
  -H "Authorization: Bearer $DOSSIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"subject_name": "Jane Doe", "context": "VP of Engineering at Acme"}'
# → 201 { "id": "b3f1c2a4-...", "status": "pending" }

# 2. Poll until the status is complete or error
curl https://rundossier.com/api/v1/dossiers/b3f1c2a4-... \
  -H "Authorization: Bearer $DOSSIER_API_KEY"
# → { "status": "processing", ... }  then eventually
# → { "status": "complete", "result_markdown": "# Jane Doe...", ... }
```

**JavaScript (fetch)**

```javascript
const BASE = "https://rundossier.com/api/v1";
const headers = {
  Authorization: `Bearer ${process.env.DOSSIER_API_KEY}`,
  "Content-Type": "application/json",
};

// 1. Create
const created = await fetch(`${BASE}/dossiers`, {
  method: "POST",
  headers,
  body: JSON.stringify({ subject_name: "Jane Doe", context: "VP of Engineering at Acme" }),
});
if (!created.ok) throw new Error(`Create failed: ${created.status}`);
const { id } = await created.json();

// 2. Poll until a terminal state
async function poll(id) {
  while (true) {
    const res = await fetch(`${BASE}/dossiers/${id}`, { headers });
    const d = await res.json();
    if (d.status === "complete") return d;
    if (d.status === "error") throw new Error(d.error_message ?? "Generation failed");
    if (d.status === "awaiting_selection") {
      // Pick the best candidate (or surface d.candidate_profiles to a human)
      await fetch(`${BASE}/dossiers/${id}/select`, {
        method: "POST",
        headers,
        body: JSON.stringify({ selected_url: d.candidate_profiles[0].url }),
      });
    }
    await new Promise((r) => setTimeout(r, 5000));
  }
}

const dossier = await poll(id);
console.log(dossier.result_markdown);
```

**Python (requests)**

```python
import os, time, requests

BASE = "https://rundossier.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['DOSSIER_API_KEY']}"}

# 1. Create
resp = requests.post(
    f"{BASE}/dossiers",
    headers=HEADERS,
    json={"subject_name": "Jane Doe", "context": "VP of Engineering at Acme"},
)
resp.raise_for_status()
dossier_id = resp.json()["id"]

# 2. Poll until a terminal state
while True:
    d = requests.get(f"{BASE}/dossiers/{dossier_id}", headers=HEADERS).json()
    if d["status"] == "complete":
        break
    if d["status"] == "error":
        raise RuntimeError(d.get("error_message", "Generation failed"))
    if d["status"] == "awaiting_selection":
        requests.post(
            f"{BASE}/dossiers/{dossier_id}/select",
            headers=HEADERS,
            json={"selected_url": d["candidate_profiles"][0]["url"]},
        )
    time.sleep(5)

print(d["result_markdown"])
```

## OpenAPI specification

A machine-readable OpenAPI 3.1 description of this API is served at:

```http
GET /api/v1/openapi.json
```

Use it to generate clients, validate requests, or feed the API to tooling. This reference is also available as raw markdown at `/docs/api.md` for ingestion by AI agents.
