twenty-app-intake
v0.2.1
Published
The missing ingestion layer for Twenty CRM. Automatically sync any webhook source — contact forms, pipeline apps, or any platform — with native schema extension, field normalization, and deduplication. Zero manual configuration required.
Downloads
252
Maintainers
Readme
Built by Machina
The problem
Your contact forms, pipeline scrapers, and partner APIs each have their own field names. phone_number here, phoneNumber there, tel somewhere else. Half the time a new field appears and breaks your Zap. The other half, someone enters a duplicate that your team has to clean manually.
Intake handles all of it automatically.
What it does
Send any JSON payload to Intake's webhook. It figures out the rest.
curl -X POST https://your-crm.com/s/intake/getting-started \
-H "Content-Type: application/json" \
-d '{
"first_name": "Jane",
"last_name": "Doe",
"email": "[email protected]",
"phone_number": "415-555-0199",
"company": "Acme Inc",
"message": "Need a new website by Q3.",
"utm_source": "google",
"budget": "25000"
}'What Twenty gets:
- ✅ Person record — Jane Doe, [email protected], +1 415 555 0199
- ✅ Company record — Acme Inc (linked to Jane)
- ✅ Opportunity — "Getting Started — Jane Doe", stage: NEW
- ✅ Note — message + UTM source, formatted and attached
- ✅ Custom field
extBudgetauto-created on Person (first time only)
No Zaps. No middleware. No broken automations when your form adds a field.
How it works
Any JSON payload
│
▼
① Normalize phone_number → phone, emailAddress → email, firstName + lastName → name
│
▼
② Classify short values → CRM fields │ prose / UTMs → note
│
▼
③ Extend unknown fields → auto-create ext_ custom fields on Person or Company
│
▼
④ Deduplicate match by email (Person) or domain (Company) before creating anything
│
▼
⑤ Ingest Person + Company + Opportunity + Note — one webhook, the full chain
│
▼
⑥ Log every ingestion recorded in IntakeLog with status, timing, field countsGet started
1. Install
From the Twenty marketplace in Settings → Applications, search for Intake and install.
On fresh install, Intake automatically creates a "Getting Started" source with a ready-to-use webhook URL.
2. Register a source
curl -X POST https://your-crm.com/s/intake/sources/register \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Contact Form",
"slug": "contact-form",
"targetObject": "AUTO"
}'{
"webhookUrl": "https://your-crm.com/s/intake/contact-form",
"secret": "wh_live_abc123..."
}3. Test without writing anything
curl -X POST https://your-crm.com/s/intake/contact-form/test \
-H "Content-Type: application/json" \
-d '{"first_name":"Jane","email":"[email protected]","budget":"15000"}'Returns exactly what would be created — standard fields, custom fields to create, note preview — without touching the CRM.
Payload formats
Intake accepts any valid JSON. No required fields.
Flat (contact form):
{
"first_name": "Jane",
"email": "[email protected]",
"company": "Acme",
"message": "Looking for a full rebrand.",
"utm_source": "google"
}Structured (pipeline app):
{
"company": {
"name": "Acme Plumbing",
"domainName": { "primaryLinkUrl": "https://acmeplumbing.com" },
"address": { "addressCity": "San Jose", "addressState": "CA" }
},
"person": {
"name": { "firstName": "John", "lastName": "Smith" },
"emails": { "primaryEmail": "[email protected]" }
},
"google_rating": 4.7,
"review_count": 143,
"analysis": "Strong reviews, outdated website."
}Arbitrary nested:
{
"submitted_by": { "full_name": "Alex Thompson", "contact_email": "[email protected]" },
"project": { "type": "SaaS Dashboard", "budget": "15k" },
"referrer": "behance"
}Built-in field normalization
100+ mappings ship by default. Some highlights:
| Incoming key | Twenty field |
|---|---|
| phone, phone_number, phoneNumber, tel, mobile, cell | phones.primaryPhoneNumber |
| email, email_address, contact_email | emails.primaryEmail |
| first_name, firstName, fname | name.firstName |
| last_name, lastName, surname | name.lastName |
| name, full_name, fullName | name (auto-split) |
| company, company_name, business, organization | Company record |
| website, url, domain, homepage | domainName.primaryLinkUrl |
| utm_source/medium/campaign/content/term | Note (always) |
| message, description, notes, comments, analysis | Note (always) |
Unknown fields get an ext_ prefix and are created as custom fields the first time they appear.
Custom field rules
Add IntakeFieldRule records to extend or override the built-in map for a specific source or globally:
| Field | Description |
|---|---|
| inputPattern | Exact key name or JavaScript regex |
| canonicalName | Target field in Twenty (use ext prefix for custom fields) |
| fieldType | TEXT, NUMBER, LINKS, EMAILS, PHONES, BOOLEAN, DATE_TIME, NOTE, or SKIP |
| priority | Higher = checked first (0–100) |
Rules with no source linked apply globally across all sources.
Source configuration
Each IntakeSource record controls:
| Field | Default | Description |
|---|---|---|
| targetObject | AUTO | PERSON, COMPANY, or auto-detect |
| webhookSecret | — | HMAC-SHA256 signing secret |
| createOpportunity | true | Auto-create Opportunity per ingestion |
| opportunityNameTemplate | {{source}} — {{firstName}} {{lastName}} | Supports {{source}}, {{firstName}}, {{lastName}}, {{email}}, {{company}} |
| status | ACTIVE | Pause a source without deleting it |
Workspace settings
Configurable from Settings → Applications → Intake → Custom:
| Setting | Default | Description |
|---|---|---|
| INTAKE_APP_LABEL | Intake | Name used in note titles and opportunity names |
| INTAKE_DEFAULT_OPP_STAGE | NEW | Stage for auto-created Opportunities |
| INTAKE_FIELD_CREATION_ENABLED | true | Toggle auto-schema extension |
| INTAKE_MAX_EXT_FIELDS | 50 | Cap on custom fields per object |
| INTAKE_DEDUP_WINDOW_MINUTES | 5 | Duplicate suppression window |
| INTAKE_REQUIRE_HMAC | false | Enforce signed webhooks globally |
Webhook security
Sign requests with HMAC-SHA256 using the source's secret:
SECRET="your-signing-secret"
PAYLOAD='{"email":"[email protected]"}'
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST https://your-crm.com/s/intake/contact-form \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: sha256=$SIGNATURE" \
-d "$PAYLOAD"Sources without a secret accept unsigned requests — useful for internal tools. Set INTAKE_REQUIRE_HMAC=true to enforce signatures globally.
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /s/intake/:slug | HMAC or open | Ingest a payload |
| POST | /s/intake/:slug/test | None | Dry-run — preview without writing |
| POST | /s/intake/logs/:logId/retry | API key | Retry a failed ingestion |
| GET | /s/intake/health | None | Health check |
| POST | /s/intake/sources/register | API key | Register a new source |
Retry failed ingestions
Every ingestion is logged in IntakeLog. Failed logs can be retried from the record's detail page in Twenty, or via API:
curl -X POST https://your-crm.com/s/intake/logs/LOG_ID/retry \
-H "Authorization: Bearer YOUR_API_KEY"Development
git clone https://github.com/FranciscoContreras/twenty-app-intake
cd twenty-app-intake
yarn install
# Run unit tests
yarn test
# Connect to your Twenty instance
yarn twenty remote add --api-url https://your-crm.com --api-key YOUR_KEY --as production
# Sync in watch mode
yarn twenty dev
# One-shot sync
yarn twenty dev --oncevs. the alternatives
| | Intake | Zapier/Make | Hookdeck | Custom webhook | |---|---|---|---|---| | Zero config | ✅ | ❌ Manual mapping | ❌ Write ingestion logic | ❌ Build everything | | Auto schema extension | ✅ | ❌ New fields break flows | ❌ | ❌ | | Native Twenty objects | ✅ | ❌ | ❌ | ❌ | | Deduplication | ✅ | Partial | ❌ | Roll your own | | Audit log | ✅ | ❌ | ✅ | ❌ | | Self-hosted | ✅ | ❌ | Paid | ✅ | | Open source | ✅ MIT | ❌ | ❌ | ✅ |
License
MIT — built by Machina · FranciscoContreras
