Lender API reference

Send a loan from your loan software, follow its homeowners insurance, and fetch the binder file when it is delivered.

JSON over HTTPSBearer keyOpenAPI document

First call, with a test key
curl -X POST https://policyvera.com/api/webhooks/partner-intake \
  -H "Authorization: Bearer pv_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"loan_id":"ABC-12345",
       "borrower":{"first_name":"Jane","last_name":"Doe",
                   "email":"jane@example.com"},
       "subject_property":{"street":"200 New Home Ave","state":"ID"}}'
Answer 200
{
  "status": "test",
  "sandbox": true,
  "pending_import_id": "9c1f0e7a-2b4d-4f6a-8a1c-5d3e7b9f0a12",
  "quote_ready": false,
  "missing_for_quote": ["Phone", "Date of birth",
    "Marital status", "Where the borrower lives now",
    "Estimated closing date", "Loan amount",
    "Who will hold title"],
  "state_supported": true,
  "message": "Test key (sandbox): payload validated and held in …"
}
  1. 1Send the loanOne call with the loan's facts, under the lender's key.
  2. 2The lender gives the OKTheir team asks licensed local agents for quotes. The borrower compares every quote and picks their own agent.
  3. 3Follow itSigned events as quotes arrive and coverage is bound. Or read a loan, or list the loans that changed, at any time.
  4. 4Fetch the binderThe delivered file's address arrives with binder.ready.

Keys

A lender makes its keys in PolicyVera under Settings, Integrations. Every call carries one as Authorization: Bearer <key>. The key decides the lender and the environment; nothing in a request's body can.

A key is shown once. PolicyVera keeps a hash of it and its last four characters, so a lost key is replaced, never recovered.

Building for a lender? The lender adds you to its team as a developer. That seat makes keys and sees the test loans. It is read-only on the lender's borrowers.

No lender yet? A loan system or a software company building a connection can ask for a sandbox: a test key and test loans, with no lender and no real borrower. Ask for a sandbox

pv_test_A sandbox. The call is checked exactly as a live call and answers with the same readiness fields. The loan is held in the lender's Test imports and never reaches a pipeline, an agent or a borrower.
pv_live_The loan reaches the lender's Needs review, where a person at the lender gives the OK.

Send a loan

POST/api/webhooks/partner-intake

One call per loan. Required: the borrower's email, a first or last name, and the insured home's street and state.

Send the rest when you hold it. What a quote still needs comes back as missing_for_quote, a list of plain labels.

Or send the loan file itself. A MISMO 3.4 file, as your loan system exports it, sent with Content-Type: application/xml. PolicyVera reads the loan from it and answers the same way. Its loan number is the file's lender loan number, or its universal loan identifier when it has none. The file is read, never kept.

Sending a loan again is safe. While it waits for review, what is held is refreshed. Once the lender has added it, its closing date, loan amount and home address follow onto the file. A loan the lender dismissed stays dismissed. A loan on a file the lender closed is held for the lender's answer while its closing day is still ahead, and answers closed after it.

Fields this page does not list are dropped. SSN, income and assets are never stored.

Request body
{
  "loan_id": "ABC-12345",
  "borrower": {
    "first_name": "Jane", "last_name": "Doe",
    "email": "jane@example.com", "phone": "208-555-0100",
    "date_of_birth": "1985-04-12",
    "marital_status": "Married", "occupation": "Nurse"
  },
  "has_co_borrower": true,
  "co_borrower": { "first_name": "John", "last_name": "Doe",
                   "date_of_birth": "1984-02-03" },
  "title_holder_names": ["Jane Doe", "John Doe"],
  "current_address": { "street": "100 Main St", "city": "Boise",
                       "state": "ID", "zip": "83702" },
  "subject_property": {
    "street": "200 New Home Ave", "city": "Boise",
    "state": "ID", "zip": "83704",
    "occupancy": "primary", "property_type": "home",
    "year_built": 2018, "construction_type": "Frame"
  },
  "loan_amount": 285000, "property_value": 350000,
  "estimated_closing_date": "2027-08-15",
  "loan_officer": { "email": "lo@yourcompany.com" }
}
Or the loan file
curl -X POST https://policyvera.com/api/webhooks/partner-intake \
  -H "Authorization: Bearer pv_test_YOUR_KEY" \
  -H "Content-Type: application/xml" \
  --data-binary @loan-ABC-12345.xml

