Skip to main content

Twenty CRM Integration

The Twenty CRM module provides a REST client for integrating with Twenty, an open-source CRM. It supports CRUD operations on companies, contacts, products, and deals, along with entity mapping utilities and multi-tenant context resolution.

Architecture

apps/api/src/integrations/twenty-crm/
twenty-crm.module.ts # Global module with forRoot()/forRootAsync()
config/
crm-config.service.ts # Environment-based configuration
services/
twenty-crm.service.ts # Base REST client (HTTP methods, auth, errors)
client.service.ts # Entity-level CRUD operations
crm-tenant.service.ts # Multi-tenant context resolution
controllers/
companies.service.ts # CompaniesController (REST endpoints)
types/
twenty-crm.types.ts # CRM entity interfaces
mapping.types.ts # Ever Works entity interfaces
utils/
mapping.utils.ts # Entity mapping functions
retry.utils.ts # Retry with exponential backoff

Configuration

The module reads configuration from environment variables via CrmConfigService:

Environment VariableDefaultDescription
TWENTY_CRM_BASE_URL--Twenty CRM API base URL
TWENTY_CRM_API_KEY--API key for authentication
TWENTY_CRM_WORKSPACE_ID--Workspace identifier
TWENTY_CRM_TENANTS--Per-tenant workspace credentials (JSON map)
TWENTY_CRM_TIMEOUT_MS30000Request timeout in milliseconds
TWENTY_CRM_MAX_RETRIES3Maximum retry attempts
TWENTY_CRM_RETRY_DELAY_MS1000Base retry delay in milliseconds

The isEnabled getter returns true only when all three required variables (BASE_URL, API_KEY, WORKSPACE_ID) are set.

Per-tenant isolation (cross-tenant IDOR fix)

All Ever Works tenants would otherwise share one Twenty workspace + API key, so any authenticated user could read/mutate/delete every tenant's CRM records. The companies/people controllers resolve the caller's real tenantId (fail-closed if absent) and pass it down to makeRequest, which selects that tenant's own Twenty workspace credentials via CrmConfigService.configForTenant(tenantId). Because a Twenty API key is scoped to a single workspace, a caller can only ever address rows in their own workspace — isolation is enforced by the credential, not by a URL path prefix (Twenty's REST API exposes objects at /rest/<object> and has no tenant path routing).

Configure TWENTY_CRM_TENANTS as a JSON map of tenantId -> { apiKey, workspaceId, apiUrl? }, e.g. {"<tenant-uuid>":{"apiKey":"...","workspaceId":"..."}}. A tenant-scoped call fails closed (refused with 404) unless that tenant has an explicit entry with an API key — the resolver never falls back to the shared TWENTY_CRM_API_KEY for per-caller calls, so a partially-populated map or a still-set legacy default cannot leak one tenant's records to another. The shared base values are used only by internal/system sync paths (which are not per-caller).

Base REST Client

TwentyCrmService provides a generic makeRequest<T>() method that handles all HTTP communication:

  • Authentication: Bearer token via Authorization header
  • Workspace: X-Workspace-Id header on every request
  • Base URLs: /rest for entity operations, /rest/metadata for schema operations
  • Error handling: Maps Twenty CRM error responses to NestJS HttpException with status codes and details

Entity CRUD Operations

ClientService wraps TwentyCrmService with typed methods for each entity:

EntityCreateRead (one)Read (all)UpdateDelete
CompaniescreateCompanygetCompanygetCompaniesupdateCompanydeleteCompany
ContactscreateContactgetContactgetContactsupdateContactdeleteContact
DealscreateDealgetDealgetDealsupdateDealdeleteDeal
ProductscreateProductgetProductgetProductsupdateProductdeleteProduct

CRM Entity Types

TwentyContact

interface TwentyContact {
id?: string;
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
companyId?: string;
position?: string;
avatarUrl?: string;
}

TwentyOrganization

interface TwentyOrganization {
id?: string;
name: string;
domainName?: string;
address?: string;
employees?: number;
linkedinUrl?: string;
annualRecurringRevenue?: number;
idealCustomerProfile?: boolean;
}

TwentyProduct / TwentyDeal

Products have name, description, price, currency, and category. Deals have title, amount, currency, stage, probability, companyId, and personId.

Entity Mapping

MappingUtils provides static methods to convert between Ever Works entities and Twenty CRM entities:

MethodFromTo
mapClientToContact()EverWorksClientTwentyContact
mapCompanyToOrganization()EverWorksCompanyTwentyOrganization
mapItemToProduct()EverWorksItemTwentyProduct
mapItemToDeal()EverWorksItemTwentyDeal

Validation helpers (validateContactData, validateOrganizationData, validateProductData, validateDealData) return arrays of error messages for missing required fields.

Multi-Tenant Support

CrmTenantService resolves tenant context (tenantId, workId, userId) for operations that need workspace isolation.

Retry Utilities

RetryUtils provides withRetry() with exponential backoff and jitter. The isRetryableError() method identifies transient errors (network timeouts, 5xx status codes) that are safe to retry.

Module Registration

// Static configuration
TwentyCrmModule.forRoot({
twentyCrmConfig: { apiKey: '...', apiUrl: '...', workspaceId: '...' }
});

// Async configuration
TwentyCrmModule.forRootAsync({
useFactory: (configService) => configService.getCrmConfig(),
inject: [ConfigService]
});

The module is decorated with @Global(), making TwentyCrmService, ClientService, CrmTenantService, and CrmConfigService available application-wide.