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

2026-10-09

How an operation is deprecated, and what a breaking change looks like, now on the new page Versioning and deprecation. No operation is deprecated, and nothing changes for a client until one is.
  • When an operation is deprecated, every response of it, errors included, carries Deprecation (the day it was deprecated, as @ and seconds since 1970, RFC 9745), Sunset (the first day it may be gone, once scheduled, RFC 8594) and a Link with rel="deprecation" to this changelog, after the API host’s own Link values. The OpenAPI document marks it deprecated: true with x-deprecated-since, x-sunset and x-replacement, the operationId to move to. The TypeScript SDK marks it @deprecated and warns once per process when it sends it.
  • A breaking change to a stable operation ships as a new operation with its own operationId under /v2, such as companiesV2.list, while the /v1 one stays until its sunset, at least 183 days after its deprecation reaches production.
Conditional requests on every public read. Nothing is removed, and a client that ignores the new headers sees no change.
  • Every public GET that answers 200 now carries ETag, a strong tag over the exact JSON body you receive (its base64url SHA-256, quoted), and Cache-Control: private, no-cache. Send the tag back as If-None-Match (one tag, several separated by commas, its weak form W/"…", or *): while your answer is the same, the API answers 304 Not Modified with no body and the same two headers. The request still runs and counts against the rate limit, so a 304 saves the transfer, not the request. The tag is taken over your answer only, after your scopes and fields are applied, so it says nothing about what another key or member sees. Each operation declares the If-None-Match header parameter, the two headers on its 200 and the 304 response, all shared components. /v1/openapi.json keeps its own tag and no-cache, and exportJobs.download, a CSV file, carries none. The operations: apiKeys.getCurrent, companies.get, companies.getProvenance, companies.getSignalSummary, companies.list, companies.listContacts, companies.listSignals, companies.listTimeline, companies.listTrash, companyCandidates.get, companyCandidates.list, companyDiscoveryJobs.get, companyDiscoveryJobs.getQualitySummary, companyDiscoveryJobs.list, companyDiscoveryJobs.listCandidates, companySignals.get, contacts.get, contacts.list, contacts.listTimeline, contacts.listTrash, customFieldDefinitions.list, exportFields.getCatalogue, exportJobs.get, exportJobs.list, discoveryBriefs.get, discoveryBriefs.getVersion, discoveryBriefs.list, discoveryBriefs.listVersions, health.get, icpAssessments.get, icpProfiles.get, icpProfiles.getVersion, icpProfiles.list, icpProfiles.listVersions, savedViews.get, savedViews.list, signalDefinitions.get, signalDefinitions.getVersion, signalDefinitions.list, signalDefinitions.listVersions, signalDetectors.getCatalogue, webhookEventDeliveries.get, webhookEventDeliveries.list, webhookEventTypes.list, webhookSubscriptions.get, webhookSubscriptions.list, websiteOnboarding.get, websiteOnboarding.getAccess, websiteOnboarding.getCompletion and workspaceMembers.list.
