Skip to main content
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 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 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.
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.
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.
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. 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. 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:

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.

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.

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.