Skip to content

Enterprise design partners open. Request early access →

Enterprise design partners open. Request early access →

Developer documentation

Licensy API

Standardized U.S. physician and physician assistant licensing data across all 50 states + DC, sourced from official public records. Build licensing workflows with three lines of code.

Authentication

The Licensy API uses bearer-token authentication. Pass your API key in the Authorization header with the Bearer prefix on every request.

Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx

API keys carry production-grade access. Keep them in environment variables — never commit them to source control or include them in client-side JavaScript shipped to browsers.

Developer preview

Try it in your AI client

Licensy speaks the Model Context Protocol (MCP), so any MCP-compatible AI tool can call it directly. Pick your client below — you'll be running real license lookups inside Claude / Cursor / VS Code in about 60 seconds.

What you need first: a Licensy account for your bearer token. Sign up at licensy.ai/signup.

To get your token: log into the dashboard, open Overview, and click the copy-to-clipboard button next to "API Key". This is a stable, long-lived token — paste it into Claude Desktop / Cursor configs and it stays valid for months, not hours.

Claude Desktop

Open Claude Desktop → Settings → Developer → Edit Config. Add the Licensy block:

{
  "mcpServers": {
    "licensy": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://mcp.licensy.ai/mcp",
        "--header",
        "Authorization: Bearer YOUR_TOKEN_HERE"
      ]
    }
  }
}

Restart Claude. You'll see a 🔌 plugin indicator at the bottom of the chat. Try: "Is Dr. Steven Winiarski's California license active? NPI 1700013174." Claude will call verify_license under the hood and quote the answer with the state board as source.

Cursor / VS Code (Claude integration)

Cursor and the VS Code Claude extension use the same mcpServers config shape. Open the MCP settings panel and paste the same JSON block above. The plugin will auto-connect on next launch.

Same config works for any MCP-compatible client — Zed, Cline, Continue.dev, and others. The transport is the standard Streamable HTTP MCP spec; no Licensy-specific shim required.

MCP Inspector (for developers)

Want to poke at the protocol surface directly? The official MCP Inspector gives you a browser UI for tools, resources, and prompts. No install needed:

npx @modelcontextprotocol/inspector https://mcp.licensy.ai/mcp

The Inspector opens at http://localhost:6274. In the auth panel, paste your Bearer token, then "Connect". You'll see all 9 tools, click any of them, fill the form, and watch the raw JSON-RPC round-trip.

Or skip MCP — just use the REST API

If you're not building an AI integration, the same 9 tools are exposed as plain JSON HTTP endpoints. The Quickstart below shows the three-line Python / TypeScript / curl path.

Developer tier: 1,000 calls / month, all 50 states + DC, current license status. See pricing for paid-tier limits.

Install

Both SDKs are on the public package registries — installable in one command, with TypeScript types and async/await on the JS side, and full typed responses + httpx under the hood on the Python side.

pip install licensy

Python 3.9+. Async client also available via AsyncLicensyClient.

npm install @licensyai/sdk

Node 18+ (uses built-in fetch). ESM + CJS both shipped; works in modern browsers.

Quickstart

Verify a physician's California license in three lines. Replacesk_live_...with your real API key from the dashboard.

Python
from licensy import LicensyClient

client = LicensyClient(api_key="sk_live_...")

result = client.verify_license(npi="1700013174", state="CA")
if result.license_active:
    print(f"Active through {result.expiration_date}")
else:
    print(f"Inactive: {result.status}")
TypeScript
import { LicensyClient } from '@licensyai/sdk'

const client = new LicensyClient({ apiKey: 'sk_live_...' })

const result = await client.verifyLicense({
  npi: '1700013174',
  state: 'CA',
})
if (result.licenseActive) {
  console.log(`Active through ${result.expirationDate}`)
} else {
  console.log(`Inactive: ${result.status}`)
}
curl
curl -X POST https://mcp.licensy.ai/api/v1/tools/verify_license \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"npi": "1700013174", "state": "CA"}'

