Developers / REST API

REST API reference

The Lead Friendly REST API lets your own software, or a tool such as Zapier, read contacts, calls and appointments from a Lead Friendly account, add contacts to it, and send transactional texts. It is a JSON API over HTTPS. Every request is authenticated with an API key, and a key only ever reaches the one account it was created in.

Looking for the MCP servers for AI assistants instead? They are on the developers page.

Base URL

https://www.leadfriendly.com/api/v1

All paths on this page are relative to that base URL. Requests and responses use JSON. Send Content-Type: application/json on every POST. Times are ISO 8601 timestamps in UTC unless a field says otherwise.

Endpoints

EndpointKey neededWhat it does
GET /mereadCheck a key and see which account it belongs to.
GET /contactsreadList contacts, newest first.
POST /contactswriteCreate a contact, or find the existing one with the same phone or email.
GET /callsreadList completed calls, newest first.
GET /appointmentsreadList appointments, newest first.
POST /messagesmessages:sendSend a transactional text from your account’s number.

These six are the whole API. There are no update or delete endpoints, and no endpoint returns call recordings or transcripts.

Authentication

Send your API key as a bearer token in the Authorization header of every request:

Authorization: Bearer lf_live_…

Getting a key

Sign in to Lead Friendly and go to Settings → API keys. An owner or admin of the account can create a key. The full key is shown once, when it is created, so copy it then. Keys start with lf_live_. An account can have several keys at the same time, so you can give each integration its own.

A key does not expire. It works until someone revokes it in Settings → API keys, and a revoked key stops working on the next request. Treat a key like a password: keep it on your server and never put it in a web page or a mobile app.

Kinds of key

KeyScopesWhat it can call
Read onlyreadThe four GET endpoints.
Read + setupread, configureThe four GET endpoints. On this API it behaves as a read key. The setup scope applies to the MCP server only.
Read + writeread, writeThe four GET endpoints and POST /contacts.
Messaging keymessages:sendPOST /messages only. It cannot read anything. No other kind of key can send through POST /messages.

Read, setup and write keys are created at the top of the API keys page. A messaging key is created in the Messaging API section of the same page.

How the list endpoints work

GET /contacts, GET /calls and GET /appointments behave the same way:

  • They return the most recently created records first, in an object with one field, items.
  • The only query parameter is limit: how many records to return. The default is 25 and the maximum is 100. A value above 100 is treated as 100, and a missing or invalid value as 25.
  • There is no cursor, page number, date filter or sort option. To pick up new records, call the endpoint on a schedule and skip any id you have already seen. This is how Zapier's polling triggers use it.
  • An account with no records returns { "items": [] }.

GET /me

Checks that a key works and tells you which account it belongs to. Use it to test a connection. Needs the read scope. It takes no parameters.

curl https://www.leadfriendly.com/api/v1/me \
  -H "Authorization: Bearer lf_live_…"

Response, 200 OK:

{
  "organization_id": "5b1f0c1e-8a44-4f0e-9d0a-2f6f7f3f6a11",
  "organization_name": "Example Motors",
  "scopes": ["read", "write"]
}

GET /contacts

Lists the account's contacts, newest first. Needs the read scope.

Query parameterTypeDescription
limitinteger1 to 100. Default 25.
curl "https://www.leadfriendly.com/api/v1/contacts?limit=2" \
  -H "Authorization: Bearer lf_live_…"

Response, 200 OK:

{
  "items": [
    {
      "id": "0d0c9a1e-6a2b-4d9c-8a53-2b9d6f1f7c10",
      "first_name": "Dana",
      "last_name": "Whitfield",
      "phone": "+12065550123",
      "email": "dana@example.com",
      "source": "API: Website form",
      "status": "new",
      "created_at": "2026-11-12T17:04:11.512+00:00",
      "name": "Dana Whitfield"
    },
    {
      "id": "9f3b1c77-1e0d-4b0a-b6d2-70c1a4e2d955",
      "first_name": "Luis",
      "last_name": null,
      "phone": "+15095550144",
      "email": null,
      "source": null,
      "status": "new",
      "created_at": "2026-11-11T22:40:03.120+00:00",
      "name": "Luis"
    }
  ]
}

name is the first and last name joined with a space, for convenience. last_name, phone, email and source can be null. Custom fields and notes are not returned by this endpoint.

POST /contacts

