Skip to content

Extracting Fields From a Professional License Certificate

9 min read · updated August 11, 2026

Three fields matter on a professional licence: the number, the issuing board, and the expiry. The first two are static and the third is not, which means the only genuinely hard part of this extraction is that the answer changes after you have stored it.

The three fields, and the one that moves

A licence certificate — a nursing licence, a professional engineer’s stamp certificate, a contractor’s licence, a real estate licence, a pharmacy or medical licence — carries a licence number, the body that issued it, the licence type or scope, an issue date and an expiration date. Most also carry the licensee’s name as registered, which is not always the name they use.

The issuing board is the field most often extracted badly, because a certificate names its board in full ceremonial form at the top of the document and that string is not a stable identifier. “State Board of Registration for Professional Engineers and Land Surveyors” and “Board of Professional Engineers” may be the same body in different eras. Resolve the board to an identifier of your own, keyed by jurisdiction and profession, and keep the printed string. The jurisdiction is the part you cannot omit: the same licence number exists in several states for different people, so a licence number without a jurisdiction is not an identifier at all.

Licence type matters more than it looks because a single board issues several. A nursing board issues registered nurse and practical nurse licences with different scopes; an engineering board licences several disciplines. Extract the type verbatim and resolve it against the board’s own list rather than a general vocabulary, for the same reason a degree name needs a per-institution glossary.

As-of semantics, or the status rots

The obvious check is a comparison: is the expiry date before today? Written that way, it produces a boolean that is correct at the moment of extraction and wrong at some unannounced point afterwards. If that boolean is stored, you now have a database full of assertions that were true when written and are not marked as time-bound.

The fix is small and it changes everything downstream: never store the decision, store the input and the moment. Persist the expiry date and the date the record was evaluated, and compute the status on read.

// Not this.
const record = { expired: expiry < new Date() };

// This. The stored fields are facts; "expired" is derived at read time.
const record = {
  expiry_date: "2027-03-31",
  expiry_precision: "day",
  evaluated_at: "2026-08-11",
  status_at_evaluation: "valid",
};

const isExpiredAsOf = (r, asOf) => r.expiry_date != null && r.expiry_date < asOf;

Three subtleties sit underneath that, and each has caused a real category of wrong answer:

  • An expiry date is usually inclusive. A licence expiring 31 March is generally valid through 31 March, not until the start of it. A strict less-than comparison against the expiry date marks a valid licence expired for one day, which is a small error that generates an outsized number of support contacts.
  • Precision varies. Many licences expire at the end of a month or on a fixed anniversary and print only a month and year. Store the precision, and when comparing at month precision compare against the last day of that month rather than inventing the first.
  • Expired is not the same as invalid. Boards commonly operate grace periods, renewal-pending states, inactive-but-renewable status, and reinstatement. A licence past its printed date may be lawfully in use during a grace window. So the derived value should be past_printed_expiry, which is a fact about the document, rather than invalid, which is a conclusion about a person that the document cannot support.

The same reasoning applies to a certification on a resume, where the expiry is often absent altogether — see extracting skills and certifications for why an unknown expiry has to be distinguishable from no expiry.

Some licence numbers are checkable

Most licence numbers are jurisdiction-defined with no check digit, so the only validation available is a format check against a pattern you maintain per board. But some professional identifiers do carry arithmetic, and where one does you should use it, because it converts an OCR digit error from a silent corruption into a rejected record — the general pattern in a checksum-validated identifier field.

The clearest example is the National Provider Identifier, the ten-digit identifier issued by the US Centers for Medicare & Medicaid Services to healthcare providers. Its final digit is a Luhn check digit, and the documented rule is that the check is computed as though the identifier were prefixed with 80840 — the prefix that identifies it as a health card issuer number — whether or not the prefix is actually present. Take CMS’s own example, the NPI 1234567893:

payload = 80840 + first nine digits = 80840123456789

Double every second digit from the right, casting out nines:
   9 -> 9    7 -> 5    5 -> 1    3 -> 6    1 -> 2    4 -> 8    0 -> 0
   sum of doubled digits                              = 31
Remaining digits:  8  6  4  2  0  8  8                = 36

total = 31 + 36 = 67
check = (10 - (67 mod 10)) mod 10 = 3       printed check digit: 3   OK

The final mod 10 is the same branch that catches people out on an ISBN: when the total already ends in zero the check digit is 0, not 10. The published rule is available from the Centers for Medicare & Medicaid Services, and it is worth reading rather than reimplementing from memory, because the 80840 prefix step is the part everyone omits and omitting it makes every valid NPI fail.

Where a board publishes no check rule, do not derive one from a sample of numbers you have seen. A pattern inferred from two hundred certificates will reject the first number that uses a range the board had not yet issued from, and the failure will look like an extraction error rather than a bad rule. Record the format check as a soft flag, not a rejection.

The certificate is not the authority

Every field on this page comes from a piece of paper stating what was true when it was printed. Whether a licence is currently in good standing is held by the board, not by the certificate, and it can change without the certificate changing: suspensions, disciplinary action, voluntary surrender and administrative lapse all leave the document in the licensee’s hands looking exactly as it did.

Nearly every licensing board operates a public register for exactly this reason, and a verification against it is the operation that actually answers the question. The right architecture makes extraction the cheap step that produces a lookup key — jurisdiction, board, licence number, name — and the register lookup the step that produces the status. Extraction at scale is genuinely valuable in that shape: it turns a pile of scanned certificates into a queue of verifiable claims, quickly.

Two consequences for the record. Store a verification block separately from the extracted fields, with its own timestamp and its own source — the per-field provenance of an extraction field audit trail — so nobody can mistake a parsed expiry for a confirmed status. And where extraction and register disagree, the register wins and the disagreement is worth keeping — a mismatch between a presented certificate and the public register is precisely the condition a compliance process exists to notice, and it should reach a person rather than being silently overwritten.

The record

{
  "licensee_name_as_printed": "Sample Q. Person",
  "licence": {
    "number_raw": "RN-000-0000",
    "number_normalised": "RN0000000",
    "format_check": "matches_board_pattern",
    "jurisdiction": "XX",
    "board_printed": "State Board of Nursing",
    "board_id": "xx-nursing",
    "licence_type_printed": "Registered Nurse",
    "issue_date": "2019-04-01",
    "expiry_date": "2027-03-31",
    "expiry_precision": "day",
    "expiry_inclusive": true
  },
  "evaluated_at": "2026-08-11",
  "past_printed_expiry_at_evaluation": false,
  "verification": {
    "source": null,
    "checked_at": null,
    "status": "not_verified"
  }
}

The verification block is deliberately present and empty rather than absent. An empty block that says not_verified is a fact every consumer can act on; a missing block is an ambiguity that consumers resolve differently, and some of them will resolve it optimistically. That is the same argument as recording an occluded field with a status instead of a null, and it is the habit that distinguishes a document pipeline you can build a compliance process on from one that merely produces JSON.