Free API key, instantly: 1,000 calls a day. Get a free key, or use it with no key at 100 a day.
HLA-Verify

API reference v0 · free public beta

One deterministic engine, every endpoint pinned to one release

Every response is computed from a pinned IPD-IMGT/HLA release and carries that release in the payload. Every example on this page is real output from the engine at release 3.66.0 — not illustrative JSON. The pin is the v3.66.0-alpha tag of IPD-IMGT/HLA (2026-10-14), ahead of the latest official release (3.65.0), so names new, renamed or deleted in 3.66.0 differ from the published release; it moves to the official 3.66.0 when IPD-IMGT/HLA publishes it. GET /v1/releases reports it in pin. The three core endpoints are documented in full below; the Lab Toolkit, Pharmacogenomics, Release history and Report a wrong answer endpoints are summarised here and specified at api.hlaverify.com/docs.

Base URL, auth and versioning

Base URLhttps://api.hlaverify.com
AuthNone required: anonymous callers get 100 calls a day per IP, 60/min burst. With a key: X-API-Key: <your key> on every request
Content typeapplication/json for POST bodies
LimitsCalls per day and typings per batch call, both set by tier. Quotas reset at midnight UTC and every response carries the quota left. A free key, issued instantly at /research, gets 1,000 calls a day; verified labs get 10,000 by request. See pricing.
ReleaseOne release is compiled into the service and returned as release in every response. Per-key pinning is not implemented.
Interactive docsOpenAPI at /docs on your deployment
Free public beta. The engine, endpoints and response shapes below are implemented, tested and live: api.hlaverify.com answers anonymously at 100 calls a day per IP, and verdicts are production-quality, pinned to IPD-IMGT/HLA 3.66.0. It is free: get a free key instantly for 1,000 calls a day, and verified labs can have the Lab tier (10,000 calls a day, 5,000 typings per call) by request. See pricing.

POST /v1/verify

Takes arbitrary free text — a typing report, a LIMS comment field, a note, a model's answer — and returns a verdict for every allele-shaped token it contains.

Body: {"text": string}, up to 200,000 characters.

Send allele names and typing strings, not patient identifiers. The 200,000-character limit exists because typing reports and model output arrive as prose, not as an invitation to paste a patient record. The endpoint needs no name, date of birth or medical record number to classify a token, and does not want one. Strip the identifiers and send the part with the alleles in it. Request bodies are processed in memory and discarded either way: see Privacy.
$ curl -s https://api.hlaverify.com/v1/verify \
    -H 'content-type: application/json' -H "x-api-key: $KEY" \
    -d '{"text": "Donor typing: A*0101, B*15:504:01, DRB1*14:06, DQB1*05:03:01G.
                Assistant suggested DQB1*05:03:26:99."}'
{
  "release": "3.66.0",
  "clean": false,
  "counts": { "valid": 3, "deleted": 0, "group": 1,
              "fabricated_group": 0, "hallucinated": 1,
              "mac_code": 0, "out_of_scope": 0 },
  "tokens": [
    { "token": "A*0101",
      "status": "valid",
      "note": "assigned in this release (or a valid lower-resolution prefix)",
      "current_2field": "A*01:01",
      "g_group": "AMBIGUOUS",
      "flags": ["deprecated_name"] },
    { "token": "B*15:504:01",
      "status": "valid",
      "current_2field": "B*15:504",
      "g_group": "NONE" },
    { "token": "DQB1*05:03:01G",
      "status": "group",
      "note": "a G/P group name in this release" },
    { "token": "DQB1*05:03:26:99",
      "status": "hallucinated",
      "note": "no such name in any release back to 1.05.0 — fabricated" },
    { "token": "DRB1*14:06",
      "status": "valid",
      "current_2field": "DRB1*14:06",
      "g_group": "AMBIGUOUS" }
  ],
  "attribution": "Computed from IPD-IMGT/HLA …"
}