Bulk create and bulk update of companies and contacts, in beta. Like the bulk upserts, they count against api-key-bulk as well as api-key, and answer results, one per item in order, and the counts. Nothing is removed.
  • companies.bulkCreate: new. POST /v1/companies/bulk-create with items, 1 to 100 bodies of companies.create, and a required Idempotency-Key for the whole request: a retry with the same key and body answers the first request’s results, item for item, and creates nothing again; the same key with another body is 409 idempotency_key_reused. The body is checked as a whole first: a malformed item is 422 validation_error at its path, such as items.3.name, and nothing is written. Then the items are created one after another, and one that fails, such as conflict with details.code duplicate_domain, is left out while the others are created. The answer is 200: each result has outcome created and the company, or failed and an error in the error envelope’s shape with the single call’s code.
  • contacts.bulkCreate: new. POST /v1/contacts/bulk-create, the same for contacts.create bodies; an address another live contact has fails its item with duplicate_email.
  • companies.bulkUpdate: new. POST /v1/companies/bulk-update with items, 1 to 100 bodies of companies.update, each with the id of the company to change. It is checked as a whole like the bulk create, then each item runs as companies.update in its own transaction: an id that is not a live company fails with not_found, an expectedUpdatedAt the company has changed since with stale_version, and the others are written (outcome updated). It takes no Idempotency-Key.
  • contacts.bulkUpdate: new. POST /v1/contacts/bulk-update, the same for contacts.update bodies. An API key can rotate and revoke itself, in beta, so a client no longer needs a person in Settings to change its secret. Nothing is removed. Rotating or revoking the key in Settings → API keys still ends every secret of it at once.
  • apiKeys.rotateCurrent: new. POST /v1/me/rotate, for any valid API key, whatever its scopes. It gives the calling key a new secret and answers 200 with the key (its id, name, scopes and expiry unchanged), the new secret in key, and previousKeyExpiresAt. The body is {} or sets previousKeyExpiresInSeconds, 0 to 86400, 3600 when not sent: until then the secret that sent the request keeps working too, then it answers 401; 0 ends it at once. Only the current secret may call it: a secret a rotation replaced gets 401 here, as a revoked key does, retries included. It takes the required Idempotency-Key: a retry with the same key and body, sent with the new secret, answers the same new secret, as long as the key has not rotated again or been revoked. If the answer is lost, rotate the key in Settings → API keys.
  • apiKeys.revokeCurrent: new. POST /v1/me/revoke, for any valid API key, whatever its scopes, sent with either secret. It revokes the calling key, and the secret a rotation left working, and answers 200 with { "ok": true }. The next request with the key answers 401; an owner or admin mints a new key in Settings → API keys. CSV exports of companies, contacts and candidates, in beta, under two new scopes: exports:read reads export jobs and the fields an export can carry, and exports:run creates, previews, starts, cancels and archives exports and downloads the file. Every exports:run operation also needs crm:read, and the field catalogue needs crm:read beside exports:read. A candidate export also needs discovery:read. An export carries at most 5000 records and counts against the plan’s exports_created quota when it starts. Delivering an export to a webhook destination stays in the app: an API key that asks for a webhook_delivery export gets 403 permission_denied with error.details.code export_delivery_human_only. An API key sees only the exports it created, and an export runs as the key that created it, with the key’s scopes: if the key is revoked or expires before the export runs, or no longer reads the record type, the export ends failed. Nothing is removed.
  • exportJobs.list, exportJobs.get: new on exports:read. GET /v1/export-jobs pages the export jobs the calling key created, newest first, with status, exportType and objectType filters and the shared cursor and limit; GET /v1/export-jobs/{id} reads one. Any other export job, one a member or another key created included, is 404 not_found on every /v1/export-jobs/{id} route. A job an API key created names the member who created the key as createdBy.
  • exportFields.getCatalogue: new on exports:read and crm:read. GET /v1/export-fields?objectType=… answers every field source.fieldKeys takes for a record type, the workspace’s custom fields included, in one answer.
  • exportJobs.create: new. POST /v1/export-jobs with exportType csv_file, objectType and a source creates a draft; it takes the required Idempotency-Key. A source that does not fit the record type is 422 validation_error with error.details.code export_invalid_config, an unknown field export_invalid_field.
  • exportJobs.preview: new. POST /v1/export-jobs/preview answers up to 10 escaped sample rows and creates nothing.
  • exportJobs.start: new. POST /v1/export-jobs/{id}/start queues a draft; poll exportJobs.get until status is final. A job that is no longer draft or queued is 409 conflict.
  • exportJobs.cancel, exportJobs.archive: new. POST /v1/export-jobs/{id}/cancel and /archive.
  • exportJobs.download: new. GET /v1/export-jobs/{id}/download answers the file as text/csv, an attachment, until downloadExpiresAt. A job without a file yet is 409 conflict; an expired file, or one whose recommendation sources can no longer be exported, is 410 gone.
  • apiKeys.getCurrent: scopes may now list exports:read and exports:run.
Bulk upsert of companies and contacts, and reading several records by id, in beta. A bulk request also counts against a new rate-limit policy, api-key-bulk (30 a minute by default), on top of api-key; its RateLimit-Policy and RateLimit name both. Nothing is removed.
  • companies.bulkUpsert: new. POST /v1/companies/bulk-upsert with items, 1 to 100 bodies of companies.upsert. The body is checked as a whole: a malformed item is 422 validation_error at its path, such as items.3.name, and nothing is written. Then each item runs as companies.upsert, one after another, each in its own transaction. The answer is 200 with results, one per item in order (index, outcome created, updated or failed, and the company or an error in the error envelope’s shape with the single call’s code, such as conflict with details.code ambiguous_domain), and the counts created, updated and failed. It takes no Idempotency-Key: sending the same body again is harmless.
  • contacts.bulkUpsert: new. POST /v1/contacts/bulk-upsert, the same for contacts.upsert bodies.
  • companies.list, contacts.list: filters takes id. With in it reads up to 100 records at once, [{"field":"id","operator":"in","value":[…]}]: the ones found, as the list answers them, include as usual. More than 100 ids is 422 validation_error at filters.id.
Create or update a company by its domain and a contact by its e-mail address, in beta, so a sync no longer has to search before it writes. Both take the key in the body, need crm:write and take no Idempotency-Key: the key is the idempotency key, so sending the same body again is harmless. Nothing is removed.
  • companies.upsert: new. POST /v1/companies/upsert with domain and the create fields. The live company with that domain is updated with the fields you send, the rest kept; with none, a company is created, which needs name (else 422 validation_error at name). The answer is 200 with outcome (created or updated) and the company. A company in the trash is not matched. A domain that more than one live company has, linked to Enrik Data or not, is 409 conflict with error.details.code ambiguous_domain and the ids in error.details.companyIds.
  • contacts.upsert: new. POST /v1/contacts/upsert with email and the create fields, the same way: the live contact with that address, compared ignoring case, is updated, or a contact is created. The answer has outcome and the contact.
  • companies.create: a domain another live, unlinked company has is still 409 conflict, and now names that company: error.details.code duplicate_domain, error.details.companyId.
  • contacts.create: a taken e-mail address is still 409 conflict, and now names that contact: error.details.code duplicate_email, error.details.contactId.
Search on the company and contact lists, in beta. Nothing is removed; a list read without q answers as before.
  • companies.list: new q, 1 to 200 characters. Every whitespace-separated term must appear, ignoring case, in the name or the domain; different terms may match different fields. It is ANDed with filters and keeps the sort. A cursor is valid only with the same q: another one answers 422 with error.details.code invalid_cursor, so start again without a cursor when you change it. With rankingMode it is refused with 422 validation_error at q.
  • contacts.list: new q, the same search over the first name, the last name and the e-mail address.
