The v1 API, as shipped
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
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
| Status | error | Meaning |
|---|---|---|
| 401 | no_api_key | No Authorization: Bearer header on the request. |
| 401 | invalid_api_key | The key is unknown or has been revoked. |
| 400 | key_in_url | A 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. |
| 429 | — | More 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.
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.
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.
| Field | Type | Notes |
|---|---|---|
name | string, required | Full name, 1–160 characters. |
company | string | Employer name, up to 160 characters. |
companyDomain | string | Employer domain, e.g. brambleco.com. The strongest hint you can give. |
title | string | Job title, up to 255 characters. |
location | string | Free text, up to 255 characters. |
linkedinUrl | string | Profile URL, up to 500 characters. Names one person exactly. |
Response
| Field | Meaning |
|---|---|
person.id | Stable id for this person in your workspace. Pass it to GET /api/v1/people/:id later. |
person.status | One of verified, risky, provided, not-found. See the statuses below. |
person.email | The address, or null when the status is not-found. |
person.confidence | 0–100. How the address was arrived at, not a probability. |
person.candidates | Every address considered, best first. Each has email, confidence, source and a status of verified, risky or unconfirmed. |
person.sourcesTried | How many sources the lookup consulted. |
person.enrichedAt | ISO-8601 UTC timestamp of the lookup. |
charged | Whether this call spent a credit. False on a miss, on a repeat, and on a guess nothing vouches for. |
credits | The 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
| Status | error | Meaning |
|---|---|---|
| 400 | invalid_input | The body did not validate. issues lists each problem. |
| 402 | insufficient_credits | The 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
| status | Meaning | Charged? |
|---|---|---|
verified | Two independent sources agree, or a check reached the mailbox. | Yes, once. |
risky | One sighting, shown with that sighting. Read confidence and candidates before sending. | Yes, once — the credit pays for the evidence, not the label. |
provided | An address you supplied that a lookup has not improved on. | No. |
not-found | Nothing we would stand behind. email is null. | No. |
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:
| event | Fires when | data carries |
|---|---|---|
person.enriched | A 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.completed | An enrichment run finished. | jobId, name, processed, verified, notFound |
list.updated | People or companies were added to or removed from a list. | listId, name, type, total |
monitor.job_change | The 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 ContentDELETE /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 secondsEvery 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.
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.
Something here disagree with what the API did? Write to support@cuvy.io with the request and the response, and this page is what gets fixed.