Lelemon
  • Get started
  • Use cases
  • Guides
  • API basics
  • Webhooks
  • SDK, CLI and MCP
  • API reference
  • Changelog
  • v1 (legacy)
Configure your account
    AgentsPipelinesKnowledgeContactsActions and MCP in v2Approve agent actionsUse Claude as your team's assistantConnect your back officeAPI keys
Talk to people
Run and protect
powered by Zudoku
Configure your account

Contacts

You will

Keep one contact per person, find them by your own id, and know where presence, events, opt-outs and erasure live.

You need

  • A key with contacts:write (contacts:read to list and read, companies:write to create companies)

A contact is the person your organization attends. Cases, messages and events hang from a contact. Phone and email are optional, because a contact can come from your own app and have neither. The externalRef is your own id for the person: use it, and you never need to store a Lelemon id in your database.

The examples use this client:

Code
import { Lelemon } from '@lelemon-ai/sdk'; const lelemon = new Lelemon({ apiKey: process.env.LELEMON_API_KEY });

Create a contact

Only name is required. Sending the same externalRef twice answers 409 external_ref_conflict.

Expected: 201 with the contact:

Contactobject, 16 fields
  • objectenumrequired

    contact

  • idstringrequired

    Example ctc_4Zq0x8bVw1TqS9mYp3RkLd

  • namestringrequired

    Example Ana Pérez

  • emailstring or nullrequired
  • phonestring or nullrequired
  • externalRefstring or nullrequired
  • companyIdstring or nullrequired

    The company of this contact, or null when it has none.

    Example cpy_4Zq0x8bVw1TqS9mYp3RkLd

  • tagsstring[]required
  • fieldsobjectrequired

    The values of the contact fields this contact has, by key. Empty when it has none.

    Example {"plan":"pro"}

  • createdAtstring (date-time)required
  • updatedAtstring (date-time)required
  • presencePresenceoptional

    Whether the contact is online. Present only when the organization setting presenceEnabled is on, at least one presence signal has arrived, and the key has the contacts:presence-read scope.

    Presence2 fields
    • onlinebooleanrequired

      true when the last active signal is less than 120 seconds old and no offline signal came after.

    • lastSeenAtstring or null (date-time)required

      The last time a signal arrived. While online it is exact; once offline it can be up to 5 minutes behind. null when no signal ever arrived for this contact.

  • matchobject or nulloptional

    Only when you search with q: the field of the contact that matched and its value, or null when the match was the name, email, phone or externalRef. Fields marked sensitive are never searched or shown.

    match2 fields

    Only when you search with q: the field of the contact that matched and its value, or null when the match was the name, email, phone or externalRef. Fields marked sensitive are never searched or shown.

    • fieldstringoptional

      Example Plan

    • valuestringoptional

      Example enterprise

  • caseCountintegeroptional

    Only when you search, filter by pipeline or open cases, or sort: how many cases the contact has.

  • openCaseCountintegeroptional

    Same as caseCount, only the open ones.

  • lastActivityAtstring (date-time)optional

    Same as caseCount: the latest message of its cases, or its creation date.

Create or update in one call

PUT /v2/contacts/by-external-ref/{externalRef} creates the contact when the reference is new and updates it when it exists. It always answers 200 and calling it twice leaves one contact. The fields you leave out stay as they were; a new contact with no name is named after the externalRef. This is the call to make every time your system sees a person.

To open a case for a person you may not have stored yet, send contact instead of contactId to POST /v2/cases: it finds the person by externalRef or phone, or creates them, and does not change a contact that exists.

Code
const kase = await lelemon.cases.create({ contact: { name: 'Pedro Soto', externalRef: 'cust-90211', phone: '+56911113333' }, agentId: 'agt_...', });

Contact fields

A contact field is a piece of data you want on every person: a plan, a customer number, a birth date. You define it once for the organization and then send its value with each contact. The dashboard shows the values in the contact page, lets your team edit them there and filters the Contacts list by them.