Every field

The loan
loan_idstringYour loan number. Send it on every call: a repeat is matched on it.
co_borrower.first_name, .last_name, .date_of_birthstringThe co-applicant on the loan. Sending it means has_co_borrower is true.
has_co_borrowerbooleanWhether the loan has a co-applicant.
title_holder_namesstring[]Who will hold title, as the 1003 prints it in L2: one entry per owner, or the whole line. A second owner also needs a date of birth: send that person as co_borrower. Needed for a quote.
title_trust_typeliving | landWhen a trust will hold title.
loan_amountnumber | stringThe loan amount. Needed for a quote.
property_valuenumber | stringThe home's price or value.
estimated_closing_datestringYYYY-MM-DD. Needed for a quote.
insurance_estimate_monthlynumber | stringThe monthly homeowners insurance figure the loan was qualified with. An estimate, never a ceiling.
max_qualifying_premiumnumber | stringThe older name of insurance_estimate_monthly. Still accepted; the newer name wins when both are sent.
loan_has_insurance_roombooleanTrue when the lender states no figure is needed.
loan_officer.name, .email, .nmls_idstring | numberThe working loan officer, matched to one seat at the lender by exact email, then by NMLS number. No match leaves the loan unassigned.
The borrower
borrower.first_namestringThe borrower's first name. A first or a last name is required.
borrower.last_namestringThe borrower's last name.
borrower.emailrequiredstringWhere the borrower's invitation goes.
borrower.phonestringThe borrower's phone. Needed for a quote.
borrower.date_of_birthstringYYYY-MM-DD. Needed for a quote.
borrower.marital_statusstringSingle, Married, Divorced, Widowed, Separated or Unmarried. Needed for a quote.
borrower.occupationstringThe borrower's occupation.
spouse.name, .date_of_birthstringThe borrower's spouse, when the lender holds it.
current_address.street, .city, .state, .zipstringWhere the borrower lives now. Needed for a quote.
previous_address.street, .city, .state, .zipstringThe borrower's address before that one.
The insured home
subject_property.streetrequiredstringThe insured home's street address. Without the home no agent can quote.
subject_property.citystringThe insured home's city.
subject_property.staterequiredstringThe insured home's two-letter state.
subject_property.zipstringThe insured home's ZIP code.
subject_property.occupancyprimary | second_home | investmentHow the borrower will use the home.
subject_property.property_typehome | condoThe kind of home.
subject_property.year_builtnumber | stringThe year the home was built.
subject_property.construction_typestringFor example Frame, Masonry or Manufactured.

Answers

Every answer is JSON with a status. A live key's loan waits for a person at the lender before anything reaches an agent.

Answer 200, a live key
{
  "status": "pending",
  "pending_import_id": "9c1f0e7a-2b4d-4f6a-8a1c-5d3e7b9f0a12",
  "quote_ready": false,
  "missing_for_quote": ["Phone", "Date of birth",
    "Marital status", "Where the borrower lives now",
    "Estimated closing date", "Loan amount",
    "Who will hold title"],
  "state_supported": true
}
200pendingHeld in Needs review until the lender's team gives the OK. The answer lists what a quote still needs. Carries pending_import_id, quote_ready, missing_for_quote and state_supported.
200duplicateThe same loan again while it waits in Needs review. What is held is refreshed. Carries pending_import_id, quote_ready, missing_for_quote and state_supported.
200updated / unchangedThe lender's team already added this loan and its file is open. Its closing date, loan amount and home address follow onto the file; unchanged when nothing differed. Carries referral_id.
200dismissedThe lender's team dismissed this loan, so it is not held again. Restore under Needs review brings it back with the newest values. Carries pending_import_id.
200closedThe lender's team closed this loan's file and the loan's closing day has passed. Nothing is held and the file is not changed. Carries referral_id.
200testA test key: checked exactly like a live call, then held in Test imports. Nothing reaches the lender's pipeline. Carries pending_import_id, quote_ready, missing_for_quote, state_supported, sandbox and message.
201createdOnly for a key PolicyVera has set to add loans without review: the borrower is on the board. Carries referral_id, quote_ready, missing_for_quote and state_supported.
200blocked / not_serviceableOnly on that setting. Nothing was added: the borrower blocked the lender, or no agent serves that state yet and the lender's intake setting skips those.
400errorInvalid JSON, a loan file that cannot be read, a field that fails its check, or no insured home. Nothing is stored. Carries error, issues and missing.
401errorA missing, unknown or disabled key. One answer for all three. Carries error.
413errorA body larger than 4 MB. Nothing is stored. Carries error.
422errorOnly on that setting: the email already belongs to another kind of account. Carries error.
429More than 60 calls in a minute for one lender. The Retry-After header gives the wait in seconds.