Creates a contact. If the account already has a contact with the same phone number (or the same email, when no phone is sent), nothing new is created and the existing contact's id is returned. Needs the write scope.

Creating a contact here only stores it. It does not call, text or email the person, does not add them to a campaign, and does not record their consent to be contacted.

Body fields

FieldTypeDescription
namestringFull name, up to 200 characters. Split at the first space into first and last name.
phonestringA US 10-digit number in any common format, such as (206) 555-0123, or any number in international format starting with +. Stored as +12065550123. A value that is not a phone number is ignored.
emailstringStored in lower case. A value with no @ is ignored.
sourcestringWhere the contact came from, up to 80 characters. Stored with an API: prefix, for example API: Website form. Without it, the source is API.
notesstringFree text from the person, such as their question, up to 4,000 characters. Shown on the contact as their message.
custom_fieldsobjectValues for the account's custom contact fields, as { "field_key": value }. See below.

At least one of name, phone and email is required. Other fields in the body are ignored. If no name is sent, the phone number or email is used as the contact's name. New contacts get the status new.

curl -X POST https://www.leadfriendly.com/api/v1/contacts \
  -H "Authorization: Bearer lf_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Dana Whitfield",
    "phone": "(206) 555-0123",
    "email": "dana@example.com",
    "source": "Website form",
    "notes": "Asked about the 2022 CX-5."
  }'

Responses

A new contact was created, 201 Created:

{ "id": "0d0c9a1e-6a2b-4d9c-8a53-2b9d6f1f7c10", "deduped": false }

The contact already existed, 200 OK:

{ "id": "0d0c9a1e-6a2b-4d9c-8a53-2b9d6f1f7c10", "deduped": true }

The contact already existed and custom_fields were saved to it, 200 OK:

{ "id": "0d0c9a1e-6a2b-4d9c-8a53-2b9d6f1f7c10", "deduped": true, "updated": true }

Duplicates

  • When a valid phone is sent, the match is on the phone number only. It is compared in its normalized form against each contact's phone and mobile number, so (206) 555-0123 matches +12065550123.
  • When no valid phone is sent, the match is on email.
  • A request with only a name always creates a new contact.
  • On a match, the existing contact's name, phone, email, source and notes are left exactly as they are. Only custom_fields, if sent, are saved.
  • Because of this, it is safe to send the same person again. You get the same id back.

Custom fields

custom_fields sets the custom contact fields your account has defined under Contacts → Manage fields. Use each field's key (lower-case letters, digits and underscores), not its label.

  • A value can be text (up to 500 characters), a number, true or false. Send null or "" to clear a field. Up to 50 fields can be set in one request.
  • Each value is checked against its field's type: a number or currency field needs a number, a checkbox needs true or false, a date needs YYYY-MM-DD, a dropdown needs one of its options, and email, URL and phone fields need a value of that kind.
  • On an existing contact, the fields you send are merged in. Fields you do not mention keep their values.
  • A key that is not an active field in the account is refused with 422, and nothing is saved.
curl -X POST https://www.leadfriendly.com/api/v1/contacts \
  -H "Authorization: Bearer lf_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "+12065550123",
    "custom_fields": { "carrier": "Acme Health", "plan_year": 2027 }
  }'

Errors

StatusBodyWhen
400{ "error": "Invalid JSON body" }The body is not valid JSON.
400{ "error": "At least one of name, phone, email is required" }No name was sent, and no valid phone or email.
400{ "error": "custom_fields must be an object of field_key: value pairs." }custom_fields has the wrong shape: not an object, an invalid key, a value that is too long or of the wrong JSON type, or more than 50 fields. The message says which.
422{ "error": "unknown_custom_fields", "message": "…", "unknown": ["carier"] }One or more keys are not active contact fields in the account.
422{ "error": "invalid_custom_field_values", "message": "…", "problems": ["\"plan_year\" must be a number."] }One or more values do not fit their field’s type.

GET /calls

Lists the account's completed calls, newest first. Calls that are still in progress, or that ended in any other state, are not included. Needs the read scope.

Query parameterTypeDescription
limitinteger1 to 100. Default 25.
curl "https://www.leadfriendly.com/api/v1/calls?limit=1" \
  -H "Authorization: Bearer lf_live_…"

Response, 200 OK:

