Contacts

The people your agents reach: add them one by one, in bulk or from a CSV, and keep opted-out numbers silent.

View as Markdown

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

  1. Every contact belongs to a campaign. Campaign contacts are created under /api/campaigns/{campaignId}/contacts. POST /api/contacts saves a contact for the workspace without choosing a campaign: it is stored in the workspace’s system campaign named Manual Conversations, which is created on demand and does not count as an active campaign on your plan.
  2. 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).
  3. Contacts move through statuses as the dialer and the text channels work: pendingcalledconverted, or into failed, invalid or dnc.
  4. 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.

FieldTypeNullableDescription
idstring (uuid)NoContact ID.
tenant_idstring (uuid)NoYour workspace ID.
campaign_idstring (uuid)YesThe campaign the contact belongs to. For contacts created through POST /api/contacts, the ID of the Manual Conversations system campaign.
phone_numberstringNoThe dial target and the uniqueness key. An E.164 number, or a synthetic key starting with em: for contacts created with only an email address (see Email-only contacts).
emailstringYesEmail address.
whatsapp_numberstringYesWhatsApp number in E.164 format.
namestringYesDisplay name.
statusstringNoOne of pending, called, converted, failed, dnc, invalid. See Statuses.
call_attemptsintegerNoNumber of call attempts made to this contact.
last_called_atstring (date-time)YesWhen the contact was last called.
created_atstring (date-time)NoWhen the row was created.
updated_atstring (date-time)NoWhen the row last changed.
deleted_atstring (date-time)YesAlways null in responses: deleted contacts are not returned.
metadataobjectYesCustom fields. Create responses only; not included in the list.
crm_external_idstringYesThe record ID in your CRM, if you supplied one. Create responses only.
crm_providerstringYesThe CRM the external ID belongs to. Create responses only.
sync_statusstringYesState of the push to a connected CRM. Create responses only.
last_synced_to_crm_atstring (date-time)YesLast successful CRM push. Create responses only.
sync_errorstringYesLast CRM push error. Create responses only.
opted_out_atstring (date-time)YesWhen the person opted out on a text channel. Create responses only.
opted_out_reasonstringYesWhy, for example whatsapp_keyword:baja or email_unsubscribe:.... Create responses only.

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.

FieldTypeNullableDescription
idstring (uuid)NoThe ID of one of the underlying contact rows for this number.
phone_numberstringNoThe number that groups the rows.
namestringYesA name found on one of the rows.
emailstringYesAn email found on one of the rows.
campaign_countintegerNoNumber of distinct campaigns the number appears in.
last_contact_atstring (date-time)NoMost recent call to the number, or the most recent created_at if it was never called.
is_dncbooleanNotrue if the number has status dnc in any campaign.

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

StatusMeaningSet by
pendingWaiting to be contacted. Every new or reactivated contact starts here.Create, bulk and CSV import.
calledPicked up by the dialer, or called without reaching a success outcome.The dialer and post-call processing.
convertedThe interaction reached a success outcome. Never regresses to another status except dnc.Post-call processing and conversions on text channels.
failedThe contact could not be dialed, for example when no window in the campaign schedule is left.The dialer.
invalidThe contact was never dialable, for example it has no E.164 phone number. Does not consume retries.The dialer and campaign activation.
dncThe person asked not to be contacted. Outranks every other status.Opt-out detection on calls, WhatsApp and email.

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.

phoneNumber
string

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.

email
string

A valid email address, up to 320 characters.

whatsappNumber
string

E.164 format, same rules as phoneNumber.

name
string

Up to 200 characters.

metadata
object

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.

crmExternalId
string

The record ID in your CRM, up to 500 characters.

crmProvider
string

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

InputJSON endpointsCSV import
Phone and WhatsApp numbersMust already be E.164. Not reformatted.Spaces, dashes and parentheses are removed, and a + is prepended if missing.
EmailStored as sent, including capitals.Lower-cased.
Name and metadataStored as sent.Stored as sent, trimmed.

CSV import does not know your country. A local number such as 3001234567 becomes +3001234567, which is a valid-looking E.164 number with the wrong country code, and the preview counts it as valid. Always include the country code in the file (573001234567 or +57 300 123 4567).

Deduplication

  • Same phone number, same campaign. No second row is created. The existing contact is returned with 201, and any of email, whatsappNumber or name you sent are written only into fields that are empty on the existing row. A value that is already stored is never overwritten, and metadata is 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 pending instead 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