Guardrail pattern: gate on clean. It is true only when hallucinated, fabricated_group and deleted are all zero. A sixth status, mac_code, marks NMDP multiple allele codes such as A*02:AB: reporting shorthand the service does not expand, surfaced so a report full of them is never reported clean with those tokens unchecked; it has its own count and does not affect clean. A seventh, out_of_scope, marks KIR allele names such as KIR3DL1*001: a gene family outside IPD-IMGT/HLA, surfaced rather than checked, so a report that lists KIR typing next to HLA typing is no longer flagged dirty for the KIR names; it too has its own count and does not affect clean. Two-field A*02:01g and the XX code A*02:XX are checked by the name they abbreviate; legacy spellings — colon-less pre-2010 names (A*0201, DRB1*1501) and the old Cw locus label (Cw*0702, Cw*07:02) — by the current name they stand for, reported valid with current_2field and the flag deprecated_name, so a report in the old format is clean while a made-up Cw*9999 is still hallucinated. Tokens are returned sorted by name.

POST /v1/normalize

Takes a list of reported typing strings from any era and returns each one resolved to its current name, its 2-field reduction, its G group and its flags. This is the batch workhorse: a LIMS column in, a clean column out. Accepted: legacy colon-less names, deleted names, lower-resolution prefixes, G and P group names (A*02:01:01G, DPB1*04:01P), the XX code (A*02:XX), two-field A*02:01g, and an optional HLA- prefix; each shorthand leaves a flag (g_group_name, p_group_name, xx_code, lg_notation). NMDP multiple allele codes are recognised but not expanded: they return UNRESOLVABLE with the flag mac_code.

Body: {"typings": string[]}, up to your tier's cap of typings per call: 250 on Free and Starter, 5,000 from Lab. Every billable response carries the cap in the x-hla-verify-max-typings header; a batch over it is 422, naming the cap and the tier that lifts it — split the batch or upgrade.

$ curl -s https://api.hlaverify.com/v1/normalize \
    -H 'content-type: application/json' -H "x-api-key: $KEY" \
    -d '{"typings": ["A*0101", "B*15:504:01", "DRB1*1406", "C*07:01:01:01"]}'
{
  "release": "3.66.0",
  "rows": [
    { "reported": "A*0101",        "current_name": "A*01:01",
      "allele_2field": "A*01:01",  "g_group": "AMBIGUOUS",
      "flags": ["deprecated_name"] },
    { "reported": "B*15:504:01",   "current_name": "B*15:504:01",
      "allele_2field": "B*15:504", "g_group": "NONE",      "flags": [] },
    { "reported": "DRB1*1406",     "current_name": "DRB1*14:06",
      "allele_2field": "DRB1*14:06", "g_group": "AMBIGUOUS",
      "flags": ["deprecated_name"] },
    { "reported": "C*07:01:01:01", "current_name": "C*07:01:01:01",
      "allele_2field": "C*07:01",  "g_group": "C*07:01:01G", "flags": [] }
  ]
}

g_group is AMBIGUOUS when a lower-resolution name spans more than one G group, and NONE when the allele belongs to no G group. Unresolvable input returns current_name: "UNRESOLVABLE" rather than a guess.

GET /v1/allele/{name}

The facts for one name: G group, P group, serology, first release, confirmation status, null-allele marking. Lower-resolution names return the size of the set they cover and a sample of members; a G or P group name returns status: "group" with its group_type, member count and sample.

$ curl -s "https://api.hlaverify.com/v1/allele/A*01:01:01:01" -H "x-api-key: $KEY"

{
  "release": "3.66.0",       "name": "A*01:01:01:01",
  "status": "assigned",     "confirmed": true,
  "g_group": "A*01:01:01G", "p_group": "A*01:01P",
  "first_release": "3.00.0",
  "serology": { "unambiguous": ["1"] }
}
$ curl -s "https://api.hlaverify.com/v1/allele/A*01:01" -H "x-api-key: $KEY"

{
  "release": "3.66.0", "name": "A*01:01", "status": "valid_prefix",
  "members_count": 303,
  "members_sample": ["A*01:01:01:01", "A*01:01:01:02N", "A*01:01:01:03", "…"]
}

The same reported shorthands /v1/normalize accepts resolve here too, and the answer carries the facts of the name they stand for plus resolves_to and a flag, so nothing is converted silently: legacy colon-less names (A*0101, Cw*0702 → deprecated_name), the XX code (A*02:XX → xx_code), two-field A*02:01g (lg_notation) and an optional HLA- prefix. An NMDP multiple allele code (A*02:AB) returns status: "mac_code": recognised, not expanded.

$ curl -s "https://api.hlaverify.com/v1/allele/A*0101" -H "x-api-key: $KEY"