{
  "items": [
    {
      "id": "7c2a4b9e-3f51-4c1d-9a0e-5d8b2e6f4a33",
      "contact_id": "0d0c9a1e-6a2b-4d9c-8a53-2b9d6f1f7c10",
      "direction": "inbound",
      "status": "completed",
      "outcome": "Appointment Booked",
      "duration_seconds": 142,
      "call_summary": "Dana asked about the 2022 CX-5 and booked a test drive for Saturday at 10am.",
      "summary": "Dana asked about the 2022 CX-5 and booked a test drive for Saturday at 10am.",
      "created_at": "2026-11-12T17:10:45.901+00:00"
    }
  ]
}
FieldDescription
contact_idThe contact on the call, or null.
directioninbound or outbound.
statusAlways completed on this endpoint.
outcomeA short label for how the call ended, as free text, or null. Do not rely on a fixed list of values.
duration_secondsLength of the call, as a number of seconds.
call_summaryA written summary of the call, or null.
summaryThe same text as call_summary, under a shorter name.

Recordings and transcripts are not available through this API.

GET /appointments

Lists the account's appointments, most recently booked first. The order is by when the appointment was created, not by when it takes place. Appointments in every status are included. Needs the read scope.

Query parameterTypeDescription
limitinteger1 to 100. Default 25.
curl "https://www.leadfriendly.com/api/v1/appointments?limit=1" \
  -H "Authorization: Bearer lf_live_…"

Response, 200 OK:

{
  "items": [
    {
      "id": "a41e7d02-5b6c-4e88-8f13-0c9d2b7e6f54",
      "contact_id": "0d0c9a1e-6a2b-4d9c-8a53-2b9d6f1f7c10",
      "title": "Test drive: 2022 Mazda CX-5",
      "appointment_date": "2026-11-14",
      "start_time": "10:00:00",
      "end_time": "10:30:00",
      "starts_at": "2026-11-14T10:00:00",
      "status": "scheduled",
      "booked_by": "ai_agent",
      "created_at": "2026-11-12T17:12:30.044+00:00"
    }
  ]
}
FieldDescription
contact_idThe contact the appointment is with, or null.
titleThe appointment’s title, or null.
appointment_dateThe day of the appointment, YYYY-MM-DD.
start_time, end_timeTime of day on a 24-hour clock, in the appointment’s own time zone (not UTC).
starts_atappointment_date and start_time joined into one value. It is in the appointment's own time zone and has no UTC offset. Null when either part is missing.
statusThe appointment's state, for example scheduled.
booked_byHow it was booked: manual, ai_agent, booking_link, api, calendar.google, calendar.outlook or calendar.cal.
created_atWhen the appointment was booked. Can be null on old records, which are listed last.

POST /messages

Sends one transactional text, such as a sign-in code or a booking alert, from a number your account owns. Needs a messaging key (scope messages:send). Each text is billed to the account's wallet at the same rate as any other Lead Friendly text. The recipient is not added to your contacts.

Before the first send, the account's 10DLC brand and campaign must be approved under Settings → Compliance, and an owner or admin chooses the sending number in Settings → API keys → Messaging API. It must be a US local number that can send texts.

HeaderDescription
AuthorizationBearer lf_live_…, a messaging key.
Idempotency-KeyRequired. 1 to 200 characters, unique for each text you intend to send, for example a UUID.
Content-Typeapplication/json
Body fieldTypeDescription
tostringRequired. A US number in E.164 format: +1 and 10 digits. A formatted number such as (206) 555-0123 is refused.
bodystringRequired. 1 to 1,600 characters. A long body is sent and billed as more than one segment.
categorystringRequired. Must be "transactional".
curl -X POST https://www.leadfriendly.com/api/v1/messages \
  -H "Authorization: Bearer lf_live_…" \
  -H "Idempotency-Key: 6f1c2b7e-0a3d-4c1b-9e57-3b1f0a2d4c68" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+12065550123",
    "body": "Your sign-in code is 482913",
    "category": "transactional"
  }'

Response, 202 Accepted:

{ "id": "3b0d6a52-6c3e-4c56-9a59-6b8f1f0c1d7e", "status": "queued" }

queued means the carrier accepted the text. Lead Friendly waits up to 2.5 seconds for the carrier, then answers 503.