Read a loan

GET/api/partner/loans/{loan_id}

Ask at any time where a loan's insurance stands, by the loan number you sent. The answer carries the fields the events carry, read at that moment. No borrower detail is in it.

Events tell your system that something changed. This call is the whole state, for a screen opened days later or an event that never arrived.

Once the binder is delivered the answer carries document_url, a fresh address on every call, and what the binder says.

The accepted_ fields and was_insured name the home, condo or flood insurance on the loan. Before a policy is bound they hold the quote the borrower accepted. Once a policy is bound they hold the bound policy, whether or not a quote was accepted first, and was_insured is true.

policy_annual_premium: The annual premium for the closing disclosure, as the lender's file page prints it: the premium a person confirmed on the policy's record, else the bound quote's yearly total. Filled once coverage is bound and a binder has been asked for. Blank for a package of policies.

accepted_agency: The accepted agent's agency. Blank when the agent has none.

A test key reads only the loans its own test calls hold.

Request
curl https://policyvera.com/api/partner/loans/ABC-12345 \
  -H "Authorization: Bearer pv_live_YOUR_KEY"
Answer 200
{
  "loan_number": "ABC-12345",
  "lender_status": "insured",
  "estimated_closing_date": "2027-08-15",
  "agents_engaged": 3,
  "quotes_received": 2,
  "was_insured": true,
  "accepted_lines": 1,
  "accepted_carrier": "Example Mutual",
  "accepted_coverage": "home",
  "accepted_agent": "Sam Rivera",
  "accepted_agency": "Rivera Insurance Group",
  "accepted_effective_date": "2027-08-15",
  "accepted_premium": "123.50",
  "accepted_premium_interval": "monthly",
  "accepted_total_premium": "1482.00",
  "opportunity_value": 1482,
  "policy_annual_premium": "1482.00",
  "document_url": "https://policyvera.com/api/webhooks/binder-doc/3f6c…",
  "document_filename": "binder.pdf",
  "binder_policy_number": "EM-4471902",
  "binder_effective_date": "2027-08-15",
  "binder_coverage_a": "350000",
  "binder_deductible": "$2,500",
  "binder_mortgagee_clause": "Example Mortgage Co ISAOA/ATIMA, PO Box 100, …",
  "binder_lines_to_check": "coverage_a, deductible",
  "deal_url": "https://policyvera.com/login?next=%2Flender%2Fdashb…"
}
pendingSent, and waiting in the lender's Needs review.
dismissedThe lender's team dismissed it.
needs_insuranceA borrower's insurance request entered the lender's pipeline.
shoppingAgents are actively quoting this borrower.
insuredCoverage is bound. The borrower has insurance.
closedThe insurance request was closed, on the lender's board or by the borrower.
200The loan's state at that moment.
401A missing, unknown or disabled key. One answer for all three.
404A loan number the key's lender does not hold. The same answer for a number nobody holds.
429More than 60 reads in a minute for one lender. The Retry-After header gives the wait in seconds.

Changed loans

GET/api/partner/loans?changed_since={time}

One call lists the lender's loans whose insurance changed after a time you give. It keeps a pipeline current with no event address, and it catches an event that never arrived.

Each row is a loan number, its status and when it last changed. Read the loan for the rest. No borrower detail is in it.

Keep next_since from each answer and send it on the next call. Leave changed_since out to start from the first change on record.

A change is one of the events' own moments, from Nov 13, 2026 on. A loan still waiting in the lender's Needs review, or dismissed there, is not listed: read it by its number.

Under a test key the list holds the test loans you have played.

Request
curl "https://policyvera.com/api/partner/loans?changed_since=2027-08-01T00:00:00Z" \
  -H "Authorization: Bearer pv_live_YOUR_KEY"
