> ## Documentation Index
> Fetch the complete documentation index at: https://docs.enrik.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Changelog

> Every change to the public Enrik API, newest first.

Every change to the public API, newest first, under the day it shipped. Operations are named by
their `operationId`. A `beta` operation may change in place; each change is listed here.

## 2026-10-09

How an operation is deprecated, and what a breaking change looks like, now on the new page
[Versioning and deprecation](https://docs.enrik.ai/api-reference/versioning.md). No operation is deprecated, and nothing
changes for a client until one is.

* When an operation is deprecated, every response of it, errors included, carries `Deprecation` (the
  day it was deprecated, as `@` and seconds since 1970, RFC 9745), `Sunset` (the first day it may be
  gone, once scheduled, RFC 8594) and a `Link` with `rel="deprecation"` to this changelog, after the
  API host's own `Link` values. The OpenAPI document marks it `deprecated: true` with
  `x-deprecated-since`, `x-sunset` and `x-replacement`, the `operationId` to move to. The TypeScript
  SDK marks it `@deprecated` and warns once per process when it sends it.
* A breaking change to a stable operation ships as a new operation with its own `operationId` under
  `/v2`, such as `companiesV2.list`, while the `/v1` one stays until its sunset, at least 183 days
  after its deprecation reaches production.

Conditional requests on every public read. Nothing is removed, and a client that ignores the new
headers sees no change.

* Every public `GET` that answers `200` now carries `ETag`, a strong tag over the exact JSON body you
  receive (its base64url SHA-256, quoted), and `Cache-Control: private, no-cache`. Send the tag back
  as `If-None-Match` (one tag, several separated by commas, its weak form `W/"…"`, or `*`): while
  your answer is the same, the API answers `304 Not Modified` with no body and the same two headers.
  The request still runs and counts against the rate limit, so a `304` saves the transfer, not the
  request. The tag is taken over your answer only, after your scopes and fields are applied, so it
  says nothing about what another key or member sees. Each operation declares the `If-None-Match`
  header parameter, the two headers on its `200` and the `304` response, all shared components.
  `/v1/openapi.json` keeps its own tag and `no-cache`, and `exportJobs.download`, a CSV file, carries
  none. The operations: `apiKeys.getCurrent`, `companies.get`, `companies.getProvenance`,
  `companies.getSignalSummary`, `companies.list`, `companies.listContacts`, `companies.listSignals`,
  `companies.listTimeline`, `companies.listTrash`, `companyCandidates.get`,
  `companyCandidates.list`, `companyDiscoveryJobs.get`, `companyDiscoveryJobs.getQualitySummary`,
  `companyDiscoveryJobs.list`, `companyDiscoveryJobs.listCandidates`, `companySignals.get`,
  `contacts.get`, `contacts.list`, `contacts.listTimeline`, `contacts.listTrash`,
  `customFieldDefinitions.list`, `exportFields.getCatalogue`, `exportJobs.get`, `exportJobs.list`,
  `discoveryBriefs.get`, `discoveryBriefs.getVersion`, `discoveryBriefs.list`,
  `discoveryBriefs.listVersions`, `health.get`, `icpAssessments.get`, `icpProfiles.get`,
  `icpProfiles.getVersion`, `icpProfiles.list`, `icpProfiles.listVersions`, `savedViews.get`,
  `savedViews.list`, `signalDefinitions.get`, `signalDefinitions.getVersion`,
  `signalDefinitions.list`, `signalDefinitions.listVersions`, `signalDetectors.getCatalogue`,
  `webhookEventDeliveries.get`, `webhookEventDeliveries.list`, `webhookEventTypes.list`,
  `webhookSubscriptions.get`, `webhookSubscriptions.list`, `websiteOnboarding.get`,
  `websiteOnboarding.getAccess`, `websiteOnboarding.getCompletion` and `workspaceMembers.list`.

Bulk create and bulk update of companies and contacts, in beta. Like the bulk upserts, they count
against `api-key-bulk` as well as `api-key`, and answer `results`, one per item in order, and the
counts. Nothing is removed.

* `companies.bulkCreate`: new. `POST /v1/companies/bulk-create` with `items`, 1 to 100 bodies of
  `companies.create`, and a required `Idempotency-Key` for the whole request: a retry with the same key
  and body answers the first request's results, item for item, and creates nothing again; the same key
  with another body is `409 idempotency_key_reused`. The body is checked as a whole first: a malformed
  item is `422 validation_error` at its path, such as `items.3.name`, and nothing is written. Then the
  items are created one after another, and one that fails, such as `conflict` with `details.code`
  `duplicate_domain`, is left out while the others are created. The answer is `200`: each result has
  `outcome` `created` and the `company`, or `failed` and an `error` in the error envelope's shape with
  the single call's code.

* `contacts.bulkCreate`: new. `POST /v1/contacts/bulk-create`, the same for `contacts.create` bodies;
  an address another live contact has fails its item with `duplicate_email`.

* `companies.bulkUpdate`: new. `POST /v1/companies/bulk-update` with `items`, 1 to 100 bodies of
  `companies.update`, each with the `id` of the company to change. It is checked as a whole like the
  bulk create, then each item runs as `companies.update` in its own transaction: an `id` that is not a
  live company fails with `not_found`, an `expectedUpdatedAt` the company has changed since with
  `stale_version`, and the others are written (`outcome` `updated`). It takes no `Idempotency-Key`.

* `contacts.bulkUpdate`: new. `POST /v1/contacts/bulk-update`, the same for `contacts.update` bodies.
  An API key can rotate and revoke itself, in beta, so a client no longer needs a person in Settings to
  change its secret. Nothing is removed. Rotating or revoking the key in Settings → API keys still ends
  every secret of it at once.

* `apiKeys.rotateCurrent`: new. `POST /v1/me/rotate`, for any valid API key, whatever its scopes.
  It gives the calling key a new secret and answers `200` with the key (its id, name, scopes and
  expiry unchanged), the new secret in `key`, and `previousKeyExpiresAt`. The body is `{}` or sets
  `previousKeyExpiresInSeconds`, 0 to 86400, 3600 when not sent: until then the secret that sent the
  request keeps working too, then it answers `401`; `0` ends it at once. Only the current secret may
  call it: a secret a rotation replaced gets `401` here, as a revoked key does, retries included. It
  takes the required `Idempotency-Key`: a retry with the same key and body, sent with the new secret,
  answers the same new secret, as long as the key has not rotated again or been revoked. If the
  answer is lost, rotate the key in Settings → API keys.

* `apiKeys.revokeCurrent`: new. `POST /v1/me/revoke`, for any valid API key, whatever its scopes, sent
  with either secret. It revokes the calling key, and the secret a rotation left working, and answers
  `200` with
  `{ "ok": true }`. The next request with the key answers `401`; an owner or admin mints a new key in
  Settings → API keys.
  CSV exports of companies, contacts and candidates, in beta, under two new scopes: `exports:read` reads
  export jobs and the fields an export can carry, and `exports:run` creates, previews, starts, cancels and
  archives exports and downloads the file. Every `exports:run` operation also needs `crm:read`, and the
  field catalogue needs `crm:read` beside `exports:read`. A candidate export also needs `discovery:read`. An
  export carries at most 5000 records and counts against the plan's `exports_created` quota when it
  starts. Delivering an export to a webhook destination stays in the app: an API key that asks for a
  `webhook_delivery` export gets `403 permission_denied` with `error.details.code`
  `export_delivery_human_only`. An API key sees only the exports it created, and an export runs as the
  key that created it, with the key's scopes: if the key is revoked or expires before the export runs, or
  no longer reads the record type, the export ends `failed`. Nothing is removed.

* `exportJobs.list`, `exportJobs.get`: new on `exports:read`. `GET /v1/export-jobs` pages the export
  jobs the calling key created, newest first, with `status`, `exportType` and `objectType` filters and
  the shared `cursor` and `limit`; `GET /v1/export-jobs/{id}` reads one. Any other export job, one a
  member or another key created included, is `404 not_found` on every `/v1/export-jobs/{id}` route. A
  job an API key created names the member who created the key as `createdBy`.

* `exportFields.getCatalogue`: new on `exports:read` and `crm:read`. `GET /v1/export-fields?objectType=…`
  answers every field `source.fieldKeys` takes for a record type, the workspace's custom fields
  included, in one answer.

* `exportJobs.create`: new. `POST /v1/export-jobs` with `exportType` `csv_file`, `objectType` and a
  `source` creates a `draft`; it takes the required `Idempotency-Key`. A source that does not fit the
  record type is `422 validation_error` with `error.details.code` `export_invalid_config`, an unknown
  field `export_invalid_field`.

* `exportJobs.preview`: new. `POST /v1/export-jobs/preview` answers up to 10 escaped sample rows and
  creates nothing.

* `exportJobs.start`: new. `POST /v1/export-jobs/{id}/start` queues a draft; poll `exportJobs.get`
  until `status` is final. A job that is no longer `draft` or `queued` is `409 conflict`.

* `exportJobs.cancel`, `exportJobs.archive`: new. `POST /v1/export-jobs/{id}/cancel` and `/archive`.

* `exportJobs.download`: new. `GET /v1/export-jobs/{id}/download` answers the file as `text/csv`, an
  attachment, until `downloadExpiresAt`. A job without a file yet is `409 conflict`; an expired file,
  or one whose recommendation sources can no longer be exported, is `410 gone`.

* `apiKeys.getCurrent`: `scopes` may now list `exports:read` and `exports:run`.

Bulk upsert of companies and contacts, and reading several records by id, in beta. A bulk request
also counts against a new rate-limit policy, `api-key-bulk` (30 a minute by default), on top of
`api-key`; its `RateLimit-Policy` and `RateLimit` name both. Nothing is removed.

* `companies.bulkUpsert`: new. `POST /v1/companies/bulk-upsert` with `items`, 1 to 100 bodies of
  `companies.upsert`. The body is checked as a whole: a malformed item is `422 validation_error` at its
  path, such as `items.3.name`, and nothing is written. Then each item runs as `companies.upsert`, one
  after another, each in its own transaction. The answer is `200` with `results`, one per item in
  order (`index`, `outcome` `created`, `updated` or `failed`, and the `company` or an `error` in the
  error envelope's shape with the single call's code, such as `conflict` with `details.code`
  `ambiguous_domain`), and the counts `created`, `updated` and `failed`. It takes no
  `Idempotency-Key`: sending the same body again is harmless.
* `contacts.bulkUpsert`: new. `POST /v1/contacts/bulk-upsert`, the same for `contacts.upsert` bodies.
* `companies.list`, `contacts.list`: `filters` takes `id`. With `in` it reads up to 100 records at
  once, `[{"field":"id","operator":"in","value":[…]}]`: the ones found, as the list answers them,
  `include` as usual. More than 100 ids is `422 validation_error` at `filters.id`.

Create or update a company by its domain and a contact by its e-mail address, in beta, so a sync no
longer has to search before it writes. Both take the key in the body, need `crm:write` and take no
`Idempotency-Key`: the key is the idempotency key, so sending the same body again is harmless. Nothing
is removed.

* `companies.upsert`: new. `POST /v1/companies/upsert` with `domain` and the create fields. The live
  company with that domain is updated with the fields you send, the rest kept; with none, a company is
  created, which needs `name` (else `422 validation_error` at `name`). The answer is `200` with
  `outcome` (`created` or `updated`) and the `company`. A company in the trash is not matched. A domain
  that more than one live company has, linked to Enrik Data or not, is `409 conflict` with
  `error.details.code` `ambiguous_domain` and the ids in `error.details.companyIds`.
* `contacts.upsert`: new. `POST /v1/contacts/upsert` with `email` and the create fields, the same way:
  the live contact with that address, compared ignoring case, is updated, or a contact is created. The
  answer has `outcome` and the `contact`.
* `companies.create`: a domain another live, unlinked company has is still `409 conflict`, and now
  names that company: `error.details.code` `duplicate_domain`, `error.details.companyId`.
* `contacts.create`: a taken e-mail address is still `409 conflict`, and now names that contact:
  `error.details.code` `duplicate_email`, `error.details.contactId`.

Search on the company and contact lists, in beta. Nothing is removed; a list read without `q` answers
as before.

* `companies.list`: new `q`, 1 to 200 characters. Every whitespace-separated term must appear,
  ignoring case, in the name or the domain; different terms may match different fields. It is ANDed
  with `filters` and keeps the `sort`. A cursor is valid only with the same `q`: another one answers
  `422` with `error.details.code` `invalid_cursor`, so start again without a cursor when you change
  it. With `rankingMode` it is refused with `422 validation_error` at `q`.
* `contacts.list`: new `q`, the same search over the first name, the last name and the e-mail
  address.

Record-change webhook events, in beta. Nine event types join
`companyDiscoveryJob.terminal` under OpenAPI `webhooks`; nothing is added under `paths`. A
subscription receives only the types it names, so an existing subscription sees no change. Events are
being rolled out and are not sent yet. Each body carries ids and who made the change, never record
data: read the record with your key.

* `companies.created`, `companies.updated`, `companies.deleted`, `companies.restored`: the
  `company.created`, `company.updated`, `company.deleted` and `company.restored` events. `data` has
  `companyId` and `changeSource` (`app`, `api`, `import`, `automation`, `assistant` or `system`), plus
  `apiKeyId` when a key made the change, so you can ignore your own writes; `company.updated` also lists
  the `changedFields` by name (`customFields.<key>` for a custom field), never their values. Read the
  company with `companies.get`.
* `contacts.created`, `contacts.updated`, `contacts.deleted`, `contacts.restored`: the same four for
  contacts, with `contactId`. Read the contact with `contacts.get`.
* `companySignals.created`: the `companySignal.created` event, sent when a signal is applied to a
  company, with `signalId` and `companyId`. Read the signal with `companySignals.get`.

`GET /v1/me` names the key, in beta. Nothing is removed; a client that ignores unknown fields needs
no change.

* `apiKeys.getCurrent`: the answer gains `name` (the label the key was given when it was minted, or
  `null`), `prefix` (the first characters of the secret, as Settings → API keys lists them) and
  `expiresAt` (when the key stops authenticating, ISO 8601, or `null` for a key that does not
  expire).

ICP profiles and discovery briefs can be written with an API key, in beta. A new scope,
`discovery:write`, covers creating and changing them; the reads stay on `discovery:read`. Every write
takes the required `Idempotency-Key` header: a retry with the same key and body answers the first
response for 24 hours, the same key with another body is `409 idempotency_key_reused`, and a missing,
blank or over-long key is `422 validation_error`. A key without the scope gets
`403 insufficient_scope`. Drafting a profile or a brief with the model stays in the app.

* `icpProfiles.create`, `icpProfiles.update`, `icpProfiles.archive`, `icpProfiles.restore` and
  `icpProfiles.createVersion`: create a profile with its first version, change its name, description
  or status, archive and restore it, and add a version. The path parameter is `icpProfileId`, as on
  the reads. `update` and `createVersion` need the `expectedRevision` you were shown; a stale one is
  `409 stale_version`.
* `discoveryBriefs.create`, `discoveryBriefs.update`, `discoveryBriefs.archive` and
  `discoveryBriefs.appendVersion`: create a brief pinned to one profile version, change its title,
  content or status, archive it, and add an authored version. The path parameter is
  `discoveryBriefId`, as on the reads.
* `apiKeys.getCurrent`: `scopes` may now list `discovery:write`.

Webhook subscriptions can be managed with an API key, in beta. Two new scopes: `webhooks:read` reads
the subscriptions, the event types they can name and their delivery log; `webhooks:write` creates,
pauses, resumes and archives subscriptions and redelivers events. Only workspace owners and admins can
grant `webhooks:write`. A key without the scope gets `403 insufficient_scope`.

* `webhookSubscriptions.create`: new. `POST /v1/webhook-subscriptions` subscribes a receiver `url`
  (Enrik creates its destination and answers its signing secret once) or an existing `destinationId`
  to event types. It takes the required `Idempotency-Key` header: a retry with the same key and body
  answers the first response, the secret included, for 24 hours.
* `webhookSubscriptions.list` and `webhookSubscriptions.get`: new. A page of the subscriptions, newest
  first, and one subscription, each with its `url`, `eventTypes`, `status` and failure counts, never
  the secret.
* `webhookSubscriptions.disable`, `webhookSubscriptions.enable` and `webhookSubscriptions.archive`:
  new. Pause, resume and end a subscription. A repeat answers it unchanged; an archived one is `404`.
* `webhookEventTypes.list`: new. A page of the event types a subscription can name, with the scope a
  receiver's key needs to read what each one names.
* `webhookEventDeliveries.list`, `webhookEventDeliveries.get` and `webhookEventDeliveries.redeliver`:
  new. A subscription's delivery log a page at a time, one delivery with its attempts, and a redelivery
  of the same event, which takes the required `Idempotency-Key`.
* `apiKeys.getCurrent`: `scopes` may now list `webhooks:read` and `webhooks:write`.

A subscription can be edited, tested and given a new signing secret, in beta, under `webhooks:write`.

* `webhookSubscriptions.update`: new. `PATCH /v1/webhook-subscriptions/{id}` replaces the
  `eventTypes` (each still needs its read permission) or changes the receiver `url` or `description`,
  which changes the subscription's destination. A field left out keeps its value; an empty body is
  `422 validation_error`. A repeat changes nothing more, so it takes no `Idempotency-Key`. A
  destination that sends a bearer token (set up in the app) keeps its URL here: a new `url` for it is
  `422 validation_error` at `secret`, because the token would follow the URL; change it in the app.
* `webhookSubscriptions.rotateSecret`: new. `POST /v1/webhook-subscriptions/{id}/rotate-secret`
  replaces the signing secret and answers the new one once; the old one stops verifying at once. It
  takes the required `Idempotency-Key` header: a retry with the same key answers the same secret for
  24 hours, until the secret is rotated again (`409 idempotency_receipt_superseded`).
* `webhookSubscriptions.test`: new. `POST /v1/webhook-subscriptions/{id}/test` sends one signed
  `webhook.ping` to the receiver and answers its `responseStatus`, `durationMs` and `errorCode`. One
  attempt, no retry, nothing stored: the ping never appears in the delivery log.
* `webhookSubscriptions.ping`: the `webhook.ping` request, published under OpenAPI `webhooks`. It
  carries the headers and the `X-Enrik-Signature` of every event, and `data.subscriptionId`. No
  subscription receives it otherwise.
* `webhookSubscriptions.archive`: a subscription created with a `url` now archives the destination
  made for it; a destination named by `destinationId` stays.

Signal-definition drafts can be written with an API key, in beta, under the same `discovery:write`
scope. Every write takes the required `Idempotency-Key` header, as the profile and brief writes do. A
key writes drafts only: publishing, retiring and deleting a definition stay in the app, and a key's
call to them answers `404`. While signal definitions are switched off for the deployment, a write
answers `503 signal_definitions_disabled`.

* `signalDefinitions.create`: new. Creates a definition and its first draft version. A name already in
  use is `409`.
* `signalDefinitions.update`: new. Changes the name or description, never the content.
* `signalDefinitions.appendVersion`: new. Adds an immutable draft version and answers it with the
  definition's new `revision`.
* `signalDefinitions.archive` and `signalDefinitions.restore`: new. Move the definition to `archived`
  and back to `active`.
* Each one but `create` takes the path parameter `signalDefinitionId`, as on the reads, and needs the
  `expectedRevision` you were shown; a stale one is `409 stale_version`.

Discovery jobs can be created, changed, started, canceled and archived with an API key, in beta.
`discovery:run` now also covers changing a draft job and starting it, so a key minted with it before
this change cannot start a job until it is minted again. Starting a job spends provider budget, so a
key's start is refused while the platform's spend switch is off: `403 permission_denied` with
`error.details.code` `api_spend_disabled`. With the switch on, a key's start also needs fewer than 3
jobs queued or running in the workspace (else `409 concurrent_run_limit`, `discovery_active_jobs`) and
room in the daily allowance of 10 key starts per workspace and 50 on the platform per UTC day (else
`409 conflict`, `discovery_daily_limit`, with `scope` and `resetsAt`); those are the defaults. A job a
key creates runs with that key's own authority, and fails with `permission_denied` if the key was
revoked before it ran. Starts in the app are unchanged.

* `companyDiscoveryJobs.create` (`discovery:run`): create a job in `draft`; it finds nothing until it
  is started. Takes the required `Idempotency-Key` header: a retry with the same key and body answers
  the first response for 24 hours, the same key with another body is `409 idempotency_key_reused`.
* `companyDiscoveryJobs.update` (`discovery:run`): change a draft job's label or its ICP profile and
  brief links. The path parameter is `jobId`, as on the reads.
* `companyDiscoveryJobs.start` (`discovery:run`): queue the job; poll it with
  `companyDiscoveryJobs.get`. Starting a queued job again republishes it and counts once.
* `companyDiscoveryJobs.cancel` (`discovery:run`): any key may cancel a job a key created; a job a
  member created answers `403 permission_denied`.
* `companyDiscoveryJobs.archive` (`discovery:write`): archive a job that is not queued or running.
* `companyDiscoveryJobs.get`, `companyDiscoveryJobs.list`: a job gains `createdByApiKeyId`, the key
  that created it, or `null`; for such a job `createdBy` is the user who minted the key.

Discovery candidates can be reviewed and promoted with an API key, in beta, under the same
`discovery:write` scope; promoting also needs `crm:write`, because it creates or links workspace
companies. None of these writes takes an `Idempotency-Key`: a repeat changes nothing. A single verdict
repeated answers `422` with `error.details.code` `invalid_status`; a bulk repeat reports the item as
failed or skipped. A key without a scope the operation needs gets `403 insufficient_scope` naming it.

* `companyCandidates.accept` and `companyCandidates.reject`: new. Move a `new` or `needs_review`
  candidate to `accepted` or `rejected`. Reject is the review's dismiss.
* `companyCandidates.archive`: new. Moves an `accepted`, `rejected` or `promoted` candidate to
  `archived`, which hides it from review; a promoted candidate's company stays.
* `companyCandidates.promote`: new. Turns an `accepted` candidate into a workspace company: `201` when
  a company was created, `200` when it was linked to one the workspace has. A domain the workspace
  already holds is `409`; for a candidate from Enrik data, send `targetCompanyId` to link instead.
* `companyCandidates.bulkAccept`, `companyCandidates.bulkReject`, `companyCandidates.bulkArchive` and
  `companyCandidates.bulkPromote`: new. The same actions for 1 to 200 `candidateIds`, each candidate
  on its own: one failure never fails another, and the answer reports every item.
* The single-candidate operations take the path parameter `candidateId`, as on the reads.

## 2026-10-08

A members list, in beta, and a contact create now needs a name or an e-mail address.

* `workspaceMembers.list`: new. `GET /v1/workspace-members` pages through the workspace's active
  members, with `crm:write`, as setting an owner needs. Each member has the user `id` that
  `ownerUserId` takes on contacts and companies, and its `name`.
* `contacts.create`: a body without a non-blank `firstName`, `lastName` or `email` is refused with
  `422 validation_error` and one `details` entry at the path `""`, instead of creating an empty
  contact. Send at least one of the three.

Webhook events, in beta. The public document now describes, under OpenAPI `webhooks`, the
signed `POST` Enrik sends to a subscribed destination; nothing is added under `paths`, so no key gains
an operation. Events are being rolled out and are not sent yet.

* `companyDiscoveryJobs.terminal`: the `companyDiscoveryJob.terminal` event, sent once a company
  discovery job completes, completes with errors, fails or is canceled. Its body carries the event
  `id`, `type`, `version`, `occurredAt`, `workspaceId` and `data` (`jobId`, `status`); read the job with
  `companyDiscoveryJobs.get`. Delivery is at least once and unordered: deduplicate by `id`. Verify
  `X-Enrik-Signature` (HMAC-SHA256 over the timestamp, a dot and the raw body); see the webhooks page.

The public document now declares every error status an operation can answer (API review). Nothing
changes on the wire; a generated client gains typed errors. Branch on the status and `error.code`.

* Every operation declares `400`, `422`, `500` and `503`, and, with an API key, `401`, `403` and
  `429`; with a path parameter `404`; a write that is not a delete `409`; a body `413` and `415`.
  `companies.list` and `companyCandidates.list` also declare `404` and `409` (a ranked list). Each
  status is a shared response on the same error envelope that lists the codes answered with it, and
  `ErrorInfo.code` lists every known code as an extensible enum. The operations:
  `apiKeys.getCurrent`, `companies.create`, `companies.delete`, `companies.get`,
  `companies.getProvenance`, `companies.getSignalSummary`, `companies.list`, `companies.listContacts`,
  `companies.listSignals`, `companies.listTimeline`, `companies.listTrash`, `companies.restore`,
  `companies.update`, `companyCandidates.get`, `companyCandidates.list`, `companyDiscoveryJobs.get`,
  `companyDiscoveryJobs.getQualitySummary`, `companyDiscoveryJobs.list`,
  `companyDiscoveryJobs.listCandidates`, `companySignals.get`, `contacts.create`, `contacts.delete`,
  `contacts.get`, `contacts.list`, `contacts.listTimeline`, `contacts.listTrash`, `contacts.restore`,
  `contacts.update`, `customFieldDefinitions.list`, `discoveryBriefs.get`, `discoveryBriefs.getVersion`,
  `discoveryBriefs.list`, `discoveryBriefs.listVersions`, `feedback.submit`, `icpAssessments.get`,
  `icpProfiles.get`, `icpProfiles.getVersion`, `icpProfiles.list`, `icpProfiles.listVersions`,
  `savedViews.create`, `savedViews.delete`, `savedViews.get`, `savedViews.list`, `savedViews.update`,
  `signalDefinitions.get`, `signalDefinitions.getVersion`, `signalDefinitions.list`,
  `signalDefinitions.listVersions`, `signalDetectors.getCatalogue`, `websiteOnboarding.chooseSetups`,
  `websiteOnboarding.complete`, `websiteOnboarding.confirmProfile`, `websiteOnboarding.draftProfile`,
  `websiteOnboarding.get`, `websiteOnboarding.getAccess`, `websiteOnboarding.getCompletion`,
  `websiteOnboarding.reset` and `websiteOnboarding.start`.
* `health.get` joins the public document: `GET /v1/health` answers `{ "status": "ok" }` while the API is
  up, with no API key (`security: []`). It is the API catalog's `status` link. Like every public
  operation it refuses a query parameter it does not declare with `422`.

## 2026-10-07

A filter or cursor value the database cannot read answers `422 validation_error`, like every other
bad value, instead of `400` or `500`. A client that branched on a `400 validation_error` must
handle the `422` instead.

* `companies.list` and `contacts.list`: a filter on an id field (`ownerUserId`, `companyId`) whose
  value is not a UUID, or on a date field whose value lies outside the years 0001–9999, answers
  `422` with one `details` entry whose `path` names the filter, such as `filters.ownerUserId`. The
  id case answered `400`, the date case `500`.
* Every list operation that takes a `cursor`: an edited cursor whose id is not a UUID, or whose
  date lies outside those years, answers `422 validation_error` instead of `400` or `500`. Most
  lists, `companies.list` and `contacts.list` among them, name it with `error.details.code`
  `invalid_cursor`, as they already did for a cursor of another sort. On those lists a cursor
  date that does not exist, such as 30 February, is refused the same way instead of being read
  as a later day.
* Any other value the database cannot read answers `422 validation_error` with no `details`, never
  `400` or `500`.

Errors use the codes the API reference's error table lists. The statuses are unchanged; a
client that branched on the old codes must switch to the new ones.

* `contacts.create` and `contacts.update` (a `companyId` that is not a live company of the
  workspace), and `contacts.create`, `contacts.update`, `companies.create` and `companies.update`
  (an `ownerUserId` that is not an active member), answer `422` with `error.code`
  `validation_error` instead of `unprocessable`. `details` keeps its one entry, `path` and
  `message`, and the entry adds `code: "related_record_not_found"`. The message names the field.
* `websiteOnboarding.confirmProfile`: a website that is not a usable web address answers
  `422 validation_error` with one `details` entry, `path` `profile.website` and `code`
  `invalid_url`, instead of `422 company_twin_invalid_url`.
* `companies.create`, `companies.update`, `contacts.create`, `contacts.update`, `savedViews.create`,
  `savedViews.update`, `feedback.submit`, `websiteOnboarding.start`, `websiteOnboarding.draftProfile`,
  `websiteOnboarding.confirmProfile`, `websiteOnboarding.reset`, `websiteOnboarding.chooseSetups` and
  `websiteOnboarding.complete`: a body over 1 MiB answers `413 payload_too_large`, and a
  `Content-Type` that is not a media type `415 unsupported_media_type`. Both answered
  `internal_error`.
* `companies.create`, `contacts.create` and `savedViews.create`: a retry refused because the key
  lacks `crm:read` still answers `403 permission_denied`; its message now names the scope.
* `savedViews.update` and `savedViews.delete` describe that a company view needs
  `objectType=company`; `websiteOnboarding.getAccess` describes eligibility in scopes.

A workspace without website research confirms its company profile. `websiteOnboarding.confirmProfile`
with a website answered `403 company_twin_controller_unavailable` and saved nothing when website
research was not available to the workspace. It now saves the profile and the setup state and starts
no research. Every Confirm answer carries `research`: `{ "status": "started" }`, or
`{ "status": "not_started", "reason": "no_website" | "unavailable" }`; a replay answers the same.

* `websiteOnboarding.confirmProfile` answers `200` where it answered `403` and adds `research`.
* `websiteOnboarding.get`, `websiteOnboarding.start`, `websiteOnboarding.draftProfile`,
  `websiteOnboarding.reset` and `websiteOnboarding.chooseSetups` share Confirm's answer schema,
  which declares the optional `research`; they never send it. Nothing changes for their clients.

Added `apiKeys.getCurrent`: an API key's description of itself. `GET /v1/me` answers
`{ apiKeyId, workspaceId, scopes }` for any valid API key, whatever its scopes, like `feedback.submit`
(`x-api-key-access: any-authenticated`, `security: [{ apiKey: [] }]`); a browser session gets `401`.
`scopes` are the scope names the key holds, in a fixed order. What the key may do is also bounded by
the permissions frozen when it was minted, so a listed scope does not promise that every operation
naming it succeeds; `403 insufficient_scope` still names what an operation needs. It counts against
the key's ordinary rate limit. The generated document's inventory becomes 58 operations.

* `apiKeys.getCurrent` is new: `GET /v1/me`, beta, any authenticated API key.

Query parameters refuse a parameter they do not declare (verification Issue D). Every operation but
`signalDefinitions.list` used to ignore an undeclared query parameter, so a misspelt `?filter=`
returned the unfiltered list with `200`. It is now refused with `422 validation_error` before anything
is read or written, and the error's `details` name it, as for bodies:
`{ "path": "", "message": "Unrecognized key(s) in object: 'filter'" }`. An operation that declares no
query parameter refuses every one. Unknown tokens inside `include` are still ignored. Migration: drop
cache-busters (`_=…`), tracking parameters and any parameter the reference does not list for the
operation; the TypeScript SDK and MCP already refused them before sending. The OpenAPI document does
not change for this: query parameters were always the ones it lists.

* `companies.list`, `companies.listTrash`, `companies.get`, `companies.listContacts`, `companies.listTimeline` and `companies.listSignals` refuse an undeclared query parameter.
* `contacts.list`, `contacts.listTrash`, `contacts.get` and `contacts.listTimeline` refuse an undeclared query parameter.
* `savedViews.list`, `savedViews.get`, `savedViews.update` and `savedViews.delete` refuse an undeclared query parameter.
* `customFieldDefinitions.list`, `icpAssessments.get`, `icpProfiles.list`, `discoveryBriefs.list` and `discoveryBriefs.listVersions` refuse an undeclared query parameter.
* `companyCandidates.list`, `companyDiscoveryJobs.list` and `companyDiscoveryJobs.listCandidates` refuse an undeclared query parameter, reported before any declared parameter is judged.
* `companies.create`, `companies.update`, `companies.delete`, `companies.restore`, `companies.getProvenance` and `companies.getSignalSummary` take no query parameter and now refuse every one.
* `contacts.create`, `contacts.update`, `contacts.delete` and `contacts.restore` take no query parameter and now refuse every one.
* `companySignals.get`, `companyCandidates.get`, `companyDiscoveryJobs.get`, `companyDiscoveryJobs.getQualitySummary`, `discoveryBriefs.get` and `discoveryBriefs.getVersion` take no query parameter and now refuse every one.
* `icpProfiles.get`, `icpProfiles.listVersions`, `icpProfiles.getVersion`, `signalDefinitions.get`, `signalDefinitions.listVersions`, `signalDefinitions.getVersion` and `signalDetectors.getCatalogue` take no query parameter and now refuse every one.
* `savedViews.create` and `feedback.submit` take no query parameter and now refuse every one.
* `websiteOnboarding.get`, `websiteOnboarding.getAccess`, `websiteOnboarding.getCompletion`, `websiteOnboarding.start`, `websiteOnboarding.draftProfile`, `websiteOnboarding.confirmProfile`, `websiteOnboarding.reset`, `websiteOnboarding.chooseSetups` and `websiteOnboarding.complete` take no query parameter and now refuse every one.
* `signalDefinitions.list` already refused one; unchanged.

On the API host, a method a published path does not support answers `405 method_not_allowed` with
an `Allow` header listing the methods the reference publishes for that path, instead of `404`.
Paths the reference does not publish keep the neutral `404`. The path is read as the router reads
it: it ends at `?` or `#` but not at `;`, so `/v1/me;extra` is a path of its own and answers `404`.
`GET /v1/openapi.json` carries a strong `ETag`; send it as `If-None-Match` to get `304 Not Modified`
while the document is unchanged.

Paging no longer repeats or skips records. A record's `createdAt` and `updatedAt` were stored
with microseconds, while a cursor carries milliseconds. So an ascending `createdAt` or `updatedAt` walk
could return the previous page's last record again (with `limit=1`, forever), and a descending one, the
default order included, could leave out a record created in the same millisecond as the previous page's
last one; a timeline could leave out events written together. Timestamps are now stored to the
millisecond, as they are returned. Values you have already read do not change, and nothing on the wire
changes. No action is needed. (Internal lists that page on a timestamp alone, without an id, still need a
tie-break: #534.)

* `companies.list`, `companies.listContacts`, `companies.listTimeline`, `contacts.list` and `contacts.listTimeline` page without repeats or gaps.
* `companyCandidates.list` (sorted by `createdAt`), `companyDiscoveryJobs.list`, `companyDiscoveryJobs.listCandidates`, `discoveryBriefs.list`, `icpProfiles.list` and `signalDefinitions.list` page without repeats or gaps.

Companies can be filtered on `registerNumber`, `city`, `legalForm` and `summary`. A filter on
them was refused with `422 validation_error`, and the message suggested `customFields.<name>`, which is a
different value: a custom field of that name. Following it returned a wrong answer. They now take every
text operator, `isNull` and `isNotNull` included. A field the record has but that cannot be filtered on,
such as `address`, is still refused, now without that suggestion; it is given only for a name the record
does not have. The `filters` parameters and the saved-view query now list the fields they filter on.

* `companies.list` filters on `registerNumber`, `city`, `legalForm` and `summary`, and its `filters` description lists every field, `nameOrDomain` and the signal fields included.
* `savedViews.create` and `savedViews.update` accept a company view that filters on the four fields.
* `contacts.list` lists its filterable and sortable fields; its behaviour is unchanged.

Request bodies refuse a field they do not declare. A body field an operation does not
declare, at any level of the body, used to be dropped while the request succeeded; it is now
refused with `422 validation_error`, and nothing is written. The error's `details` name each
unknown field: an entry's `path` is the object that holds it (`''` for the top of the body) and its
`message` names the fields, such as `Unrecognized key(s) in object: 'bogusField'`. The request
schemas publish `additionalProperties: false`. Maps of the caller's own keys (`customFields`, a
company's `address`) stay open. Send only the documented fields.

* `companies.create` and `companies.update` refuse an undeclared field.
* `contacts.create` and `contacts.update` refuse an undeclared field.
* `savedViews.create` and `savedViews.update` refuse an undeclared field in the body, its `query`, a filter or a sort key.
* `websiteOnboarding.start`, `websiteOnboarding.reset`, `websiteOnboarding.draftProfile` and `websiteOnboarding.confirmProfile` (its `profile` too) refuse an undeclared field. `websiteOnboarding.chooseSetups`, `websiteOnboarding.complete` and `feedback.submit` already did.

## 2026-10-06

Added anonymous agent feedback. `feedback.submit` is a new beta operation any valid API key
may call, whatever its scopes: it needs no scope and is marked `x-api-key-access: any-authenticated`
with `security: [{ apiKey: [] }]`. It requires an `Idempotency-Key` that is a fresh UUIDv4 per
note (published inline, not the shared 1–200 character header), answers `202 {}`, stores no sender,
and answers `409 idempotency_key_reused` for another body under a used key.

* `feedback.submit` is new: `POST /v1/feedback`, beta, any authenticated API key, the ordinary per-key rate limit.

Added nine beta workspace setup operations: access, state and completion reads; start,
profile draft/confirm, reset, choose setups and complete. They use the new `setup:read`
and `setup:run` scopes. Existing grants receive no setup write authority.
Every setup POST requires `Idempotency-Key` as a UUID equal to body `operationId`, including
browser-session callers. Replays remain durable and actor-bound. API keys must omit private answers.

Public server moves to the configured dedicated API host; internal/browser traffic stays
same-origin. Anonymous `/v1/openapi.json` serves exact packaged public bytes with `no-cache`.
The dedicated host permits public operations only, requires Bearer keys and carries noindex headers.

* `websiteOnboarding.get`, `websiteOnboarding.getAccess` and `websiteOnboarding.getCompletion` are new beta reads requiring `setup:read`.

* `websiteOnboarding.start`, `websiteOnboarding.draftProfile`, `websiteOnboarding.confirmProfile`, `websiteOnboarding.reset`, `websiteOnboarding.chooseSetups` and `websiteOnboarding.complete` are new beta mutations requiring `setup:run` and matching UUID header/body command IDs.

* `icpProfiles.list` and `icpProfiles.get` now return `draft` alongside `active` and `archived`.
  The list's `status` filter also accepts `draft`; an unfiltered list includes drafts. Clients must
  display drafts as awaiting review. Drafts cannot execute evaluations or discovery; activation is
  an explicit revision-fenced metadata PATCH. Public create and writable status values stay unchanged.

## 2026-10-03

* `companySignals.get` is new: fetch one signal under the app's display rule. A key with `crm:read`
  can reach it; a key without `crm:read` gets 403 `insufficient_scope`. A signal hidden from the app,
  including one with unavailable check evidence or a deleted company, remains a neutral 404.

## 2026-10-02

A company's signals, `beta` and read-only. Both need the `crm:read` scope, which now carries the
signals permission (`company_signals:read`).

A key sees what the app shows: the same signals, with the same fields and `evidence` passages,
the same pages and cursors, and the same summary. Like the app, it does not see a company's signals
from signal checks while the evidence of any of them was purged, is past its retention or may not
be shown; they are left out before paging, so `pageInfo` describes only the signals served.

A key whose stored grant lacks `company_signals:read` (one minted with `crm:read` alone before this
change) is refused both with 403 `permission_denied`. A key minted with both `crm:read` and `twin:read`
already holds it. Rotating a key keeps its stored grant and does not add the permission: create a new
key instead.

* `companies.listSignals` is new: the signals recorded about a live company, newest first. It pages
  like `savedViews.list`; filter by stored `status` (`active`, `dismissed` or `archived`), and an
  active signal past its age reads `expired`. A `cursor` whose id is not a uuid answers 422 with
  `error.code` `validation_error` and `error.details.code` `invalid_cursor`.
* `companies.getSignalSummary` is new: what the company's active signals add up to, as the app
  shows it: the latest one, the counts and the priority. With none, or while the app hides the
  company's signal-check results, the answer is the empty summary: it keeps `companyId` and
  `scoringVersion`, the counts are 0, and the latest-signal fields and the priority are `null`.

Rate limits are per API key. A key's requests count against the key itself, never against the
address it calls from, so clients behind one shared egress address (an MCP server, a proxy) no longer
spend each other's budget. Two policies, named in the headers:

* `api-key` counts every request the key makes: 600 a minute.
* `api-key-run` also counts each request to an operation that needs a `run` scope: 60 a minute. No
  public operation needs one yet.

`RateLimit-Policy` always states the quotas that apply; read them there rather than from this page.

Every answer to a key carries `RateLimit-Policy` (each policy's quota and window) and `RateLimit`
(what is left under each, and its effective window in seconds), the IETF RateLimit header fields.
Both are lists, one item per policy that counted the request, such as `"api-key";r=599;t=60`: parse
them as lists, not numbers, and honour every item, since continued traffic must satisfy each policy.
`t` is a window, not a promise that the whole quota is back when it ends. A key past a limit gets
`429` with `error.code` `rate_limited` and `Retry-After` in seconds, which takes priority over
`RateLimit`; wait that long, then retry. Keys no longer get the `X-RateLimit-*` headers of the
per-address limit, which nothing documented.

A network that presents too many invalid, expired or revoked keys (30 a minute) gets `429` with
`Retry-After` for the next minute instead of `401`, whatever key it sends, except a key the API
verified from it recently. Fix the key rather than retrying a `401`.

Every operation's success response now names the two headers, and its `429` names `Retry-After` too.
The examples are each operation's own: none of these needs a `run` scope, so each names `api-key`
alone:

* `companies.create`, `companies.delete`, `companies.get`, `companies.getProvenance`,
  `companies.getSignalSummary`, `companies.list`, `companies.listContacts`,
  `companies.listSignals`, `companies.listTimeline`, `companies.listTrash`, `companies.restore` and
  `companies.update`.
* `companyCandidates.get` and `companyCandidates.list`.
* `companyDiscoveryJobs.get`, `companyDiscoveryJobs.getQualitySummary`, `companyDiscoveryJobs.list`
  and `companyDiscoveryJobs.listCandidates`.
* `contacts.create`, `contacts.delete`, `contacts.get`, `contacts.list`, `contacts.listTimeline`,
  `contacts.listTrash`, `contacts.restore` and `contacts.update`.
* `customFieldDefinitions.list`.
* `discoveryBriefs.get`, `discoveryBriefs.getVersion`, `discoveryBriefs.list` and
  `discoveryBriefs.listVersions`.
* `icpAssessments.get`.
* `icpProfiles.get`, `icpProfiles.getVersion`, `icpProfiles.list` and `icpProfiles.listVersions`.
* `savedViews.create`, `savedViews.delete`, `savedViews.get`, `savedViews.list` and
  `savedViews.update`.
* `signalDefinitions.get`, `signalDefinitions.getVersion`, `signalDefinitions.list` and
  `signalDefinitions.listVersions`.
* `signalDetectors.getCatalogue`.

## 2026-09-30

The first public operations: saved views, all `beta` until launch (they may still change, with an
entry here).

* A key missing an operation's scope gets `403 insufficient_scope` with a
  `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"` challenge naming what it needs.
* `savedViews.list` is new. It needs the `crm:read` scope, and pages: pass `pageInfo.endCursor` as
  `cursor` for the next page; `limit` is 1 to 200, 50 when absent. `objectType` (`contact` or
  `company`) is required.
* `savedViews.get` is new. It needs `crm:read`. Name `objectType=company` for a company view.
* `savedViews.create` is new. It needs `crm:write` and an `Idempotency-Key` header: send a unique key
  per view, and the same key when you retry; a retry returns the first view instead of a second, to a
  key that also holds `crm:read` (without it the retry is refused with 403).
* `savedViews.update` is new. It needs `crm:write`. Sending the same body twice is harmless.
* `savedViews.delete` is new. It needs `crm:write`. Deleting a view that is already deleted also
  answers `{ "ok": true }`, so a retry is harmless.

The discovery reads, all `beta` and read-only: running discovery, reviewing candidates and changing
profiles, briefs or signal definitions stay in the app. Each needs the `discovery:read` scope, and the
ICP assessment also `crm:read`. The lists page like `savedViews.list`: pass `pageInfo.endCursor` as
`cursor`; `limit` is 1 to 200, 50 when absent, and a value outside that is refused with 422, not
clamped. A page may hold fewer items than `limit`: `pageInfo.limit` says what applied (at most 100).

* `companyDiscoveryJobs.list` and `companyDiscoveryJobs.get` are new: discovery jobs, filtered by
  `status` or `sourceType`.
* `companyDiscoveryJobs.listCandidates` and `companyDiscoveryJobs.getQualitySummary` are new: one
  job's candidates, and how good they are.
* `companyCandidates.list` and `companyCandidates.get` are new. The list filters by job, status,
  scores and source; `sort` is one score field, highest first, or `createdAt`. Ask for a ranked list
  with `rankingMode` and `sort=contextual_priority` when `contextualRanking.available` is true; its
  rows carry a `recommendation`. A ranked sort without `rankingMode`, or another sort with it, is
  refused with `400 invalid_sort`.
* `icpProfiles.list`, `icpProfiles.get`, `icpProfiles.listVersions` and `icpProfiles.getVersion`
  are new. A profile's criteria are in its versions; the versions read is an array, newest first.
* `discoveryBriefs.list`, `discoveryBriefs.get`, `discoveryBriefs.listVersions` and
  `discoveryBriefs.getVersion` are new.
* `icpAssessments.get` is new: a company's stored assessment against an ICP profile version. Reading
  never evaluates; a company not assessed yet answers `status: "never_evaluated"`.
* `signalDefinitions.list`, `signalDefinitions.get`, `signalDefinitions.listVersions` and
  `signalDefinitions.getVersion` are new. The list refuses a parameter it does not know.
* `signalDetectors.getCatalogue` is new: every detector kind, and why any cannot run.

Contacts, companies and the custom-field list become public, all `beta` until launch. Every list
pages the same way: pass `pageInfo.endCursor` as `cursor` for the next page; `limit` is 1 to 200, 50
when absent, and a value outside that range is refused. Every create needs an `Idempotency-Key`
header: send a unique key per record, and the same key when you retry; a retry returns the first
record instead of a second, to a key that also holds `crm:read` (without it the retry is refused with
403\), and the same key with another body is refused with 409 `idempotency_key_reused`. Every update
may send the `updatedAt` it read as `expectedUpdatedAt`: if the record changed since, nothing is
written and the answer is 409 `stale_version`. Deleting a record that is already deleted also answers
`{ "ok": true }`, so a retry is harmless. On the record reads, `include` may also ask for the
per-column cell states; those need `enrichment:read` as well (`agentCells`: `automation:read`), and
a key without it is refused with 403 `insufficient_scope`, whatever the page holds. A company's
domain is unique among the live companies not linked to an Enrik company. A company's signal list
and signal summary are not public yet: they stay in the app for now.

* `contacts.list` is new. It needs `crm:read`. `filters`, `sort` and `columns` are JSON text (the
  query a saved view stores); `include=company` adds each contact's company.
* `contacts.listTrash`, `contacts.get` and `contacts.listTimeline` are new. They need `crm:read`.
* `contacts.create`, `contacts.update` and `contacts.restore` are new. They need `crm:write`.
* `contacts.delete` is new. It needs `crm:delete`.
* `companies.list` is new. It needs `crm:read`. It takes the contact list's query, and with
  `rankingMode` (and `sort=contextual_priority`) ranks the companies by recommendation; ranking
  reads the ICP profile and the brief, so it also needs `discovery:read`.
* `companies.listTrash`, `companies.get`, `companies.listTimeline`, `companies.listContacts` and
  `companies.getProvenance` are new. They need `crm:read`.
* `companies.create`, `companies.update` and `companies.restore` are new. They need `crm:write`.
* `companies.delete` is new. It needs `crm:delete`.
* `customFieldDefinitions.list` is new. It needs `crm:read`, and names the keys contacts' and
  companies' `customFields` hold.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.