Errors

The API returns standard HTTP status codes. Error bodies are JSON with a stable code and a human-readable message. Both SDKs map these to typed exceptions you can catch directly.

StatusExceptionMeaning
401AuthErrorAPI key missing, malformed, or revoked
403TierUpgradeRequiredTool requires a higher tier; required_tier field tells you which
404NotFoundErrorNo license exists for the (NPI, state) lookup
422ValidationErrorInput rejected; details says which field
429RateLimitErrorExceeded per-minute or per-day budget; Retry-After header tells you when to retry
5xxLicensyErrorUpstream issue on our side; retry with backoff

Rate limits

Rate limits apply per API key. The exact ceilings depend on your tier; see the pricing page for current numbers.

Every response includes rate-limit headers so you never have to guess where you stand:

HeaderMeaning
X-RateLimit-LimitTotal requests permitted in the current window
X-RateLimit-RemainingRequests you have left before the window resets
X-RateLimit-ResetUnix timestamp (seconds) when the window resets
Retry-AfterOn 429: seconds to wait before your next request will succeed

Both SDKs surface Retry-After via typed error fields: RateLimitError.retry_after_seconds (Python) / RateLimitError.retryAfterSeconds (TypeScript). Both also expose .limit_remaining / .limitRemaining on every successful response so you can throttle pre-emptively.

Recommended client behavior: retry on 429 with exponential backoff seeded by the Retry-After value. Never retry on 401/403/404/422 — those are permanent for that request.

Try it in Postman

Download collection ↓

One-click Postman v2.1 collection covering 21 endpoints across the playground, reports, /me, and public APIs. Set the apiKey variable to your credential_key and start hitting endpoints in 30 seconds. Always reflects the current production API — re-download anytime.

Prefer raw OpenAPI? openapi.json · Swagger UI: https://mcp.licensy.ai/docs

Tool reference

Every tool is documented with its parameters, response shape, and code samples in Python, TypeScript, and curl.

verify_license

Free tier

Verify a single physician's license in a single state. Most common call shape.

Parameters

npistringoptional

10-digit NPI (preferred input).

statestringrequired

2-letter code ("CA") or full name ("California").

license_numberstringoptional

Use when NPI isn't known.

license_typestringoptional

Optional credential filter — MD, DO, PA, etc.

Response shape

{
  "status": "active",
  "state": "California",
  "expiration_date": "2027-09-30",
  "license_active": true,
  "license_number": "C12345",
  "board_source": "Medical Board of California",
  "last_refreshed_at": "2026-06-09T05:00:00Z"
}

Example call

result = client.verify_license(npi="1700013174", state="CA")
print(result.status, result.expiration_date)

list_physician_licenses

Free tier

Return every license Licensy has on file for one NPI — one entry per state.

Parameters

npistringrequired

The physician's 10-digit NPI.

include_inactivebooleanoptional

When true, also include expired/lapsed licenses. Default false.

Response shape

{
  "npi": "1700013174",
  "licenses": [
    {
      "state": "CA",
      "status": "active",
      "expiration_date": "2027-09-30",
      "license_active": true
    },
    {
      "state": "NY",
      "status": "active",
      "expiration_date": "2026-12-31",
      "license_active": true
    }
  ]
}

Example call

result = client.list_physician_licenses(npi="1700013174")
for license in result:
    print(license.state, license.status)
print(f"{result.active_count} active total")

get_license_status

Free tier

Return just the status word — the cheapest call. Useful for routing logic that needs a yes/no decision.

Parameters

npistringrequired

The physician's 10-digit NPI.

statestringrequired

2-letter code or full state name.

Response shape

{
  "status": "active"
}

Example call

status = client.get_license_status(npi="1700013174", state="CA")
# Returns "active", "expired", "suspended", "not_found", etc.

get_screenshot_url

Pro tier

Get a 15-minute signed URL to the state-board page screenshot. Useful for audit trails — many credentialing workflows require visual proof of the source.

