API reference
Base URL https://api.sortance.com. Every request carries Authorization: Bearer $SORTANCE_KEY. Every non-2xx body is the same envelope. One endpoint matters; the rest support it.
POST /v1/verify
Verify one address.
Request
{
"email": "mia@acme.com",
"context": {
"purpose": "signup",
"sender_domain": "acme.com",
"max_cost_usd": 0.002,
"max_latency_ms": 800
}
}Only email is required. context.purpose is one of outbound, signup or list_hygiene. The two ceilings are hard: Sortance degrades to fewer providers and a wider confidence interval rather than exceeding a budget you stated.
Response fields
| Field | Type | Meaning |
|---|---|---|
email | string | The address you sent, echoed back. Sortance never persists a raw address; this is the one you handed it moments ago. |
verdict | enum | Compatibility shim: valid, likely_valid, risky, likely_invalid, invalid, unknown. Derived from bounce_probability last and never read back internally. Do not branch on it if you can branch on the probability. |
bounce_probability | number | The product. Calibrated against observed bounces, so 0.31 means roughly 31 in 100 — not a confidence score, an actual rate. |
confidence_interval | [number, number] | How much to trust that number. A wide interval on a catch-all domain is the honest answer, not a failure. |
calibration | enum | prior or fitted. prior means the probability came from the seeded benchmark table rather than your observed outcomes. |
recommendation | enum | send, hold, challenge or drop. A policy suggestion, not a verdict. |
actor_class | enum | human_mailbox, agent_mailbox, relay_alias, role_account, shared or unknown. Who is behind the address — a different question from whether it delivers. |
mailbox_type | enum | corporate, consumer, role or unknown. |
esp | enum | microsoft365, google_workspace, consumer_esp, self_hosted or unknown. The single strongest predictor of whether SMTP will answer honestly. |
email_type | enum | business, free, role, disposable, relay or unknown. free is the consumer-provider class (Gmail, Outlook, Yahoo, Apple, …), also called social email. Recomputed on every request, cache hits included, because your own domain and address rules can change it. |
signals | object | Free local signals: syntax, mx, disposable, role_account, catch_all, relay_alias. |
providers | object | Per-provider receipts — verdict, ms and cost for each one that answered. Empty when the local gate or the cache settled it, which is most traffic. |
disagreement | number | How far apart the pool landed on the same address. 0 means one cheap check would have done. High means genuinely ambiguous — a fact no single verifier can report about itself. |
cache | object | { hit, ttl_s }. Freshness signal for the answer you just got. |
cost_usd | number | What this decision cost Sortance in provider spend. 0 on a gate or cache resolution. |
latency_ms | number | Wall-clock for the decision. |
trace_id | string | vr_-prefixed. Quote it when reporting an outcome, replaying a decision, or asking about one. |
reason | enum | null | The first check reason present: syntax_invalid, address_blocklisted, domain_blocklisted, no_mx, mx_unresolved, disposable_domain, disposable_suspected, domain_allowlisted or address_allowlisted. null when none applied, including when only the paid providers drove the answer: that reason lives in providers. See /docs/check#reasons. |
reasons | enum[] | Every check reason present, in the order above. Present on every response, cache hits and /v1/decide's embedded verification included. |
degraded | boolean? | Present and true only when a provider call failed and no other provider added evidence, so the answer came from the prior alone. The Sortance check runs in-process and does not fail like a vendor; its unknown does not count as evidence. The request still returns 200 — see the fail-open contract. |
identity_hash | string? | Stable dedupe key for relay aliases, salted per account so it can never become a cross-customer identifier. |
Every response also carries reason and reasons, from one closed vocabulary shared with /v1/check. The full table, with which codes end a request, is in the check reference.
POST /v1/check
The free checks alone: syntax, MX, Sortance’s disposable lists and your own domain and address rules, with the same reasons and the same email_type. No provider is called, so there is no verdict and no bounce_probability: this endpoint does not claim to know whether a mailbox exists. Same key, body limit and rate limit as /v1/verify; not counted against your monthly quota, and no trace is stored. The full explanation is in email check.
Request
{ "email": "test@mailinator.com" }Response fields
| Field | Type | Meaning |
|---|---|---|
email | string | The address you sent, echoed back. Never stored. |
domain | string | The domain after normalisation. |
passes | boolean | Syntax valid, MX present, not on the block tier and not on your domain or address blocklist. The old email-check worker called this valid; it is not called that here because nobody asked the mailbox. |
disposable | boolean | The domain or a parent is on Sortance's block tier and your allowlist does not cover it. |
suspected | boolean | The domain or a parent is on the signal tier: worth a look, never a block. |
reason | enum | null | The first check reason present. Same vocabulary as /v1/verify. |
reasons | enum[] | Every check reason present, in vocabulary order. |
checks | object | { syntax: pass | fail, mx: pass | fail | unknown, disposable: pass | fail | suspected }. mx is unknown when the DNS lookup itself failed. |
esp | enum | The same mail-provider family /v1/verify reports. |
email_type | enum | The same classification /v1/verify reports: business, free (social email), role, disposable, relay or unknown. |
mx_hosts | string[] | The domain's MX hosts, highest priority first. |
mx_provider | string | null | The retired email-check worker's provider label, matched on the MX hosts: gmail, outlook, zoho, yahoo, icloud, aol, protonmail, yandex, mail.com, gmx or fastmail; unknown when the hosts match none of them; null when there are no MX hosts. Google Workspace domains read gmail, as they always did. |
latency_ms | number | Wall-clock for the request. |
trace_id | string | ck_-prefixed. Nothing is stored under it; it matches a log line if you ask support. |
/v1/domains
Your account’s own blocklist and allowlist of domains: GET, POST and DELETE on /v1/domains/blocklist and /v1/domains/allowlist, and POST /v1/domains/check. A block rule ends every check for the domain with domain_blocklisted; an allow rule sets Sortance’s disposable lists aside for it. Full reference in Blocklists.
/v1/addresses
Your account’s own blocklist and allowlist of single addresses. A blocked address ends every check with address_blocklisted (verdict invalid, no provider called, no cost); an allowed one has Sortance’s disposable lists and your domain blocks set aside, with address_allowlisted. An address rule beats any domain rule. Sortance keeps a keyed hash and a masked form, never the address, so a listing cannot give it back. Up to 10,000 address rules per account, blocklist and allowlist together. Any key can list and check; changing a list needs a member key. Not metered. Full reference in address rules.
| Method | Path | Body or query | Returns |
|---|---|---|---|
GET | /v1/addresses/blocklist, /v1/addresses/allowlist | ?limit&cursor | { rule, entries: [{ id, display, note, created_at, created_by }], count, cursor }. Masked (j•••@acme.com), newest first; never the address. |
POST | /v1/addresses/blocklist, /v1/addresses/allowlist | { emails: string[], note? } or { trace_ids: string[], note? }, up to 1,000 | { rule, added, moved, unchanged, total }. Adding to one list takes the address off the other. A bad entry is reported by index and reason only. |
DELETE | /v1/addresses/blocklist, /v1/addresses/allowlist | { emails }, { ids } (from the listing) or { trace_ids } | Removes from the list named in the path. |
POST | /v1/addresses/check | { email } | { org_rule: block | allow | null, note, display, domain_rule, effective }. |
POST /v1/decide
Verify the address, enrich it only if a rule needs it, apply your policy and return one decision (allow, allow_with_flow, review, challenge or deny) with the rule that made it. The verification is embedded unchanged as verification, email_type included, and rules read it as verification.email_type. The Email types switches on the dashboard’s Policy screen are rules in your default policy, so a “block free email” setting denies here and never changes a /v1/verify verdict. Metered like a verification. Full reference in decide and policies.
/v1/bulk
Bulk verification: upload a CSV, get it back with a verdict on every row. The four calls most integrations need are create, upload, start and download; the list guide walks through them, and the dashboard’s Bulk verify screen does the same with a drop zone. Each unique address counts as one verification against your monthly quota, cache hits included; duplicate rows share one answer. Bulk rows do not count against the per-minute rate limit. Viewer keys can list jobs and download results; everything else needs a member key.
| Method | Path | Body or query | Returns |
|---|---|---|---|
POST | /v1/bulk/jobs | { name?, purpose?, policy_slug?, has_header?, email_column? } | 201 { job } in state uploading. |
PUT | /v1/bulk/jobs/:id/upload | The raw CSV as the body, Content-Type: text/csv, up to 50 MB and 100,000 rows. Streamed. | { job } ready (the file has been read: rows, unique, invalid, the email column) or failed. |
POST | /v1/bulk/jobs/:id/paste | { emails: string[] }, up to 1,000 | The same as an upload, as a one-column file. |
PATCH | /v1/bulk/jobs/:id | { email_column?, has_header?, name?, policy_slug? } while ready | { job }, re-read from the stored file. |
GET | /v1/bulk/jobs/:id/estimate | — | { rows_unique, verifications, cached, to_verify, quota_remaining, fits, max_cost_usd, … } |
POST | /v1/bulk/jobs/:id/start | { allow_partial?, notify? } | 202 { job } running, or queued behind two running jobs. 402 quota_exceeded, with the estimate, when it does not fit and allow_partial is not set. notify: true emails the user whose key created the job, once, when it is done (counts and a link, never a row of the file); nothing is sent for a key with no user. |
POST | /v1/bulk/jobs/:id/cancel | — | { job }. Rows already verified stay downloadable. |
POST | /v1/bulk/jobs/:id/resume | — | 202 { job }: continues a job paused on quota (quota_exceeded); a raised quota also resumes it by itself. Paused jobs also resume on the 1st, unless their 7 days ran out first (state expired, error expired_unfinished). |
GET | /v1/bulk/jobs | ?limit&cursor&state | { jobs, cursor }, newest first. |
GET | /v1/bulk/jobs/:id | — | { job } with progress: { chunks_done, chunks_total, rows_done }, and live verdict counts while it runs. |
GET | /v1/bulk/jobs/:id/results.csv | ?segment=all | deliverable | undeliverable | review | business (allow | deny | review with a policy) | The file. 409 before the job is done, 410 after the files are deleted. |
DELETE | /v1/bulk/jobs/:id | — | Deletes the files now. The job record and its traces stay. |
POST | /v1/bulk/jobs/:id/block | { segment: undeliverable | review (deny | review with a policy), note? } | Adds that segment's addresses to your address blocklist, up to the 10,000 address rules an account holds (blocklist and allowlist together). Needs a member key. |
A results file keeps your columns first, in order and unchanged, and appends these (the sortance_ prefix keeps them from colliding with yours):
| Column | Meaning |
|---|---|
sortance_status | ok, skipped (empty cell, no @, or over 320 characters; no check, no cost) or error (the check failed after retries). |
sortance_verdict, sortance_bounce_probability, sortance_recommendation, sortance_reason | As /v1/verify returns them. |
sortance_email_type, sortance_esp, sortance_mailbox_type, sortance_actor_class | As named. |
sortance_disposable, sortance_role_account, sortance_catch_all, sortance_relay_alias | The signals, true or false. |
sortance_decision, sortance_decision_rule | Only when the job ran a policy: the decision and the rule that matched. |
sortance_trace_id | For /v1/outcome and support. |
Segments: deliverable is recommendation send, undeliverable is drop or a terminal reason, review is hold or challenge, and business is every row whose email_type is business. With a policy_slug the segments follow the decision instead: allow, deny, review. Running a policy can spend enrichment budget when its rules need a profile; the estimate’s max_cost_usd includes it. The uploaded file and the results are kept for 7 days, then deleted (trust).
POST /v1/screen
Screen a signup: is the address a typo, a placeholder, paired with a name that is not a real one, or at a domain that looks like a lookalike, a placeholder or a throwaway. The step after the domain’s MX resolves. Same auth, body limit and rate limit as /v1/verify; metered on its own allowance (10,000 screenings a month by default), not against verifications. The full explanation is in screening.
Request
{
"email": "jhon.smtih@gmail.com",
"name": "John Smith"
}email is required, up to 320 characters, with the same rules as /v1/verify. name is optional, up to 200 characters after trimming; an empty name is treated as absent.
Response fields
| Field | Type | Meaning |
|---|---|---|
email | string | The address you sent, echoed back. Never stored. |
domain | object | { syntax, mx, disposable } from the gate, so you can see why screening stopped early when it did. |
screening.typo_suggestion | string | null | A corrected full address to offer back on the form, or null. |
screening.typo_source | enum | null | name_match (the part before the @ is a near-miss of the name) or domain (the domain is a near-miss of a common one). |
screening.name_match | enum | match, typo, partial, mismatch, or not_checked when no name was sent. |
screening.fake_name | enum | likely, unlikely, uncertain, or not_checked when the model was not asked or did not answer. |
screening.domain_check | enum | ok, typo_like, lookalike, placeholder, throwaway_like, uncertain, or not_checked. Judged from the domain name alone, in a separate model request that carries only the domain; typo_like, lookalike, placeholder and throwaway_like add domain_suspicious (review). Never sets typo_suggestion. See /docs/screen#domain-check. |
screening.placeholder | boolean | The address looks like something typed to get past a form. |
screening.recommendation | enum | allow or review. Never deny. |
screening.reasons | string[] | Closed vocabulary; see /docs/screen#reasons. |
screening.degraded | boolean | True when a model request was made and failed; that request's label (fake_name or domain_check) is not_checked. The code checks still answered. |
cost_usd | number | The model requests alone, summed when both the name and the domain check ran. 0 when the model was not called; a cached domain answer adds 0. |
latency_ms | number | Wall-clock for the request. |
trace_id | string | sc_-prefixed. |
POST /v1/outcome
Report what happened to a send. This is the only way your probabilities stop being everyone else’s averages.
{
"trace_id": "vr_...",
"outcome": "hard_bounce",
"smtp_code": "550 5.1.1 User unknown"
}outcome—delivered,hard_bounce,soft_bounce,complaintorunknown.smtp_code— optional. Paste the whole bounce message if that is easier; Sortance reduces it to the basic and enhanced codes and discards the rest, including any recipient address in the text.- The account is stamped from your key, never read from the body. One customer cannot write outcomes into another’s calibration.
Sortance does not currently POST to your server. There is no inbound event stream and no signing secret to configure — you call /v1/outcome when your ESP tells you a message bounced.
GET /v1/traces and /v1/traces/:trace_id
The list gives you recent decisions across your organisation, from every key it holds. The single-trace form replays one: which providers were asked, what each returned, how long each took, what it cost, and the features the router decided on. Useful when a send went wrong and you want to know what Sortance actually knew at the time.
?bulk_job_id=bj_…narrows the list to the traces one bulk job wrote. Every trace carriesbulk_job_id, null outside bulk jobs, andemail_type, null on traces written before the field existed.?key_id=key_…narrows the list to the rows one key of your organisation wrote. It works the same onGET /v1/screenings, the history of/v1/screencalls. A key id that is not in your organisation returns an empty page.- A
manualorservicekey belowadminreads only the rows it wrote, whatever filter it asks for, and another key’s trace is a 404 to it. Dashboard keys and admin keys read the whole organisation. A leaked CI key therefore cannot expose every other key’s traffic. ?scope=owneris accepted and ignored for one release. It only selects the labelled row shape, where each row also carriesaccount_id,api_key_id,key_nameandkey_origin, andpolicy_decision:{ decision, rule_id, policy: { slug, version } }when/v1/decidewrote the trace, elsenull. Every read is organisation-wide already, so drop the parameter.
GET /v1/me
Who the presented key is and which organisation it acts for. Nothing in it is secret or personal.
{
"account_id": "acct_…",
"org": { "id": "org_…", "name": "Acme" },
"key": { "id": "key_…", "role": "member", "origin": "manual" },
"operator": false,
"owner_user_id": "u_…"
}account_id is your organisation’s account id, the same on every key the organisation holds. key.role is viewer, member or admin (see Teams); key.origin is auto (a dashboard key), manual (one you created) or service. owner_user_id is deprecated: it is the user who created the key and will be removed after one release. A personal organisation’s org.name reads “Personal org” until you rename it.
GET /v1/usage
Calls made, provider spend and quota remaining for the current period. Metered on the key’s organisation across every key it holds, including revoked ones. It also reports screenings used this month and the screening quota, which is a separate meter, as a screening object present on every response:
"screening": { "used": 412, "quota": 10000, "unmetered": false, "period": "utc_month" }used is the screenings this calendar month (UTC), or null when the count could not be read, never a guessed zero. quota is the allowance, and unmetered is true when the quota is 0 or less. period is always utc_month.
A bulk object reports { jobs_this_month, rows_this_month }: bulk jobs started this month (UTC) and the verifications they metered. Those verifications are already inside the main count and quota; bulk rows and single calls share one allowance.
GET /health
Unauthenticated liveness check. Returns 200 when the worker is up.
Errors
Every non-2xx body is this shape, without exception:
{ "error": { "code": "quota_exceeded", "message": "...", "trace_id": "vr_..." } }| Status | Code | When |
|---|---|---|
400 | invalid_request | Malformed body, bad address, or an outcome value outside the allowed set. |
401 | unauthorized | Missing, malformed or revoked key, or a key of a deleted organisation. |
402 | quota_exceeded | The monthly allowance of the key's organisation is spent. Not a rate limit — a quota. |
402 | screening_quota_exceeded | POST /v1/screen and the MCP screen_contact tool: the separate monthly screening allowance of the key's organisation is spent. Verification is unaffected. |
403 | forbidden | The key's role (viewer, member or admin) is below what the route needs. Nothing is metered. See /docs/teams. |
409 | invalid_request | Bulk: the job is not in a state that allows this (results before it is done, a change after it started). |
410 | invalid_request | Bulk: the job's files were deleted after 7 days. |
413 | invalid_request | Bulk: the upload is larger than 50 MB. |
429 | rate_limited | Too many requests this minute. Back off and retry. |
500 | internal_error | A bug. Carries a trace_id; nothing about the underlying exception reaches you. |
503 | all_providers_failed | Reserved for the case where not even a prior can be produced. A provider outage alone does not cause this — see below. |
If every provider Sortance called fails, /v1/verify still returns 200 — with verdict: "unknown", calibration: "prior" and degraded: true. A verification API that 500s breaks your signup flow, which is a worse failure than a wide interval. Check degraded and decide for yourself.