API
Check one address, or a list of any size, from your own code. Same credits as the dashboard, same verdicts.
A REST API over HTTPS. Everything is JSON, every field is snake_case, and the
base is https://listcheckup.com. There is no SDK to install and nothing to
wait for — create a key and the two commands below work.
Authentication
Every request carries an API key, created in
your dashboard. Keys start
lck_live_, can be given an expiry, and can be revoked at any time.
curl "https://listcheckup.com/v1/me" \
-H "Authorization: Bearer lck_live_..." GET /v1/me answers with the account the key belongs to and its balance, which
makes it the quickest way to confirm a key works:
{ "email": "[email protected]", "credits": 24600 }
Without a key, or with one that has been revoked or has expired, every endpoint answers
401 and says which of those it was — a revoked key and a typo call for
different fixes.
Rate limit
120 requests to begin with, refilling at 60 a minute, per key. That is a ceiling on
request volume rather than on addresses: a list of half a million addresses is one
request. Exceeding it answers 429, and waiting is the fix.
Verify one address
Answers in the response. Use it when somebody is waiting — a signup form, a checkout — rather than for a list you already hold.
curl "https://listcheckup.com/v1/[email protected]" \
-H "Authorization: Bearer lck_live_..."
There is a POST /v1/verify with { "email": "..." } in the
body if a query string is awkward. Both return the same thing:
{
"email": "[email protected]",
"normalized_email": "[email protected]",
"local_part": "someone",
"domain": "example.com",
"status": "valid",
"reason": "mailbox_accepted",
"confidence": 95,
"duration_ms": 412
} status is one of five values, and confidence is 0–100 for
how much that status can be relied on. A catch-all scores low even though the server
accepted the address, because acceptance proves nothing there.
| status | What it means |
|---|---|
valid | The mailbox exists and can receive mail. |
invalid | It cannot receive mail. Remove it. |
risky | It works, but it is a throwaway address or a shared inbox rather than a person. |
catch_all | The domain accepts every address, so nobody can tell whether this one exists. |
unknown | The mail server would not answer. A fact about their policy, not the address. |
You are only charged for an answer. catch_all and
unknown cost nothing, because the work was done and the mail server declined
to say — billing for those would be billing you for our inability to find out.
Verify a list
Four calls: create, start, check, collect.
1. Create
Addresses as JSON, not a file upload — you already hold them as strings, and building a multipart body to send strings is work for nothing.
curl -X POST "https://listcheckup.com/v1/lists" \
-H "Authorization: Bearer lck_live_..." \
-H "Content-Type: application/json" \
-d '{"name": "march-subscribers", "emails": ["[email protected]", "[email protected]"]}' Nothing is charged yet. The response is a list summary with the price, so you can decide before spending anything:
{
"id": "7f3c1d20-9a4b-4c8e-8f21-5b6d7e8a9c04",
"name": "march-subscribers",
"status": "awaiting_confirmation",
"addresses": 24600,
"duplicates": 1020,
"not_addresses": 180,
"unique": 23580,
"excluded": 0,
"price": 23400,
"checked": 0,
"credits_charged": 0,
"failure_reason": null,
"created_at": "2026-03-18T09:12:00+00:00",
"completed_at": null
} price is in credits and is what starting it will cost. Repeats are counted
once and lines that are not addresses are never charged, which is why it is lower than
addresses.
2. Start
curl -X POST "https://listcheckup.com/v1/lists/<id>/start" \
-H "Authorization: Bearer lck_live_..."
This is the call that spends credits. 402 means the balance is short, and
nothing was charged or started.
3. Know when it is done
Register a webhook and we POST to your server the moment a list finishes — signed, retried if your end is down. That is the way to do this.
If you would rather ask, GET /v1/lists/<id> returns the same summary as
above with status and checked moving. Poll it sparingly; a
webhook costs you no requests at all.
| status | Meaning |
|---|---|
uploaded | Accepted, not yet read. |
reading | Being counted. price is null until this finishes. |
awaiting_confirmation | Counted and priced. Nothing is charged until you start it. |
queued | Started and waiting for a worker. |
running | In progress. checked climbs. |
completed | Finished. Results are ready. |
failed | Stopped. See failure_reason. |
cancelled | Stopped by you. Unused credits are returned. |
4. Collect the results
curl "https://listcheckup.com/v1/lists/<id>/results?limit=500&skip=0" \
-H "Authorization: Bearer lck_live_..." limit defaults to 500 and is capped at 2,000; page with skip.
Add include=valid,risky to narrow it to the categories you want.
{
"id": "7f3c1d20-...",
"status": "completed",
"total": 24600,
"offset": 0,
"returned": 500,
"results": [
{
"row": 1,
"email": "[email protected]",
"status": "valid",
"confidence": 95,
"reason": "mailbox_accepted",
"billable": true,
"duplicate_of_row": null
}
]
} row is the position in the list you submitted, so results line up with your
own records without matching on the address.
Delete
DELETE /v1/lists/<id> removes a list and its results. Anything unspent
on a list that never ran comes back.
Webhooks
Add an endpoint in the dashboard and we send a
signed POST when a list finishes:
{
"event": "list.finished",
"occurred_at": "2026-03-18T09:41:00+00:00",
"list": {
"id": "7f3c1d20-...",
"name": "march-subscribers",
"checked": 24600,
"safe_to_send": 12000,
"to_remove": 7400,
"uncertain": 3100
}
}
Answer with any 2xx. Anything else and we try again, six times over about a
day. Each request carries an HMAC-SHA256 signature you check with your signing secret; the
exact scheme, with a worked example, is shown beside the secret when you create the
endpoint.
Slack works without any of that. Paste a Slack incoming webhook URL instead of your own, and we send a message Slack can display rather than the JSON above — nothing to write, nothing to host. Retries and the delivery log work the same way.
Use it from an AI assistant
There is an MCP server at https://listcheckup.com/mcp. Point
an assistant that supports MCP — Claude, or an editor like Cursor — at that URL
with the same API key, and it can check addresses for you without anyone writing code.
Connecting it
However you connect, the key travels the same way: an
Authorization: Bearer <key> header. Only where you write it differs.
Claude Code — one command:
claude mcp add --transport http listcheckup https://listcheckup.com/mcp \
--header "Authorization: Bearer lck_live_..." Claude Desktop, Cursor and most other clients — in the config file:
{
"mcpServers": {
"listcheckup": {
"url": "https://listcheckup.com/mcp",
"headers": { "Authorization": "Bearer lck_live_..." }
}
}
} Checking it works
Before wiring it into anything, ask the server what it can do:
curl -X POST "https://listcheckup.com/mcp" -H "Authorization: Bearer lck_live_..." -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
Six tools listed means you are connected. "error": "unauthorized" means the
key did not arrive — nearly always the word Bearer and its space
missing, or the key clipped short when it was pasted. A key is about fifty characters and
starts lck_live_.
What it can do
| Tool | What it does |
|---|---|
check_email | One address. Costs a credit unless the answer is catch-all or unknown. |
get_balance | Credits left. Free. |
create_list | Submit a list and get a price. Charges nothing. |
start_list | Begin checking. This is the call that spends credits. |
get_list | Status and progress. Free. |
get_list_summary | The counts once it has finished. Free. |
Pricing and starting are two separate tools deliberately, so an assistant has to show you what a list will cost before anything is spent, and you get to say no. No tool takes an account as an argument — the key in the header decides whose credits are used, so nothing an assistant reads or is told can point it at somebody else’s account.
Treat the key as a password
Anything holding it can spend your credits. It sits in a configuration file in clear text, so keep that file out of a shared repository, and revoke the key if it goes somewhere it should not have — revoking takes effect immediately, for the API and this server alike. Give each machine its own key and you can revoke one without disturbing the rest.
Errors
Every error is JSON with a stable code and a sentence meant for a human:
{ "error": "unauthorized", "message": "Provide your API key as 'Authorization: Bearer <key>'." } | Code | Means |
|---|---|
400 | Something in the request is missing or wrong. The body says which. |
401 | No key, or one that is unknown, revoked or expired. |
402 | Not enough credits. Nothing was charged or started. |
404 | No such list on this account. |
409 | The list is not in a state where that makes sense — already started, say. |
429 | Rate limited. Wait. |
What it will not do
The API checks addresses you already hold. It does not find, generate or supply addresses, and it never sends mail to the addresses it checks — the conversation with each mail server is closed before a message exists. Using it on scraped, purchased or rented lists is prohibited; see acceptable use.
Getting started
Create an account, make a key in the
dashboard, and run the /v1/me command above. ListCheckup gives you free
credits at sign-up, so the first checks cost nothing.
Related: what credits cost, and how to choose a verification service.