1

Add a contact to a campaign

Requires a write key. The campaign must exist and must not be completed or archived.

curl -sS -X POST "https://api.jelliu.co/api/campaigns/0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90/contacts" \
-H "Authorization: Bearer $JELLIU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phoneNumber": "+573001234567",
"name": "Ana Gómez",
"email": "ana.gomez@example.com",
"metadata": {
"company": "Transportes Andinos",
"plan_interest": "growth"
},
"crmExternalId": "hs-8812731",
"crmProvider": "hubspot"
}'

Response 201 Created:

{
"data": {
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"tenant_id": "4d7e9a10-2b3c-4d5e-8f60-718293a4b5c6",
"campaign_id": "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90",
"phone_number": "+573001234567",
"name": "Ana Gómez",
"metadata": { "company": "Transportes Andinos", "plan_interest": "growth" },
"call_attempts": 0,
"last_called_at": null,
"status": "pending",
"email": "ana.gomez@example.com",
"whatsapp_number": null,
"crm_external_id": "hs-8812731",
"crm_provider": "hubspot",
"sync_status": "none",
"last_synced_to_crm_at": null,
"sync_error": null,
"opted_out_at": null,
"opted_out_reason": null,
"created_at": "2026-09-14T15:42:07.318Z",
"updated_at": "2026-09-14T15:42:07.318Z",
"deleted_at": null
}
}

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.

2

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.

curl -sS -X POST "https://api.jelliu.co/api/contacts" \
-H "Authorization: Bearer $JELLIU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Carlos Pérez",
"whatsappNumber": "+5215512345678",
"email": "carlos.perez@example.com"
}'

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.

3

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.

curl -sS -X POST "https://api.jelliu.co/api/campaigns/0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90/contacts/bulk" \
-H "Authorization: Bearer $JELLIU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"contacts": [
{ "phoneNumber": "+573001234567", "name": "Ana Gómez" },
{ "phoneNumber": "+573109876543", "name": "Luis Rojas", "metadata": { "city": "Medellín" } },
{ "email": "marta.diaz@example.com", "name": "Marta Díaz" }
]
}'

Response 201 Created:

{
"data": {
"imported": 3
}
}

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.

4

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 .csv and a Content-Type of text/csv, application/csv or text/plain on the file part. Set it explicitly: many HTTP clients send .csv files as application/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.

curl -sS -X POST "https://api.jelliu.co/api/campaigns/0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90/contacts/upload-preview" \
-H "Authorization: Bearer $JELLIU_API_KEY" \
-F "file=@contacts.csv;type=text/csv"

Response 200 OK:

{
"success": true,
"data": {
"headers": ["Nombre", "Teléfono", "Móvil", "Correo", "Empresa"],
"previewRows": [
{ "Nombre": "Ana Gómez", "Teléfono": "'+57 300 123 4567", "Móvil": "'+57 310 987 6543", "Correo": "Ana.Gomez@example.com", "Empresa": "Transportes Andinos" }
],
"detectedMapping": {
"Nombre": "name",
"Teléfono": "phoneNumber",
"Móvil": "metadata",
"Correo": "email",
"Empresa": "metadata"
},
"stats": {
"totalRows": 1250,
"validRows": 1238,
"invalidRows": 12,
"duplicateRows": 4,
"errors": [
{ "row": 17, "field": "Correo", "message": "Invalid email: \"ana@\"" }
]
},
"fileHash": "3b0f6c1e9a2d4b7c8e5f10a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6"
}
}
  • previewRows holds the first 10 rows. Values starting with =, +, - or @ are shown with a leading ' in the preview only; the stored values are not altered.
  • stats covers at most the first 1,000 rows, and errors lists at most 100. row is the line number in the file, counting the header as row 1.
  • fileHash is 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:

