Skip to main content

Overview

Contacts are the core entity in folksbase. Each contact belongs to a workspace and has standard fields (email, name, phone, company, notes) plus a flexible custom_fields JSONB column for anything else. Contacts can be tagged, searched, filtered, and exported. All contact operations go through the layered architecture: Route → Service → Repository → Drizzle. The API is fully documented via OpenAPI at /api/docs.

Creating Contacts

Contacts can be created in two ways:
  • Manually via the UI or POST /api/contacts
  • Bulk via CSV import (see CSV Import)
When creating a contact, the system automatically:
  • Normalizes the email address (lowercase, trimmed)
  • Fetches a Gravatar avatar using an MD5 hash of the email (2-second timeout, graceful fallback to null)
  • Invalidates the workspace stats cache
// POST /api/contacts
{
  "email": "[email]",
  "first_name": "[name]",
  "last_name": "[name]",
  "phone": "[phone_number]",
  "company": "Acme Corp",
  "notes": "Met at conference"
}
Email is the only required field. Duplicate emails within a workspace are handled via upsert during CSV import, but the manual create endpoint will insert a new record.

Listing & Pagination

The GET /api/contacts endpoint returns a paginated list using cursor-based pagination (never OFFSET). The response shape:
{
  "data": [{ "id": "...", "email": "...", "tags": [...] }],
  "nextCursor": "uuid-of-last-item",
  "total": 1234
}
Pass nextCursor as the cursor query parameter to fetch the next page. The default page size is 50, configurable up to 100 via the limit parameter. The search query parameter performs a case-insensitive ILIKE search across three fields simultaneously:
  • email
  • first_name
  • last_name
The frontend debounces search input by 300ms to avoid excessive API calls on every keystroke.
GET /api/contacts?search=jane

Filtering

By Tags

Pass one or more tag IDs as a comma-separated tag_ids query parameter. The filter uses an ANY match — contacts with at least one of the specified tags are returned.
GET /api/contacts?tag_ids=uuid-1,uuid-2
When tag filters are active, the total count in the response reflects the filtered count (using COUNT(DISTINCT contact.id) to avoid double-counting contacts with multiple matching tags).

By Unsubscribe Status

GET /api/contacts?is_unsubscribed=true

Updating Contacts

PATCH /api/contacts/:id accepts partial updates. Only the fields you send are modified. If the email changes, the system automatically re-fetches the Gravatar avatar for the new address.

Deleting Contacts

Two options:
  • Single deleteDELETE /api/contacts/:id returns 204
  • Bulk deletePOST /api/contacts/bulk-delete accepts up to 500 IDs per request
Both operations invalidate the count cache and stats cache.

Sending Emails

You can send a one-off email to any contact via POST /api/contacts/:id/send-email:
{
  "subject": "Follow up",
  "body": "Thanks for chatting at the conference."
}
The email is sent through Resend using the workspace’s configured API key (if set) or the default key. The sender name is derived from the authenticated user’s profile, and the reply-to address is set to the user’s email.

Tagging

Contacts support a many-to-many relationship with tags through the contact_tags junction table. See the Tags guide for details on creating and assigning tags. When listing contacts, tags are fetched in a single batch query (not N+1) and included in the response as a tags array on each contact.

Custom Fields

Any CSV column that doesn’t map to a standard field is stored in the custom_fields JSONB column. These fields are:
  • Preserved during upserts
  • Included in CSV exports
  • Indexed with a GIN index for query performance
See the Database docs for more on the schema design.