Record-change webhook events, in beta. Nine event types join companyDiscoveryJob.terminal under OpenAPI webhooks; nothing is added under paths. A subscription receives only the types it names, so an existing subscription sees no change. Events are being rolled out and are not sent yet. Each body carries ids and who made the change, never record data: read the record with your key.
  • companies.created, companies.updated, companies.deleted, companies.restored: the company.created, company.updated, company.deleted and company.restored events. data has companyId and changeSource (app, api, import, automation, assistant or system), plus apiKeyId when a key made the change, so you can ignore your own writes; company.updated also lists the changedFields by name (customFields.<key> for a custom field), never their values. Read the company with companies.get.
  • contacts.created, contacts.updated, contacts.deleted, contacts.restored: the same four for contacts, with contactId. Read the contact with contacts.get.
  • companySignals.created: the companySignal.created event, sent when a signal is applied to a company, with signalId and companyId. Read the signal with companySignals.get.
GET /v1/me names the key, in beta. Nothing is removed; a client that ignores unknown fields needs no change.
  • apiKeys.getCurrent: the answer gains name (the label the key was given when it was minted, or null), prefix (the first characters of the secret, as Settings → API keys lists them) and expiresAt (when the key stops authenticating, ISO 8601, or null for a key that does not expire).
ICP profiles and discovery briefs can be written with an API key, in beta. A new scope, discovery:write, covers creating and changing them; the reads stay on discovery:read. Every write takes the required Idempotency-Key header: a retry with the same key and body answers the first response for 24 hours, the same key with another body is 409 idempotency_key_reused, and a missing, blank or over-long key is 422 validation_error. A key without the scope gets 403 insufficient_scope. Drafting a profile or a brief with the model stays in the app.
  • icpProfiles.create, icpProfiles.update, icpProfiles.archive, icpProfiles.restore and icpProfiles.createVersion: create a profile with its first version, change its name, description or status, archive and restore it, and add a version. The path parameter is icpProfileId, as on the reads. update and createVersion need the expectedRevision you were shown; a stale one is 409 stale_version.
  • discoveryBriefs.create, discoveryBriefs.update, discoveryBriefs.archive and discoveryBriefs.appendVersion: create a brief pinned to one profile version, change its title, content or status, archive it, and add an authored version. The path parameter is discoveryBriefId, as on the reads.
  • apiKeys.getCurrent: scopes may now list discovery:write.
Webhook subscriptions can be managed with an API key, in beta. Two new scopes: webhooks:read reads the subscriptions, the event types they can name and their delivery log; webhooks:write creates, pauses, resumes and archives subscriptions and redelivers events. Only workspace owners and admins can grant webhooks:write. A key without the scope gets 403 insufficient_scope.
  • webhookSubscriptions.create: new. POST /v1/webhook-subscriptions subscribes a receiver url (Enrik creates its destination and answers its signing secret once) or an existing destinationId to event types. It takes the required Idempotency-Key header: a retry with the same key and body answers the first response, the secret included, for 24 hours.
  • webhookSubscriptions.list and webhookSubscriptions.get: new. A page of the subscriptions, newest first, and one subscription, each with its url, eventTypes, status and failure counts, never the secret.
  • webhookSubscriptions.disable, webhookSubscriptions.enable and webhookSubscriptions.archive: new. Pause, resume and end a subscription. A repeat answers it unchanged; an archived one is 404.
  • webhookEventTypes.list: new. A page of the event types a subscription can name, with the scope a receiver’s key needs to read what each one names.
  • webhookEventDeliveries.list, webhookEventDeliveries.get and webhookEventDeliveries.redeliver: new. A subscription’s delivery log a page at a time, one delivery with its attempts, and a redelivery of the same event, which takes the required Idempotency-Key.
  • apiKeys.getCurrent: scopes may now list webhooks:read and webhooks:write.
A subscription can be edited, tested and given a new signing secret, in beta, under webhooks:write.
  • webhookSubscriptions.update: new. PATCH /v1/webhook-subscriptions/{id} replaces the eventTypes (each still needs its read permission) or changes the receiver url or description, which changes the subscription’s destination. A field left out keeps its value; an empty body is 422 validation_error. A repeat changes nothing more, so it takes no Idempotency-Key. A destination that sends a bearer token (set up in the app) keeps its URL here: a new url for it is 422 validation_error at secret, because the token would follow the URL; change it in the app.
  • webhookSubscriptions.rotateSecret: new. POST /v1/webhook-subscriptions/{id}/rotate-secret replaces the signing secret and answers the new one once; the old one stops verifying at once. It takes the required Idempotency-Key header: a retry with the same key answers the same secret for 24 hours, until the secret is rotated again (409 idempotency_receipt_superseded).
  • webhookSubscriptions.test: new. POST /v1/webhook-subscriptions/{id}/test sends one signed webhook.ping to the receiver and answers its responseStatus, durationMs and errorCode. One attempt, no retry, nothing stored: the ping never appears in the delivery log.
  • webhookSubscriptions.ping: the webhook.ping request, published under OpenAPI webhooks. It carries the headers and the X-Enrik-Signature of every event, and data.subscriptionId. No subscription receives it otherwise.
  • webhookSubscriptions.archive: a subscription created with a url now archives the destination made for it; a destination named by destinationId stays.
