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
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"}}'{
"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 …"
}- 1Send the loanOne call with the loan's facts, under the lender's key.
- 2The lender gives the OKTheir team asks licensed local agents for quotes. The borrower compares every quote and picks their own agent.
- 3Follow itSigned events as quotes arrive and coverage is bound. Or read a loan, or list the loans that changed, at any time.
- 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
| Key | What a call does |
|---|---|
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.
{
"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" }
}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.xmlEvery field
| Field | Type | Meaning |
|---|---|---|
| The loan | ||
loan_id | string | Your loan number. Send it on every call: a repeat is matched on it. |
co_borrower.first_name, .last_name, .date_of_birth | string | The co-applicant on the loan. Sending it means has_co_borrower is true. |
has_co_borrower | boolean | Whether the loan has a co-applicant. |
title_holder_names | string[] | 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_type | living | land | When a trust will hold title. |
loan_amount | number | string | The loan amount. Needed for a quote. |
property_value | number | string | The home's price or value. |
estimated_closing_date | string | YYYY-MM-DD. Needed for a quote. |
insurance_estimate_monthly | number | string | The monthly homeowners insurance figure the loan was qualified with. An estimate, never a ceiling. |
max_qualifying_premium | number | string | The older name of insurance_estimate_monthly. Still accepted; the newer name wins when both are sent. |
loan_has_insurance_room | boolean | True when the lender states no figure is needed. |
loan_officer.name, .email, .nmls_id | string | number | The 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_name | string | The borrower's first name. A first or a last name is required. |
borrower.last_name | string | The borrower's last name. |
borrower.emailrequired | string | Where the borrower's invitation goes. |
borrower.phone | string | The borrower's phone. Needed for a quote. |
borrower.date_of_birth | string | YYYY-MM-DD. Needed for a quote. |
borrower.marital_status | string | Single, Married, Divorced, Widowed, Separated or Unmarried. Needed for a quote. |
borrower.occupation | string | The borrower's occupation. |
spouse.name, .date_of_birth | string | The borrower's spouse, when the lender holds it. |
current_address.street, .city, .state, .zip | string | Where the borrower lives now. Needed for a quote. |
previous_address.street, .city, .state, .zip | string | The borrower's address before that one. |
| The insured home | ||
subject_property.streetrequired | string | The insured home's street address. Without the home no agent can quote. |
subject_property.city | string | The insured home's city. |
subject_property.staterequired | string | The insured home's two-letter state. |
subject_property.zip | string | The insured home's ZIP code. |
subject_property.occupancy | primary | second_home | investment | How the borrower will use the home. |
subject_property.property_type | home | condo | The kind of home. |
subject_property.year_built | number | string | The year the home was built. |
subject_property.construction_type | string | For 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.
{
"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
}| Code | Status | What happened |
|---|---|---|
| 200 | pending | Held 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. |
| 200 | duplicate | The 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. |
| 200 | updated / unchanged | The 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. |
| 200 | dismissed | The 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. |
| 200 | closed | The 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. |
| 200 | test | A 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. |
| 201 | created | Only 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. |
| 200 | blocked / not_serviceable | Only 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. |
| 400 | error | Invalid 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. |
| 401 | error | A missing, unknown or disabled key. One answer for all three. Carries error. |
| 413 | error | A body larger than 4 MB. Nothing is stored. Carries error. |
| 422 | error | Only on that setting: the email already belongs to another kind of account. Carries error. |
| 429 | | More 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.
curl https://policyvera.com/api/partner/loans/ABC-12345 \
-H "Authorization: Bearer pv_live_YOUR_KEY"{
"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…"
}| lender_status | Meaning |
|---|---|
pending | Sent, and waiting in the lender's Needs review. |
dismissed | The lender's team dismissed it. |
needs_insurance | A borrower's insurance request entered the lender's pipeline. |
shopping | Agents are actively quoting this borrower. |
insured | Coverage is bound. The borrower has insurance. |
closed | The insurance request was closed, on the lender's board or by the borrower. |
| Code | What happened |
|---|---|
| 200 | The loan's state at that moment. |
| 401 | A missing, unknown or disabled key. One answer for all three. |
| 404 | A loan number the key's lender does not hold. The same answer for a number nobody holds. |
| 429 | More 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.
curl "https://policyvera.com/api/partner/loans?changed_since=2027-08-01T00:00:00Z" \
-H "Authorization: Bearer pv_live_YOUR_KEY"{
"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
}| Field | What it holds |
|---|---|
loans | The loans that changed, oldest change first. Up to 100 in one answer. |
loans[].loan_number | The loan number you sent. |
loans[].lender_status | Where the loan stands now, in the read's own words. |
loans[].changed_at | When the loan's insurance last changed: added, quoted, accepted, bound, binder delivered or closed. UTC. |
next_since | The time to send on your next call: the last listed loan's changed_at, or the time you sent when nothing is listed. |
more | True when more loans changed than this answer holds. Call again with next_since. |
| Code | What happened |
|---|---|
| 200 | The loans that changed after that time, oldest change first. An empty list when none did. |
| 400 | A changed_since that is not an ISO 8601 time. |
| 401 | A missing, unknown or disabled key. One answer for all three. |
| 429 | More 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
- Read the headers
X-PolicyVera-TimestampandX-PolicyVera-Signature. - Join the timestamp, a period and the raw body.
- Compute HMAC-SHA256 of that string with the destination's signing secret, as hex.
- 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.
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));
}X-PolicyVera-Event: binder.ready
X-PolicyVera-Delivery: 0b6f6c1e-58a4-4c0a-9d0e-7f1a2b3c4d5e
X-PolicyVera-Timestamp: 1817226251
X-PolicyVera-Signature: sha256=5f2c9a…{
"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…"
}| Event | When |
|---|---|
referral.created | A borrower's insurance request entered the lender's pipeline. |
referral.shopping | Agents are actively quoting this borrower. |
quote.received | An agent submitted a quote for this borrower. |
quote.accepted | The borrower accepted a quote. Binding is next. |
quote.unaccepted | The borrower took back their acceptance. The deal is back to open quoting. |
referral.insured | Coverage is bound. The borrower has insurance. |
binder.ready | The insurance binder (proof of coverage) was delivered. |
referral.closed | The 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.
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.
| Field | What it holds |
|---|---|
binder_policy_number | The policy number as printed. |
binder_effective_date | The day coverage starts, as YYYY-MM-DD. |
binder_coverage_a | Coverage A, the dwelling limit, in dollars. |
binder_deductible | The deductible as the binder prints it. Often words and more than one figure. |
binder_mortgagee_clause | The mortgagee clause as printed. |
binder_lines_to_check | The 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.
curl -X POST https://policyvera.com/api/partner/loans/ABC-12345/test-step \
-H "Authorization: Bearer pv_test_YOUR_KEY"{
"status": "test",
"sandbox": true,
"loan_number": "ABC-12345",
"step": "quote_received",
"event": "quote.received",
"lender_status": "shopping",
"events_sent": 1,
"next_step": "quote_accepted"
}| Step | What your system gets |
|---|---|
added | referral.created. The read answers needs_insurance. |
shopping | referral.shopping. The read answers shopping. |
quote_received | quote.received. quotes_received counts it. |
quote_accepted | quote.accepted. The accepted fields are filled. |
insured | referral.insured. The read answers insured. |
binder_ready | binder.ready. document_url serves a sample binder file, and the binder's lines are filled. |
| Code | What happened |
|---|---|
| 200 | The step the test loan now stands on. |
| 400 | A body that is not JSON, or a step this page does not list. |
| 401 | A missing, unknown or disabled key. One answer for all three. |
| 403 | A live key. Only a test key's loans can be played. |
| 404 | A loan number the key's test calls do not hold. |
| 429 | More 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