Pending and polling
Most checks answer in the same response. When one cannot, the API does not make you wait on an open connection: it accepts the check and answers status: "pending" with a check_id. The body has exactly the same shape as a completed check, so the code that reads the response does not change; you only branch on data.status.
Why a check is pending
A check is handed to a queue, and answers pending, when it genuinely needs more time than a request should hold:
- a third-party data source is busy and the lookups have to wait their turn,
- a cold traversal of the chain takes longer than the request budget, or
- the check reached the end of the time it was given to answer inline.
While a check waits for required evidence there is no verdict, no charge and no completion webhook. Everything is settled once, when the check finishes.
The waiting fields
| Field | While pending | Once terminal |
|---|---|---|
status | pending | completed or failed |
pending_reason | Why the check is waiting for evidence, for example provider_pending (lookups at a third-party data source are in flight) or provider_capacity (waiting for lookup capacity). Informational: do not branch on values you do not know. null when the check is simply queued. | null |
deadline_at | The configured time by which the check will have finished. It is a limit, not an estimate: most checks finish far sooner. For native and batch checks it defaults to 24 hours after acceptance. | Kept as recorded. null for a check that never had to wait. |
next_attempt_at | The earliest time the next retry or readiness inspection is scheduled. Not a promise of a result at that time. | null |
When a data source is known to be down, a check does not wait for its deadline: it finishes early with a partial verdict that says why (see partial_reasons and degraded_sources in Address checks) and answers once.
Poll a check
GET /v1/checks/{check_id} with the checks:read scope.
- A pending id never answers
404. It resolves immediately withstatus: "pending". A404means the id is not yours, belongs to the other environment, or never existed. - Poll no earlier than
next_attempt_at. Nothing changes between scheduled inspections. When it isnull, wait a few seconds. Polling counts against the rate limit of theGETroute, 30 requests per minute from one source IP address; see Rate limits and quotas. - Stop at
completedorfailed. Both are terminal, and no field changes afterwards. Two reads of a terminal check return identical bodies, so it is safe to read it again whenever you need the verdict. - Stop at
deadline_at. The check is finalised by then. If the last answer is stillpendinga short time after it, keep thecheck_idand read it again later instead of looping.
Prefer a webhook when you can: you register an endpoint once and receive one signed check.completed delivery per finished check, so there is nothing to poll. Keep polling as the fallback for a delivery that never arrives.
- curl
- Node.js
- Python
curl -s https://api.leeri.ai/v1/checks/$CHECK_ID \
-H "Authorization: Bearer $LEERI_API_KEY"
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitForCheck(checkId) {
for (;;) {
const response = await fetch(`https://api.leeri.ai/v1/checks/${checkId}`, {
headers: { Authorization: `Bearer ${process.env.LEERI_API_KEY}` },
});
if (response.status === 429) {
await sleep(Number(response.headers.get('retry-after') ?? 5) * 1000);
continue;
}
if (!response.ok) throw new Error(`poll failed with ${response.status}`);
const { data } = await response.json();
if (data.status !== 'pending') return data;
if (data.deadline_at && Date.now() > Date.parse(data.deadline_at) + 60_000) {
throw new Error(`check ${checkId} is still pending after its deadline`);
}
const next = data.next_attempt_at ? Date.parse(data.next_attempt_at) - Date.now() : 5000;
await sleep(Math.min(Math.max(next, 2000), 60_000));
}
}
const check = await waitForCheck(checkId);
console.log(check.status, check.band, check.score);
import os
import time
from datetime import datetime, timezone
import requests
def parse(value):
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def wait_for_check(check_id):
while True:
response = requests.get(
f"https://api.leeri.ai/v1/checks/{check_id}",
headers={"Authorization": f"Bearer {os.environ['LEERI_API_KEY']}"},
timeout=30,
)
if response.status_code == 429:
time.sleep(int(response.headers.get("Retry-After", "5")))
continue
response.raise_for_status()
data = response.json()["data"]
if data["status"] != "pending":
return data
now = datetime.now(timezone.utc)
if data["deadline_at"] and (now - parse(data["deadline_at"])).total_seconds() > 60:
raise RuntimeError(f"check {check_id} is still pending after its deadline")
wait = (parse(data["next_attempt_at"]) - now).total_seconds() if data["next_attempt_at"] else 5
time.sleep(min(max(wait, 2), 60))
check = wait_for_check(check_id)
print(check["status"], check["band"], check["score"])
When a check fails
A check that ends without a verdict has status: "failed", with score, band and the other verdict fields null. A failed check is never charged. If you submit a check and it fails during the request itself, the answer is 422 CHECK_FAILED; status: "failed" is what you read when you fetch a check later. To try again, submit the check as a new request with a new Idempotency-Key; see Idempotency.
Testing the pending path
A sandbox key submitting the test address 1PENDNGCHECKADDRESSFRSANDBXTEST99 as a BTC address check always answers pending, so you can build and test your polling and webhook handling without waiting for a real slow lookup. See Sandbox and live.