{
  "release": "3.66.0", "name": "A*0101", "status": "valid_prefix",
  "resolves_to": "A*01:01", "flags": ["deprecated_name"],
  "members_count": 303,
  "members_sample": ["A*01:01:01:01", "A*01:01:01:02N", "A*01:01:01:03", "…"]
}

Deleted names return status: "deleted" with a successor; a legacy spelling of a deleted name is chased the same way (A*0134N → A*01:34N, successor A*01:01:38L). Unassigned but parseable names return 404, and when a shorthand stands for a name that does not exist the message names both forms; unparseable names return 404 with a parse message. Names are URL-path segments, so * and : may need encoding depending on your client.

GET /v1/usage

Your key's own usage: calls and units today, over 7 days and over 30 days, each by endpoint and by response status, with the tier's daily quota beside them. Scoped to the presented key; anonymous callers get 401. Cached for 60 seconds per key and never billed against the quota. Counts only, never content.

$ curl -s "https://api.hlaverify.com/v1/usage" -H "x-api-key: $KEY"

{
  "release": "3.66.0", "tier": "lab", "daily_quota": 10000, "max_typings": 5000,
  "usage": { "today": { "calls": 412, "units": 890, "by_endpoint": { "verify": 300, "normalize": 112 }, "by_status": { "200": 405, "429": 7 } },
             "7d": { "…" }, "30d": { "…" } }
}

GET /healthz

Liveness plus the release the process is currently pinned to. Unauthenticated. Use it to assert in your own CI that the release under you has not moved.

{ "ok": true, "release": "3.66.0", "uptime_s": 4192 }

Lab Toolkit

Four more POST endpoints on the same base URL, same auth, same pinned release and same daily quota: typing-level checks built on the normalizer above. Request and response schemas, with real examples, are at api.hlaverify.com/docs; they are summarised here, with the Pharmacogenomics and signup and access endpoints below, so this page lists every endpoint a caller can use.

POST /v1/match

Donor–recipient match verdict. Two reported alleles per locus, any nomenclature era, G/P group names included. Frameworks 6/6, 8/8, 10/10, 12/12, antigen. Counts per chromosome, not per locus; GvH and HvG mismatches reported separately; a locus whose typing is too coarse to call is potential and excluded from the denominator with resolution_insufficient, because a confident count over unresolvable typing is itself the error. Null alleles hiding inside serologic matches (the A*24:09N trap) raise null_allele_mismatch. Rules R1–R6 are published in rules.py for lab audit.

Body: {"framework": string, "recipient": typing, "donor": typing}, where a typing is {"A": [...], "B": [...], ...}.

POST /v1/typing/check

QC one typing, all loci. Any nomenclature era, up to 4 reported alleles per locus. Resolves every reported string, flags unresolvable / deprecated_name / locus_mismatch / null_allele per allele and too_many_alleles / single_allele / homozygous per locus, and returns a profile (B-leader -21 M/T genotype, C KIR-ligand genotype, KIR ligands present, and whether the A/B/C typing is complete enough for a confident KIR-ligand call) plus a drb345 expected-vs-reported block when a DRB1 key is present. Units metered = total reported strings.

Body: {"typing": {"A": [...], "B": [...], "DRB1": [...], ...}}.

POST /v1/compat

Donor/recipient immunogenetic compatibility. Two published models, each run over the full typing QC on both sides: HLA-B leader match (-21 M/T; Petersdorf 2020, Blood) for a single HLA-B mismatch, and KIR ligand (C1/C2/Bw4) class comparison, which requires complete A, B and C typing. Decision support only; not a medical device. Units metered = total reported strings on both sides.

Body: {"recipient": typing, "donor": typing}, each shaped as in /v1/typing/check.

POST /v1/glstring

GL String validation and normalization: the ^ locus-block / | genotype-list / + genotype / ~ haplotype / / allele-list grammar, up to 5,000 allele tokens. Resolves and renames every allele token, flags structural problems (mixed_locus_allele_list, haplotype_repeats_locus, more_than_two_haplotypes, genotype_loci_differ, genotype_list_loci_differ, locus_repeated_across_blocks, empty_element, whitespace_in_name), and returns a normalized string with outdated names rewritten to current. Units metered = allele token count.

Body: {"gl": "A*01:01/A*02:01+A*03:01~B*07:02"}.

Pharmacogenomics

