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.
What can I build?
Four common Licensy workflows. The API and SDKs make each one trivial to compose.
Verify a hospital roster
FreeHand a list of NPIs to Licensy, get back current license status for every state each physician is licensed in. The credentialing-team workflow.
# Hospital onboarding 200 physicians — verify their CA + NY licenses
for npi in roster_npis:
licenses = client.list_physician_licenses(npi=npi)
active = [l for l in licenses if l.license_active]
flag_missing(npi, expected_states={"CA","NY"} - {l.state for l in active})Monitor a watchlist for disciplinary actions
ProSubscribe a webhook to a set of NPIs. Get a signed POST within minutes of a status change. The compliance-team workflow.
# One-time setup
client.subscribe_to_changes(
npis=current_credentialed_physicians,
webhook_url="https://your-app.com/webhooks/licensy",
)
# Then in your webhook handler:
def on_status_change(event):
if event["event"] == "status_change":
page_compliance_team(event["npi"], event["license"]["status"])
elif event["event"] == "expiration_warning_60d":
email_provider_to_renew(event["npi"])Audit a credentialing pipeline
ProPoint-in-time queries: was this physician licensed when they billed for X procedure on date Y? Plus state-board screenshots for the audit file.
# Quarterly retrospective audit
result = client.get_license_history(
npi="1700013174", state="CA",
as_of_date="2024-08-12",
)
if result.status != "active":
flag_for_review(npi, billed_procedure_id, result)
# And a notarized screenshot of the source:
screenshot_url = client.get_screenshot_url(
npi="1700013174", state="CA",
)
attach_to_audit_record(billed_procedure_id, screenshot_url)Build a credentialing copilot
FreeConnect Claude / Cursor / your own MCP client. Your team asks 'is Dr. X licensed?' in chat, Claude calls Licensy directly. The AI-native workflow.
// One config block in Claude Desktop:
{
"mcpServers": {
"licensy": {
"command": "npx",
"args": ["-y", "mcp-remote",
"https://mcp.licensy.ai/mcp",
"--header", "Authorization: Bearer YOUR_KEY"]
}
}
}
// Then ask Claude in chat:
// "Verify Dr. Smith NPI 1700013174 across CA, NY, TX"
// Claude calls bulk_verify under the hood and quotes the answer.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.
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}")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 -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.
| Status | Exception | Meaning |
|---|---|---|
| 401 | AuthError | API key missing, malformed, or revoked |
| 403 | TierUpgradeRequired | Tool requires a higher tier; required_tier field tells you which |
| 404 | NotFoundError | No license exists for the (NPI, state) lookup |
| 422 | ValidationError | Input rejected; details says which field |
| 429 | RateLimitError | Exceeded per-minute or per-day budget; Retry-After header tells you when to retry |
| 5xx | LicensyError | Upstream 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:
| Header | Meaning |
|---|---|
| X-RateLimit-Limit | Total requests permitted in the current window |
| X-RateLimit-Remaining | Requests you have left before the window resets |
| X-RateLimit-Reset | Unix timestamp (seconds) when the window resets |
| Retry-After | On 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 tierVerify a single physician's license in a single state. Most common call shape.
Parameters
npistringoptional10-digit NPI (preferred input).
statestringrequired2-letter code ("CA") or full name ("California").
license_numberstringoptionalUse when NPI isn't known.
license_typestringoptionalOptional 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 tierReturn every license Licensy has on file for one NPI — one entry per state.
Parameters
npistringrequiredThe physician's 10-digit NPI.
include_inactivebooleanoptionalWhen 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 tierReturn just the status word — the cheapest call. Useful for routing logic that needs a yes/no decision.
Parameters
npistringrequiredThe physician's 10-digit NPI.
statestringrequired2-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 tierGet 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
npistringoptionalIdentify the license by NPI + state.
statestringoptional2-letter code or full state name.
license_idstringoptionalAlternative: 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 tierVerify many (NPI, state) pairs in one call. Returned in input order. Pro max 100/call, Enterprise max 1000/call.
Parameters
licensesarrayrequiredArray 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 tierFind known disciplinary actions (suspension, revocation, surrender, probation, reprimand) against a physician.
Parameters
npistringoptionalSearch by NPI.
namestringoptionalOr search by full name.
statestringoptionalRestrict 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 tierPoint-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
npistringrequired10-digit NPI.
statestringrequired2-letter code or full state name.
as_of_datestring (YYYY-MM-DD)requiredThe 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 tierSubscribe 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 stringrequiredNPIs to watch.
webhook_urlstringrequiredHTTPS endpoint we should POST events to.
eventsarray of stringoptionalFilter 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 secretunsubscribe
Pro tierCancel an active subscription. Idempotent — calling it on a missing or already-cancelled subscription returns success.
Parameters
subscription_idstringrequiredThe 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 datenew_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.
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 '', 200import 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 '', 200Playground
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.