Parameters

npistringoptional

Identify the license by NPI + state.

statestringoptional

2-letter code or full state name.

license_idstringoptional

Alternative: pass the Licensy license_id directly.

Response shape

{
  "screenshot_url": "https://screenshots.licensy.ai/sig?...",
  "expires_at": "2026-06-09T22:15:00Z"
}

Example call

url = client.get_screenshot_url(npi="1700013174", state="CA")
# Download or embed within 15 minutes

bulk_verify

Pro tier

Verify many (NPI, state) pairs in one call. Returned in input order. Pro max 100/call, Enterprise max 1000/call.

Parameters

licensesarrayrequired

Array of { npi, state } pairs to verify.

Response shape

{
  "results": [
    {
      "status": "active",
      "state": "CA",
      "expiration_date": "2027-09-30",
      "license_active": true
    },
    {
      "status": "expired",
      "state": "NY",
      "expiration_date": "2025-12-31",
      "license_active": false
    }
  ]
}

Example call

results = client.bulk_verify([
    {"npi": "1700013174", "state": "CA"},
    {"npi": "1700013174", "state": "NY"},
])
for r in results:
    print(r.state, r.status)

search_disciplinary_actions

Pro tier

Find known disciplinary actions (suspension, revocation, surrender, probation, reprimand) against a physician.

Parameters

npistringoptional

Search by NPI.

namestringoptional

Or search by full name.

statestringoptional

Restrict results to a single state.

Response shape

{
  "actions": [
    {
      "state": "CA",
      "action_type": "suspension",
      "date": "2023-05-12",
      "source_url": "https://board.example/action/12345"
    }
  ]
}

Example call

actions = client.search_disciplinary_actions(npi="1700013174")
for a in actions:
    print(a.state, a.action_type, a.date)

get_license_history

Pro tier

Point-in-time license status lookup. Answer 'was Dr. X licensed in California on date Y?' for any date back to when we started capturing snapshots.

Parameters

npistringrequired

10-digit NPI.

statestringrequired

2-letter code or full state name.

as_of_datestring (YYYY-MM-DD)required

The date to query against.

Response shape

{
  "status": "active",
  "as_of_date": "2024-08-12",
  "coverage_start": "2026-06-05",
  "coverage_note": "Snapshot coverage starts 2026-06-05. Earlier dates fall back to issuance-date heuristic."
}

Example call

result = client.get_license_history(
    npi="1700013174",
    state="CA",
    as_of_date="2024-08-12",
)
print(result.status)

subscribe_to_changes

Pro tier

Subscribe a webhook URL to license-change events. We'll POST a signed JSON message to your URL within minutes of detecting any matching change.

Parameters

npisarray of stringrequired

NPIs to watch.

webhook_urlstringrequired

HTTPS endpoint we should POST events to.

eventsarray of stringoptional

Filter to specific event types. Empty/omitted = all events. Known events: status_change, expiration_warning_60d, new_disciplinary_action.

Response shape

{
  "subscription_id": "sub_xxxxxxxxxxx",
  "expires_at": "2027-06-09T00:00:00Z",
  "note": "HMAC signing secret (store this — it will not be shown again): wK3pX9... Verify each delivery via the X-Licensy-Signature header."
}

Example call

sub = client.subscribe_to_changes(
    npis=["1700013174", "1234567890"],
    webhook_url="https://your-app.com/webhooks/licensy",
)
# Store sub.note — it contains the HMAC signing secret

unsubscribe

Pro tier

Cancel an active subscription. Idempotent — calling it on a missing or already-cancelled subscription returns success.

Parameters

subscription_idstringrequired

The subscription_id returned from subscribe_to_changes.

Response shape

{
  "success": true
}

Example call

client.unsubscribe(subscription_id="sub_xxxxxxxxxxx")

Webhook events

Once subscribed, your endpoint receives POST requests within minutes of detected license changes. Every delivery includes an HMAC-SHA256 signature you must verify before trusting the payload.

