Contacts Endpoints
Contacts are the core data object for SMS campaigns. A contact is a phone number with optional metadata (name, email, tags, notes). Contacts belong to an account and can be a member of multiple contact lists simultaneously via a junction table.
Base paths:
/api/v1/contacts— individual contacts/api/v1/contact-lists— named lists (groups)
All endpoints require Authorization: Bearer <access_token>.
Data Model Overview
Why many-to-many?
The initial design used a single list_id FK on contacts. In practice, customers frequently need the same contact in multiple campaigns (e.g. "VIP Customers" list + "Newsletter" list). The junction table contact_list_memberships handles this cleanly. The service layer maintains a denormalised total_count on contact_lists rather than running COUNT(*) on every request.
Contact Lists
GET /api/v1/contact-lists
Returns all contact lists for the authenticated account.
Response — 200 OK
[
{
"id": "<uuid>",
"name": "VIP Customers",
"description": "Customers who spend over KES 10,000/month",
"contactCount": 42,
"createdAt": "2025-01-01T00:00:00.000Z"
}
]POST /api/v1/contact-lists
Creates a new contact list.
Request Body
{
"name": "Newsletter Subscribers",
"description": "Opted-in newsletter audience"
}| Field | Required | Notes |
|---|---|---|
name | Yes | |
description | No | Shown in the UI beside the list name |
Response — 201 Created — returns the created list object.
PATCH /api/v1/contact-lists/:id
Updates a contact list's name and/or description.
Request Body
{
"name": "Newsletter Subscribers (updated)",
"description": "Updated description"
}Response — 200 OK — returns the updated list.
DELETE /api/v1/contact-lists/:id
Deletes a contact list. The contacts themselves are not deleted — they remain in the account and may still belong to other lists.
Response — 204 No Content
POST /api/v1/contact-lists/:id/contacts
Adds one or more contacts to a list (bulk add). Uses INSERT ... ON CONFLICT DO NOTHING — safe to call with contacts already in the list.
Request Body
{
"contactIds": ["<uuid>", "<uuid>"]
}Response — 200 OK
{
"added": 2
}DELETE /api/v1/contact-lists/:id/contacts
Removes one or more contacts from a list. The contacts themselves are not deleted from the account.
Request Body
{
"contactIds": ["<uuid>"]
}Response — 200 OK
{
"removed": 1
}Contacts
GET /api/v1/contacts
Returns paginated contacts for the account. Supports filtering and searching.
Query Parameters
| Param | Type | Notes |
|---|---|---|
page | integer | Default: 1 |
pageSize | integer | Default: 25, max: 100 |
search | string | Searches first name, last name, and phone |
listId | uuid | Filter to contacts in a specific list |
isOptedOut | boolean | Filter by opt-out status |
tags | string | Comma-separated tag names. Returns contacts that have all specified tags |
Response — 200 OK
{
"contacts": [
{
"id": "<uuid>",
"firstName": "John",
"lastName": "Doe",
"phone": "+254712345678",
"email": "john@example.com",
"tags": ["vip", "nairobi"],
"notes": "Prefers Swahili",
"isOptedOut": false,
"listIds": ["<list-uuid-1>", "<list-uuid-2>"],
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
}
],
"total": 150,
"page": 1,
"pageSize": 25
}GET /api/v1/contacts/tags
Returns all distinct tag strings used across the account's contacts.
Response — 200 OK
["enterprise", "nairobi", "newsletter", "vip"]GET /api/v1/contacts/:id
Returns a single contact by ID.
Response — 200 OK — same shape as a single item from the list above.
Errors
| Code | Cause |
|---|---|
404 Not Found | Contact not found or belongs to a different account |
POST /api/v1/contacts
Creates a new contact and optionally adds them to one or more lists.
Request Body
{
"firstName": "John",
"lastName": "Doe",
"phone": "+254712345678",
"email": "john@example.com",
"tags": ["vip"],
"notes": "Met at Nairobi conference 2025",
"listIds": ["<list-uuid>"]
}| Field | Required | Notes |
|---|---|---|
firstName | No | |
lastName | No | |
phone | Yes | Must be a valid international phone number |
email | No | |
tags | No | Array of strings |
notes | No | |
listIds | No | UUIDs of lists to add the contact to |
Response — 201 Created — returns the created contact.
Errors
| Code | Cause |
|---|---|
422 Unprocessable | Phone number fails validation |
PATCH /api/v1/contacts/:id
Updates a contact. All fields optional. listIds performs a full sync — the contact's list memberships are replaced with the provided array.
Request Body
{
"firstName": "John",
"phone": "+254722000000",
"tags": ["vip", "enterprise"],
"listIds": ["<list-uuid-a>", "<list-uuid-b>"]
}Response — 200 OK — returns the updated contact.
DELETE /api/v1/contacts/:id
Soft-deletes a contact (moves to trash). Sets deleted_at = NOW(). The contact disappears from active views but can be restored from the trash for 90 days.
Response — 204 No Content
POST /api/v1/contacts/bulk-delete
Soft-deletes multiple contacts at once.
Request Body
{
"ids": ["<uuid>", "<uuid>"]
}Response — 200 OK
{
"deleted": 2
}Trash
Soft-deleted contacts live in the trash for 90 days before being permanently removed.
GET /api/v1/contacts/trash
Returns all trashed contacts for the account.
Response — 200 OK — array of contact objects with deletedAt populated.
POST /api/v1/contacts/trash/restore
Restores one or more contacts from trash (clears deleted_at).
Request Body
{
"ids": ["<uuid>"]
}Response — 200 OK
{
"restored": 1
}DELETE /api/v1/contacts/trash
Permanently purges one or more trashed contacts. This is irreversible.
Request Body
{
"ids": ["<uuid>"]
}Response — 200 OK
{
"purged": 1
}Import & Export
POST /api/v1/contacts/import
Bulk-imports contacts from a CSV or Excel file. The file is parsed server-side and each phone number is validated against the mno_prefixes table synchronously at import time.
Content-Type: multipart/form-data
| Field | Type | Notes |
|---|---|---|
file | File | .csv or .xlsx |
listId | string (optional) | Add all imported contacts to this list |
Expected CSV columns (order flexible, matched by header name):
first_name, last_name, phone, email, tags, notesResponse — 201 Created
{
"imported": 90,
"valid": 87,
"invalid": 3,
"skipped": 2
}| Field | Meaning |
|---|---|
imported | Total rows inserted (valid + invalid combined) |
valid | Contacts whose phone prefix matched a known operator (status: valid) |
invalid | Contacts whose prefix was unrecognised — stored with status: invalid, invalid_reason: 'unknown_network' |
skipped | Rows rejected entirely — duplicate phone number or unparseable format |
POST /api/v1/contacts/bulk
JSON-body bulk import. Used by the frontend CSV import wizard after client-side column mapping and parsing.
Request Body
{
"contacts": [
{
"firstName": "Jane",
"lastName": "Doe",
"phone": "+254712345678",
"email": "jane@example.com",
"tags": ["vip"],
"notes": "Met at Nairobi conference"
}
],
"listId": "<optional-list-uuid>"
}Response — 201 Created
{
"imported": 90,
"valid": 87,
"invalid": 3,
"skipped": 2
}Same field semantics as POST /contacts/import.
GET /api/v1/contacts/export
Downloads all contacts (or contacts in a specific list) as a CSV file.
Query Parameters
| Param | Notes |
|---|---|
listId | Export only contacts in this list. Omit for all contacts |
Response — text/csv file download.
first_name,last_name,phone,email,tags,notes
John,Doe,+254712345678,john@example.com,vip;nairobi,Met at conferenceGET /api/v1/contacts/export-data
Returns contacts as a JSON array for client-side export formatting. Used by the UI's export wizard (which lets users choose CSV vs. other formats and send by email before downloading).
Query Parameters
| Param | Notes |
|---|---|
listId | Scope to a specific list. Omit for all contacts |
Response — 200 OK — array of contact objects (same shape as GET /contacts items).
POST /api/v1/contacts/export-email
Sends a pre-formatted export file to an email address. The client builds the file (base64-encoded) and the API dispatches the email via the configured mail provider.
Request Body
{
"email": "user@example.com",
"filename": "contacts-export.csv",
"fileBase64": "<base64-encoded-file-contents>",
"mimeType": "text/csv",
"count": 87
}Response — 200 OK (no body)
Phone Validation
Validation runs in two passes:
1. Client-side format check — google-libphonenumber (parseAndKeepRawInput + isValidNumberForRegion) confirms the number is structurally valid for its region before the form is submitted. Numbers that fail are rejected with an inline error.
2. Server-side MNO lookup — at import or single-contact creation time, the API normalises the phone (strips + / 00 IDD prefix, removes non-digits), then queries the mno_prefixes table using a longest-prefix-match algorithm. Contacts that match a known operator prefix receive status: 'valid'; those that do not are stored with status: 'invalid' and invalid_reason: 'unknown_network'. The lookup covers 53 African countries (151 prefixes).