The Contacts API is the primary way to read, write, search, and bulk-manage contacts in NimbusOS programmatically. Every endpoint enforces workspace isolation through the TenantScopedMixin, returns JSON, and supports cursor-based pagination for large result sets. This article covers the core endpoints with example requests and responses.
Base URL and Authentication
All endpoints live under https://getnimbusos.com/api/v1/contacts/.
Authentication via Bearer token in the Authorization header. JWT or API key. See the Authentication article.
Contact Object
A contact is returned as:
{
"id": "cont_abc123",
"workspace_id": "ws_xyz789",
"email": "alice@acme.com",
"first_name": "Alice",
"last_name": "Smith",
"full_name": "Alice Smith",
"title": "Director of Revenue Operations",
"seniority": "director",
"department": "revenue_operations",
"phone": "+1-555-0100",
"mobile": null,
"linkedin_url": "https://linkedin.com/in/alicesmith",
"city": "New York",
"state": "NY",
"country": "US",
"time_zone": "America/New_York",
"lead_source": "apollo_import_20260401",
"lead_score": 72,
"icp_tier": "A",
"grade": "A",
"tags": ["director_plus", "us_east", "verified_email"],
"verification_status": "valid",
"is_enriched": true,
"is_deleted": false,
"company": {
"id": "comp_def456",
"name": "Acme Corporation",
"domain": "acme.com",
"industry": "Software",
"employee_count": 350,
"revenue_band": "25M_50M"
},
"custom_fields": {
"onboarding_stage": "trial_active",
"assigned_rep": "rep_1"
},
"created_at": "2026-04-01T10:00:00Z",
"updated_at": "2026-04-20T14:32:11Z"
}
List Contacts
GET /api/v1/contacts/?page_size=100&icp_tier=A,B
Authorization: Bearer <token>
Query parameters:
page_size: 1 to 500, default 50.cursor: pagination cursor from previous response.icp_tier: comma-separated list of tiers (A, B, C, D).grade: comma-separated list of grades.tag: filter by one or more tags.segment_id: filter to segment members.search: full-text search across email, name, title, company.created_after,updated_after: ISO 8601 timestamps.sort: one ofcreated_desc,created_asc,updated_desc,lead_score_desc.
Response:
{
"data": [
{ "id": "cont_abc", ... },
...
],
"meta": {
"next_cursor": "eyJpZCI6ImNvbnRfMTIzIn0=",
"has_more": true,
"total_count": 1250
}
}
Pagination is cursor-based. Pass next_cursor in the next request to continue.
Get Contact
GET /api/v1/contacts/cont_abc123/
Returns the single contact object.
Create Contact
POST /api/v1/contacts/
Content-Type: application/json
{
"email": "bob@example.com",
"first_name": "Bob",
"last_name": "Johnson",
"title": "VP Marketing",
"company_domain": "example.com",
"tags": ["inbound_lead", "demo_requested"],
"custom_fields": {
"lead_source_detail": "organic_search_q2"
}
}
Response: 201 Created with the created contact object.
Creation runs through the same pipeline as import: dedup against GlobalContact, suppression check, ICP segmentation, scoring. If dedup finds an existing contact in the workspace, the request returns 200 with the existing contact (not 201).
If the email is currently assigned to another client (agency mode) or is globally suppressed, the response is 409 with a specific error code.
Update Contact
PATCH /api/v1/contacts/cont_abc123/
Content-Type: application/json
{
"title": "Senior Director of RevOps",
"tags": ["director_plus", "us_east", "verified_email", "executive_sponsor"]
}
PATCH updates only the provided fields. Omitted fields remain unchanged. tags replaces the full array; to add a tag without replacing, use the tags endpoint:
POST /api/v1/contacts/cont_abc123/tags/
Content-Type: application/json
{
"add": ["executive_sponsor"],
"remove": []
}
Delete Contact
Soft delete:
DELETE /api/v1/contacts/cont_abc123/
Content-Type: application/json
{
"deletion_reason": "opted_out_via_form"
}
Returns 200. The contact is marked is_deleted=true but preserved in the database.
Hard delete (GDPR):
DELETE /api/v1/contacts/cont_abc123/?hard=true
Requires the admin:gdpr scope. Removes the contact row, clears engagement history, and writes a retention log.
Bulk Create
POST /api/v1/contacts/bulk/
Content-Type: application/json
{
"contacts": [
{ "email": "contact1@example.com", "first_name": "Alice", ... },
{ "email": "contact2@example.com", "first_name": "Bob", ... },
...
],
"options": {
"dedup_behavior": "update",
"trigger_enrichment": true,
"trigger_scoring": true
}
}
Up to 1,000 contacts per request. For larger batches, use the import job endpoint:
POST /api/v1/imports/
Content-Type: multipart/form-data
file: <csv>
field_mapping_id: fm_abc
The import endpoint supports up to 500,000 rows per upload and runs asynchronously.
Search
Beyond the basic search query param, the API supports advanced query via POST:
POST /api/v1/contacts/search/
Content-Type: application/json
{
"filters": [
{ "field": "icp_tier", "op": "in", "value": ["A", "B"] },
{ "field": "lead_score", "op": "gte", "value": 60 },
{ "field": "has_tag", "op": "eq", "value": "verified_email" }
],
"filters_logic": "AND",
"sort": [{ "field": "lead_score", "direction": "desc" }],
"page_size": 100
}
Supported operators: eq, ne, gt, gte, lt, lte, in, not_in, contains, starts_with, ends_with, is_empty, is_not_empty.
Custom Fields
Custom fields are workspace-scoped. Create via:
POST /api/v1/custom-fields/
Content-Type: application/json
{
"name": "onboarding_stage",
"display_label": "Onboarding Stage",
"field_type": "dropdown",
"options": ["not_started", "trial_active", "trial_expired", "customer"],
"fallback_value": "not_started"
}
Field types: string, number, boolean, date, dropdown, multi_select.
Once a custom field exists, contact create and update calls can include custom_fields.<name> with the value.
Engagement Events
Each contact has a timeline of events. Read:
GET /api/v1/contacts/cont_abc123/events/?event_type=email_opened,email_replied&limit=50
Events are immutable. Writing events directly via API is not supported; events are created by the email engine and the reply intelligence layer.
Enrollments
List a contact's sequence enrollments:
GET /api/v1/contacts/cont_abc123/enrollments/
Enroll a contact:
POST /api/v1/sequences/seq_abc/enroll/
Content-Type: application/json
{
"contact_ids": ["cont_abc123", "cont_def456"]
}
The enrollment runs through the campaign filter and ICP checks the way a normal enrollment does.
Error Responses
Standard error shape:
{
"error": {
"code": "validation_error",
"message": "Email is required",
"field": "email"
}
}
Common error codes:
- 400
validation_error: request body invalid - 401
authentication_required: no valid auth - 403
forbidden: scope insufficient or tenant mismatch - 404
not_found: resource does not exist in this workspace - 409
conflict: dedup or assignment conflict - 422
suppressed: contact is suppressed - 429
rate_limited: too many requests - 500
internal_error: server-side issue
Idempotency
POST requests support idempotency via Idempotency-Key header. A retry with the same key returns the original response without re-executing.
POST /api/v1/contacts/
Idempotency-Key: create_contact_bob_20260423
...
Idempotency keys are retained for 24 hours. Keys must be unique per operation.
Webhooks on Contact Events
Subscribe to contact events via the Webhooks API:
contact.createdcontact.updatedcontact.deletedcontact.enrichedcontact.scored
Each event carries the full contact object in the data field.
Common Integration Patterns
Pattern 1: Nightly sync from internal CRM
Scheduled job queries the internal CRM for changed records, transforms to NimbusOS contact format, and bulk-creates via POST /api/v1/contacts/bulk/ with dedup_behavior=update.
Pattern 2: Real-time webhook ingestion
A webhook fires when a contact changes in your system. Handler calls POST /api/v1/contacts/ with Idempotency-Key set from the source event ID to handle retries cleanly.
Pattern 3: Score-based enrollment
Scheduled job queries for contacts where lead_score >= 70 and has_tag=['hot'], then enrolls them in a specific sequence via the enrollment endpoint.
Troubleshooting
"Bulk create returns 200 but only half the contacts were created"
Dedup behavior and suppression. Check the response body for a per-row status breakdown. Contacts that matched existing records were updated, not created; suppressed contacts were rejected.
"Contact appears in API but missing from UI list"
Filter mismatch. The UI has default filters (e.g., hide deleted). API does not apply those unless specified. Check is_deleted on the contact.
"Update returns 403 Forbidden"
Scope missing (contacts:write), or workspace mismatch. Check the API key's scopes.
"Custom field value not persisted"
The custom field does not exist in the workspace. Create it first via POST /api/v1/custom-fields/, then retry the contact update.
Frequently Asked Questions
Is there a search index that handles complex queries?
Yes. The search endpoint uses a Postgres-backed indexing layer. Complex filters run efficiently up to millions of contacts.
Can I query across workspaces with one API call?
No. Workspace isolation is enforced. Multi-workspace agencies make one call per workspace.
How fast are bulk operations?
A 1,000 contact bulk create completes in 2 to 10 seconds typically. Enrichment if triggered runs asynchronously after the bulk response returns.
Is there a GraphQL endpoint?
No. REST only. GraphQL is a roadmap item for enterprise.
What to Read Next
Useful next pages after this one: Campaigns API for campaign operations, Authentication for the token model, and Rate Limits for the throughput envelope.