POST /v2/contact-fields takes a key (letters, numbers and underscores, starting with a letter, fixed once created), a type and optionally a label. The types check what you send:

typeAccepts
text, textareaA string, up to 500 and 5000 characters
emailAn email address
phoneA phone number: digits, spaces, +, -, ( and )
urlAn http or https URL
numberA JSON number
dateA date as YYYY-MM-DD
booleantrue or false
selectOne of its options (required for this type and not allowed for the others)

An organization can have up to 100 contact fields.

Expected: 201 with the field, whose id starts with cfd_.

ContactFieldobject, 8 fields
  • objectenumrequired

    contact_field

  • idstringrequired

    Example cfd_7AdX2mRw0TqS9mYp3LkQ8d

  • keystringrequired

    Example plan

  • labelstringrequired

    Example Plan

  • typeenumrequired

    texttextareaemailphoneurlnumberdatebooleanselect

  • optionsContactFieldOptionOut[]required
    ContactFieldOptionOut2 fields
    • valuestringrequired

      Example pro

    • labelstringrequired

      Example Pro

  • createdAtstring (date-time)required
  • updatedAtstring (date-time)required

Send the values in fields when you create a contact, update it, or use the upsert. A key that was never defined, or a value that does not fit its type, answers 400 invalid_request with param set to fields.<key>, and nothing is saved. On an update the keys you leave out stay as they were and null removes a value.

Every contact answers its values in fields, which is {} when it has none. GET /v2/contact-fields, GET /v2/contact-fields/{id}, PATCH /v2/contact-fields/{id} (the label and, for a select, its options) and DELETE /v2/contact-fields/{id} complete the resource. Deleting a field also removes its value from every contact, and it cannot be undone. Contact fields are not for secrets: they are searchable and your whole team sees them.

Find and list contacts

GET /v2/contacts lists newest first. Filter by externalRef, phone, email or updatedSince (a date-time, to sync only what changed). presence=online keeps the contacts online now and needs contacts:presence-read. GET /v2/contacts/{id} returns one.

Lists follow the pagination rules.

Search contacts

Send q to search by name, email, phone, externalRef and the values of the contact fields, ignoring case and accents. Each result says in match which field matched and its value, or null when the match was the name, email, phone or externalRef. A field the team marked sensitive in any pipeline is never searched and never shown in match, for contacts with or without cases.

pipelineId keeps the contacts that have a case in that pipeline, hasOpenCases=true the ones with an open case, and fieldKey with fieldValue the ones whose contact field has exactly that value. order is activity (latest activity first) or name. They combine with each other and with the filters above.

With any of these parameters each contact also carries caseCount, openCaseCount and lastActivityAt, and the list is no longer newest first: it follows order, which is activity when you do not send it. It is the same search as the Contacts screen of the dashboard.

Companies

A company groups the contacts that work in it. GET /v2/companies lists them by name, with q (part of the name), status and type filters, and GET /v2/companies/{id} returns one with the ids of its contacts in contactIds. Both need contacts:read.

POST /v2/companies, PATCH /v2/companies/{id} and DELETE /v2/companies/{id} need companies:write, the same permission the dashboard asks of its owners and admins to edit a company. Only name is required: type is prospect and status is lead when you do not send them. The rut is unique among the live companies of the organization, and one that is taken answers 409 conflict with param: "rut". PATCH changes only what you send, and null removes rut, website, industry, source or notes.

Delete is for data created by mistake: a company that was lost as a business stays, with status: "lost". Deleting a company never deletes its contacts. They stay, their companyId becomes null, and the rut can be used again.

To link a person to a company, send companyId when you create the contact or in PATCH /v2/contacts/{id}. null unlinks it, and a company that does not exist in the organization answers 400 with param: "companyId". Linking is a contact edit, so it needs contacts:write. Every contact answers its companyId.

companyId is also accepted by PUT /v2/contacts/by-external-ref/{externalRef} (omitted keeps the company, null unlinks), by contact in POST /v2/cases and by contact in POST /v2/contact-events, with param: "contact.companyId" when the company does not exist. In POST /v2/cases it applies only when the contact is created there. Deleting a company at the same time as linking a contact to it is safe: the contact is either linked to a live company or the link is refused.

