| name | twenty-crm |
| description | Manage contacts, companies, deals, tasks, and notes in the Twenty CRM. Use when the user asks about CRM data, contacts, leads, companies, opportunities, deals, pipelines, tasks, or notes. Also use for creating, updating, or searching CRM records. |
Twenty CRM REST API
Manage CRM data via Twenty's REST API (local Docker instance).
Authentication
Load the API key from /opt/ph-infra/.env — same pattern as the Mercury skill. The key works as a regular Bearer token.
TOKEN="$(grep TWENTY_API_KEY /opt/ph-infra/.env | cut -d= -f2)"
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:3001/rest/..."
Base URL: http://127.0.0.1:3001 (local Docker — do NOT use the Tailscale URL for API calls)
Filter Syntax
Twenty's filter syntax uses colon as the key-value separator (not =). Use curl -G --data-urlencode for reliable encoding of % wildcards:
curl -s -G -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:3001/rest/companies' \
--data-urlencode 'filter=name[like]:%Acme%'
curl -s -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:3001/rest/people?filter=emails.primaryEmail[eq]:jane@example.com'
Operators: [eq], [like], [gte], [lte], [in]
Response Shape
The JSON response wraps data differently depending on the operation — handle each pattern to avoid KeyError:
| Operation | Response path | Example |
|---|
| List | data.companies | d['data']['companies'] |
| Get by ID | data.company | d['data']['company'] |
| Create | data.createCompany | d['data']['createCompany'] |
| Update | data.updateCompany | d['data']['updateCompany'] |
| Delete | data.deleteCompany | d['data']['deleteCompany'] |
Same pattern for all entities: people/person, opportunities/opportunity, tasks/task, notes/note.
Tip: To handle mutations generically: result = list(d['data'].values())[0]
Enum Fields Reference
All SELECT fields require exact enum values — free text will 400. Here are all valid values:
Opportunity Fields
stage (required):
| Value | Label | Color | Use when |
|---|
LEAD | Lead | gray | Initial interest, no conversation yet |
PROSPECT | Prospect | blue | Qualified lead, exploring fit |
DISCOVERY | Discovery | sky | Active discovery calls/meetings |
PROPOSAL | Proposal | yellow | Proposal sent, awaiting decision |
WON | Won | green | Deal closed, not yet started |
ACTIVE | Active | purple | Engagement in progress |
RETAINER | Retainer | orange | Ongoing recurring engagement |
DONE | Done | turquoise | Engagement completed |
LOST | Lost | red | Deal lost |
leadSource:
| Value | Label | Use when |
|---|
REFERRAL | Referral | Referred by someone (e.g. Vistage, partner intro) |
INBOUND | Inbound | They came to us (website, social, etc.) |
OUTBOUND | Outbound | We reached out to them |
EVENT | Event | Met at a conference/event |
PARTNER | Partner | Came via a partner org |
SOCIAL | Social | Came via social media |
contractType:
| Value | Label | Use when |
|---|
RETAINER | Retainer | Recurring monthly engagement |
PROJECT | Project | One-time or fixed-duration work |
HYBRID | Hybrid | Mix of retainer + project |
TBD | TBD | Not yet determined |
budgetRange:
UNDER_5K, RANGE_5_15K, RANGE_15_50K, OVER_50K, UNKNOWN
dealTimeline:
IMMEDIATE, THIS_QUARTER, NEXT_QUARTER, EXPLORING
lostReason (use when stage=LOST):
BUDGET, TIMING, COMPETITOR, NO_RESPONSE, BAD_FIT, INTERNAL_CHANGE
Company Fields
companyIndustry:
MEDIA_ENTERTAINMENT, ADVERTISING, ECOMMERCE, SAAS_TECH, AGENCY, CREATOR, NONPROFIT, OTHER
companySizeRange:
SIZE_1_10, SIZE_11_50, SIZE_51_200, SIZE_201_1000, SIZE_1000_PLUS
aiMaturity:
NONE, EXPERIMENTING, ACTIVE_USER, AI_NATIVE
Core Endpoints
People (Contacts)
TOKEN="$(grep TWENTY_API_KEY /opt/ph-infra/.env | cut -d= -f2)"
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:3001/rest/people"
curl -s -G -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:3001/rest/people' \
--data-urlencode 'filter=name.lastName[like]:%Smith%'
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:3001/rest/people/{id}"
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"http://127.0.0.1:3001/rest/people" \
-d '{
"name": {"firstName": "Jane", "lastName": "Doe"},
"emails": {"primaryEmail": "jane@example.com", "additionalEmails": ["jane.doe@gmail.com"]},
"phones": {"primaryPhoneNumber": "5551234567", "primaryPhoneCountryCode": "US", "primaryPhoneCallingCode": "+1"},
"linkedinLink": {"primaryLinkUrl": "https://linkedin.com/in/janedoe"},
"xLink": {"primaryLinkUrl": "https://x.com/janedoe"},
"city": "Miami",
"jobTitle": "Director",
"companyId": "COMPANY_UUID"
}'
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"http://127.0.0.1:3001/rest/people/{id}" \
-d '{"companyId": "COMPANY_UUID"}'
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:3001/rest/people/{id}"
Companies
TOKEN="$(grep TWENTY_API_KEY /opt/ph-infra/.env | cut -d= -f2)"
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:3001/rest/companies"
curl -s -G -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:3001/rest/companies' \
--data-urlencode 'filter=name[like]:%Acme%'
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"http://127.0.0.1:3001/rest/companies" \
-d '{
"name": "Acme Corp",
"domainName": {"primaryLinkLabel": "acme.com", "primaryLinkUrl": "https://acme.com"},
"linkedinLink": {"primaryLinkUrl": "https://linkedin.com/company/acme"},
"xLink": {"primaryLinkUrl": "https://x.com/acme"},
"address": {"addressCity": "Miami", "addressState": "Florida", "addressCountry": "United States"},
"companyIndustry": "SAAS_TECH",
"companySizeRange": "SIZE_11_50",
"aiMaturity": "EXPERIMENTING"
}'
Opportunities (Deals)
Amounts use micros: $1 = 1,000,000 micros
| Dollar amount | amountMicros |
|---|
| $800 | 800,000,000 |
| $2,000 | 2,000,000,000 |
| $5,000 | 5,000,000,000 |
| $50,000 | 50,000,000,000 |
| $120,000 | 120,000,000,000 |
Convention: Store the annual deal value in amount, the monthly rate in useCase, and the deal type in contractType:
RETAINER — recurring monthly (amount = monthly × 12)
PROJECT — one-time or fixed-duration (amount = total project value)
Track referral attribution in leadSource + note the source in useCase (e.g. "$5,000/month retainer — referred via Vistage (Karl Sprague)").
TOKEN="$(grep TWENTY_API_KEY /opt/ph-infra/.env | cut -d= -f2)"
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:3001/rest/opportunities"
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"http://127.0.0.1:3001/rest/opportunities" \
-d '{
"name": "Client Name - Monthly Retainer",
"stage": "WON",
"amount": {"amountMicros": 60000000000, "currencyCode": "USD"},
"contractType": "RETAINER",
"leadSource": "REFERRAL",
"budgetRange": "RANGE_5_15K",
"dealTimeline": "IMMEDIATE",
"useCase": "$5,000/month retainer — referred via Vistage (Karl Sprague)",
"companyId": "COMPANY_UUID",
"pointOfContactId": "PERSON_UUID"
}'
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"http://127.0.0.1:3001/rest/opportunities/{id}" \
-d '{"stage": "WON", "pointOfContactId": "PERSON_UUID"}'
Tasks
TOKEN="$(grep TWENTY_API_KEY /opt/ph-infra/.env | cut -d= -f2)"
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:3001/rest/tasks"
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"http://127.0.0.1:3001/rest/tasks" \
-d '{
"title": "Follow up with client",
"status": "TODO",
"dueAt": "2026-03-15T00:00:00Z"
}'
Notes
TOKEN="$(grep TWENTY_API_KEY /opt/ph-infra/.env | cut -d= -f2)"
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:3001/rest/notes"
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"http://127.0.0.1:3001/rest/notes" \
-d '{
"title": "Meeting Notes",
"body": "Discussion about Q2 goals..."
}'
Sorting & Pagination
curl -s -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:3001/rest/companies?order_by=createdAt[AscNullsLast]&limit=10"
List responses include pagination info:
{
"totalCount": 623,
"pageInfo": {
"hasNextPage": true,
"endCursor": "eyJ..."
}
}
Direct DB Access
For field metadata lookups (enum options, field types), the REST metadata endpoint is slow and unreliable. Query the DB directly instead:
docker exec ph-postgres psql -U twenty -d twenty -c "
SELECT fm.name, fm.type, fm.options
FROM core.\"fieldMetadata\" fm
JOIN core.\"objectMetadata\" om ON om.id = fm.\"objectMetadataId\"
WHERE om.\"nameSingular\" = 'opportunity' AND fm.name = 'stage';
"
docker exec ph-postgres psql -U twenty -d twenty -c "
SELECT fm.name, fm.type
FROM core.\"fieldMetadata\" fm
JOIN core.\"objectMetadata\" om ON om.id = fm.\"objectMetadataId\"
WHERE om.\"nameSingular\" = 'opportunity'
ORDER BY fm.name;
"
Postgres container: ph-postgres, DB: twenty, User: twenty, Schemas: core (metadata), workspace_9guv38tcs38c4r7yknxi5fdqu (data).
Important Practices
- Search before creating — always check if a company/person already exists to avoid duplicates
- Amount convention — store annual value in
amount, monthly breakdown in useCase
- contractType — use
RETAINER for recurring, PROJECT for one-time, HYBRID for mix
- leadSource — always set on opportunities to track where deals come from
- Enum fields — all SELECT fields require exact values from the reference above; free text will 400
- Avoid UI aggregates on amount — SUM/MIN/MAX on the amount column crashes the Twenty frontend (it's a compound CURRENCY type). Use the API for revenue calculations instead.