> ## 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.

# Authentication and quick start

> Use scoped workspace API keys and durable command retries.

The API reference uses the public production server configured in the OpenAPI document.
Every path includes `/v1`. The browser app continues to use its same-origin API.
The public document is available anonymously at `/v1/openapi.json` on both hosts.
Every operation is in beta today; [Versioning and deprecation](https://docs.enrik.ai/api-reference/versioning.md) says what
beta and stable promise, and how a deprecated operation tells you so in its response headers.

The API is server-to-server: call it from your backend, never from a browser. It sends no CORS
headers, so a browser refuses to call it, and an `OPTIONS` request to an API path answers
`405 method_not_allowed`. Keep the key on your server.

Create a key in **Settings → API keys**. Select only the scopes your integration needs.
A key belongs to its workspace and keeps its own frozen permissions when its creator leaves.
Expiry and revocation prevent every subsequent request, including receipt replay.
Provide the key through a secret store or secure credential channel; never put it in a URL,
source file, copied agent prompt or log. Send `Authorization: Bearer <key>`.
Session cookies cannot authenticate on the public API host.

For setup, grant both `setup:read` and `setup:run`. The read scope observes setup
and the company profile. The run scope changes setup and authorizes sponsored drafting and
background company research. Other scopes do not gain setup authority.
Missing scopes return `403 insufficient_scope`; internal routes return `404`.
See [Scopes](#scopes) for what every scope grants.

For each setup POST, create a UUID and use it for both the `Idempotency-Key` header and the
JSON `operationId`. Retain the full command body and UUID until you have its response.
If delivery fails, retry the same command with the same key. A stored response replays without
repeating effects. Header errors return `422`; cross-command or cross-actor reuse returns `409`.
Read fresh state after a conflict and use the schema's current generation and revision.

```bash theme={null}
# Set ENRIK_API_BASE_URL to the production server displayed in the generated API reference.
# ENRIK_API_KEY comes from your secure secret store.
curl "$ENRIK_API_BASE_URL/v1/onboarding/setup" \
  --header "Authorization: Bearer $ENRIK_API_KEY"
```

Request bodies are strict. A field an operation's body does not declare, at any level of the
body, is refused with `422 validation_error` and nothing is written; it is never silently dropped.
The error's `details` name it: each entry's `path` is the object that holds the unknown field (an
empty string for the top level of the body) and its `message` names every unknown field there, for
example `{ "path": "", "message": "Unrecognized key(s) in object: 'bogusField'" }`. Correct the
field names against the operation's schema before you retry. Maps of your own keys, such as
`customFields` and a company's `address`, accept any key.

Query parameters are strict too. A query parameter an operation does not declare, such as a
misspelt `filter` for `filters` or a cache-buster like `_=123`, is refused with `422
validation_error` before anything is read or written. The error's `details` name it, for example
`{ "path": "", "message": "Unrecognized key(s) in object: 'filter'" }`. An operation that declares no
query parameters refuses every one. Unknown tokens inside `include` are still ignored.

## Check your key

`GET /v1/me` tells you which key you are using: its id, its name, the prefix Settings → API keys
shows for it, when it expires (`null` if never), its workspace and the scopes it holds. Any valid key
may call it, whatever its scopes; a browser session gets `401`.

```bash theme={null}
curl "$ENRIK_API_BASE_URL/v1/me" \
  --header "Authorization: Bearer $ENRIK_API_KEY"
```

```json theme={null}
{
  "apiKeyId": "0199a1b2-0000-7000-8000-00000000a001",
  "name": "Nightly sync",
  "prefix": "crm_live_7Ff4mS0k",
  "expiresAt": "2027-01-01T00:00:00.000Z",
  "workspaceId": "0199a1b2-0000-7000-8000-00000000b001",
  "scopes": ["crm:read", "discovery:read"]
}
```

`scopes` lists the scope names the key holds. What the key may do is also bounded by the permissions
frozen when it was minted, so a scope listed here does not promise that every operation naming it
succeeds: an operation that needs a scope the key lacks still answers `403 insufficient_scope` with
the scopes it needs. `apiKeyId` is the id a record's timeline shows as the actor of what this key did.

## Rotate or revoke your key

`POST /v1/me/rotate` gives the key a new secret; its id, name, scopes and expiry stay. The answer
carries the new secret in `key`: store it, it is not shown again. The secret you sent keeps working
until `previousKeyExpiresAt`, one hour by default, so every client can switch. Set
`previousKeyExpiresInSeconds` (0 to 86400) to change that; `0` ends the old secret at once. Only the
current secret may rotate the key: the old secret gets `401` here, as a revoked key does, retries
included. Send an `Idempotency-Key`: the same request with the same key, sent with the new secret,
answers the same new secret. If the answer is lost, so you only hold the old secret, rotate the key
in Settings → API keys before its overlap ends.

```bash theme={null}
curl "$ENRIK_API_BASE_URL/v1/me/rotate" \
  --request POST \
  --header "Authorization: Bearer $ENRIK_API_KEY" \
  --header "Idempotency-Key: $(uuidgen)" \
  --header "Content-Type: application/json" \
  --data '{"previousKeyExpiresInSeconds": 3600}'
```

`POST /v1/me/revoke` revokes the key, and any secret a rotation left working; either secret may send
it, and its next request answers `401`. Any valid key may call both, whatever its scopes. Rotating or revoking the key in
Settings → API keys ends every secret of it at once.

## Scopes

A key holds the scopes you selected when you created it, and every operation names the scopes it
needs. A few parameters need one more: the key is refused with `403 insufficient_scope` when it lacks
it. A scope without a public operation yet grants nothing through the API today.

| Scope | What it grants | Public operations | Also needed for |
| - | - | - | - |
| `setup:read` | Read the workspace setup and its company profile. | `websiteOnboarding.get`, `websiteOnboarding.getAccess`, `websiteOnboarding.getCompletion` | — |
| `setup:run` | Change the setup: confirm the company profile, authorize sponsored drafting and background company research, choose what to set up and complete setup. | `websiteOnboarding.chooseSetups`, `websiteOnboarding.complete`, `websiteOnboarding.confirmProfile`, `websiteOnboarding.draftProfile`, `websiteOnboarding.reset`, `websiteOnboarding.start` | — |
| `workspace:read` | Read the workspace’s own profile: its health summary and setup state. | None yet | — |
| `crm:read` | Read companies with their signals, contacts and the saved views over them. | `companies.get`, `companies.getProvenance`, `companies.getSignalSummary`, `companies.list`, `companies.listContacts`, `companies.listSignals`, `companies.listTimeline`, `companies.listTrash`, `companySignals.get`, `contacts.get`, `contacts.list`, `contacts.listTimeline`, `contacts.listTrash`, `customFieldDefinitions.list`, `exportFields.getCatalogue`, `exportJobs.archive`, `exportJobs.cancel`, `exportJobs.create`, `exportJobs.download`, `exportJobs.preview`, `exportJobs.start`, `icpAssessments.get`, `savedViews.get`, `savedViews.list` | — |
| `crm:write` | Create, update and restore companies and contacts, and create, update and delete saved views. | `companies.bulkCreate`, `companies.bulkUpdate`, `companies.bulkUpsert`, `companies.create`, `companies.restore`, `companies.update`, `companies.upsert`, `companyCandidates.bulkPromote`, `companyCandidates.promote`, `contacts.bulkCreate`, `contacts.bulkUpdate`, `contacts.bulkUpsert`, `contacts.create`, `contacts.restore`, `contacts.update`, `contacts.upsert`, `savedViews.create`, `savedViews.delete`, `savedViews.update`, `workspaceMembers.list` | — |
| `crm:delete` | Delete companies and contacts. | `companies.delete`, `contacts.delete` | — |
| `twin:read` | Read the Company Twin: structured company knowledge and its signals. | None yet | — |
| `discovery:read` | Read ICP profiles, ICP assessments, discovery briefs, discovery jobs, candidates and the signal library. | `companyCandidates.get`, `companyCandidates.list`, `companyDiscoveryJobs.get`, `companyDiscoveryJobs.getQualitySummary`, `companyDiscoveryJobs.list`, `companyDiscoveryJobs.listCandidates`, `discoveryBriefs.get`, `discoveryBriefs.getVersion`, `discoveryBriefs.list`, `discoveryBriefs.listVersions`, `icpAssessments.get`, `icpProfiles.get`, `icpProfiles.getVersion`, `icpProfiles.list`, `icpProfiles.listVersions`, `signalDefinitions.get`, `signalDefinitions.getVersion`, `signalDefinitions.list`, `signalDefinitions.listVersions`, `signalDetectors.getCatalogue` | `rankingMode` on `companies.list` |
| `discovery:write` | Create and change ICP profiles, discovery briefs and signal-definition drafts, and review discovery candidates. | `companyCandidates.accept`, `companyCandidates.archive`, `companyCandidates.bulkAccept`, `companyCandidates.bulkArchive`, `companyCandidates.bulkPromote`, `companyCandidates.bulkReject`, `companyCandidates.promote`, `companyCandidates.reject`, `companyDiscoveryJobs.archive`, `discoveryBriefs.appendVersion`, `discoveryBriefs.archive`, `discoveryBriefs.create`, `discoveryBriefs.update`, `icpProfiles.archive`, `icpProfiles.create`, `icpProfiles.createVersion`, `icpProfiles.restore`, `icpProfiles.update`, `signalDefinitions.appendVersion`, `signalDefinitions.archive`, `signalDefinitions.create`, `signalDefinitions.restore`, `signalDefinitions.update` | — |
| `discovery:run` | Create, start and cancel discovery jobs, and edit a draft one. Uses provider budget. | `companyDiscoveryJobs.cancel`, `companyDiscoveryJobs.create`, `companyDiscoveryJobs.start`, `companyDiscoveryJobs.update` | — |
| `enrichment:read` | Read the enrichment configuration and its results, including a record’s enrichment, formula and custom-API cells. | None yet | `include` with `enrichmentCells`, `formulaCells` or `customApiCells` on `companies.list`, `companies.get`, `contacts.list`, `contacts.get` |
| `enrichment:run` | Launch and cancel enrichment. Spends enrichment credits. | None yet | — |
| `automation:read` | Read workflows and agents with their run history, including a record’s agent cells. | None yet | `include` with `agentCells` on `companies.list`, `companies.get`, `contacts.list`, `contacts.get` |
| `automation:run` | Run existing workflows and agents. | None yet | — |
| `exports:read` | Read export jobs and the fields an export can carry. | `exportFields.getCatalogue`, `exportJobs.get`, `exportJobs.list` | — |
| `exports:run` | Create, preview, start, cancel and archive CSV exports of companies, contacts and candidates, and download the file. Also needs `crm:read`. Delivering an export to a webhook destination stays in the app. | `exportJobs.archive`, `exportJobs.cancel`, `exportJobs.create`, `exportJobs.download`, `exportJobs.preview`, `exportJobs.start` | — |
| `integrations:read` | Read connections, field mappings, CRM sync jobs and webhook destinations. | None yet | — |
| `webhooks:read` | Read webhook subscriptions, the event types they can name and their delivery log. | `webhookEventDeliveries.get`, `webhookEventDeliveries.list`, `webhookEventTypes.list`, `webhookSubscriptions.get`, `webhookSubscriptions.list` | — |
| `webhooks:write` | Subscribe a receiver URL to webhook events; edit, test, pause, resume and archive a subscription and rotate its signing secret; redeliver an event. Only workspace owners and admins can grant it. | `webhookEventDeliveries.redeliver`, `webhookSubscriptions.archive`, `webhookSubscriptions.create`, `webhookSubscriptions.disable`, `webhookSubscriptions.enable`, `webhookSubscriptions.rotateSecret`, `webhookSubscriptions.test`, `webhookSubscriptions.update` | — |
| `reporting:read` | Read analytics and dashboards. | None yet | — |
| `assistant:read` | Read the Assistant: its threads, messages and pending approvals. | None yet | — |
| `assistant:run` | Converse with the Assistant. Uses the workspace’s AI quota. | None yet | — |
| `assistant:write` | Approve or reject the changes the Assistant proposes. | None yet | — |

An operation that names two scopes needs both. Any valid key, whatever its scopes, may call `apiKeys.getCurrent`, `apiKeys.revokeCurrent`, `apiKeys.rotateCurrent` and `feedback.submit`.

## Errors

Every error answers with the same JSON envelope:
`{ "error": { "code": "…", "message": "…", "requestId": "…", "details": … } }`. Branch on the HTTP
status and `error.code`, never on `message`. `details` is present only on some errors:

* A field error (`validation_error`) usually carries an array with one entry per problem: `path`
  names the field (dotted, such as `profile.website`; an empty string for the top of the body),
  `message` says what is wrong, and `code`, where the case has a name, says which:
  `related_record_not_found` when an id such as `companyId` or `ownerUserId` names no live record
  of your workspace, `invalid_url` when a website is not a usable web address.
* Another code that covers several cases carries an object whose `code` names the case, such as
  `invalid_cursor` or a setup conflict's `onboarding_setup_stale`.

Every response also carries an `x-request-id` header; quote the request id when you contact
support.

| Status | `error.code` | When | What to do |
| - | - | - | - |
| `400` | `malformed_json` | The body is not valid JSON, or is empty while `Content-Type` is `application/json`. | Send valid JSON; omit `Content-Type` when there is no body. |
| `400` | `invalid_sort` | A sort names a field or direction the list does not support. | Sort only by the fields the operation lists. |
| `401` | `unauthenticated` | The key is missing, invalid, expired or revoked. | Send a valid, unexpired key. |
| `403` | `insufficient_scope` | The key lacks a scope the operation, or a parameter such as `include` or `rankingMode`, needs. `details.missingScopes` names it; `WWW-Authenticate` lists every scope the request needs. | Create a key with the missing scope. |
| `403` | `permission_denied` | The key’s scopes admit the operation, but its permissions do not cover this request: replaying a create needs `crm:read`, and a key keeps the permissions its scopes had when it was created. The message names the scope. | Create a new key with the scope the message names. |
| `403` | `beta_access_required` | The workspace is not admitted to the beta, which every write except `feedback.submit` needs. | Contact support. |
| `403` | `not_a_member` | `websiteOnboarding.getAccess`: the key’s workspace has been deleted. | Use a key of a live workspace. |
| `404` | `not_found` | No such record in your workspace. A path that is not part of the public API also answers `404`. | Check the id and the path. |
| `405` | `method_not_allowed` | The path exists, but not for this method; an `OPTIONS` request to an API path is one, since the API sends no CORS headers. The `Allow` header lists the methods the path supports. | Use a listed method, from a server. |
| `409` | `conflict` | The request clashes with the current state, such as a duplicate domain or e-mail, a changed setup or a ranked list that changed. `details.code` names the case where there is one. | Read fresh state, then decide. |
| `409` | `stale_version` | The record changed since the `expectedUpdatedAt` you sent. `details.currentUpdatedAt` is its current version. | Read the record again and reapply your change. |
| `409` | `idempotency_key_reused` | The `Idempotency-Key` was used for a different command or body. | Use a new key for a new command. |
| `409` | `request_in_flight` | The first request with this `Idempotency-Key` is still running. | Retry shortly with the same key. |
| `409` | `company_twin_research_already_running` | `websiteOnboarding.confirmProfile`: research for this setup or website is already running. | Read fresh setup state. |
| `409` | `company_twin_own_domain_conflict` | `websiteOnboarding.confirmProfile`: the website does not match the workspace’s company profile. | Read fresh setup state, then confirm the profile with its own website. |
| `409` | `concurrent_run_limit` | `companyDiscoveryJobs.start` by an API key: the workspace already has as many discovery jobs queued or running as a key may start beside. `details.code` names the cap. | Wait for a job to finish, or cancel one, then start again. |
| `410` | `gone` | `exportJobs.download`: the file is past its `downloadExpiresAt`, or can no longer be exported; `details.code` says which. | Start a new export of the same source. |
| `413` | `payload_too_large` | The body is over the size limit, currently 1 MiB. | Send a smaller body. |
| `415` | `unsupported_media_type` | The `Content-Type` header is not a media type. | Send `Content-Type: application/json`. |
| `422` | `validation_error` | The request does not match the operation’s schema, a field names something that does not exist, or a value cannot be used, such as an id that is not a UUID, a date outside the years 0001–9999 in a filter, or an edited cursor. See `details`. | Fix the request. |
| `429` | `rate_limited` | The key spent a rate limit, or its network presented too many invalid keys. | Wait for `Retry-After`, then send again. |
| `500` | `internal_error` | Something failed on our side. | Retry later; reuse a command’s idempotency key. |
| `500` | `response_contract_violation` | Our answer broke its own schema, so it was withheld. `details.committed` says whether a change was saved. | If `committed` is true, read the state instead of resending. Quote the request id. |
| `503` | `service_unavailable` | A create ran out of time, or the service is briefly unavailable. | Retry later with the same idempotency key. |

The OpenAPI document declares these statuses on every operation that can answer them, and lists the
codes as the known values of `ErrorInfo.code`. More codes may be added: handle one you do not know by
its HTTP status.

### Error cases

A code that covers several cases names the case in `details.code`, or, for a field error, in the
`code` of a `details` entry:

| Status | `error.code` | `details.code` | Operations | When | What to do |
| - | - | - | - | - | - |
| `409` | `conflict` | `onboarding_ai_in_progress` | `websiteOnboarding.draftProfile` | A draft for this setup is already being written. `details.retryAfterMs` says how long to wait. | Wait `retryAfterMs` milliseconds, then retry the same command with the same key. |
| `409` | `conflict` | `onboarding_setup_stale` | `websiteOnboarding.draftProfile`, `websiteOnboarding.confirmProfile`, `websiteOnboarding.reset`, `websiteOnboarding.chooseSetups`, `websiteOnboarding.complete` | The setup changed since you read it: the generation or revision you sent is not current. | Read the setup again, then send a new command with its current generation and revision. |
| `409` | `conflict` | `onboarding_setup_counter_exhausted` | `websiteOnboarding.draftProfile`, `websiteOnboarding.confirmProfile`, `websiteOnboarding.reset`, `websiteOnboarding.complete` | The setup’s revision counter is used up, so it cannot change again. | Contact support. |
| `409` | `conflict` | `onboarding_setup_completed` | `websiteOnboarding.draftProfile`, `websiteOnboarding.confirmProfile`, `websiteOnboarding.chooseSetups` | Setup is already complete. | Read the setup; nothing is left to do. |
| `409` | `conflict` | `onboarding_setups_already_chosen` | `websiteOnboarding.confirmProfile` | The setups are already chosen, so the profile cannot change any more. | Reset the setup with `websiteOnboarding.reset` to change the profile. |
| `409` | `conflict` | `onboarding_profile_changed` | `websiteOnboarding.confirmProfile` | The company profile changed since you read it: `expectedProfileHash` is not current. | Read the setup again and confirm the profile as it is now. |
| `409` | `conflict` | `onboarding_profile_required` | `websiteOnboarding.complete` | Setup cannot complete before the company profile is confirmed. | Confirm the profile first. |
| `409` | `conflict` | `onboarding_handoff_required` | `websiteOnboarding.complete` | A workspace member has not yet acknowledged the setup handoff in the app. | Ask a workspace member to open the setup in the app and acknowledge it. |
| `409` | `conflict` | `onboarding_setups_required` | `websiteOnboarding.complete` | Setup cannot complete before the setups are chosen. | Choose the setups first. |
| `409` | `conflict` | `duplicate_domain` | `companies.create` | Another live company that is not linked to Enrik Data has the domain. `details.companyId` names it. | Update that company, or create or update by domain with `companies.upsert`. |
| `409` | `conflict` | `duplicate_email` | `contacts.create` | Another live contact has the e-mail address. `details.contactId` names it. | Update that contact, or create or update by e-mail with `contacts.upsert`. |
| `409` | `conflict` | `ambiguous_domain` | `companies.upsert` | More than one live company has the domain, linked to Enrik Data or not, so it names no single company. `details.companyIds` lists them. | Update the company you mean by its id with `companies.update`. |
| `409` | `conflict` | `recommendation_restart_required` | `companies.list`, `companyCandidates.list` | The ranked list changed while you paged through it. | Start again from the first page, without a cursor. |
| `404` | `not_found` | `recommendation_brief_not_found` | `companies.list`, `companyCandidates.list` | A contextual ranking names a brief or brief version your workspace does not have. | Check `briefId` and `briefVersionId`. |
| `422` | `validation_error` | `invalid_cursor` | every list | The cursor was not issued for this list, was edited, or does not match the `sort` you sent. | Send the cursor back unchanged with the same sort, or start without one. |
| `422` | `validation_error` | `recommendation_cursor_invalid` | `companies.list`, `companyCandidates.list` | A ranked list’s cursor cannot be read. | Start again from the first page. |
| `422` | `validation_error` | `recommendation_cursor_foreign` | `companies.list`, `companyCandidates.list` | The cursor belongs to another ranked list: another mode, brief or set of parameters. | Send the cursor only with the parameters of the page that returned it. |
| `422` | `validation_error` | `recommendation_contextual_disabled` | `companies.list`, `companyCandidates.list` | Ranked lists are not available to this workspace yet. | List without `rankingMode`; `contextualRanking.available` says when they are. |
| `422` | `validation_error` | `recommendation_brief_version_mismatch` | `companies.list`, `companyCandidates.list` | `briefVersionId` is not a version of `briefId`. | Send a version of the brief you name. |
| `422` | `validation_error` | `icp_assessment_no_active_profile` | `icpAssessments.get`, `companies.list`, `companyCandidates.list` | The workspace has no active ICP profile to assess against. | Activate an ICP profile in the app first. |
| `422` | `validation_error` | `icp_assessment_ambiguous_profile` | `icpAssessments.get`, `companies.list`, `companyCandidates.list` | The workspace has more than one active ICP profile, so “the ICP” names none. | Pass the `icpProfileId` you mean. |
| `422` | `validation_error` | `icp_assessment_profile_draft` | `icpAssessments.get`, `companies.list`, `companyCandidates.list` | The ICP profile is still a draft. | Activate the profile in the app, or name another one. |
| `422` | `validation_error` | `icp_assessment_profile_archived` | `icpAssessments.get`, `companies.list`, `companyCandidates.list` | The ICP profile is archived. | Restore the profile in the app, or name another one. |
| `422` | `validation_error` | `icp_assessment_version_without_profile` | `icpAssessments.get` | `icpProfileVersionId` was sent without `icpProfileId`. | Send `icpProfileId` with the version. |
| `422` | `validation_error` | `related_record_not_found` (on a `details` entry) | `companies.create`, `companies.update`, `contacts.create`, `contacts.update` | An id such as `companyId` or `ownerUserId` names no live record of your workspace. The entry’s `path` names the field. | Send the id of a live record, or leave the field out. |
| `422` | `validation_error` | `invalid_url` (on a `details` entry) | `websiteOnboarding.confirmProfile` | A website is not a usable web address. The entry’s `path` names the field. | Send the full address of a public website, such as `https://example.com`. |
| `403` | `permission_denied` | `api_spend_disabled` | `companyDiscoveryJobs.start` | Starting a discovery job with an API key is switched off for the platform: a job spends provider budget. Creating, changing and canceling jobs still work. | Start the job in the app, or ask support whether it can be enabled. |
| `409` | `concurrent_run_limit` | `discovery_active_jobs` | `companyDiscoveryJobs.start` | The workspace already has the cap of discovery jobs queued or running (`details.limit`, 3 by default). | Wait for a job to finish, or cancel one, then start again. |
| `409` | `conflict` | `discovery_daily_limit` | `companyDiscoveryJobs.start` | The daily allowance of discovery jobs started with an API key is used up, for the workspace or the whole platform (`details.scope`); `details.resetsAt` is when it renews. | Start again after `resetsAt`, or start the job in the app. |
| `422` | `validation_error` | `invalid_status` | `companyCandidates.accept`, `companyCandidates.reject`, `companyCandidates.archive`, `companyCandidates.promote` | The candidate’s status does not allow this action: it was already done, or the candidate moved on. | Read the candidate. A repeat of an action already done needs nothing more. |
| `409` | `conflict` | `duplicate_company` | `companyCandidates.promote` | A company in the workspace already has the candidate’s domain; nothing was created. | Use that company, or archive the candidate. |
| `409` | `conflict` | `domain_collision` | `companyCandidates.promote` | Companies in the workspace share the domain of a candidate from Enrik data; `details.candidates` lists them. | Promote again with `targetCompanyId` set to one of them to link the candidate to it. |
| `409` | `conflict` | `canonical_conflict` | `companyCandidates.promote` | The company `targetCompanyId` names is already linked to another Enrik company. | Name another company, or leave `targetCompanyId` out. |
| `422` | `validation_error` | `export_invalid_config` | `exportJobs.create`, `exportJobs.preview` | The `source` does not fit the record type, or is malformed. `details.issues` lists what is wrong, each at its path. | Fix the source; `saved_view` is for companies and contacts, `discovery_job` for candidates. |
| `422` | `validation_error` | `export_invalid_field` | `exportJobs.create`, `exportJobs.preview` | A key in `source.fieldKeys` is no field of the record type; `details.fieldKey` names it. | Use the keys `exportFields.getCatalogue` answers for the record type. |
| `403` | `permission_denied` | `export_delivery_human_only` | `exportJobs.create`, `exportJobs.start` | An API key asked for a `webhook_delivery` export, or to start one: delivering an export to a webhook destination is done in the app only. | Create a `csv_file` export and download it, or deliver the export from the app. |
| `410` | `gone` | `export_rights_withdrawn` | `exportJobs.download` | A recommendation the export rests on may no longer be exported (revoked, purged or expired) since the file was written. | Start a new export; it leaves out what can no longer be exported. |
| `410` | `gone` | `export_rights_unverifiable` | `exportJobs.download` | What a recommendation export rests on can no longer be traced, so the file is withheld. | Start a new export. |

## Rate limits

Each key may send 600 requests a minute. Operations that need a `:run` scope, such as `setup:run`,
are also limited to 60 a minute, and bulk operations, such as `companies.bulkUpsert`, to 30 a
minute. Every response to a key carries `RateLimit-Policy` and
`RateLimit` headers, and a `429` carries `Retry-After` in seconds. The numbers may change during the beta, so read the headers
rather than hard-coding them. Do not retry without bounds or assume that a refused request created
work.

## Pagination

List operations page with a cursor. `limit` sets the page size: 50 by default, at most 200. Each
page returns `pageInfo`; while `pageInfo.hasNextPage` is true, send `pageInfo.endCursor` as `cursor`
with the same filters and sort.

A cursor does not expire: it marks a position in the list, so you can resume a listing later. It is
bound to the sort and filters of the request that returned it:

* A cursor sent with another sort, or with another search `q`, answers `422` with
  `error.details.code` `invalid_cursor`.
* A cursor sent with other filters is not refused, but the page would continue from its position under
  the new filters. When you change the filters, start again without a cursor.
* A ranked list (`rankingMode`) refuses a cursor from another ranking with
  `recommendation_cursor_foreign`, and answers `409` with `recommendation_restart_required` when the
  ranking changed while you paged; start again from the first page.

## Conditional requests

Every `GET` that answers JSON carries an `ETag`, a tag over the exact body you received, and
`Cache-Control: private, no-cache`. Keep the tag with the body. To check whether it changed, send the
tag back as `If-None-Match`: while your answer is the same, the API answers `304 Not Modified` with no
body, and you keep using your copy; otherwise you get the new `200` and its new tag. The request still
runs and counts against your rate limit, so a `304` saves the transfer, not a request. Browsers and
most HTTP caches send `If-None-Match` for you.

```bash theme={null}
curl --include "$ENRIK_API_BASE_URL/v1/companies/<id>" \
  --header "Authorization: Bearer $ENRIK_API_KEY" \
  --header 'If-None-Match: "<the ETag of your copy>"'
```

## Look up a company or contact

There is no separate lookup operation: filter the list. A domain names at most one live company of
your workspace, and an e-mail address at most one live contact, so the first page holds the record or
is empty.

* A company by its domain: `companies.list` with
  `filters=[{"field":"domain","operator":"eq","value":"acme.com"}]`. Send the bare domain, without
  `https://` or `www.`; the comparison ignores case.
* A company by its name or domain: the `nameOrDomain` field with `eq` matches either exactly, ignoring
  case; with `contains` it matches part of either.
* A contact by e-mail: `contacts.list` with
  `filters=[{"field":"email","operator":"eq","value":"ada@acme.com"}]`; the comparison ignores case.
* Several records by id: `companies.list` or `contacts.list` with
  `filters=[{"field":"id","operator":"in","value":["<id>","<id>"]}]` and `limit=100`. It takes at most
  100 ids and answers the records it found; an id it did not find is left out.

The `eq` filters are exact. To find records from part of a name, send `q` instead: `companies.list`
and `contacts.list` split it on whitespace, and every term must appear, ignoring case, in a company's
name or domain, or in a contact's first name, last name or e-mail address. `q=ada acme` finds Ada at
`ada@acme.com`. It combines with `filters` and keeps the sort; it does not rank.

```bash theme={null}
curl --get "$ENRIK_API_BASE_URL/v1/companies" \
  --header "Authorization: Bearer $ENRIK_API_KEY" \
  --data-urlencode 'filters=[{"field":"domain","operator":"eq","value":"acme.com"}]'
```

## Sync changed records

To keep a copy of your companies or contacts current, read only what changed since your last sync.

1. List with `filters=[{"field":"updatedAt","operator":"gte","value":"<since>"}]` and
   `sort=[{"field":"updatedAt","direction":"asc"}]`. Follow `pageInfo.endCursor` until
   `pageInfo.hasNextPage` is false.
2. Upsert each record by `id`, and keep the largest `updatedAt` you saw.
3. Next time, set `<since>` to that value minus a few minutes. A write still in progress while you read
   can commit with an earlier `updatedAt`; the overlap catches it, and the upsert makes the repeated
   records harmless.
4. Deleted records leave the list. Read them with `companies.listTrash` or `contacts.listTrash` and
   `deletedAfter=<since>` (deleted at or after that instant), and remove them from your copy. Deleting and
   restoring both advance `updatedAt`, so a restored record comes back through step 1.

Some values change without advancing `updatedAt`, so this sync does not see them change:

* a company's `enrikUpdateAvailable`, and its `enrikSnapshotVersion` when Enrik Data is re-applied
  without changing a field;
* a company's `signalSummary` and the cell states (`enrichmentCells`, `formulaCells`, `customApiCells`,
  `agentCells`), which are read from other records;
* a contact's `company`, which is read from the linked company when you ask for it.

Read those again when you need them current.

A company's fields are independent of each other. `address` is stored as you send it and is not read
for `city` or `country`, which have their own fields: set them directly. `domain` is not derived from
`website`.

## Idempotency and retries

`companies.create`, `contacts.create`, their bulk forms `companies.bulkCreate` and
`contacts.bulkCreate` (one key for the whole request), and `savedViews.create` require an
`Idempotency-Key` header of 1 to 200 characters; `feedback.submit` a fresh UUID (version 4); the setup
commands a UUID equal to the body's `operationId`. Use a new key for each new command. If the response
is lost, send the same body with the same key: the API returns the first answer instead of acting
twice. Record creates replay for 24 hours. Reusing a key with a different body answers
`409 idempotency_key_reused`; `409 request_in_flight` means the first request is still running, so
retry shortly with the same key.

## Discovery

A client that knows only the API host can find the rest without a key:

* `GET https://api.enrik.ai/` returns a small JSON index: the documentation, the OpenAPI
  document, the API catalog and how to authenticate.
* `GET https://api.enrik.ai/.well-known/api-catalog` returns the API catalog (RFC 9727) as
  `application/linkset+json`. It links the OpenAPI document (`service-desc`), this documentation
  (`service-doc`) and the health endpoint (`status`). `GET` and `HEAD` answer with a `Link`
  header using `rel="api-catalog"`.
* `GET https://api.enrik.ai/v1/health` (`health.get`) answers `{ "status": "ok" }` while the API is
  up. It needs no key.
* Every other response from the API host, errors included, carries a `Link` header with
  `rel="service-desc"`, `rel="service-doc"` and `rel="api-catalog"`.
* Every `401` from the API carries `WWW-Authenticate: Bearer realm="Enrik API"`. If the request
  sent `Authorization: Bearer <token>` and the token was refused, the challenge adds
  `error="invalid_token"`.

Continue with [agent setup](https://docs.enrik.ai/api-reference/agent-setup.md).


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