Contacts and groups
Save the people who fill in your site's forms as contacts, look a contact up by email or phone, add them to groups with extra details, and read a group back onto your site.
Use these endpoints when a form on your own website should end up in your Lahuta CRM: a contact form, a wholesale inquiry, a class waitlist, a job application. The person becomes a contact, and anything else the form asked can go on a note or into a group's columns.
| You want to | Use |
|---|---|
| Save or update one person or company | PUT /v1/contacts |
| Find the contacts with an email address or phone number | GET /v1/contacts?email=... or GET /v1/contacts?phone=... |
| Save form answers as a row in a group, creating the group and its columns if needed | PUT /v1/groups |
| Show a group on your website, like a team or instructor directory | GET /v1/groups/{group}/rows |
To list, edit or archive contacts, write notes and reminders, or take people out of a group from your own systems, see Keep another system in sync.
Save a contact
PUT /v1/contacts creates a contact, or updates the one that already matches. Send a kind, either person or company, and a name: firstName for a person, name for a company. Everything else is optional.
Here Northside Pottery's wholesale inquiry form saves a shop buyer and leaves a note with what she asked for:
curl -X PUT https://api.lahuta.org/v1/contacts \
-H "X-Api-Key: $LAHUTA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"kind": "person",
"firstName": "Maya",
"lastName": "Chen",
"jobTitle": "Buyer",
"emails": [{ "label": "Work", "value": "maya@fernandfig.com" }],
"phones": [{ "label": null, "value": "+15035550148" }],
"source": "Wholesale form",
"note": {
"key": "wholesale-form:8421",
"body": "Shop: Fern & Fig, Portland\nInterested in speckled mugs, about 48 a quarter."
}
}'{
"contact": {
"kind": "person",
"id": "0197b2e4-6a1c-7f02-8d3e-5b9a1c4f2e61",
"firstName": "Maya",
"lastName": "Chen",
"jobTitle": "Buyer",
"birthDate": null,
"emails": [{ "label": "Work", "value": "maya@fernandfig.com" }],
"phones": [{ "label": null, "value": "+15035550148" }],
"urls": [],
"addresses": [],
"description": null,
"linkedin": null,
"image": null,
"source": "Wholesale form",
"createdAt": "2026-09-25T16:04:11.482Z",
"updatedAt": "2026-09-25T16:04:11.482Z"
},
"matchedExisting": false,
"noteId": "0197b2e4-6a2d-7c19-9e40-3f7b8d2a1c05"
}Every contact the API returns, here and from a lookup, has these fields. A company has name in place of firstName, lastName, jobTitle and birthDate. What only your team uses in the dashboard, like a contact's favorite star, isn't included.
How matching works
- A contact matches an existing contact of the same kind that shares any email address. Only when you send no email does a shared phone number match, since a household can share one phone. A contact without either always creates a new contact.
- On a match,
matchedExistingistrue. New emails, phones, links and addresses are added. Empty fields likelastNameorjobTitleare filled in. Fields that already have a value are never overwritten, so a typo on a form can't rename a customer. - A match with an archived contact brings them back.
emails,phones,urlsandaddressesare lists of objects. Each one needs alabel, which can benull. Phone numbers must be E.164, like+15035550148. An address needs aformattedAddress, and its other fields, fromaddressLine1togoogleMapsURL, must all be present, set tonullwhen you don't know them.
Notes
note.body is plain text, up to 20,000 characters. Blank lines become paragraphs. The note appears on the contact marked via API instead of an author, because an API key isn't a person. Anyone on your team who can edit contacts can delete it.
Give the note a key so a form that's submitted twice writes one note. Without a key, every call adds another note. noteId is null when you don't send a note. See Idempotency.
Look up a contact
GET /v1/contacts finds the contacts that have an email address or a phone number. Use it when your site needs to know whether someone is already in your CRM, or needs a contact's id, for example to start them in a workflow.
Send either email or phone, not both. A phone number must be E.164, like +15035550148. URL-encode the value, so the + becomes %2B.
curl "https://api.lahuta.org/v1/contacts?phone=%2B15035550148" \
-H "X-Api-Key: $LAHUTA_API_KEY"{
"items": [
{
"kind": "person",
"id": "0197b2e4-6a1c-7f02-8d3e-5b9a1c4f2e61",
"firstName": "Maya",
"lastName": "Chen",
"jobTitle": "Buyer",
"birthDate": null,
"emails": [{ "label": "Work", "value": "maya@fernandfig.com" }],
"phones": [{ "label": null, "value": "+15035550148" }],
"urls": [],
"addresses": [],
"description": null,
"linkedin": null,
"image": null,
"source": "Wholesale form",
"createdAt": "2026-09-25T16:04:11.482Z",
"updatedAt": "2026-09-25T16:04:11.482Z"
}
]
}- It matches on any of a contact's email addresses or phone numbers, and returns people and companies alike. Capital letters in an email don't matter.
- When nobody matches,
itemsis empty. You never get a404. - When your CRM has duplicates, you get more than one contact, oldest first. You can merge duplicates in the dashboard.
- Archived contacts are left out.
- Sending both
emailandphoneis refused with a400. Sending neither lists your contacts a page at a time instead. See List your contacts. - The response also has
total,limitandoffset, which for a lookup are the number of matches, the number of matches and0.
Add rows to a group
Groups hold people or companies with extra columns, like a spreadsheet next to your contacts. PUT /v1/groups writes rows into a group by name. If the group doesn't exist, it's created. If a column doesn't exist, it's created with the type you send. You never have to set anything up in the dashboard first.
Here the "Wheel class waitlist" form adds Priya with her preferred day and experience:
curl -X PUT https://api.lahuta.org/v1/groups \
-H "X-Api-Key: $LAHUTA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Wheel class waitlist",
"type": "person",
"rows": [
{
"contact": {
"kind": "person",
"firstName": "Priya",
"lastName": "Nair",
"emails": [{ "label": null, "value": "priya.nair@example.com" }]
},
"cols": [
{ "name": "Preferred day", "type": "select", "value": "Saturday" },
{ "name": "Experience", "type": "select", "value": "Never tried" },
{ "name": "Joined waitlist", "type": "date", "value": "2026-09-25" }
]
}
]
}'{
"group": {
"id": "0197b2e5-0c4a-7d11-a2f3-6e8b9c1d0a47",
"name": "Wheel class waitlist",
"type": "person",
"created": true
},
"columns": [
{
"id": "0197b2e5-0c52-7a08-b1d4-2c7e5f9a3b16",
"name": "Preferred day",
"type": "select",
"created": true
},
{
"id": "0197b2e5-0c53-7e2b-8f61-9d0a4b7c2e38",
"name": "Experience",
"type": "select",
"created": true
},
{
"id": "0197b2e5-0c54-73c9-a0e5-1b6d8f2a4c90",
"name": "Joined waitlist",
"type": "date",
"created": true
}
],
"rows": [
{
"id": "0197b2e5-0c61-7b3f-9a2e-4d8c1f6b0e75",
"contactId": "0197b2e5-0c5e-7f40-8b19-3a6d2e9c5f01",
"created": true
}
]
}Your team sees the group in the dashboard under CRM, with the new columns. To open it directly, use lahuta.org/dash/<your-org>/crm/groups/<group id>.
Group types
type | Each row carries | Sending the same row again |
|---|---|---|
person | A person contact, saved the same way as PUT /v1/contacts | Updates that person's row with the latest values |
company | A company contact | Updates that company's row |
general | Only cells, no contact | Adds another row, unless the row has a key |
General groups don't appear in the dashboard's CRM sidebar. If your team should see the rows, use a person or company group.
A group is found by its name and type. The name matches ignoring capital letters, spaces and punctuation, so "Wholesale Leads" and "wholesale-leads" are the same group. To write to a specific group, send its id as well. The type must then match the group's type, or the call fails with GroupTypeMismatch.
Column types
Each cell names its column, a type and a value. Column names match ignoring upper and lower case.
type | value |
|---|---|
text, url, email | A string |
number | A number, or a decimal string like "12.50" |
date | "YYYY-MM-DD" |
select | One option as a string. A new option is added to the column |
multiSelect | An array of option strings |
phone | An E.164 phone number |
address | An address object, the same shape as a contact's address, with every field present |
file | Up to 10 assetIds from POST /v1/uploads |
A column's type is fixed when it's created. Sending a different type for an existing column fails with ColumnTypeMismatch. You can send up to 100 rows per call and 50 cells per row. If anything in the call is refused, nothing is written.
To clear a cell, send its column name with null and no type: { "name": "Gift note", "value": null }. A column the group doesn't have is skipped, not created.
Update a general row
A row in a general group has no contact to find it by, so each call adds a new row. To update the same row instead, give it a key, your own name for it, like an order number:
{
"name": "Commission orders",
"type": "general",
"rows": [
{
"key": "commission-2207",
"cols": [{ "name": "Status", "type": "select", "value": "Glazing" }]
}
]
}The next call with commission-2207 in that group writes over that row, and its created is false. A retried call no longer adds a duplicate. Keys follow the same rules as in Idempotency.
Show a group on your site
GET /v1/groups/{group}/rows reads a group through one of its views. The view decides what your site gets: only its visible columns, only the rows its filters keep, in its sort order. That way your team controls what's public from the dashboard, without touching your site's code.
Northside Pottery keeps its teachers in an "Instructors" group and shows them on its About page through a view called "About page":
curl "https://api.lahuta.org/v1/groups/Instructors/rows?view=About%20page" \
-H "X-Api-Key: $LAHUTA_API_KEY"{
"group": {
"id": "0197a9c0-4b2e-7d15-8a3f-2c6e9b1d7f40",
"name": "Instructors",
"type": "person"
},
"view": {
"id": "0197a9c0-4b31-7a62-9d08-5e1f3c8b2a96",
"name": "About page"
},
"columns": [
{
"id": "0197a9c1-02d4-7c3b-b5e7-8a1f6d2c9e03",
"name": "Specialty",
"type": "select"
},
{
"id": "0197a9c1-02d5-74e1-a9c2-3b7d0f5e8a12",
"name": "Bio",
"type": "text"
}
],
"rows": [
{
"id": "0197a9c2-7e10-7b48-8c3d-1f9a6e2b5d70",
"contact": {
"kind": "person",
"id": "0197a9c2-7e0a-7f25-9b61-4d8e3a1c6f29",
"name": "Tomás Rivera",
"firstName": "Tomás",
"lastName": "Rivera",
"jobTitle": "Lead instructor",
"email": null,
"phone": null,
"image": null,
"linkedin": null,
"urls": []
},
"cells": {
"Specialty": { "kind": "select", "option": "Wheel throwing" },
"Bio": {
"kind": "text",
"text": "Fifteen years at the wheel, and still surprised by every firing."
}
}
}
],
"total": 4,
"limit": 100,
"offset": 0
}groupandviewcan each be a name or an id. URL-encode names with spaces. If two groups share a name, the first one in the sidebar order is used.viewis required. A group created through the API gets one view with the same name as the group.cellsis keyed by column name and only includes cells that have a value. Text, URL and email columns all read back as{ "kind": "text" }.- A file cell reads back as
{ "kind": "file", "files": [...] }. Each file has itsid,name,size,contentTypeand aurlthat opens it. Anyone with theurlcan open the file, so only show a file column in a view if the files are meant to be public. - A contact comes back as a public card: name, job title, picture, LinkedIn and links. There's never a postal address, description or birthday.
emailandphoneare only filled in when the view shows the Email and Phone columns. limitgoes up to 500, default 100. See Pagination.
Whatever the view shows can end up on your public website. Before you point your site at a view, hide the columns you don't want published, especially email and phone.
Errors
| Error | When |
|---|---|
404 NotFound | The group id, group name or view doesn't exist. Reading never creates anything |
409 GroupTypeMismatch | The id you sent belongs to a group of another type |
422 ColumnTypeMismatch | A column exists with a different type |
422 AssetRejected | A file cell names a file that isn't one of your organization's finished uploads |
For every field, see Contacts and Groups in the API reference.