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. 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 aLinkwithrel="deprecation"to this changelog, after the API host’s ownLinkvalues. The OpenAPI document marks itdeprecated: truewithx-deprecated-since,x-sunsetandx-replacement, theoperationIdto move to. The TypeScript SDK marks it@deprecatedand warns once per process when it sends it. - A breaking change to a stable operation ships as a new operation with its own
operationIdunder/v2, such ascompaniesV2.list, while the/v1one stays until its sunset, at least 183 days after its deprecation reaches production.
- Every public
GETthat answers200now carriesETag, a strong tag over the exact JSON body you receive (its base64url SHA-256, quoted), andCache-Control: private, no-cache. Send the tag back asIf-None-Match(one tag, several separated by commas, its weak formW/"…", or*): while your answer is the same, the API answers304 Not Modifiedwith no body and the same two headers. The request still runs and counts against the rate limit, so a304saves 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 theIf-None-Matchheader parameter, the two headers on its200and the304response, all shared components./v1/openapi.jsonkeeps its own tag andno-cache, andexportJobs.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.getCompletionandworkspaceMembers.list.
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-createwithitems, 1 to 100 bodies ofcompanies.create, and a requiredIdempotency-Keyfor 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 is409 idempotency_key_reused. The body is checked as a whole first: a malformed item is422 validation_errorat its path, such asitems.3.name, and nothing is written. Then the items are created one after another, and one that fails, such asconflictwithdetails.codeduplicate_domain, is left out while the others are created. The answer is200: each result hasoutcomecreatedand thecompany, orfailedand anerrorin the error envelope’s shape with the single call’s code. -
contacts.bulkCreate: new.POST /v1/contacts/bulk-create, the same forcontacts.createbodies; an address another live contact has fails its item withduplicate_email. -
companies.bulkUpdate: new.POST /v1/companies/bulk-updatewithitems, 1 to 100 bodies ofcompanies.update, each with theidof the company to change. It is checked as a whole like the bulk create, then each item runs ascompanies.updatein its own transaction: anidthat is not a live company fails withnot_found, anexpectedUpdatedAtthe company has changed since withstale_version, and the others are written (outcomeupdated). It takes noIdempotency-Key. -
contacts.bulkUpdate: new.POST /v1/contacts/bulk-update, the same forcontacts.updatebodies. 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 answers200with the key (its id, name, scopes and expiry unchanged), the new secret inkey, andpreviousKeyExpiresAt. The body is{}or setspreviousKeyExpiresInSeconds, 0 to 86400, 3600 when not sent: until then the secret that sent the request keeps working too, then it answers401;0ends it at once. Only the current secret may call it: a secret a rotation replaced gets401here, as a revoked key does, retries included. It takes the requiredIdempotency-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 answers200with{ "ok": true }. The next request with the key answers401; 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:readreads export jobs and the fields an export can carry, andexports:runcreates, previews, starts, cancels and archives exports and downloads the file. Everyexports:runoperation also needscrm:read, and the field catalogue needscrm:readbesideexports:read. A candidate export also needsdiscovery:read. An export carries at most 5000 records and counts against the plan’sexports_createdquota when it starts. Delivering an export to a webhook destination stays in the app: an API key that asks for awebhook_deliveryexport gets403 permission_deniedwitherror.details.codeexport_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 endsfailed. Nothing is removed. -
exportJobs.list,exportJobs.get: new onexports:read.GET /v1/export-jobspages the export jobs the calling key created, newest first, withstatus,exportTypeandobjectTypefilters and the sharedcursorandlimit;GET /v1/export-jobs/{id}reads one. Any other export job, one a member or another key created included, is404 not_foundon every/v1/export-jobs/{id}route. A job an API key created names the member who created the key ascreatedBy. -
exportFields.getCatalogue: new onexports:readandcrm:read.GET /v1/export-fields?objectType=…answers every fieldsource.fieldKeystakes for a record type, the workspace’s custom fields included, in one answer. -
exportJobs.create: new.POST /v1/export-jobswithexportTypecsv_file,objectTypeand asourcecreates adraft; it takes the requiredIdempotency-Key. A source that does not fit the record type is422 validation_errorwitherror.details.codeexport_invalid_config, an unknown fieldexport_invalid_field. -
exportJobs.preview: new.POST /v1/export-jobs/previewanswers up to 10 escaped sample rows and creates nothing. -
exportJobs.start: new.POST /v1/export-jobs/{id}/startqueues a draft; pollexportJobs.getuntilstatusis final. A job that is no longerdraftorqueuedis409 conflict. -
exportJobs.cancel,exportJobs.archive: new.POST /v1/export-jobs/{id}/canceland/archive. -
exportJobs.download: new.GET /v1/export-jobs/{id}/downloadanswers the file astext/csv, an attachment, untildownloadExpiresAt. A job without a file yet is409 conflict; an expired file, or one whose recommendation sources can no longer be exported, is410 gone. -
apiKeys.getCurrent:scopesmay now listexports:readandexports:run.
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-upsertwithitems, 1 to 100 bodies ofcompanies.upsert. The body is checked as a whole: a malformed item is422 validation_errorat its path, such asitems.3.name, and nothing is written. Then each item runs ascompanies.upsert, one after another, each in its own transaction. The answer is200withresults, one per item in order (index,outcomecreated,updatedorfailed, and thecompanyor anerrorin the error envelope’s shape with the single call’s code, such asconflictwithdetails.codeambiguous_domain), and the countscreated,updatedandfailed. It takes noIdempotency-Key: sending the same body again is harmless.contacts.bulkUpsert: new.POST /v1/contacts/bulk-upsert, the same forcontacts.upsertbodies.companies.list,contacts.list:filterstakesid. Withinit reads up to 100 records at once,[{"field":"id","operator":"in","value":[…]}]: the ones found, as the list answers them,includeas usual. More than 100 ids is422 validation_erroratfilters.id.
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/upsertwithdomainand 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 needsname(else422 validation_erroratname). The answer is200withoutcome(createdorupdated) and thecompany. A company in the trash is not matched. A domain that more than one live company has, linked to Enrik Data or not, is409 conflictwitherror.details.codeambiguous_domainand the ids inerror.details.companyIds.contacts.upsert: new.POST /v1/contacts/upsertwithemailand the create fields, the same way: the live contact with that address, compared ignoring case, is updated, or a contact is created. The answer hasoutcomeand thecontact.companies.create: a domain another live, unlinked company has is still409 conflict, and now names that company:error.details.codeduplicate_domain,error.details.companyId.contacts.create: a taken e-mail address is still409 conflict, and now names that contact:error.details.codeduplicate_email,error.details.contactId.
q answers
as before.
companies.list: newq, 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 withfiltersand keeps thesort. A cursor is valid only with the sameq: another one answers422witherror.details.codeinvalid_cursor, so start again without a cursor when you change it. WithrankingModeit is refused with422 validation_erroratq.contacts.list: newq, the same search over the first name, the last name and the e-mail address.
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: thecompany.created,company.updated,company.deletedandcompany.restoredevents.datahascompanyIdandchangeSource(app,api,import,automation,assistantorsystem), plusapiKeyIdwhen a key made the change, so you can ignore your own writes;company.updatedalso lists thechangedFieldsby name (customFields.<key>for a custom field), never their values. Read the company withcompanies.get.contacts.created,contacts.updated,contacts.deleted,contacts.restored: the same four for contacts, withcontactId. Read the contact withcontacts.get.companySignals.created: thecompanySignal.createdevent, sent when a signal is applied to a company, withsignalIdandcompanyId. Read the signal withcompanySignals.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 gainsname(the label the key was given when it was minted, ornull),prefix(the first characters of the secret, as Settings → API keys lists them) andexpiresAt(when the key stops authenticating, ISO 8601, ornullfor a key that does not expire).
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.restoreandicpProfiles.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 isicpProfileId, as on the reads.updateandcreateVersionneed theexpectedRevisionyou were shown; a stale one is409 stale_version.discoveryBriefs.create,discoveryBriefs.update,discoveryBriefs.archiveanddiscoveryBriefs.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 isdiscoveryBriefId, as on the reads.apiKeys.getCurrent:scopesmay now listdiscovery:write.
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-subscriptionssubscribes a receiverurl(Enrik creates its destination and answers its signing secret once) or an existingdestinationIdto event types. It takes the requiredIdempotency-Keyheader: a retry with the same key and body answers the first response, the secret included, for 24 hours.webhookSubscriptions.listandwebhookSubscriptions.get: new. A page of the subscriptions, newest first, and one subscription, each with itsurl,eventTypes,statusand failure counts, never the secret.webhookSubscriptions.disable,webhookSubscriptions.enableandwebhookSubscriptions.archive: new. Pause, resume and end a subscription. A repeat answers it unchanged; an archived one is404.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.getandwebhookEventDeliveries.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 requiredIdempotency-Key.apiKeys.getCurrent:scopesmay now listwebhooks:readandwebhooks:write.
webhooks:write.
webhookSubscriptions.update: new.PATCH /v1/webhook-subscriptions/{id}replaces theeventTypes(each still needs its read permission) or changes the receiverurlordescription, which changes the subscription’s destination. A field left out keeps its value; an empty body is422 validation_error. A repeat changes nothing more, so it takes noIdempotency-Key. A destination that sends a bearer token (set up in the app) keeps its URL here: a newurlfor it is422 validation_erroratsecret, because the token would follow the URL; change it in the app.webhookSubscriptions.rotateSecret: new.POST /v1/webhook-subscriptions/{id}/rotate-secretreplaces the signing secret and answers the new one once; the old one stops verifying at once. It takes the requiredIdempotency-Keyheader: 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}/testsends one signedwebhook.pingto the receiver and answers itsresponseStatus,durationMsanderrorCode. One attempt, no retry, nothing stored: the ping never appears in the delivery log.webhookSubscriptions.ping: thewebhook.pingrequest, published under OpenAPIwebhooks. It carries the headers and theX-Enrik-Signatureof every event, anddata.subscriptionId. No subscription receives it otherwise.webhookSubscriptions.archive: a subscription created with aurlnow archives the destination made for it; a destination named bydestinationIdstays.
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 is409.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 newrevision.signalDefinitions.archiveandsignalDefinitions.restore: new. Move the definition toarchivedand back toactive.- Each one but
createtakes the path parametersignalDefinitionId, as on the reads, and needs theexpectedRevisionyou were shown; a stale one is409 stale_version.
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 indraft; it finds nothing until it is started. Takes the requiredIdempotency-Keyheader: a retry with the same key and body answers the first response for 24 hours, the same key with another body is409 idempotency_key_reused.companyDiscoveryJobs.update(discovery:run): change a draft job’s label or its ICP profile and brief links. The path parameter isjobId, as on the reads.companyDiscoveryJobs.start(discovery:run): queue the job; poll it withcompanyDiscoveryJobs.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 answers403 permission_denied.companyDiscoveryJobs.archive(discovery:write): archive a job that is not queued or running.companyDiscoveryJobs.get,companyDiscoveryJobs.list: a job gainscreatedByApiKeyId, the key that created it, ornull; for such a jobcreatedByis the user who minted the key.
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.acceptandcompanyCandidates.reject: new. Move aneworneeds_reviewcandidate toacceptedorrejected. Reject is the review’s dismiss.companyCandidates.archive: new. Moves anaccepted,rejectedorpromotedcandidate toarchived, which hides it from review; a promoted candidate’s company stays.companyCandidates.promote: new. Turns anacceptedcandidate into a workspace company:201when a company was created,200when it was linked to one the workspace has. A domain the workspace already holds is409; for a candidate from Enrik data, sendtargetCompanyIdto link instead.companyCandidates.bulkAccept,companyCandidates.bulkReject,companyCandidates.bulkArchiveandcompanyCandidates.bulkPromote: new. The same actions for 1 to 200candidateIds, 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-memberspages through the workspace’s active members, withcrm:write, as setting an owner needs. Each member has the useridthatownerUserIdtakes on contacts and companies, and itsname.contacts.create: a body without a non-blankfirstName,lastNameoremailis refused with422 validation_errorand onedetailsentry at the path"", instead of creating an empty contact. Send at least one of the three.
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: thecompanyDiscoveryJob.terminalevent, sent once a company discovery job completes, completes with errors, fails or is canceled. Its body carries the eventid,type,version,occurredAt,workspaceIdanddata(jobId,status); read the job withcompanyDiscoveryJobs.get. Delivery is at least once and unordered: deduplicate byid. VerifyX-Enrik-Signature(HMAC-SHA256 over the timestamp, a dot and the raw body); see the webhooks page.
error.code.
- Every operation declares
400,422,500and503, and, with an API key,401,403and429; with a path parameter404; a write that is not a delete409; a body413and415.companies.listandcompanyCandidates.listalso declare404and409(a ranked list). Each status is a shared response on the same error envelope that lists the codes answered with it, andErrorInfo.codelists 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.resetandwebsiteOnboarding.start. health.getjoins the public document:GET /v1/healthanswers{ "status": "ok" }while the API is up, with no API key (security: []). It is the API catalog’sstatuslink. Like every public operation it refuses a query parameter it does not declare with422.
2026-10-07
A filter or cursor value the database cannot read answers422 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.listandcontacts.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, answers422with onedetailsentry whosepathnames the filter, such asfilters.ownerUserId. The id case answered400, the date case500.- Every list operation that takes a
cursor: an edited cursor whose id is not a UUID, or whose date lies outside those years, answers422 validation_errorinstead of400or500. Most lists,companies.listandcontacts.listamong them, name it witherror.details.codeinvalid_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_errorwith nodetails, never400or500.
contacts.createandcontacts.update(acompanyIdthat is not a live company of the workspace), andcontacts.create,contacts.update,companies.createandcompanies.update(anownerUserIdthat is not an active member), answer422witherror.codevalidation_errorinstead ofunprocessable.detailskeeps its one entry,pathandmessage, and the entry addscode: "related_record_not_found". The message names the field.websiteOnboarding.confirmProfile: a website that is not a usable web address answers422 validation_errorwith onedetailsentry,pathprofile.websiteandcodeinvalid_url, instead of422 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.chooseSetupsandwebsiteOnboarding.complete: a body over 1 MiB answers413 payload_too_large, and aContent-Typethat is not a media type415 unsupported_media_type. Both answeredinternal_error.companies.create,contacts.createandsavedViews.create: a retry refused because the key lackscrm:readstill answers403 permission_denied; its message now names the scope.savedViews.updateandsavedViews.deletedescribe that a company view needsobjectType=company;websiteOnboarding.getAccessdescribes eligibility in scopes.
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.confirmProfileanswers200where it answered403and addsresearch.websiteOnboarding.get,websiteOnboarding.start,websiteOnboarding.draftProfile,websiteOnboarding.resetandwebsiteOnboarding.chooseSetupsshare Confirm’s answer schema, which declares the optionalresearch; they never send it. Nothing changes for their clients.
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.getCurrentis new:GET /v1/me, beta, any authenticated API key.
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.listTimelineandcompanies.listSignalsrefuse an undeclared query parameter.contacts.list,contacts.listTrash,contacts.getandcontacts.listTimelinerefuse an undeclared query parameter.savedViews.list,savedViews.get,savedViews.updateandsavedViews.deleterefuse an undeclared query parameter.customFieldDefinitions.list,icpAssessments.get,icpProfiles.list,discoveryBriefs.listanddiscoveryBriefs.listVersionsrefuse an undeclared query parameter.companyCandidates.list,companyDiscoveryJobs.listandcompanyDiscoveryJobs.listCandidatesrefuse an undeclared query parameter, reported before any declared parameter is judged.companies.create,companies.update,companies.delete,companies.restore,companies.getProvenanceandcompanies.getSignalSummarytake no query parameter and now refuse every one.contacts.create,contacts.update,contacts.deleteandcontacts.restoretake no query parameter and now refuse every one.companySignals.get,companyCandidates.get,companyDiscoveryJobs.get,companyDiscoveryJobs.getQualitySummary,discoveryBriefs.getanddiscoveryBriefs.getVersiontake no query parameter and now refuse every one.icpProfiles.get,icpProfiles.listVersions,icpProfiles.getVersion,signalDefinitions.get,signalDefinitions.listVersions,signalDefinitions.getVersionandsignalDetectors.getCataloguetake no query parameter and now refuse every one.savedViews.createandfeedback.submittake no query parameter and now refuse every one.websiteOnboarding.get,websiteOnboarding.getAccess,websiteOnboarding.getCompletion,websiteOnboarding.start,websiteOnboarding.draftProfile,websiteOnboarding.confirmProfile,websiteOnboarding.reset,websiteOnboarding.chooseSetupsandwebsiteOnboarding.completetake no query parameter and now refuse every one.signalDefinitions.listalready refused one; unchanged.
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.listandcontacts.listTimelinepage without repeats or gaps.companyCandidates.list(sorted bycreatedAt),companyDiscoveryJobs.list,companyDiscoveryJobs.listCandidates,discoveryBriefs.list,icpProfiles.listandsignalDefinitions.listpage without repeats or gaps.
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.listfilters onregisterNumber,city,legalFormandsummary, and itsfiltersdescription lists every field,nameOrDomainand the signal fields included.savedViews.createandsavedViews.updateaccept a company view that filters on the four fields.contacts.listlists its filterable and sortable fields; its behaviour is unchanged.
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.createandcompanies.updaterefuse an undeclared field.contacts.createandcontacts.updaterefuse an undeclared field.savedViews.createandsavedViews.updaterefuse an undeclared field in the body, itsquery, a filter or a sort key.websiteOnboarding.start,websiteOnboarding.reset,websiteOnboarding.draftProfileandwebsiteOnboarding.confirmProfile(itsprofiletoo) refuse an undeclared field.websiteOnboarding.chooseSetups,websiteOnboarding.completeandfeedback.submitalready 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.submitis new:POST /v1/feedback, beta, any authenticated API key, the ordinary per-key rate limit.
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.getAccessandwebsiteOnboarding.getCompletionare new beta reads requiringsetup:read. -
websiteOnboarding.start,websiteOnboarding.draftProfile,websiteOnboarding.confirmProfile,websiteOnboarding.reset,websiteOnboarding.chooseSetupsandwebsiteOnboarding.completeare new beta mutations requiringsetup:runand matching UUID header/body command IDs. -
icpProfiles.listandicpProfiles.getnow returndraftalongsideactiveandarchived. The list’sstatusfilter also acceptsdraft; 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.getis new: fetch one signal under the app’s display rule. A key withcrm:readcan reach it; a key withoutcrm:readgets 403insufficient_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.listSignalsis new: the signals recorded about a live company, newest first. It pages likesavedViews.list; filter by storedstatus(active,dismissedorarchived), and an active signal past its age readsexpired. Acursorwhose id is not a uuid answers 422 witherror.codevalidation_erroranderror.details.codeinvalid_cursor.companies.getSignalSummaryis 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 keepscompanyIdandscoringVersion, the counts are 0, and the latest-signal fields and the priority arenull.
api-keycounts every request the key makes: 600 a minute.api-key-runalso counts each request to an operation that needs arunscope: 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.restoreandcompanies.update.companyCandidates.getandcompanyCandidates.list.companyDiscoveryJobs.get,companyDiscoveryJobs.getQualitySummary,companyDiscoveryJobs.listandcompanyDiscoveryJobs.listCandidates.contacts.create,contacts.delete,contacts.get,contacts.list,contacts.listTimeline,contacts.listTrash,contacts.restoreandcontacts.update.customFieldDefinitions.list.discoveryBriefs.get,discoveryBriefs.getVersion,discoveryBriefs.listanddiscoveryBriefs.listVersions.icpAssessments.get.icpProfiles.get,icpProfiles.getVersion,icpProfiles.listandicpProfiles.listVersions.savedViews.create,savedViews.delete,savedViews.get,savedViews.listandsavedViews.update.signalDefinitions.get,signalDefinitions.getVersion,signalDefinitions.listandsignalDefinitions.listVersions.signalDetectors.getCatalogue.
2026-09-30
The first public operations: saved views, allbeta until launch (they may still change, with an
entry here).
- A key missing an operation’s scope gets
403 insufficient_scopewith aWWW-Authenticate: Bearer error="insufficient_scope", scope="…"challenge naming what it needs. savedViews.listis new. It needs thecrm:readscope, and pages: passpageInfo.endCursorascursorfor the next page;limitis 1 to 200, 50 when absent.objectType(contactorcompany) is required.savedViews.getis new. It needscrm:read. NameobjectType=companyfor a company view.savedViews.createis new. It needscrm:writeand anIdempotency-Keyheader: 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 holdscrm:read(without it the retry is refused with 403).savedViews.updateis new. It needscrm:write. Sending the same body twice is harmless.savedViews.deleteis new. It needscrm:write. Deleting a view that is already deleted also answers{ "ok": true }, so a retry is harmless.
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.listandcompanyDiscoveryJobs.getare new: discovery jobs, filtered bystatusorsourceType.companyDiscoveryJobs.listCandidatesandcompanyDiscoveryJobs.getQualitySummaryare new: one job’s candidates, and how good they are.companyCandidates.listandcompanyCandidates.getare new. The list filters by job, status, scores and source;sortis one score field, highest first, orcreatedAt. Ask for a ranked list withrankingModeandsort=contextual_prioritywhencontextualRanking.availableis true; its rows carry arecommendation. A ranked sort withoutrankingMode, or another sort with it, is refused with400 invalid_sort.icpProfiles.list,icpProfiles.get,icpProfiles.listVersionsandicpProfiles.getVersionare new. A profile’s criteria are in its versions; the versions read is an array, newest first.discoveryBriefs.list,discoveryBriefs.get,discoveryBriefs.listVersionsanddiscoveryBriefs.getVersionare new.icpAssessments.getis new: a company’s stored assessment against an ICP profile version. Reading never evaluates; a company not assessed yet answersstatus: "never_evaluated".signalDefinitions.list,signalDefinitions.get,signalDefinitions.listVersionsandsignalDefinitions.getVersionare new. The list refuses a parameter it does not know.signalDetectors.getCatalogueis new: every detector kind, and why any cannot run.
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.listis new. It needscrm:read.filters,sortandcolumnsare JSON text (the query a saved view stores);include=companyadds each contact’s company.contacts.listTrash,contacts.getandcontacts.listTimelineare new. They needcrm:read.contacts.create,contacts.updateandcontacts.restoreare new. They needcrm:write.contacts.deleteis new. It needscrm:delete.companies.listis new. It needscrm:read. It takes the contact list’s query, and withrankingMode(andsort=contextual_priority) ranks the companies by recommendation; ranking reads the ICP profile and the brief, so it also needsdiscovery:read.companies.listTrash,companies.get,companies.listTimeline,companies.listContactsandcompanies.getProvenanceare new. They needcrm:read.companies.create,companies.updateandcompanies.restoreare new. They needcrm:write.companies.deleteis new. It needscrm:delete.customFieldDefinitions.listis new. It needscrm:read, and names the keys contacts’ and companies’customFieldshold.