/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.
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 with403 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:pathnames the field (dotted, such asprofile.website; an empty string for the top of the body),messagesays what is wrong, andcode, where the case has a name, says which:related_record_not_foundwhen an id such ascompanyIdorownerUserIdnames no live record of your workspace,invalid_urlwhen a website is not a usable web address. - Another code that covers several cases carries an object whose
codenames the case, such asinvalid_cursoror a setup conflict’sonboarding_setup_stale.
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 indetails.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, answers422witherror.details.codeinvalid_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 withrecommendation_cursor_foreign, and answers409withrecommendation_restart_requiredwhen the ranking changed while you paged; start again from the first page.
Conditional requests
EveryGET 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.listwithfilters=[{"field":"domain","operator":"eq","value":"acme.com"}]. Send the bare domain, withouthttps://orwww.; the comparison ignores case. - A company by its name or domain: the
nameOrDomainfield witheqmatches either exactly, ignoring case; withcontainsit matches part of either. - A contact by e-mail:
contacts.listwithfilters=[{"field":"email","operator":"eq","value":"ada@acme.com"}]; the comparison ignores case. - Several records by id:
companies.listorcontacts.listwithfilters=[{"field":"id","operator":"in","value":["<id>","<id>"]}]andlimit=100. It takes at most 100 ids and answers the records it found; an id it did not find is left out.
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.- List with
filters=[{"field":"updatedAt","operator":"gte","value":"<since>"}]andsort=[{"field":"updatedAt","direction":"asc"}]. FollowpageInfo.endCursoruntilpageInfo.hasNextPageis false. - Upsert each record by
id, and keep the largestupdatedAtyou saw. - Next time, set
<since>to that value minus a few minutes. A write still in progress while you read can commit with an earlierupdatedAt; the overlap catches it, and the upsert makes the repeated records harmless. - Deleted records leave the list. Read them with
companies.listTrashorcontacts.listTrashanddeletedAfter=<since>(deleted at or after that instant), and remove them from your copy. Deleting and restoring both advanceupdatedAt, so a restored record comes back through step 1.
updatedAt, so this sync does not see them change:
- a company’s
enrikUpdateAvailable, and itsenrikSnapshotVersionwhen Enrik Data is re-applied without changing a field; - a company’s
signalSummaryand 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.
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-catalogreturns the API catalog (RFC 9727) asapplication/linkset+json. It links the OpenAPI document (service-desc), this documentation (service-doc) and the health endpoint (status).GETandHEADanswer with aLinkheader usingrel="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
Linkheader withrel="service-desc",rel="service-doc"andrel="api-catalog". - Every
401from the API carriesWWW-Authenticate: Bearer realm="Enrik API". If the request sentAuthorization: Bearer <token>and the token was refused, the challenge addserror="invalid_token".