Signal-definition drafts can be written with an API key, in beta, under the same discovery:write scope. Every write takes the required Idempotency-Key header, as the profile and brief writes do. A key writes drafts only: publishing, retiring and deleting a definition stay in the app, and a key’s call to them answers 404. While signal definitions are switched off for the deployment, a write answers 503 signal_definitions_disabled.
  • signalDefinitions.create: new. Creates a definition and its first draft version. A name already in use is 409.
  • signalDefinitions.update: new. Changes the name or description, never the content.
  • signalDefinitions.appendVersion: new. Adds an immutable draft version and answers it with the definition’s new revision.
  • signalDefinitions.archive and signalDefinitions.restore: new. Move the definition to archived and back to active.
  • Each one but create takes the path parameter signalDefinitionId, as on the reads, and needs the expectedRevision you were shown; a stale one is 409 stale_version.
Discovery jobs can be created, changed, started, canceled and archived with an API key, in beta. discovery:run now also covers changing a draft job and starting it, so a key minted with it before this change cannot start a job until it is minted again. Starting a job spends provider budget, so a key’s start is refused while the platform’s spend switch is off: 403 permission_denied with error.details.code api_spend_disabled. With the switch on, a key’s start also needs fewer than 3 jobs queued or running in the workspace (else 409 concurrent_run_limit, discovery_active_jobs) and room in the daily allowance of 10 key starts per workspace and 50 on the platform per UTC day (else 409 conflict, discovery_daily_limit, with scope and resetsAt); those are the defaults. A job a key creates runs with that key’s own authority, and fails with permission_denied if the key was revoked before it ran. Starts in the app are unchanged.
  • companyDiscoveryJobs.create (discovery:run): create a job in draft; it finds nothing until it is started. Takes the required Idempotency-Key header: a retry with the same key and body answers the first response for 24 hours, the same key with another body is 409 idempotency_key_reused.
  • companyDiscoveryJobs.update (discovery:run): change a draft job’s label or its ICP profile and brief links. The path parameter is jobId, as on the reads.
  • companyDiscoveryJobs.start (discovery:run): queue the job; poll it with companyDiscoveryJobs.get. Starting a queued job again republishes it and counts once.
  • companyDiscoveryJobs.cancel (discovery:run): any key may cancel a job a key created; a job a member created answers 403 permission_denied.
  • companyDiscoveryJobs.archive (discovery:write): archive a job that is not queued or running.
  • companyDiscoveryJobs.get, companyDiscoveryJobs.list: a job gains createdByApiKeyId, the key that created it, or null; for such a job createdBy is the user who minted the key.
Discovery candidates can be reviewed and promoted with an API key, in beta, under the same discovery:write scope; promoting also needs crm:write, because it creates or links workspace companies. None of these writes takes an Idempotency-Key: a repeat changes nothing. A single verdict repeated answers 422 with error.details.code invalid_status; a bulk repeat reports the item as failed or skipped. A key without a scope the operation needs gets 403 insufficient_scope naming it.
  • companyCandidates.accept and companyCandidates.reject: new. Move a new or needs_review candidate to accepted or rejected. Reject is the review’s dismiss.
  • companyCandidates.archive: new. Moves an accepted, rejected or promoted candidate to archived, which hides it from review; a promoted candidate’s company stays.
  • companyCandidates.promote: new. Turns an accepted candidate into a workspace company: 201 when a company was created, 200 when it was linked to one the workspace has. A domain the workspace already holds is 409; for a candidate from Enrik data, send targetCompanyId to link instead.
  • companyCandidates.bulkAccept, companyCandidates.bulkReject, companyCandidates.bulkArchive and companyCandidates.bulkPromote: new. The same actions for 1 to 200 candidateIds, each candidate on its own: one failure never fails another, and the answer reports every item.
  • The single-candidate operations take the path parameter candidateId, as on the reads.

2026-10-08

A members list, in beta, and a contact create now needs a name or an e-mail address.
  • workspaceMembers.list: new. GET /v1/workspace-members pages through the workspace’s active members, with crm:write, as setting an owner needs. Each member has the user id that ownerUserId takes on contacts and companies, and its name.
  • contacts.create: a body without a non-blank firstName, lastName or email is refused with 422 validation_error and one details entry at the path "", instead of creating an empty contact. Send at least one of the three.
Webhook events, in beta. The public document now describes, under OpenAPI webhooks, the signed POST Enrik sends to a subscribed destination; nothing is added under paths, so no key gains an operation. Events are being rolled out and are not sent yet.
  • companyDiscoveryJobs.terminal: the companyDiscoveryJob.terminal event, sent once a company discovery job completes, completes with errors, fails or is canceled. Its body carries the event id, type, version, occurredAt, workspaceId and data (jobId, status); read the job with companyDiscoveryJobs.get. Delivery is at least once and unordered: deduplicate by id. Verify X-Enrik-Signature (HMAC-SHA256 over the timestamp, a dot and the raw body); see the webhooks page.