Event types

  • status_change — any change in the license's status field (e.g. Active → Expired, Active → Suspended)
  • expiration_warning_60d — the license is within 60 days of its expiration date
  • new_disciplinary_action — a new disciplinary action recorded against the physician (coming soon)

Sample payload

{
  "id": "a4f7e2c1-...",
  "event": "status_change",
  "occurred_at": "2026-06-09T05:30:00Z",
  "npi": "1700013174",
  "license": {
    "state": "California",
    "license_number": "C12345",
    "license_type": "MD",
    "status": "expired",
    "expired": "2026-06-08",
    "snapshotted_at": "2026-06-09T05:00:00Z"
  }
}

Signature verification

Every delivery includes an X-Licensy-Signature header in the format sha256=<hex>. Compute the expected signature from the raw request body using HMAC-SHA256 with your subscription's signing secret, then compare with a constant-time comparison.

Python (Flask)
import hmac, hashlib

@app.route('/webhooks/licensy', methods=['POST'])
def webhook():
    sig = request.headers.get('X-Licensy-Signature', '')
    expected = 'sha256=' + hmac.new(
        SIGNING_SECRET.encode(),
        request.get_data(),  # raw bytes — not request.json
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(expected, sig):
        return 'invalid signature', 401

    event = request.get_json()
    # ... handle event ...
    return '', 200
TypeScript (Express)
import crypto from 'crypto'

app.post('/webhooks/licensy',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['x-licensy-signature'] as string
    const expected = 'sha256=' + crypto
      .createHmac('sha256', SIGNING_SECRET)
      .update(req.body)  // raw Buffer
      .digest('hex')
    if (!crypto.timingSafeEqual(
      Buffer.from(sig), Buffer.from(expected)
    )) {
      return res.status(401).send('invalid signature')
    }
    const event = JSON.parse(req.body.toString())
    // ... handle event ...
    res.status(200).send()
  }
)

Generate a snippet with your signing secret pre-filled — paste it into your handler and you're done:

import hmac, hashlib
from flask import request

SIGNING_SECRET = "whsec_paste_your_signing_secret_here"  # from /subscriptions

@app.route('/webhooks/licensy', methods=['POST'])
def webhook():
    sig = request.headers.get('X-Licensy-Signature', '')
    expected = 'sha256=' + hmac.new(
        SIGNING_SECRET.encode(),
        request.get_data(),  # raw bytes — NOT request.json
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(expected, sig):
        return 'invalid signature', 401
    event = request.get_json()
    # ... handle event.id / event['event'] / event['license'] ...
    return '', 200

Playground

Paste your API key, pick a tool, fill the inputs, and see the result immediately. No Claude Desktop / Cursor setup needed. Grab your key from your settings page.

Embed a "Verified by Licensy" badge

Medical groups can drop a verification badge onto each physician's bio page in one line. The badge fetches the latest license status from /api/v2/public/badge/{npi} (public, no auth, cached 10 minutes) and links back to Licensy. No API key required.

<!-- Drop this anywhere on the page -->
<script src="https://www.licensy.ai/badge.js" data-npi="1700013174"></script>

<!-- Or, render into a specific target: -->
<span id="licensy-jane"></span>
<script src="https://www.licensy.ai/badge.js"
        data-npi="1700013174"
        data-target="#licensy-jane"></script>

Live preview:

Try 1700013174 (tracked) or any 10-digit number not in the system (you'll get the "not tracked" state).

Retry behavior

Your endpoint should return a 2xx status code within 30 seconds. Any other response or timeout triggers a retry on the following schedule:

  • Attempt 1: immediate
  • Attempt 2: after 5 minutes
  • Attempt 3: after 30 minutes
  • Attempt 4: after 4 hours
  • Attempt 5: after 24 hours

After the 5th failed attempt, the delivery is dead-lettered and you will not receive that event again. Use a stable response handler with proper idempotency — every delivery includes a unique id field you can deduplicate on.