Payna is backed by Y Combinator
ReadingOverview

Documentation

Payna Compliance API

Programmatic access to the licensing data Payna manages for you. Status, renewal dates, per-person licensing, continuing education, open regulator conditions, and attestations, over HTTPS.

# Is this company licensed in California, right now?

curl -s https://app.payna.com/api/v1/licenses/CA \
  -H "Authorization: Bearer $PAYNA_API_KEY"

Every endpoint is scoped to your company by the API key you send. There is no company or tenant parameter anywhere in the API, because the key already determines it. Nothing in the API writes to your Payna data.

Responses reflect the state as of the last time Payna reconciled your records against the registries, which happens on a schedule rather than on every request. meta.synced_at carries that timestamp so you can show freshness rather than infer it. It is null on endpoints where Payna holds no single reconcile time for the answer, so render it only when it is present.

What it covers#

The API is split into resource groups, and most integrations use only one of them.

  • Licenses are entity level. This is the company license portfolio across every state and activity type: lending, mortgage, debt collection, money transmission.
  • Individuals are person level. Loan officers, insurance adjusters, and any other regulated person, each with their own licenses, continuing education, open conditions, attestations, and task queue.
  • NMLS verification reads the public record. The two groups above answer about people and entities Payna manages for you. This one answers about anybody on NMLS, by reading what NMLS Consumer Access publishes about them, live.

Individuals are typed by vertical rather than split across separate routes. Filter with ?type=mlo or ?type=adjuster, or omit the filter to get everyone. A new regulated role does not add new endpoints.

Two surfaces, and which one to read#

The documentation is deliberately in two places, and they do different jobs.

  • This guide is one continuous page covering concepts and workflows: how authentication and entitlements behave, what the status models mean, and the handful of things that surprise integrators. Read it once, top to bottom.
  • The interactive reference is generated from the OpenAPI spec the API itself serves. It is exhaustive, always current, and lets you paste a key and run a real request. Go there for field-by-field detail.

Where the two overlap, the reference wins: it is generated from the live spec, and this guide is written by hand. Each chapter below links into it rather than restating it.

The MCP server chapter is the exception. MCP is not expressible in OpenAPI, so the guide is the only documentation for it.

Base URL#

https://app.payna.com/api/v1

The API is served from the application domain, not from this marketing site. HTTPS only.

At a glance#

PropertyValue
AuthenticationBearer token, keys begin pk_live_
MethodsGET, plus one POST that starts an NMLS verification. Nothing writes to your Payna data.
Rate limit300 requests per 15 minutes, per API key, sliding window
PaginationPage and limit, default 50, maximum 200
FormatJSON. Every success carries data and meta, with two exceptions below.
VersioningPath versioned at /v1

Endpoints#

11 endpoints in total. Full parameters and response schemas are in the reference chapters below, and the interactive reference lets you paste a key and run a real request against your own data.

EndpointReturns
GET /licensesList all licenses
GET /licenses/summaryGet license summary
GET /licenses/{state}Get license for a state
GET /individualsList regulated individuals
GET /individuals/{id}/licensesGet a person’s licenses
GET /individuals/{id}/license-conditionsGet open conditions
GET /individuals/{id}/ce-statusGet continuing education status
GET /individuals/{id}/attestationsGet attestations
GET /individuals/{id}/tasksGet assigned tasks
POST /nmls/verificationsStart a verification
GET /nmls/verifications/{id}Get a verification

Response shape#

Every successful response is an object with data and meta. Collections put an array in data; single resources put an object there. Errors replace both with a single error key, which is covered on the errors page.

{
  "data": [ ... ],
  "meta": {
    "total": 37,
    "page": 1,
    "limit": 50,
    "synced_at": "2026-07-16T18:36:49Z"
  }
}

synced_at is the last time Payna reconciled these records against the registry. It is the field to surface if you are showing a freshness indicator to your own users. It is null on /licenses/summary and on the attestations endpoint, which aggregate across records with no single reconcile time.

Callers compute their own “is there more”: there is no has_more and no cursor. Keep paging while page × limit < meta.total.

Two exceptions

  • GET /licenses/{state} returns a single object in data, and its meta carries only total and synced_at. There is no page or limit on a single resource, and total there means how many licenses that state holds, not how many were returned.
  • The NMLS verification endpoints do not use the envelope at all. The verification object is the response body.

One endpoint adds a field to meta: /individuals/{id}/ce-status carries active_licenses_without_ce_tracking, the count of active licenses left out of data because Payna holds no CE facts for them.

Getting a key#

NextQuickstart ↓

Getting started

Quickstart

From nothing to a live licensure check. If you already have a key, skip to step three.

1. Mint an API key#

In the Payna dashboard, open Settings, then API Keys, and create a key with a label describing the system that will use it. You need to be an admin on the account.

2. Put it in the environment#

# Store the key in the environment rather than in source

export PAYNA_API_KEY="pk_live_..."

A key grants read access to your whole compliance portfolio, so treat it like a database credential. It belongs on a server. Never ship one in browser code, a mobile binary, or a public repository.

3. Call the summary endpoint#

The summary is the cheapest call in the API and the best way to confirm a key works.

curl
curl -s https://app.payna.com/api/v1/licenses/summary \
  -H "Authorization: Bearer $PAYNA_API_KEY"

You should get back aggregate counts across the portfolio.

Response
{
  "data": {
    "total": 37,
    "by_status": {
      "active": 13,
      "deficient": 2,
      "pending": 3,
      "applying": 10,
      "not_licensed": 9
    },
    "renewing_soon": 3,
    "renewing_30_days": 1,
    "expired": 0,
    "verified": 0
  },
  "meta": { "total": 37, "synced_at": null }
}

A 401 here means the key is wrong, malformed, or revoked. A 403 means the key is genuine but is not entitled to this resource. Both are covered on the errors page.

4. Ask a real question#

Aggregates are for dashboards. The call that tends to matter is whether a specific state is good right now.

curl
# The question most integrations actually ask:
# can we operate in this state today?

curl -s https://app.payna.com/api/v1/licenses/CA \
  -H "Authorization: Bearer $PAYNA_API_KEY"

5. Wrap it in a client#

A minimal client with the two things every integration needs: rate limit handling and a real error path.

const BASE = 'https://app.payna.com/api/v1'