The public document now declares every error status an operation can answer (API review). Nothing changes on the wire; a generated client gains typed errors. Branch on the status and error.code.
  • Every operation declares 400, 422, 500 and 503, and, with an API key, 401, 403 and 429; with a path parameter 404; a write that is not a delete 409; a body 413 and 415. companies.list and companyCandidates.list also declare 404 and 409 (a ranked list). Each status is a shared response on the same error envelope that lists the codes answered with it, and ErrorInfo.code lists every known code as an extensible enum. The operations: apiKeys.getCurrent, companies.create, companies.delete, companies.get, companies.getProvenance, companies.getSignalSummary, companies.list, companies.listContacts, companies.listSignals, companies.listTimeline, companies.listTrash, companies.restore, companies.update, companyCandidates.get, companyCandidates.list, companyDiscoveryJobs.get, companyDiscoveryJobs.getQualitySummary, companyDiscoveryJobs.list, companyDiscoveryJobs.listCandidates, companySignals.get, contacts.create, contacts.delete, contacts.get, contacts.list, contacts.listTimeline, contacts.listTrash, contacts.restore, contacts.update, customFieldDefinitions.list, discoveryBriefs.get, discoveryBriefs.getVersion, discoveryBriefs.list, discoveryBriefs.listVersions, feedback.submit, icpAssessments.get, icpProfiles.get, icpProfiles.getVersion, icpProfiles.list, icpProfiles.listVersions, savedViews.create, savedViews.delete, savedViews.get, savedViews.list, savedViews.update, signalDefinitions.get, signalDefinitions.getVersion, signalDefinitions.list, signalDefinitions.listVersions, signalDetectors.getCatalogue, websiteOnboarding.chooseSetups, websiteOnboarding.complete, websiteOnboarding.confirmProfile, websiteOnboarding.draftProfile, websiteOnboarding.get, websiteOnboarding.getAccess, websiteOnboarding.getCompletion, websiteOnboarding.reset and websiteOnboarding.start.
  • health.get joins the public document: GET /v1/health answers { "status": "ok" } while the API is up, with no API key (security: []). It is the API catalog’s status link. Like every public operation it refuses a query parameter it does not declare with 422.

2026-10-07

A filter or cursor value the database cannot read answers 422 validation_error, like every other bad value, instead of 400 or 500. A client that branched on a 400 validation_error must handle the 422 instead.
  • companies.list and contacts.list: a filter on an id field (ownerUserId, companyId) whose value is not a UUID, or on a date field whose value lies outside the years 0001–9999, answers 422 with one details entry whose path names the filter, such as filters.ownerUserId. The id case answered 400, the date case 500.
  • Every list operation that takes a cursor: an edited cursor whose id is not a UUID, or whose date lies outside those years, answers 422 validation_error instead of 400 or 500. Most lists, companies.list and contacts.list among them, name it with error.details.code invalid_cursor, as they already did for a cursor of another sort. On those lists a cursor date that does not exist, such as 30 February, is refused the same way instead of being read as a later day.
  • Any other value the database cannot read answers 422 validation_error with no details, never 400 or 500.
Errors use the codes the API reference’s error table lists. The statuses are unchanged; a client that branched on the old codes must switch to the new ones.
  • contacts.create and contacts.update (a companyId that is not a live company of the workspace), and contacts.create, contacts.update, companies.create and companies.update (an ownerUserId that is not an active member), answer 422 with error.code validation_error instead of unprocessable. details keeps its one entry, path and message, and the entry adds code: "related_record_not_found". The message names the field.
  • websiteOnboarding.confirmProfile: a website that is not a usable web address answers 422 validation_error with one details entry, path profile.website and code invalid_url, instead of 422 company_twin_invalid_url.
  • companies.create, companies.update, contacts.create, contacts.update, savedViews.create, savedViews.update, feedback.submit, websiteOnboarding.start, websiteOnboarding.draftProfile, websiteOnboarding.confirmProfile, websiteOnboarding.reset, websiteOnboarding.chooseSetups and websiteOnboarding.complete: a body over 1 MiB answers 413 payload_too_large, and a Content-Type that is not a media type 415 unsupported_media_type. Both answered internal_error.
  • companies.create, contacts.create and savedViews.create: a retry refused because the key lacks crm:read still answers 403 permission_denied; its message now names the scope.
  • savedViews.update and savedViews.delete describe that a company view needs objectType=company; websiteOnboarding.getAccess describes eligibility in scopes.
A workspace without website research confirms its company profile. websiteOnboarding.confirmProfile with a website answered 403 company_twin_controller_unavailable and saved nothing when website research was not available to the workspace. It now saves the profile and the setup state and starts no research. Every Confirm answer carries research: { "status": "started" }, or { "status": "not_started", "reason": "no_website" | "unavailable" }; a replay answers the same.
  • websiteOnboarding.confirmProfile answers 200 where it answered 403 and adds research.
  • websiteOnboarding.get, websiteOnboarding.start, websiteOnboarding.draftProfile, websiteOnboarding.reset and websiteOnboarding.chooseSetups share Confirm’s answer schema, which declares the optional research; they never send it. Nothing changes for their clients.
Added apiKeys.getCurrent: an API key’s description of itself. GET /v1/me answers { apiKeyId, workspaceId, scopes } for any valid API key, whatever its scopes, like feedback.submit (x-api-key-access: any-authenticated, security: [{ apiKey: [] }]); a browser session gets 401. scopes are the scope names the key holds, in a fixed order. What the key may do is also bounded by the permissions frozen when it was minted, so a listed scope does not promise that every operation naming it succeeds; 403 insufficient_scope still names what an operation needs. It counts against the key’s ordinary rate limit. The generated document’s inventory becomes 58 operations.
  • apiKeys.getCurrent is new: GET /v1/me, beta, any authenticated API key.
