# Exclia API > Screen people and organizations against US healthcare exclusion lists (OIG LEIE, SAM.gov, state Medicaid lists). Results are `clear` or `potential_match` with evidence and provenance — never an automated "excluded" verdict. Base URL: https://api.exclia.com/v1. Auth: `Authorization: Bearer `. The full contract is the OpenAPI document: https://exclia.com/docs/openapi.json # Exclia API The Exclia API screens people and organizations against federal and state healthcare exclusion lists — the OIG LEIE, SAM.gov and the state Medicaid lists on the [coverage page](/coverage) — and keeps watching them after the first check. Every endpoint lives under one base URL: ``` https://api.exclia.com/v1 ``` ## What you can do | Endpoint | What it does | | ---------------------- | ------------------------------------------------------------------------------------------------------------- | | `POST /screen` | Screen one person or organization and get the answer in the response. | | `POST /batch` | Submit many entities at once and poll for the results. | | `PUT /monitor` | Enroll an entity in continuous monitoring. It is re-screened whenever a covered list publishes a new version. | | `DELETE /monitor/{id}` | Stop monitoring an entity. | | `GET /lists` | Every source we screen against, with its current list version and retrieval date. | | `GET /usage` | This billing period's metered usage, priced. | ## How results are worded A result is `clear` or `potential_match`. The API never returns an automated "excluded" verdict about a person: a potential match carries the evidence (which fields matched, and how) and the provenance (which list version, retrieved when), and a person at your organization verifies it. Every match carries the same label at every score. ## Start here 1. [Quickstart](/docs/quickstart) — your first successful call in under five minutes. 2. [Authentication](/docs/authentication) — API keys and permissible use. 3. [Errors](/docs/errors) — every status code and what to do about it. 4. [Webhooks](/docs/webhooks) — signed deliveries when a status changes. ## For coding agents The whole contract is machine-readable. Point your agent at one of these: - [`/docs/openapi.json`](/docs/openapi.json) — the OpenAPI 3.0 document, generated from the same schemas the API validates with. - [`/llms.txt`](/llms.txt) — an index of these docs. - [`/llms-full.txt`](/llms-full.txt) — every guide in one Markdown file. # Quickstart This gets you from no account to a screened name. It takes about five minutes. ## 1. Create an API key [Create an account](/pricing), then open **API keys** in the app. The first key asks the organization's owner to certify the permissible use of the results — see [Authentication](/docs/authentication). The key is shown **once**; store it in your secret manager. ```bash export EXCLIA_API_KEY="paste-your-key-here" ``` ## 2. Screen a person ```bash curl https://api.exclia.com/v1/screen \ -H "Authorization: Bearer $EXCLIA_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: quickstart-1" \ -d '{"type":"person","name":"Jane Q. Example","dateOfBirth":"1980-04-12","state":"NJ"}' ``` The same call in TypeScript, with nothing but `fetch`: ```ts const response = await fetch('https://api.exclia.com/v1/screen', { method: 'POST', headers: { Authorization: `Bearer ${process.env.EXCLIA_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': 'quickstart-1', }, body: JSON.stringify({ type: 'person', name: 'Jane Q. Example', dateOfBirth: '1980-04-12', state: 'NJ', }), }); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); const result = await response.json(); console.log(result.status, result.matches.length); ``` And in Python, with `requests`: ```python import os import requests response = requests.post( "https://api.exclia.com/v1/screen", headers={ "Authorization": f"Bearer {os.environ['EXCLIA_API_KEY']}", "Idempotency-Key": "quickstart-1", }, json={ "type": "person", "name": "Jane Q. Example", "dateOfBirth": "1980-04-12", "state": "NJ", }, ) response.raise_for_status() result = response.json() print(result["status"], len(result["matches"])) ``` ## 3. Read the result ```json { "status": "clear", "matches": [], "sources": [ { "source": "leie", "listVersionId": "lv_…", "retrievedAt": "2026-10-01T06:00:00.000Z" }, { "source": "sam", "listVersionId": "lv_…", "retrievedAt": "2026-10-01T06:00:00.000Z" } ], "checkedAt": "2026-10-06T09:30:00.000Z", "recallCapped": false } ``` - `status` is `clear` or `potential_match`. A potential match is not a verdict: each entry in `matches` lists the fields that matched and the list version it came from, and a person verifies it. - `sources` is the provenance of the check — keep it with your record. It is what shows, later, which version of each list the person was screened against. - `matches` arrive strongest first. `score` orders them; it is not a probability, so do not threshold on it. ## 4. Next - Screening an organization: send `{"type":"organization","legalName":"…"}` instead. - Many at once: [`POST /batch`](/docs/reference/submit-batch-screen). - Keep watching: [`PUT /monitor`](/docs/reference/enroll-monitor), then [webhooks](/docs/webhooks) tell you when a status changes. # Authentication Every request is authenticated with an API key sent as a bearer token: ``` Authorization: Bearer ``` A missing, malformed or revoked key is answered `401` with `code: "unauthorized"`. No request runs before the key is checked. ## Getting a key Keys are created in the app under **API keys** by an owner of the organization. Before the first key is issued, the owner certifies the purpose the results will be used for — exclusion screening is regulated, and the certification is recorded against the organization. Every plan may create keys. A key is shown once, at creation. We store only a hash of it, so a lost key cannot be recovered — rotate it instead. ## Rotating and revoking - **Rotate** issues a new key and retires the old one. Deploy the new key, then rotate. - **Revoke** stops a key immediately. Do it whenever a key may have leaked. ## Keeping keys safe - Call the API from your server. A key in a browser or mobile app is a key anyone can read. - One key per environment (production, staging) so you can revoke one without the other. - Never commit a key. Load it from an environment variable or a secret manager. ## Rate limits and allowances Limits are per key and resolved from your plan on every call — see [Rate limits](/docs/rate-limits). # Errors Every error is JSON with a machine-readable `code` and a human-readable `message`. Branch on `code`, not on `message` — messages are written for people and may change. ```json { "code": "rate_limited", "message": "Rate limit exceeded. Retry after 12 seconds." } ``` ## Status codes | Status | `code` | Meaning | What to do | | ------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | | `400` | `invalid_request` | The body or a parameter is invalid. `fields` names each invalid field and why. | Fix the named fields. Retrying the same request will fail the same way. | | `401` | `unauthorized` | The API key is missing, malformed or revoked. | Check the `Authorization` header; rotate the key if it was revoked. | | `402` | `api_allowance_reached` | The organization has used its monthly API allowance on a plan that does not bill the excess. The body's `block` names the reset date and the plans with more room. | Wait for the reset date or change plan. Nothing was charged. | | `402` | `single_screening_allowance_reached` | The monthly screening allowance is used up. | Same as above. | | `403` | `forbidden` | The organization is read-only: a trial has ended or a payment is overdue. Reads (`/lists`, `/usage`) still work. | Choose a plan or settle the payment in the app. | | `404` | `not_found` | The batch job or monitored record does not exist for this key's organization. | Check the id. A second `DELETE` of the same monitored record is also a `404`. | | `429` | `rate_limited` | The per-key burst limit was exceeded. | Wait the number of seconds in `Retry-After`. Retrying earlier still counts against the window. | | `5xx` | — | Something failed on our side. | Retry with exponential backoff, reusing the same `Idempotency-Key`. | ## Validation errors A `400` carries the field-level detail so the fix is in the response: ```json { "code": "invalid_request", "message": "Invalid request.", "fields": { "dateOfBirth": ["dateOfBirth must be YYYY-MM-DD."] } } ``` ## Retrying safely Send an `Idempotency-Key` header on `POST /screen` (optional) and `POST /batch` (required). A retry with the same key is not metered twice, and a resubmitted batch returns the job you already have. Use a value that identifies the operation in your system — a record id plus a date, for example — not a fresh random value per attempt. # Rate limits Every `/v1` request counts against your organization’s plan ceiling, per API key, in a fixed one-minute window: | Plan | Burst limit | Included calls per month | Past the allowance | | --- | --- | --- | --- | | Trial | 30 requests per minute | 1,000 | refused until the 1st of the next month | | Small | 30 requests per minute | 500 | refused until the 1st of the next month | | Medium | 60 requests per minute | 5,000 | refused until the 1st of the next month | | Large | 120 requests per minute | 25,000 | billed at the published rates | | Enterprise | 600 requests per minute | custom | billed at the published rates | Both numbers are resolved from your plan on every call, so an upgrade takes effect immediately — there is nothing to redeploy or re-issue. Every plan may call `/v1`. The two columns bound different things: the burst limit is how fast, the monthly allowance is how much. Past the monthly allowance, a plan that bills the excess simply continues; a plan that does not refuses with `402` and `code: "api_allowance_reached"`, naming the date the count returns to zero and the plans that would give more room before then. Nothing is charged for a refused call, and no usage is recorded for it. Over the ceiling, the response is `429` with `code: "rate_limited"` and a `Retry-After` header giving the seconds until the window resets. Refused requests still count toward the window, so retrying before `Retry-After` does not help. The limit is enforced against a shared counter, so it is the same ceiling regardless of how many servers are answering — and if that counter is briefly unreachable we refuse requests rather than serve them uncounted. The free public lookup (`POST /public/lookup`) is limited separately, and has no key: 5 requests per minute per IP address, and 120 requests per minute across all callers. # Webhooks When you configure a webhook endpoint, Exclia pushes signed deliveries for three events: `match_status_change` (a monitored entity’s screening status changed), `batch_completed`, and `list_update`. Screening payloads carry only `clear` or `potential_match` with per-field evidence and provenance (`list_version` ids and retrieval dates) — never an automated “excluded” verdict. Each delivery is a JSON body with headers `Exclia-Webhook-Id` (the delivery id), `Exclia-Event-Type`, and `Exclia-Signature: t=,v1=`. ### Verifying a delivery 1. Read `t` and `v1` from the `Exclia-Signature` header. 2. Reject the delivery if `t` is more than 5 minutes from your current time (replay protection). 3. Compute `HMAC-SHA256(signingSecret, "." + rawRequestBody)` as lowercase hex — use the EXACT received bytes, before any JSON re-serialization. 4. Constant-time compare your hex digest to `v1`. If they differ, the payload was tampered with or signed with a different secret — discard it. Only on a match do you trust the event. 5. Respond `2xx` to acknowledge. A non-2xx or timeout is retried with exponential backoff; every attempt is recorded in your delivery log, and you can replay any delivery from it. Your signing secret is shown once when you register or rotate the endpoint. Rotate it if it leaks; the previous secret stops signing immediately. # Versioning The API is versioned by its path. Everything documented here is `/v1`. ## What we change without a new version These are **not** breaking, and can ship to `/v1` at any time: - a new endpoint; - a new optional request field; - a new field in a response; - a new value in an enum you read (write your client to tolerate unknown values); - a new event type on webhooks. Write your client to ignore fields it does not know. ## What counts as breaking A change is breaking when a request that worked before can fail after it — a removed or renamed field, a field that becomes required, a narrower accepted format. Every breaking change is listed in the [changelog](/docs/changelog) with its date and exactly what to change. A breaking change is announced, never discovered. ## The OpenAPI document [`/docs/openapi.json`](/docs/openapi.json) is generated from the same schemas the API validates requests and serializes responses with, so it cannot drift from what the API does. It is safe to generate a client from it. # Changelog Changes to the `/v1` contract, newest first. **Breaking** marks a change that can turn a request that used to succeed into a failure. ### 2026-08-30 `POST /v1/screen` returns its `matches` ordered strongest first, and every match now carries a `score` between 0 and 1. Responses also carry `recallCapped`. Nothing is required — the fields are additive and the order is the array you already receive. Two things are worth knowing. `score` is a DETERMINISTIC ORDERING FIGURE, not a probability and not a confidence: 1 means the name matched exactly, and lower values rank a close or partial name below it. It is computed from the name comparison and the corroborating fields, never from a search relevance score, so the same subject and the same list versions always produce the same value and the same order. Do not threshold on it — every match, at every score, carries the same "Potential match — verification required" label and still needs a person to verify it. And `recallCapped: true` means the check reached its candidate limit: the matches are the strongest of what was found, and there may be more. Narrow the subject (add a date of birth, an NPI or a state) rather than paging — there is no paging on this endpoint. ### 2026-08-23 · Breaking A person is identified by one `name` on `POST /v1/screen` and `PUT /v1/monitor`. The `firstName`, `middleName`, `lastName` and `suffix` fields are gone. Send the whole name — including any generational suffix — in `name`. A body still carrying the old fields is refused with a 400 naming `name`, rather than an anonymous unrecognized-field error, so the failure says what to change. Organizations are unaffected: they keep `legalName`. # API reference - Screen one person or organization — https://exclia.com/docs/reference/screen-entity - Submit a batch screen job — https://exclia.com/docs/reference/submit-batch-screen - Get batch job status and results — https://exclia.com/docs/reference/get-batch-job - Enroll an entity in continuous monitoring — https://exclia.com/docs/reference/enroll-monitor - Remove an entity from continuous monitoring — https://exclia.com/docs/reference/remove-monitor - Live coverage and list versions — https://exclia.com/docs/reference/get-coverage - Current billing-period consumption — https://exclia.com/docs/reference/get-usage