Sending the same request twice

  • A request with the same Idempotency-Key from the same account within 24 hours returns the original response and does not send the text again. The replayed response carries the header Idempotent-Replayed: true. Only the key is compared, not the body.
  • The 202 and the 400, 402, 403 and 409 answers are replayed. A 429, 500 or 503 is not stored, so retrying with the same key makes a new attempt.

Errors

Errors from this endpoint carry a code and a message:

{ "error": { "code": "invalid_recipient", "message": "`to` must be a US phone number in E.164 format, e.g. +12065550100." } }
StatusCodeMeaning
400invalid_requestThe Idempotency-Key, the JSON, body or category is missing or invalid.
400invalid_recipientto is not a US number in E.164 format.
401unauthorizedThe key is missing, unknown or revoked.
402insufficient_fundsThe wallet cannot pay for the text.
403forbiddenThe key does not have the messages:send scope.
403not_registeredThe account cannot send registered texts yet: the campaign is not approved, no number qualifies, several qualify and none is chosen, or the account is paused.
409suppressedThe recipient texted STOP to this account and has not texted START since.
409request_in_progressAnother request with the same Idempotency-Key is still running. Retry with the same key.
429rate_limitedToo many requests. See rate limits.
500internal_errorA fault on our side. Retry with the same key.
503provider_unavailableThe carrier refused the text or did not answer in time. Retry with the same key.

Replies

If you save an https:// webhook URL in Settings → API keys → Messaging API, texts sent to the chosen sending number are forwarded to it as a POST:

POST <your webhook URL>
Content-Type: application/json
x-leadfriendly-timestamp: 1761588000
x-leadfriendly-signature: v1=<64 hex characters>

{"id":"9a7c…","from":"+12065550123","to":"+15095550100","body":"STOP","received_at":"2026-10-27T18:00:00.000Z"}
  • The signature is v1= followed by the lower-case hex HMAC-SHA256 of timestamp + "." + rawBody, keyed with the signing secret shown when you first save the URL. Check it over the raw body before you parse the JSON, and refuse a timestamp more than five minutes old.
  • Answer with any 2xx status before 10 seconds have passed. After a failure the delivery is retried after 2 minutes, 10 minutes, 30 minutes and 2 hours, five attempts in all.
  • A text can be delivered more than once. Use id to skip duplicates.
  • Lead Friendly answers STOP, START and HELP on the number itself and records the opt-out. Those texts are still forwarded, so your app can record them too. Do not send your own confirmation.

Errors

The API uses ordinary HTTP status codes. On every endpoint except POST /messages, an error is a JSON object with an error string:

{ "error": "Invalid or missing API key" }

The two 422 answers from POST /contacts add a message and a list. POST /messages uses the { "error": { "code", "message" } } shape shown above.

StatusBodyMeaning
400{ "error": "…" }The request is malformed. The message says what to fix.
401{ "error": "Invalid or missing API key" }No key was sent, or the key is unknown or revoked.
403{ "error": "API key lacks 'write' scope" }The key is valid but is not allowed to call this endpoint. The message names the missing scope.
422{ "error": "unknown_custom_fields", … }A custom_fields key or value was refused.
429{ "error": "Rate limit exceeded" }Too many requests. Wait for the number of seconds in the Retry-After header.
500{ "error": "Query failed" }A fault on our side (also Insert failed or Update failed). Safe to retry.

Rate limits

  • Read and write keys: 60 requests a minute for each key, counted across /me, /contacts, /calls and /appointments together.
  • Messaging keys: 300 sends a minute for each key, and 10 texts to any one recipient per 10-minute window. The account's daily text limit also applies.
  • All requests: 600 requests a minute from one IP address.

A request over a limit gets 429 with a Retry-After header giving the number of seconds to wait. Polling once a minute, as Zapier does, stays well inside these limits.

Zapier

The Lead Friendly app on Zapier is built on this API. You connect it with an API key from Settings → API keys. Use a Read + write key if your Zaps create contacts, or a Read only key if they only use triggers.

In ZapierTypeEndpoint
Connecting your accountConnection testGET /me
New ContactTriggerGET /contacts
Call CompletedTriggerGET /calls
Appointment BookedTriggerGET /appointments
Create ContactActionPOST /contacts

The triggers poll their endpoint and fire once for each new id. Create Contact follows the duplicate rules above, so a Zap that runs twice for the same person does not create a second contact.

Questions?

Email hello@leadfriendly.com. For how the API handles customer data, see our privacy policy.