Query parameters refuse a parameter they do not declare (verification Issue D). Every operation but signalDefinitions.list used to ignore an undeclared query parameter, so a misspelt ?filter= returned the unfiltered list with 200. It is now refused with 422 validation_error before anything is read or written, and the error’s details name it, as for bodies: { "path": "", "message": "Unrecognized key(s) in object: 'filter'" }. An operation that declares no query parameter refuses every one. Unknown tokens inside include are still ignored. Migration: drop cache-busters (_=…), tracking parameters and any parameter the reference does not list for the operation; the TypeScript SDK and MCP already refused them before sending. The OpenAPI document does not change for this: query parameters were always the ones it lists.
  • companies.list, companies.listTrash, companies.get, companies.listContacts, companies.listTimeline and companies.listSignals refuse an undeclared query parameter.
  • contacts.list, contacts.listTrash, contacts.get and contacts.listTimeline refuse an undeclared query parameter.
  • savedViews.list, savedViews.get, savedViews.update and savedViews.delete refuse an undeclared query parameter.
  • customFieldDefinitions.list, icpAssessments.get, icpProfiles.list, discoveryBriefs.list and discoveryBriefs.listVersions refuse an undeclared query parameter.
  • companyCandidates.list, companyDiscoveryJobs.list and companyDiscoveryJobs.listCandidates refuse an undeclared query parameter, reported before any declared parameter is judged.
  • companies.create, companies.update, companies.delete, companies.restore, companies.getProvenance and companies.getSignalSummary take no query parameter and now refuse every one.
  • contacts.create, contacts.update, contacts.delete and contacts.restore take no query parameter and now refuse every one.
  • companySignals.get, companyCandidates.get, companyDiscoveryJobs.get, companyDiscoveryJobs.getQualitySummary, discoveryBriefs.get and discoveryBriefs.getVersion take no query parameter and now refuse every one.
  • icpProfiles.get, icpProfiles.listVersions, icpProfiles.getVersion, signalDefinitions.get, signalDefinitions.listVersions, signalDefinitions.getVersion and signalDetectors.getCatalogue take no query parameter and now refuse every one.
  • savedViews.create and feedback.submit take no query parameter and now refuse every one.
  • websiteOnboarding.get, websiteOnboarding.getAccess, websiteOnboarding.getCompletion, websiteOnboarding.start, websiteOnboarding.draftProfile, websiteOnboarding.confirmProfile, websiteOnboarding.reset, websiteOnboarding.chooseSetups and websiteOnboarding.complete take no query parameter and now refuse every one.
  • signalDefinitions.list already refused one; unchanged.
On the API host, a method a published path does not support answers 405 method_not_allowed with an Allow header listing the methods the reference publishes for that path, instead of 404. Paths the reference does not publish keep the neutral 404. The path is read as the router reads it: it ends at ? or # but not at ;, so /v1/me;extra is a path of its own and answers 404. GET /v1/openapi.json carries a strong ETag; send it as If-None-Match to get 304 Not Modified while the document is unchanged. Paging no longer repeats or skips records. A record’s createdAt and updatedAt were stored with microseconds, while a cursor carries milliseconds. So an ascending createdAt or updatedAt walk could return the previous page’s last record again (with limit=1, forever), and a descending one, the default order included, could leave out a record created in the same millisecond as the previous page’s last one; a timeline could leave out events written together. Timestamps are now stored to the millisecond, as they are returned. Values you have already read do not change, and nothing on the wire changes. No action is needed. (Internal lists that page on a timestamp alone, without an id, still need a tie-break: #534.)
  • companies.list, companies.listContacts, companies.listTimeline, contacts.list and contacts.listTimeline page without repeats or gaps.
  • companyCandidates.list (sorted by createdAt), companyDiscoveryJobs.list, companyDiscoveryJobs.listCandidates, discoveryBriefs.list, icpProfiles.list and signalDefinitions.list page without repeats or gaps.
Companies can be filtered on registerNumber, city, legalForm and summary. A filter on them was refused with 422 validation_error, and the message suggested customFields.<name>, which is a different value: a custom field of that name. Following it returned a wrong answer. They now take every text operator, isNull and isNotNull included. A field the record has but that cannot be filtered on, such as address, is still refused, now without that suggestion; it is given only for a name the record does not have. The filters parameters and the saved-view query now list the fields they filter on.
  • companies.list filters on registerNumber, city, legalForm and summary, and its filters description lists every field, nameOrDomain and the signal fields included.
  • savedViews.create and savedViews.update accept a company view that filters on the four fields.
  • contacts.list lists its filterable and sortable fields; its behaviour is unchanged.
Request bodies refuse a field they do not declare. A body field an operation does not declare, at any level of the body, used to be dropped while the request succeeded; it is now refused with 422 validation_error, and nothing is written. The error’s details name each unknown field: an entry’s path is the object that holds it ('' for the top of the body) and its message names the fields, such as Unrecognized key(s) in object: 'bogusField'. The request schemas publish additionalProperties: false. Maps of the caller’s own keys (customFields, a company’s address) stay open. Send only the documented fields.
  • companies.create and companies.update refuse an undeclared field.
  • contacts.create and contacts.update refuse an undeclared field.
  • savedViews.create and savedViews.update refuse an undeclared field in the body, its query, a filter or a sort key.
  • websiteOnboarding.start, websiteOnboarding.reset, websiteOnboarding.draftProfile and websiteOnboarding.confirmProfile (its profile too) refuse an undeclared field. websiteOnboarding.chooseSetups, websiteOnboarding.complete and feedback.submit already did.