Companyobject, 12 fields
  • objectenumrequired

    company

  • idstringrequired

    Example cpy_4Zq0x8bVw1TqS9mYp3RkLd

  • namestringrequired

    Example Panadería La Espiga

  • rutstring or nullrequired

    Example 76.123.456-7

  • typeenumrequired

    What the company is for the organization.

    prospectclientpartnervendor

  • statusenumrequired

    How the relationship is today.

    leadactivepausedlost

  • websitestring or nullrequired
  • industrystring or nullrequired
  • sourcestring or nullrequired
  • notesstring or nullrequired
  • createdAtstring (date-time)required
  • updatedAtstring (date-time)required

The account in your system

If a contact has an externalRef and the organization has an active data connector, the contact page of the dashboard shows the account of that reference in your system, read live each time the page opens. It uses the same permissions and the same masking as the connector: only owners and admins see it, personal data stays hidden until someone reveals it and each reveal is recorded. Nothing is stored. When your system does not answer, the page says why and the rest of the contact keeps working.

Update a contact

PATCH /v2/contacts/{id} changes only what you send. Send null in email, phone or externalRef to remove it. tags replaces the whole list, so send every tag you want to keep.

Events about a contact

Tell Lelemon what happened to a person in your system and let it message them. POST /v2/contact-events takes a type you choose, the contact (by externalRef) and your data. A trigger that listens to that type sends the template, opens the case or moves it. The contact is created if it does not exist.

Code
const result = await lelemon.contactEvents.create({ type: 'order.shipped', contact: { externalRef: 'cust-88123', name: 'Ana Rojas', phone: '+56911112222' }, data: { orderNumber: 'A-1042', carrier: 'Starken' }, }); console.log(result.result, result.caseActions);

The event costs money when a message goes out, so it takes ?dryRun=true and requires an Idempotency-Key. The full flow, with templates and cadences, is in Message on events.

Presence

If your product has a web or mobile app, report who is online and your team sees it in the inbox and can filter by it. See Show who is online.

Opt-outs

An opt-out stops all sending to a contact on one channel (whatsapp, email or voice): events, messages and campaigns skip them. POST /v2/whatsapp/templates/{id}/test too: a phone that belongs to a contact who opted out of whatsapp answers 422 contact_opted_out and nothing is sent. A contact who writes "stop" is opted out by the platform; you can also set it yourself. GET /v2/opt-outs lists them and needs contacts:read.

Code
await lelemon.contacts.optOuts.set('ctc_...', 'email'); await lelemon.contacts.optOuts.clear('ctc_...', 'email');

DELETE clears only the opt-outs you set. One the contact asked for stays until they write again, and answers 409.

An opt-out blocks every WhatsApp message to that number, whoever sends it: a template, a reply from the team in the dashboard, an agent or a test send (422 contact_opted_out). It also holds for another contact with the same phone number or email. It survives the erasure of the person: the opt-out keeps only a keyed hash of the number and the address, never the number itself, so a new contact with the same number is still opted out.

Erase a contact

DELETE /v2/contacts/{id} erases the person for good: the contact, its cases, messages, files, fields, agent memory and logs lose their content. It runs as a job and answers 202 with a suppression you follow with GET /v2/suppressions/{id} and the contact.suppressed webhook. It answers 409 resource_in_use while the contact has open cases, unless you send closeOpenCases=true. Use ?dryRun=true first to see the inventory without content. The deadlines, what is kept and how to redact a single message are in Erase a contact or a message.

Code
const preview = await lelemon.contacts.delete('ctc_...', { query: { dryRun: true } }); const job = await lelemon.contacts.delete('ctc_...');

Errors you will meet

ErrorCause
400 invalid_requestA field breaks a limit, or a value in fields does not fit its type; param names it
403 scope_missingThe key lacks contacts:write or contacts:read
404 not_foundThe contact does not exist in this mode, or it was erased
409 external_ref_conflictAnother contact already uses that externalRef
409 conflictA contact field with that key already exists, or the organization has 100
409 resource_in_useErasure of a contact with open cases