Answer 200
{
  "loans": [
    {
      "loan_number": "ABC-12345",
      "lender_status": "insured",
      "changed_at": "2027-08-03T17:42:10.000Z"
    },
    {
      "loan_number": "ABC-12377",
      "lender_status": "shopping",
      "changed_at": "2027-08-03T18:05:44.000Z"
    }
  ],
  "next_since": "2027-08-03T18:05:44.000Z",
  "more": false
}
loansThe loans that changed, oldest change first. Up to 100 in one answer.
loans[].loan_numberThe loan number you sent.
loans[].lender_statusWhere the loan stands now, in the read's own words.
loans[].changed_atWhen the loan's insurance last changed: added, quoted, accepted, bound, binder delivered or closed. UTC.
next_sinceThe time to send on your next call: the last listed loan's changed_at, or the time you sent when nothing is listed.
moreTrue when more loans changed than this answer holds. Call again with next_since.
200The loans that changed after that time, oldest change first. An empty list when none did.
400A changed_since that is not an ISO 8601 time.
401A missing, unknown or disabled key. One answer for all three.
429More than 60 reads in a minute for one lender; a list call counts as a read. The Retry-After header gives the wait in seconds.

Events

PolicyVera POSTs signed JSON to a URL the lender sets under Settings, Integrations, Event notifications. The lender picks its events.

Check the signature

  1. Read the headers X-PolicyVera-Timestamp and X-PolicyVera-Signature.
  2. Join the timestamp, a period and the raw body.
  3. Compute HMAC-SHA256 of that string with the destination's signing secret, as hex.
  4. Compare it with the signature after sha256=.

Retries

A 5xx, a 429, no answer in 10 seconds or a network error is tried again after 1 minute, 5 minutes, 30 minutes and 2 hours. Any other answer outside 2xx is final. After 20 failed attempts in a row the destination pauses and the lender is told.

Duplicates

X-PolicyVera-Delivery stays the same on every retry. Skip an id you have already handled.

Check the signature, Node.js
import crypto from "node:crypto";

function isFromPolicyVera(rawBody, headers, secret) {
  const timestamp = headers["x-policyvera-timestamp"];
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(timestamp + "." + rawBody)
    .digest("hex");
  const got = headers["x-policyvera-signature"] || "";
  return got.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}
Headers
X-PolicyVera-Event: binder.ready
X-PolicyVera-Delivery: 0b6f6c1e-58a4-4c0a-9d0e-7f1a2b3c4d5e
X-PolicyVera-Timestamp: 1817226251
X-PolicyVera-Signature: sha256=5f2c9a…
Body, binder.ready (shortened)
{
  "version": "2026-06-24",
  "event": "binder.ready",
  "event_type": "Insurance binder ready",
  "loan_number": "ABC-12345",
  "lender_status": "insured",
  "estimated_closing_date": "2027-08-15",
  "quotes_received": 2,
  "was_insured": true,
  "accepted_carrier": "Example Mutual",
  "accepted_agent": "Sam Rivera",
  "accepted_agency": "Rivera Insurance Group",
  "accepted_effective_date": "2027-08-15",
  "accepted_total_premium": "1482.00",
  "policy_annual_premium": "1482.00",
  "document_url": "https://policyvera.com/api/webhooks/binder-doc/3f6c…",
  "document_filename": "binder.pdf",
  "binder_policy_number": "EM-4471902",
  "binder_effective_date": "2027-08-15",
  "binder_coverage_a": "350000",
  "binder_deductible": "$2,500",
  "binder_mortgagee_clause": "Example Mortgage Co ISAOA/ATIMA, PO Box 100, …",
  "binder_lines_to_check": "coverage_a, deductible",
  "deal_url": "https://policyvera.com/login?next=%2Flender%2Fdashb…"
}
referral.createdA borrower's insurance request entered the lender's pipeline.
referral.shoppingAgents are actively quoting this borrower.
quote.receivedAn agent submitted a quote for this borrower.
quote.acceptedThe borrower accepted a quote. Binding is next.
quote.unacceptedThe borrower took back their acceptance. The deal is back to open quoting.
referral.insuredCoverage is bound. The borrower has insurance.
binder.readyThe insurance binder (proof of coverage) was delivered.
referral.closedThe insurance request was closed, on the lender's board or by the borrower.

