{
  "openapi": "3.0.4",
  "info": {
    "title": "Kenso Client API",
    "description": "Welcome to the **Kenso Client API v2** - the public, stable surface for building integrations\nagainst your Kenso 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 Kenso API base URL (for example, `https://api.ken.so`)\nto form the full request URL.\n\n## Authentication\n\nEvery request must carry a Kenso-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 Kenso 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 apply per workspace, shared by every API key of that workspace. Each request counts\nagainst the per-minute limit of its class and against the workspace's daily total. On the\nPlatform plan the limits are:\n\n| Class     | Limit                                                     |\n|-----------|-----------------------------------------------------------|\n| Read      | 3,000 requests per minute                                 |\n| Write     | 1,200 requests per minute                                 |\n| Bulk      | 120 calls per minute, with up to 1,000,000 items per day  |\n| Heavy     | 60 requests per minute                                    |\n| Sensitive | 30 requests per minute                                    |\n\nAll classes together allow **1,000,000 requests per day**. `GET` requests are Read and\nother methods are Write unless an endpoint is marked Bulk, Heavy or Sensitive.\n\nSuccessful responses carry `RateLimit-Limit` (the per-minute limit of the request's\nclass), `RateLimit-Remaining` (requests left in the current minute) and `RateLimit-Reset`\n(seconds until the minute resets), so scripts can pace themselves.\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\n(with `error` set to `rate_limited` and the `class`, `limit` and `window` that was hit),\nwhile a 429 raised at the API gateway may carry a short plain-text body instead.\n\nTo change many records, use bulk endpoints instead of one request per record.\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\nKenso 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).** Kenso 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. Register endpoints yourself, in Kenso's Settings or with the\n  Kenso API 1.1 (`Ken-Version: 1.1` and `X-Ken-Workspace-Id` headers):\n  `POST /v1/webhook-endpoints` with your HTTPS `url` and the `events` to receive,\n  `GET /v1/webhook-endpoints/{webhookEndpointId}/signing-secret` for the signing secret,\n  and `POST /v1/webhook-endpoints/{webhookEndpointId}/test` to send one example\n  delivery. `GET /v1/webhook-event-types` lists every event type your workspace can\n  subscribe to, with its group, description, supported filters and a sample body.\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`, `reply_opened`, and the four\n  workflow events are 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 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. `GET /v1/webhook-event-types` is the authoritative,\ncurrent list: Kenso adds types there as they ship.\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| `reply.received`    | A reply arrives, from a campaign (`lane: \"campaign\"`), a 1:1 account touch (`lane: \"accounts\"`) or an agent's 1:1 thread (`threadId` set, no `lane`). | no |\n| `meeting.booked`    | A meeting is booked with an account.                | no    |\n| `touch.approved`    | A 1:1 account touch is approved.                    | no    |\n| `touch.sent`        | A 1:1 account touch is sent.                        | no    |\n| `signal.detected`   | A new signal is found at an account.                | no    |\n| `asset.viewed`      | Someone views a Studio page.                        | no    |\n| `opportunity.created` | A deal (opportunity) is created.                  | no    |\n| `opportunity.stage_changed` | A deal moves stage, including when it is won or lost (`opportunity.stageCategory` is `open`, `won` or `lost`). | no |\n| `role_candidate.stage_changed` | A recruiting candidate moves pipeline stage on a role (`roleCandidate.toStage`). Recruiting workspaces only. | no |\n| `call.logged`       | A call with a person is logged, in Kenso or by a connected dialer. | no |\n| `workflow_run_failed` | A workflow run fails.                             | no    |\n| `workflow_run_needs_attention` | A workflow run stops until a person acts. | no    |\n| `workflow_published` | A workflow version is published.                   | no    |\n| `workflow_limit_reached` | Event-triggered runs pass a workflow's hourly run limit. | no |\n\nA campaign reply fires both `lead_replied` and `reply.received`; subscribe to one\nof them. Each has its own `eventId`.\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 (the workflow events carry\nflat fields instead; see \"Workflow events\" below). Field names are\n**camelCase**, and the top-level key is `eventType` (not `type`); there is **no**\n`data` wrapper. The six original blocks (`contact`, `company`, `campaign`, `email`,\n`reply`, `click`) and their original fields are always sent: one that does not apply\nis present with a `null` value. Every key added since (see \"Envelope\" below and the\naccount, opportunity, recruiting and call blocks) is left out when it does not apply.\nTreat a missing key and a `null` the same, and ignore keys you do not know: Kenso adds\nkeys without notice. The example below shows a `lead_replied` delivery, so its `click`\nblock is `null`; it leaves out the envelope and full-depth fields described below.\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 lead and email 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** Kenso 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\nA campaign `reply.received` is a `lead_replied` delivery with `eventType`\n`reply.received` and an extra `\"lane\": \"campaign\"` key. The account events\n(`reply.received` with `\"lane\": \"accounts\"`, `meeting.booked`, `touch.approved`,\n`touch.sent`, `signal.detected`, `asset.viewed`) send the six legacy blocks as\n`null` and carry: `lane`, `activityEventId`, `account` (`id`, `name`, `domain`),\n`person` (`id`, `firstName`, `lastName`, `email`, `title`, `linkedinUrl`), `actor`,\nplus `channel` and `touch.id` on touch and reply events,\n`signal` (`id`, `kind`), `meeting` (`id`, `ownerUserId`, `startAt`,\n`personIds`, `firstMeeting`) or `asset` (`id`, `linkId`, `viewId`,\n`templateId`). These newer keys, and an `account` or `person` field Kenso has no value\nfor, are left out when they do not apply. Fetch message content through the API.\n\nThe opportunity events (`opportunity.created`, `opportunity.stage_changed`) carry\nthe same account blocks plus an `opportunity` block: `id`, `name`, `stage`,\n`stageLabel`, `stageCategory` (`open`, `won` or `lost`), and `previousStage` and\n`lostReason` on a stage change (`lostReason` only when the deal was lost). A\ncreated deal also carries `amount`, `currency`, `source`, `sourceCampaignId` and\n`sourceStepOrder`. `person.id` is the deal's primary person and `account.id` its\naccount. `stage` and `previousStage` are the workspace's stage keys; branch on\n`stageCategory` to tell a win or a loss from a normal move.\n\n`role_candidate.stage_changed` carries the contact, company and campaign blocks of the\ncampaign events plus a `roleCandidate` block: `id` (the candidate), `roleId`,\n`contactId`, `campaignId`, `fromStage` (`null` on the move that created the\ncandidate), `toStage`, `source` (`campaign`, `manual`, `referral` or `ats`),\n`moveKind` (`automatic`, `manual` or `ats`) and `changedAt`. Stages are `sourced`,\n`contacted`, `replied`, `interested`, `screen_booked`, `submitted`, `interviewing`,\n`offer`, `hired` and `not_a_fit`. `eventId` is unique per stage change; a redelivery\ncarries the same `eventId`.\n\n`call.logged` goes out once per call, when the call has its outcome: at once for a call\nlogged with one, else when a member sets the first disposition of a dialer call. It\ncarries the account blocks (`touch.id` when the call is for a touch) plus a `call`\nblock: `id`, `direction` (`outbound` or `inbound`), `source` (`manual`, `api`,\n`aircall`, `ringover` or `webhook`), `disposition` (`connected`, `voicemail`,\n`no_answer`, `busy`, `wrong_number`, `meeting_booked` or `not_interested`),\n`durationSeconds`, `startedAt`, `contactId`,\n`campaignId`, `taskId` and `userId` (the member on the call). The call's numbers,\nnotes, transcript and recording are never sent: fetch them with `GET /v1/calls/{callId}`.\n\n`reply.received` for a reply to an agent's 1:1 thread carries the `contact` and `company`\nblocks, no `campaign`, `email.messageId` (the reply's raw id, as on `lead_replied`) and five\ntop-level keys: `threadId` (`thr_`) and `messageId` (`msg_`), the ids the thread routes take;\n`inboxId`, the shared inbox the reply landed in; `extractedText`, the reply's new text without\nthe quoted history and the signature; and `classification` (`interested`, `negative`, ...).\n`classification` is left out while the reply is not classified yet: when classification is\ndelayed (a classifier outage), the event goes out at once without it and is not sent again once\nthe reply is classified. Read the classification with `GET /v1/threads/{threadId}`.\n\n`email.bounced` for a 1:1 message (an agent's thread) carries the `contact` and `company`\nblocks, no `campaign`, `email.messageId` (the bounced message's RFC Message-ID), `inboxId` (the\ninbox that sent it), the `bounce` block and, when the bounced message is found, `threadId`\nand `messageId`, the ids the thread routes take. A 1:1 bounce goes out only in workspaces with\nagent inboxes turned on.\n\n`inbox.status_changed` has no contact. Its `inbox` block is the same block `inbox.send_blocked`\ncarries (`id`, `email`, `providerKind`, `domainId`), and `status` is `send_blocked`, `paused` or\n`active`. A `send_blocked` status adds the `sendBlock` block (`reason`, `until`, `scope`). `active` only means the inbox is no longer paused: a resumed inbox can still be\nsend-blocked or disconnected, so read `GET /v1/inboxes/{inboxId}` before you send from it. It\ngoes out only in workspaces with agent inboxes turned on.\n\n### Envelope\n\nEvery lead, email and account event also carries:\n\n- `schemaVersion` - the payload schema, `\"2026-11\"` today. Within a version Kenso only adds\n  keys; a new version means an existing key changed meaning.\n- `actor` - who caused the event: `kind` (`user`, `agent`, `api_key`, `system` or\n  `workflow`) and `userId` for a user. `kind` is `system` when Kenso cannot tell.\n- `links` - where the event's thread, meeting, account or campaign lives (the first that\n  applies): `app`, the page in Kenso, and `api`, the Kenso API 1.1 route that reads it\n  (`GET /v1/threads/{threadId}`, `/v1/accounts/{accountId}` or\n  `/v1/campaigns/{campaignId}`; no `api` for a meeting). Left out when none applies.\n- `sender` - the workspace inbox that sent the email or received the reply: `inboxId`,\n  `email`, `name`. Left out when Kenso cannot match the message to one of your inboxes.\n\nThe lead and email events carry the full depth of their blocks. These fields are always\npresent in their block, `null` when Kenso has no value (`customVariables` is `{}` instead):\n\n- `contact.customVariables` - the lead's custom variables, keyed as in your templates;\n  `{}` when the lead has none.\n- `contact.aiFields` - what Kenso's AI wrote for the lead: `qualityScore` (1-10),\n  `qualityReasoning`, `qualificationResponse`, `segmentationReasoning`, and\n  `personalization1` to `personalization3`.\n- `email.body` and `email.bodyHtml` - the sent email (`null` on reply events, whose\n  `email` block describes the original email by subject and snippet only),\n  `email.stepId` (the sequence step), `email.variant` (`A`, `B`, ...) and\n  `email.threadId` (the `thr_` conversation id).\n- `reply.bodyHtml`, `reply.threadId` and `reply.channel` (`email`).\n\nEach body is capped at 64 KB of UTF-8. A body cut at the cap sets `truncated: true` on\nits block; read the whole message with `GET /v1/threads/{threadId}`.\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### Workflow events\n\nThe four workflow events use the same top-level `eventType`, `eventId`, `timestamp`, and\n`clientId` keys. They carry no `contact`, `company`, `campaign`, `email`, `reply`, or `click`\nblocks, and no `schemaVersion`, `actor` or `links` yet: each event's own fields sit flat\nbeside the top-level keys, and ids are numbers. The\n`eventId` is `{clientId}:{eventType}:{subject}:{occurrence}`, followed by `:{reason}` when the event\nhas a reason. The subject is the run id (the workflow id for `workflow_published`). The occurrence\nis `v{versionId}` for `workflow_published` and otherwise the send time in Unix milliseconds. Every\noccurrence has its own `eventId`: a workflow published again, or a run restarted and failing again\nfor the same reason, is a new event. All values are illustrative.\n\n**`workflow_run_failed`** - a workflow run failed (`recordType` is `account`, `person`, `lead`,\nor `none`; `recordId` is `null` for a run without a record):\n\n```json\n{\n  \"eventType\": \"workflow_run_failed\",\n  \"eventId\": \"4821:workflow_run_failed:90412:1776866531000:qualify failed\",\n  \"timestamp\": \"2026-04-22T14:02:11Z\",\n  \"clientId\": 4821,\n  \"workflowId\": 77,\n  \"runId\": 90412,\n  \"recordType\": \"account\",\n  \"recordId\": 3312,\n  \"reason\": \"qualify failed\"\n}\n```\n\n**`workflow_run_needs_attention`** - a workflow run stopped until a person acts (`reason` says why):\n\n```json\n{\n  \"eventType\": \"workflow_run_needs_attention\",\n  \"eventId\": \"4821:workflow_run_needs_attention:90415:1776866737000:definition_invalid\",\n  \"timestamp\": \"2026-04-22T14:05:37Z\",\n  \"clientId\": 4821,\n  \"workflowId\": 77,\n  \"runId\": 90415,\n  \"reason\": \"definition_invalid\"\n}\n```\n\n**`workflow_published`** - a workflow version was published:\n\n```json\n{\n  \"eventType\": \"workflow_published\",\n  \"eventId\": \"4821:workflow_published:77:v412\",\n  \"timestamp\": \"2026-04-22T13:58:00Z\",\n  \"clientId\": 4821,\n  \"workflowId\": 77,\n  \"versionId\": 412,\n  \"versionNumber\": 3,\n  \"name\": \"Inbound router\"\n}\n```\n\n**`workflow_limit_reached`** - event-triggered runs passed the workflow's hourly run limit and were\nheld (`runId` is the first held run; `count` is how many runs were held together, `null` for a\nsingle run):\n\n```json\n{\n  \"eventType\": \"workflow_limit_reached\",\n  \"eventId\": \"4821:workflow_limit_reached:90420:1776870003000\",\n  \"timestamp\": \"2026-04-22T15:00:03Z\",\n  \"clientId\": 4821,\n  \"workflowId\": 77,\n  \"runId\": 90420,\n  \"count\": 12,\n  \"limit\": \"event_runs_per_hour\"\n}\n```\n\n### Verifying signatures\n\nEach delivery carries Svix signature headers so you can verify it came from Kenso\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_`; read it\nin Settings or with `GET /v1/webhook-endpoints/{webhookEndpointId}/signing-secret`). 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 are\n  stored on Kenso's do-not-contact list and take effect immediately: the Kenso sequencer stops\n  sending to them. Nothing is mirrored to a third party.\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`, and the four workflow events) are not returned here and\n  `400` if filtered on.\n  This is a pull/history feed. Register outbound webhook delivery to your own URLs\n  in Settings or with the Kenso API 1.1 `/v1/webhook-endpoints` routes (see \"Webhooks\").",
    "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. older campaign-level rows from the retired 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].\n            \nFreshness (DEV-721): the underlying stats are minutes-fresh (stored daily base + bounded\nlive sent/reply/click reads, 180s cache). `asOfUtc` is the response compute time and\n`isRealtime` is endpoint-level — true only when every stats row behind this response\ncame from successful live reads (AND of rows, empty window => false). Each `daily` entry\ncarries its own day-level `asOfUtc`/`isRealtime` (AND of that day's rows).",
        "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. Nothing is mirrored to a third party.",
        "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, or lacks the Workspace settings permission there.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "500": {
            "description": "Internal Server Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "504": {
            "description": "The request timed out before the entry was saved."
          }
        }
      }
    },
    "/v2/blocklist/{id}": {
      "delete": {
        "tags": [
          "Blocklist"
        ],
        "summary": "Delete a blocklist entry by id.",
        "description": "Deletes the Ken row only.",
        "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, or lacks the Workspace settings permission there.",
            "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"
                }
              }
            }
          },
          "409": {
            "description": "The entry mirrors one of the workspace's own domains; remove it in Settings > Company, or ask Ken support to change the Primary domain.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "500": {
            "description": "Internal Server Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiError"
                }
              }
            }
          },
          "504": {
            "description": "The request timed out before the entry was removed."
          }
        }
      }
    },
    "/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.\n            \nNeeds Campaigns edit and the Import permission in the key's workspace: the key owner's permissions,\nnarrowed by the key's permission template (DEV-1654). Without them: `403`.",
        "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"
                }
              }
            }
          },
          "402": {
            "description": "The workspace's plan limit refused the upload. The body carries the refusal `code` (for example `LEAD_CREDITS_LIMIT_REACHED`) and a structured `refusal`; no lead was staged.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientApiRefusalError"
                }
              }
            }
          },
          "403": {
            "description": "API key is valid but lacks access to this client workspace, or lacks Campaigns edit or the Import permission there.",
            "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"
                }
              }
            }
          },
          "503": {
            "description": "Another upload to this campaign is still running. Nothing was staged; retry after the `Retry-After` seconds.",
            "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.\n            \nNeeds Campaigns edit in the key's workspace (DEV-1654). Without it: `403`.",
        "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, or lacks Campaigns edit there.",
            "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. A signal vendor's source (DEV-4371) also\nchecks the vendor's secret header (Common Room's `x-commonroom-webhook-secret`).",
        "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            \nExport needs Campaigns view and the Export permission in the key's workspace: the key owner's\npermissions, narrowed by the key's permission template (DEV-1654; the Admin and Editor templates have\nboth). A key without them 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": {
              "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": {
              "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 lacks access to this client workspace, or its user lacks Campaigns view or the Export permission.",
            "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/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"
          },
          "asOfUtc": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "isRealtime": {
            "type": "boolean"
          }
        },
        "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,
          "asOfUtc": "2026-04-20T16:05:42Z",
          "isRealtime": true
        }
      },
      "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
          },
          "asOfUtc": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "isRealtime": {
            "type": "boolean"
          },
          "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,
          "asOfUtc": "2026-04-20T16:05:42Z",
          "isRealtime": true,
          "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,
              "asOfUtc": "2026-04-20T16:05:42Z",
              "isRealtime": true
            }
          ]
        }
      },
      "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
        }
      },
      "ClientApiRefusalError": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean"
          },
          "message": {
            "type": "string",
            "nullable": true
          },
          "code": {
            "type": "string",
            "nullable": true
          },
          "refusal": {
            "$ref": "#/components/schemas/RefusalDto"
          }
        },
        "additionalProperties": false
      },
      "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,
            "asOfUtc": "2026-04-20T16:05:42Z",
            "isRealtime": true,
            "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,
                "asOfUtc": "2026-04-20T16:05:42Z",
                "isRealtime": true
              }
            ]
          }
        }
      },
      "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": { }
      },
      "RefusalDto": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string",
            "nullable": true
          },
          "metric": {
            "type": "string",
            "nullable": true
          },
          "used": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "limit": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "remaining": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "planType": {
            "type": "string",
            "nullable": true
          },
          "remedy": {
            "type": "string",
            "nullable": true
          },
          "unlocksAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "requiredMillicredits": {
            "type": "integer",
            "format": "int64",
            "nullable": true
          },
          "availableMillicredits": {
            "type": "integer",
            "format": "int64",
            "nullable": true
          },
          "restrictedMillicredits": {
            "type": "integer",
            "format": "int64",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "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 Kenso-issued sk_live_ API key for Client API v2, or a staff Auth0 access token for internal v1 endpoints}",
        "scheme": "bearer",
        "bearerFormat": "Opaque API key or JWT"
      }
    }
  },
  "security": [
    { }
  ],
  "tags": [
    {
      "name": "Analytics",
      "description": "Client-facing campaign analytics. Aggregates daily campaign stats for the scoped\nclient across one or all of their campaigns. Needs Campaigns view in the key's workspace (DEV-1654)."
    },
    {
      "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. Nothing is mirrored to a third\nparty. Any member of the key's workspace reads the list; adding and deleting entries need the\nWorkspace settings permission there, as on `/v1/blocklist-entries` (DEV-1654)."
    },
    {
      "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.\nReads need Campaigns view in that workspace; lead writes need Campaigns edit (DEV-1654)."
    },
    {
      "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. Needs Campaigns view in the key's workspace; export also needs Export (DEV-1654)."
    },
    {
      "name": "Webhook Events",
      "description": "Client-facing webhook events feed. Reads the business webhook-events\nstore scoped to the authenticated\nclient's campaigns. Needs Campaigns view in the key's workspace (DEV-1654)."
    }
  ]
}