Using the API
Errors
Every refusal answers the same small body with a stable code. This is the whole list.
A refusal answers an HTTP status and a JSON body with two fields:
{
"error": "quote_exceeded",
"message": "The price is above the maxCredits the call allowed. Nothing was charged."
}error is a stable code: branch on it. message is a plain sentence for a person and may be reworded. Some codes carry extra fields, listed beside each code in the table below, such as retryAfterSeconds on rate_limited and issues on invalid_body. A field is present when that refusal has it to say. A field you do not recognise can be ignored.
A refusal the HTTP API makes itself, before the call reaches Leadity's servers, also carries docs: a link to its code's row on this page.
Issues
A request that does not match its operation's schema is refused with invalid_body, or with its field's own code such as invalid_country, and issues lists what is wrong. Each entry has a path and a message. The path is the field as a dotted path, led by the part of the request it travels in: path, query or body.
{
"error": "invalid_body",
"message": "The request does not match the operation's schema.",
"issues": [{ "path": "body.items.1", "message": "an item holds one value: no line break, comma, semicolon or tab" }]
}body.items.1 is the second entry of the body's items, query.state is the state query parameter, and path.id is the id in the URL. The path is spelled the same way whichever check refused the call. On the MCP server a tool takes one input object, and the path still names the part of the operation the argument belongs to: body.items is the tool's items argument.
On the MCP server the same body comes back as the tool call's error result, with the HTTP status beside it as httpStatus. A field named status is always the code's own, such as the final status of a run on not_running.
Every code
| Code | Status | Meaning | Extra fields |
|---|---|---|---|
unauthorized | 401 | The API key is missing, malformed, revoked or expired. | - |
wrong_key_kind | 401 | This key opens the other surface: an API key works on the HTTP API, an MCP key on the MCP server. | - |
insufficient_scope | 403 | The key does not carry the scope this operation needs. | requiredScope: The scope the operation needs: read, write or spend. |
account_suspended | 403 | The account is suspended. | - |
email_not_verified | 403 | The account's email address is not verified yet. | - |
paywall_required | 402 | This operation needs a purchase on the account first. | - |
package_locked | 402 | Until its first purchase the account may order only the starter packages. The body names what was ordered and what is allowed. | ordered: The package the order's filters read as, custom for any other set.allowed: The packages the account may order now. |
tenant_not_found | 404 | The account no longer exists. | - |
invalid_body | 400 | The request does not match the operation's schema. | issues: The problems found, each with path (the field as a dotted path, led by the part of the request it travels in: path, query or body, such as body.items.1) and message (what is wrong there). |
invalid_json | 400 | The request body is not valid JSON. | - |
payload_too_large | 413 | The request body is too large. | - |
not_found | 404 | No such resource on this account. | id: On export_scrapes: the first id that is not one of the account's scrapes. |
idempotency_key_required | 400 | A spending operation must carry an Idempotency-Key header (16 to 64 characters of A-Z, a-z, 0-9, _ and -). | - |
invalid_idempotency_key | 400 | The Idempotency-Key must be 16 to 64 characters of A-Z, a-z, 0-9, _ and -. | - |
idempotency_conflict | 409 | This Idempotency-Key was already used for a different request. | - |
max_credits_required | 400 | A spending operation must carry maxCredits, the most it may take. | - |
quote_exceeded | 409 | The price is above the maxCredits the call allowed. Nothing was charged. | credits: What the call would have taken: the whole hold on an order or a list, the price or the hold on an upgrade.maxCredits: The ceiling the call sent.reviewsReservedCredits: On create_scrape: the part of credits held for review scraping.count: On purchase_phone_reveal, when the count was the cause: how many numbers the reveal would name.maxNumbers: On purchase_phone_reveal, when the count was the cause: the maxNumbers the call sent. |
price_changed | 409 | The price moved above the ceiling the call allowed. Nothing was charged; quote again. | - |
insufficient_credits | 402 | The balance does not cover the price. Nothing was charged. | required: On create_validation: the credits the list holds. |
invalid_country | 400 | The country is not one Leadity holds territory for. | issues: The problems found, each with path (the field as a dotted path, led by the part of the request it travels in: path, query or body, such as body.items.1) and message (what is wrong there). |
invalid_state | 400 | The region is missing. | issues: The problems found, each with path (the field as a dotted path, led by the part of the request it travels in: path, query or body, such as body.items.1) and message (what is wrong there). |
invalid_query | 400 | The search text is missing. | issues: The problems found, each with path (the field as a dotted path, led by the part of the request it travels in: path, query or body, such as body.items.1) and message (what is wrong there). |
invalid_niche | 400 | No niche id was given. | issues: The problems found, each with path (the field as a dotted path, led by the part of the request it travels in: path, query or body, such as body.items.1) and message (what is wrong there). |
too_many_niches | 400 | Too many niches in one call. The body carries max. | max: The most niches one call may name. |
unknown_niche | 400 | A niche id is not in the catalog. The body names it. | niches: The ids the catalog does not hold.findOwner: On an order or an estimate: whether it asked for the owner lookup. |
niche_unavailable | 400 | A niche cannot be ordered with these filters, usually the owner lookup. The body names it. | niches: The ids that cannot be ordered so.findOwner: On an order or an estimate: whether it asked for the owner lookup. |
no_areas_selected | 400 | The selection names no area. | - |
selection_too_large | 400 | The selection names more areas than one order may. The body carries max. | max: The most areas one order may name. |
below_minimum | 400 | The quantity is under the smallest one this operation takes: targetLeads (the body carries min), or an upgrade's target (list_scrape_offers names it). | min: On create_scrape: the smallest targetLeads the account may order. |
below_minimum_credits | 400 | The leads cost less than the smallest order. The body carries min, credits and minLeads, the smallest lead count that clears it. | min: The smallest order, in credits.credits: What the leads ordered come to, in credits.minLeads: The smallest targetLeads that clears both floors at this order's rate. |
below_minimum_work | 409 | Too little of the selection is still unswept to run a scrape. The body carries min and workItems. | min: The fewest searches a scrape runs.workItems: The searches this order would run, when it was counted. |
too_much_work | 400 | The order would run more searches than one order may. The body carries max. | max: The most searches one order may run.workItems: The searches this order would run, when it was counted. |
all_covered | 409 | Every area selected is already swept for these niches. Choose other areas. | - |
all_in_progress | 409 | Every area selected is held by another live scrape of this account. Try again once it ends. | - |
too_many_active | 409 | The account has as many live runs as it may. The body carries max. | max: The most live runs of this kind the account may hold. |
create_busy | 409 | Another order of this account is being placed right now. Retry in a moment. | - |
seed_failed | 503 | The scrape could not be planned. Nothing was charged; retry with the same Idempotency-Key. | - |
not_active | 409 | The scrape is no longer live, so there is nothing to stop. | - |
not_running | 409 | The validation run is already final. The body carries its status. | status: The run's final status. |
no_offer | 409 | The scrape has nothing of that kind to sell, or it is still running. Read list_scrape_offers. | - |
already_purchased | 409 | This upgrade was already bought on this scrape. | - |
already_running | 409 | An owner-name run is already going on this scrape. | - |
not_eligible | 409 | This scrape cannot take an owner-name run: it ordered the owner lookup, or it is still running. | - |
no_pool | 409 | No delivered lead is left for an owner-name run to try. | - |
empty_list | 400 | No item in the list is a phone number or an address. The body carries submitted. | submitted: The lines or items submitted. |
list_too_large | 413 | The list is larger than one run takes: over 8 MB of text. The body carries maxItems. | maxItems: The most items one run checks. |
numbering_unavailable | 409 | The list holds numbers whose country register is not loaded yet. The body names the plan. | region: The region the list was read under.plan: The register not loaded yet: gb or au. |
unknown_pack | 400 | No plan has that sku. list_plans names them. | - |
subscription_exists | 409 | A monthly plan still renews. A person cancels it on Whop before another size can start. | - |
same_plan_live | 409 | A cancelled plan of this size is still running out. The body carries endsAt, when it ends. | endsAt: When the last of that plan's credits expire, or null when that is not still ahead. |
invalid_amount | 400 | The credits are not an amount a top-up takes. The body carries min, max and step. | min: On create_top_up_checkout: the smallest top-up, in credits.max: On create_top_up_checkout: the largest top-up one purchase may be, in credits.step: Every top-up is a whole multiple of this many credits. |
no_subscription | 409 | A top-up adds credits to a monthly plan, and the account has no live plan. create_checkout sells a one-time pack instead. | - |
unpriceable_plan | 409 | The account's plan carries no price a top-up can be rated at. Contact Leadity support. | - |
checkout_failed | 502 | Whop did not open the checkout. Try again. | - |
rate_limited | 429 | Too many requests. Wait for the Retry-After seconds and try again. | retryAfterSeconds: The seconds to wait, the same number as the Retry-After header. |
try_again | 503 | Leadity could not answer just now. Retry in a moment, with the same Idempotency-Key on a spending call. | retryAfterSeconds: When a request with this Idempotency-Key is still being processed: the seconds to wait before retrying with the same key, as the Retry-After header says. |
internal_error | 500 | Something failed on our side. Nothing was charged twice. | body: On the MCP server, when Leadity answered in a shape the tool does not publish: that answer as it came. |
An operation's reference page lists the codes every call of its kind can meet: the key and account checks, a malformed request, the limits and, on a spend, the money rules. The codes an order or an upgrade adds of its own, such as an order below the account's minimum or an area already swept, are in the table above, and the operation's description says what to change.
Handling errors
- 4xx except 429: the request is wrong as sent. Read
error, fix the request, and only then try again. - 409
idempotency_conflict: the key already named a different request. Do not retry it: send the new request under a new key. - 429
rate_limited: wait the number of seconds in theRetry-Afterheader, then retry the same request. - 503
try_againand 500internal_error: retry with backoff. Atry_againfrom the HTTP API always carriesRetry-After: wait that many seconds first. On a spending call, retry with the sameIdempotency-Key: if the first attempt went through, the retry replays its answer instead of charging again. A read has no key to keep, so it simply retries. See Idempotency.
const response = await fetch(url, init);
if (!response.ok) {
const { error, message } = await response.json();
if (error === "rate_limited") {
const wait = Number(response.headers.get("retry-after") ?? 30);
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
} else {
throw new Error(`${error}: ${message}`);
}
}