2026-10-06

Added anonymous agent feedback. feedback.submit is a new beta operation any valid API key may call, whatever its scopes: it needs no scope and is marked x-api-key-access: any-authenticated with security: [{ apiKey: [] }]. It requires an Idempotency-Key that is a fresh UUIDv4 per note (published inline, not the shared 1–200 character header), answers 202 {}, stores no sender, and answers 409 idempotency_key_reused for another body under a used key.
  • feedback.submit is new: POST /v1/feedback, beta, any authenticated API key, the ordinary per-key rate limit.
Added nine beta workspace setup operations: access, state and completion reads; start, profile draft/confirm, reset, choose setups and complete. They use the new setup:read and setup:run scopes. Existing grants receive no setup write authority. Every setup POST requires Idempotency-Key as a UUID equal to body operationId, including browser-session callers. Replays remain durable and actor-bound. API keys must omit private answers. Public server moves to the configured dedicated API host; internal/browser traffic stays same-origin. Anonymous /v1/openapi.json serves exact packaged public bytes with no-cache. The dedicated host permits public operations only, requires Bearer keys and carries noindex headers.
  • websiteOnboarding.get, websiteOnboarding.getAccess and websiteOnboarding.getCompletion are new beta reads requiring setup:read.
  • websiteOnboarding.start, websiteOnboarding.draftProfile, websiteOnboarding.confirmProfile, websiteOnboarding.reset, websiteOnboarding.chooseSetups and websiteOnboarding.complete are new beta mutations requiring setup:run and matching UUID header/body command IDs.
  • icpProfiles.list and icpProfiles.get now return draft alongside active and archived. The list’s status filter also accepts draft; an unfiltered list includes drafts. Clients must display drafts as awaiting review. Drafts cannot execute evaluations or discovery; activation is an explicit revision-fenced metadata PATCH. Public create and writable status values stay unchanged.

2026-10-03

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

2026-10-02

A company’s signals, beta and read-only. Both need the crm:read scope, which now carries the signals permission (company_signals:read). A key sees what the app shows: the same signals, with the same fields and evidence passages, the same pages and cursors, and the same summary. Like the app, it does not see a company’s signals from signal checks while the evidence of any of them was purged, is past its retention or may not be shown; they are left out before paging, so pageInfo describes only the signals served. A key whose stored grant lacks company_signals:read (one minted with crm:read alone before this change) is refused both with 403 permission_denied. A key minted with both crm:read and twin:read already holds it. Rotating a key keeps its stored grant and does not add the permission: create a new key instead.
  • companies.listSignals is new: the signals recorded about a live company, newest first. It pages like savedViews.list; filter by stored status (active, dismissed or archived), and an active signal past its age reads expired. A cursor whose id is not a uuid answers 422 with error.code validation_error and error.details.code invalid_cursor.
  • companies.getSignalSummary is new: what the company’s active signals add up to, as the app shows it: the latest one, the counts and the priority. With none, or while the app hides the company’s signal-check results, the answer is the empty summary: it keeps companyId and scoringVersion, the counts are 0, and the latest-signal fields and the priority are null.
Rate limits are per API key. A key’s requests count against the key itself, never against the address it calls from, so clients behind one shared egress address (an MCP server, a proxy) no longer spend each other’s budget. Two policies, named in the headers:
  • api-key counts every request the key makes: 600 a minute.
  • api-key-run also counts each request to an operation that needs a run scope: 60 a minute. No public operation needs one yet.
RateLimit-Policy always states the quotas that apply; read them there rather than from this page. Every answer to a key carries RateLimit-Policy (each policy’s quota and window) and RateLimit (what is left under each, and its effective window in seconds), the IETF RateLimit header fields. Both are lists, one item per policy that counted the request, such as "api-key";r=599;t=60: parse them as lists, not numbers, and honour every item, since continued traffic must satisfy each policy. t is a window, not a promise that the whole quota is back when it ends. A key past a limit gets 429 with error.code rate_limited and Retry-After in seconds, which takes priority over RateLimit; wait that long, then retry. Keys no longer get the X-RateLimit-* headers of the per-address limit, which nothing documented. A network that presents too many invalid, expired or revoked keys (30 a minute) gets 429 with Retry-After for the next minute instead of 401, whatever key it sends, except a key the API verified from it recently. Fix the key rather than retrying a 401. Every operation’s success response now names the two headers, and its 429 names Retry-After too. The examples are each operation’s own: none of these needs a run scope, so each names api-key alone:
  • companies.create, companies.delete, companies.get, companies.getProvenance, companies.getSignalSummary, companies.list, companies.listContacts, companies.listSignals, companies.listTimeline, companies.listTrash, companies.restore and companies.update.
  • companyCandidates.get and companyCandidates.list.
  • companyDiscoveryJobs.get, companyDiscoveryJobs.getQualitySummary, companyDiscoveryJobs.list and companyDiscoveryJobs.listCandidates.
  • contacts.create, contacts.delete, contacts.get, contacts.list, contacts.listTimeline, contacts.listTrash, contacts.restore and contacts.update.
  • customFieldDefinitions.list.
  • discoveryBriefs.get, discoveryBriefs.getVersion, discoveryBriefs.list and discoveryBriefs.listVersions.
  • icpAssessments.get.
  • icpProfiles.get, icpProfiles.getVersion, icpProfiles.list and icpProfiles.listVersions.
  • savedViews.create, savedViews.delete, savedViews.get, savedViews.list and savedViews.update.
  • signalDefinitions.get, signalDefinitions.getVersion, signalDefinitions.list and signalDefinitions.listVersions.
  • signalDetectors.getCatalogue.

