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/v1All 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
| Endpoint | Key needed | What it does |
|---|---|---|
GET /me | read | Check a key and see which account it belongs to. |
GET /contacts | read | List contacts, newest first. |
POST /contacts | write | Create a contact, or find the existing one with the same phone or email. |
GET /calls | read | List completed calls, newest first. |
GET /appointments | read | List appointments, newest first. |
POST /messages | messages:send | Send 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
| Key | Scopes | What it can call |
|---|---|---|
| Read only | read | The four GET endpoints. |
| Read + setup | read, configure | The four GET endpoints. On this API it behaves as a read key. The setup scope applies to the MCP server only. |
| Read + write | read, write | The four GET endpoints and POST /contacts. |
| Messaging key | messages:send | POST /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
idyou 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 parameter | Type | Description |
|---|---|---|
limit | integer | 1 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
| Field | Type | Description |
|---|---|---|
name | string | Full name, up to 200 characters. Split at the first space into first and last name. |
phone | string | A 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. |
email | string | Stored in lower case. A value with no @ is ignored. |
source | string | Where the contact came from, up to 80 characters. Stored with an API: prefix, for example API: Website form. Without it, the source is API. |
notes | string | Free text from the person, such as their question, up to 4,000 characters. Shown on the contact as their message. |
custom_fields | object | Values 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
phoneis 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-0123matches+12065550123. - When no valid
phoneis sent, the match is onemail. - 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
idback.
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,
trueorfalse. Sendnullor""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
| Status | Body | When |
|---|---|---|
| 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 parameter | Type | Description |
|---|---|---|
limit | integer | 1 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"
}
]
}| Field | Description |
|---|---|
contact_id | The contact on the call, or null. |
direction | inbound or outbound. |
status | Always completed on this endpoint. |
outcome | A short label for how the call ended, as free text, or null. Do not rely on a fixed list of values. |
duration_seconds | Length of the call, as a number of seconds. |
call_summary | A written summary of the call, or null. |
summary | The 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 parameter | Type | Description |
|---|---|---|
limit | integer | 1 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"
}
]
}| Field | Description |
|---|---|
contact_id | The contact the appointment is with, or null. |
title | The appointment’s title, or null. |
appointment_date | The day of the appointment, YYYY-MM-DD. |
start_time, end_time | Time of day on a 24-hour clock, in the appointment’s own time zone (not UTC). |
starts_at | appointment_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. |
status | The appointment's state, for example scheduled. |
booked_by | How it was booked: manual, ai_agent, booking_link, api, calendar.google, calendar.outlook or calendar.cal. |
created_at | When 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.
| Header | Description |
|---|---|
Authorization | Bearer lf_live_…, a messaging key. |
Idempotency-Key | Required. 1 to 200 characters, unique for each text you intend to send, for example a UUID. |
Content-Type | application/json |
| Body field | Type | Description |
|---|---|---|
to | string | Required. A US number in E.164 format: +1 and 10 digits. A formatted number such as (206) 555-0123 is refused. |
body | string | Required. 1 to 1,600 characters. A long body is sent and billed as more than one segment. |
category | string | Required. 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-Keyfrom the same account within 24 hours returns the original response and does not send the text again. The replayed response carries the headerIdempotent-Replayed: true. Only the key is compared, not the body. - The
202and the400,402,403and409answers are replayed. A429,500or503is 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." } }| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | The Idempotency-Key, the JSON, body or category is missing or invalid. |
| 400 | invalid_recipient | to is not a US number in E.164 format. |
| 401 | unauthorized | The key is missing, unknown or revoked. |
| 402 | insufficient_funds | The wallet cannot pay for the text. |
| 403 | forbidden | The key does not have the messages:send scope. |
| 403 | not_registered | The 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. |
| 409 | suppressed | The recipient texted STOP to this account and has not texted START since. |
| 409 | request_in_progress | Another request with the same Idempotency-Key is still running. Retry with the same key. |
| 429 | rate_limited | Too many requests. See rate limits. |
| 500 | internal_error | A fault on our side. Retry with the same key. |
| 503 | provider_unavailable | The 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 oftimestamp + "." + 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
idto 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.
| Status | Body | Meaning |
|---|---|---|
| 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,/callsand/appointmentstogether. - 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 Zapier | Type | Endpoint |
|---|---|---|
| Connecting your account | Connection test | GET /me |
| New Contact | Trigger | GET /contacts |
| Call Completed | Trigger | GET /calls |
| Appointment Booked | Trigger | GET /appointments |
| Create Contact | Action | POST /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.