Three more endpoints on the same base URL, same auth and same pinned release: a guideline lookup that joins a typing to the published HLA–drug associations in hla-pgx-open (CPIC guideline recommendations and PharmGKB clinical annotations that key on an HLA allele: abacavir and B*57:01, carbamazepine and B*15:02 / A*31:01, allopurinol and B*58:01, …). Every allele name in the table is checked against the pinned release, and every response names the pgx_snapshot (UTC date and git commit of the table) it was answered from, next to release, so a lookup is reproducible the way a verdict is. The table is CC BY-SA 4.0 and each response carries its attribution block. Request and response schemas, with real examples, are at api.hlaverify.com/docs.

GET /v1/pgx/table

The whole table, one row per source record × allele × drug: source, drug, allele, evidence level, CPIC testing recommendation, implication, recommendation, guideline or annotation URL and PMIDs. JSON by default, ?format=csv for the file. Free, no key, not billable and never counted against a quota; cached for a day. Open data.

Query: ?format=csv optional. Returns release, pgx_snapshot, scope, license, attribution, row_count, columns and rows (or text/csv).

POST /v1/pgx/screen

Which drugs a typing carries a published HLA association for. Send a typing shaped as in /v1/typing/check or a flat list of reported allele names, any nomenclature era or shorthand. Every name goes through the normalizer first — guideline lookups need clean two-field names and labs report every era — so each row carries current_name, allele_2field and the usual flags, nothing is converted silently, and the join is on the two-field form. Per allele: associations[] (source, drug, evidence level, CPIC testing recommendation, allele status, implication, recommendation, URL, PMIDs). summary.actionable is the subset a prescriber acts on: CPIC level A, allele-positive. Billable like /v1/normalize; units metered = names sent. Decision support only; not a medical device.

Body: {"typing": {"A": [...], "B": [...], ...}} or {"alleles": ["B*5701", "A*31:01", ...]} — exactly one of the two. Allele strings only, never patient identifiers.

POST /v1/pgx/pair

Which genome carries each actionable association, donor and recipient. Screens both sides exactly as /v1/pgx/screen does, then lists every actionable association with in_recipient, in_donor, discordant (present in exactly one) and whose_hla (both / recipient_only / donor_only). After an allogeneic transplant the recipient's blood carries the donor's genome, and which genome should drive an HLA-mediated drug alert is an open question (NCT06856226): this endpoint reports both and decides nothing. Billable like /v1/match; units metered = names on both sides. Decision support only; not a medical device.

Body: {"recipient": {"typing": {...}} | {"alleles": [...]}, "donor": {"typing": {...}} | {"alleles": [...]}}, each side shaped as in /v1/pgx/screen.

Release history

IPD-IMGT/HLA renames and retires allele names between releases. These endpoints answer from its own name history, every release since 1.05.0, so data typed under an old release can be compared with today's. Try them on release history; full schemas at api.hlaverify.com/docs.

POST /v1/translate

What each name, as written in one release, is called in another. Each row is unchanged, renamed, deleted (with the reason and successor), not_yet_named, not_in_from_release, ambiguous or unknown. Batch size as /v1/normalize; one billable call.

Body: {"names": ["A*24:02:03", "A*0101"], "from": "1.05.0", "to": "3.66.0"}. from is optional; to defaults to the current release.

GET /v1/releases/diff

Every allele name added, removed and renamed between any two releases, optionally for one locus. One billable call.

Query: ?from=3.58.0&to=3.66.0&locus=A&limit=500.

GET /v1/allele/{name}/history

The allele behind a name: its HLA ID, current name, every name it has had release by release, and why a name was retired. ?as_of=3.40.0 adds its name in that release. Billable like /v1/allele.

GET /v1/releases

The releases the history covers, oldest first. Free.

Signup and access

Two more POST endpoints on the same base URL. Neither needs a key, neither counts against any quota, and neither issues a key by itself. Full field schemas, with examples, are at api.hlaverify.com/docs.

POST /v1/beta-signup

Joins the beta list; the same list as the form at /beta. Only email is required; org, use_case and source are optional. Signing the same address twice is not an error and does not create a second record. Stored: the address, the optional fields, a timestamp and the request's country — no IP address — and the record is not an API key.

Body: {"email": string, "org": string?, "use_case": string?, "source": string?}. Returns {"ok": true, "status": "recorded" | "already_recorded"}; 422 for an address that does not look like one or a field over its length cap.

POST /v1/research-access

