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.