{ "kind": "open-flow-workflow", "version": 1, "name": "Log and Route Twenty CRM Events", "content": { "modelVersion": 14, "document": { "bindings": {}, "graph": { "nodes": { "n_e088afb95fbed303": { "name": "Receive Twenty CRM events", "kind": "webhook", "bodyFields": [ { "jsonSchema": {}, "nullable": true, "handle": "targetUrl" }, { "jsonSchema": {}, "nullable": true, "handle": "eventName" }, { "jsonSchema": {}, "nullable": true, "handle": "workspaceId" }, { "jsonSchema": {}, "nullable": true, "handle": "webhookId" }, { "jsonSchema": {}, "nullable": true, "handle": "eventDate" }, { "jsonSchema": {}, "nullable": true, "handle": "userId" }, { "jsonSchema": {}, "nullable": true, "handle": "apiKeyId" }, { "jsonSchema": {}, "nullable": true, "handle": "objectMetadata" }, { "jsonSchema": {}, "nullable": true, "handle": "workspaceMemberId" }, { "jsonSchema": {}, "nullable": true, "handle": "applicationId" }, { "jsonSchema": {}, "nullable": true, "handle": "record" }, { "jsonSchema": {}, "nullable": true, "handle": "updatedFields" }, { "jsonSchema": {}, "nullable": true, "handle": "event" } ], "method": "POST", "options": { "responseData": "Accepted", "responseStatusCode": 200 } }, "n_0c388da3ca1fcc9a": { "inputs": { "body": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_e088afb95fbed303", "output": "body" } ] }, "headers": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_e088afb95fbed303", "output": "headers" } ] }, "secret": { "kind": "unset" } }, "name": "Verify and read the event", "kind": "task", "task": { "inputs": [ { "jsonSchema": { "type": "object", "additionalProperties": true }, "nullable": false, "handle": "body" }, { "jsonSchema": { "type": "object", "additionalProperties": true }, "nullable": false, "handle": "headers" }, { "jsonSchema": { "type": "string" }, "nullable": false, "handle": "secret" } ], "outputs": [ { "jsonSchema": { "type": "boolean" }, "nullable": false, "handle": "deleted" }, { "jsonSchema": { "type": "string" }, "nullable": false, "handle": "event" }, { "jsonSchema": { "type": "string" }, "nullable": false, "handle": "metadataId" }, { "jsonSchema": { "type": "string" }, "nullable": false, "handle": "recordId" }, { "jsonSchema": { "type": "array", "items": { "type": "array", "items": { "anyOf": [ { "type": "string", "description": "A union variant." }, { "type": "number", "description": "A union variant." }, { "type": "boolean", "description": "A union variant." }, { "const": null, "description": "A union variant." } ], "description": "The cell value schema." }, "description": "An array item." }, "description": "The data to append." }, "nullable": false, "handle": "values" } ], "name": "Verify and read the event", "moduleId": "n_1c1f8a1e9f05cfd0", "capabilities": [ { "kind": "connector", "mode": "independent", "actions": [] } ] } }, "n_831c3e5d1bb52231": { "inputs": { "spreadsheetId": { "kind": "value", "value": "YOUR_SPREADSHEET_ID" }, "range": { "kind": "value", "value": "'Events'!A:E" }, "values": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_0c388da3ca1fcc9a", "output": "values" } ] }, "majorDimension": { "kind": "value", "value": "ROWS" }, "valueInputOption": { "kind": "value", "value": "RAW" }, "insertDataOption": { "kind": "value", "value": "INSERT_ROWS" } }, "name": "Append the event log", "kind": "task", "task": { "inputs": [ { "description": "The spreadsheet ID.", "jsonSchema": { "type": "string", "minLength": 1, "description": "The spreadsheet ID." }, "nullable": true, "handle": "spreadsheetId", "value": null }, { "description": "The A1 notation of the range to append values after.", "jsonSchema": { "type": "string", "minLength": 1, "description": "The A1 notation of the range to append values after." }, "nullable": false, "handle": "range" }, { "description": "The data to append.", "jsonSchema": { "type": "array", "items": { "type": "array", "items": { "anyOf": [ { "type": "string", "description": "A union variant." }, { "type": "number", "description": "A union variant." }, { "type": "boolean", "description": "A union variant." }, { "const": null, "description": "A union variant." } ], "description": "The cell value schema." }, "description": "An array item." }, "description": "The data to append." }, "nullable": false, "handle": "values" }, { "description": "The major dimension of the values array.", "jsonSchema": { "type": "string", "enum": [ "ROWS", "COLUMNS" ], "description": "The major dimension of the values array." }, "nullable": true, "handle": "majorDimension", "value": null }, { "description": "How the data should be inserted.", "jsonSchema": { "type": "string", "enum": [ "OVERWRITE", "INSERT_ROWS" ], "description": "How the data should be inserted." }, "nullable": true, "handle": "insertDataOption", "value": null }, { "description": "How the input data should be interpreted.", "jsonSchema": { "type": "string", "enum": [ "RAW", "USER_ENTERED" ], "description": "How the input data should be interpreted." }, "nullable": true, "handle": "valueInputOption", "value": null }, { "description": "Whether to include the appended values in the response.", "jsonSchema": { "type": "boolean", "description": "Whether to include the appended values in the response." }, "nullable": true, "handle": "includeValuesInResponse", "value": null }, { "description": "How values in the response should be rendered.", "jsonSchema": { "type": "string", "enum": [ "FORMATTED_VALUE", "UNFORMATTED_VALUE", "FORMULA" ], "description": "How values in the response should be rendered." }, "nullable": true, "handle": "responseValueRenderOption", "value": null }, { "description": "How dates in the response should be rendered.", "jsonSchema": { "type": "string", "enum": [ "SERIAL_NUMBER", "FORMATTED_STRING" ], "description": "How dates in the response should be rendered." }, "nullable": true, "handle": "responseDateTimeRenderOption", "value": null } ], "outputs": [ { "description": "The unique ID of the spreadsheet.", "jsonSchema": { "type": "string", "description": "The unique ID of the spreadsheet." }, "nullable": false, "handle": "spreadsheetId" }, { "description": "The A1 notation of the range that was updated.", "jsonSchema": { "type": "string", "description": "The A1 notation of the range that was updated." }, "nullable": false, "handle": "updatedRange" }, { "description": "The number of rows that were updated.", "jsonSchema": { "type": "integer", "description": "The number of rows that were updated." }, "nullable": false, "handle": "updatedRows" }, { "description": "The number of columns that were updated.", "jsonSchema": { "type": "integer", "description": "The number of columns that were updated." }, "nullable": false, "handle": "updatedColumns" }, { "description": "The number of cells that were updated.", "jsonSchema": { "type": "integer", "description": "The number of cells that were updated." }, "nullable": false, "handle": "updatedCells" }, { "description": "The values of the cells after the update.", "jsonSchema": { "type": "object", "properties": { "range": { "type": "string", "description": "The A1 notation of the range." }, "majorDimension": { "type": "string", "description": "The major dimension of the values (ROWS or COLUMNS)." }, "values": { "type": "array", "items": { "type": "array", "items": { "anyOf": [ { "type": "string", "description": "A union variant." }, { "type": "number", "description": "A union variant." }, { "type": "boolean", "description": "A union variant." }, { "const": null, "description": "A union variant." } ], "description": "The cell value schema." }, "description": "An array item." }, "description": "The data values in the range." } }, "required": [ "range", "majorDimension", "values" ], "additionalProperties": false, "description": "The values of the cells after the update." }, "nullable": true, "handle": "updatedData" }, { "description": "The A1 notation of the table range that data was appended to.", "jsonSchema": { "type": "string", "description": "The A1 notation of the table range that data was appended to." }, "nullable": false, "handle": "tableRange" } ], "name": "Append the event log", "executor": { "kind": "connector", "action": "googlesheets.spreadsheets_values_append" } } }, "n_cf4aad006f3ebe9b": { "inputs": { "updatedRows": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_831c3e5d1bb52231", "output": "updatedRows" } ] } }, "name": "Confirm the log entry", "kind": "task", "task": { "inputs": [ { "jsonSchema": { "type": "integer", "description": "The number of rows that were updated." }, "nullable": false, "handle": "updatedRows" } ], "outputs": [], "name": "Confirm the log entry", "moduleId": "n_9951191d4a4455d4", "capabilities": [ { "kind": "connector", "mode": "independent", "actions": [] } ] } }, "n_2d3862584944ee3c": { "inputs": { "event": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_0c388da3ca1fcc9a", "output": "event" } ] }, "metadataId": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_0c388da3ca1fcc9a", "output": "metadataId" } ] }, "recordId": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_0c388da3ca1fcc9a", "output": "recordId" } ] } }, "name": "Prepare the notification", "kind": "task", "task": { "inputs": [ { "jsonSchema": { "type": "string" }, "nullable": false, "handle": "event" }, { "jsonSchema": { "type": "string" }, "nullable": false, "handle": "metadataId" }, { "jsonSchema": { "type": "string" }, "nullable": false, "handle": "recordId" } ], "outputs": [ { "jsonSchema": { "type": "string", "description": "Email subject line." }, "nullable": true, "handle": "subject" }, { "jsonSchema": { "type": "string", "description": "Email body content." }, "nullable": true, "handle": "body" }, { "jsonSchema": { "type": "string", "description": "Plain text message content. When blocks are provided, Slack uses this as notification and accessibility fallback text." }, "nullable": true, "handle": "text" } ], "name": "Prepare the notification", "moduleId": "n_6ff49958fc9df60a", "capabilities": [ { "kind": "connector", "mode": "independent", "actions": [] } ] } }, "n_a34f1a3c6a68f396": { "inputs": {}, "name": "Was the record deleted?", "kind": "condition", "matchMode": "first", "cases": [ { "output": "yes", "groups": [ { "expressions": [ { "left": { "kind": "source", "source": { "kind": "node", "nodeId": "n_0c388da3ca1fcc9a", "output": "deleted" } }, "operator": "isTrue" } ] } ] } ] }, "n_ae083f08f26268fb": { "inputs": { "to": { "kind": "value", "value": "YOUR_ALERT_EMAIL" }, "subject": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_2d3862584944ee3c", "output": "subject" } ] }, "body": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_2d3862584944ee3c", "output": "body" } ] }, "isHtml": { "kind": "value", "value": false } }, "name": "Email deletion details", "kind": "task", "task": { "inputs": [ { "description": "Primary recipient email address.", "jsonSchema": { "type": "string", "description": "Primary recipient email address." }, "nullable": true, "handle": "to", "value": null }, { "description": "Additional To recipients.", "jsonSchema": { "type": "array", "items": { "type": "string", "description": "An array item." }, "description": "Additional To recipients." }, "nullable": true, "handle": "extraRecipients", "value": null }, { "description": "Cc recipients.", "jsonSchema": { "anyOf": [ { "type": "string", "description": "A union variant." }, { "type": "array", "items": { "type": "string", "description": "An array item." }, "description": "A union variant." } ], "description": "Cc recipients." }, "nullable": true, "handle": "cc", "value": null }, { "description": "Bcc recipients.", "jsonSchema": { "anyOf": [ { "type": "string", "description": "A union variant." }, { "type": "array", "items": { "type": "string", "description": "An array item." }, "description": "A union variant." } ], "description": "Bcc recipients." }, "nullable": true, "handle": "bcc", "value": null }, { "description": "Email subject line.", "jsonSchema": { "type": "string", "description": "Email subject line." }, "nullable": true, "handle": "subject", "value": null }, { "description": "Email body content.", "jsonSchema": { "type": "string", "description": "Email body content." }, "nullable": true, "handle": "body", "value": null }, { "description": "Set to true when the body content is already HTML.", "jsonSchema": { "type": "boolean", "description": "Set to true when the body content is already HTML." }, "nullable": true, "handle": "isHtml", "value": null }, { "description": "Attachments and CID inline images. Up to 25,000,000 decoded bytes in total and 35,000,000 MIME bytes.", "jsonSchema": { "type": "array", "items": { "type": "object", "properties": { "filename": { "type": "string", "description": "Attachment filename; omit for an unnamed attachment." }, "mimeType": { "type": "string", "description": "MIME type without parameters; defaults to application/octet-stream." }, "contentBase64": { "type": "string", "description": "Standard padded Base64 content. Empty string represents a zero-byte file. Supply this or fileUrl." }, "fileUrl": { "type": "string", "format": "uri", "description": "Public URL to download the attachment. Supply this or contentBase64." }, "contentId": { "type": "string", "minLength": 1, "description": "Bare Content-ID without cid: or angle brackets; reference it as cid: in HTML." }, "disposition": { "type": "string", "enum": [ "inline", "attachment" ], "description": "Defaults to inline when contentId is set, otherwise attachment. Inline requires contentId." } }, "additionalProperties": false, "description": "An attachment or CID inline resource.", "oneOf": [ { "required": [ "contentBase64" ] }, { "required": [ "fileUrl" ] } ] }, "maxItems": 100, "description": "Attachments and CID inline images. Up to 25,000,000 decoded bytes in total and 35,000,000 MIME bytes." }, "nullable": true, "handle": "attachments", "value": null }, { "description": "Verified Gmail send-as alias to use in the From header.", "jsonSchema": { "type": "string", "description": "Verified Gmail send-as alias to use in the From header." }, "nullable": true, "handle": "fromEmail", "value": null } ], "outputs": [ { "description": "Gmail message ID.", "jsonSchema": { "type": "string", "description": "Gmail message ID." }, "nullable": false, "handle": "messageId" }, { "description": "Gmail thread ID.", "jsonSchema": { "type": "string", "description": "Gmail thread ID." }, "nullable": true, "handle": "threadId" } ], "name": "Email deletion details", "executor": { "kind": "connector", "action": "gmail.send_email" } } }, "n_2f386a2834293e99": { "inputs": { "channelId": { "kind": "value", "value": "YOUR_SLACK_CHANNEL_ID" }, "text": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_2d3862584944ee3c", "output": "text" } ] } }, "name": "Send the event to Slack", "kind": "task", "task": { "inputs": [ { "description": "The Slack conversation or channel ID.", "jsonSchema": { "type": "string", "minLength": 1, "description": "The Slack conversation or channel ID." }, "nullable": false, "handle": "channelId" }, { "description": "Plain text message content. When blocks are provided, Slack uses this as notification and accessibility fallback text.", "jsonSchema": { "type": "string", "description": "Plain text message content. When blocks are provided, Slack uses this as notification and accessibility fallback text." }, "nullable": true, "handle": "text", "value": null }, { "description": "Slack Block Kit blocks to render in the message.", "jsonSchema": { "type": "array", "items": { "type": "object", "properties": {}, "additionalProperties": true, "description": "A Slack Block Kit block object. Pass the block exactly as Slack documents it." }, "minItems": 1, "description": "Slack Block Kit blocks to render in the message." }, "nullable": true, "handle": "blocks", "value": null }, { "description": "Slack legacy attachments to include in the message.", "jsonSchema": { "type": "array", "items": { "type": "object", "properties": {}, "additionalProperties": true, "description": "A Slack legacy attachment object. Prefer blocks for new messages when possible." }, "minItems": 1, "description": "Slack legacy attachments to include in the message." }, "nullable": true, "handle": "attachments", "value": null }, { "description": "Whether Slack should unfurl links in the message.", "jsonSchema": { "type": "boolean", "description": "Whether Slack should unfurl links in the message." }, "nullable": true, "handle": "unfurlLinks", "value": null }, { "description": "Whether Slack should unfurl media in the message.", "jsonSchema": { "type": "boolean", "description": "Whether Slack should unfurl media in the message." }, "nullable": true, "handle": "unfurlMedia", "value": null }, { "description": "Slack message metadata to attach to the message.", "jsonSchema": { "type": "object", "properties": {}, "additionalProperties": true, "description": "Slack message metadata to attach to the message." }, "nullable": true, "handle": "metadata", "value": null } ], "outputs": [ { "description": "The timestamp identifier of the posted message.", "jsonSchema": { "type": "string", "description": "The timestamp identifier of the posted message." }, "nullable": false, "handle": "ts" }, { "description": "The channel ID where the message was posted.", "jsonSchema": { "type": "string", "description": "The channel ID where the message was posted." }, "nullable": false, "handle": "channelId" } ], "name": "Send the event to Slack", "executor": { "kind": "connector", "action": "slack.post_message" } } } }, "edges": [ { "source": "n_e088afb95fbed303", "target": "n_0c388da3ca1fcc9a" }, { "source": "n_0c388da3ca1fcc9a", "target": "n_831c3e5d1bb52231" }, { "source": "n_831c3e5d1bb52231", "target": "n_cf4aad006f3ebe9b" }, { "source": "n_cf4aad006f3ebe9b", "target": "n_2d3862584944ee3c" }, { "source": "n_2d3862584944ee3c", "target": "n_a34f1a3c6a68f396" }, { "source": "n_a34f1a3c6a68f396", "target": "n_ae083f08f26268fb", "sourceHandle": "yes" }, { "source": "n_a34f1a3c6a68f396", "target": "n_2f386a2834293e99", "sourceHandle": "otherwise" } ] } }, "modules": { "n_1c1f8a1e9f05cfd0": { "name": "Verify and read the event", "imports": [], "source": "import {createHmac} from 'node:crypto';\nexport default function verify({body,headers,secret}){if(!body||typeof body!=='object'||Array.isArray(body)||!headers||typeof headers!=='object'||typeof secret!=='string'||!secret)throw Error('Configure Twenty webhook secret');const h=Object.fromEntries(Object.entries(headers).map(([k,v])=>[k.toLowerCase(),v])),timestamp=h['x-twenty-webhook-timestamp'],signature=h['x-twenty-webhook-signature'];if(typeof timestamp!=='string'||!/^\\d{13}$/.test(timestamp)||Math.abs(Date.now()-Number(timestamp))>900000||typeof signature!=='string'||!/^[0-9a-f]{64}$/.test(signature))throw Error('Invalid Twenty webhook signature or timestamp');const expected=createHmac('sha256',secret).update(timestamp+':'+JSON.stringify(body)).digest('hex');let diff=0;for(let i=0;i<64;i++)diff|=expected.charCodeAt(i)^signature.charCodeAt(i);if(diff)throw Error('Invalid Twenty webhook signature');const event=body.eventName,meta=body.objectMetadata,record=body.record;if(typeof event!=='string'||!/^.+\\.[^.]+$/.test(event)||!meta||typeof meta.id!=='string'||!meta.id||!record||typeof record.id!=='string'||!record.id)throw Error('Invalid Twenty record event');const type=typeof meta.nameSingular==='string'?meta.nameSingular:'',typename=typeof record.__typename==='string'?record.__typename:'';return {deleted:['delete','deleted'].includes(event.split('.')[1]),event,metadataId:meta.id,recordId:record.id,values:[[event,meta.id,type,record.id,typename]]}}\n" }, "n_9951191d4a4455d4": { "name": "Confirm the log entry", "imports": [], "source": "export default function confirm({updatedRows}){if(updatedRows!==1)throw Error('Event log append not confirmed');return {}}\n" }, "n_6ff49958fc9df60a": { "name": "Prepare the notification", "imports": [], "source": "export default function notice({event,metadataId,recordId}){if(!event||!metadataId||!recordId)throw Error('Missing event details');const esc=x=>x.replace(/&/g,'&').replace(//g,'>');return {subject:'Record Deleted in TwentyCRM',body:`Object metadata ID: ${metadataId}\\nRecord ID: ${recordId}`,text:`event: ${esc(event)}\\nevent_id: ${esc(metadataId)}\\nrecord_id: ${esc(recordId)}`}}\n" } } }, "presentation": { "designer": { "version": 1, "flow": { "direction": "vertical" } } } }