FieldRequiredDescription
fileYesThe exact same bytes as the preview. Any change fails with File changed between preview and confirm.
mappingYesJSON object of header to target field, as a string.
fileHashYesThe fileHash from the preview: 64 hexadecimal characters.
metadataKeysNoJSON object renaming metadata columns, as a string. For example {"Empresa": "company"} stores the column under metadata.company instead of metadata.Empresa.
consentNoJSON object {"source": "...", "evidence": "..."}, as a string. The consent basis for this list, recorded for every imported contact. source is 2 to 80 characters (for example web_form, contract, double_opt_in) and evidence 3 to 500 characters (a URL, an export ID, a contract reference). Both are required when consent is present. Omit it to record the import as having no evidence of consent.
curl -sS -X POST "https://api.jelliu.co/api/campaigns/0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90/contacts/upload-confirm" \
-H "Authorization: Bearer $JELLIU_API_KEY" \
-F "file=@contacts.csv;type=text/csv" \
-F 'mapping={"Nombre":"name","Teléfono":"phoneNumber","Correo":"email","Empresa":"metadata"}' \
-F 'metadataKeys={"Empresa":"company"}' \
-F "fileHash=3b0f6c1e9a2d4b7c8e5f10a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6" \
-F 'consent={"source":"web_form","evidence":"https://example.com/webinar-signup"}'

Response 200 OK:

{
"success": true,
"data": {
"imported": 1234,
"skipped": 12,
"errors": [
{ "row": 17, "message": "Invalid email: ana@" },
{ "row": 88, "message": "No phone number, email, or WhatsApp number" }
]
}
}
  • Invalid rows are always skipped; they never fail the request. skipped is the number of rows that failed validation. Rows whose number is already in the campaign are neither imported nor skipped.
  • errors is capped at 100 entries.

A 200 from upload-confirm does not mean every valid row was imported. Rows are written in chunks of 500, each in its own transaction, and a chunk that fails is reported inside errors instead of failing the request: for example Failed to import rows 502-1001: Import of 500 contacts would exceed the plan limit (...). That is how the plan’s contact limit, a campaign that was completed in the meantime, or a temporary database error show up here. Always read errors, and compare imported with the row count you expected.

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.

5

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.

curl -sS "https://api.jelliu.co/api/campaigns/0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90/contacts?status=dnc&page=1&limit=100" \
-H "Authorization: Bearer $JELLIU_API_KEY"
{
"data": {
"contacts": [
{
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"tenant_id": "4d7e9a10-2b3c-4d5e-8f60-718293a4b5c6",
"campaign_id": "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90",
"phone_number": "+573001234567",
"email": "ana.gomez@example.com",
"whatsapp_number": null,
"name": "Ana Gómez",
"status": "dnc",
"call_attempts": 1,
"last_called_at": "2026-09-12T14:03:51.004Z",
"created_at": "2026-09-10T09:15:22.871Z",
"updated_at": "2026-09-12T14:05:10.442Z",
"deleted_at": null
}
],
"total": 1,
"page": 1,
"limit": 100
}
}

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.

curl -sS "https://api.jelliu.co/api/contacts?search=gomez&limit=20" \
-H "Authorization: Bearer $JELLIU_API_KEY"
{
"data": {
"contacts": [
{
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"phone_number": "+573001234567",
"name": "Ana Gómez",
"email": "ana.gomez@example.com",
"campaign_count": 2,
"last_contact_at": "2026-09-12T14:03:51.004Z",
"is_dnc": true
}
],
"total": 1,
"page": 1,
"limit": 20
}
}

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.

6

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.

numbers
string[]Required

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.

source
stringRequired

One of registry:rne_co (Colombia), registry:dnc_us (United States), registry:repep_mx (Mexico), registry:nmp_br (Brazil), customer_list.

reason
string

Up to 200 characters, stored on each new row, for example RNE export 2026-09-01. Defaults to imported: followed by the source.

curl -sS -X POST "https://api.jelliu.co/api/compliance/suppressions/import" \
-H "Authorization: Bearer $JELLIU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": "registry:rne_co",
"reason": "RNE export 2026-09-01",
"numbers": ["+573001112233", "+57 310 444 5566", "3205556677"]
}'

Response 201 Created:

{
"data": {
"suppressed": 2,
"received": 2,
"rejected": 1,
"rejectedSample": ["3205556677"]
}
}

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:

{
"data": {
"total": 48213,
"bySource": [
{ "source": "registry:rne_co", "count": 48190, "lastImportedAt": "2026-08-01T12:00:03.114Z" },
{ "source": "whatsapp", "count": 17, "lastImportedAt": "2026-09-13T21:40:18.502Z" },
{ "source": "voice", "count": 6, "lastImportedAt": "2026-09-11T16:22:45.090Z" }
],
"stale": [
{ "source": "registry:rne_co", "ageDays": 44 }
]
}
}

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.

