Feature Specification: Integrations — Twenty CRM
Feature ID: integrations-twenty-crm
Branch: docs/spec-integrations-twenty-crm
Status: Retrospective
Created: 2026-05-08
Last updated: 2026-05-08
Owner: Ever Works Team
1. Overview
The Twenty CRM integration lets the platform mirror its native domain
objects (clients, companies, items) to a self-hosted or cloud
Twenty CRM workspace via Twenty's REST API.
Once configured, authenticated platform users can drive
companies / contacts / deals / products through the integration's
HTTP surface (/api/twenty-crm/companies,
/api/twenty-crm/people), which proxies to the Twenty workspace's
/rest and /rest/metadata endpoints. The integration is gated by
three required environment variables (base URL, API key, workspace
id) — when any are missing the integration self-disables and the
CrmSyncGuard refuses any inbound request without surfacing the
internal error to clients.
2. User Scenarios
2.1 Primary scenarios
- Given a workspace administrator has set
TWENTY_CRM_BASE_URL,TWENTY_CRM_API_KEY, andTWENTY_CRM_WORKSPACE_ID, when the API process starts, thenCrmConfigService.isEnabledreturnstrueandCrmSyncGuard.canActivatereturnstruefor any inbound request. - Given I am signed in, when I
GET /api/twenty-crm/companies, then the controller proxies to Twenty'sGET <apiUrl>/rest/companieswith the workspace's bearer token and workspace header, and returns the company list. - Given I
POST /api/twenty-crm/companieswith aTwentyOrganizationbody, when the controller forwards the body, then Twenty returns the created organization with its generatedid, and the controller relays it verbatim. - Given I
PATCH /api/twenty-crm/companies/:id, when Twenty applies the partial update, then the response body is the updated organization. (Note: the controller forwards viaPUTinternally — see OQ-3.) - Given I
DELETE /api/twenty-crm/companies/:id, when Twenty removes the row, then the controller returns the empty body Twenty replies with. - Given the integration is enabled and an item-mapper is run,
when
MappingUtils.mapClientToContact(client)is invoked, then the first space-separated token ofclient.namebecomesfirstNameand the rest becomeslastName. - Given an
EverWorksItemhas nocurrency, whenMappingUtils.mapItemToProductormapItemToDealruns, then the resulting Twenty entity defaults tocurrency: 'USD'. - Given an
EverWorksCompanyhas a non-URLwebsitefield (e.g."acme.com"), whenMappingUtils.mapCompanyToOrganizationruns, then the helper prependshttps://before parsing sodomainNameis set to the hostname. - Given the
TwentyCrmServicemakes a request and Twenty responds with a 5xx, when wrapped byRetryUtils.withRetry, then the call retries up tomaxAttemptstimes with exponential backoff atdelayMs * backoffMultiplier^(attempt-1). - Given a tenant context resolver runs with a
workId, whenCrmTenantService.resolveTenantContext(workId, userId)is called, thentenantIdis built aswork_<workId>and the endpoint prefix is/tenants/work_<workId>.
2.2 Edge cases & failures
- Given any of the three required env vars is missing, when
any controller endpoint is hit, then
CrmSyncGuardreturnsfalseand the global guard layer answers403 Forbidden— the request never reachesClientService. - Given the env vars are present but
CrmConfigService.validateConfigthrows (e.g. blank-string-vs-undefined), when the guard catches the throw, then it logs'CRM configuration validation failed'vialogger.errorand returnsfalse. - Given Twenty responds with a
429, whenRetryUtils.isRetryableErrorevaluates the error, then it returnstrueand the retry loop fires another attempt. - Given a request ECONNRESET / ETIMEDOUT / ENOTFOUND occurs,
when
RetryUtils.isRetryableErrorevaluates it, then the retry loop continues even though there is noerror.response. - Given Twenty returns a 4xx (other than 429), when the retry
helper sees it, then
isRetryableErrorreturnsfalseand the loop exits with the original error. - Given the
withRetryloop runsmaxAttempts=1(the test-mode default), when the first call throws, then the helper does NOT sleep before re-throwing the original error. - Given Twenty returns no body on error (network timeout, DNS
failure), when
TwentyCrmService.makeRequestcatches the thrown axios error, then the service logs the event and re-throws asHttpException("Failed to communicate with Twenty CRM", 503). - Given Twenty returns an error body with
message,statusCode, and optionaldetails, whenmakeRequestcatches it, then the service throwsHttpException({message, details}, error.response.status ?? 500). - Given the Twenty error body's
messageis missing, whenmakeRequestbuilds the wrapped exception, then the fallback message'Twenty CRM API error'is used. - Given an integration consumer hits an endpoint with
schema: true, whenmakeRequestbuilds the URL, then it uses${apiUrl}/rest/metadata${endpoint}instead of${apiUrl}/rest${endpoint}. - Given
workspaceIdis the empty string, whenmakeRequestreads theX-Workspace-Idheader, then the default'default'is sent (the falsy short-circuitworkspaceId || 'default'triggers). - Given
MappingUtils.mapClientToContactruns against anEverWorksClientwhosenameis a single token (e.g. "Madonna"), whensplit(' ')returns one element, thenfirstNameis set to that token andlastNameis''. - Given
MappingUtils.validateContactDatachecks a contact with no name fields and no email, when it runs, then it returns both'Either firstName or lastName is required'and'Email is required'. - Given an
EverWorksCompany.websiteis malformed ('not-a-url'even after https-prefix), whenextractDomainFromWebsiteparses, then theURLconstructor throws and the helper returnsundefined. - Given
CrmTenantService.resolveTenantContextis called with noworkIdand noglobalTenantId, when it buildstenantId, then it falls back to the literal'global_everworks'. - Given
CrmTenantService.validateTenantContextis called with a context whosetenantIdis empty/undefined, when it logs, then it emits'Tenant ID is required'vialogger.errorand returnsfalse. - Given the
PeopleControllerreceives aPOSTbody with extra fields beyond the documented contact shape, when the controller forwards toclientService.createContact, then ONLY the whitelisted fields (firstName,lastName,email,phone,companyId,position,avatarUrl) are forwarded — extra keys are silently dropped via the explicit object construction. - Given the
CompaniesControllerreceives aPOSTbody, when the controller forwards, then the full object is passed through verbatim (no whitelist) — this divergence is documented in OQ-4.
3. Functional Requirements
- FR-1 The integration MUST be globally registered as a Nest
@Global()module viaTwentyCrmModule.forRoot()/forRootAsync()soTwentyCrmService,ClientService,CrmTenantService, andCrmConfigServiceare exported and injectable from any other module. - FR-2
CrmConfigService.twentyCrmConfigMUST return the live(apiUrl, apiKey, workspaceId, timeout, retryAttempts, retryDelay)tuple read fromConfigService(env varsTWENTY_CRM_BASE_URL,TWENTY_CRM_API_KEY,TWENTY_CRM_WORKSPACE_ID,TWENTY_CRM_TIMEOUT_MS(default 30000),TWENTY_CRM_MAX_RETRIES(default 3),TWENTY_CRM_RETRY_DELAY_MS(default 1000)). - FR-3
CrmConfigService.isEnabledMUST evaluate!!(apiUrl && apiKey && workspaceId)— empty strings count as falsy. - FR-4
CrmConfigService.validateConfigMUST throwError('Missing required Twenty CRM configuration: <list>')when any of the three required keys is missing, listing each missing key by its env-var name; otherwise it MUST returntrue. - FR-5
CrmSyncGuard.canActivateMUST short-circuit tofalsewithlogger.warn('CRM integration is disabled - request blocked')whenisEnabledisfalse; MUST callvalidateConfig; MUST returnfalsewithlogger.error('CRM configuration validation failed:', err)on any throw; MUST returntrueonly when both pass. - FR-6 The
@CrmSync(enabled?: boolean)decorator MUST set thecrm_syncNest metadata key with the suppliedenabledflag (defaulttrue). - FR-7
TwentyCrmService.makeRequest(method, endpoint, data?, params?, schema?)MUST: build the URL as${apiUrl}/rest${endpoint}(or${apiUrl}/rest/metadata${endpoint}whenschema === true); set headersAuthorization: Bearer <apiKey>,Content-Type: application/json,X-Workspace-Id: <workspaceId || 'default'>; honour the configuredtimeout; logMaking <method> request to <url>at debug; returnresponse.data. - FR-8 When the request throws,
TwentyCrmService.makeRequestMUST log'Twenty CRM API error: <msg>'with the{endpoint, method, status, data}context; iferror.response.datais set, throwHttpException({message: data.message ?? 'Twenty CRM API error', details: data.details}, error.response.status ?? 500); else throwHttpException('Failed to communicate with Twenty CRM', HttpStatus.SERVICE_UNAVAILABLE). - FR-9
ClientServiceMUST expose 16 thin-wrapper methods —create/get/update/deletefor each of the four entity types (company,contact,deal,product) plus four list endpoints (getCompanies,getContacts,getDeals,getProducts) — each delegating toTwentyCrmService.makeRequestwith the appropriate(method, endpoint[, body])tuple. - FR-10 All
ClientService.create*methods MUSTPOSTto/<plural-entity>,get*(id)MUSTGET /<plural-entity>/:id,update*(id, body)MUSTPUT /<plural-entity>/:id, anddelete*(id)MUSTDELETE /<plural-entity>/:idwith no body. - FR-11
CompaniesControllerMUST be guarded byAuthSessionGuard(@UseGuards(AuthSessionGuard)at the class level) so only signed-in users can drive it. ThePeopleControlleris currently NOT guarded — see OQ-1. - FR-12
CompaniesControllerMUST exposeGET /api/twenty-crm/companies,POST /api/twenty-crm/companies,PATCH /api/twenty-crm/companies/:id,DELETE /api/twenty-crm/companies/:idand MUST forward the request to the appropriateClientServicemethod. - FR-13
PeopleControllerMUST exposeGET /(path is currently mounted as the bare class — see OQ-1 and the duplicated controller path in OQ-2),POST /,PATCH /:id,DELETE /:id. OnPOST, the controller MUST explicitly project the body into{firstName, lastName, email, phone, companyId, position, avatarUrl}— extra keys MUST be dropped at the boundary. - FR-14
CrmTenantService.resolveTenantContext(workId?, userId?, globalTenantId?)MUST settenantId = workId ? \work_${workId}` : globalTenantId || 'global_everworks', log the resulting context at debug, and return{tenantId, workId, userId}`. - FR-15
CrmTenantService.getTenantEndpointPrefix(ctx)MUST return/tenants/${ctx.tenantId}so callers can build multi-tenant URLs. - FR-16
CrmTenantService.validateTenantContext(ctx)MUST returnfalse(withlogger.error('Tenant ID is required')) whentenantIdis falsy, otherwisetrue. - FR-17
CrmTenantService.getTenantConfig(ctx)MUST return a flattened{tenantId, workId, userId}object — preservingundefinedvalues rather than stripping them. - FR-18
RetryUtils.withRetry(fn, maxAttempts=3, delayMs=1000, backoffMultiplier=2)MUST runfn()once per attempt, sleepdelayMs * backoffMultiplier^(attempt-1)between attempts, and re-throw the LAST error after exhausting attempts. The helper MUST NOT sleep whenmaxAttempts === 1. - FR-19
RetryUtils.isRetryableErrorMUST returntrueforerror.code in {ECONNRESET, ETIMEDOUT, ENOTFOUND}, forerror.response.status >= 500, and forerror.response.status === 429. All other shapes MUST returnfalse. - FR-20
RetryUtils.calculateRetryDelay(baseDelayMs, attempt, backoffMultiplier=2, maxDelayMs=30000)MUST computebaseDelayMs * backoffMultiplier^(attempt-1), add 10% jitter (Math.random() * 0.1 * delay), and clamp the result viaMath.min(delay + jitter, maxDelayMs). - FR-21
MappingUtils.mapClientToContactMUST splitclient.nameon the first space —firstNameis the first token (or''if the name is empty),lastNameis the rest joined by a single space (or''). Email/phone/position/companyId pass through unchanged. - FR-22
MappingUtils.mapCompanyToOrganizationMUST setname: company.name,employees: company.size, anddomainName: extractDomainFromWebsite(company.website).extractDomainFromWebsiteMUSTURL-parse the input (after prependinghttps://if it doesn't start withhttp); on parse failure it MUST returnundefined. - FR-23
MappingUtils.mapItemToProductMUST setcurrency: item.currency || 'USD'and pass throughname/description/price/category. - FR-24
MappingUtils.mapItemToDealMUST settitle: item.name,amount: item.price,currency: item.currency || 'USD',stage: 'NEW',probability: 50, and pass throughcompanyId/personId(item.clientId). - FR-25 Every
validate*Datahelper MUST return an array of human-readable error strings — empty when the entity is valid, populated when required fields are missing.validateContactDatarequires(firstName || lastName)ANDemail.validateOrganizationDatarequiresname.validateProductDatarequiresname.validateDealDatarequirestitle.
4. Non-Functional Requirements
- Performance: HTTP requests MUST honour
TWENTY_CRM_TIMEOUT_MS(default 30 s). Retry-back-off is exponential with 10% jitter to avoid synchronised retry storms across replicas. - Reliability: Network failures (ECONNRESET / ETIMEDOUT / ENOTFOUND), 5xx, and 429 are retryable. 4xx (other than 429) are fatal. The retry loop MUST always re-throw the LAST attempt's error rather than the FIRST.
- Security & privacy: API keys are read from environment
variables only — never echoed in responses, never logged. The
bearer token is set in the request header by
TwentyCrmServiceitself, not the caller. Cross-tenant data leak is prevented byCrmTenantService— thetenantIdis always derived from the caller-suppliedworkId/userId. - Observability:
TwentyCrmServicelogs every request atdebug(Making <method> request to <url>); errors aterrorwith{endpoint, method, status, data}.CrmSyncGuardlogs the disabled / config-failure paths atwarn/error. - Compatibility: Twenty CRM REST v0.x — the integration uses
Twenty's
/restand/rest/metadataendpoints. Twenty workspaces on different versions may diverge; the integration is pinned to the documented schema by theTwenty<Entity>types intypes/twenty-crm.types.ts.
5. Key Entities & Domain Concepts
| Entity / concept | Description |
|---|---|
TwentyContact | Twenty CRM person — firstName, lastName, email, phone, position, companyId, avatarUrl, plus standard id/timestamps. |
TwentyOrganization | Twenty CRM company — name, domainName, address, employees, linkedinUrl, xUrl, annualRecurringRevenue, idealCustomerProfile. |
TwentyProduct | Twenty CRM product — name, description, price, currency, category. |
TwentyDeal | Twenty CRM deal — title, amount, currency, stage, probability, companyId, personId. |
EverWorksClient | Platform-side client/customer — name (single field, split at the boundary), email, phone, companyId, position. |
EverWorksCompany | Platform-side company — name, website, description, industry, size. |
EverWorksItem | Platform-side item — name, description, price, currency, category, companyId, clientId. |
CrmTenantContext | (tenantId, workId?, userId?) — derived by CrmTenantService.resolveTenantContext and consumed by tenant-aware callers. |
CrmConfigService | Reads TWENTY_CRM_* env vars; gates the integration via isEnabled. |
CrmSyncGuard | Nest guard that refuses the request when isEnabled is false or validateConfig throws. |
@CrmSync(enabled?) | Decorator that stamps the crm_sync metadata key. Currently informational — no consumer reads it (see OQ-5). |
RetryUtils.withRetry | Retry helper: exponential back-off with last-error re-throw. |
MappingUtils | EverWorks → Twenty wire-format mappers + validate*Data helpers. |
6. Out of Scope
- Writing back from Twenty into the platform — this integration is push-only.
- Webhook receivers from Twenty for two-way sync.
- Twenty workspace bootstrapping (creating the workspace, schema migrations, seed data) — the integration assumes a pre-provisioned workspace.
- Field-level access control / audit logging beyond Nest's standard request logger.
- Multi-workspace support (a single platform process today maps to
a single Twenty workspace via the
TWENTY_CRM_WORKSPACE_IDenv var). Multi-tenant viaCrmTenantServiceis for endpoint-prefix modelling, NOT multi-workspace fan-out. - Bulk-import endpoints — the controllers are per-row CRUD only.
- Rate-limit-aware queueing — the retry helper covers transient 429s but does not back off globally on sustained throttling.
7. Acceptance Criteria
- Setting all three env vars (
TWENTY_CRM_BASE_URL,TWENTY_CRM_API_KEY,TWENTY_CRM_WORKSPACE_ID) flipsisEnabledtotrue. - Removing any one env var causes
CrmSyncGuard.canActivateto returnfalseandvalidateConfigto throw with the missing key listed. -
GET / POST / PATCH / DELETE /api/twenty-crm/companies[/:id]proxy correctly to Twenty. -
POST /api/twenty-crm/peoplestrips extra body fields, forwarding only the whitelisted contact shape. -
RetryUtils.withRetry(fn, 3)retries 3 times, sleeping 1s / 2s / (no-sleep on the final throw); the last error is surfaced. -
RetryUtils.isRetryableErrorreturnstruefor ECONNRESET / ETIMEDOUT / ENOTFOUND / 5xx / 429 andfalsefor everything else. -
MappingUtilsproduces the exact mapping shape pinned by the tests in PR #498 (multi-word vs single-token name split, USD-default currency, 50% probability NEW-stage deals, URL parse fallback toundefined). -
CrmTenantService.resolveTenantContextproduceswork_<workId>, falls back toglobalTenantId, then to'global_everworks'. - All 107 unit tests in #498 remain green.
8. Open Questions
[NEEDS CLARIFICATION: OQ-1]PeopleControlleris not decorated with@UseGuards(AuthSessionGuard)— meaning, with the current global guard configuration, requests against it may bypass authentication if the global pipeline doesn't already cover it. Should the controller be explicitly guarded for parity withCompaniesController?[NEEDS CLARIFICATION: OQ-2]PeopleControllerdoes NOT have a@Controller('api/twenty-crm/people')decorator at all — it is a bareclass PeopleControllerwhich would not register routes unless added to a module'scontrollers: []array. InspectingTwentyCrmModule.forRoot()shows only[CompaniesController]is registered. IsPeopleControllercurrently dead code? The type signature suggests it was meant to be live; needs an explicit decision: either remove it or wire it into the module.[NEEDS CLARIFICATION: OQ-3]CompaniesControllerandPeopleControllerboth expose@Patch(':id')for updates, butClientService.updateCompany/updateContact/ etc. proxy viaPUT(full replacement). Should the integration use Twenty'sPATCHendpoint for partial updates instead?[NEEDS CLARIFICATION: OQ-4]CompaniesController.createCompanyforwards the body verbatim, whilePeopleController.createContactwhitelists only seven fields. Should both controllers share the same whitelist policy?[NEEDS CLARIFICATION: OQ-5]The@CrmSync(enabled)decorator stampscrm_syncmetadata but no consumer (no guard, no interceptor, no service) currently reads that metadata. Either wire it into a real switch (e.g. an interceptor that no-ops the request whencrm_syncisfalse) or remove the decorator.[NEEDS CLARIFICATION: OQ-6]TheTwentyCrmModule.forRoot()signature acceptsPartial<CrmConfigService>but readsconfig?.twentyCrmConfig.timeoutfrom it —Partial<>makes that property optional and the chained.timeoutaccess is unsafe iftwentyCrmConfigis missing. The TS compiler tolerates it because the call site passes?., but a future refactor could break.[NEEDS CLARIFICATION: OQ-7]RetryUtilsis exported as a class with only static methods. Is there an architecture preference for pure functions vs static-class helpers in the codebase? Cleaning this up would need a one-shot rename across consumers.[NEEDS CLARIFICATION: OQ-8]ClientService.makeRequesthas no retry wrapper. The retry helper exists but is not consumed by the default request path; only callers who explicitly useRetryUtils.withRetry(() => clientService.x(...))get retries. Should retries be the default?
9. Constitution Gates
- Plugin-first if introducing an external integration
(Principle I): partial — the integration is implemented
INSIDE
apps/api/, not as an external plugin. This is an intentional choice (the integration touches platform-side mapping types and tenant resolution), but a future move topackages/plugins/twenty-crmshould be considered. - Capability-driven resolution: N/A — no plugin capability is declared.
- Source-of-truth repos preserved: the platform's own data remains in its DB; Twenty receives a projection only. ✅
- Long-running work via Trigger.dev: N/A — all CRUD is request-scoped. (Bulk syncs would change this.)
- Schema changes ship as forward-only migrations: N/A — no DB schema added.
- Tests accompany the change: 107 unit tests in PR #498. ✅
- Secrets handled per
x-secretrules: API key in env var only, never logged, never echoed back. ✅ - Plugin counts touch the canonical doc only: N/A — not a plugin yet.
- Behaviour-first — no implementation in this spec. ✅
- Backwards-compatible API/SDK/schema changes: the integration is additive and gated by env vars. No existing endpoint is affected. ✅
10. References
- Source:
apps/api/src/integrations/twenty-crm/apps/api/src/integrations/twenty-crm/twenty-crm.module.tsapps/api/src/integrations/twenty-crm/services/twenty-crm.service.tsapps/api/src/integrations/twenty-crm/services/client.service.tsapps/api/src/integrations/twenty-crm/services/crm-tenant.service.tsapps/api/src/integrations/twenty-crm/utils/retry.utils.tsapps/api/src/integrations/twenty-crm/utils/mapping.utils.tsapps/api/src/integrations/twenty-crm/config/crm-config.service.tsapps/api/src/integrations/twenty-crm/guards/crm-sync.guard.tsapps/api/src/integrations/twenty-crm/decorators/crm-sync.decorator.tsapps/api/src/integrations/twenty-crm/types/twenty-crm.types.tsapps/api/src/integrations/twenty-crm/types/mapping.types.tsapps/api/src/integrations/twenty-crm/controllers/companies.service.tsapps/api/src/integrations/twenty-crm/controllers/people.controler.ts
- Tests: 107 unit tests across the module — see PR #498.
- External: Twenty CRM REST API docs.