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:

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/…

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."
    }
  }
}
StatusCodeWhat happened
400bad_jsonThe body isn't a JSON object.
400key_in_urlThe key was in the URL. Make a new key and revoke that one.
401no_key, bad_keyNo key, or one LeadVault doesn't know (or revoked).
403posting_keyThe call needs a full key.
404not_found, no_such_callNo lead or appointment with that id in the account, or no such call.
413too_largeOver 64 KB.
415content_typeNot JSON or a form post.
422invalidSomething sent can't be used; fields says what.
429slow_down, too_many_bad_keysOver a limit. Wait as long as Retry-After says.
500server_errorLeadVault's fault. Nothing was saved; try again.

Limits

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" } }
}

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"
}

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.

phonestringEither of the lead's numbers, in any format.
emailstringThe lead's email.
reference_idstringThe ID the sender gave the lead (reference_id).
typestringA lead type's key, like mortgage.
stagestringA stage's id or name.
sourcestringA source's name.
statusstringOne of: active, flagged, duplicate, do_not_contact, returned, merged.
received_afterdate-timeLeads that arrived after this time, like 2026-10-01T00:00:00Z.
updated_afterdate-timeLeads changed after this time.
after_idintegerThe next_after_id of the page before.
limitintegerLeads a page, up to 100. Default 50.

GET/leads/{id}Get a leadFull key

One lead, with its consent record.

idintegerThe 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.

idintegerThe 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.

idintegerThe lead's id.
stagestringThe 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.

idintegerThe lead's id.
userstring or nullAn 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.

idintegerThe lead's id.
kindstringOne of: note, call, email. Default note.
textstringUp to 4,000 characters. Needed unless a call sends an outcome.
outcomestringCalls 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.

idintegerThe lead's id.
due_atdate-timeWith its offset or Z, like 2026-10-30T14:00:00-07:00.
notestringUp to 500 characters.
userstringAn 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.

idintegerThe 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.

idintegerThe lead's id.
starts_atdate-timeWith its offset or Z, like 2026-10-30T14:00:00-07:00.
minutesintegerHow long. Leave out for the calendar's usual length.
howstringOne of: phone, video, in_person.
placestringA video link or an address.
notestring
userstringWhose 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.

fromdate-timeThe start, with its offset or Z.
todate-timeThe end, at most 62 days after from.
userstringOne 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.

idintegerThe 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).

statusstringrequested: waiting on the vendor's answer. One of: requested, credited, refused. Default requested.
after_idintegerOnly returns after this id.
limitintegerDefault 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.

idintegerThe return's id.
statusstringOne of: credited, refused.
creditnumberCredited only: the amount, when less than the lead cost. Leave it out to credit the whole cost.
notestringShown to the account with the answer, up to 500 characters.
{
  "status": "refused",
  "note": "We reached them on 11/2. Valid lead."
}