Issues a free API key at once: 1,000 calls a day, 250 typings per call, 60 requests a minute. This is the same thing the form at /research does, and POST /v1/keys is an alias. Only email is required. The key is in the response and is shown once. One key per email address: asking again returns already_issued with the key's first eight characters, never the key (lost keys: hello@hlaverify.com). Disposable inboxes get 422, and a fourth new key from one connection in a UTC day gets 429. Stored: the email address with the key (service notices only), a timestamp and the request's country. No IP address is stored. Add "tier": "lab" with institution and use_case to apply for the Lab tier (10,000 calls a day) instead: that request is reviewed, and an approved Lab key is emailed.

Body: {"email": string, "institution": string?, "use_case": string?, "tier": "lab"?, "source": string?}. Returns {"ok": true, "status": "issued", "key": "hlv_…", "key_ref": string, "tier": "free_key", "limits": {…}, "usage": {…}}, or "already_issued"; with "tier": "lab", "recorded" | "already_recorded".

One more route exists on the service and is not a customer API: GET /admin/usage is the operator dashboard, protected by a separate admin token that is never accepted as a customer key. It is specified at api.hlaverify.com/docs for completeness.

Report a wrong answer

Two routes on the same base URL for telling us an answer looks wrong. Neither needs a key, neither counts against any quota, and neither is billable. The same intake as the form at /support; full field schemas are at api.hlaverify.com/docs.

POST /v1/report

Reports a verdict, normalization or lookup you believe is wrong, a docs page that disagrees with the API, an outdated release, or a question. Every report is rerun against the live API and the IPD-IMGT/HLA reference data, and a person confirms any error before it is published; a confirmed error is fixed and listed with the release that fixed it. Returns an id and a status_url. Send allele names or the request only, never patient identifiers, names included. Obvious identifiers (record-number and date-of-birth labels, Social Security numbers, US-format dates) are refused with 422 and nothing is stored; a name cannot be detected, so leave names out. At most 20 reports a day from one connection (429 after that).

Body: {"input": string, "expected": string, "kind": string?, "endpoint": string?, "received": string?, "details": string?, "email": string?, "request_id": string?, "source": string?}. input and expected are required; kind is wrong_result (default), docs_mismatch, outdated_release, question or other. Returns {"ok": true, "id": "r…", "status": "received", "status_url": string, "message": string, "release": string}.

GET /v1/report/{id}

The public half of a report: id, kind, status (received, investigating, confirmed, fixed, expected_behavior, duplicate or needs_info), created, updated, the public note, the release that fixed it (fixed_in) and the release live at the time (release_at_report). Never the input or the email address. An unknown id is 404.

Errors

StatusWhen
400Malformed JSON body
401Missing, unrecognized or revoked X-API-Key
404The name is not assigned in the pinned release, or is not a parseable HLA allele name; also an unknown route
415Wrong content type — POST bodies must be application/json
422Body failed validation — text over 200,000 characters, a batch over your tier's typings cap (250 on Free and Starter, 5,000 from Lab; the message names the cap and the tier that lifts it), a GL String over 5,000 allele tokens, wrong shape
429Rate limited (per-minute burst) or the daily quota exhausted — tier-specific, with a Retry-After header; quotas reset at midnight UTC
500Server error; nothing is stored, retry the request

Every error is JSON {"detail": "…"}. Note the deliberate asymmetry: /v1/verify never fails on bad allele names — a fabricated name is a result, not an error. Only /v1/allele/{name} 404s, because there is nothing to report.

Self-hosting

The service ships as a container. For pilots that cannot send anything outside their own network, run it yourself; the reference files are fetched from the official source at startup and MD5-verified, and no request content leaves the process.

# build and run the verification service locally
$ docker build -t hla-verify -f sci_envs/service/Dockerfile .
$ docker run -p 8000:8000 \
    -e HLA_VERIFY_TAG=v3.66.0-alpha \
    -e HLA_VERIFY_API_KEYS="key-one,key-two" \
    hla-verify

# /docs is the OpenAPI UI; / is the interactive demo page
Environment variableEffect
HLA_VERIFY_TAGThe IPD-IMGT/HLA release tag to pin. Default v3.66.0-alpha.
HLA_VERIFY_API_KEYSComma-separated keys. Unset means open — demo mode only, never for a deployment reachable from outside.
Licence. The service code is under PolyForm Noncommercial 1.0.0 — free to read, evaluate and use noncommercially; commercial use needs a licence. The benchmark, harness and adapters around it are Apache-2.0. See Trust.