async function payna(path) {
  const res = await fetch(BASE + path, {
    headers: { Authorization: `Bearer ${process.env.PAYNA_API_KEY}` },
  })

  if (res.status === 429) {
    // Retry-After is in seconds and is always present on a 429.
    const wait = Number(res.headers.get('Retry-After') ?? 30)
    throw new Error(`Rate limited, retry in ${wait}s`)
  }

  const body = await res.json()
  if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`)
  return body
}

// Gate on the derived descriptor, not the raw status string.
const { data } = await payna('/licenses/CA')
const canOperate = data.status_detail.category === 'active'

Next#

The interactive reference accepts your key and runs requests against your own data, which is usually faster than reading schemas.

NextAuthentication ↓

Getting started

Authentication

Every request carries an API key as a bearer token. The key identifies your company, so no endpoint takes a tenant parameter.

The header#

Authorization: Bearer pk_live_...

Keys always begin with pk_live_ and are 48 characters long. A key that is neither is rejected before any lookup happens. There is no sandbox key prefix and no test mode: every read-only endpoint answers from your real data, so there is nothing to sandbox.

curl -s https://app.payna.com/api/v1/licenses \
  -H "Authorization: Bearer $PAYNA_API_KEY"

Managing keys#

Keys are created and revoked by an admin from Settings, then API Keys, in the Payna dashboard. Each key carries a label, and the dashboard records when it was last used, which is the fastest way to find a key that nothing depends on any more.

Rotating a key

Mint the replacement first, deploy it, confirm the old key has stopped being used, then revoke. Revocation takes effect immediately and any request still presenting the old key gets a REVOKED response, so revoking before deploying will cause an outage.

Capabilities#

A key is entitled to specific resource groups rather than to the whole API. A key can hold any combination. A key created without an explicit list holds licenses only.

CapabilityGrants access to
licensesThe company license portfolio endpoints.
individualsPer-person licensing, conditions, CE, attestations, and tasks.
nmls_verificationLive reads of what NMLS publishes about any loan originator.

Entitlements, and the three ways to be refused#

Access is gated twice. A capability says what the key may do. Separately, Payna has to have switched the API on for your account. API access is off by default on a new account, so a correct key on an un-enabled account is the most common first-call failure.

The three refusals look similar and need different handling. Only one of them is worth retrying.

HTTPCodeWhat happenedWhat to do
403FORBIDDENThe key does not hold the capability this endpoint needs, or API access is off for your account entirely.Stop. Contact Payna. Rotating the key changes nothing.
404FORBIDDENYour account does not have this particular API switched on, licenses or individuals.Stop. Contact Payna. The 404 is about entitlement, not a missing record.
503INTERNAL_ERRORPayna could not read your entitlements. Nothing has been decided about your access.Retry with exponential backoff.
// Three different "no" answers, and only one of them is temporary.
if (res.status === 403 || res.status === 404) {
  const { error } = await res.json()
  if (error.code === 'FORBIDDEN') {
    // Permanent until someone changes a setting. Stop retrying,
    // alert, and contact Payna.
    return halt(error.message)
  }
}

if (res.status === 503) {
  // Entitlements could not be read. Nothing is wrong with the key.
  return retryWithBackoff()
}

Rate limits#

300 requests per 15 minutes, on a sliding window. The budget is bucketed per API key, not per IP address, so two services sharing one key share one budget. Give each service its own key if you want them isolated.

Exceeding the budget returns 429 with a Retry-After header giving the number of seconds until the window frees up. Wait that long rather than retrying immediately.

// A 429 always carries Retry-After, in seconds. It is the only
// rate-limit header the API sends, and it is sent on 429 alone.
if (res.status === 429) {
  const wait = Number(res.headers.get('Retry-After') ?? 30)
  await new Promise((r) => setTimeout(r, wait * 1000))
  // then retry once
}

Staying inside it

  • Poll the summary endpoint, not the list endpoint, when you only need to know whether anything changed.
  • Raise limit toward its maximum of 200 rather than walking many small pages.
  • Cache against meta.synced_at where it carries a real timestamp. Payna reconciles with the registries on a schedule, so polling faster than that returns identical data and spends budget for nothing.

Handling keys safely#

  • A key reads your entire compliance portfolio. Treat it like a database credential.
  • Server side only. Never in browser JavaScript, a mobile binary, or a repository.
  • One key per consuming system, so revoking one does not take down the others.
  • Revoke immediately if a key may have leaked. It is free, and minting a replacement takes seconds.

NextErrors ↓

Getting started

Errors

Failures replace the usual data and meta envelope with a single error object. The status in the body always matches the HTTP status, but a code can arrive on more than one status, so read both.

The envelope#

{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Missing or invalid API key.",
    "status": 401
  }
}

code is a stable machine string and is the only part safe to branch on. message is written for a human reading a log and may be reworded at any time. status repeats the HTTP status so a client that only has the body still knows what happened.

Every code#

A code is not one-to-one with a status. FORBIDDEN and INTERNAL_ERROR each arrive on two, and the pair means different things, so branch on the code and the status together.

CodeHTTPMeaningWhat to do
UNAUTHORIZED401The Authorization header is missing, malformed, or the key does not exist. Keys must be sent as a bearer token and begin with pk_live_.Do not retry. The key will not start working on its own.
REVOKED401The key was valid but has since been revoked in the Payna dashboard.Do not retry. Mint a replacement key.
FORBIDDEN403404An entitlement said no. 403 means the key lacks the capability for this endpoint, or API access is off for your account. 404 means your account does not have this particular API, licenses or individuals, switched on.Do not retry. Rotating the key does not help. Contact Payna.
RATE_LIMITED429Budget exhausted for the current window. The Retry-After response header carries the number of seconds to wait.Wait exactly Retry-After seconds, then retry.
INVALID_PARAM400A parameter failed validation. The message names the offending one. This is also what an unrecognised status filter value returns.Do not retry. Fix the request.
INVALID_STATE_CODE400The state path segment or filter is neither a two-letter US state code nor an ISO 3166-1 alpha-3 country code.Do not retry. Fix the request.
LICENSE_NOT_FOUND404No license record exists for the requested state. Usually an answer rather than a failure.Treat as not licensed there. Do not retry.
INDIVIDUAL_NOT_FOUND404No individual exists with that ID at your company. Also returned when the person exists but their vertical is not switched on, deliberately, so the two are not distinguishable from outside.Do not retry. Re-list from GET /individuals.
INTERNAL_ERROR500503Something failed on our side. 500 is an unexpected failure. 503 means Payna could not read your account entitlements, which is transient and is specifically not a 403.Retry with exponential backoff.

Two codes exist in the product but are never returned by a v1 endpoint. MLO_NOT_FOUND is kept for backwards compatibility and INDIVIDUAL_NOT_FOUND replaces it. INVALID_STATUS_VALUE is reserved: an unrecognised status filter value comes back as INVALID_PARAM today. Do not write a branch for either.

Handling them#

const body = await res.json()

if (!res.ok) {
  // Branch on code, never on message. Messages are written for
  // humans and are not part of the contract.
  switch (body.error.code) {
    case 'RATE_LIMITED':
      return retryAfter(res.headers.get('Retry-After'))
    case 'UNAUTHORIZED':
    case 'REVOKED':
      return alertOncall('Payna API key needs rotating')
    case 'FORBIDDEN':
      // 403 and 404 both land here. Neither is retryable.
      return alertOncall('Payna entitlement missing: ' + body.error.message)
    case 'INTERNAL_ERROR':
      // 503 is a transient entitlement read, 500 is anything else.
      // Both are safe to retry; neither is a refusal.
      return retryWithBackoff()
    case 'LICENSE_NOT_FOUND':
      return null // absence, not a failure
    default:
      throw new Error(body.error.code)
  }
}

Five of these deserve deliberate handling rather than a generic throw.

401, both variants

UNAUTHORIZED and REVOKED both mean the key will not start working again on its own. Retrying is pointless. Page someone.

FORBIDDEN is not always about the key, and not always a 403

FORBIDDEN covers three situations across two statuses. On 403: the key lacks the capability, or API access is off for your account. On 404: your account does not have that particular API, licenses or individuals, switched on. None of the three is fixed by rotating the key, and a 404 carrying FORBIDDEN is an entitlement answer rather than a missing record. See authentication.

503 means try again, 403 means stop

A 503 with INTERNAL_ERROR means Payna could not read your account entitlements. Nothing has been decided about your access and the same request will work shortly. Back off and retry. Never treat it as a refusal, and never treat a 403 as a blip.

404 usually means absence, not failure

LICENSE_NOT_FOUND means no license record exists for that state, which for most integrations is a legitimate answer rather than an error. Treat it as not licensed here, not as a broken call.

429 carries its own instructions

Read the Retry-After header and wait exactly that long. Retrying sooner spends budget without succeeding and pushes the window further out.

Retries#

Every read endpoint is a GET with no side effects, so retrying one is always safe. Retry 429 after the interval it gives you, and 500 or 503 with exponential backoff. Do not retry a 400, 401, 403, or a 404 carrying FORBIDDEN: the request will fail identically until something changes at your end or at ours.

The one endpoint that is not a GET is POST /nmls/verifications, and it is made safe to retry a different way: it requires an Idempotency-Key, and replaying a key returns the original verification rather than starting a second read.

NextLicenses ↓

Reference

Licenses

The company license portfolio: every state, every activity type. These are entity level licenses. For licenses held by people, see Individuals.

Endpoints#

GET/licensesList all licenses

Paginated list of every license record held by your company, ordered by state code. Filter with the query parameters below.

ParameterTypeDescription
statestringComma separated jurisdiction codes, for example CA,NY,TX. Two-letter US state codes, or ISO 3166-1 alpha-3 country codes such as GBR. Case insensitive.
statusstringComma separated status values, for example active,pending. See the status model on this page.
license_typestringPartial match on the license type, for example Money Transmission. Up to 200 characters.
verifiedbooleanExactly true or false. Any other value is ignored rather than rejected, and no filter is applied.
pageintegerPage number, starting at 1. Defaults to 1.
limitintegerResults per page. Defaults to 50, maximum 200.
Response fields

data · each array item

  • idstring · Required

    Unique license record ID.

  • state_codestring · Required

    Two-letter US state code.

  • license_typestring · Required
  • activity_typeenum: lending | debt_collection | money_transmission | mortgage | other · Required
  • license_numberstring · Optional
  • nmls_idstring · Optional
  • regulatorstring · Optional
  • statusenum: active | deficient | pending | applying | not_licensed · Required

    Core license status (5-value model). For accurate display, prefer status_detail.category and status_detail.code — the top-level status can say "applying" for abandoned applications or "pending" for withdrawal-requested licenses; status_detail resolves those correctly.

  • sub_statusstring · Optional

    Detailed sub-status within the core status category. Known values: expired, suspended, nmls_deficiency, no_license_needed, in_state_office_required, withdrawn, surety_bond_only (bond-only states — compliance is a surety bond on file, no license exists).

  • raw_statusstring · Optional

    Verbatim status string from the regulator.

  • issued_datestring · Optional
  • renewal_datestring · Optional

    Deadline to file the renewal — the single "act-by" date for the license. NMLS licenses run on a renewal cycle (not a hard expiry); state-portal licenses may use the same field.

  • days_until_renewalinteger · Optional

    Days until the renewal date from today. Negative means the renewal date has passed without a recorded renewal.

  • verifiedboolean · Required

    Whether Payna has verified this license record with the regulator.

  • verified_atstring · Optional
  • jurisdiction_levelstring · Optional
  • filing_methodstring · Optional
  • updated_atstring · Required

    Last time this record was updated in Payna.

  • status_detailobject · Required

    Derived, machine-stable descriptor of the license status. Render per-state nuance off `code`/`category` instead of parsing `raw_status`. Always present and well-formed.

  • status_detail.codestring (enum) · Required

    Stable machine slug — safe to branch on.

  • status_detail.labelstring · Required

    Human-readable label.

  • status_detail.categoryenum: active | in_progress | deficient | exited | not_pursued | needs_review · Required

    Color bucket: active→green, in_progress→blue/amber, deficient→red, exited/not_pursued→gray, needs_review→amber.

  • status_detail.intentionalboolean · Required

    True when the unlicensed posture is deliberate (voluntary withdrawal / no-license-needed / in-state-office / surety-bond-only) — i.e. not a problem.

  • status_detail.is_nmlsboolean · Required

    False for state-portal (non-NMLS) jurisdictions such as TX/DE debt collection.

  • status_detail.portalobject · Required

    State portal for non-NMLS jurisdictions; null for NMLS or when there is no online portal.

  • status_detail.source_textstring · Required

    Passthrough of the underlying raw_status.

Response
{
  "data": [
    {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "state_code": "CA",
      "license_type": "California - DFPI Debt Collection License",
      "activity_type": "debt_collection",
      "license_number": "11622-99",
      "nmls_id": "2671499",
      "regulator": "DFPI",
      "status": "active",
      "sub_status": null,
      "raw_status": "Approved",
      "issued_date": "2025-07-17",
      "renewal_date": "2026-12-31",
      "days_until_renewal": 168,
      "verified": false,
      "verified_at": null,
      "jurisdiction_level": null,
      "filing_method": null,
      "updated_at": "2026-07-16T18:36:49Z",
      "status_detail": {
        "code": "licensed",
        "label": "Licensed",
        "category": "active",
        "intentional": false,
        "is_nmls": true,
        "portal": null,
        "source_text": "Approved"
      }
    }
  ],
  "meta": {
    "total": 37,
    "page": 1,
    "limit": 50,
    "synced_at": "2026-07-16T18:36:49Z"
  }
}
GET/licenses/summaryGet license summary

Aggregate counts across the whole portfolio. Cheap enough to call on a dashboard load or a startup health check. Use renewing_30_days to drive blocking logic.

Response fields

data · object

  • totalinteger · Optional
  • by_statusobject · Optional

    Count per core status.

  • renewing_sooninteger · Optional

    Active licenses with a renewal date within 90 days.

  • renewing_30_daysinteger · Optional

    Active licenses with a renewal date within 30 days.

  • expiredinteger · Optional

    Licenses with status=deficient and sub_status=expired.

  • verifiedinteger · Optional

    Licenses that Payna has verified with the regulator.

Response
{
  "data": {
    "total": 37,
    "by_status": {
      "active": 13,
      "deficient": 2,
      "pending": 3,
      "applying": 10,
      "not_licensed": 9
    },
    "renewing_soon": 3,
    "renewing_30_days": 1,
    "expired": 0,
    "verified": 0
  },
  "meta": {
    "total": 37,
    "synced_at": null
  }
}
GET/licenses/{state}Get license for a state

The single most recently updated license for a state. State codes are case insensitive. A state can hold more than one license, so check meta.total: when it is greater than 1, call GET /licenses?state=XX to retrieve all of them. This is the one endpoint whose meta carries only total and synced_at, because a single resource has no page or limit.

ParameterTypeDescription
state *stringTwo-letter US state code in the path, for example CA. ISO 3166-1 alpha-3 country codes are also accepted.
Response fields

data · object

  • idstring · Required

    Unique license record ID.

  • state_codestring · Required

    Two-letter US state code.

  • license_typestring · Required
  • activity_typeenum: lending | debt_collection | money_transmission | mortgage | other · Required
  • license_numberstring · Optional
  • nmls_idstring · Optional
  • regulatorstring · Optional
  • statusenum: active | deficient | pending | applying | not_licensed · Required

    Core license status (5-value model). For accurate display, prefer status_detail.category and status_detail.code — the top-level status can say "applying" for abandoned applications or "pending" for withdrawal-requested licenses; status_detail resolves those correctly.

  • sub_statusstring · Optional

    Detailed sub-status within the core status category. Known values: expired, suspended, nmls_deficiency, no_license_needed, in_state_office_required, withdrawn, surety_bond_only (bond-only states — compliance is a surety bond on file, no license exists).

  • raw_statusstring · Optional

    Verbatim status string from the regulator.

  • issued_datestring · Optional
  • renewal_datestring · Optional

    Deadline to file the renewal — the single "act-by" date for the license. NMLS licenses run on a renewal cycle (not a hard expiry); state-portal licenses may use the same field.

  • days_until_renewalinteger · Optional

    Days until the renewal date from today. Negative means the renewal date has passed without a recorded renewal.

  • verifiedboolean · Required

    Whether Payna has verified this license record with the regulator.

  • verified_atstring · Optional
  • jurisdiction_levelstring · Optional
  • filing_methodstring · Optional
  • updated_atstring · Required

    Last time this record was updated in Payna.

  • status_detailobject · Required

    Derived, machine-stable descriptor of the license status. Render per-state nuance off `code`/`category` instead of parsing `raw_status`. Always present and well-formed.

  • status_detail.codestring (enum) · Required

    Stable machine slug — safe to branch on.

  • status_detail.labelstring · Required

    Human-readable label.

  • status_detail.categoryenum: active | in_progress | deficient | exited | not_pursued | needs_review · Required

    Color bucket: active→green, in_progress→blue/amber, deficient→red, exited/not_pursued→gray, needs_review→amber.

  • status_detail.intentionalboolean · Required

    True when the unlicensed posture is deliberate (voluntary withdrawal / no-license-needed / in-state-office / surety-bond-only) — i.e. not a problem.

  • status_detail.is_nmlsboolean · Required

    False for state-portal (non-NMLS) jurisdictions such as TX/DE debt collection.

  • status_detail.portalobject · Required

    State portal for non-NMLS jurisdictions; null for NMLS or when there is no online portal.

  • status_detail.source_textstring · Required

    Passthrough of the underlying raw_status.

Requires the licenses capability on your key. Full response schemas are in the interactive reference.

Filtering#

curl -s "https://app.payna.com/api/v1/licenses?state=CA,NY&status=active" \
  -H "Authorization: Bearer $PAYNA_API_KEY"

Filters combine with AND. state and status both accept comma separated lists, so the call above means California or New York, and active. Results are ordered by state code ascending.

state takes two-letter US state codes and also ISO 3166-1 alpha-3 country codes such as GBR, for companies licensed outside the US. Codes are case insensitive. An unrecognised one is rejected with INVALID_STATE_CODE rather than ignored.

A license record#

{
  "data": [
    {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "state_code": "CA",
      "license_type": "California - DFPI Debt Collection License",
      "activity_type": "debt_collection",
      "license_number": "11622-99",
      "nmls_id": "2671499",
      "regulator": "DFPI",
      "status": "active",
      "sub_status": null,
      "raw_status": "Approved",
      "issued_date": "2025-07-17",
      "renewal_date": "2026-12-31",
      "days_until_renewal": 168,
      "verified": false,
      "status_detail": {
        "code": "licensed",
        "label": "Licensed",
        "category": "active",
        "intentional": false,
        "is_nmls": true,
        "portal": null,
        "source_text": "Approved"
      }
    }
  ],
  "meta": { "total": 37, "page": 1, "limit": 50, "synced_at": "2026-07-16T18:36:49Z" }
}

The status model#

The top level status is a five value model aligned to NMLS raw status values.

StatusMeaning
activeLicensed and in good standing in that state.
deficientLicensed but with something outstanding against it, including expired licenses.
pendingFiled and waiting on the regulator.
applyingApplication in progress on our side, not yet with the regulator.
not_licensedNo license held. This is often deliberate, so check status_detail.intentional before treating it as a gap.

sub_status carries the detail underneath, for example withdrawn or expired.

// Correct: the derived category resolves the awkward cases.
const ok = license.status_detail.category === 'active'

// Wrong: raw_status is a verbatim regulator string. It differs
// between states and changes without notice.
const brittle = license.raw_status === 'Approved'

Fields on status_detail

FieldUse
codeStable machine slug, for example voluntary_withdrawal. Safe to branch on.
categoryCoarse bucket for display: active, in_progress, deficient, exited, not_pursued, needs_review.
intentionalTrue when being unlicensed is deliberate. This is the field that separates a real gap from a decision.
is_nmlsFalse for state portal jurisdictions that do not run through NMLS, such as Texas and Delaware debt collection.
portalThe state portal for non-NMLS jurisdictions, with a name and URL. Null for NMLS states.
labelHuman readable version of code. For display, not for logic.

Two things that surprise people#

A state can hold more than one license

Different activity types, or a state level plus a city level entry. GET /licenses/{state} returns only the most recently updated one and puts the real count in meta.total. When that count is above 1, call GET /licenses?state=XX to get all of them. An integration that ignores this will silently read one license and miss the rest.

That endpoint is also the one place the envelope differs: its data is a single object, and its meta carries only total and synced_at, with no page or limit.

Not licensed is often correct

A not_licensed status, or no record at all, frequently means no license is required there, or the activity is covered by a surety bond instead. Check status_detail.intentional before surfacing it as a compliance problem.

Renewals#

renewal_date is the deadline to file, and days_until_renewal counts down to it. A negative value means the date has passed with no renewal recorded. NMLS licenses run on a renewal cycle rather than a hard expiry, so a passed date is a signal to investigate rather than proof the license is dead.

For portfolio wide counts, GET /licenses/summary exposes renewing_soon at 90 days and renewing_30_days at 30, which is cheaper than paging the full list and computing it yourself.

NextIndividuals ↓

Reference

Individuals

Per-person licensing for the regulated people at your company. Loan officers, insurance adjusters, and any future regulated role, on the same endpoints.

How it is shaped#

Start at GET /individuals to get an individual_id, then use it against the sub-resources. Every sub-resource takes the same ID in the same position, so a client only needs one path builder.

People are typed by vertical rather than split across separate routes. ?type=mlo gives loan officers, ?type=adjuster gives insurance adjusters, and omitting the filter returns everyone. Onboarding a new regulated role does not add endpoints or change response shapes.

Endpoints#

GET/individualsList regulated individuals

Paginated list of the regulated people at your company. Omit type to list everyone regardless of vertical.

ParameterTypeDescription
typestringVertical filter, for example mlo or adjuster. Omit to return all individual types.
employment_statusstringOne of active, inactive, terminated, pending.
pageintegerPage number, starting at 1. Defaults to 1.
limitintegerResults per page. Defaults to 50, maximum 200.
Response fields

data · each array item

  • individual_idstring · Required

    Payna internal ID for this person. Use in all sub-resource endpoints.

  • individual_typestring · Required

    Regulated vertical: mlo (mortgage loan officer), adjuster (insurance adjuster), or any future type.

  • full_namestring · Required
  • work_emailstring · Optional
  • nmls_idstring · Optional

    NMLS individual identifier, when applicable.

  • employment_statusenum: active | inactive | terminated | pending · Optional

    Employment/sponsorship status at this company. An individual can be active at one company and terminated at another.

  • company_idstring · Required
  • updated_atstring · Required
Response
{
  "data": [
    {
      "individual_id": "c1d2e3f4-0000-0000-0000-aabbccddeeff",
      "individual_type": "mlo",
      "full_name": "Jane Smith",
      "work_email": "jane@company.com",
      "nmls_id": "1234567",
      "employment_status": "active",
      "company_id": "f0a1b2c3-0000-0000-0000-112233445566",
      "updated_at": "2026-07-17T10:00:00Z"
    }
  ],
  "meta": {
    "total": 12,
    "page": 1,
    "limit": 50,
    "synced_at": "2026-07-17T10:00:00Z"
  }
}
GET/individuals/{id}/licensesGet a person’s licenses

Every state license held by one person. For loan officers this is the NMLS individual record per state. Other verticals come from their own source system, NIPR for adjusters.

Response fields

data · each array item

  • idstring · Required
  • statestring · Required

    2-letter US state code, or US for federal/national registrations.

  • license_typestring · Required
  • statusenum: active | inactive | pending | not_licensed · Required
  • sub_statusstring · Optional
  • license_numberstring · Optional
  • effective_datestring · Optional

    Date the license was issued or became effective.

  • renewal_datestring · Optional

    Date by which the license must be renewed.

  • sourcestring · Required

    Where this record originated: nmls, nipr, manual, etc.

  • updated_atstring · Required
GET/individuals/{id}/license-conditionsGet open conditions

Regulator conditions attached to a person, with the verbatim condition text and a short action_required title. Lifecycle runs open, in_progress, submitted, cleared.

ParameterTypeDescription
statusstringOne of open, in_progress, submitted, cleared. Omit for all four.
Response fields

data · each array item

  • item_idstring · Required

    Unique condition ID.

  • individual_idstring · Required
  • statestring · Optional
  • condition_typestring · Optional

    Type of condition (e.g. education, disclosure, background_check). Null when not yet classified.

  • descriptionstring · Required

    Full condition text from the regulator.

  • action_requiredstring · Required

    Short title describing what needs to happen.

  • statusenum: open | in_progress | submitted | cleared · Required
  • due_datestring · Optional
  • sourceenum: nmls_sync | manual · Required

    How this condition was recorded.

  • first_seen_atstring · Optional
  • updated_atstring · Required
GET/individuals/{id}/ce-statusGet continuing education status

One record per state license that has CE hours tracked, with required, completed and remaining hours plus the cycle deadline. Active licenses Payna holds no CE facts for are left out of data and counted in meta.active_licenses_without_ce_tracking instead.

Response fields

data · each array item

  • statestring · Optional
  • license_typestring · Optional
  • yearinteger · Optional

    CE cycle year, derived from the deadline date.

  • required_hoursnumber · Optional
  • completed_hoursnumber · Optional
  • remaining_hoursnumber · Optional

    Computed: max(0, required - completed). Null when required or completed is unknown.

  • statusenum: compliant | pending | deficient · Optional

    compliant = CE complete; pending = in progress; deficient = overdue.

  • deadlinestring · Optional

    Date by which CE must be completed for the current cycle.

GET/individuals/{id}/attestationsGet attestations

Attestation state for a person, currently NMLS MU2 filing attestations, with the last completed timestamp and the next deadline.

Response fields

data · each array item

  • attestation_idstring · Optional

    Attestation request ID, or individual_id for synthetic records derived from NMLS sync data.

  • attestation_typestring · Optional

    Type of attestation. Currently: mu2 (NMLS MU2 filing attestation).

  • statusstring · Optional

    Current status: pending, paused, attested, cancelled, superseded.

  • last_completed_atstring · Optional

    Most recent timestamp this person attested.

  • next_due_atstring · Optional

    Deadline for the current open attestation request, when applicable.

GET/individuals/{id}/tasksGet assigned tasks

Work assigned to this person. Read assignment_status rather than status: it is the authoritative per-person value, where status is the parent task.

ParameterTypeDescription
statusstringEither open or completed. Cancelled tasks are never returned and cannot be requested.
Response fields

data · each array item

  • task_idstring · Optional
  • individual_idstring · Optional
  • titlestring · Optional
  • descriptionstring · Optional
  • categoryenum: people | state | general · Optional

    people = person-level work; state = license/deficiency work; general = other.

  • prioritystring · Optional

    Task priority (pass-through from Payna internal value).

  • statusstring · Optional

    Task-level status (pass-through from Payna internal value).

  • assignment_statusenum: open | completed · Optional

    This person's assignment status. Authoritative for this individual.

  • due_datestring · Optional
  • completed_atstring · Optional
  • completion_urlstring · Optional

    Deep link to this specific task in the Payna portal. Format: https://app.payna.com/portal?taskId=<uuid>.

Every sub-resource is paginated the same way, with page and limit, so only the filters that differ are listed above.

Requires the individuals capability on your key. Full parameter and response schemas, field by field, are in the interactive reference.

Listing people#

curl -s "https://app.payna.com/api/v1/individuals?type=mlo&employment_status=active" \
  -H "Authorization: Bearer $PAYNA_API_KEY"
{
  "data": [
    {
      "individual_id": "c1d2e3f4-0000-0000-0000-aabbccddeeff",
      "individual_type": "mlo",
      "full_name": "Jane Smith",
      "work_email": "jane@company.com",
      "nmls_id": "1234567",
      "employment_status": "active",
      "company_id": "f0a1b2c3-0000-0000-0000-112233445566",
      "updated_at": "2026-07-17T10:00:00Z"
    }
  ],
  "meta": { "total": 12, "page": 1, "limit": 50, "synced_at": "2026-07-17T10:00:00Z" }
}

The common question#

Most integrations exist to answer one thing before letting work proceed: is this person licensed in this state right now?

// Can this loan officer originate in the subject state today?
const { data: licenses } = await payna(
  `/individuals/${individualId}/licenses`
)

const licensed = licenses.some(
  (l) => l.state === subjectState && l.status === 'active'
)

Individual licenses use a four value status: active, inactive, pending, not_licensed. Note this differs from the five value model on company licenses, and there is no status_detail object here. Detail lives in sub_status, for example temporary_authority.

The state field is a two-letter US state code, or US for federal and national registrations.

Sub-resources#

ResourceWhat it tells you
licensesState by state license status for the person. For loan officers this is the NMLS individual record; other verticals come from their own source system.
license-conditionsOpen regulator conditions with verbatim text, a short action_required title, and a lifecycle of open, in_progress, submitted, cleared.
ce-statusContinuing education per state license: required, completed, and remaining hours plus the cycle deadline.
attestationsAttestation state, currently NMLS MU2 filing attestations, with last completed and next due.
tasksWork assigned to the person, each with a deep link into the Payna portal.

Two fields worth reading carefully#

assignment_status, not status

On tasks, status belongs to the parent task and assignment_status belongs to this person. When one task fans out to several people, the parent stays open until everyone is done, so the parent status will not tell you whether this person has finished. Read assignment_status, which is either open or completed.

remaining_hours can be null

On continuing education, remaining_hours is computed as required minus completed, floored at zero. It is null when either input is unknown, which is not the same as zero. Treating null as done will under-report a compliance gap.

Privacy#

NextNMLS verification ↓

Reference

NMLS verification

Ask what NMLS publishes about a loan originator, read live at the moment you ask. Works for anybody on NMLS, not only the people at your company.

What it returns#

You send an NMLS ID. Payna reads that person’s NMLS Consumer Access page and returns what it publishes: every licence with its regulator, state, name, number, status, status date, original issue date, renewed-through year and authorised-to-conduct-business flag, plus their other names, published office locations, employment history, and any published regulatory actions.

Results are not instant, so the API is asynchronous. Start a verification, then poll it.

The scope is individual loan originators. There is no company check and no branch check on these endpoints.

Endpoints#

POST/nmls/verificationsStart a verification

Starts a read of one person’s NMLS Consumer Access page and returns straight away with an ID to poll. Results are not instant, so this is asynchronous. Nothing is cached, so every call re-reads NMLS.

HeaderTypeDescription
Idempotency-Key *stringA key you generate per verification, for example a UUID. Replaying one returns the original verification and starts no second read.
Body fieldTypeDescription
nmls_idstringThe person’s NMLS ID. The preferred path: it resolves to exactly one page. Send this or name.
namestringFull name, when you hold no NMLS ID. Resolution only. A name that does not resolve to exactly one person comes back as needs_identification.
statestringTwo-letter state code narrowing a name search. Ignored when nmls_id is sent.
external_account_idstringYour own account identifier. Echoed back on the result. Payna stores nothing else against it.
loan_idstringYour own loan identifier. Echoed back on the result.
GET/nmls/verifications/{id}Get a verification

The verification as it currently stands, including the full record once the read has finished. Poll until status is terminal. This response is not wrapped in data and meta.

ParameterTypeDescription
id *stringThe verification_id the POST returned.
Response fields

response body · object

  • verification_idstring · Required

    Payna's identifier for this verification.

  • statusenum: pending | reading | complete | needs_identification | not_found | failed · Required

    Where the read has got to. complete, needs_identification, not_found and failed are terminal.

  • external_account_idstring · Optional

    Echoed back from the request. Payna stores nothing else against it.

  • loan_idstring · Optional

    Echoed back from the request.

  • requestedobject · Optional

    What was asked for, as asked.

  • requested.nmls_idstring · Optional
  • requested.namestring · Optional
  • requested.statestring · Optional
  • retrieved_atstring · Optional

    When the page was read from NMLS. Nothing is cached, so this is always the moment of this read. Null until the read happens.

  • sourceobject · Optional

    Where the answer came from, so it can be cited.

  • source.providerstring · Optional
  • source.urlstring · Optional
  • recordobject · Optional

    Present when status is complete. Absent otherwise.

  • record.nmls_idstring · Optional
  • record.namestring · Optional
  • record.phonestring · Optional
  • record.faxstring · Optional
  • record.other_namesstring[] · Optional

    Other names NMLS prints for this person.

  • record.authorized_to_representstring · Optional

    The sponsor string NMLS prints, verbatim, including the trailing NMLS ID. Null when NMLS prints no listing, which is not the same as unsponsored: some states publish no listing at all.

  • record.licensesobject[] · Optional

    Every licence on the person's Consumer Access page.

  • record.office_locationsobject[] · Optional
  • record.employmentobject[] · Optional
  • record.regulatory_actionsobject[] · Optional
  • candidatesobject[] · Optional

    Present when status is needs_identification. Re-request with one of these nmls_id values.

  • errorstring · Optional

    Present when status is failed. Says what went wrong. A failed verification never carries a record.

Response
{
  "verification_id": "ver_01JBQ4R7K3M2X9N8TZPV6YH0CD",
  "status": "complete",
  "external_account_id": "acct-123",
  "loan_id": "loan-456",
  "requested": {
    "nmls_id": "1234567"
  },
  "retrieved_at": "2026-10-05T17:00:04Z",
  "source": {
    "provider": "NMLS Consumer Access",
    "url": "https://nmlsconsumeraccess.org/EntityDetails.aspx/INDIVIDUAL/1234567"
  },
  "record": {
    "nmls_id": "1234567",
    "name": "Jane Smith",
    "phone": null,
    "fax": null,
    "other_names": [],
    "authorized_to_represent": "Example Mortgage Company, LLC (123456)",
    "licenses": [
      {
        "nmls_license_id": "1894302",
        "regulator": "California - DFPI",
        "state_code": "CA",
        "license_name": "CA-DFPI CRMLA Mortgage Loan Originator License",
        "license_number": "CA-123456",
        "status": "Approved",
        "status_date": "2024-02-11",
        "original_issue_date": "2019-06-03",
        "renewed_through_year": 2026,
        "authorized": true
      },
      {
        "nmls_license_id": "1894311",
        "regulator": "Texas - SML",
        "state_code": "TX",
        "license_name": "Texas - SML Mortgage Loan Originator License",
        "license_number": "1234567",
        "status": "Approved",
        "status_date": "2022-01-04",
        "original_issue_date": "2022-01-04",
        "renewed_through_year": 2026,
        "authorized": true
      }
    ],
    "office_locations": [
      {
        "company_name": "Example Mortgage Company, LLC",
        "nmls_id": "2184001",
        "location_type": "Branch",
        "street": "100 Market Street, Suite 400",
        "city": "San Diego",
        "state_code": "CA",
        "postal_code": "92101",
        "start_date": "03/2021"
      }
    ],
    "employment": [
      {
        "from_date": "03/2021",
        "to_date": "Present",
        "employer": "Example Mortgage Company, LLC",
        "position": "Loan Originator",
        "city": "San Diego",
        "state_code": "CA",
        "postal_code": "92101",
        "financial_services": true
      }
    ],
    "regulatory_actions": []
  }
}

Requires the nmls_verification capability on your key. Unlike every other endpoint in these docs, these two do not wrap the response in data and meta. The verification object is the body.

Starting one#

Send nmls_id when you have it. It resolves to exactly one page and is the path to prefer. external_account_id and loan_id are yours, echoed back on the result so a reply can be correlated without a lookup table. Payna stores nothing else against them.

curl
curl -s -X POST https://app.payna.com/api/v1/nmls/verifications \
  -H "Authorization: Bearer $PAYNA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"nmls_id":"1234567","loan_id":"loan-456"}'
202 Accepted
{
  "verification_id": "ver_01JBQ4R7K3M2X9N8TZPV6YH0CD",
  "status": "pending"
}

Polling for the answer#

curl -s https://app.payna.com/api/v1/nmls/verifications/ver_01JBQ4R7K3M2X9N8TZPV6YH0CD \
  -H "Authorization: Bearer $PAYNA_API_KEY"

Poll every couple of seconds until status is terminal. Four of the six are.

Response
{
  "verification_id": "ver_01JBQ4R7K3M2X9N8TZPV6YH0CD",
  "status": "complete",
  "external_account_id": null,
  "loan_id": "loan-456",
  "requested": { "nmls_id": "1234567" },
  "retrieved_at": "2026-10-05T17:00:04Z",
  "source": {
    "provider": "NMLS Consumer Access",
    "url": "https://nmlsconsumeraccess.org/EntityDetails.aspx/INDIVIDUAL/1234567"
  },
  "record": {
    "nmls_id": "1234567",
    "name": "Jane Smith",
    "phone": null,
    "fax": null,
    "other_names": [],
    "authorized_to_represent": "Example Mortgage Company, LLC (123456)",
    "licenses": [
      {
        "nmls_license_id": "1894302",
        "regulator": "California - DFPI",
        "state_code": "CA",
        "license_name": "CA-DFPI CRMLA Mortgage Loan Originator License",
        "license_number": "CA-123456",
        "status": "Approved",
        "status_date": "2024-02-11",
        "original_issue_date": "2019-06-03",
        "renewed_through_year": 2026,
        "authorized": true
      },
      {
        "nmls_license_id": "1894311",
        "regulator": "Texas - SML",
        "state_code": "TX",
        "license_name": "Texas - SML Mortgage Loan Originator License",
        "license_number": "1234567",
        "status": "Approved",
        "status_date": "2022-01-04",
        "original_issue_date": "2022-01-04",
        "renewed_through_year": 2026,
        "authorized": true
      }
    ],
    "office_locations": [
      {
        "company_name": "Example Mortgage Company, LLC",
        "nmls_id": "2184001",
        "location_type": "Branch",
        "street": "100 Market Street, Suite 400",
        "city": "San Diego",
        "state_code": "CA",
        "postal_code": "92101",
        "start_date": "03/2021"
      }
    ],
    "employment": [
      {
        "from_date": "03/2021",
        "to_date": "Present",
        "employer": "Example Mortgage Company, LLC",
        "position": "Loan Originator",
        "city": "San Diego",
        "state_code": "CA",
        "postal_code": "92101",
        "financial_services": true
      }
    ],
    "regulatory_actions": []
  }
}

Statuses#

StatusTerminalMeaning
pendingNoAccepted and queued. The read has not started.
readingNoThe read is in flight.
completeYesThe page was read. record carries everything NMLS publishes about the person.
needs_identificationYesA name search matched zero people or more than one. candidates lists them. Payna never picks.
not_foundYesNMLS publishes no page for that person. This is an answer about NMLS, not a failed read.
failedYesPayna could not read. error says what went wrong. Never a silent empty record.

Searching by name#

Send name, optionally with state, when you hold no NMLS ID. Name search resolves, it does not decide. Anything other than exactly one match comes back as needs_identification with the candidates, and Payna never picks one for you.

Response
{
  "verification_id": "ver_01JBQ4R7K3M2X9N8TZPV6YH0CE",
  "status": "needs_identification",
  "requested": { "name": "Jane Smith", "state": "CA" },
  "retrieved_at": "2026-10-05T17:01:22Z",
  "candidates": [
    { "nmls_id": "1234567", "name": "Jane Smith", "city": "San Diego", "state_code": "CA" },
    { "nmls_id": "7654321", "name": "Jane A Smith", "city": "Fresno", "state_code": "CA" }
  ]
}

Re-request with the nmls_id of whichever candidate your own records identify. That second request is a new verification and needs a new Idempotency-Key.

When a read fails#

Response
{
  "verification_id": "ver_01JBQ4R7K3M2X9N8TZPV6YH0CF",
  "status": "failed",
  "requested": { "nmls_id": "1234567" },
  "retrieved_at": null,
  "error": "Could not reach NMLS."
}

A failed verification is an HTTP 200. The request succeeded, the read did not. Read status, not the status code, to know whether you have an answer.

The record#

FieldWhat it carries
licensesEvery licence on the page. Each carries regulator, state_code, license_name, license_number, status, status_date, original_issue_date, renewed_through_year, and authorized.
authorizedNMLS’s authorized-to-conduct-business flag for that one licence. Null when NMLS prints no value, which is not false.
statusThe regulator’s own word, verbatim, for example Approved. Not Payna’s five-value licence model, and not normalised between states.
authorized_to_representThe sponsor line NMLS prints, as a raw string including the trailing NMLS ID.
regulatory_actionsPublished enforcement actions. An empty array means NMLS publishes none, which is not a certification that none exist.
other_namesOther names NMLS prints for the person.
employmentSelf-reported work history. Dates stay verbatim at the precision NMLS states them, which is month precision and the literal word Present.
office_locationsOffices NMLS lists the person as working from. The owning institution is not always your company, so match on nmls_id before adopting one.

Idempotency#

Idempotency-Key is required on the POST. Generate one per verification, a UUID is fine. Replaying a key returns the original verification_id and starts no second read, which makes the POST safe to retry after a timeout or a network drop.

This is not caching. A new key for the same person re-reads NMLS, deliberately. Keys are scoped to your company, so they never collide with another customer’s.

Freshness#

Results are never cached. Every verification reads the live page, so the answer is current as of the moment you asked.

retrieved_at is on every response and is the moment that read happened, not a reconcile time from a schedule. Store it with whatever you decide: it is the timestamp that says when the public record said what it said.

A complete flow#

Start, poll, and act. payna() here is the quickstart client extended to take a method, extra headers and a JSON body.

const TERMINAL = new Set([
  'complete',
  'needs_identification',
  'not_found',
  'failed',
])

async function verify(nmlsId, loanId) {
  const start = await payna('/nmls/verifications', {
    method: 'POST',
    headers: { 'Idempotency-Key': crypto.randomUUID() },
    body: { nmls_id: nmlsId, loan_id: loanId },
  })

  // Poll every 2 seconds and give up after 90. Do not spin faster:
  // the budget is 300 requests per 15 minutes across every call
  // your systems make.
  const deadline = Date.now() + 90_000
  while (Date.now() < deadline) {
    await new Promise((r) => setTimeout(r, 2000))
    const v = await payna(`/nmls/verifications/${start.verification_id}`)
    if (TERMINAL.has(v.status)) return v
  }

  throw new Error('Verification did not settle in 90s')
}

const v = await verify('1234567', 'loan-456')

// Your logic, over the published record.
if (v.status !== 'complete') return escalateToHuman(v)

const inState = v.record.licenses.filter((l) => l.state_code === 'CA')
const anyAuthorized = inState.some((l) => l.authorized === true)

NextMCP server ↓

MCP server

MCP server

Connect your AI assistant to Payna and it can answer licensing questions — about the company, about the team, or about one person’s own record. Add the URL, sign in through your browser, approve. Eight read-only tools, and which of them you see depends on who you are.

A different product from the REST API#

The chapters above describe the REST API: a pk_live_ key, company-wide, GET over /api/v1/*. This is not that. The MCP server takes an OAuth bearer token, is scoped to who signed in, and exposes tools rather than endpoints. It never accepts an API key.

MCP cannot be expressed in OpenAPI, so this chapter is the only documentation for it. Nothing here appears in the interactive reference, and nothing there applies to the server described below.

Connect an assistant#

Pick your client and the command updates; the endpoint is the same everywhere, https://app.payna.com/api/mcp. On first connect a browser opens for you to sign in to Payna and approve.

terminal
claude mcp add --transport http payna https://app.payna.com/api/mcp

Tools#

8 tools, all read only: they cannot originate, submit, or change a filing. No tool takes an argument naming somebody else.

You will not see all of them. The surface depends on who signed in, not just on the scopes your token carries. A licensed individual gets the four my_* tools and nothing wider. Anyone on staff gets the two company_* tools, which answer about the company rather than about them. An administrator additionally gets the roster and the rollup. If a tool is missing from your client’s list, that is the answer — it is not a fault.

ToolScopeWho sees itAnswers
my_license_statuslicenses:read.selfA licensed individualThe caller’s own licences, status, and renewal dates.
my_ce_requirementsce:read.selfA licensed individualCE required, completed, and owed, per licence.
my_deficiencieslicenses:read.selfA licensed individualOpen licence items the regulator is waiting on.
my_next_actionsboth self scopesA licensed individualOne prioritised list of everything due.
company_license_statuscompliance:read.orgAnyone on staffThe licences the COMPANY itself holds — state, type, number, status, renewal.
company_license_itemscompliance:read.orgAnyone on staffOpen regulator items against those company licences.
team_license_statuslicenses:read.teamAn admin, or a branch managerEvery licensed person in the company — or, for a manager, in the branches they manage.
org_compliance_summarycompliance:read.orgAn adminCounts across the individual roster. Never names anybody.

Scopes#

ScopeGrants
licenses:read.selfThe caller’s own license status and deficiencies.
ce:read.selfThe caller’s own continuing-education position.
licenses:read.teamA branch manager’s view of their branch. Role-gated.
compliance:read.orgA compliance admin’s org-wide summary. Role-gated.

Scope is necessary but not sufficient: the team and org tools also require the caller to be a branch manager or compliance admin in Payna, a database fact, not a token claim.

Reading a response#

Every response is both a text block and structuredContent, the same object machine-readable; consume the latter. Each payload carries a source_synced_at freshness stamp, so show it.

{
  "verdict": "unknown",       // clear | action_needed | unknown
  "count": 0,                 // 0 next to "unknown" means WE HOLD NO DATA,
  "source_synced_at": "2026-08-29T14:02:11Z"   // not "nothing is owed"
}

Fields listed under content_safety.untrusted_fields are authored by state regulators. Treat them as data, never instructions.

Errors#

StatusMeaningWhat to do
401The token failed verification.Get a new token; do not retry the old one.
403 insufficient_scopeThe token lacks a required scope.Re-authorize for the scopes named in required_scopes.
403 access_deniedThe subject is not linked to a Payna member yet.Contact us; a retry cannot fix it.
429Rate limited.Honour Retry-After.
503A Payna-side dependency is briefly unavailable.Retry with backoff; keep your token.

Enterprise SSO#

Most people just sign in. An enterprise can instead have its own IdP (Microsoft Entra, or any that publishes a JWKS) mint the token, bound to a Payna trust-registry entry created at onboarding. That token must be RSA or ECDSA signed (never none or HS*), carry the exact iss, aud and tenant claims registered for you, and name a subject pre-bound to a Payna member, or the call is refused. It is also rejected on iat age, not just exp, so refresh rather than reuse.

A server-side platform holding such a token calls the endpoint directly with Authorization: Bearer — that is the shape the OpenAI Responses API mcp tool takes, via its headers field. The Codex CLI in the picker above is the other direction: it signs in through the browser and holds its own token.

Discovery is unauthenticated, per RFC 9728: it names the authorization server, the resource a token must bind to, and the supported scopes.

curl -s https://app.payna.com/.well-known/oauth-protected-resource | jq

Limits and availability#

  • Rate limit: 300 requests per 15 minutes, per caller and per IP. A 429 carries Retry-After; ask us about a per-tenant tier before high-volume agent loops.
  • Availability: a 503 is a brief Payna-side blip, not an auth problem. Retry and keep your token.
  • Data handling: row-level security returns your tenant’s records only. SSN and date of birth are stripped from every response and never reach your model. Every call is audited.

Troubleshooting#

SymptomCause
Sign-in says ask your adminThe connector is not switched on for your workspace yet; a Payna admin enables it.
Works, then fails after about an hourThe token aged out. Payna rejects on age, not just exp; your client refreshes automatically.
↑↓ navigate↵ openesc close