All codes are in the error catalog.

Good practice

  • Put your own customer id in externalRef and use the upsert. It makes every sync safe to repeat.
  • Store phones in E.164 (+56911112222). The API keeps what you send.
  • Do not use tags as a database: keep a few that your team filters by. For data about the person, define a contact field.
  • Check opt-outs before you build your own sending on top of Lelemon; Lelemon already skips them.

What's next

  • Message on events to write first.
  • Pipelines for the cases a contact opens.
  • Erase a contact or a message for data requests.
KnowledgeActions and MCP in v2
On this page
  • Create a contact
  • Create or update in one call
  • Contact fields
  • Find and list contacts
  • Search contacts
  • Companies
  • The account in your system
  • Update a contact
  • Events about a contact
  • Presence
  • Opt-outs
  • Erase a contact
  • Errors you will meet
  • Good practice
  • What's next
TypeScript
curl https://api.lelemon.cl/v2/contacts \ -H "Authorization: Bearer $LELEMON_API_KEY" \ -H "Idempotency-Key: contact-cust-88123" \ --json '{ "name": "Ana Rojas", "email": "[email protected]", "phone": "+56911112222", "externalRef": "cust-88123", "tags": ["vip", "newsletter"] }'
curl -X PUT https://api.lelemon.cl/v2/contacts/by-external-ref/cust-88123 \ -H "Authorization: Bearer $LELEMON_API_KEY" \ -H "Idempotency-Key: upsert-cust-88123-1" \ --json '{ "name": "Ana Rojas", "email": "[email protected]" }'
TypeScript
curl https://api.lelemon.cl/v2/contact-fields \ -H "Authorization: Bearer $LELEMON_API_KEY" \ --json '{ "key": "plan", "type": "select", "label": "Plan", "options": [{ "value": "free" }, { "value": "pro", "label": "Pro" }] }'
curl -X PUT https://api.lelemon.cl/v2/contacts/by-external-ref/cust-88123 \ -H "Authorization: Bearer $LELEMON_API_KEY" \ --json '{ "fields": { "plan": "pro" } }'
curl "https://api.lelemon.cl/v2/contacts?externalRef=cust-88123" \ -H "Authorization: Bearer $LELEMON_API_KEY" curl "https://api.lelemon.cl/v2/contacts?updatedSince=2026-10-01T00:00:00Z&limit=100" \ -H "Authorization: Bearer $LELEMON_API_KEY"
curl "https://api.lelemon.cl/v2/contacts?q=rojas&hasOpenCases=true&order=name" \ -H "Authorization: Bearer $LELEMON_API_KEY" curl "https://api.lelemon.cl/v2/contacts?fieldKey=plan&fieldValue=pro" \ -H "Authorization: Bearer $LELEMON_API_KEY"
curl https://api.lelemon.cl/v2/companies \ -H "Authorization: Bearer $LELEMON_API_KEY" \ -H "Idempotency-Key: company-76123456-7" \ --json '{ "name": "Panadería La Espiga", "rut": "76.123.456-7", "type": "client", "status": "active" }' curl -X PATCH https://api.lelemon.cl/v2/contacts/$CONTACT_ID \ -H "Authorization: Bearer $LELEMON_API_KEY" \ --json '{ "companyId": "'$COMPANY_ID'" }' curl "https://api.lelemon.cl/v2/companies?q=espiga&status=active" \ -H "Authorization: Bearer $LELEMON_API_KEY" curl -X DELETE https://api.lelemon.cl/v2/companies/$COMPANY_ID \ -H "Authorization: Bearer $LELEMON_API_KEY"
curl -X PATCH https://api.lelemon.cl/v2/contacts/$CONTACT_ID \ -H "Authorization: Bearer $LELEMON_API_KEY" \ --json '{ "phone": null, "tags": ["vip", "b2b"] }'
TypeScript
TypeScript
TypeScript