2026-09-30

The first public operations: saved views, all beta until launch (they may still change, with an entry here).
  • A key missing an operation’s scope gets 403 insufficient_scope with a WWW-Authenticate: Bearer error="insufficient_scope", scope="…" challenge naming what it needs.
  • savedViews.list is new. It needs the crm:read scope, and pages: pass pageInfo.endCursor as cursor for the next page; limit is 1 to 200, 50 when absent. objectType (contact or company) is required.
  • savedViews.get is new. It needs crm:read. Name objectType=company for a company view.
  • savedViews.create is new. It needs crm:write and an Idempotency-Key header: send a unique key per view, and the same key when you retry; a retry returns the first view instead of a second, to a key that also holds crm:read (without it the retry is refused with 403).
  • savedViews.update is new. It needs crm:write. Sending the same body twice is harmless.
  • savedViews.delete is new. It needs crm:write. Deleting a view that is already deleted also answers { "ok": true }, so a retry is harmless.
The discovery reads, all beta and read-only: running discovery, reviewing candidates and changing profiles, briefs or signal definitions stay in the app. Each needs the discovery:read scope, and the ICP assessment also crm:read. The lists page like savedViews.list: pass pageInfo.endCursor as cursor; limit is 1 to 200, 50 when absent, and a value outside that is refused with 422, not clamped. A page may hold fewer items than limit: pageInfo.limit says what applied (at most 100).
  • companyDiscoveryJobs.list and companyDiscoveryJobs.get are new: discovery jobs, filtered by status or sourceType.
  • companyDiscoveryJobs.listCandidates and companyDiscoveryJobs.getQualitySummary are new: one job’s candidates, and how good they are.
  • companyCandidates.list and companyCandidates.get are new. The list filters by job, status, scores and source; sort is one score field, highest first, or createdAt. Ask for a ranked list with rankingMode and sort=contextual_priority when contextualRanking.available is true; its rows carry a recommendation. A ranked sort without rankingMode, or another sort with it, is refused with 400 invalid_sort.
  • icpProfiles.list, icpProfiles.get, icpProfiles.listVersions and icpProfiles.getVersion are new. A profile’s criteria are in its versions; the versions read is an array, newest first.
  • discoveryBriefs.list, discoveryBriefs.get, discoveryBriefs.listVersions and discoveryBriefs.getVersion are new.
  • icpAssessments.get is new: a company’s stored assessment against an ICP profile version. Reading never evaluates; a company not assessed yet answers status: "never_evaluated".
  • signalDefinitions.list, signalDefinitions.get, signalDefinitions.listVersions and signalDefinitions.getVersion are new. The list refuses a parameter it does not know.
  • signalDetectors.getCatalogue is new: every detector kind, and why any cannot run.
Contacts, companies and the custom-field list become public, all beta until launch. Every list pages the same way: pass pageInfo.endCursor as cursor for the next page; limit is 1 to 200, 50 when absent, and a value outside that range is refused. Every create needs an Idempotency-Key header: send a unique key per record, and the same key when you retry; a retry returns the first record instead of a second, to a key that also holds crm:read (without it the retry is refused with 403), and the same key with another body is refused with 409 idempotency_key_reused. Every update may send the updatedAt it read as expectedUpdatedAt: if the record changed since, nothing is written and the answer is 409 stale_version. Deleting a record that is already deleted also answers { "ok": true }, so a retry is harmless. On the record reads, include may also ask for the per-column cell states; those need enrichment:read as well (agentCells: automation:read), and a key without it is refused with 403 insufficient_scope, whatever the page holds. A company’s domain is unique among the live companies not linked to an Enrik company. A company’s signal list and signal summary are not public yet: they stay in the app for now.
  • contacts.list is new. It needs crm:read. filters, sort and columns are JSON text (the query a saved view stores); include=company adds each contact’s company.
  • contacts.listTrash, contacts.get and contacts.listTimeline are new. They need crm:read.
  • contacts.create, contacts.update and contacts.restore are new. They need crm:write.
  • contacts.delete is new. It needs crm:delete.
  • companies.list is new. It needs crm:read. It takes the contact list’s query, and with rankingMode (and sort=contextual_priority) ranks the companies by recommendation; ranking reads the ICP profile and the brief, so it also needs discovery:read.
  • companies.listTrash, companies.get, companies.listTimeline, companies.listContacts and companies.getProvenance are new. They need crm:read.
  • companies.create, companies.update and companies.restore are new. They need crm:write.
  • companies.delete is new. It needs crm:delete.
  • customFieldDefinitions.list is new. It needs crm:read, and names the keys contacts’ and companies’ customFields hold.