LeadVault API
Send leads to LeadVault from any system, and read and work them from your own.
Keys
An account admin makes keys in LeadVault, under API keys. There are two kinds:
- Posting key (
lv_post_…): belongs to one lead source, like a vendor or your website, and can only add leads to it, and answer the returns of the leads it sold. It can't read leads. Give each vendor their own, and revoke one to stop that vendor alone. - Full key (
lv_full_…): for your own systems, like Zapier or reports. It reads and changes every lead in the account, so keep it on your servers.
Send the key in a header with every call. X-Api-Key: lv_… works too, and a vendor whose system can only post form fields may send it as a field named api_key. Never put a key in a URL: LeadVault refuses those calls, because URLs end up in logs.
Authorization: Bearer lv_post_…
Adding a lead
Post JSON to https://leadvault.com/api/v1/leads:
curl https://leadvault.com/api/v1/leads \
-H "Authorization: Bearer lv_post_…" \
-H "Content-Type: application/json" \
-d '{
"type": "mortgage",
"first_name": "Ana", "last_name": "Rivera",
"email": "ana.rivera@example.com", "phone": "(310) 555-0123",
"state": "CA", "zip": "90025",
"answers": { "loan_purpose": "refinance", "loan_amount": "$350,000", "credit_rating": "good" },
"consent": { "text": "By clicking Get my quote, I agree…", "checked": true,
"at": "2026-10-29T14:05:00Z", "certificate_id": "https://cert.trustedform.com/…" },
"ip": "203.0.113.7", "page_url": "https://example.com/refinance", "utm_source": "google"
}'
Or send the same names as a plain form post, the way many lead systems do:
curl https://leadvault.com/api/v1/leads \ -d api_key=lv_post_… -d type=mortgage -d first_name=Ana -d phone=3105550123 \ -d loan_purpose=refinance -d loan_amount=350000 \ -d consent_text="By clicking…" -d consent_checked=yes \ -d trustedform_cert_url=https://cert.trustedform.com/…
type: the lead type, likemortgage,debtorinsurance. Needed when the account has more than one.- Contact details:
first_name,last_name,email,phone,phone_2,address,city,state,zip,best_time_to_call. A phone or an email is needed. Any 10-digit US phone format is fine, and a number from another country starts with + and its country code (+52 55 1234 5678); states as two letters or the full name. A whole name in one field can come asfull_name(orname): "Ana Maria Rivera" becomes Ana Maria, Rivera. answers: by the lead type's field keys, which GET /lead-types lists (a posting key may call it). They may be at the top level too. "$350,000", "350k", yes or no, a choice's label or value, and dates as 2026-06-15 or 6/15/2026 all work.consent: the wording the consumer saw (text),checked,at, and a TrustedForm or Jornayacertificate_id. On a form post:consent_text,consent_checked,consent_at,trustedform_cert_url. It's kept with the lead and can't be changed.ip,user_agent,page_url: the consumer's, from the form. Campaign tags:utm_source,utm_medium,utm_campaign,utm_term,utm_content.reference_id: your own ID for the lead, kept with it, so you can find it again with GET /leads?reference_id=.submitted_at: when the person filled in your form, if that was before you sent it.- With a full key only:
source(by name),assigned_to(an email or user id) andstage(by name or id) to start in. Withoutassigned_to, the account's lead distribution rules give the lead to someone (or leave it unassigned).
The answer, with status 201:
{ "id": 612345, "status": "active", "consent": "recorded" }
status is active, duplicate (the same phone or email asked for the same type recently) or do_not_contact (on the account's do-not-contact list). Either way the lead is kept and marked. ignored lists any names LeadVault didn't use, so you can fix your mapping. A lead with a problem isn't added: the answer names each field and what's wrong.
Errors
Every answer is JSON, errors too:
{
"error": {
"code": "invalid",
"message": "Some of what was sent can't be used; see fields.",
"fields": {
"phone": "Send a 10-digit US phone number, or another country's number starting with + and its country code.",
"answers.loan_amount": "Send a number, like 350000."
}
}
}
| Status | Code | What happened |
|---|---|---|
| 400 | bad_json | The body isn't a JSON object. |
| 400 | key_in_url | The key was in the URL. Make a new key and revoke that one. |
| 401 | no_key, bad_key | No key, or one LeadVault doesn't know (or revoked). |
| 403 | posting_key | The call needs a full key. |
| 404 | not_found, no_such_call | No lead or appointment with that id in the account, or no such call. |
| 413 | too_large | Over 64 KB. |
| 415 | content_type | Not JSON or a form post. |
| 422 | invalid | Something sent can't be used; fields says what. |
| 429 | slow_down, too_many_bad_keys | Over a limit. Wait as long as Retry-After says. |
| 500 | server_error | LeadVault's fault. Nothing was saved; try again. |
Limits
- 120 calls a minute for each key.
- 64 KB a call.
- After 20 calls with a wrong key in 10 minutes, the address they came from waits 10 minutes.
Webhooks
LeadVault can post to your systems when something happens, instead of them asking. An account admin adds a webhook in LeadVault under API, Webhooks: an https address (a Zapier “Catch Hook” address works) and what to send: new leads, stage changes, appointments booked, appointments cancelled, or any mix. Within seconds of the change, LeadVault posts JSON like this:
{
"id": 81234,
"event": "lead.stage_changed",
"created_at": "2026-10-29T15:04:05Z",
"account": "Acme Mortgage",
"lead": { "id": 612345, "type": "mortgage", "first_name": "Ana", … },
"stage_change": { "from": { "id": 11, "name": "New" }, "to": { "id": 12, "name": "Contacted" } }
}
eventislead.created(every new lead except those on the do-not-contact list),lead.stage_changed,appointment.booked,appointment.cancelled, orpingfrom the Send test button, which has no lead.- An appointment post also has
appointment, as GET /leads/{id}/appointments shows it. It's sent however the appointment was booked or cancelled: in LeadVault, by the lead on a booking link, or through the API. Moving a booking cancels the old appointment and books a new one, so it sends both. leadis the lead as GET /leads/{id} shows it, without the consent record, as it is when the post goes out.- Headers:
LeadVault-Event,LeadVault-Delivery(the same on every try, so a repeat can be spotted) andLeadVault-Signature. - Answer with any 2xx within 10 seconds. Otherwise LeadVault tries again after 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours and 24 hours. Redirects aren't followed. After 20 posts in a row fail, the webhook is turned off and the Webhooks page says why.
- The address has to be https on the usual port, on the internet.
To know a post came from LeadVault, check its signature. LeadVault-Signature is t=<unix time>,v1=<hex>, where v1 is the HMAC-SHA256 of the time, a dot and the raw body, keyed with the webhook's secret (whsec_…, shown once when the webhook is added). In Node:
const crypto = require('crypto');
// rawBody: the body exactly as it arrived, before parsing it.
function fromLeadVault(rawBody, signature, secret) {
const { t, v1 } = Object.fromEntries(signature.split(',').map(p => p.split('=')));
const want = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return v1?.length === want.length &&
crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(want)) &&
Math.abs(Date.now() / 1000 - Number(t)) < 300; // and not an old post sent again
}
Returns
For lead vendors. An account gives back a lead it bought from you, with a reason, while you take bad leads back. Its returns reach you the way it sets up for you: a spreadsheet it sends, a daily email with the spreadsheet, your LeadByte, or your own system. For your own system, LeadVault posts each return to the address you give the account, as it's made:
{
"event": "lead.returned",
"account": "Acme Mortgage",
"return": {
"id": 5521, "lead_id": 612345, "your_lead_id": "LT-88231", "source": "Your company",
"first_name": "Ana", "last_name": "Rivera", "phone": "+13105550123", "email": "ana.rivera@example.com",
"received_at": "2026-11-02T16:20:00Z", "cost": 40,
"reason": "wrong_number", "reason_text": "Wrong or disconnected number", "note": "Number not in service",
"returned_at": "2026-11-03T15:02:11Z", "status": "requested"
},
"answer_url": "https://leadvault.com/api/v1/returns/5521"
}
your_lead_idis thereference_idyou sent with the lead.reasonisduplicate,wrong_number,fake,did_not_ask,out_of_areaorother(with anotesaying why).- Headers:
LeadVault-Event(lead.returned),LeadVault-Return(the return's id, the same on every try) andLeadVault-Signature, signed with the secret the account gives you (rtsec_…), checked as for webhooks. - Answer with any 2xx within 10 seconds. Otherwise LeadVault tries again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours, then the account sends it another way.
- To settle it at once, answer with
{"status": "credited", "credit": 25, "note": "…"}(creditonly when less than the cost) or{"status": "refused", "note": "…"}. Anything else just means it arrived.
To answer later, or however the return reached you, use the posting key the account gave you for your leads. GET /returns lists the ones waiting on you, and POST /returns/{id} answers one:
curl https://leadvault.com/api/v1/returns/5521 \
-H "Authorization: Bearer lv_post_…" \
-H "Content-Type: application/json" \
-d '{ "status": "refused", "note": "We reached them on 11/2. Valid lead." }'
A refused lead goes back on the account's lists, and your note shows with it. A return is settled once: answering again changes nothing.
Postman and other tools
The OpenAPI file is at https://leadvault.com/api/v1/openapi.json. In Postman, choose Import, paste that link, then set the collection's authorization to Bearer token with your key. Insomnia, Swagger Editor and most other API tools take the same link.
All calls
Paths are under https://leadvault.com/api/v1.
POST/leadsAdd a leadPosting or full key
Adds one lead to the key's source (a full key may name a source). Send JSON or a plain form post. A lead whose phone or email is on the account's do-not-contact list is kept with status do_not_contact; one that repeats a recent lead of the same type is kept with status duplicate. Names LeadVault doesn't use are listed in ignored, so a vendor can fix their mapping.
What to send and what comes back: Adding a lead, above.
GET/leadsFind leadsFull key
Leads oldest first (by id). To read them all, or just what's new, pass after_id the next_after_id of the last page; it's null on the last page. Filters combine.
phone | string | Either of the lead's numbers, in any format. |
email | string | The lead's email. |
reference_id | string | The ID the sender gave the lead (reference_id). |
type | string | A lead type's key, like mortgage. |
stage | string | A stage's id or name. |
source | string | A source's name. |
status | string | One of: active, flagged, duplicate, do_not_contact, returned, merged. |
received_after | date-time | Leads that arrived after this time, like 2026-10-01T00:00:00Z. |
updated_after | date-time | Leads changed after this time. |
after_id | integer | The next_after_id of the page before. |
limit | integer | Leads a page, up to 100. Default 50. |
GET/leads/{id}Get a leadFull key
One lead, with its consent record.
id | integer | The lead's id. |
PATCH/leads/{id}Change a leadFull key
Changes what's sent, in one call: contact details, answers (one sent as null is cleared), your reference ID, the source, the stage and who holds the lead. Anything not sent stays as it is, and nothing changes unless all of it can. The lead's type, consent and submitted_at can't be changed.
id | integer | The lead's id. |
{
"phone": "310-555-0199",
"answers": {
"loan_amount": 400000
},
"source": "Live chat",
"stage": "Contacted",
"assigned_to": "joe@example.com"
}
POST/leads/{id}/stageMove a lead to a stageFull key
GET /lead-types lists each type's stages.
id | integer | The lead's id. |
stage | string | The stage's id or name. |
{
"stage": "Contacted"
}
POST/leads/{id}/assignAssign a leadFull key
Gives the lead to someone in the account, or to nobody. Its open callbacks go with it.
id | integer | The lead's id. |
user | string or null | An email or user id; null to unassign. |
{
"user": "marcus@example.com"
}
POST/leads/{id}/notesAdd a note, or log a call or emailFull key
Goes on the lead's timeline, under the key's name. A call can say how it went (outcome); a call with an outcome needs no text.
id | integer | The lead's id. |
kind | string | One of: note, call, email. Default note. |
text | string | Up to 4,000 characters. Needed unless a call sends an outcome. |
outcome | string | Calls only: how it went. no_answer, voicemail (left one), talked (successful contact), transferred, appointment (one was scheduled), needs_info, not_ready (qualified but not ready), considering (considering options), wrong_number, not_in_service, not_eligible, dnc (asked not to be contacted: puts the lead's phone and email on the do-not-contact list and moves it to Do not contact). One of: no_answer, voicemail, talked, transferred, appointment, needs_info, not_ready, considering, wrong_number, not_in_service, not_eligible, dnc. |
{
"kind": "call",
"outcome": "voicemail",
"text": "Asked them to call back after 5."
}
POST/leads/{id}/callbacksSet a callbackFull key
A reminder to call the lead back, for the person named or whoever holds the lead.
id | integer | The lead's id. |
due_at | date-time | With its offset or Z, like 2026-10-30T14:00:00-07:00. |
note | string | Up to 500 characters. |
user | string | An email or user id. Leave out for whoever holds the lead. |
{
"due_at": "2026-10-30T14:00:00-07:00",
"note": "Wants rates after 2pm"
}
GET/leads/{id}/appointmentsA lead's appointmentsFull key
Everything booked with the lead, cancelled appointments too, soonest first.
id | integer | The lead's id. |
POST/leads/{id}/appointmentsBook an appointmentFull key
Books a time with the lead on someone's calendar: the person named, or whoever holds the lead. They have to see the lead (an agent sees only their own). One person's appointments never overlap, so a time that overlaps is refused. The length, how and place default to that person's booking settings. Webhooks for appointment.booked go out.
id | integer | The lead's id. |
starts_at | date-time | With its offset or Z, like 2026-10-30T14:00:00-07:00. |
minutes | integer | How long. Leave out for the calendar's usual length. |
how | string | One of: phone, video, in_person. |
place | string | A video link or an address. |
note | string | |
user | string | Whose calendar: an email or user id. Leave out for whoever holds the lead. |
{
"starts_at": "2026-10-30T14:00:00-07:00",
"minutes": 30,
"how": "video",
"place": "https://meet.example.com/ana",
"note": "Go over the rate quote"
}
GET/appointmentsAppointments in a time rangeFull key
Everyone's appointments (or one person's) starting in the range, cancelled ones too, so another calendar can keep in step. At most 62 days and 1,000 appointments a call.
from | date-time | The start, with its offset or Z. |
to | date-time | The end, at most 62 days after from. |
user | string | One person's calendar: an email or user id. |
POST/appointments/{id}/cancelCancel an appointmentFull key
Cancels it and notes it on the lead's timeline. An appointment is never deleted. Calling this again for one already cancelled changes nothing. Webhooks for appointment.cancelled go out.
id | integer | The appointment's id. |
GET/lead-typesLead types, their fields and stagesPosting or full key
What a lead of each type can carry: its fields (the keys to send in answers, their kinds and choices) and its stages. Posting keys may call it too, to map their fields.
GET/sourcesSourcesFull key
Where the account's leads come from.
GET/usersPeopleFull key
Everyone active in the account, to assign leads and callbacks to.
GET/meCheck a keyPosting or full key
Which account and key this is. The first call to try with a new key.
GET/returnsReturnsPosting or full key
Leads the account returned, oldest first. With a posting key, only the ones it sold (its source's); with a full key, every source's. Page through with after_id: next_after_id is the last id sent, or null at the end. A vendor's system can call this every few minutes, or take each return as LeadVault posts it (lead.returned).
status | string | requested: waiting on the vendor's answer. One of: requested, credited, refused. Default requested. |
after_id | integer | Only returns after this id. |
limit | integer | Default 50. |
POST/returns/{id}Answer a returnPosting or full key
The vendor's answer: credited (by the lead's cost, unless credit says less) or refused. A refused lead goes back on the account's lists, as it was paid for. A return is settled once: answering again changes nothing, and the answer shows how it was settled. With a posting key, only its own source's returns.
id | integer | The return's id. |
status | string | One of: credited, refused. |
credit | number | Credited only: the amount, when less than the lead cost. Leave it out to credit the whole cost. |
note | string | Shown to the account with the answer, up to 500 characters. |
{
"status": "refused",
"note": "We reached them on 11/2. Valid lead."
}