Contacts and groups

Save the people who fill in your site's forms as contacts, 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
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

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",
    "emails": [{ "label": "Work", "value": "maya@fernandfig.com" }],
    "phones": [{ "label": null, "value": "+15035550148" }],
    "source": "Wholesale form",
    "createdAt": "2026-09-25T16:04:11.482Z"
  },
  "matchedExisting": false,
  "noteId": "0197b2e4-6a2d-7c19-9e40-3f7b8d2a1c05"
}

How matching works

  • A contact matches an existing contact of the same kind that shares any email address or phone number. 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 with no author, because an API key isn't a person.

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.

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 every time

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 exact name and type. 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 files uploaded with 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.

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 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 ForeignFileA file cell uses a URL that isn't one of your organization's uploads

For every field, see Contacts and Groups in the API reference.

On this page