{
  "openapi": "3.0.4",
  "info": {
    "title": "Ken Client API",
    "description": "Welcome to the **Ken Client API v2** — the public, stable surface for building integrations\nagainst your Ken workspace. It exposes a focused slice of the platform: campaigns, leads\n(list, CSV/ZIP export, trigger-campaign upload, and in-place lead field / custom-variable\nupdates), campaign analytics, suppression-list (blocklist) management, and a feed of past\nwebhook events. The broader internal control surface is **not** part of this API.\n\nEvery endpoint in this document is automatically scoped to the API key's client workspace.\nYou never pass a `clientId` — it is derived from the authenticated key, and resources that\nbelong to another workspace are reported as `404` rather than `403`.\n\n## Base path\n\nEvery endpoint in this document is served under the `/v2` prefix (for example,\n`GET /v2/campaigns`). Per-operation paths shown below omit the host but always include the\n`/v2` segment; prefix them with your Ken API base URL (for example, `https://api.getken.ai`)\nto form the full request URL.\n\n## Authentication\n\nEvery request must carry a Ken-issued API key as a Bearer token:\n\n```http\nAuthorization: Bearer sk_live_<your_key>\n```\n\nA key resolves to exactly one client workspace. If a key is linked to **no** workspace, or\nto **more than one**, requests are rejected with `403` — the v2 API requires a\nsingle-workspace key.\n\n### Getting and rotating a key\n\nAPI keys are provisioned from the Ken dashboard under **Settings → API Keys**. Each key is\nscoped to a single client workspace and inherits that workspace's data-access role. The key\nsecret is shown **only once** at creation. Rotate by creating a new key and revoking the\nold one; a revoked key immediately returns `401`.\n\n### Roles\n\nRead endpoints require only a valid key. **Lead export** (`GET /v2/leads/export`)\nadditionally requires the key's workspace role to be **Member or higher**; a valid key\nwhose user is view-only is rejected with `403`.\n\n## Response envelope\n\nSuccessful responses use one of four envelopes, all with `success: true`.\n\n**Single item** (`GET /v2/campaigns/{id}`, `GET /v2/analytics`, `POST /v2/blocklist`,\n`PATCH /v2/campaigns/{id}/leads/{leadId}`):\n\n```json\n{ \"success\": true, \"data\": { /* item */ } }\n```\n\n`GET /v2/events/types` also uses the single-item envelope, but its `data` is a JSON array\nof event-type strings rather than an object:\n\n```json\n{ \"success\": true, \"data\": [\"lead_replied\", \"lead_interested\", \"lead_unsubscribed\"] }\n```\n\n**Paginated list** (`GET /v2/leads`, `GET /v2/blocklist`, `GET /v2/events`):\n\n```json\n{\n  \"success\": true,\n  \"data\": [ /* page of items */ ],\n  \"pageIndex\": 1,\n  \"pageSize\": 50,\n  \"totalCount\": 187,\n  \"totalPages\": 4\n}\n```\n\n**Full (unpaginated) list with count** (`GET /v2/campaigns` only):\n\n```json\n{ \"success\": true, \"data\": [ /* all items */ ], \"total\": 12 }\n```\n\n**Lead upload result** (`POST /v2/campaigns/{id}/leads`):\n\n```json\n{\n  \"success\": true,\n  \"data\": {\n    \"accepted\": 1,\n    \"duplicates\": 1,\n    \"rejected\": 1,\n    \"results\": [\n      { \"index\": 0, \"status\": \"accepted\", \"leadId\": 123, \"errors\": [] },\n      { \"index\": 1, \"status\": \"duplicate\", \"leadId\": null, \"errors\": [] },\n      { \"index\": 2, \"status\": \"rejected\", \"leadId\": null, \"errors\": [\"last_name is required\"] }\n    ]\n  }\n}\n```\n\nIf every uploaded row is rejected, `POST /v2/campaigns/{id}/leads` returns `400` with the\nsame result payload under `data` so callers can recover each row's validation errors:\n\n```json\n{\n  \"success\": false,\n  \"message\": \"All rows were invalid.\",\n  \"data\": {\n    \"accepted\": 0,\n    \"duplicates\": 0,\n    \"rejected\": 1,\n    \"results\": [\n      { \"index\": 0, \"status\": \"rejected\", \"leadId\": null, \"errors\": [\"last_name is required\"] }\n    ]\n  }\n}\n```\n\n`204 No Content` is returned with no body by `DELETE /v2/blocklist/{id}`. The `GET\n/v2/leads/export` endpoint returns a binary file (`text/csv` or `application/zip`), not a\nJSON envelope.\n\n## Error responses\n\nErrors raised by the API itself — model validation, workspace scoping, rate limiting, and\nthe endpoint handlers — use a uniform shape, including model-validation failures such as a\nnon-numeric `page`:\n\n```json\n{\n  \"success\": false,\n  \"message\": \"Campaign not found.\"\n}\n```\n\n| Status | Meaning                                                              |\n|--------|----------------------------------------------------------------------|\n| 400    | Validation failed (bad paging, invalid filter, malformed body).      |\n| 401    | Missing, malformed, expired, or revoked API key.                     |\n| 403    | Key is valid but not bound to a single workspace, or lacks the role for the requested action (e.g. export). |\n| 404    | Resource not found, or not owned by the authenticated client.        |\n| 429    | Rate limit exceeded (see below).                                     |\n| 5xx    | Transient server error — retry with exponential backoff.             |\n\n**Two framework-issued cases return only a status code with an empty body (no JSON\nenvelope):**\n\n- A request whose `Authorization` header is missing, is not a `Bearer` token, or carries a\n  JWT instead of an API key returns `401` with a `WWW-Authenticate: Bearer` header and no\n  body.\n- An authenticated request whose workspace role is below the level the action requires — for\n  example a view-only key calling `GET /v2/leads/export`, which needs Member or above —\n  returns `403` with no body.\n\nFor these two cases, branch on the HTTP status code, not the response body. (A `403` for a\nkey that is not bound to a single workspace, by contrast, still carries the JSON envelope\nabove.)\n\n## Rate limits\n\nLimits are enforced per API key:\n\n- **60 requests per minute** (short-burst ceiling).\n- **1000 requests per day** (daily ceiling).\n- `GET /v2/leads/export` is additionally capped at **10 requests per minute** because each\n  call can materialize a large file.\n\nExceeding a limit returns `429 Too Many Requests` with a `Retry-After` header giving the\nnumber of seconds to wait. Detect throttling by the **status code and `Retry-After`\nheader** rather than the body: the application limiter returns the JSON envelope above,\nwhile a 429 raised at the API gateway may carry a short plain-text body instead.\n\n## Pagination\n\nMost list endpoints share the same paging contract:\n\n- `page` — 1-based page number. Defaults to `1`; must be `1..10000`.\n- `pageSize` — items per page. Defaults to `50`; must be `1..500`.\n\nOut-of-range `page`/`pageSize` values are rejected with `400` (they are **not** clamped).\nThe `400` message names the offending field using the internal name `pageIndex` for the\n`page` parameter (for example, `pageIndex must be between 1 and 10000`). A\nvalid page that lies past the last page returns an **empty** `200` page (not `404`). The\nresponse echoes the paging metadata alongside the `data` array (see \"Paginated list\"\nabove), where `pageIndex` is the page that was served.\n\n### Exception\n\n`GET /v2/campaigns` is **not** paginated: it accepts no paging parameters and returns the\nfull set of campaigns in a `{ success, data, total }` envelope. Client workspaces hold a\nbounded number of campaigns, so the list is returned in one response.\n\n## Webhooks\n\nKen can notify your systems in real time as outreach events happen, or you can\npoll the pull-available event history on demand.\n\n### Consuming events\n\n- **Push (recommended).** Ken delivers each event as an HTTPS `POST` to an\n  endpoint you register. Delivery is powered by Svix, with automatic retries and\n  signed payloads. Endpoint registration is currently handled by the Ken team -\n  contact your Ken representative with the HTTPS URL(s) you want events sent to.\n- **Pull.** `GET /v2/events` exposes a **subset** of the push catalog as a\n  paginated, filterable history feed — it is **not** a full mirror of push\n  delivery. Today it returns only `lead_replied`, `lead_interested`, and\n  `lead_unsubscribed`; `email_sent`, `lead_clicked`, and `reply_opened` are\n  push-only and are rejected with `400` if you pass them to the `eventType`\n  filter. Treat `GET /v2/events/types` as the authoritative list of the\n  `eventType` values this feed accepts. Filter by `eventType`, `campaignId`,\n  and date range. Use this if you prefer polling or want to backfill the\n  pull-available events.\n\n### Event catalog\n\nAll six event types below are deliverable via **push**. The **Pull?** column\nmarks the ones also exposed by `GET /v2/events` (and accepted by its `eventType`\nfilter); the rest are push-only.\n\n| Event               | Fires when                                          | Pull? |\n|---------------------|-----------------------------------------------------|-------|\n| `email_sent`        | An outreach email is sent to a lead.                | no    |\n| `lead_replied`      | A lead replies to an outreach email.                | yes   |\n| `lead_interested`   | A reply is classified as interested / positive.     | yes   |\n| `lead_unsubscribed` | A lead unsubscribes or opts out.                    | yes   |\n| `lead_clicked`      | A lead clicks a tracked link in an outreach email.  | no    |\n| `reply_opened`      | A lead opens a reply (first unfiltered open).       | no    |\n\n### Payload shape\n\nThis section describes the **push** delivery body. Svix sends each event as an\nHTTPS `POST` whose request body **is** the JSON object below — there is no extra\nenvelope. Every body carries the event type, a unique event id (the `eventId`, formatted `{clientId}:{eventType}:{sourceEventId}`; dedupe on it or on the `svix-id` header), a UTC timestamp,\nyour `clientId`, and the nested blocks that apply to the event. Field names are\n**camelCase**, and the top-level key is `eventType` (not `type`); there is **no**\n`data` wrapper. The payload always carries the full set of keys: the `reply`,\n`email`, and `click` blocks are event-specific (see the table below), and any\nblock (or field) that does not apply to an event is present with a `null` value\nrather than dropped. Treat a missing-vs-`null` block the same and never assume a\nkey is absent. The example below shows a `lead_replied` delivery, so its `click`\nblock is `null`.\n\n```json\n{\n  \"eventType\": \"lead_replied\",\n  \"eventId\": \"4821:lead_replied:1644085\",\n  \"timestamp\": \"2026-04-20T16:05:42Z\",\n  \"clientId\": 4821,\n  \"contact\": {\n    \"id\": 56789,\n    \"firstName\": \"Jane\",\n    \"lastName\": \"Doe\",\n    \"email\": \"jane@acme.com\",\n    \"phone\": null,\n    \"linkedinUrl\": \"https://www.linkedin.com/in/jane-doe\",\n    \"title\": \"VP of Engineering\",\n    \"seniority\": \"VP\"\n  },\n  \"company\": {\n    \"id\": 3312,\n    \"name\": \"Acme Inc.\",\n    \"domain\": \"acme.com\",\n    \"industry\": \"Software\",\n    \"size\": \"201-500\",\n    \"linkedinUrl\": \"https://www.linkedin.com/company/acme\"\n  },\n  \"campaign\": {\n    \"id\": 1234,\n    \"name\": \"Q2 outbound — Series B SaaS founders\",\n    \"aiWorkflow\": \"Default reply workflow\"\n  },\n  \"email\": {\n    \"sequenceStep\": 2,\n    \"subject\": \"Cutting onboarding time in half\",\n    \"bodySnippet\": \"Hi Jane — noticed Acme is scaling fast. We help teams cut onboarding time...\",\n    \"sentAt\": \"2026-04-18T09:12:00Z\",\n    \"messageId\": \"<abc123@mail.getken.ai>\"\n  },\n  \"reply\": {\n    \"subject\": \"Re: Cutting onboarding time in half\",\n    \"body\": \"Thanks for reaching out — happy to chat next week.\",\n    \"classification\": null,\n    \"sentiment\": \"positive\",\n    \"confidence\": null\n  },\n  \"click\": null\n}\n```\n\nEvery delivery contains all six nested keys (`contact`, `company`, `campaign`,\n`email`, `reply`, `click`). The table below shows which ones are **populated**\nfor each event; the rest are sent as `null`:\n\n| Event               | Populated blocks                                  |\n|---------------------|---------------------------------------------------|\n| `email_sent`        | `contact`, `company`, `campaign`, `email`         |\n| `lead_replied`      | `contact`, `company`, `campaign`, `email`, `reply` |\n| `lead_interested`   | `contact`, `company`, `campaign`, `email`, `reply` (`classification` is `\"Interested\"`) |\n| `lead_unsubscribed` | `contact`, `company`, `campaign`                  |\n| `lead_clicked`      | `contact`, `company`, `campaign`, `click`         |\n| `reply_opened`      | `contact`, `company`, `campaign`                  |\n\n`contact` is always populated. `company` is `null` when the contact has no\nassociated company, and `campaign` is `null` when the event cannot be tied to a\ncampaign. `confidence` is always `null` today (no source). On reply events\n(`lead_replied` / `lead_interested`) the `email` block describes the **original\noutbound email** Ken sent (its subject and a snippet of that email's body), while\nthe `reply` block carries the **lead's inbound reply** — they are not the same\nmessage.\n\n### Example deliveries by event type\n\nThe `lead_replied` delivery above shows the full envelope. The other event types use the\nsame envelope and differ only in which blocks are populated; complete examples follow. All\nvalues are illustrative.\n\n**`email_sent`** - an outreach email was sent (`reply` and `click` are `null`):\n\n```json\n{\n  \"eventType\": \"email_sent\",\n  \"eventId\": \"4821:email_sent:1653535\",\n  \"timestamp\": \"2026-04-18T09:12:00Z\",\n  \"clientId\": 4821,\n  \"contact\": {\n    \"id\": 6733796,\n    \"firstName\": \"Daniel\",\n    \"lastName\": \"Brooks\",\n    \"email\": \"daniel.brooks@northwind-saas.com\",\n    \"phone\": null,\n    \"linkedinUrl\": \"https://www.linkedin.com/in/daniel-brooks\",\n    \"title\": \"Head of Growth\",\n    \"seniority\": \"Director\"\n  },\n  \"company\": {\n    \"id\": 4471,\n    \"name\": \"Northwind SaaS\",\n    \"domain\": \"northwind-saas.com\",\n    \"industry\": \"Software Development\",\n    \"size\": \"51 - 200\",\n    \"linkedinUrl\": \"https://www.linkedin.com/company/northwind-saas\"\n  },\n  \"campaign\": {\n    \"id\": 1240,\n    \"name\": \"Outbound - heads of growth, mid-market SaaS\",\n    \"aiWorkflow\": \"Default outbound workflow\"\n  },\n  \"email\": {\n    \"sequenceStep\": 2,\n    \"subject\": \"cutting your activation drop-off\",\n    \"bodySnippet\": \"Hi Daniel - noticed Northwind is scaling its self-serve motion. We help growth teams recover activation drop-off without adding headcount...\",\n    \"sentAt\": \"2026-04-18T09:12:00Z\",\n    \"messageId\": \"<a15959eb-4e93-4fd3-bbb3-130c60c11573@mail.getken.ai>\"\n  },\n  \"reply\": null,\n  \"click\": null\n}\n```\n\n**`lead_interested`** - same shape as `lead_replied`, but `reply.classification` is `\"Interested\"`:\n\n```json\n{\n  \"eventType\": \"lead_interested\",\n  \"eventId\": \"4821:lead_interested:1644085\",\n  \"timestamp\": \"2026-04-20T16:57:48Z\",\n  \"clientId\": 4821,\n  \"contact\": {\n    \"id\": 6028454,\n    \"firstName\": \"Priya\",\n    \"lastName\": \"Raman\",\n    \"email\": \"priya@lumen-analytics.io\",\n    \"phone\": null,\n    \"linkedinUrl\": \"https://www.linkedin.com/in/priya-raman\",\n    \"title\": \"Founder\",\n    \"seniority\": \"Owner\"\n  },\n  \"company\": {\n    \"id\": 5520,\n    \"name\": \"Lumen Analytics\",\n    \"domain\": \"lumen-analytics.io\",\n    \"industry\": \"Software Development\",\n    \"size\": \"11 - 50\",\n    \"linkedinUrl\": \"https://www.linkedin.com/company/lumen-analytics\"\n  },\n  \"campaign\": {\n    \"id\": 1255,\n    \"name\": \"Founder outreach - vertical SaaS\",\n    \"aiWorkflow\": \"Positive-reply workflow\"\n  },\n  \"email\": {\n    \"sequenceStep\": 3,\n    \"subject\": \"the ROI math for Lumen\",\n    \"bodySnippet\": \"Hi Priya - we work with 40+ vertical-SaaS teams at your stage and can build the list, write the sequence, and book real interest...\",\n    \"sentAt\": \"2026-04-20T14:19:00Z\",\n    \"messageId\": \"<a14d88b8-e92a-40aa-885b-f20de5252edb@mail.getken.ai>\"\n  },\n  \"reply\": {\n    \"subject\": \"Re: the ROI math for Lumen\",\n    \"body\": \"This is interesting - can you send a few times next week? Wed or Thu afternoon works.\",\n    \"classification\": \"Interested\",\n    \"sentiment\": \"positive\",\n    \"confidence\": null\n  },\n  \"click\": null\n}\n```\n\n**`lead_clicked`** - a tracked link was clicked (`click` populated; `email` and `reply` are `null`):\n\n```json\n{\n  \"eventType\": \"lead_clicked\",\n  \"eventId\": \"4821:lead_clicked:1781481702041-0\",\n  \"timestamp\": \"2026-04-22T00:01:42Z\",\n  \"clientId\": 4821,\n  \"contact\": {\n    \"id\": 10670983,\n    \"firstName\": \"Marcus\",\n    \"lastName\": \"Webb\",\n    \"email\": \"marcus.webb@tidewater-systems.com\",\n    \"phone\": null,\n    \"linkedinUrl\": \"https://www.linkedin.com/in/marcus-webb\",\n    \"title\": \"Chief Executive Officer\",\n    \"seniority\": \"CLevel\"\n  },\n  \"company\": {\n    \"id\": 7188,\n    \"name\": \"Tidewater Systems\",\n    \"domain\": \"tidewater-systems.com\",\n    \"industry\": \"IT Services and IT Consulting\",\n    \"size\": \"51 - 200\",\n    \"linkedinUrl\": \"https://www.linkedin.com/company/tidewater-systems\"\n  },\n  \"campaign\": {\n    \"id\": 1240,\n    \"name\": \"Outbound - heads of growth, mid-market SaaS\",\n    \"aiWorkflow\": \"Default outbound workflow\"\n  },\n  \"email\": null,\n  \"reply\": null,\n  \"click\": {\n    \"originalUrl\": \"https://www.tidewater-demo.com/case-study\",\n    \"destinationUrl\": \"https://go.getken.ai/cl-4821-ca-1240-rl-792-c-10670983-d-27-ss-2529-so-1-v-B\",\n    \"userAgent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/109.0\",\n    \"ip\": \"203.0.113.42, 198.51.100.7\",\n    \"country\": null,\n    \"clickedAt\": \"2026-04-22T00:01:42Z\"\n  }\n}\n```\n\n**`lead_unsubscribed`** - the lead opted out (context blocks only; `email`, `reply`, `click` are `null`):\n\n```json\n{\n  \"eventType\": \"lead_unsubscribed\",\n  \"eventId\": \"4821:lead_unsubscribed:1640286\",\n  \"timestamp\": \"2026-04-21T11:02:13Z\",\n  \"clientId\": 4821,\n  \"contact\": {\n    \"id\": 6718678,\n    \"firstName\": \"Sofia\",\n    \"lastName\": \"Marin\",\n    \"email\": \"sofia.marin@cedar-freight.com\",\n    \"phone\": null,\n    \"linkedinUrl\": \"https://www.linkedin.com/in/sofia-marin\",\n    \"title\": \"Director of Operations\",\n    \"seniority\": \"Director\"\n  },\n  \"company\": {\n    \"id\": 6033,\n    \"name\": \"Cedar Freight\",\n    \"domain\": \"cedar-freight.com\",\n    \"industry\": \"IT Services and IT Consulting\",\n    \"size\": \"2-10 employees\",\n    \"linkedinUrl\": \"https://www.linkedin.com/company/cedar-freight\"\n  },\n  \"campaign\": {\n    \"id\": 1262,\n    \"name\": \"Ops leaders - logistics\",\n    \"aiWorkflow\": null\n  },\n  \"email\": null,\n  \"reply\": null,\n  \"click\": null\n}\n```\n\n**`reply_opened`** - the lead opened a reply, first unfiltered open (context blocks only):\n\n```json\n{\n  \"eventType\": \"reply_opened\",\n  \"eventId\": \"4821:reply_opened:po_5c1d09a3\",\n  \"timestamp\": \"2026-04-22T13:30:05Z\",\n  \"clientId\": 4821,\n  \"contact\": {\n    \"id\": 6028454,\n    \"firstName\": \"Priya\",\n    \"lastName\": \"Raman\",\n    \"email\": \"priya@lumen-analytics.io\",\n    \"phone\": null,\n    \"linkedinUrl\": \"https://www.linkedin.com/in/priya-raman\",\n    \"title\": \"Founder\",\n    \"seniority\": \"Owner\"\n  },\n  \"company\": {\n    \"id\": 5520,\n    \"name\": \"Lumen Analytics\",\n    \"domain\": \"lumen-analytics.io\",\n    \"industry\": \"Software Development\",\n    \"size\": \"11 - 50\",\n    \"linkedinUrl\": \"https://www.linkedin.com/company/lumen-analytics\"\n  },\n  \"campaign\": {\n    \"id\": 1255,\n    \"name\": \"Founder outreach - vertical SaaS\",\n    \"aiWorkflow\": \"Positive-reply workflow\"\n  },\n  \"email\": null,\n  \"reply\": null,\n  \"click\": null\n}\n```\n\nFor the **pull** feed (`GET /v2/events`), the shape is different: it is a\npaginated list of flat `ClientEventDto` rows, **not** the push payload above.\nEach row looks like:\n\n```json\n{\n  \"eventType\": \"lead_replied\",\n  \"timestamp\": \"2026-04-20T16:05:42Z\",\n  \"campaignId\": 1234,\n  \"campaignName\": \"Q2 outbound — Series B SaaS founders\",\n  \"leadId\": 56789,\n  \"leadEmail\": \"jane@acme.com\",\n  \"leadFullName\": null,\n  \"leadCompany\": null\n}\n```\n\n`leadFullName` and `leadCompany` are reserved and are **always `null`** in the\ncurrent pull feed.\n\n### Verifying signatures\n\nEach delivery carries Svix signature headers so you can verify it came from Ken\nand was not tampered with:\n\n- `svix-id` - unique message id.\n- `svix-timestamp` - Unix timestamp of the send.\n- `svix-signature` - one or more space-separated HMAC-SHA256 signatures.\n\nThe signature is an `HMAC-SHA256` over `{svix-id}.{svix-timestamp}.{raw_body}`,\nkeyed by your endpoint's signing secret (a value beginning with `whsec_`,\nprovided by Ken when your endpoint is registered). Reject deliveries whose\n`svix-timestamp` is outside a few minutes of now to prevent replay. The Svix\nopen-source verification libraries (`svix` for Python, Node, Go, and others)\nimplement this check for you.\n\n### Delivery semantics\n\nDeliveries are retried with backoff on non-`2xx` responses. Respond `2xx`\nquickly and process asynchronously. Events may arrive out of order and, rarely,\nmore than once - dedupe on `svix-id`.\n\n## Date handling\n\nAll timestamps are **UTC ISO-8601** (for example, `2026-04-20T16:05:42Z`). Date-range query\nparameters (`startDate`, `endDate`) accept either a date (`2026-04-20`) or a full datetime\n(`2026-04-20T16:05:42Z`). When both bounds are supplied they must be ordered and span no\nmore than **366 days**.\n\n`GET /v2/analytics` defaults to the **last 30 days** when no range is given, and because it\nfills in the missing bound (`endDate` → now, `startDate` → 30 days before `endDate`) *before*\nvalidating, the ordering and 366-day cap apply even when you supply only one of\n`startDate`/`endDate` — for example, a `startDate` more than 366 days before now is rejected\nwith `400`. For `GET /v2/events`, the cap and ordering are enforced only when **both** bounds\nare present.\n\n## Endpoint groups\n\n- **Campaigns** — list the campaigns in your workspace, read a single campaign's public\n  status and contact counts, and upload leads to trigger campaigns via\n  `POST /v2/campaigns/{id}/leads`. A campaign's `status` is a string that is normally one\n  of `Draft`, `Building`, `Ready`, `Sending`, `Paused`, `Completed`, `Error`, or\n  `Archived`; for a small number of legacy campaigns whose public status has not been\n  computed it can instead be `ToScrape`, `InProgress`, `Scraped`, or `Done`. Treat it as\n  a case-sensitive string and tolerate unrecognized values. Uploads accept a JSON array\n  of 1..1000 lead objects, each a flat map of `field -> scalar value` (string, number, or\n  boolean). Each row requires `first_name`, `last_name`, and either `company_domain` or\n  `company_name`; every other field is optional. The full set of accepted fields is:\n  `first_name`, `last_name`, `full_name`, `title`, `headline`, `location`, `profile_url`,\n  `profile_image`, `summary`, `profile_urn`, `is_open_link`, `last_job_title`,\n  `last_job_description`, `employment_type`, `employment_location`, `skills`, `education`,\n  `city`, `state`, `country`, `seniority_level`, `function`, `is_decision_maker`,\n  `years_of_experience`, `company_name`, `company_domain`, `company_industry`,\n  `company_linkedin_url`, `company_employee_count`, `company_specialities`,\n  `company_location`, `company_description`, `company_technologies`, `company_type`,\n  `company_founded_year`, `company_crunch_base_url`, `company_employee_count_range`,\n  `headquarters_city`, `headquarters_state`, `headquarters_country`, `email`, and\n  `phone_number`. Unknown field names and non-scalar values are rejected per row.\n  Re-uploading the same lead to the same trigger campaign returns `duplicate`, while\n  uploads to other campaigns follow the workspace/campaign duplicate settings. Accepted\n  leads are enriched in an API-upload staging campaign, and outreach is triggered only\n  after a verified email is ready.\n- **Leads** — page through your leads (optionally filtered by `campaignId` and a free-text\n  `search`), where each row is a flat object of selected columns; or export a campaign's\n  leads as CSV (a single file), or as a ZIP of one CSV per segment when the campaign has\n  more than one active segment and a segment column is included in the export `fields`.\n- **Analytics** — campaign engagement totals (sent, delivered, opened, uniqueOpened,\n  replied, uniqueReplied, bounced, interested, unsubscribed, clicked, uniqueClicked).\n  Derived rates (deliveryRate, openRate, replyRate, bounceRate, clickRate) are\n  returned on the `totals` object **only**; the optional per-day breakdown (`daily[]`)\n  carries the raw counts for each day with no rate fields, so compute any per-day\n  rates client-side.\n- **Blocklist** — list, add, and remove suppression entries (email or domain). Entries take\n  effect in Ken immediately and, when the workspace has an EmailBison integration, are\n  mirrored to its blacklist.\n- **Webhook Events** — page through past business events for your campaigns and\n  discover the supported event-type filter values via `GET /v2/events/types`. This\n  pull feed exposes only the three pull-available event types (`lead_replied`,\n  `lead_interested`, `lead_unsubscribed`); the push-only types (`email_sent`,\n  `lead_clicked`, `reply_opened`) are not returned here and `400` if filtered on.\n  This is a pull/history feed; registering outbound webhook delivery to your own\n  URLs is handled by the Ken team, not via this API.",
    "version": "v2"
  },
  "paths": {
    "/v2/analytics": {
      "get": {
        "tags": [
          "Analytics"
        ],
        "summary": "Get campaign analytics totals (and optionally a daily breakdown).",
        "description": "When campaignId is omitted, totals are aggregated across every campaign\nthe authenticated client owns. Date filters default to the last 30 days, and the requested\nwindow may span at most 366 days.\nWhen campaign-scoped canonical analytics is enabled, daily Contacted, human Clicks, Replies,\nand Positive Replies use first-event-per-contact counts over the inclusive UTC window.\n            \nRates are computed by the v2 analytics aggregator: openRate = uniqueOpens / delivered,\nclickRate = uniqueClicks / delivered, replyRate = uniqueRepliers / delivered,\nbounceRate = bounced / sent, deliveryRate = delivered / sent. When unique counts are\nabsent (e.g. campaign-level rows produced by the EmailBison chart-polling fallback), the\nrate numerator falls back to the total event count so engagement is still reflected. The\nengagement-rate denominator (open/click/reply) falls back from a zero `delivered` to\n`sent` so a polling-only window with sends but no recorded delivered total still\nreports engagement instead of `0`, matching `DailyCampaignStatsAggregationJob`\nand the campaign-level analytics endpoints. A rate is still <b>0 only when both delivered\nand sent are 0</b> (or, for deliveryRate/bounceRate, when sent is 0). Rates are clamped to\nand returned as fractions in [0, 1].",
        "parameters": [
          {
            "name": "campaignId",
            "in": "query",
            "description": "Optional campaign id. Omit for client-wide totals.",
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          },
          {
            "name": "startDate",
            "in": "query",
            "description": "Optional UTC start date. Defaults to endDate minus 30 days.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "endDate",
            "in": "query",
            "description": "Optional UTC end date. Defaults to now.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "granularity",
            "in": "query",
            "description": "`total` (default) or `daily`.",
            "schema": {
              "type": "string",
              "default": "total"
            }
          },
          {
            "name": "includeDaily",
            "in": "query",
            "description": "Alias for `granularity=daily`; when `true` the daily breakdown is included. When `true`, the response granularity is reported as `daily` regardless of the granularity value passed.",
            "schema": {
              "type": "boolean",
              "default": false
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Analytics payload for the requested window.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiResponseOfClientAnalyticsDto"
                }
              }
            }
          },
          "400": {
            "description": "Invalid date range or granularity.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, or revoked API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "403": {
            "description": "API key is not bound to exactly one client workspace (it is linked to zero or to multiple workspaces).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "404": {
            "description": "Campaign does not belong to the authenticated client.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v2/blocklist": {
      "get": {
        "tags": [
          "Blocklist"
        ],
        "summary": "List blocklist entries for the authenticated client.",
        "parameters": [
          {
            "name": "Page",
            "in": "query",
            "description": "1-based page number (1..10000). Defaults to 1.",
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "PageSize",
            "in": "query",
            "description": "Page size (1..500). Defaults to 50.",
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "Type",
            "in": "query",
            "description": "Optional filter: `email` or `domain`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "Value",
            "in": "query",
            "description": "Optional exact value to match (email address or domain).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of blocklist entries (empty if the page is past the end).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiPagedResponseOfClientBlocklistDto"
                }
              }
            }
          },
          "400": {
            "description": "Invalid paging arguments.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, or revoked API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "403": {
            "description": "API key is valid but lacks access to this client workspace.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "500": {
            "description": "Internal Server Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "Blocklist"
        ],
        "summary": "Add a blocklist entry (email or domain).",
        "description": "Writes the email or domain to Ken's `do_not_contact_list` only. Sequencer\nfence/planner honor the row. There is no EmailBison blacklist mirror.",
        "requestBody": {
          "description": "Entry payload.",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ClientBlocklistCreateRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/ClientBlocklistCreateRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/ClientBlocklistCreateRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Entry created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiResponseOfClientBlocklistDto"
                }
              }
            }
          },
          "400": {
            "description": "Request body missing, validation failed (invalid type or value), or the entry already exists.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, or revoked API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "403": {
            "description": "API key is valid but lacks access to this client workspace.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "500": {
            "description": "Internal Server Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "504": {
            "description": "EmailBison HttpClient timed out while syncing the entry."
          }
        }
      }
    },
    "/v2/blocklist/{id}": {
      "delete": {
        "tags": [
          "Blocklist"
        ],
        "summary": "Delete a blocklist entry by id.",
        "description": "Deletes the Ken row only. Historical EmailBison blacklist ids are not mirrored out.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Blocklist entry id.",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Entry removed."
          },
          "401": {
            "description": "Missing, malformed, or revoked API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "403": {
            "description": "API key is valid but lacks access to this client workspace.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "404": {
            "description": "Entry not found or not owned by the authenticated client.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "500": {
            "description": "Internal Server Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "504": {
            "description": "EmailBison HttpClient timed out while removing the entry."
          }
        }
      }
    },
    "/v2/campaigns": {
      "get": {
        "tags": [
          "Campaigns"
        ],
        "summary": "List campaigns owned by the authenticated client.",
        "description": "Returns every campaign the scoped client owns, including drafts. The response is not\npaginated; client workspaces typically hold a bounded number of campaigns.\n            \nEach campaign's `status` is a string that is normally one of `Draft`,\n`Building`, `Ready`, `Sending`, `Paused`, `Completed`,\n`Error`, or `Archived`. For a small number of legacy campaigns whose public\nstatus has not been computed, it can instead be one of `ToScrape`,\n`InProgress`, `Scraped`, or `Done`. Treat it as a case-sensitive string\nand tolerate unrecognized values.",
        "responses": {
          "200": {
            "description": "Campaigns returned successfully.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiListResponseOfClientCampaignDto"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, or revoked API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "403": {
            "description": "API key is valid but lacks access to this client workspace.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "500": {
            "description": "Internal Server Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v2/campaigns/{id}": {
      "get": {
        "tags": [
          "Campaigns"
        ],
        "summary": "Get a single campaign by id.",
        "description": "The returned `status` is a string that is normally one of `Draft`,\n`Building`, `Ready`, `Sending`, `Paused`, `Completed`,\n`Error`, or `Archived`. For a small number of legacy campaigns whose public\nstatus has not been computed, it can instead be one of `ToScrape`,\n`InProgress`, `Scraped`, or `Done`. Treat it as a case-sensitive string\nand tolerate unrecognized values.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Ken campaign id.",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Campaign returned successfully.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiResponseOfClientCampaignDto"
                }
              }
            }
          },
          "400": {
            "description": "Invalid campaign id.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, or revoked API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "403": {
            "description": "API key is valid but lacks access to this client workspace.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "404": {
            "description": "Campaign not found or not owned by the authenticated client.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "500": {
            "description": "Internal Server Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v2/campaigns/{id}/leads": {
      "post": {
        "tags": [
          "Campaigns"
        ],
        "summary": "Upload leads into a trigger campaign for deferred enrichment and outreach.",
        "description": "The request body is a JSON array of 1..1000 lead objects. Each lead object is a flat map\nof `field -> scalar value` (string, number, or boolean); non-scalar values and\nunknown field names are rejected per row.\n            \nA lead must include `first_name`, `last_name`, and either `company_domain`\nor `company_name`. If an `email` is supplied it must be a valid address.\n            \nEvery accepted field is optional unless listed as required above. The accepted fields are:\n`first_name`, `last_name`, `full_name`, `title`, `headline`,\n`location`, `profile_url`, `profile_image`, `summary`,\n`profile_urn`, `is_open_link`, `last_job_title`, `last_job_description`,\n`employment_type`, `employment_location`, `skills`, `education`,\n`city`, `state`, `country`, `seniority_level`, `function`,\n`is_decision_maker`, `years_of_experience`, `company_name`,\n`company_domain`, `company_industry`, `company_linkedin_url`,\n`company_employee_count`, `company_specialities`, `company_location`,\n`company_description`, `company_technologies`, `company_type`,\n`company_founded_year`, `company_crunch_base_url`,\n`company_employee_count_range`, `headquarters_city`, `headquarters_state`,\n`headquarters_country`, `email`, and `phone_number`. Any other key (for\nexample `email_validity`, `phone_number_validity`, `qualified`,\n`do_not_contact`, `website_data`, or `website_metadata`) is rejected as an\nunknown field for that row.\n            \nRe-uploading the same lead to the same trigger campaign returns `duplicate`; uploads\nto other campaigns follow the workspace/campaign duplicate settings. Uploaded leads are\nstaged, enriched, and only fire the LeadUploaded trigger after a verified email is ready.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Trigger-mode campaign id.",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "requestBody": {
          "description": "Array of leads to upload.",
          "content": {
            "application/json": {
              "schema": {
                "type": "array",
                "items": {
                  "type": "object",
                  "additionalProperties": { }
                }
              }
            },
            "text/json": {
              "schema": {
                "type": "array",
                "items": {
                  "type": "object",
                  "additionalProperties": { }
                }
              }
            },
            "application/*+json": {
              "schema": {
                "type": "array",
                "items": {
                  "type": "object",
                  "additionalProperties": { }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Upload accepted with per-row results.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiResponseOfUploadClientApiLeadsResult"
                }
              }
            }
          },
          "400": {
            "description": "Invalid body, over the batch cap, non-trigger campaign, missing trigger, or all rows invalid.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/ClientApiError"
                    },
                    {
                      "required": [
                        "data"
                      ],
                      "allOf": [
                        {
                          "$ref": "#/components/schemas/ClientApiErrorOfUploadClientApiLeadsResult"
                        }
                      ]
                    }
                  ]
                },
                "examples": {
                  "invalid-body": {
                    "summary": "Invalid request body",
                    "value": {
                      "success": false,
                      "message": "leads must contain at least one row."
                    }
                  },
                  "all-rows-rejected": {
                    "summary": "All uploaded rows were rejected",
                    "value": {
                      "success": false,
                      "message": "All rows were invalid.",
                      "data": {
                        "accepted": 0,
                        "duplicates": 0,
                        "rejected": 1,
                        "results": [
                          {
                            "index": 0,
                            "status": "rejected",
                            "leadId": null,
                            "errors": [
                              "last_name is required"
                            ]
                          }
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, or revoked API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "403": {
            "description": "API key is valid but lacks access to this client workspace.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "404": {
            "description": "Campaign not found or not owned by the authenticated client.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "500": {
            "description": "Internal Server Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v2/campaigns/{id}/leads/{leadId}": {
      "patch": {
        "tags": [
          "Campaigns"
        ],
        "summary": "Partially update a single campaign lead (safe identity fields + custom variables).",
        "description": "Applies to any lead already present on the campaign, regardless of how it was uploaded\n(Client API upload, CSV, Ken Search, scrape, campaign import, …). At least one body\nfield must be present.\n            \n**Custom variables**\n<list type=\"bullet\"><item><b>Add:</b> non-empty value for a key not yet in the campaign catalog creates the\ncatalog row (`useAsAiContext=false`) and sets the lead value.</item><item><b>Edit:</b> non-empty value for an existing key overwrites only that key on this\nlead; other keys are preserved.</item><item><b>Delete value:</b> empty string (or JSON null) for a key removes that value from\nthis lead only. The campaign catalog row is left alone so sequence tokens and other\nleads keep working. Orphan bag keys no longer in the catalog can also be cleared.</item></list>\nReserved keys that collide with built-in lead fields (for example `first_name`,\n`email`) are rejected. A campaign may hold at most 50 custom-variable catalog keys.\n            \n**Company rename:** `companyName` renames the shared company row for every lead\nlinked to that company. It does not create or re-link companies; leads without a linked\ncompany return `400`.\n            \nSequence preview reads Ken immediately. Already-exported EmailBison leads pick up\nchanges via a best-effort lead refresh.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Campaign id that owns the lead.",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          },
          {
            "name": "leadId",
            "in": "path",
            "description": "Ken lead (contact) id.",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "requestBody": {
          "description": "Partial field bag; at least one field required.",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateClientApiLeadRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateClientApiLeadRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateClientApiLeadRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated lead returned successfully.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiResponseOfClientLeadDto"
                }
              }
            }
          },
          "400": {
            "description": "Empty body, reserved custom-variable key, catalog cap, or company rename without a linked company.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, or revoked API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "403": {
            "description": "API key is valid but lacks access to this client workspace.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "404": {
            "description": "Campaign or lead not found, or not owned by the authenticated client.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "500": {
            "description": "Internal Server Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v2/webhooks/leads/{token}": {
      "post": {
        "tags": [
          "Client Webhooks"
        ],
        "summary": "Receive one lead from a customer-configured inbound webhook.",
        "description": "The token in the path is the only credential. The body is read raw and parsed as\nJSON regardless of Content-Type, because senders such as form tools and no-code\nautomations frequently post JSON with an inaccurate or missing header.",
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Accepted"
          },
          "200": {
            "description": "OK"
          },
          "422": {
            "description": "Unprocessable Content",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/v2/leads": {
      "get": {
        "tags": [
          "Leads"
        ],
        "summary": "List leads for the authenticated client.",
        "parameters": [
          {
            "name": "campaignId",
            "in": "query",
            "description": "Optional campaign filter. When omitted, returns leads across every campaign the client owns.",
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          },
          {
            "name": "page",
            "in": "query",
            "description": "1-based page number (1..10000). Defaults to 1. Note: the out-of-range validation message refers to this parameter by its internal name `pageIndex`.",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 1
            }
          },
          {
            "name": "pageSize",
            "in": "query",
            "description": "Page size (1..500). Defaults to 50.",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 50
            }
          },
          {
            "name": "search",
            "in": "query",
            "description": "Optional free-text search. The match field depends on whether the term contains an `@`: a term containing `@` is matched against lead email addresses only, while a term without `@` is matched against name (first, last, full) and company name only. The two modes are mutually exclusive - a plain term never matches email, and an `@` term never matches name or company.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of leads.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiPagedResponseOfDictionaryOfStringAndObject"
                }
              }
            }
          },
          "400": {
            "description": "Invalid paging arguments. The out-of-range message names the offending field `pageIndex` (the internal name for the `page` query parameter) or `pageSize`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, or revoked API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "403": {
            "description": "API key is valid but lacks access to this client workspace.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "404": {
            "description": "Campaign does not belong to the authenticated client.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "500": {
            "description": "Internal Server Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v2/leads/export": {
      "get": {
        "tags": [
          "Leads"
        ],
        "summary": "Export a campaign's leads.",
        "description": "Returns a single CSV file named `leads_{campaignId}_{yyyyMMdd_HHmmss}.csv` in most cases.\nA multi-file ZIP archive (one CSV per segment, named `leads_{campaignId}_{yyyyMMdd_HHmmss}.zip`)\nis returned only when BOTH of these hold: the campaign has more than one active segment, AND the\n`fields` selection includes a segment field (`segment_name` or\n`contacts.campaign_segment_id`; the legacy `audience_name` field also triggers it). With\nthe default field set, a multi-segment campaign still yields a single CSV - inspect the\n`Content-Type` (`text/csv` vs `application/zip`) rather than assuming a ZIP for\nsegmented campaigns.\n            \nThe `fields` parameter accepts column names from the internal export schema; when omitted,\nthe default field set is used. Unknown or misspelled column names are <b>not</b> rejected - the\nrequest still succeeds with `200` and the unrecognized column is emitted with an empty value\nin every row, so a typo silently produces a blank column rather than an error.\n            \nUnlike the read-only v2 endpoints, export additionally requires the API key's workspace\nrole to be <b>Member or higher</b> (the `ExportAccess` policy). A valid key whose\nuser resolves to a view-only role is rejected with `403`.",
        "parameters": [
          {
            "name": "campaignId",
            "in": "query",
            "description": "Campaign id to export. Required.",
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          },
          {
            "name": "fields",
            "in": "query",
            "description": "Optional list of columns to include. Repeatable query parameter. Unknown column names are accepted (not rejected) and produce an empty column - see the remarks.",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            }
          }
        ],
        "responses": {
          "200": {
            "description": "CSV or ZIP file download.",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              },
              "application/zip": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              },
              "application/json": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "400": {
            "description": "Missing `campaignId`, or the export could not be produced. This also covers a valid campaign that has no exportable contacts, which returns `400` with message `No contacts found for export` (an empty/new campaign is reported as a request error, not an empty file).",
            "content": {
              "text/csv": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              },
              "application/zip": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, or revoked API key.",
            "content": {
              "text/csv": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              },
              "application/zip": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "403": {
            "description": "API key lacks access to this client workspace, or its role is below Member (export requires Member+).",
            "content": {
              "text/csv": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              },
              "application/zip": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "404": {
            "description": "Campaign does not belong to the authenticated client.",
            "content": {
              "text/csv": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              },
              "application/zip": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v2/events": {
      "get": {
        "tags": [
          "Webhook Events"
        ],
        "summary": "List webhook events for the authenticated client's campaigns.",
        "parameters": [
          {
            "name": "eventType",
            "in": "query",
            "description": "Optional event type filter (see `GET /v2/events/types`).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "campaignId",
            "in": "query",
            "description": "Optional campaign filter.",
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          },
          {
            "name": "startDate",
            "in": "query",
            "description": "Optional UTC start of the event window.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "endDate",
            "in": "query",
            "description": "Optional UTC end of the event window.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "page",
            "in": "query",
            "description": "1-based page number (1..10000). Defaults to 1.",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 1
            }
          },
          {
            "name": "pageSize",
            "in": "query",
            "description": "Page size (1..500). Defaults to 50.",
            "schema": {
              "type": "integer",
              "format": "int32",
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of events.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiPagedResponseOfClientEventDto"
                }
              }
            }
          },
          "400": {
            "description": "Invalid paging or date range.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, or revoked API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "403": {
            "description": "API key is valid but lacks access to this client workspace.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "404": {
            "description": "Campaign not found or not owned by the authenticated client.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "500": {
            "description": "Internal Server Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v2/events/types": {
      "get": {
        "tags": [
          "Webhook Events"
        ],
        "summary": "List every public event type accepted by the `eventType` filter.",
        "responses": {
          "200": {
            "description": "List of event type strings.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiResponseOfString[]"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, or revoked API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "403": {
            "description": "API key is valid but lacks access to this client workspace.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "ClientAnalyticsDailyDto": {
        "type": "object",
        "properties": {
          "date": {
            "type": "string",
            "format": "date"
          },
          "sent": {
            "type": "integer",
            "format": "int32"
          },
          "delivered": {
            "type": "integer",
            "format": "int32"
          },
          "opened": {
            "type": "integer",
            "format": "int32"
          },
          "uniqueOpened": {
            "type": "integer",
            "format": "int32"
          },
          "replied": {
            "type": "integer",
            "format": "int32"
          },
          "uniqueReplied": {
            "type": "integer",
            "format": "int32"
          },
          "bounced": {
            "type": "integer",
            "format": "int32"
          },
          "interested": {
            "type": "integer",
            "format": "int32"
          },
          "unsubscribed": {
            "type": "integer",
            "format": "int32"
          },
          "clicked": {
            "type": "integer",
            "format": "int32"
          },
          "uniqueClicked": {
            "type": "integer",
            "format": "int32"
          }
        },
        "additionalProperties": false,
        "example": {
          "date": "2026-04-20",
          "sent": 142,
          "delivered": 139,
          "opened": 91,
          "uniqueOpened": 78,
          "replied": 6,
          "uniqueReplied": 5,
          "bounced": 2,
          "interested": 3,
          "unsubscribed": 1,
          "clicked": 11,
          "uniqueClicked": 9
        }
      },
      "ClientAnalyticsDto": {
        "type": "object",
        "properties": {
          "granularity": {
            "type": "string",
            "nullable": true
          },
          "startDate": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "endDate": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "campaignId": {
            "type": "integer",
            "format": "int64",
            "nullable": true
          },
          "totals": {
            "$ref": "#/components/schemas/ClientAnalyticsTotalsDto"
          },
          "daily": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ClientAnalyticsDailyDto"
            },
            "nullable": true
          }
        },
        "additionalProperties": false,
        "example": {
          "granularity": "daily",
          "startDate": "2026-03-21T00:00:00Z",
          "endDate": "2026-04-20T00:00:00Z",
          "campaignId": 482913,
          "totals": {
            "sent": 2143,
            "delivered": 2101,
            "opened": 1284,
            "uniqueOpened": 1097,
            "replied": 83,
            "uniqueReplied": 76,
            "bounced": 42,
            "interested": 29,
            "unsubscribed": 11,
            "clicked": 157,
            "uniqueClicked": 141,
            "deliveryRate": 0.9804,
            "openRate": 0.5221,
            "replyRate": 0.0362,
            "bounceRate": 0.0196,
            "clickRate": 0.0671
          },
          "daily": [
            {
              "date": "2026-04-20",
              "sent": 142,
              "delivered": 139,
              "opened": 91,
              "uniqueOpened": 78,
              "replied": 6,
              "uniqueReplied": 5,
              "bounced": 2,
              "interested": 3,
              "unsubscribed": 1,
              "clicked": 11,
              "uniqueClicked": 9
            }
          ]
        }
      },
      "ClientAnalyticsTotalsDto": {
        "type": "object",
        "properties": {
          "sent": {
            "type": "integer",
            "format": "int32"
          },
          "delivered": {
            "type": "integer",
            "format": "int32"
          },
          "opened": {
            "type": "integer",
            "format": "int32"
          },
          "uniqueOpened": {
            "type": "integer",
            "format": "int32"
          },
          "replied": {
            "type": "integer",
            "format": "int32"
          },
          "uniqueReplied": {
            "type": "integer",
            "format": "int32"
          },
          "bounced": {
            "type": "integer",
            "format": "int32"
          },
          "interested": {
            "type": "integer",
            "format": "int32"
          },
          "unsubscribed": {
            "type": "integer",
            "format": "int32"
          },
          "clicked": {
            "type": "integer",
            "format": "int32"
          },
          "uniqueClicked": {
            "type": "integer",
            "format": "int32"
          },
          "deliveryRate": {
            "type": "number",
            "format": "double"
          },
          "openRate": {
            "type": "number",
            "format": "double"
          },
          "replyRate": {
            "type": "number",
            "format": "double"
          },
          "bounceRate": {
            "type": "number",
            "format": "double"
          },
          "clickRate": {
            "type": "number",
            "format": "double"
          }
        },
        "additionalProperties": false,
        "example": {
          "sent": 2143,
          "delivered": 2101,
          "opened": 1284,
          "uniqueOpened": 1097,
          "replied": 83,
          "uniqueReplied": 76,
          "bounced": 42,
          "interested": 29,
          "unsubscribed": 11,
          "clicked": 157,
          "uniqueClicked": 141,
          "deliveryRate": 0.9804,
          "openRate": 0.5221,
          "replyRate": 0.0362,
          "bounceRate": 0.0196,
          "clickRate": 0.0671
        }
      },
      "ClientApiError": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean"
          },
          "message": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "example": {
          "success": false,
          "message": "Campaign not found."
        }
      },
      "ClientApiErrorOfUploadClientApiLeadsResult": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean"
          },
          "message": {
            "type": "string",
            "nullable": true
          },
          "data": {
            "$ref": "#/components/schemas/UploadClientApiLeadsResult"
          }
        },
        "additionalProperties": false,
        "example": {
          "success": false,
          "message": "All rows were invalid.",
          "data": {
            "accepted": 0,
            "duplicates": 0,
            "rejected": 1,
            "results": [
              {
                "index": 0,
                "status": "rejected",
                "leadId": null,
                "errors": [
                  "last_name is required"
                ]
              }
            ]
          }
        }
      },
      "ClientApiListResponseOfClientCampaignDto": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean"
          },
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ClientCampaignDto"
            },
            "nullable": true
          },
          "total": {
            "type": "integer",
            "format": "int32"
          }
        },
        "additionalProperties": false,
        "example": {
          "success": true,
          "data": [
            {
              "id": 482913,
              "name": "Q2 outbound — Series B SaaS founders",
              "status": "Sending",
              "contactLimit": 2500,
              "validContactsFound": 2143,
              "createdDatetime": "2026-04-03T14:22:11Z"
            }
          ],
          "total": 1
        }
      },
      "ClientApiPagedResponseOfClientBlocklistDto": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean"
          },
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ClientBlocklistDto"
            },
            "nullable": true
          },
          "pageIndex": {
            "type": "integer",
            "format": "int32"
          },
          "pageSize": {
            "type": "integer",
            "format": "int32"
          },
          "totalCount": {
            "type": "integer",
            "format": "int32"
          },
          "totalPages": {
            "type": "integer",
            "format": "int32"
          }
        },
        "additionalProperties": false,
        "example": {
          "success": true,
          "data": [
            {
              "id": 7712,
              "type": "domain",
              "value": "competitor-corp.com"
            }
          ],
          "pageIndex": 1,
          "pageSize": 50,
          "totalCount": 1,
          "totalPages": 1
        }
      },
      "ClientApiPagedResponseOfClientEventDto": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean"
          },
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ClientEventDto"
            },
            "nullable": true
          },
          "pageIndex": {
            "type": "integer",
            "format": "int32"
          },
          "pageSize": {
            "type": "integer",
            "format": "int32"
          },
          "totalCount": {
            "type": "integer",
            "format": "int32"
          },
          "totalPages": {
            "type": "integer",
            "format": "int32"
          }
        },
        "additionalProperties": false,
        "example": {
          "success": true,
          "data": [
            {
              "eventType": "lead_replied",
              "timestamp": "2026-04-20T16:05:42Z",
              "campaignId": 482913,
              "campaignName": "Q2 outbound — Series B SaaS founders",
              "leadId": 90125,
              "leadEmail": "amanda.chen@northwind-labs.com",
              "leadFullName": null,
              "leadCompany": null
            }
          ],
          "pageIndex": 1,
          "pageSize": 50,
          "totalCount": 1,
          "totalPages": 1
        }
      },
      "ClientApiPagedResponseOfDictionaryOfStringAndObject": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean"
          },
          "data": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": { },
              "example": {
                "id": 90125,
                "first_name": "Amanda",
                "last_name": "Chen",
                "full_name": "Amanda Chen",
                "email": "amanda.chen@northwind-labs.com",
                "email_validity": "Verified",
                "title": "VP of Engineering",
                "profile_url": "https://www.linkedin.com/in/amanda-chen-northwind",
                "company_name": "Northwind Labs",
                "company_domain": "northwind-labs.com",
                "campaign_name": "Q2 outbound — Series B SaaS founders"
              }
            },
            "nullable": true
          },
          "pageIndex": {
            "type": "integer",
            "format": "int32"
          },
          "pageSize": {
            "type": "integer",
            "format": "int32"
          },
          "totalCount": {
            "type": "integer",
            "format": "int32"
          },
          "totalPages": {
            "type": "integer",
            "format": "int32"
          }
        },
        "additionalProperties": false,
        "example": {
          "success": true,
          "data": [
            {
              "id": 90125,
              "first_name": "Amanda",
              "last_name": "Chen",
              "full_name": "Amanda Chen",
              "email": "amanda.chen@northwind-labs.com",
              "email_validity": "Verified",
              "title": "VP of Engineering",
              "profile_url": "https://www.linkedin.com/in/amanda-chen-northwind",
              "company_name": "Northwind Labs",
              "company_domain": "northwind-labs.com",
              "campaign_name": "Q2 outbound — Series B SaaS founders"
            }
          ],
          "pageIndex": 1,
          "pageSize": 50,
          "totalCount": 1,
          "totalPages": 1
        }
      },
      "ClientApiResponseOfClientAnalyticsDto": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean"
          },
          "data": {
            "$ref": "#/components/schemas/ClientAnalyticsDto"
          }
        },
        "additionalProperties": false,
        "example": {
          "success": true,
          "data": {
            "granularity": "daily",
            "startDate": "2026-03-21T00:00:00Z",
            "endDate": "2026-04-20T00:00:00Z",
            "campaignId": 482913,
            "totals": {
              "sent": 2143,
              "delivered": 2101,
              "opened": 1284,
              "uniqueOpened": 1097,
              "replied": 83,
              "uniqueReplied": 76,
              "bounced": 42,
              "interested": 29,
              "unsubscribed": 11,
              "clicked": 157,
              "uniqueClicked": 141,
              "deliveryRate": 0.9804,
              "openRate": 0.5221,
              "replyRate": 0.0362,
              "bounceRate": 0.0196,
              "clickRate": 0.0671
            },
            "daily": [
              {
                "date": "2026-04-20",
                "sent": 142,
                "delivered": 139,
                "opened": 91,
                "uniqueOpened": 78,
                "replied": 6,
                "uniqueReplied": 5,
                "bounced": 2,
                "interested": 3,
                "unsubscribed": 1,
                "clicked": 11,
                "uniqueClicked": 9
              }
            ]
          }
        }
      },
      "ClientApiResponseOfClientBlocklistDto": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean"
          },
          "data": {
            "$ref": "#/components/schemas/ClientBlocklistDto"
          }
        },
        "additionalProperties": false,
        "example": {
          "success": true,
          "data": {
            "id": 7712,
            "type": "domain",
            "value": "competitor-corp.com"
          }
        }
      },
      "ClientApiResponseOfClientCampaignDto": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean"
          },
          "data": {
            "$ref": "#/components/schemas/ClientCampaignDto"
          }
        },
        "additionalProperties": false,
        "example": {
          "success": true,
          "data": {
            "id": 482913,
            "name": "Q2 outbound — Series B SaaS founders",
            "status": "Sending",
            "contactLimit": 2500,
            "validContactsFound": 2143,
            "createdDatetime": "2026-04-03T14:22:11Z"
          }
        }
      },
      "ClientApiResponseOfClientLeadDto": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean"
          },
          "data": {
            "$ref": "#/components/schemas/ClientLeadDto"
          }
        },
        "additionalProperties": false,
        "example": {
          "success": true,
          "data": {
            "id": 90125,
            "campaignId": 482913,
            "firstName": "Amanda",
            "lastName": "Chen",
            "fullName": "Amanda Chen",
            "title": "VP of Engineering",
            "profileUrl": "https://www.linkedin.com/in/amanda-chen-northwind",
            "companyName": "Northwind Labs",
            "customVariables": {
              "rd_link": "https://docs.google.com/presentation/d/abc123",
              "segment": "Series B"
            }
          }
        }
      },
      "ClientApiResponseOfString[]": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean"
          },
          "data": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "ClientApiResponseOfUploadClientApiLeadsResult": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean"
          },
          "data": {
            "$ref": "#/components/schemas/UploadClientApiLeadsResult"
          }
        },
        "additionalProperties": false,
        "example": {
          "success": true,
          "data": {
            "accepted": 1,
            "duplicates": 1,
            "rejected": 1,
            "results": [
              {
                "index": 0,
                "status": "accepted",
                "leadId": 123,
                "errors": [ ]
              },
              {
                "index": 1,
                "status": "duplicate",
                "leadId": null,
                "errors": [ ]
              },
              {
                "index": 2,
                "status": "rejected",
                "leadId": null,
                "errors": [
                  "last_name is required"
                ]
              }
            ]
          }
        }
      },
      "ClientBlocklistCreateRequest": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "nullable": true
          },
          "value": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "example": {
          "type": "email",
          "value": "unsubscribed@example.com"
        }
      },
      "ClientBlocklistDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int64"
          },
          "type": {
            "type": "string",
            "nullable": true
          },
          "value": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "example": {
          "id": 7712,
          "type": "domain",
          "value": "competitor-corp.com"
        }
      },
      "ClientCampaignDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int64"
          },
          "name": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "description": "Public campaign status. Normally one of Draft, Building, Ready, Sending, Paused, Completed, Error, or Archived. For a small number of legacy campaigns whose public status has not been computed, it can instead fall back to ToScrape, InProgress, Scraped, or Done. Treat the value as a case-sensitive string and tolerate unknown values.",
            "nullable": true
          },
          "contactLimit": {
            "type": "integer",
            "format": "int32"
          },
          "validContactsFound": {
            "type": "integer",
            "format": "int32"
          },
          "createdDatetime": {
            "type": "string",
            "format": "date-time"
          }
        },
        "additionalProperties": false,
        "example": {
          "id": 482913,
          "name": "Q2 outbound — Series B SaaS founders",
          "status": "Sending",
          "contactLimit": 2500,
          "validContactsFound": 2143,
          "createdDatetime": "2026-04-03T14:22:11Z"
        }
      },
      "ClientEventDto": {
        "type": "object",
        "properties": {
          "eventType": {
            "type": "string",
            "nullable": true
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          },
          "campaignId": {
            "type": "integer",
            "format": "int64",
            "nullable": true
          },
          "campaignName": {
            "type": "string",
            "nullable": true
          },
          "leadId": {
            "type": "integer",
            "format": "int64",
            "nullable": true
          },
          "leadEmail": {
            "type": "string",
            "nullable": true
          },
          "leadFullName": {
            "type": "string",
            "nullable": true
          },
          "leadCompany": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "example": {
          "eventType": "lead_replied",
          "timestamp": "2026-04-20T16:05:42Z",
          "campaignId": 482913,
          "campaignName": "Q2 outbound — Series B SaaS founders",
          "leadId": 90125,
          "leadEmail": "amanda.chen@northwind-labs.com",
          "leadFullName": null,
          "leadCompany": null
        }
      },
      "ClientLeadDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int64"
          },
          "campaignId": {
            "type": "integer",
            "format": "int64"
          },
          "firstName": {
            "type": "string",
            "nullable": true
          },
          "lastName": {
            "type": "string",
            "nullable": true
          },
          "fullName": {
            "type": "string",
            "nullable": true
          },
          "title": {
            "type": "string",
            "nullable": true
          },
          "profileUrl": {
            "type": "string",
            "nullable": true
          },
          "companyName": {
            "type": "string",
            "nullable": true
          },
          "customVariables": {
            "type": "object",
            "additionalProperties": {
              "type": "string",
              "nullable": true
            },
            "nullable": true
          }
        },
        "additionalProperties": false,
        "example": {
          "id": 90125,
          "campaignId": 482913,
          "firstName": "Amanda",
          "lastName": "Chen",
          "fullName": "Amanda Chen",
          "title": "VP of Engineering",
          "profileUrl": "https://www.linkedin.com/in/amanda-chen-northwind",
          "companyName": "Northwind Labs",
          "customVariables": {
            "rd_link": "https://docs.google.com/presentation/d/abc123",
            "segment": "Series B"
          }
        }
      },
      "ProblemDetails": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "nullable": true
          },
          "title": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "detail": {
            "type": "string",
            "nullable": true
          },
          "instance": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": { }
      },
      "UpdateClientApiLeadRequest": {
        "type": "object",
        "properties": {
          "firstName": {
            "type": "string",
            "nullable": true
          },
          "lastName": {
            "type": "string",
            "nullable": true
          },
          "title": {
            "type": "string",
            "nullable": true
          },
          "profileUrl": {
            "type": "string",
            "nullable": true
          },
          "companyName": {
            "type": "string",
            "nullable": true
          },
          "customVariables": {
            "type": "object",
            "additionalProperties": {
              "type": "string",
              "nullable": true
            },
            "nullable": true
          }
        },
        "additionalProperties": false,
        "example": {
          "title": "Chief Technology Officer",
          "customVariables": {
            "rd_link": "https://docs.google.com/presentation/d/xyz789",
            "stale_note": ""
          }
        }
      },
      "UploadClientApiLeadResult": {
        "type": "object",
        "properties": {
          "index": {
            "type": "integer",
            "format": "int32"
          },
          "status": {
            "type": "string",
            "nullable": true
          },
          "leadId": {
            "type": "integer",
            "format": "int64",
            "nullable": true
          },
          "errors": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "UploadClientApiLeadsResult": {
        "type": "object",
        "properties": {
          "accepted": {
            "type": "integer",
            "format": "int32"
          },
          "duplicates": {
            "type": "integer",
            "format": "int32"
          },
          "rejected": {
            "type": "integer",
            "format": "int32"
          },
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/UploadClientApiLeadResult"
            },
            "nullable": true
          }
        },
        "additionalProperties": false,
        "example": {
          "accepted": 1,
          "duplicates": 1,
          "rejected": 1,
          "results": [
            {
              "index": 0,
              "status": "accepted",
              "leadId": 123,
              "errors": [ ]
            },
            {
              "index": 1,
              "status": "duplicate",
              "leadId": null,
              "errors": [ ]
            },
            {
              "index": 2,
              "status": "rejected",
              "leadId": null,
              "errors": [
                "last_name is required"
              ]
            }
          ]
        }
      }
    },
    "securitySchemes": {
      "Bearer": {
        "type": "http",
        "description": "Enter: Bearer {your Ken-issued sk_live_ API key for Client API v2, or staff Clerk JWT for internal v1 endpoints}",
        "scheme": "bearer",
        "bearerFormat": "Opaque API key or JWT"
      }
    }
  },
  "security": [
    { }
  ],
  "tags": [
    {
      "name": "Analytics",
      "description": "Client-facing campaign analytics. Aggregates MongoDB daily stats for the scoped\nclient across one or all of their campaigns."
    },
    {
      "name": "Blocklist",
      "description": "Client-facing blocklist endpoints. Entries are written to the Ken `do_not_contact_list`\ntable only. Sequencer fence and planner honor the Ken DNC row. There is no EmailBison\nblacklist mirror."
    },
    {
      "name": "Campaigns",
      "description": "Client-facing campaigns endpoints. Scoped to the authenticated API key's client\nworkspace via Ken.Scraper.API.Filters.ClientScopeActionFilter; callers never pass client_id."
    },
    {
      "name": "Client Webhooks",
      "description": "Anonymous inbound webhook lead ingest. The path token is the only credential;\nKen.Scraping.Application.Commands.CampaignWebhookSources.IngestWebhookLeadCommand resolves tenant context from the source row."
    },
    {
      "name": "Leads",
      "description": "Client-facing leads endpoints. ClientId comes from Ken.Scraper.API.Filters.ClientScopeActionFilter;\ncampaign-scoped requests validate that the campaign belongs to the scoped client before\ndispatching."
    },
    {
      "name": "Webhook Events",
      "description": "Client-facing webhook events feed. Reads the configured business webhook-events\ncollection (resolved from `MongoDbConfiguration`) scoped to the authenticated\nclient's campaigns."
    }
  ]
}