twenty-app-intake 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +292 -7
- package/manifest.json +389 -15
- package/package.json +1 -1
- package/src/front-components/settings-panel.mjs +7 -7
- package/src/front-components/settings-panel.mjs.map +2 -2
- package/src/front-components/settings-panel.tsx +49 -0
- package/src/logic-functions/check-silence.mjs +263 -0
- package/src/logic-functions/check-silence.mjs.map +7 -0
- package/src/logic-functions/check-silence.ts +52 -0
- package/src/logic-functions/contract.mjs +507 -0
- package/src/logic-functions/contract.mjs.map +7 -0
- package/src/logic-functions/contract.ts +172 -0
- package/src/logic-functions/health.mjs +125 -5
- package/src/logic-functions/health.mjs.map +3 -3
- package/src/logic-functions/health.ts +41 -5
- package/src/logic-functions/quarantine-discard.mjs +380 -0
- package/src/logic-functions/quarantine-discard.mjs.map +7 -0
- package/src/logic-functions/quarantine-discard.ts +65 -0
- package/src/logic-functions/quarantine-list.mjs +211 -0
- package/src/logic-functions/quarantine-list.mjs.map +7 -0
- package/src/logic-functions/quarantine-list.ts +93 -0
- package/src/logic-functions/quarantine-release.mjs +1974 -0
- package/src/logic-functions/quarantine-release.mjs.map +7 -0
- package/src/logic-functions/quarantine-release.ts +94 -0
- package/src/logic-functions/register.mjs +34 -1
- package/src/logic-functions/register.mjs.map +2 -2
- package/src/logic-functions/replay-bulk.mjs +2040 -0
- package/src/logic-functions/replay-bulk.mjs.map +7 -0
- package/src/logic-functions/replay-bulk.ts +83 -0
- package/src/logic-functions/replay-log.mjs +1987 -0
- package/src/logic-functions/replay-log.mjs.map +7 -0
- package/src/logic-functions/replay-log.ts +54 -0
- package/src/logic-functions/retry.mjs +1001 -248
- package/src/logic-functions/retry.mjs.map +4 -4
- package/src/logic-functions/retry.ts +45 -68
- package/src/logic-functions/test-ingest.mjs +616 -28
- package/src/logic-functions/test-ingest.mjs.map +4 -4
- package/src/logic-functions/test-ingest.ts +132 -28
- package/src/logic-functions/webhook.mjs +965 -209
- package/src/logic-functions/webhook.mjs.map +4 -4
- package/src/logic-functions/webhook.ts +23 -48
- package/src/post-install.mjs +34 -1
- package/src/post-install.mjs.map +2 -2
package/manifest.json
CHANGED
|
@@ -44,15 +44,50 @@
|
|
|
44
44
|
"universalIdentifier": "b3e6a2d8-9f1c-4b5e-8d7a-2c4f6e8b0a1d",
|
|
45
45
|
"value": "Intake",
|
|
46
46
|
"description": "Display name used in generated content — note titles, opportunity names, and system messages. Change to match your team's terminology (e.g. \"Leads\", \"Inbound\", \"Pipeline\")."
|
|
47
|
+
},
|
|
48
|
+
"INTAKE_MERGE_POLICY": {
|
|
49
|
+
"universalIdentifier": "a84fe9fd-3d7c-41d9-b0e8-dfdc1a87f348",
|
|
50
|
+
"value": "PRESERVE",
|
|
51
|
+
"description": "What an update does to a field that already holds a value. PRESERVE fills blanks and leaves everything else alone. NEWEST_WINS lets the incoming payload overwrite, which is how versions before 0.4.0 behaved. A blank value in a payload never overwrites under either setting."
|
|
52
|
+
},
|
|
53
|
+
"INTAKE_SPAM_FILTER_ENABLED": {
|
|
54
|
+
"universalIdentifier": "570d3ba8-1904-4bad-a5ce-27e208e6414a",
|
|
55
|
+
"value": "false",
|
|
56
|
+
"description": "Score inbound payloads and quarantine anything at or above the threshold. Off by default — turn it on after checking a few days of scores in the Intake Logs. Honeypot fields work whether or not this is on."
|
|
57
|
+
},
|
|
58
|
+
"INTAKE_SPAM_SCORE_THRESHOLD": {
|
|
59
|
+
"universalIdentifier": "4395574d-24b4-4e0e-afa3-1e90fcd4f470",
|
|
60
|
+
"value": "5",
|
|
61
|
+
"description": "Score at which a payload is held for review. Individual signals are worth 2 to 4 points, so the default needs two or more of them to agree before anything is withheld. Lower is stricter."
|
|
62
|
+
},
|
|
63
|
+
"INTAKE_HONEYPOT_FIELD": {
|
|
64
|
+
"universalIdentifier": "2cf56c56-f5cb-47c5-aa43-84f301060a95",
|
|
65
|
+
"value": "",
|
|
66
|
+
"description": "Name of a form field that is hidden from people. Any payload that arrives with it filled is quarantined. Empty disables it. A per-source Honeypot Field overrides this."
|
|
67
|
+
},
|
|
68
|
+
"INTAKE_RAW_PAYLOAD_RETENTION": {
|
|
69
|
+
"universalIdentifier": "f3314ad6-05b9-4127-bb88-cea5f2018dc9",
|
|
70
|
+
"value": "FULL",
|
|
71
|
+
"description": "FULL stores each payload on its log, which is what makes retry and replay possible. NONE stores nothing and switches both off. Credentials (password, token, secret, card and similar keys) are stripped before storage either way."
|
|
72
|
+
},
|
|
73
|
+
"INTAKE_RAW_PAYLOAD_MAX_BYTES": {
|
|
74
|
+
"universalIdentifier": "b002ca76-df3d-47b4-8ac5-d3f562239c48",
|
|
75
|
+
"value": "65000",
|
|
76
|
+
"description": "Largest payload stored for replay. Anything bigger is processed normally but not kept, and the log says so — a truncated payload would not parse, so it is dropped rather than half-stored."
|
|
77
|
+
},
|
|
78
|
+
"INTAKE_REPLAY_MAX_BATCH": {
|
|
79
|
+
"universalIdentifier": "cf55956f-177e-4afd-8d9f-7f42c614209c",
|
|
80
|
+
"value": "50",
|
|
81
|
+
"description": "Most logs one bulk replay will process. Each replayed log writes to the CRM, so this bounds how much a single call can change. Hard ceiling of 500."
|
|
47
82
|
}
|
|
48
83
|
},
|
|
49
84
|
"galleryImages": [
|
|
50
85
|
"public/cover.generated.png"
|
|
51
86
|
],
|
|
52
87
|
"defaultRoleUniversalIdentifier": "3dd2cdb0-32c7-44b4-9f7b-0a9c667ec249",
|
|
53
|
-
"aboutDescription": "<div align=\"center\">\n <img src=\"./public/logo.svg\" width=\"80\" height=\"80\" alt=\"Intake logo\" />\n <h1>Intake</h1>\n <p><strong>The missing ingestion layer for Twenty CRM.</strong><br/>\n Wire any form, webhook, or data source to Twenty. Leads land clean — every time.</p>\n\n [](https://www.npmjs.com/package/twenty-app-intake)\n [](https://twenty.com)\n [](https://github.com/FranciscoContreras/twenty-app-intake/actions)\n [](./LICENSE)\n [](https://wearemachina.com)\n\n <sub>Built by <a href=\"https://wearemachina.com\"><strong>Machina</strong></a></sub>\n</div>\n\n---\n\n## The problem\n\nYour 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.\n\nIntake handles all of it automatically.\n\n## What it does\n\nSend any JSON payload to Intake's webhook. It figures out the rest.\n\n```bash\ncurl -X POST https://your-crm.com/s/intake/getting-started \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"first_name\": \"Jane\",\n \"last_name\": \"Doe\",\n \"email\": \"jane@acme.com\",\n \"phone_number\": \"415-555-0199\",\n \"company\": \"Acme Inc\",\n \"message\": \"Need a new website by Q3.\",\n \"utm_source\": \"google\",\n \"budget\": \"25000\"\n }'\n```\n\n**What Twenty gets:**\n- ✅ Person record — Jane Doe, jane@acme.com, +1 415 555 0199\n- ✅ Company record — Acme Inc (linked to Jane)\n- ✅ Opportunity — \"Getting Started — Jane Doe\", stage: NEW\n- ✅ Note — message + UTM source, formatted and attached\n- ✅ Custom field `extBudget` auto-created on Person (first time only)\n\nNo Zaps. No middleware. No broken automations when your form adds a field.\n\n---\n\n## How it works\n\n```\nAny JSON payload\n │\n ▼\n① Normalize phone_number → phone, emailAddress → email, firstName + lastName → name\n │\n ▼\n② Classify short values → CRM fields │ prose / UTMs → note\n │\n ▼\n③ Extend unknown fields → auto-create ext_ custom fields on Person or Company\n │\n ▼\n④ Deduplicate match by email (Person) or domain (Company) before creating anything\n │\n ▼\n⑤ Ingest Person + Company + Opportunity + Note — one webhook, the full chain\n │\n ▼\n⑥ Log every ingestion recorded in IntakeLog with status, timing, field counts\n```\n\n---\n\n## Get started\n\n### 1. Install\n\nFrom the Twenty marketplace in **Settings → Applications**, search for **Intake** and install.\n\nOn fresh install, Intake automatically creates a \"Getting Started\" source with a ready-to-use webhook URL.\n\n### 2. Register a source\n\n```bash\ncurl -X POST https://your-crm.com/s/intake/sources/register \\\n -H \"Authorization: Bearer YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"Contact Form\",\n \"slug\": \"contact-form\",\n \"targetObject\": \"AUTO\"\n }'\n```\n\n```json\n{\n \"webhookUrl\": \"https://your-crm.com/s/intake/contact-form\",\n \"secret\": \"wh_live_abc123...\"\n}\n```\n\n### 3. Test without writing anything\n\n```bash\ncurl -X POST https://your-crm.com/s/intake/contact-form/test \\\n -H \"Content-Type: application/json\" \\\n -d '{\"first_name\":\"Jane\",\"email\":\"jane@co.com\",\"budget\":\"15000\"}'\n```\n\nReturns exactly what *would* be created — standard fields, custom fields to create, note preview — without touching the CRM.\n\n---\n\n## Payload formats\n\nIntake accepts any valid JSON. No required fields.\n\n**Flat (contact form):**\n```json\n{\n \"first_name\": \"Jane\",\n \"email\": \"jane@acme.com\",\n \"company\": \"Acme\",\n \"message\": \"Looking for a full rebrand.\",\n \"utm_source\": \"google\"\n}\n```\n\n**Structured (pipeline app):**\n```json\n{\n \"company\": {\n \"name\": \"Acme Plumbing\",\n \"domainName\": { \"primaryLinkUrl\": \"https://acmeplumbing.com\" },\n \"address\": { \"addressCity\": \"San Jose\", \"addressState\": \"CA\" }\n },\n \"person\": {\n \"name\": { \"firstName\": \"John\", \"lastName\": \"Smith\" },\n \"emails\": { \"primaryEmail\": \"john@acmeplumbing.com\" }\n },\n \"google_rating\": 4.7,\n \"review_count\": 143,\n \"analysis\": \"Strong reviews, outdated website.\"\n}\n```\n\n**Arbitrary nested:**\n```json\n{\n \"submitted_by\": { \"full_name\": \"Alex Thompson\", \"contact_email\": \"alex@co.com\" },\n \"project\": { \"type\": \"SaaS Dashboard\", \"budget\": \"15k\" },\n \"referrer\": \"behance\"\n}\n```\n\n---\n\n## Built-in field normalization\n\n100+ mappings ship by default. Some highlights:\n\n| Incoming key | Twenty field |\n|---|---|\n| `phone`, `phone_number`, `phoneNumber`, `tel`, `mobile`, `cell` | `phones.primaryPhoneNumber` |\n| `email`, `email_address`, `contact_email` | `emails.primaryEmail` |\n| `first_name`, `firstName`, `fname` | `name.firstName` |\n| `last_name`, `lastName`, `surname` | `name.lastName` |\n| `name`, `full_name`, `fullName` | `name` (auto-split) |\n| `company`, `company_name`, `business`, `organization` | Company record |\n| `website`, `url`, `domain`, `homepage` | `domainName.primaryLinkUrl` |\n| `utm_source/medium/campaign/content/term` | Note (always) |\n| `message`, `description`, `notes`, `comments`, `analysis` | Note (always) |\n\nUnknown fields get an `ext_` prefix and are created as custom fields the first time they appear.\n\n---\n\n## Custom field rules\n\nAdd `IntakeFieldRule` records to extend or override the built-in map for a specific source or globally:\n\n| Field | Description |\n|---|---|\n| `inputPattern` | Exact key name or JavaScript regex |\n| `canonicalName` | Target field in Twenty (use `ext` prefix for custom fields) |\n| `fieldType` | `TEXT`, `NUMBER`, `LINKS`, `EMAILS`, `PHONES`, `BOOLEAN`, `DATE_TIME`, `NOTE`, or `SKIP` |\n| `priority` | Higher = checked first (0–100) |\n\nRules with no source linked apply globally across all sources.\n\n---\n\n## Source configuration\n\nEach `IntakeSource` record controls:\n\n| Field | Default | Description |\n|---|---|---|\n| `targetObject` | `AUTO` | `PERSON`, `COMPANY`, or auto-detect |\n| `webhookSecret` | — | HMAC-SHA256 signing secret |\n| `createOpportunity` | `true` | Auto-create Opportunity per ingestion |\n| `opportunityNameTemplate` | `{{source}} — {{firstName}} {{lastName}}` | Supports `{{source}}`, `{{firstName}}`, `{{lastName}}`, `{{email}}`, `{{company}}` |\n| `status` | `ACTIVE` | Pause a source without deleting it |\n\n---\n\n## Workspace settings\n\nConfigurable from **Settings → Applications → Intake → Custom**:\n\n| Setting | Default | Description |\n|---|---|---|\n| `INTAKE_APP_LABEL` | `Intake` | Name used in note titles and opportunity names |\n| `INTAKE_DEFAULT_OPP_STAGE` | `NEW` | Stage for auto-created Opportunities |\n| `INTAKE_FIELD_CREATION_ENABLED` | `true` | Toggle auto-schema extension |\n| `INTAKE_MAX_EXT_FIELDS` | `50` | Cap on custom fields per object |\n| `INTAKE_DEDUP_WINDOW_MINUTES` | `5` | Duplicate suppression window |\n| `INTAKE_REQUIRE_HMAC` | `false` | Enforce signed webhooks globally |\n\n---\n\n## Webhook security\n\nSign requests with `HMAC-SHA256` using the source's secret:\n\n```bash\nSECRET=\"your-signing-secret\"\nPAYLOAD='{\"email\":\"jane@co.com\"}'\nSIGNATURE=$(echo -n \"$PAYLOAD\" | openssl dgst -sha256 -hmac \"$SECRET\" | awk '{print $2}')\n\ncurl -X POST https://your-crm.com/s/intake/contact-form \\\n -H \"Content-Type: application/json\" \\\n -H \"X-Webhook-Signature: sha256=$SIGNATURE\" \\\n -d \"$PAYLOAD\"\n```\n\nSources without a secret accept unsigned requests — useful for internal tools. Set `INTAKE_REQUIRE_HMAC=true` to enforce signatures globally.\n\n---\n\n## Endpoints\n\n| Method | Path | Auth | Description |\n|---|---|---|---|\n| `POST` | `/s/intake/:slug` | HMAC or open | Ingest a payload |\n| `POST` | `/s/intake/:slug/test` | None | Dry-run — preview without writing |\n| `POST` | `/s/intake/logs/:logId/retry` | API key | Retry a failed ingestion |\n| `GET` | `/s/intake/health` | None | Health check |\n| `POST` | `/s/intake/sources/register` | API key | Register a new source |\n\n---\n\n## Retry failed ingestions\n\nEvery ingestion is logged in `IntakeLog`. Failed logs can be retried from the record's detail page in Twenty, or via API:\n\n```bash\ncurl -X POST https://your-crm.com/s/intake/logs/LOG_ID/retry \\\n -H \"Authorization: Bearer YOUR_API_KEY\"\n```\n\n---\n\n## Development\n\n```bash\ngit clone https://github.com/FranciscoContreras/twenty-app-intake\ncd twenty-app-intake\nyarn install\n\n# Run unit tests\nyarn test\n\n# Connect to your Twenty instance\nyarn twenty remote add --api-url https://your-crm.com --api-key YOUR_KEY --as production\n\n# Sync in watch mode\nyarn twenty dev\n\n# One-shot sync\nyarn twenty dev --once\n```\n\n---\n\n## vs. the alternatives\n\n| | Intake | Zapier/Make | Hookdeck | Custom webhook |\n|---|---|---|---|---|\n| Zero config | ✅ | ❌ Manual mapping | ❌ Write ingestion logic | ❌ Build everything |\n| Auto schema extension | ✅ | ❌ New fields break flows | ❌ | ❌ |\n| Native Twenty objects | ✅ | ❌ | ❌ | ❌ |\n| Deduplication | ✅ | Partial | ❌ | Roll your own |\n| Audit log | ✅ | ❌ | ✅ | ❌ |\n| Self-hosted | ✅ | ❌ | Paid | ✅ |\n| Open source | ✅ MIT | ❌ | ❌ | ✅ |\n\n---\n\n## License\n\nMIT — built by [Machina](https://wearemachina.com) · [FranciscoContreras](https://github.com/FranciscoContreras)\n",
|
|
88
|
+
"aboutDescription": "<div align=\"center\">\n <img src=\"./public/logo.svg\" width=\"80\" height=\"80\" alt=\"Intake logo\" />\n <h1>Intake</h1>\n <p><strong>The missing ingestion layer for Twenty CRM.</strong><br/>\n Wire any form, webhook, or data source to Twenty. Leads land clean — every time.</p>\n\n [](https://www.npmjs.com/package/twenty-app-intake)\n [](https://twenty.com)\n [](https://github.com/FranciscoContreras/twenty-app-intake/actions)\n [](./LICENSE)\n [](https://wearemachina.com)\n\n <sub>Built by <a href=\"https://wearemachina.com\"><strong>Machina</strong></a></sub>\n</div>\n\n---\n\n## The problem\n\nYour 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.\n\nIntake handles all of it automatically.\n\n## What it does\n\nSend any JSON payload to Intake's webhook. It figures out the rest.\n\n```bash\ncurl -X POST https://your-crm.com/s/intake/getting-started \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"first_name\": \"Jane\",\n \"last_name\": \"Doe\",\n \"email\": \"jane@acme.com\",\n \"phone_number\": \"415-555-0199\",\n \"company\": \"Acme Inc\",\n \"message\": \"Need a new website by Q3.\",\n \"utm_source\": \"google\",\n \"budget\": \"25000\"\n }'\n```\n\n**What Twenty gets:**\n- ✅ Person record — Jane Doe, jane@acme.com, +1 415 555 0199\n- ✅ Company record — Acme Inc (linked to Jane)\n- ✅ Opportunity — \"Getting Started — Jane Doe\", stage: NEW\n- ✅ Note — message + UTM source, formatted and attached\n- ✅ Custom field `extBudget` auto-created on Person (first time only)\n\nNo Zaps. No middleware. No broken automations when your form adds a field.\n\n---\n\n## How it works\n\n```\nAny JSON payload\n │\n ▼\n① Normalize phone_number → phone, emailAddress → email, firstName + lastName → name\n │\n ▼\n② Classify short values → CRM fields │ prose / UTMs → note\n │\n ▼\n③ Extend unknown fields → auto-create ext custom fields on Person, Company or Opportunity\n │\n ▼\n④ Deduplicate match by email (Person) or domain (Company) before creating anything\n │\n ▼\n⑤ Ingest Person + Company + Opportunity + Note — one webhook, the full chain\n │\n ▼\n⑥ Log every ingestion recorded in IntakeLog with status, timing, field counts\n```\n\n---\n\n## Get started\n\n### 1. Install\n\nFrom the Twenty marketplace in **Settings → Applications**, search for **Intake** and install.\n\nOn fresh install, Intake automatically creates a \"Getting Started\" source with a ready-to-use webhook URL.\n\n### 2. Register a source\n\n```bash\ncurl -X POST https://your-crm.com/s/intake/sources/register \\\n -H \"Authorization: Bearer YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"Contact Form\",\n \"slug\": \"contact-form\",\n \"targetObject\": \"AUTO\"\n }'\n```\n\n```json\n{\n \"webhookUrl\": \"https://your-crm.com/s/intake/contact-form\",\n \"secret\": \"wh_live_abc123...\"\n}\n```\n\n### 3. Test without writing anything\n\n```bash\ncurl -X POST https://your-crm.com/s/intake/contact-form/test \\\n -H \"Content-Type: application/json\" \\\n -d '{\"first_name\":\"Jane\",\"email\":\"jane@co.com\",\"budget\":\"15000\"}'\n```\n\nReturns a structured diff of what *would* happen, without touching the CRM: per object\nwhether it would be **created or updated**, which existing record it matched and on\nwhat, what each field would do to the value already there, and which fields do not\nexist yet and would be added to the schema. Plus the spam score the payload would get.\n\n```jsonc\n{\n \"dryRun\": true,\n \"diff\": {\n \"objects\": [{\n \"object\": \"person\",\n \"operation\": \"update\",\n \"recordId\": \"8f21…\",\n \"matchedBy\": { \"field\": \"emails.primaryEmail\", \"value\": \"jane@co.com\" },\n \"fields\": [\n { \"name\": \"name\", \"status\": \"preserved\", \"existing\": {…}, \"incoming\": {…} },\n { \"name\": \"phones\", \"status\": \"fill-empty\", \"incoming\": {…} },\n { \"name\": \"extBudget\", \"status\": \"create\", \"fieldWouldBeCreated\": true }\n ],\n \"fieldsToCreate\": [{ \"name\": \"extBudget\", \"type\": \"NUMBER\" }]\n }]\n }\n}\n```\n\nBecause it is machine-readable, this is the gate to put in front of live traffic —\nassert on it in a deployment check rather than reading it by eye.\n\n---\n\n## Payload formats\n\nIntake accepts any valid JSON. No required fields.\n\n**Flat (contact form):**\n```json\n{\n \"first_name\": \"Jane\",\n \"email\": \"jane@acme.com\",\n \"company\": \"Acme\",\n \"message\": \"Looking for a full rebrand.\",\n \"utm_source\": \"google\"\n}\n```\n\n**Structured (pipeline app):**\n```json\n{\n \"company\": {\n \"name\": \"Acme Plumbing\",\n \"domainName\": { \"primaryLinkUrl\": \"https://acmeplumbing.com\" },\n \"address\": { \"addressCity\": \"San Jose\", \"addressState\": \"CA\" }\n },\n \"person\": {\n \"name\": { \"firstName\": \"John\", \"lastName\": \"Smith\" },\n \"emails\": { \"primaryEmail\": \"john@acmeplumbing.com\" }\n },\n \"google_rating\": 4.7,\n \"review_count\": 143,\n \"analysis\": \"Strong reviews, outdated website.\"\n}\n```\n\n**Arbitrary nested:**\n```json\n{\n \"submitted_by\": { \"full_name\": \"Alex Thompson\", \"contact_email\": \"alex@co.com\" },\n \"project\": { \"type\": \"SaaS Dashboard\", \"budget\": \"15k\" },\n \"referrer\": \"behance\"\n}\n```\n\n---\n\n## Built-in field normalization\n\n100+ mappings ship by default. Some highlights:\n\n| Incoming key | Twenty field |\n|---|---|\n| `phone`, `phone_number`, `phoneNumber`, `tel`, `mobile`, `cell` | `phones.primaryPhoneNumber` |\n| `email`, `email_address`, `contact_email` | `emails.primaryEmail` |\n| `first_name`, `firstName`, `fname` | `name.firstName` |\n| `last_name`, `lastName`, `surname` | `name.lastName` |\n| `name`, `full_name`, `fullName` | `name` (auto-split) |\n| `company`, `company_name`, `business`, `organization` | Company record |\n| `website`, `url`, `domain`, `homepage` | `domainName.primaryLinkUrl` |\n| `utm_source/medium/campaign/content/term` | Note (always) |\n| `message`, `description`, `notes`, `comments`, `analysis` | Note (always) |\n\nUnknown fields get an `ext_` prefix and are created as custom fields the first time they appear.\n\n---\n\n## Custom field rules\n\nAdd `IntakeFieldRule` records to extend or override the built-in map for a specific source or globally:\n\n| Field | Description |\n|---|---|\n| `inputPattern` | Exact key name or JavaScript regex |\n| `canonicalName` | Target field in Twenty. An `ext`-prefixed name (`extBudget`) is created automatically; any other name must already exist on the target object |\n| `fieldType` | `TEXT`, `NUMBER`, `LINKS`, `EMAILS`, `PHONES`, `BOOLEAN`, `DATE_TIME`, `CURRENCY`, `NOTE`, or `SKIP` |\n| `targetObject` | `AUTO` (default), `PERSON`, `COMPANY`, or `OPPORTUNITY` |\n| `mergeStrategy` | `INHERIT` (default), `PRESERVE`, or `NEWEST_WINS` — see [Updating existing records](#updating-existing-records) |\n| `priority` | Higher = checked first (0–100) |\n\nRules with no source linked apply globally across all sources.\n\n### Writing to the Opportunity\n\nDeal attributes — the service someone asked for, the budget they stated, your own\nlead id — belong on the Opportunity, not the contact. Two ways to put them there:\n\n**A prefix, no configuration.** Any incoming key beginning `opportunity_`, `opp_`\nor `deal_` is routed to the deal, and the prefix is stripped before the field is\nnamed — `opportunity_budget` becomes `extBudget` on the Opportunity.\n\n```jsonc\n{ \"email\": \"jane@acme.com\", \"opportunity_budget\": \"25000\", \"opportunity_service\": \"SEO\" }\n```\n\n**A rule, for keys you cannot rename.** Set `targetObject: OPPORTUNITY` on the rule\nand point `canonicalName` at the field you want written:\n\n| inputPattern | canonicalName | targetObject | fieldType |\n|---|---|---|---|\n| `service` | `machinaService` | `OPPORTUNITY` | `TEXT` |\n| `budget` | `amount` | `OPPORTUNITY` | `CURRENCY` |\n\n`amount` and `closeDate` are standard Opportunity fields and are set as the deal is\ncreated; `amount` accepts a bare number or a written figure (`\"$25,000/mo\"`) and is\nconverted to Twenty's currency micros. Everything else is written as a custom field\nin a follow-up call, so a rejected field never costs you the Opportunity itself.\n\nA field routed to the Opportunity by a source that does not create one falls back to\nthe primary record, with a warning on the log.\n\n### Fields that cannot be written\n\nA rule pointing at a field that does not exist — and is not `ext`-prefixed, so cannot\nbe auto-created — has its value routed to the note, with a warning naming the field.\nIt is not counted as matched. Create the field in Twenty first, or rename the rule's\n`canonicalName` to use an `ext` prefix.\n\n---\n\n## Source configuration\n\nEach `IntakeSource` record controls:\n\n| Field | Default | Description |\n|---|---|---|\n| `targetObject` | `AUTO` | `PERSON`, `COMPANY`, or auto-detect |\n| `webhookSecret` | — | HMAC-SHA256 signing secret |\n| `createOpportunity` | `true` | Auto-create Opportunity per ingestion |\n| `opportunityNameTemplate` | `{{source}} — {{firstName}} {{lastName}}` | Supports `{{source}}`, `{{firstName}}`, `{{lastName}}`, `{{email}}`, `{{company}}` |\n| `status` | `ACTIVE` | Pause a source without deleting it |\n| `mergePolicy` | — | Overrides `INTAKE_MERGE_POLICY` for this source |\n| `honeypotField` | — | Overrides `INTAKE_HONEYPOT_FIELD` for this source |\n| `expectedCadenceHours` | — | How long this source may go quiet before it counts as silent |\n| `alertWebhookUrl` | — | Posted to once when this source falls silent |\n\n---\n\n## Workspace settings\n\nConfigurable from **Settings → Applications → Intake → Custom**:\n\n| Setting | Default | Description |\n|---|---|---|\n| `INTAKE_APP_LABEL` | `Intake` | Name used in note titles and opportunity names |\n| `INTAKE_DEFAULT_OPP_STAGE` | `NEW` | Stage for auto-created Opportunities |\n| `INTAKE_FIELD_CREATION_ENABLED` | `true` | Toggle auto-schema extension |\n| `INTAKE_MAX_EXT_FIELDS` | `50` | Cap on custom fields per object |\n| `INTAKE_DEDUP_WINDOW_MINUTES` | `5` | Duplicate suppression window |\n| `INTAKE_REQUIRE_HMAC` | `false` | Enforce signed webhooks globally |\n| `INTAKE_MERGE_POLICY` | `PRESERVE` | What an update does to a field that already has a value |\n| `INTAKE_SPAM_FILTER_ENABLED` | `false` | Score payloads and quarantine at the threshold |\n| `INTAKE_SPAM_SCORE_THRESHOLD` | `5` | Score at which a payload is held |\n| `INTAKE_HONEYPOT_FIELD` | — | Name of a hidden form field that quarantines when filled |\n| `INTAKE_RAW_PAYLOAD_RETENTION` | `FULL` | `FULL` keeps payloads for replay; `NONE` keeps none |\n| `INTAKE_RAW_PAYLOAD_MAX_BYTES` | `65000` | Largest payload stored for replay |\n| `INTAKE_REPLAY_MAX_BATCH` | `50` | Cap on one bulk replay (hard ceiling 500) |\n\n---\n\n## Updating existing records\n\nWhen a payload matches a contact or company that already exists, `INTAKE_MERGE_POLICY`\ndecides what happens to fields that already hold a value.\n\n| Policy | Behaviour |\n|---|---|\n| `PRESERVE` *(default)* | Fills fields that are empty, leaves everything else as it is |\n| `NEWEST_WINS` | The incoming payload overwrites — how versions before 0.4.0 behaved |\n\nUnder both policies a **blank incoming value never overwrites anything**. An absent\nfield means the sender had nothing to say about it, not that it should be cleared.\n\n`PRESERVE` is the default because the alternative loses data with no record of what\nwas there. A returning enquiry typed in lowercase should not replace a name a\nsalesperson corrected by hand, and nothing in a CRM undoes a field a webhook\noverwrote at 3am.\n\nOverride it per source with the source's `mergePolicy`, or per field with a rule's\n`mergeStrategy` — useful for genuinely volatile attributes:\n\n| inputPattern | canonicalName | mergeStrategy |\n|---|---|---|\n| `lead_score` | `extLeadScore` | `NEWEST_WINS` |\n\nThe response body reports what the policy did, per object and per field, under\n`mergeDecisions`.\n\n**Upgrading from 0.3.0 and want the old behaviour?** Set `INTAKE_MERGE_POLICY=NEWEST_WINS`.\n\n> **If a source exists to refresh data, `PRESERVE` will stop it refreshing.**\n> A pipeline that re-scans a business every week and sends back an updated rating,\n> review count or score writes those values once and then never again, because\n> under `PRESERVE` the field already holds a value. This is the one case where the\n> new default is the wrong one. Fix it at whichever scope fits:\n>\n> - the whole source is a refresher → set its `mergePolicy` to `NEWEST_WINS`\n> - only some fields change → give those rules `mergeStrategy: NEWEST_WINS`\n> - every source is a refresher → set `INTAKE_MERGE_POLICY=NEWEST_WINS`\n>\n> Check `mergeDecisions` in the response, or the `Kept the existing …` warnings on\n> the log, to see whether this is happening to you.\n\n---\n\n## Not sending the same lead twice\n\nSend an `Idempotency-Key` header (or an `idempotencyKey` field in the body) and a\nrepeat of that key resolves to the record made the first time, instead of creating a\nsecond one:\n\n```bash\ncurl -X POST https://your-crm.com/s/intake/contact-form \\\n -H \"Content-Type: application/json\" \\\n -H \"Idempotency-Key: submission-8f21c9\" \\\n -d '{\"email\":\"jane@acme.com\"}'\n```\n\nUnlike the content-hash deduplication — which only looks back\n`INTAKE_DEDUP_WINDOW_MINUTES` — an idempotency key has **no time limit**. A\ndouble-tapped submit button, a client retrying after a timeout and a queue\nredelivering an hour later all resolve to the same record.\n\nA key whose only previous use was quarantined or discarded is treated as unused, so\na released payload is not blocked by its own earlier attempt.\n\n---\n\n## Spam quarantine\n\nOff by default. Two mechanisms, and they work independently.\n\n**Honeypot** — set `INTAKE_HONEYPOT_FIELD` (or a source's `honeypotField`) to the\nname of a form field hidden from people by CSS. Any payload arriving with it filled\nwas filled by a script, and is quarantined immediately. No false positives, so this\nworks whether or not scoring is enabled.\n\n**Scoring** — set `INTAKE_SPAM_FILTER_ENABLED=true`. Each signal is worth points\nrather than a verdict: a URL in a name field, a disposable or undeliverable email\ndomain, a link blast in the message, one long string pasted into every box, a\nplaceholder phone number. A payload is held when the total reaches\n`INTAKE_SPAM_SCORE_THRESHOLD` (default `5`), so no single signal is enough on its own\n— a genuine lead writing from a throwaway address still gets through.\n\nSignals that would score *people* rather than behaviour are deliberately absent.\nNon-Latin characters in a name and industry words like \"SEO\" carry no penalty.\n\nA quarantined payload creates **no Person, Company, Opportunity or Note**. It is\nrecorded as an `IntakeLog` with status `QUARANTINED`, its score and its reasons, and\nthe webhook answers `202` — telling a bot which attempts were caught only teaches it\nwhat to change, and a real person should not see a failure on a form that in fact\nwent through.\n\n```bash\n# What is being held, and why\ncurl https://your-crm.com/s/intake/quarantine -H \"Authorization: Bearer $KEY\"\n\n# Let one through — the filter is overruled, the score is still recorded\ncurl -X POST https://your-crm.com/s/intake/quarantine/$LOG_ID/release -H \"Authorization: Bearer $KEY\"\n\n# Mark one as junk; add {\"purgePayload\":true} to drop the stored body\ncurl -X POST https://your-crm.com/s/intake/quarantine/$LOG_ID/discard -H \"Authorization: Bearer $KEY\"\n```\n\nTurn scoring on only after watching the `spamScore` on a few days of real logs.\n\n---\n\n## Replay: applying a mapping you added too late\n\nEvery payload is stored on its log, so a rule written after the fact can be applied\nto everything already received.\n\n```bash\n# One log, through the rules as they are now\ncurl -X POST https://your-crm.com/s/intake/logs/$LOG_ID/replay -H \"Authorization: Bearer $KEY\"\n\n# A batch — see what would be touched first\ncurl -X POST https://your-crm.com/s/intake/replay \\\n -H \"Authorization: Bearer $KEY\" -H \"Content-Type: application/json\" \\\n -d '{\"sourceSlug\":\"contact-form\",\"since\":\"2026-08-01T00:00:00Z\",\"dryRun\":true}'\n\n# Then run it\ncurl -X POST https://your-crm.com/s/intake/replay \\\n -H \"Authorization: Bearer $KEY\" -H \"Content-Type: application/json\" \\\n -d '{\"sourceSlug\":\"contact-form\",\"since\":\"2026-08-01T00:00:00Z\",\"limit\":50}'\n```\n\nReplay accepts logs that already **succeeded** — that is the point, since the\ningestion worked and only the mapping was missing. Retry is the narrower operation\nand still refuses a successful log.\n\nReplays run one at a time and are capped by `INTAKE_REPLAY_MAX_BATCH`, because each\none writes to the CRM. Each new log records `replayOfLogId`, so a re-mapped record\ntraces back to the payload it came from.\n\n### What is stored, and what is not\n\n`INTAKE_RAW_PAYLOAD_RETENTION=FULL` (the default) keeps each payload as sent, which\nis what retry and replay run from. `NONE` keeps nothing and disables both.\n\nUnder either setting, **credentials are never stored** — keys containing `password`,\n`token`, `secret`, `apikey`, `authorization`, `cvv`, `card`, `ssn` and similar are\nreplaced with `[redacted]` before the payload is written.\n\nEverything else the sender submitted **is** kept, including names, emails and phone\nnumbers. It lives on the `IntakeLog` object under your workspace's own access\ncontrol and is readable by anyone who can read that object. Payloads over\n`INTAKE_RAW_PAYLOAD_MAX_BYTES` are ingested normally but not stored — a truncated\npayload cannot be parsed, so it is dropped rather than half-kept, and the log says so.\n\n---\n\n## Knowing when a source goes quiet\n\nThe failure nobody notices is the one that produces no error: a form that breaks in\nFebruary and is found in August, with nothing but absent leads as evidence.\n\nGive a source an `expectedCadenceHours` and it becomes monitored. Sources without\none are never flagged — silence is only a fault where traffic was expected.\n\n```bash\n# Evaluate every source; run this on whatever timer you already have\ncurl -X POST https://your-crm.com/s/intake/sources/check-silence -H \"Authorization: Bearer $KEY\"\n```\n\nThe check records `healthStatus` (`HEALTHY`, `SILENT`, `NEVER_RECEIVED`) and\n`silentSince` on each source, and posts once to the source's `alertWebhookUrl` on\nthe transition into silence — once, not on every check. The body carries a `text`\nkey, so Slack, Discord and Teams incoming webhook URLs work unchanged.\n\n`GET /s/intake/health` also reports silent sources. It still returns `200` and the\nsame `status` and `timestamp` keys it always did, so existing monitors are\nunaffected. Point a monitor at `/s/intake/health?strict=true` to get a `503` when a\nsource has fallen silent.\n\n---\n\n## Asking the app what it accepts\n\n```bash\ncurl https://your-crm.com/s/intake/contract -H \"Authorization: Bearer $KEY\"\n```\n\nOne call returns every endpoint, the built-in field map grouped by destination, the\ncustom fields that currently exist on each object, the active rules, every\nregistered source and the settings in force — so an integrator or an agent can learn\nthe contract without reading source or introspecting Twenty's metadata API.\n\nSigning secrets never appear; a source reports only whether it requires a signature.\n\n---\n\n## Webhook security\n\nSign requests with `HMAC-SHA256` using the source's secret:\n\n```bash\nSECRET=\"your-signing-secret\"\nPAYLOAD='{\"email\":\"jane@co.com\"}'\nSIGNATURE=$(echo -n \"$PAYLOAD\" | openssl dgst -sha256 -hmac \"$SECRET\" | awk '{print $2}')\n\ncurl -X POST https://your-crm.com/s/intake/contact-form \\\n -H \"Content-Type: application/json\" \\\n -H \"X-Webhook-Signature: sha256=$SIGNATURE\" \\\n -d \"$PAYLOAD\"\n```\n\nSources without a secret accept unsigned requests — useful for internal tools. Set `INTAKE_REQUIRE_HMAC=true` to enforce signatures globally.\n\n---\n\n## Endpoints\n\n| Method | Path | Auth | Description |\n|---|---|---|---|\n| `POST` | `/s/intake/:slug` | HMAC or open | Ingest a payload |\n| `POST` | `/s/intake/:slug/test` | None | Dry-run — structured diff, writes nothing |\n| `GET` | `/s/intake/health` | None | Health check, plus silent sources |\n| `GET` | `/s/intake/contract` | API key | What the app accepts and how it is configured |\n| `POST` | `/s/intake/sources/register` | API key | Register a new source |\n| `POST` | `/s/intake/sources/check-silence` | API key | Check every source against its cadence |\n| `POST` | `/s/intake/logs/:logId/retry` | API key | Retry a **failed** ingestion |\n| `POST` | `/s/intake/logs/:logId/replay` | API key | Re-run **any** stored payload through current rules |\n| `POST` | `/s/intake/replay` | API key | Bulk replay a selection of logs |\n| `GET` | `/s/intake/quarantine` | API key | List held payloads and why |\n| `POST` | `/s/intake/quarantine/:logId/release` | API key | Ingest a held payload |\n| `POST` | `/s/intake/quarantine/:logId/discard` | API key | Mark a held payload as junk |\n\n### Response codes\n\n| Code | Meaning |\n|---|---|\n| `200` | Ingested, or a duplicate resolved to the original record |\n| `202` | Held for review by the spam filter — nothing was created |\n| `401` | Signature missing or invalid |\n| `404` | No source with that slug |\n| `423` | Source is paused |\n\n---\n\n## Retry failed ingestions\n\nEvery ingestion is logged in `IntakeLog`. Failed logs can be retried from the record's detail page in Twenty, or via API:\n\n```bash\ncurl -X POST https://your-crm.com/s/intake/logs/LOG_ID/retry \\\n -H \"Authorization: Bearer YOUR_API_KEY\"\n```\n\n---\n\n## Development\n\n```bash\ngit clone https://github.com/FranciscoContreras/twenty-app-intake\ncd twenty-app-intake\nyarn install\n\n# Run unit tests\nyarn test\n\n# Connect to your Twenty instance\nyarn twenty remote add --api-url https://your-crm.com --api-key YOUR_KEY --as production\n\n# Sync in watch mode\nyarn twenty dev\n\n# One-shot sync\nyarn twenty dev --once\n```\n\n---\n\n## vs. the alternatives\n\n| | Intake | Zapier/Make | Hookdeck | Custom webhook |\n|---|---|---|---|---|\n| Zero config | ✅ | ❌ Manual mapping | ❌ Write ingestion logic | ❌ Build everything |\n| Auto schema extension | ✅ | ❌ New fields break flows | ❌ | ❌ |\n| Native Twenty objects | ✅ | ❌ | ❌ | ❌ |\n| Deduplication | ✅ | Partial | ❌ | Roll your own |\n| Audit log | ✅ | ❌ | ✅ | ❌ |\n| Self-hosted | ✅ | ❌ | Paid | ✅ |\n| Open source | ✅ MIT | ❌ | ❌ | ✅ |\n\n---\n\n## License\n\nMIT — built by [Machina](https://wearemachina.com) · [FranciscoContreras](https://github.com/FranciscoContreras)\n",
|
|
54
89
|
"yarnLockChecksum": "df2ded78f38bf57dd8c95ca58fbf7f8a",
|
|
55
|
-
"packageJsonChecksum": "
|
|
90
|
+
"packageJsonChecksum": "be8ecf21404725b81adad99b13b3df74",
|
|
56
91
|
"requiredServerVersionRange": ">=2.26.0",
|
|
57
92
|
"settingsFrontComponent": {
|
|
58
93
|
"universalIdentifier": "4e4cfb3b-508c-413b-a1b2-192a51c31ed4"
|
|
@@ -193,6 +228,114 @@
|
|
|
193
228
|
"isNullable": true,
|
|
194
229
|
"defaultValue": null
|
|
195
230
|
},
|
|
231
|
+
{
|
|
232
|
+
"universalIdentifier": "a97c3906-5555-4e60-a6d6-84155a4e00a6",
|
|
233
|
+
"name": "expectedCadenceHours",
|
|
234
|
+
"type": "NUMBER",
|
|
235
|
+
"label": "Expected Cadence (hours)",
|
|
236
|
+
"description": "How long this source may go quiet before something is wrong. Leave empty for sources with no regular rhythm — silence is only a fault when traffic was expected.",
|
|
237
|
+
"icon": "IconClockPause",
|
|
238
|
+
"isNullable": true,
|
|
239
|
+
"defaultValue": null
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
"universalIdentifier": "2ad13b20-5bd7-450a-b52d-bcf208373cf5",
|
|
243
|
+
"name": "healthStatus",
|
|
244
|
+
"type": "SELECT",
|
|
245
|
+
"label": "Health",
|
|
246
|
+
"description": "Set by the silence check. Silent means the cadence has elapsed with nothing received.",
|
|
247
|
+
"icon": "IconHeartbeat",
|
|
248
|
+
"options": [
|
|
249
|
+
{
|
|
250
|
+
"value": "HEALTHY",
|
|
251
|
+
"label": "Healthy",
|
|
252
|
+
"position": 0,
|
|
253
|
+
"color": "green",
|
|
254
|
+
"id": "1f71bed6-d225-5437-84ff-7f1102ae6a43"
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
"value": "SILENT",
|
|
258
|
+
"label": "Silent",
|
|
259
|
+
"position": 1,
|
|
260
|
+
"color": "red",
|
|
261
|
+
"id": "fcf6836b-9f67-5f4b-8e9c-e5b730a7392f"
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
"value": "NEVER_RECEIVED",
|
|
265
|
+
"label": "Never received",
|
|
266
|
+
"position": 2,
|
|
267
|
+
"color": "orange",
|
|
268
|
+
"id": "455fdabb-f544-5a2c-80d1-f3b443c44f13"
|
|
269
|
+
}
|
|
270
|
+
],
|
|
271
|
+
"isNullable": true,
|
|
272
|
+
"defaultValue": null
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
"universalIdentifier": "d3395601-f29f-4e57-98eb-561ab011cc72",
|
|
276
|
+
"name": "silentSince",
|
|
277
|
+
"type": "DATE_TIME",
|
|
278
|
+
"label": "Silent Since",
|
|
279
|
+
"icon": "IconCalendarOff",
|
|
280
|
+
"isNullable": true,
|
|
281
|
+
"defaultValue": null
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
"universalIdentifier": "5a066d62-0426-41c1-b34c-1a7e881ce854",
|
|
285
|
+
"name": "alertWebhookUrl",
|
|
286
|
+
"type": "TEXT",
|
|
287
|
+
"label": "Alert Webhook URL",
|
|
288
|
+
"description": "Posted to once when this source falls silent. Any endpoint that accepts JSON — a Slack or Teams incoming webhook works as-is.",
|
|
289
|
+
"icon": "IconBell",
|
|
290
|
+
"isNullable": true,
|
|
291
|
+
"defaultValue": null
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
"universalIdentifier": "fc64a598-ccbe-44e3-bd42-716e32d626a2",
|
|
295
|
+
"name": "lastAlertAt",
|
|
296
|
+
"type": "DATE_TIME",
|
|
297
|
+
"label": "Last Alert At",
|
|
298
|
+
"description": "Used to keep a source that stays silent from alerting on every check.",
|
|
299
|
+
"icon": "IconBellRinging",
|
|
300
|
+
"isNullable": true,
|
|
301
|
+
"defaultValue": null
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
"universalIdentifier": "5c145686-50de-496c-bb45-a30c8b39034c",
|
|
305
|
+
"name": "honeypotField",
|
|
306
|
+
"type": "TEXT",
|
|
307
|
+
"label": "Honeypot Field",
|
|
308
|
+
"description": "Name of a form field no human can see. If it arrives filled, the payload is quarantined. Overrides INTAKE_HONEYPOT_FIELD for this source.",
|
|
309
|
+
"icon": "IconBug",
|
|
310
|
+
"isNullable": true,
|
|
311
|
+
"defaultValue": null
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
"universalIdentifier": "5a946f85-b1c2-493e-92c6-31c50e637a7e",
|
|
315
|
+
"name": "mergePolicy",
|
|
316
|
+
"type": "SELECT",
|
|
317
|
+
"label": "Merge Policy",
|
|
318
|
+
"description": "What an update does to a field that already has a value. Empty inherits INTAKE_MERGE_POLICY.",
|
|
319
|
+
"icon": "IconGitMerge",
|
|
320
|
+
"options": [
|
|
321
|
+
{
|
|
322
|
+
"value": "PRESERVE",
|
|
323
|
+
"label": "Preserve existing",
|
|
324
|
+
"position": 0,
|
|
325
|
+
"color": "green",
|
|
326
|
+
"id": "1ec331d8-3e3f-5d03-a38f-f965c1f4b30e"
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
"value": "NEWEST_WINS",
|
|
330
|
+
"label": "Newest wins",
|
|
331
|
+
"position": 1,
|
|
332
|
+
"color": "orange",
|
|
333
|
+
"id": "ec469645-8136-5d6c-808f-73212cf56381"
|
|
334
|
+
}
|
|
335
|
+
],
|
|
336
|
+
"isNullable": true,
|
|
337
|
+
"defaultValue": null
|
|
338
|
+
},
|
|
196
339
|
{
|
|
197
340
|
"universalIdentifier": "6ecfa369-22e0-4812-bb75-4338cea8be65",
|
|
198
341
|
"name": "intakeLogs",
|
|
@@ -267,6 +410,20 @@
|
|
|
267
410
|
"position": 3,
|
|
268
411
|
"color": "gray",
|
|
269
412
|
"id": "0e91b180-10fc-5375-b15c-769f47f34a8a"
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
"value": "QUARANTINED",
|
|
416
|
+
"label": "Quarantined",
|
|
417
|
+
"position": 4,
|
|
418
|
+
"color": "orange",
|
|
419
|
+
"id": "1e413e2b-9083-5541-b25c-e4dd9d5057de"
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
"value": "DISCARDED",
|
|
423
|
+
"label": "Discarded",
|
|
424
|
+
"position": 5,
|
|
425
|
+
"color": "gray",
|
|
426
|
+
"id": "01d6e9fa-a4a6-5cdf-b4c8-59eec281ed62"
|
|
270
427
|
}
|
|
271
428
|
]
|
|
272
429
|
},
|
|
@@ -375,6 +532,46 @@
|
|
|
375
532
|
"isNullable": true,
|
|
376
533
|
"defaultValue": null
|
|
377
534
|
},
|
|
535
|
+
{
|
|
536
|
+
"universalIdentifier": "bf91e4c0-6477-41f2-a9bd-fb159d3091d4",
|
|
537
|
+
"name": "idempotencyKey",
|
|
538
|
+
"type": "TEXT",
|
|
539
|
+
"label": "Idempotency Key",
|
|
540
|
+
"description": "Caller-supplied key from the Idempotency-Key header. A repeat of the same key resolves to this record instead of creating another.",
|
|
541
|
+
"icon": "IconKey",
|
|
542
|
+
"isNullable": true,
|
|
543
|
+
"defaultValue": null
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
"universalIdentifier": "4e250af2-925f-4724-85f6-f353a6bb4cfd",
|
|
547
|
+
"name": "spamScore",
|
|
548
|
+
"type": "NUMBER",
|
|
549
|
+
"label": "Spam Score",
|
|
550
|
+
"description": "Heuristic score for this payload. Recorded on every ingestion once scoring is on, whether or not it was quarantined.",
|
|
551
|
+
"icon": "IconShieldHalf",
|
|
552
|
+
"isNullable": true,
|
|
553
|
+
"defaultValue": null
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
"universalIdentifier": "323b3c3e-a111-45d1-a211-cf70efa6cd9e",
|
|
557
|
+
"name": "quarantineReason",
|
|
558
|
+
"type": "TEXT",
|
|
559
|
+
"label": "Quarantine Reason",
|
|
560
|
+
"description": "Which signals held this payload back.",
|
|
561
|
+
"icon": "IconAlertOctagon",
|
|
562
|
+
"isNullable": true,
|
|
563
|
+
"defaultValue": null
|
|
564
|
+
},
|
|
565
|
+
{
|
|
566
|
+
"universalIdentifier": "b68876b1-0045-4eaa-a4e7-f0634e110ef1",
|
|
567
|
+
"name": "replayOfLogId",
|
|
568
|
+
"type": "TEXT",
|
|
569
|
+
"label": "Replay Of",
|
|
570
|
+
"description": "The log this run was replayed from, so a re-mapped ingestion can be traced back to the payload it came from.",
|
|
571
|
+
"icon": "IconRepeat",
|
|
572
|
+
"isNullable": true,
|
|
573
|
+
"defaultValue": null
|
|
574
|
+
},
|
|
378
575
|
{
|
|
379
576
|
"universalIdentifier": "b1c623ea-7a9a-47a5-bee4-35fdd668a637",
|
|
380
577
|
"name": "intakeSource",
|
|
@@ -426,9 +623,48 @@
|
|
|
426
623
|
"name": "canonicalName",
|
|
427
624
|
"type": "TEXT",
|
|
428
625
|
"label": "Canonical Field Name",
|
|
429
|
-
"description": "The target field name in Twenty. Use
|
|
626
|
+
"description": "The target field name in Twenty. Use an ext prefix (extBudget) to have the field auto-created; any other name must already exist on the target object or the value is routed to the note instead.",
|
|
430
627
|
"icon": "IconTag"
|
|
431
628
|
},
|
|
629
|
+
{
|
|
630
|
+
"universalIdentifier": "7b8411a7-5e1d-40f2-a20c-683c07f67926",
|
|
631
|
+
"name": "targetObject",
|
|
632
|
+
"type": "SELECT",
|
|
633
|
+
"label": "Target Object",
|
|
634
|
+
"description": "Which record this field is written to. Auto puts it on the primary record (person, or company for company-only payloads). Choose Opportunity for deal attributes such as service, budget or lead id.",
|
|
635
|
+
"icon": "IconTarget",
|
|
636
|
+
"options": [
|
|
637
|
+
{
|
|
638
|
+
"value": "AUTO",
|
|
639
|
+
"label": "Auto",
|
|
640
|
+
"position": 0,
|
|
641
|
+
"color": "gray",
|
|
642
|
+
"id": "9d26fcd0-f014-51a7-ad05-dffaff95f9c5"
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
"value": "PERSON",
|
|
646
|
+
"label": "Person",
|
|
647
|
+
"position": 1,
|
|
648
|
+
"color": "blue",
|
|
649
|
+
"id": "f35514cd-fcaa-522c-86dc-38fffffed788"
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
"value": "COMPANY",
|
|
653
|
+
"label": "Company",
|
|
654
|
+
"position": 2,
|
|
655
|
+
"color": "purple",
|
|
656
|
+
"id": "08d2a21a-d3a0-5911-8c68-ca7e975253d2"
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
"value": "OPPORTUNITY",
|
|
660
|
+
"label": "Opportunity",
|
|
661
|
+
"position": 3,
|
|
662
|
+
"color": "green",
|
|
663
|
+
"id": "8c5ec251-5063-5920-8b96-e933d57219b0"
|
|
664
|
+
}
|
|
665
|
+
],
|
|
666
|
+
"defaultValue": "'AUTO'"
|
|
667
|
+
},
|
|
432
668
|
{
|
|
433
669
|
"universalIdentifier": "72d1f6d8-d63e-419e-a244-8127d7b4705f",
|
|
434
670
|
"name": "fieldType",
|
|
@@ -502,6 +738,38 @@
|
|
|
502
738
|
],
|
|
503
739
|
"defaultValue": "'TEXT'"
|
|
504
740
|
},
|
|
741
|
+
{
|
|
742
|
+
"universalIdentifier": "40c6fdcb-527a-4691-8518-c40a3d3e34c6",
|
|
743
|
+
"name": "mergeStrategy",
|
|
744
|
+
"type": "SELECT",
|
|
745
|
+
"label": "Merge Strategy",
|
|
746
|
+
"description": "What happens when this field already has a value on an existing record. Inherit follows the source or workspace policy. Newest wins suits volatile attributes such as a lead score; preserve suits anything a person may have corrected by hand.",
|
|
747
|
+
"icon": "IconGitMerge",
|
|
748
|
+
"options": [
|
|
749
|
+
{
|
|
750
|
+
"value": "INHERIT",
|
|
751
|
+
"label": "Inherit",
|
|
752
|
+
"position": 0,
|
|
753
|
+
"color": "gray",
|
|
754
|
+
"id": "012f3624-5fb5-5c26-8364-13014c75e559"
|
|
755
|
+
},
|
|
756
|
+
{
|
|
757
|
+
"value": "PRESERVE",
|
|
758
|
+
"label": "Preserve existing",
|
|
759
|
+
"position": 1,
|
|
760
|
+
"color": "green",
|
|
761
|
+
"id": "982b636c-6e32-5ab0-a4cf-a91e112e2611"
|
|
762
|
+
},
|
|
763
|
+
{
|
|
764
|
+
"value": "NEWEST_WINS",
|
|
765
|
+
"label": "Newest wins",
|
|
766
|
+
"position": 2,
|
|
767
|
+
"color": "orange",
|
|
768
|
+
"id": "d474f601-bb37-50e9-a490-450b746a88ac"
|
|
769
|
+
}
|
|
770
|
+
],
|
|
771
|
+
"defaultValue": "'INHERIT'"
|
|
772
|
+
},
|
|
505
773
|
{
|
|
506
774
|
"universalIdentifier": "911df299-940b-44f9-92f7-3fd01fde01a7",
|
|
507
775
|
"name": "priority",
|
|
@@ -578,11 +846,56 @@
|
|
|
578
846
|
"agents": [],
|
|
579
847
|
"connectionProviders": [],
|
|
580
848
|
"logicFunctions": [
|
|
849
|
+
{
|
|
850
|
+
"universalIdentifier": "0203643c-2a97-4e7c-b4a5-b76d721cf23a",
|
|
851
|
+
"name": "intake-quarantine-list",
|
|
852
|
+
"description": "List payloads the spam filter is holding, with the reason each was held.",
|
|
853
|
+
"timeoutSeconds": 15,
|
|
854
|
+
"httpRouteTriggerSettings": {
|
|
855
|
+
"path": "/intake/quarantine",
|
|
856
|
+
"httpMethod": "GET",
|
|
857
|
+
"isAuthRequired": true
|
|
858
|
+
},
|
|
859
|
+
"handlerName": "default.config.handler",
|
|
860
|
+
"sourceHandlerPath": "src/logic-functions/quarantine-list.ts",
|
|
861
|
+
"builtHandlerPath": "src/logic-functions/quarantine-list.mjs",
|
|
862
|
+
"builtHandlerChecksum": "c0a45b4e004bfb7678b97b8a034eef5b"
|
|
863
|
+
},
|
|
864
|
+
{
|
|
865
|
+
"universalIdentifier": "1454a56e-6197-4dfd-9b41-5cc3b1436af7",
|
|
866
|
+
"name": "intake-quarantine-discard",
|
|
867
|
+
"description": "Mark a quarantined payload as junk, optionally purging the stored body.",
|
|
868
|
+
"timeoutSeconds": 15,
|
|
869
|
+
"httpRouteTriggerSettings": {
|
|
870
|
+
"path": "/intake/quarantine/:logId/discard",
|
|
871
|
+
"httpMethod": "POST",
|
|
872
|
+
"isAuthRequired": true
|
|
873
|
+
},
|
|
874
|
+
"handlerName": "default.config.handler",
|
|
875
|
+
"sourceHandlerPath": "src/logic-functions/quarantine-discard.ts",
|
|
876
|
+
"builtHandlerPath": "src/logic-functions/quarantine-discard.mjs",
|
|
877
|
+
"builtHandlerChecksum": "f9d833a13813f9f3dfefe735b487dd1d"
|
|
878
|
+
},
|
|
879
|
+
{
|
|
880
|
+
"universalIdentifier": "2eea4152-1b08-4b8c-903c-f4c7ee7275d0",
|
|
881
|
+
"name": "intake-contract",
|
|
882
|
+
"description": "Describes what Intake accepts and how it is currently configured, in one call.",
|
|
883
|
+
"timeoutSeconds": 20,
|
|
884
|
+
"httpRouteTriggerSettings": {
|
|
885
|
+
"path": "/intake/contract",
|
|
886
|
+
"httpMethod": "GET",
|
|
887
|
+
"isAuthRequired": true
|
|
888
|
+
},
|
|
889
|
+
"handlerName": "default.config.handler",
|
|
890
|
+
"sourceHandlerPath": "src/logic-functions/contract.ts",
|
|
891
|
+
"builtHandlerPath": "src/logic-functions/contract.mjs",
|
|
892
|
+
"builtHandlerChecksum": "99283e7d80c9f99a6393365cbf5468bc"
|
|
893
|
+
},
|
|
581
894
|
{
|
|
582
895
|
"universalIdentifier": "3f7a9c21-8d4b-4e6f-a1c2-5b8d0f2e7a4c",
|
|
583
896
|
"name": "intake-test",
|
|
584
|
-
"description": "Dry-run:
|
|
585
|
-
"timeoutSeconds":
|
|
897
|
+
"description": "Dry-run: returns a structured diff of what would be created or updated, writing nothing.",
|
|
898
|
+
"timeoutSeconds": 20,
|
|
586
899
|
"httpRouteTriggerSettings": {
|
|
587
900
|
"path": "/intake/:slug/test",
|
|
588
901
|
"httpMethod": "POST",
|
|
@@ -591,13 +904,13 @@
|
|
|
591
904
|
"handlerName": "default.config.handler",
|
|
592
905
|
"sourceHandlerPath": "src/logic-functions/test-ingest.ts",
|
|
593
906
|
"builtHandlerPath": "src/logic-functions/test-ingest.mjs",
|
|
594
|
-
"builtHandlerChecksum": "
|
|
907
|
+
"builtHandlerChecksum": "23560c1d305ffe3900d2c8e81a1f52d3"
|
|
595
908
|
},
|
|
596
909
|
{
|
|
597
910
|
"universalIdentifier": "4ece4e39-286b-4820-81f4-b7beee71d092",
|
|
598
911
|
"name": "intake-health",
|
|
599
|
-
"description": "Health check for the Intake app.",
|
|
600
|
-
"timeoutSeconds":
|
|
912
|
+
"description": "Health check for the Intake app, including sources that have fallen silent.",
|
|
913
|
+
"timeoutSeconds": 15,
|
|
601
914
|
"httpRouteTriggerSettings": {
|
|
602
915
|
"path": "/intake/health",
|
|
603
916
|
"httpMethod": "GET",
|
|
@@ -606,7 +919,7 @@
|
|
|
606
919
|
"handlerName": "default.config.handler",
|
|
607
920
|
"sourceHandlerPath": "src/logic-functions/health.ts",
|
|
608
921
|
"builtHandlerPath": "src/logic-functions/health.mjs",
|
|
609
|
-
"builtHandlerChecksum": "
|
|
922
|
+
"builtHandlerChecksum": "ab596a3b71b91ceff0cab80383519abf"
|
|
610
923
|
},
|
|
611
924
|
{
|
|
612
925
|
"universalIdentifier": "56d46e18-6cae-4d99-9893-b7d70740c9db",
|
|
@@ -621,7 +934,22 @@
|
|
|
621
934
|
"handlerName": "default.config.handler",
|
|
622
935
|
"sourceHandlerPath": "src/logic-functions/register.ts",
|
|
623
936
|
"builtHandlerPath": "src/logic-functions/register.mjs",
|
|
624
|
-
"builtHandlerChecksum": "
|
|
937
|
+
"builtHandlerChecksum": "efd6088280e8e87a24e46b8945372f81"
|
|
938
|
+
},
|
|
939
|
+
{
|
|
940
|
+
"universalIdentifier": "5a10d4fe-c807-45c6-9e61-c191d4e37b4e",
|
|
941
|
+
"name": "intake-quarantine-release",
|
|
942
|
+
"description": "Release a quarantined payload and ingest it, overruling the spam filter.",
|
|
943
|
+
"timeoutSeconds": 30,
|
|
944
|
+
"httpRouteTriggerSettings": {
|
|
945
|
+
"path": "/intake/quarantine/:logId/release",
|
|
946
|
+
"httpMethod": "POST",
|
|
947
|
+
"isAuthRequired": true
|
|
948
|
+
},
|
|
949
|
+
"handlerName": "default.config.handler",
|
|
950
|
+
"sourceHandlerPath": "src/logic-functions/quarantine-release.ts",
|
|
951
|
+
"builtHandlerPath": "src/logic-functions/quarantine-release.mjs",
|
|
952
|
+
"builtHandlerChecksum": "0b9c0d16070a268a7f90f21462756f91"
|
|
625
953
|
},
|
|
626
954
|
{
|
|
627
955
|
"universalIdentifier": "8323c257-48fe-4bb6-b1dd-166844f575c0",
|
|
@@ -635,13 +963,29 @@
|
|
|
635
963
|
"forwardedRequestHeaders": [
|
|
636
964
|
"x-webhook-signature",
|
|
637
965
|
"x-intake-source",
|
|
638
|
-
"content-type"
|
|
966
|
+
"content-type",
|
|
967
|
+
"idempotency-key"
|
|
639
968
|
]
|
|
640
969
|
},
|
|
641
970
|
"handlerName": "default.config.handler",
|
|
642
971
|
"sourceHandlerPath": "src/logic-functions/webhook.ts",
|
|
643
972
|
"builtHandlerPath": "src/logic-functions/webhook.mjs",
|
|
644
|
-
"builtHandlerChecksum": "
|
|
973
|
+
"builtHandlerChecksum": "f12012bc0402a165b50db1f4885f7578"
|
|
974
|
+
},
|
|
975
|
+
{
|
|
976
|
+
"universalIdentifier": "8ff98390-7358-4a91-84f3-435f758182a3",
|
|
977
|
+
"name": "intake-replay-log",
|
|
978
|
+
"description": "Re-run a single stored payload through the current field rules.",
|
|
979
|
+
"timeoutSeconds": 30,
|
|
980
|
+
"httpRouteTriggerSettings": {
|
|
981
|
+
"path": "/intake/logs/:logId/replay",
|
|
982
|
+
"httpMethod": "POST",
|
|
983
|
+
"isAuthRequired": true
|
|
984
|
+
},
|
|
985
|
+
"handlerName": "default.config.handler",
|
|
986
|
+
"sourceHandlerPath": "src/logic-functions/replay-log.ts",
|
|
987
|
+
"builtHandlerPath": "src/logic-functions/replay-log.mjs",
|
|
988
|
+
"builtHandlerChecksum": "7b869e6c2ed30f2d4a8aedf49efdee25"
|
|
645
989
|
},
|
|
646
990
|
{
|
|
647
991
|
"universalIdentifier": "90f20396-a69a-4ae4-af20-6be65c565bb5",
|
|
@@ -656,7 +1000,22 @@
|
|
|
656
1000
|
"handlerName": "default.config.handler",
|
|
657
1001
|
"sourceHandlerPath": "src/logic-functions/retry.ts",
|
|
658
1002
|
"builtHandlerPath": "src/logic-functions/retry.mjs",
|
|
659
|
-
"builtHandlerChecksum": "
|
|
1003
|
+
"builtHandlerChecksum": "e96a784b817bb728a9a46e6566cd9503"
|
|
1004
|
+
},
|
|
1005
|
+
{
|
|
1006
|
+
"universalIdentifier": "b82ed4c4-07bc-4095-8ee5-611200626dc0",
|
|
1007
|
+
"name": "intake-replay-bulk",
|
|
1008
|
+
"description": "Re-run a batch of stored payloads through the current field rules.",
|
|
1009
|
+
"timeoutSeconds": 300,
|
|
1010
|
+
"httpRouteTriggerSettings": {
|
|
1011
|
+
"path": "/intake/replay",
|
|
1012
|
+
"httpMethod": "POST",
|
|
1013
|
+
"isAuthRequired": true
|
|
1014
|
+
},
|
|
1015
|
+
"handlerName": "default.config.handler",
|
|
1016
|
+
"sourceHandlerPath": "src/logic-functions/replay-bulk.ts",
|
|
1017
|
+
"builtHandlerPath": "src/logic-functions/replay-bulk.mjs",
|
|
1018
|
+
"builtHandlerChecksum": "342c5df9d1a995ec6bcd419b340e37da"
|
|
660
1019
|
},
|
|
661
1020
|
{
|
|
662
1021
|
"universalIdentifier": "e836fdd6-fd54-4aa4-ba6d-87d5a02148ef",
|
|
@@ -665,7 +1024,22 @@
|
|
|
665
1024
|
"handlerName": "default.config.handler",
|
|
666
1025
|
"sourceHandlerPath": "src/post-install.ts",
|
|
667
1026
|
"builtHandlerPath": "src/post-install.mjs",
|
|
668
|
-
"builtHandlerChecksum": "
|
|
1027
|
+
"builtHandlerChecksum": "0a496f144818d5de2427bc66eedb7e25"
|
|
1028
|
+
},
|
|
1029
|
+
{
|
|
1030
|
+
"universalIdentifier": "e88cd5f2-ab96-4f89-ac55-78db66b32d66",
|
|
1031
|
+
"name": "intake-check-silence",
|
|
1032
|
+
"description": "Check every source against its expected cadence and alert on sources that have fallen silent.",
|
|
1033
|
+
"timeoutSeconds": 60,
|
|
1034
|
+
"httpRouteTriggerSettings": {
|
|
1035
|
+
"path": "/intake/sources/check-silence",
|
|
1036
|
+
"httpMethod": "POST",
|
|
1037
|
+
"isAuthRequired": true
|
|
1038
|
+
},
|
|
1039
|
+
"handlerName": "default.config.handler",
|
|
1040
|
+
"sourceHandlerPath": "src/logic-functions/check-silence.ts",
|
|
1041
|
+
"builtHandlerPath": "src/logic-functions/check-silence.mjs",
|
|
1042
|
+
"builtHandlerChecksum": "b63165b64def7272ed4099ec8bc87d4f"
|
|
669
1043
|
}
|
|
670
1044
|
],
|
|
671
1045
|
"frontComponents": [
|
|
@@ -676,7 +1050,7 @@
|
|
|
676
1050
|
"componentName": "SettingsPanel",
|
|
677
1051
|
"sourceComponentPath": "src/front-components/settings-panel.tsx",
|
|
678
1052
|
"builtComponentPath": "src/front-components/settings-panel.mjs",
|
|
679
|
-
"builtComponentChecksum": "
|
|
1053
|
+
"builtComponentChecksum": "7d734d21f34ae308798986d33566c21cf3bf0efba4bd0e5d3f76ff2a988cee69",
|
|
680
1054
|
"isHeadless": false,
|
|
681
1055
|
"usesSdkClient": false
|
|
682
1056
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "twenty-app-intake",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twenty-app",
|