Contacts
The people your agents reach: add them one by one, in bulk or from a CSV, and keep opted-out numbers silent.
A contact is a person an agent can reach: a phone number to dial, a WhatsApp number to message, an email address to write to, plus a name and free-form metadata the agent can use during the conversation. Contacts live inside a campaign, which is what tells the dialer and the text channels what to say. Contacts that do not belong to any campaign you run are kept in a system campaign so they can still be messaged, called and synced.
On top of the contact rows sits the workspace suppression list: numbers that must never be contacted again, whether the person asked on a call, wrote STOP on WhatsApp, or came from a national do-not-call registry you imported.
How it works
- Every contact belongs to a campaign. Campaign contacts are created under
/api/campaigns/{campaignId}/contacts.POST /api/contactssaves a contact for the workspace without choosing a campaign: it is stored in the workspace’s system campaign namedManual Conversations, which is created on demand and does not count as an active campaign on your plan. - One row per phone number per campaign. Within a campaign, the phone number is the uniqueness key. Adding a number that is already there does not create a duplicate (see Deduplication).
- Contacts move through statuses as the dialer and the text channels work:
pending→called→converted, or intofailed,invalidordnc. - Opt-outs are durable. When someone asks not to be contacted, their number goes onto the suppression list, which is keyed by number and not by contact row. Deleting and re-importing the contact, or adding the same number to another campaign, does not make it reachable again.
Object
Campaign contact
Returned by POST /api/campaigns/{campaignId}/contacts, POST /api/contacts, and (with fewer fields) by GET /api/campaigns/{campaignId}/contacts. Fields are snake_case; timestamps are ISO 8601 strings.
Workspace contact (CRM view)
GET /api/contacts returns a different, read-only shape: one row per distinct phone number across all campaigns, so the same person in three campaigns appears once with campaign_count: 3.
When several rows share a number with different names or emails, name and email are aggregated, not “most recent”. Do not use this view to decide which email is current; read the campaign contact instead.
Statuses
A campaign cannot complete while any of its contacts is still pending.
Creating contacts
Request body
The same body is used by POST /api/campaigns/{campaignId}/contacts, POST /api/contacts and each item of the contacts array in /bulk.
E.164 format: +, a non-zero country digit, up to 15 digits in total, for example +573001234567. No spaces or dashes. Stored exactly as sent.
A valid email address, up to 320 characters.
E.164 format, same rules as phoneNumber.
Up to 200 characters.
Custom fields as string values. Up to 20 keys; keys may contain only letters, digits, _ and -; each value up to 500 characters. Metadata is available to the agent during the conversation.
The record ID in your CRM, up to 500 characters.
The CRM that owns crmExternalId, up to 50 characters.
At least one of phoneNumber, email or whatsappNumber is required. Otherwise the request fails with 400 VALIDATION_FAILED and the message At least one contact method is required (phoneNumber, email, or whatsappNumber) in details.
Metadata values are scanned for card and national ID numbers before they are stored, on every path including CSV import. Any standalone run of 13 to 19 digits is replaced with [REDACTED-CARD], and SSN-shaped values (123-45-6789, or a standalone 9-digit number) with [REDACTED-SSN]. Order numbers or customer IDs of those lengths are redacted too, so prefix them with letters (for example ORD-123456789) if you need them intact.
Normalization
Local numbers in a CSV. A phone or WhatsApp cell without + or 00 is handled in this order:
- If your workspace has a country and the number has that country’s national length, the country code is added: in a Colombian workspace
3001234567becomes+573001234567. A leading trunk0is dropped first. - Otherwise, if the digits already start with the code of Colombia, Mexico, the United States, Brazil, Argentina, Chile or Peru and have that country’s full length, a
+is added:573001234567becomes+573001234567. - Anything else rejects the row, with a Spanish message that quotes the value, for example
Teléfono sin prefijo de país "3001234567": escríbelo en formato internacional (ejemplo: +573001234567) o configura el país de tu cuenta para completar números locales.
The workspace country is the one chosen at onboarding; if that country is not one of the seven above, local numbers are not completed. When none was chosen, it is the country of your active phone numbers, but only if they all belong to one of the seven. A phone cell that is filled in but unusable rejects the row even when the row also has an email, so a contact is never imported without the number you meant to call. The preview applies the same rules, so a row it counts as valid is not rejected on confirm.
Include the country code in the file (+57 300 123 4567 or 573001234567) when your list mixes countries.
Deduplication
- Same phone number, same campaign. No second row is created. The existing contact is returned with
201, and any ofemail,whatsappNumberornameyou sent are written only into fields that are empty on the existing row. A value that is already stored is never overwritten, andmetadatais not merged. - Same phone number, different campaign. A separate row is created. Each row counts toward your plan’s contact limit.
- Previously deleted contact. The row is restored with status
pendinginstead of creating a new one. - Bulk and CSV. Duplicates inside the request are dropped (the first occurrence wins), and numbers already in the campaign are skipped silently. They are not updated and not counted in
imported.
Email-only contacts
A contact with only an email still needs a phone_number, so Jelliu stores a synthetic key such as em:9c1b2f4e7a3d5c60 derived from the address. This key is never dialed; the dialer marks such a contact invalid on a voice campaign.
When you create a single contact with only an email, Jelliu first looks for an existing contact in the whole workspace with the same address, ignoring case. If it finds one, that contact is returned (with its name filled in if it had none), even if it belongs to a different campaign than the one in the URL. Check campaign_id in the response if the campaign matters to you.
Common tasks
Add a contact to a campaign
Requires a write key. The campaign must exist and must not be completed or archived.
Response 201 Created:
The status is 201 whether the contact was created or already existed. Compare created_at with the time of your request if you need to tell them apart.
Save a contact without a campaign
POST /api/contacts takes the same body and stores the contact in the workspace’s Manual Conversations system campaign. Use it when you need a contact ID to message someone from the conversations inbox but have no campaign for them. The workspace needs at least one agent: without one the request fails with 422.
The response is the same contact object as above, with 201 Created. A contact with a WhatsApp number but no phone number uses the WhatsApp number as its phone_number.
Import contacts in bulk (JSON)
POST /api/campaigns/{campaignId}/contacts/bulk accepts 1 to 5,000 contacts per request. Keep the request body under 1 MB, the API’s JSON size limit; for large lists that usually means a few thousand contacts per call. Rows are inserted in one transaction, so a batch either lands completely or not at all.
Response 201 Created:
imported counts only new rows. Contacts whose number is already in the campaign are skipped without error, so imported: 0 is a valid answer for a list you already loaded. If one contact in the array fails validation, the whole request is rejected with 400, and the messages appear under details.fieldErrors.contacts.
Import a CSV file
CSV import is a two-step flow. Preview parses the file and proposes a column mapping without importing anything. Confirm re-sends the same file with the mapping you accept, and imports it.
File requirements:
- Extension
.csvand aContent-Typeoftext/csv,application/csvortext/plainon the file part. Set it explicitly: many HTTP clients send.csvfiles asapplication/octet-stream, which is rejected. - Up to 10 MB. The multipart field is named
file. - UTF-8 (with or without BOM) or UTF-16 with a BOM. Latin-1 or Windows-1252 files are rejected: in Excel, use Save As → CSV UTF-8.
- Comma-separated, first row is the header. Excel workbooks (
.xlsx,.xls) and PDFs are rejected even if renamed. - At most 500 columns.
GET /api/campaigns/{campaignId}/contacts/upload-template returns a starter file with the header phone_number,name,email,whatsapp_number.
1. Preview.
Response 200 OK:
previewRowsholds the first 10 rows. Values starting with=,+,-or@are shown with a leading'in the preview only; the stored values are not altered.statscovers at most the first 1,000 rows, anderrorslists at most 100.rowis the line number in the file, counting the header as row 1. Phone cells are checked with the same local-number rules as the import.fileHashis the SHA-256 of the file. You must send it back on confirm.
Review the mapping. Each header maps to one of phoneNumber, name, email, whatsappNumber, metadata or ignore. Detection looks for common English and Spanish words anywhere in the header (phone, tel, celular, nombre, correo, whatsapp, wa, and so on) and, failing that, at the values in the first 10 rows. Because it matches substrings, an unrelated header can be claimed: Hotel contains tel and Software contains wa. Each of phoneNumber, whatsappNumber, email and name is assigned to the first matching column only; a second phone column, such as Móvil above, is mapped to metadata. Unrecognized columns also become metadata. Correct the mapping before confirming.
2. Confirm. Send the same file again with these form fields:
Response 200 OK when every valid row was written:
Rows whose number is already in the campaign are neither imported, skipped nor failed. Invalid rows never fail the request.
Rows are written in chunks of 500, each in its own transaction. When some chunks fail and others succeed, the response is 207 Multi-Status with partial: true, and each failed chunk appears in errors with the file rows it held:
The reason after the row range is the refusal’s own message (the plan’s contact limit, or a campaign completed in the meantime), or a generic Spanish message for a server error. When every chunk fails, the request fails with that first error’s status and code instead, for example 403 BILLING_ERROR or 404 CAMPAIGN_NOT_FOUND.
Check the status code or data.partial, not just success. On a 207, re-import the rows listed in errors once the cause is fixed: rows already imported are skipped as duplicates.
Every form field is a string in multipart/form-data. Send mapping, metadataKeys and consent as JSON strings, and do not send the legacy skipInvalid field: as a string it fails validation, and invalid rows are skipped regardless. Text fields are limited to 1 MB and five levels of nesting.
List and search contacts
In a campaign. GET /api/campaigns/{campaignId}/contacts pages with page (default 1) and limit (1 to 100, default 50), oldest first, and filters by status. Use status=dnc to review opt-outs.
Across the workspace. GET /api/contacts returns the CRM view, newest first, with the same page and limit, plus search (1 to 200 characters), a case-insensitive match on phone number, name or email.
total is the number of distinct phone numbers that match. Email-only contacts are grouped by their synthetic em: key, so each appears as its own row. List responses are cached briefly, so a status change made by a call or a message can take a moment to show; a search request on /api/contacts always reads live data. See Pagination.
Load a do-not-call registry into the suppression list
POST /api/compliance/suppressions/import adds numbers to the workspace suppression list. Requires a full key. Use it to load national registry exports, such as Colombia’s Registro de Números Excluidos or the US National Do Not Call Registry, or your own list.
1 to 200,000 numbers, each 5 to 32 characters. Spaces, dashes, dots, parentheses and non-breaking spaces are removed; what remains must be E.164 (with + and the country code) or the line is rejected. Local formats are not converted.
One of registry:rne_co (Colombia), registry:dnc_us (United States), registry:repep_mx (Mexico), registry:nmp_br (Brazil), customer_list.
Up to 200 characters, stored on each new row, for example RNE export 2026-09-01. Defaults to imported: followed by the source.
Response 201 Created:
received and suppressed are the distinct valid numbers in the request, including numbers that were already suppressed. rejectedSample shows up to 10 rejected lines so you can spot a formatting problem. Importing is idempotent: a number already on the list keeps its original reason and date.
The 200,000-number limit is larger than the API’s 1 MB JSON body limit allows in practice. Send big registry exports in pages of about 50,000 numbers.
Check the list. GET /api/compliance/suppressions returns counts per source and the registries whose most recent entry is older than 31 days:
lastImportedAt and stale are based on when numbers were first added, not on when you last ran an import. Re-importing an export that contains only numbers already on the list adds no rows, so the registry keeps showing as stale. Keep your own record of when each scrub ran. Jelliu reports stale registries but does not stop calls because of them.
Lift one number. DELETE /api/compliance/suppressions with { "phoneNumber": "+573001112233", "justification": "..." } removes a single number. phoneNumber must match the stored E.164 value exactly, and justification (10 to 500 characters) is written to the audit log. The response is { "data": { "removed": true } }, or false if the number was not on the list. There is no bulk removal.
Opt-out and suppression
Opt-out means total silence. When a person asks not to be contacted, Jelliu stops, and it does not send a confirmation message either.
Each contact that becomes opted out sends one contact.opted_out webhook event with the channel it arrived on. A contact that was already opted out sends nothing again.
A keyword inside a sentence is not an opt-out: “quiero cancelar mi cita” is a request to reschedule, not to stop.
Before every outbound call, the compliance gate refuses the number if it is on the suppression list, if the contact is dnc, or (when the country’s compliance configuration requires a DNC check) if any contact of the workspace with that phone or WhatsApp number is dnc. The suppression list check applies regardless of configuration. Because the list is keyed by number, re-importing a suppressed number, or adding it to another campaign, does not make it callable.
There is no endpoint to change a contact’s status back from dnc. If a person gives renewed consent, lift the number from the suppression list with a written justification, and add them again so the new consent is on record.
Errors
A limit error on a batch looks like this:
See Errors for the envelope and retry guidance.
Limits
Rate limits. The import routes, POST /api/campaigns/{campaignId}/contacts/bulk, .../upload-preview and .../upload-confirm, share a budget of 5 requests per minute per workspace, on top of the general limit. A CSV import (preview plus confirm) uses two. Listing a campaign’s contacts, adding one contact and downloading the CSV template use only the general limit. /api/contacts and /api/compliance/suppressions use the general per-plan limit. See Rate limits.
Plan limits. The contact limit counts every live contact row in the workspace, across all campaigns including the system campaign. The same number in two campaigns counts twice.
Single creates are refused once the limit is reached. Bulk imports are refused when current + batch would exceed it, so a batch is never partially inserted.
Size limits.
Scopes. See Authentication.
Webhooks
contact.updated and contact.erased are also available; the catalog describes their fields. contact.updated is opt-in only and lists the changed fields, never their values; contact.erased contains only contact_id and erased_at.
See Webhooks for payloads and signature verification.