The binder file

binder.ready carries document_url and document_filename. A GET on that address returns the file. It needs no key and works for 30 days, then answers 404.

Fetch the file
curl -L -o binder.pdf "$DOCUMENT_URL"

What the binder says

binder.ready and the read also carry the binder's own lines, so a loan system can fill its insurance fields. Each is PolicyVera's reading of the file, or the agent's confirmation of it. A line that was not read is blank.

binder_lines_to_check names each line to look at: one that may differ from the lender's request, one the lender's own guideline decides, or one that was not read. A blank list is not a pass. The lender's own review of the binder clears the condition.

binder.ready waits for the binder to be read, and leaves at most 1 minute after the delivery.

binder_policy_numberThe policy number as printed.
binder_effective_dateThe day coverage starts, as YYYY-MM-DD.
binder_coverage_aCoverage A, the dwelling limit, in dollars.
binder_deductibleThe deductible as the binder prints it. Often words and more than one figure.
binder_mortgagee_clauseThe mortgagee clause as printed.
binder_lines_to_checkThe lines to look at, joined by commas. Any of: insured, property, mortgagee_clause, coverage_a, deductible, effective_date, policy_number.

Test the whole trip

POST/api/partner/loans/{loan_id}/test-step

A test key's loan can be played forward, one step per call: the lender adds it, agents quote, the borrower accepts, coverage is bound, the binder is delivered.

After each step the read answers the new state under your own loan number. The step's event goes to each of the lender's event destinations that listens for it, marked as a test: the header X-PolicyVera-Event reads test and the body carries "test": true.

To put the loan at a step you choose, send {"step": "quote_accepted"}. Send "held" to start over.

Nothing here reaches a lender's pipeline, an agent or a borrower. The borrower, carrier, agent, premium and binder are sample values.

Request
curl -X POST https://policyvera.com/api/partner/loans/ABC-12345/test-step \
  -H "Authorization: Bearer pv_test_YOUR_KEY"
Answer 200
{
  "status": "test",
  "sandbox": true,
  "loan_number": "ABC-12345",
  "step": "quote_received",
  "event": "quote.received",
  "lender_status": "shopping",
  "events_sent": 1,
  "next_step": "quote_accepted"
}
addedreferral.created. The read answers needs_insurance.
shoppingreferral.shopping. The read answers shopping.
quote_receivedquote.received. quotes_received counts it.
quote_acceptedquote.accepted. The accepted fields are filled.
insuredreferral.insured. The read answers insured.
binder_readybinder.ready. document_url serves a sample binder file, and the binder's lines are filled.
200The step the test loan now stands on.
400A body that is not JSON, or a step this page does not list.
401A missing, unknown or disabled key. One answer for all three.
403A live key. Only a test key's loans can be played.
404A loan number the key's test calls do not hold.
429More than 60 reads and plays in a minute for one lender. The Retry-After header gives the wait in seconds.

Limits and changes

60 loan calls and 60 reads a minute for one lender. Past that the answer is 429 with Retry-After. A request body can be up to 4 MB.

Changes. Fields are added, never renamed or removed. A change that would break a sender ships on a new address. Build to ignore fields you do not know.

Change log. Days are UTC.

  • Nov 13, 2026 · The loan call takes a MISMO 3.4 loan file as its body. A loan on a file the lender closed, sent again after its closing day, answers closed and is not held. One call lists the loans that changed. The read and the events carry policy_annual_premium and accepted_agency. Once a policy is bound, was_insured and the accepted_ fields name the bound policy, whether or not a quote was accepted first.
  • Nov 12, 2026 · This page and the OpenAPI document. Read a loan. The read and binder.ready carry what the binder says; binder.ready waits for the binder to be read and leaves up to 1 minute after the delivery. A test key's loan can be played forward.

The spec. /api/v1/lender/openapi.json, the contract this page is printed from.

Questions. Ask us

Security. Security details

Ask for a sandbox

Building a connection with no lender yet? Tell us what you are connecting. A person at PolicyVera reads it and writes back to your work email to set up a test key.

A question about the API goes here too.

Have a PolicyVera sign-in? Submit a ticket

The loan system or software, and what you want it to do with PolicyVera.