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 toUse
Save or update one person or companyPUT /v1/contacts
Find the contacts with an email address or phone numberGET /v1/contacts?email=... or GET /v1/contacts?phone=...
Save form answers as a row in a group, creating the group and its columns if neededPUT /v1/groups
Show a group on your website, like a team or instructor directoryGET /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, matchedExisting is true. New emails, phones, links and addresses are added. Empty fields like lastName or jobTitle are 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, urls and addresses are lists of objects. Each one needs a label, which can be null. Phone numbers must be E.164, like +15035550148. An address needs a formattedAddress, and its other fields, from addressLine1 to googleMapsURL, must all be present, set to null when 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, items is empty. You never get a 404.
  • 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 email and phone is refused with a 400. Sending neither lists your contacts a page at a time instead. See List your contacts.
  • The response also has total, limit and offset, which for a lookup are the number of matches, the number of matches and 0.

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

typeEach row carriesSending the same row again
personA person contact, saved the same way as PUT /v1/contactsUpdates that person's row with the latest values
companyA company contactUpdates that company's row
generalOnly cells, no contactAdds 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.

typevalue
text, url, emailA string
numberA number, or a decimal string like "12.50"
date"YYYY-MM-DD"
selectOne option as a string. A new option is added to the column
multiSelectAn array of option strings
phoneAn E.164 phone number
addressAn address object, the same shape as a contact's address, with every field present
fileUp 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
}
  • group and view can 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.
  • view is required. A group created through the API gets one view with the same name as the group.
  • cells is 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 its id, name, size, contentType and a url that opens it. Anyone with the url can 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. email and phone are only filled in when the view shows the Email and Phone columns.
  • limit goes 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

ErrorWhen
404 NotFoundThe group id, group name or view doesn't exist. Reading never creates anything
409 GroupTypeMismatchThe id you sent belongs to a group of another type
422 ColumnTypeMismatchA column exists with a different type
422 AssetRejectedA 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.

On this page