Skip to content
API reference

The v1 API, as shipped

What does the Cuvy API expose?

Seven endpoints. One tells you whose key you hold and what it has left, one looks a person up, one reads a person back, and three manage webhook subscriptions so another system can be told when an address is found. Every call runs under the rules the rest of Cuvy runs under: one credit per person with evidence behind the address, nothing for a miss, and a refusal rather than an overage when the balance is empty.

Base URL
https://app.cuvy.io
Auth
Authorization: Bearer cuvy_sk_…
Rate limit
60 requests a minute per key
Format
JSON in, JSON out, UTC timestamps
Authentication

One header, one key per workspace

Keys are made in the app under Settings → API by an Owner or an Admin. A workspace can hold three active keys; the secret is shown once when it is made and cannot be read back. Send it on every request as a bearer token:

Authorization: Bearer cuvy_sk_9f3a…

A key belongs to the workspace rather than to the person who made it, so somebody leaving the team does not break a nightly job. Revoking a key takes effect on the next call.

Errors every endpoint can return

StatuserrorMeaning
401no_api_keyNo Authorization: Bearer header on the request.
401invalid_api_keyThe key is unknown or has been revoked.
400key_in_urlA key was sent in the query string. Refused before it is read: a URL is written to every log between you and us. Send it as the bearer header only.
429More than 60 requests in a minute on this key. Retry-After says how many seconds to wait. Nothing was looked up or charged.

Error bodies are JSON with an error code meant for a program and, where it helps, a message meant for a person. Match on the code.

Account

Who am I, and what is left

GET /api/v1/me

The key's workspace and its own name, plus the balance. Free. This is the call to point an authentication test at: it proves the key works and returns a name to label the connection with. It never returns the key itself — key.prefix is the visible part, enough to tell two keys apart and not enough to authenticate.

GET https://app.cuvy.io/api/v1/me
Authorization: Bearer cuvy_sk_…

200 OK
{
  "workspace": { "id": "5b1c…", "name": "Acme" },
  "key": { "prefix": "cuvy_sk_9f3a", "name": "Zapier" },
  "credits": 4831
}

GET /api/v1/credits

Just the balance, as { "credits": 4831 }. Free, and the same number every other Cuvy surface shows. Call it before a batch to know whether the batch will finish.

Lookup

One person in, one person out

POST /api/v1/lookup

Finds one person's work address. Send what you know; only the name is required, and every field you add narrows the search. A handle names one person; a name plus a company is a guess two namesakes at one employer can defeat.

FieldTypeNotes
namestring, requiredFull name, 1–160 characters.
companystringEmployer name, up to 160 characters.
companyDomainstringEmployer domain, e.g. brambleco.com. The strongest hint you can give.
titlestringJob title, up to 255 characters.
locationstringFree text, up to 255 characters.
linkedinUrlstringProfile URL, up to 500 characters. Names one person exactly.

Response

FieldMeaning
person.idStable id for this person in your workspace. Pass it to GET /api/v1/people/:id later.
person.statusOne of verified, risky, provided, not-found. See the statuses below.
person.emailThe address, or null when the status is not-found.
person.confidence0–100. How the address was arrived at, not a probability.
person.candidatesEvery address considered, best first. Each has email, confidence, source and a status of verified, risky or unconfirmed.
person.sourcesTriedHow many sources the lookup consulted.
person.enrichedAtISO-8601 UTC timestamp of the lookup.
chargedWhether this call spent a credit. False on a miss, on a repeat, and on a guess nothing vouches for.
creditsThe balance after this call.
POST https://app.cuvy.io/api/v1/lookup
Authorization: Bearer cuvy_sk_…
Content-Type: application/json

{
  "name": "Dana Whitfield",
  "company": "Bramble Co",
  "companyDomain": "brambleco.com"
}

200 OK
{
  "person": {
    "id": "0f6e…",
    "name": "Dana Whitfield",
    "title": "Head of Operations",
    "company": "Bramble Co",
    "companyDomain": "brambleco.com",
    "location": "Leeds, England",
    "linkedinUrl": "",
    "status": "verified",
    "email": "dana@brambleco.com",
    "confidence": 96,
    "candidates": [
      { "email": "dana@brambleco.com", "confidence": 96, "source": "corpus", "status": "verified" }
    ],
    "sourcesTried": 3,
    "enrichedAt": "2026-09-06T08:30:00.000Z"
  },
  "charged": true,
  "credits": 4830
}

402 Payment Required
{
  "error": "insufficient_credits",
  "message": "Out of credits. Nothing was charged.",
  "credits": 0
}

Errors

StatuserrorMeaning
400invalid_inputThe body did not validate. issues lists each problem.
402insufficient_creditsThe balance is empty. Nothing was charged, and credits says what it stopped at. There is no overage.

GET /api/v1/people/:id

Reads a person the workspace already holds, by the id a lookup returned, in the same person shape and without charging. 404 not_found if the id is not in your workspace; 400 invalid_id if it is not a UUID.

Statuses