ChannelWhat is detectedWhat is written
VoiceA request not to be called again, detected in the call transcript in Spanish or English, such as “no me vuelvan a llamar”, “quítenme de la lista” or “stop calling me”.The number is added to the suppression list (source voice), and the call’s contact is set to dnc. The suppression is written even for inbound and manual calls with no contact row.
WhatsAppA message that is only an opt-out keyword, such as STOP, BAJA, CANCELAR, unsubscribe or dar de baja (trailing punctuation ignored), or a longer phrase like “dejen de llamarme” or “remove me from your list”.Every contact of the workspace with that number (as phone_number or whatsapp_number) is set to dnc with opted_out_at and opted_out_reason, and the number is added to the suppression list (source whatsapp).
EmailAn unsubscribe request in a reply.Every contact with that address (ignoring case) is set to dnc with opted_out_at and opted_out_reason. No auto-reply is sent.

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

CodeStatusWhen
VALIDATION_FAILED400The body failed validation (Invalid contact input, Invalid bulk contacts input, Invalid contact), with details. Also Invalid campaign ID for a malformed UUID, and Invalid query params on list endpoints.
VALIDATION_FAILED400Cannot add contacts to a completed or archived campaign.
VALIDATION_FAILED400CSV problems: No file uploaded, Only CSV files are allowed (.csv), Uploaded file is too large (max 10MB)., File appears to be a ZIP/XLSX archive, not a CSV, CSV must be UTF-8 encoded (...), File is empty or could not be parsed, File changed between preview and confirm, Invalid upload confirm body.
VALIDATION_FAILED422POST /api/contacts in a workspace with no agents: No agent configured for this tenant — create an agent before sending manual conversations.
CAMPAIGN_NOT_FOUND404The campaign does not exist or belongs to another workspace.
BILLING_ERROR403The plan’s contact limit is reached, or a bulk batch would exceed it. metadata carries limit, current, tier and, for batches, batch. Also returned when the workspace has no active plan.
FORBIDDEN403A read key on a POST, or a key without the full scope on the suppression list endpoints.
RATE_LIMIT_EXCEEDED429More than 5 requests per minute under /api/campaigns/{campaignId}/contacts: Too many bulk import requests, please wait before importing again.
SERVICE_UNAVAILABLE503A temporary database problem. Retry after Retry-After.

A limit error on a batch looks like this:

{
"error": {
"code": "BILLING_ERROR",
"message": "Import of 2000 contacts would exceed the plan limit (2000 contacts on the growth plan; currently 1200). Reduce the batch or upgrade your plan in Settings → Plan.",
"metadata": { "limit": 2000, "current": 1200, "batch": 2000, "tier": "growth" }
}
}

See Errors for the envelope and retry guidance.

Limits

Rate limits. Everything under /api/campaigns/{campaignId}/contacts, including the list, the CSV preview and the template download, shares a budget of 5 requests per minute per workspace, on top of the general limit. A CSV import (preview plus confirm) uses two. /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.

PlanContacts
No active plan0
Starter500
Growth2,000
Business20,000
Enterprise999,999

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.

OperationLimit
JSON bulk5,000 contacts per request, 1 MB request body
CSV upload10 MB file, 500 columns, imported in chunks of 500 rows
Metadata (JSON endpoints)20 keys, 500 characters per value
Suppression import200,000 numbers per request, 1 MB request body

Scopes. See Authentication.

EndpointScope
GET /api/contacts, GET /api/campaigns/{campaignId}/contacts, GET .../upload-templateread
POST /api/contacts, POST /api/campaigns/{campaignId}/contacts, .../bulk, .../upload-preview, .../upload-confirmwrite
POST /api/compliance/suppressions/import, GET /api/compliance/suppressions, DELETE /api/compliance/suppressionsfull

Webhooks

The contact.created, contact.updated, contact.status_changed, contact.converted and contact.dnc events are accepted in webhook subscriptions but not delivered yet. To follow what happens to your contacts today, subscribe to:

EventRelevance
call.completedCarries contactId, phoneNumber, outcome and dataCollection for each finished call.
call.failedCarries contactId for calls that failed or could not connect.
campaign.completedSent when no contact in the campaign is left pending.
crm_sync.completed, crm_sync.failedA contact’s interaction was, or was not, written to your CRM.

See Webhooks for payloads and signature verification.