statusMeaningCharged?
verifiedTwo independent sources agree, or a check reached the mailbox.Yes, once.
riskyOne sighting, shown with that sighting. Read confidence and candidates before sending.Yes, once — the credit pays for the evidence, not the label.
providedAn address you supplied that a lookup has not improved on.No.
not-foundNothing we would stand behind. email is null.No.
Webhooks

Be told when something happens

Subscribe a URL to an event, get an id back, delete the id to stop. The same endpoints an automation platform's app uses on a user's behalf.

POST /api/v1/hooks

Body { "url", "event" }. The URL must be public https on port 443 — private and link-local addresses are refused with 400 unsafe_url. The event is one of:

eventFires whendata carries
person.enrichedA lookup produced an address for somebody — from the API, the app or the extension. Once per person.id, personId, name, email, status, title, company, companyDomain, linkedinUrl, confidence, enrichedAt
run.completedAn enrichment run finished.jobId, name, processed, verified, notFound
list.updatedPeople or companies were added to or removed from a list.listId, name, type, total
monitor.job_changeThe extension saw a profile again and the company had changed.name, previousCompany, company

Answers 201 with the subscription's id. A URL at hooks.zapier.com is recognised and receives the flat body described below with provider set to zapier; any other URL receives the signed envelope, and the secret to verify it with is returned here, once.

POST https://app.cuvy.io/api/v1/hooks
Authorization: Bearer cuvy_sk_…
Content-Type: application/json

{ "url": "https://example.com/hooks/cuvy", "event": "person.enriched" }

201 Created
{
  "id": "c2d4…",
  "event": "person.enriched",
  "provider": "custom",
  "secret": "3f9a…64 hex characters…",
  "createdAt": "2026-09-06T08:30:00.000Z"
}

DELETE https://app.cuvy.io/api/v1/hooks/c2d4…
Authorization: Bearer cuvy_sk_…

204 No Content

DELETE /api/v1/hooks/:id

Removes the subscription. 204 when it was there, 404 not_found when it was not — including when it belonged to a different workspace.

GET /api/v1/hooks/sample

Up to three recent events, as an array, rendered exactly as a subscriber would receive them — so a system can learn the shape before a real delivery arrives. Query event picks the event (default person.enriched) and preset picks the body: zapier for the flat form, custom for the envelope. For person.enriched the samples are your workspace's own latest finds; a workspace that has found nobody yet gets one clearly marked test record with test: true.

What a delivery looks like

One POST per event, JSON body, with the event and delivery ids in headers so a receiver can log before it parses. A zapier endpoint receives the same fields flattened to one level — event, created, workspace_id, then everything in data — because a Zap reads its fields off the first body it sees and name reads better than data__name.

POST https://example.com/hooks/cuvy
Content-Type: application/json
User-Agent: Cuvy/1.0 (+https://cuvy.io)
Cuvy-Event: person.enriched
Cuvy-Delivery: 7a1e…
Cuvy-Signature: t=1757147400,v1=9c0b…

{
  "id": "evt_7a1e…",
  "event": "person.enriched",
  "created": "2026-09-06T08:30:00.000Z",
  "workspaceId": "5b1c…",
  "data": {
    "id": "0f6e…",
    "personId": "0f6e…",
    "name": "Dana Whitfield",
    "email": "dana@brambleco.com",
    "status": "verified",
    "title": "Head of Operations",
    "company": "Bramble Co",
    "companyDomain": "brambleco.com",
    "linkedinUrl": "",
    "confidence": 96,
    "enrichedAt": "2026-09-06T08:30:00.000Z"
  }
}

Verifying the signature

Cuvy-Signature is t=<unix seconds>,v1=<hex>. Compute an HMAC-SHA256 over the timestamp, a dot and the raw request body — the bytes as received, not re-serialised — with the secret from the 201, and reject anything older than five minutes:

expected = hex( hmac_sha256( secret, t + "." + raw_body ) )
accept if expected == v1 and |now - t| <= 300 seconds

Every retry is re-signed with a fresh timestamp, so a correct receiver accepts retries and rejects replays.

Retries, and how to stop

A 2xx settles the delivery. A 408, a 429 or any 5xx is retried after 1, 5, 30, 120 and 360 minutes — six attempts in all — and then marked failed. Any other 4xx fails at once. Answer 410 Gone and the endpoint is switched off, which is the polite way for a receiver that no longer exists to say so.

Questions

Asked while writing a client

Does asking for the same person twice charge twice?

No. A credit is spent the first time a lookup hands the workspace an address it has evidence for, and never again for that person — a second call, from any key on the workspace, returns what is stored and reports charged as false.

Why did a lookup return 200 with no email?

Because a miss is an answer, not an error. A person whose address could not be found comes back with status not-found, email null and charged false, so a loop can record the outcome and move on without special-casing a failure code.

What is the rate limit, and what happens past it?

Sixty requests a minute per key, across every v1 endpoint. Past that the call is answered 429 with a Retry-After header and nothing is looked up or charged. A client that waits the stated seconds and tries again loses nothing.

Can a webhook subscription made by a Zap be switched off by a person?

Yes. A subscription made through the API is the same kind of endpoint the webhooks screen in the app manages, labelled after the key that made it. An Owner or Admin can pause or delete it there, and a delete from the API removes it in the same way.