snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
---
title: Handle Arrays in Code Actions
description: Learn how to properly handle array inputs in workflow Code actions.
---
When working with arrays in Code actions, you may encounter two common challenges:
1. **Arrays passed as strings** — data from external systems or previous steps arrives as a string instead of an actual array
2. **Can't select individual items** — you can only select the entire array, not specific fields within it
Both can be solved with a Code node.
## Parsing Arrays from Strings
Arrays are often passed between workflow steps as strings or JSON rather than native arrays. This happens when:
- Receiving data from external APIs via HTTP Request
- Processing webhook payloads
- Passing data between workflow steps
**Solution**: Add this pattern at the start of your Code action:
```javascript
export const main = async (params: {
users: any;
}): Promise<object> => {
const { users } = params;
// Handle input that may come as a string or an array
const usersFormatted = typeof users === "string" ? JSON.parse(users) : users;
// Now you can safely work with usersFormatted as an array
return {
users: usersFormatted.map((user) => ({
...user,
activityStatus: String(user.activityStatus).toUpperCase(),
})),
};
};
```
The key line `typeof users === "string" ? JSON.parse(users) : users` checks if the input is a string, parses it if needed, or uses it directly if it's already an array.
## Extracting Individual Fields from Arrays
A webhook might return an array like `answers: [...]`, but in subsequent workflow steps you can only select the **entire array** — not individual items within it.
**Solution**: Add a Code node to extract specific fields and return them as a structured object:
```javascript
export const main = async (params: {
answers: any;
}): Promise<object> => {
const { answers } = params;
// Handle input that may come as a string or an array
const answersFormatted = typeof answers === "string"
? JSON.parse(answers)
: answers;
// Extract specific fields from the array
const firstname = answersFormatted[0]?.text || "";
const name = answersFormatted[1]?.text || "";
return {
answer: {
firstname,
name
}
};
};
```
The Code node returns a structured object instead of an array. In subsequent steps, you can now select individual fields like `answer.firstname` and `answer.name` from the variable picker.
<Tip>
**Want to loop over the array instead of extracting fields?** When a Code or Logic Function step returns a top-level array, you can feed it straight into an [Iterator](/user-guide/workflows/capabilities/use-iterator): select the step's **Whole list** option as the Iterator's input, then reference each element with `{{iterator.currentItem}}` inside the loop. In that case you don't need to restructure the array into an object.
</Tip>
<Tip>
Click the square icon at the top right of the code editor to display it in full screen — helpful since the default editor width is limited.
</Tip>

View File

@@ -0,0 +1,182 @@
---
title: Bring Product Data into Twenty
description: Sync product catalog data from a data warehouse into your CRM on a schedule.
---
Use this pattern to keep Twenty in sync with product data from your data warehouse (e.g., Snowflake, BigQuery, PostgreSQL).
## Workflow Structure
1. **Trigger**: On a Schedule
2. **Code**: Query your data warehouse
3. **Code** (optional): Format data as array
4. **Iterator**: Loop through each product
5. **Upsert Record**: Create or update in Twenty
<img src="/images/user-guide/workflows/create_users_workflow.png" style={{width:'100%'}}/>
## Step 1: Schedule the Trigger
Set the workflow to run at a frequency matching your data freshness needs:
- Every 5 minutes for near real-time sync
- Every hour for less critical data
- Daily for batch updates
## Step 2: Query Your Data Warehouse
Add a **Code** action to fetch recent data:
```javascript
export const main = async () => {
const intervalMinutes = 10; // Match your schedule frequency
const cutoffTime = new Date(Date.now() - intervalMinutes * 60 * 1000).toISOString();
// Replace with your actual data warehouse connection
const response = await fetch("https://your-warehouse-api.com/query", {
method: "POST",
headers: {
"Authorization": "Bearer YOUR_API_KEY",
"Content-Type": "application/json"
},
body: JSON.stringify({
query: `
SELECT id, name, sku, price, stock_quantity, updated_at
FROM products
WHERE updated_at >= '${cutoffTime}'
`
})
});
const data = await response.json();
return { products: data.results };
};
```
<Tip>
Filter by `updated_at >= last X minutes` to retrieve only recently changed records. This keeps the sync efficient.
</Tip>
## Step 3: Format Data (Optional)
If your warehouse returns data in a format that needs transformation, add another **Code** action. Common transformations include type conversions, field renaming, and data cleanup.
### Example: User Data with Boolean and Status Fields
```javascript
export const main = async (params: {
users: any;
}): Promise<object> => {
const { users } = params;
const usersFormatted = typeof users === "string" ? JSON.parse(users) : users;
// Convert string "true"/"false" to actual booleans
const toBool = (v: any) => v === true || v === "true";
return {
users: usersFormatted.map((user) => ({
...user,
activityStatus: String(user.activityStatus).toUpperCase(),
isActiveLast30d: toBool(user.isActiveLast30d),
isActiveLast7d: toBool(user.isActiveLast7d),
isActiveLast24h: toBool(user.isActiveLast24h),
isTwenty: toBool(user.isTwenty),
})),
};
};
```
### Example: Product Data with Type Conversions
```javascript
export const main = async (params: { products: any }) => {
const products = typeof params.products === "string"
? JSON.parse(params.products)
: params.products;
return {
products: products.map(product => ({
externalId: product.id,
name: product.name,
sku: product.sku,
price: parseFloat(product.price), // String → Number
stockQuantity: parseInt(product.stock_quantity),
isActive: product.status === "active" // String → Boolean
}))
};
};
```
### Example: Date and Currency Formatting
```javascript
export const main = async (params: { deals: any }) => {
const deals = typeof params.deals === "string"
? JSON.parse(params.deals)
: params.deals;
return {
deals: deals.map(deal => ({
...deal,
// Convert Unix timestamp to ISO date
closedAt: deal.closed_timestamp
? new Date(deal.closed_timestamp * 1000).toISOString()
: null,
// Ensure amount is a number (remove currency symbols)
amount: parseFloat(String(deal.amount).replace(/[^0-9.-]/g, "")),
// Normalize stage names
stage: deal.stage?.toLowerCase().replace(/_/g, " ")
}))
};
};
```
### Common Transformations
| Source Format | Target Format | Code |
|---------------|---------------|------|
| `"true"` / `"false"` | `true` / `false` | `v === true \|\| v === "true"` |
| `"123.45"` | `123.45` | `parseFloat(value)` |
| `"active"` | `"ACTIVE"` | `value.toUpperCase()` |
| `1704067200` (Unix) | ISO date | `new Date(v * 1000).toISOString()` |
| `"$1,234.56"` | `1234.56` | `parseFloat(v.replace(/[^0-9.-]/g, ""))` |
| `null` / `undefined` | `""` | `value \|\| ""` |
## Step 4: Iterate Through Products
Add an **Iterator** action:
- Input: `{{code.products}}`
This loops through each product in the array.
## Step 5: Upsert Each Record
Inside the iterator, add an **Upsert Record** action:
| Setting | Value |
|---------|-------|
| **Object** | Your custom Product object |
| **Match by** | External ID or SKU (unique identifier) |
| **Name** | `{{iterator.item.name}}` |
| **SKU** | `{{iterator.item.sku}}` |
| **Price** | `{{iterator.item.price}}` |
<Tip>
Use **Upsert** (update or create) instead of building separate branches for create vs. update. It's faster to build and easier to debug.
</Tip>
## Example Use Cases
| Source | Data |
|--------|------|
| **ERP system** | Product catalog, pricing, inventory |
| **E-commerce platform** | Orders, customers, product updates |
| **Data warehouse** | Aggregated metrics, enriched data |
| **Inventory system** | Stock levels, reorder alerts |
## Related
- [Workflow Triggers](/user-guide/workflows/capabilities/workflow-triggers)
- [Workflow Actions](/user-guide/workflows/capabilities/workflow-actions)
- [Handle Arrays in Code Actions](/user-guide/workflows/how-tos/advanced-configurations/handle-arrays-in-code-actions)

View File

@@ -0,0 +1,130 @@
---
title: Bring Typeform Submissions into Twenty
description: Handle Typeform's webhook payload to create leads from form submissions.
---
For standard webhook setup, see [Set Up a Webhook Trigger](/user-guide/workflows/how-tos/connect-to-other-tools/set-up-a-webhook-trigger). This article covers the specific handling required for Typeform's custom payload structure.
### Step 1: Create a Webhook Workflow
1. Go to **Settings → Workflows**
2. Click **+ New Workflow**
3. Select **Webhook** as the trigger
4. Copy the webhook URL
### Step 2: Configure Typeform
1. In Typeform, open your form
2. Go to **Connect → Webhooks**
3. Paste your Twenty webhook URL
4. Save
### Step 3: Understand the Typeform Payload
Typeform sends a nested JSON structure. Here's a simplified example:
```json
{
"event_type": "form_response",
"form_response": {
"form_id": "abc123",
"submitted_at": "2025-01-15T10:30:00Z",
"answers": [
{
"text": "Jane",
"type": "text",
"field": { "id": "field1", "type": "short_text", "title": "First Name" }
},
{
"text": "Smith",
"type": "text",
"field": { "id": "field2", "type": "short_text", "title": "Last Name" }
},
{
"text": "Acme Corp",
"type": "text",
"field": { "id": "field3", "type": "short_text", "title": "Company" }
},
{
"email": "jane@acme.com",
"type": "email",
"field": { "id": "field4", "type": "email", "title": "Email" }
},
{
"type": "choice",
"field": { "id": "field5", "type": "dropdown", "title": "Team Size" },
"choice": { "label": "10-50" }
}
]
}
}
```
Key things to note:
- Form data is nested under `form_response`
- **Answers are returned as an array**, not as named fields
- Each answer includes the field type and title for reference
### Step 4: Extract Fields from the Answers Array
Since `answers` is an array, you can only select the entire array in subsequent steps — not individual fields. Add a **Code** action to extract the fields you need:
```javascript
export const main = async (params: {
answers: any;
}): Promise<object> => {
const { answers } = params;
// Handle input that may come as a string or an array
const answersFormatted = typeof answers === "string"
? JSON.parse(answers)
: answers;
// Extract fields by position or by finding the field type
const firstName = answersFormatted[0]?.text || "";
const lastName = answersFormatted[1]?.text || "";
const company = answersFormatted[2]?.text || "";
const email = answersFormatted.find(a => a.type === "email")?.email || "";
const teamSize = answersFormatted.find(a => a.type === "choice")?.choice?.label || "";
return {
contact: {
firstName,
lastName,
company,
email,
teamSize
}
};
};
```
Now in subsequent steps, you can select `contact.firstName`, `contact.email`, etc. from the variable picker.
<Tip>
For more details on handling arrays in Code actions, see [Handle Arrays in Code Actions](/user-guide/workflows/how-tos/advanced-configurations/handle-arrays-in-code-actions).
</Tip>
### Step 5: Create the Record
Add a **Create Record** action:
| Field | Value |
|-------|-------|
| **Object** | People |
| **First Name** | `{{code.contact.firstName}}` |
| **Last Name** | `{{code.contact.lastName}}` |
| **Email** | `{{code.contact.email}}` |
| **Company** | Search or create based on `{{code.contact.company}}` |
### Step 6: Test and Activate
1. Submit a test response in Typeform
2. Check the workflow run to verify data was captured
3. Activate the workflow
## Related
- [Set Up a Webhook Trigger](/user-guide/workflows/how-tos/connect-to-other-tools/set-up-a-webhook-trigger)
- [Handle Arrays in Code Actions](/user-guide/workflows/how-tos/advanced-configurations/handle-arrays-in-code-actions)

View File

@@ -0,0 +1,319 @@
---
title: Generate a PDF from Twenty
description: Create a workflow to generate and attach a PDF (such as a quote) to a record.
---
Automatically generate or fetch a PDF and attach it to a record in Twenty. This is commonly used to create quotes, invoices, or reports that are linked to Companies, Opportunities, or other objects.
## Overview
This workflow uses a **Manual Trigger** so users can generate a PDF on demand for any selected record. A **Logic Function** handles:
1. Downloading the PDF from a URL (from a PDF generation service)
2. Uploading the file to Twenty
3. Creating an Attachment linked to the record
## Prerequisites
Before setting up the workflow:
1. **Create an API Key**: Go to **Settings → APIs** and create a new API key. You'll need this token for the Logic Function.
2. **Set up a PDF generation service** (optional): If you want to dynamically generate PDFs (e.g., quotes), use a service like Carbone, PDFMonkey, or DocuSeal to create the PDF and get a download URL.
## Step-by-Step Setup
### Step 1: Configure the Trigger
1. Go to **Workflows** and create a new workflow
2. Select **Manual Trigger**
3. Choose the object you want to attach PDFs to (e.g., **Company** or **Opportunity**)
<Tip>
With a Manual Trigger, users can run this workflow using a button that appears on the top right once a record is selected — this runs the workflow to generate and attach a PDF.
</Tip>
### Step 2: Add a Logic Function
1. Add a **Code** action (Logic Function)
2. Create a new function with the code below
3. Configure the input parameters
#### Input Parameters
| Parameter | Value |
|-----------|-------|
| `companyId` | `{{trigger.object.id}}` |
<Note>
If attaching to a different object (Person, Opportunity, etc.), rename the parameter accordingly (e.g., `personId`, `opportunityId`) and update the logic function.
</Note>
#### Logic Function Code
```typescript
export const main = async (
params: { companyId: string },
) => {
const { companyId } = params;
// Replace with your Twenty GraphQL endpoints (/metadata for metadata and files or /graphql for your records)
// Cloud: https://api.twenty.com/graphql
// Self-hosted: https://your-domain.com/graphql
const metadataGraphqlEndpoint = 'https://api.twenty.com/metadata';
const dataGraphqlEndpoint = 'https://api.twenty.com/graphql';
// Replace with your API key from Settings → APIs
const authToken = 'YOUR_API_KEY';
// Replace with your PDF URL
// This could be from a PDF generation service or a static URL
const pdfUrl = 'https://your-pdf-service.com/generated-quote.pdf';
const filename = 'quote.pdf';
// Step 1: Download the PDF file
const pdfResponse = await fetch(pdfUrl);
if (!pdfResponse.ok) {
throw new Error(`Failed to download PDF: ${pdfResponse.status}`);
}
const pdfBlob = await pdfResponse.blob();
const pdfFile = new File([pdfBlob], filename, { type: 'application/pdf' });
const fieldMetadataIdQuery = `
query FindUploadFileFieldMetadataId {
objects {
edges {
node {
nameSingular
fieldsList {
id
name
}
}
}
}
}
`;
// Step 2: Find a fieldMetadataId of "Attachment file" field in Attachments object with GraphQL API
const response = await fetch(metadataGraphqlEndpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${authToken}`
},
body: {
query: fieldMetadataIdQuery,
}
});
const result = await response.json();
const uploadFileFieldMetadataId = result.data.objects.edges.find(object => object.node.nameSingular === 'attachment').node.fieldsList.find(field => field.name === 'file').id;
// Step 3: Request an upload URL for the file
const createFileUploadMutation = `
mutation CreateFileUpload($filename: String!, $size: Float!, $fileFolder: FileFolder!, $fieldMetadataId: String) {
createFileUpload(filename: $filename, size: $size, fileFolder: $fileFolder, fieldMetadataId: $fieldMetadataId) {
fileId
uploadUrl
contentType
}
}
`;
const createFileUploadResponse = await fetch(metadataGraphqlEndpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: createFileUploadMutation,
variables: {
filename,
size: pdfFile.size,
fileFolder: 'FilesField',
fieldMetadataId: uploadFileFieldMetadataId,
},
}),
});
if (!createFileUploadResponse.ok) {
throw new Error(`Upload failed with status ${createFileUploadResponse.status}`);
}
const createFileUploadResult = await createFileUploadResponse.json();
if (createFileUploadResult.errors?.length) {
throw new Error(`Upload failed: ${createFileUploadResult.errors[0].message}`);
}
const uploadTarget = createFileUploadResult.data?.createFileUpload;
if (!uploadTarget) {
throw new Error('No upload target returned from createFileUpload');
}
const { fileId, uploadUrl, contentType } = uploadTarget;
// Step 4: Send the file bytes to the upload URL
const putResponse = await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': contentType },
body: pdfFile,
});
if (!putResponse.ok) {
throw new Error(`Upload failed with status ${putResponse.status}`);
}
// Step 5: Confirm the upload so the file becomes usable
const completeFileUploadMutation = `
mutation CompleteFileUpload($fileId: String!) {
completeFileUpload(fileId: $fileId) {
id
}
}
`;
const completeFileUploadResponse = await fetch(metadataGraphqlEndpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: completeFileUploadMutation,
variables: { fileId },
}),
});
if (!completeFileUploadResponse.ok) {
throw new Error(`Upload failed with status ${completeFileUploadResponse.status}`);
}
const completeFileUploadResult = await completeFileUploadResponse.json();
if (completeFileUploadResult.errors?.length) {
throw new Error(`Upload failed: ${completeFileUploadResult.errors[0].message}`);
}
if (!completeFileUploadResult.data?.completeFileUpload?.id) {
throw new Error('No file id returned from completeFileUpload');
}
// Step 6: Create the attachment linked to the company
const attachmentMutation = `
mutation CreateOneAttachment($data: AttachmentCreateInput!) {
createAttachment(data: $data) {
id
name
}
}
`;
const attachmentResponse = await fetch(dataGraphqlEndpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: attachmentMutation,
variables: {
data: {
name: filename,
targetCompanyId: companyId,
file: [
{
fileId: fileId,
label: filename
}
]
},
},
}),
});
const attachmentResult = await attachmentResponse.json();
if (attachmentResult.errors?.length) {
throw new Error(`Attachment creation failed: ${attachmentResult.errors[0].message}`);
}
return attachmentResult.data?.createAttachment;
};
```
### Step 3: Customize for Your Use Case
#### To attach to a different object
Replace `targetCompanyId` with the appropriate field:
| Object | Field Name |
|--------|------------|
| Company | `targetCompanyId` |
| Person | `targetPersonId` |
| Opportunity | `targetOpportunityId` |
| Custom Object | `targetYourCustomObjectId` |
Update both the function parameter and the `variables.data` object in the attachment mutation.
#### To use a dynamic PDF URL
If using a PDF generation service, you can:
1. First make an HTTP Request action to generate the PDF
2. Pass the returned PDF URL to the logic function as a parameter
```typescript
export const main = async (
params: { companyId: string; pdfUrl: string; filename: string },
) => {
const { companyId, pdfUrl, filename } = params;
// ... rest of the function
};
```
### Step 4: Test and Activate
1. Save the workflow
2. Navigate to a Company record
3. Click the **⋮** menu and select your workflow
4. Check the **Attachments** section on the record to verify the PDF was attached
5. Activate the workflow
## Combining with PDF Generation Services
For creating dynamic quotes or invoices:
### Example: Generate Quote → Attach PDF
| Step | Action | Purpose |
|------|--------|---------|
| 1 | Manual Trigger (Company) | User initiates on a record |
| 2 | Search Record | Get Opportunity or line item details |
| 3 | HTTP Request | Call PDF generation API with record data |
| 4 | Logic Function | Download and attach the generated PDF |
### Popular PDF Generation Services
- **Carbone** - Template-based document generation
- **PDFMonkey** - Dynamic PDF creation from templates
- **DocuSeal** - Document automation platform
- **Documint** - API-first document generation
Each service provides an API that returns a PDF URL, which you can then pass to the Logic Function.
## Troubleshooting
| Issue | Solution |
|-------|----------|
| "Failed to download PDF" | Check the PDF URL is accessible and returns a valid PDF |
| "Upload failed" | Verify your API key is valid and has write permissions |
| "Attachment creation failed" | Ensure the object ID field name matches your target object |
## Related
- [Workflow Triggers](/user-guide/workflows/capabilities/workflow-triggers)
- [Logic Functions](/user-guide/workflows/capabilities/workflow-actions#code)
- [Generate a Quote or Invoice from Twenty](/user-guide/workflows/how-tos/connect-to-other-tools/generate-quote-or-invoice-from-twenty)

View File

@@ -0,0 +1,145 @@
---
title: Generate a Quote or Invoice from Twenty
description: Automatically create invoices in external tools when deals close.
---
Automatically send deal data to your invoicing system (Stripe, QuickBooks, Xero, etc.) when an opportunity is won.
## Workflow Structure
1. **Trigger**: Record is Updated (Opportunity)
2. **Filter**: Stage = Closed Won
3. **Search Record**: Get Company details
4. **Code** (optional): Format payload
5. **HTTP Request**: Send to invoicing system
## Step 1: Set Up the Trigger
1. Create a new workflow
2. Select **Record is Updated** trigger
3. Choose **Opportunity** as the object
## Step 2: Filter for Closed Won
Add a **Filter** action to only continue when the deal is won:
| Setting | Value |
|---------|-------|
| **Field** | Stage |
| **Condition** | Equals |
| **Value** | `CLOSED_WON` (or your stage name) |
<Tip>
The trigger fires on any Opportunity update. The Filter ensures the workflow only continues when the stage changes to Closed Won.
</Tip>
## Step 3: Get Company Details
The Opportunity record may not include all Company fields you need for the invoice. Add a **Search Record** action:
| Setting | Value |
|---------|-------|
| **Object** | Company |
| **Match by** | ID equals `{{trigger.object.companyId}}` |
This retrieves the full Company record with billing address, tax ID, etc.
## Step 4: Format the Payload (Optional)
If your invoicing system expects a specific format, add a **Code** action:
```javascript
export const main = async (params: {
opportunity: any;
company: any;
}): Promise<object> => {
const { opportunity, company } = params;
return {
invoice: {
// Customer info from Company
customer_name: company.name,
customer_email: company.email || "",
billing_address: {
line1: company.address?.street || "",
city: company.address?.city || "",
postal_code: company.address?.postalCode || "",
country: company.address?.country || ""
},
tax_id: company.taxId || null,
// Invoice details from Opportunity
amount: opportunity.amount,
currency: opportunity.currency || "USD",
description: `Invoice for ${opportunity.name}`,
due_days: 30,
// Reference back to Twenty
metadata: {
opportunity_id: opportunity.id,
company_id: company.id
}
}
};
};
```
## Step 5: Send to Invoicing System
Add an **HTTP Request** action:
| Setting | Value |
|---------|-------|
| **Method** | POST |
| **URL** | Your invoicing API endpoint |
| **Headers** | `Authorization: Bearer YOUR_API_KEY` |
| **Body** | `{{code.invoice}}` or map fields directly |
### Example: Stripe Invoice
```
POST https://api.stripe.com/v1/invoices
Headers:
Authorization: Bearer sk_live_xxx
Content-Type: application/x-www-form-urlencoded
Body:
customer: {{company.stripeCustomerId}}
collection_method: send_invoice
days_until_due: 30
```
### Example: QuickBooks Invoice
```
POST https://quickbooks.api.intuit.com/v3/company/{realmId}/invoice
Headers:
Authorization: Bearer YOUR_ACCESS_TOKEN
Content-Type: application/json
Body: {{code.invoice}}
```
## Complete Workflow Summary
| Step | Action | Purpose |
|------|--------|---------|
| 1 | Trigger: Record Updated | Fires when any Opportunity changes |
| 2 | Filter | Only proceed if Stage = Closed Won |
| 3 | Search Record | Get full Company details for billing |
| 4 | Code | Format data for invoicing API |
| 5 | HTTP Request | Create invoice in external system |
## Tips
- **Store external IDs**: Save the invoice ID returned by the API back to the Opportunity using an **Update Record** action
- **Error handling**: Add a branch to send a notification if the HTTP request fails
- **Test first**: Use your invoicing system's sandbox/test mode before going live
## Related
- [Generate a PDF from Twenty](/user-guide/workflows/how-tos/connect-to-other-tools/generate-pdf-from-twenty) — attach generated PDFs to records
- [Workflow Triggers](/user-guide/workflows/capabilities/workflow-triggers)
- [Workflow Actions](/user-guide/workflows/capabilities/workflow-actions)
- [Closed Won Automations](/user-guide/workflows/how-tos/crm-automations/closed-won-automations)

View File

@@ -0,0 +1,168 @@
---
title: Set Up a Webhook Trigger
description: Receive data from external services to trigger workflows.
---
Webhook triggers allow external services to start your workflows by sending data to a unique URL. Use them to connect forms, third-party apps, and custom integrations.
## When to Use Webhooks
| Use Case | Example |
|----------|---------|
| **Web forms** | Contact form submissions create leads |
| **Third-party apps** | Stripe payment → create customer record |
| **Custom integrations** | Your app → Twenty automation |
| **No-code tools** | Zapier, Make, n8n connections |
## Step-by-Step Setup
### Step 1: Create the Workflow
1. Go to **Settings → Workflows**
2. Click **+ New Workflow**
3. Name it (e.g., "Website Form Submission")
### Step 2: Configure the Webhook Trigger
1. Click on the trigger block
2. Select **Webhook**
3. You'll receive a unique webhook URL like:
```
https://api.twenty.com/webhooks/workflow/abc123...
```
4. Copy this URL—you'll need it for your external service
### Step 3: Define Expected Data Structure
For **POST** requests, define the expected body structure:
1. Click **Define expected body**
2. Enter a sample JSON that matches what your service will send:
```json
{
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"company": "Acme Inc",
"message": "Interested in your product"
}
```
3. Click **Save**—this creates variables you can use in subsequent steps
### Step 4: Add Actions
Now add actions that use the webhook data:
**Example: Create a Person record**
1. Add **Create Record** action
2. Select **People** object
3. Map fields:
| Field | Value |
|-------|-------|
| First Name | `{{trigger.body.firstName}}` |
| Last Name | `{{trigger.body.lastName}}` |
| Email | `{{trigger.body.email}}` |
| Company | Search or create based on `{{trigger.body.company}}` |
### Step 5: Test the Webhook
Before activating, test your webhook:
**Using cURL**:
```bash
curl -X POST https://api.twenty.com/webhooks/workflow/abc123... \
-H "Content-Type: application/json" \
-d '{"firstName":"Test","lastName":"User","email":"test@example.com"}'
```
**Using Postman or similar**:
1. Create a POST request to your webhook URL
2. Set Content-Type header to `application/json`
3. Add your test JSON body
4. Send and check workflow runs
### Step 6: Activate
Once tested, click **Activate** to make the workflow live.
## Handling Different Data Structures
### Nested Data
If your webhook sends nested data:
```json
{
"contact": {
"name": "John Doe",
"email": "john@example.com"
},
"source": "website"
}
```
Reference with: `{{trigger.body.contact.email}}`
### Arrays
If data includes arrays:
```json
{
"items": [
{"name": "Product A", "qty": 2},
{"name": "Product B", "qty": 1}
]
}
```
How you handle arrays depends on your use case:
**Unknown number of items → Use Iterator**
If you need to process each item in the array (e.g., create a record for each), add a **Code** action to parse the array, then use **Iterator**:
```javascript
export const main = async (params: { items: any }) => {
const items = typeof params.items === "string"
? JSON.parse(params.items)
: params.items;
return { items };
};
```
Then use Iterator to loop through: `{{code.items}}`
**Known/specific fields → Extract to named fields**
If the array contains specific fields you want to access individually (e.g., form answers where position 0 is always "first name", position 1 is always "last name"), add a **Code** action to extract them:
```javascript
export const main = async (params: { items: any }) => {
const items = typeof params.items === "string"
? JSON.parse(params.items)
: params.items;
return {
product: {
name: items[0]?.name || "",
qty: items[0]?.qty || 0
}
};
};
```
Now you can select `product.name` and `product.qty` individually in subsequent steps.
<Tip>
For more details on handling arrays, see [Handle Arrays in Code Actions](/user-guide/workflows/how-tos/advanced-configurations/handle-arrays-in-code-actions).
</Tip>
## Related
- [Workflow Triggers](/user-guide/workflows/capabilities/workflow-triggers)
- [Workflow Actions](/user-guide/workflows/capabilities/workflow-actions)

View File

@@ -0,0 +1,121 @@
---
title: Auto-Reply to Inbound Emails
description: Build a workflow that uses AI to triage incoming emails and send threaded replies automatically.
---
Respond to inbound emails in seconds — not hours. This workflow uses an AI Agent to filter out noise (newsletters, spam, auto-replies) and draft a personalized response to real messages. Then, AI sends it as a threaded reply inside the original conversation.
## How email threading works
Every email carries a hidden `Message-ID` header — a unique fingerprint assigned by the sender's mail server. When you reply to an email, your mail client sets an `In-Reply-To` header referencing that fingerprint. That's how Gmail, Outlook, and every other client groups messages into threads.
In Twenty, that fingerprint is stored as `headerMessageId` on the Message object. Your workflow grabs it and passes it into the Send Email action's In-Reply-To field.
## Building the workflow
### Step 1: Create a new workflow
Head to **Settings -> Workflows** and click **+ New Workflow**.
### Step 2: Trigger on incoming messages
Pick **When a Record is Created** and choose **Messages**.
Every time an email lands in Twenty, this fires.
<img src="/images/user-guide/workflows/auto-reply/workflow-overview.png" style={{width:'100%'}}/>
### Step 3: Look up who sent it
Add a **Search Records** action.
The sender's address isn't on the message itself — it's on the related Message Participant record.
| Field | Value |
|-------|-------|
| **Object** | Message Participants |
| **Filter** | Message **is** `{{trigger.id}}` |
| **Filter** | Role **is** From |
| **Limit** | 1 |
This gives you the sender's email in `handle` and their name in `displayName`.
<img src="/images/user-guide/workflows/auto-reply/find-sender.png" style={{width:'100%'}}/>
### Step 4: Triage and draft a reply with AI
Add an **AI Agent** action. This single step does two things: decides whether the email deserves a reply, and if so, writes one.
Use a prompt like:
```
You are an email triage assistant for a sales team. Read the following
inbound email and decide if it deserves a reply.
Subject: {{trigger.subject}}
Body: {{trigger.text}}
From: {{Find Sender.first.displayName}} ({{Find Sender.first.handle}})
If this email is spam, a newsletter, an automated notification, or
otherwise does not need a human reply, respond with exactly: SKIP
Otherwise, write a short, professional reply (3-4 sentences max) that:
- Acknowledges their specific message
- Lets them know someone from the team will follow up shortly
- Is warm but not overly casual
Respond with only the reply text, no subject line or greeting prefix.
```
The AI Agent outputs its response in a `response` field that the next steps can reference.
<img src="/images/user-guide/workflows/auto-reply/ai-triage.png" style={{width:'100%'}}/>
### Step 5: Branch on the AI decision
Add an **If/Else** action to check whether the AI decided to reply or skip.
| Field | Value |
|-------|-------|
| **Condition** | AI Agent `response` **does not contain** `SKIP` |
| **If true** | Continue to Send Email |
| **Else** | Do nothing (workflow ends) |
Spam, newsletters, and auto-generated messages get dropped. Everything else moves to the next step.
<img src="/images/user-guide/workflows/auto-reply/should-reply.png" style={{width:'100%'}}/>
### Step 6: Send a threaded reply
Add a **Send Email** action on the "if true" branch. Click **Advanced options**, then **Add In-Reply-To**.
| Field | Value |
|-------|-------|
| **To** | `{{Find Sender.first.handle}}` |
| **Subject** | `Re: {{trigger.subject}}` |
| **Body** | `{{AI Triage & Draft Reply.response}}` |
| **In-Reply-To** | `{{trigger.headerMessageId}}` |
The In-Reply-To field is what makes this a reply instead of a new conversation. The recipient sees it threaded under the original email in Gmail, Outlook, or any other client.
<img src="/images/user-guide/workflows/auto-reply/send-email.png" style={{width:'100%'}}/>
<Tip>
**In-Reply-To** expects a `message.headerMessageId` from the trigger — it's the email's unique fingerprint, not a recipient address. If you leave it empty, the email still sends, just as a standalone message.
</Tip>
<Warning>
Gmail uses the subject line to group messages into threads. The subject **must** start with `Re:` (including the colon and space) for Gmail to display the reply inside the original thread. Without it, the reply will appear as a separate conversation — even if the In-Reply-To header is set correctly.
</Warning>
### Step 7: Test and activate
Hit **Test**, then check your email client. The reply should appear nested under the original message.
Activate when you're happy with it.
## Ideas to build on
- **Only reply to VIPs** — add a branch that checks the sender's domain or whether they exist as a Contact in Twenty
- **Route by intent** — use separate AI Agent prompts to handle sales inquiries differently from support requests
- **Enrich before replying** — add a Search Records step to pull the sender's company or deal history into the AI prompt for more personalized replies

View File

@@ -0,0 +1,170 @@
---
title: Closed Won Automations
description: Automate post-win activities when opportunities close.
---
When a deal closes, multiple things need to happen: update company status, notify team members, create onboarding tasks. Automate all of this with a single workflow.
## The Problem
When an opportunity moves to "Closed Won":
- Company type needs to change from "Prospect" to "Customer"
- Onboarding tasks need to be created
- Customer success team needs to be notified
- Sales rep needs confirmation
Doing this manually is time-consuming and error-prone.
## The Solution
Create a workflow that handles all post-win activities automatically.
## Complete Workflow Setup
### Step 1: Create the Workflow
1. Go to **Settings → Workflows**
2. Click **+ New Workflow**
3. Name it "Deal Won - Post-Win Automation"
### Step 2: Configure the Trigger
1. Select **Record is Updated**
2. Choose **Opportunities**
3. Under "Fields to monitor", select **Stage**
### Step 3: Add Stage Filter
1. Add **Filter** action
2. Condition: `{{trigger.object.stage}}` equals "Closed Won"
### Step 4: Update Company Type
1. Add **Update Record** action
2. Configure:
| Field | Value |
|-------|-------|
| **Object** | Companies |
| **Record** | `{{trigger.object.company.id}}` |
| **Type** | Customer |
| **First Deal Date** | `{{trigger.object.closedAt}}` |
| **Account Owner** | `{{trigger.object.owner.id}}` |
### Step 5: Create Onboarding Task
1. Add **Create Record** action
2. Configure:
| Field | Value |
|-------|-------|
| **Object** | Tasks |
| **Title** | `Onboarding: {{trigger.object.name}}` |
| **Assignee** | Customer Success team member |
| **Due Date** | 3 days from now |
| **Priority** | High |
| **Related Company** | `{{trigger.object.company.id}}` |
| **Related Opportunity** | `{{trigger.object.id}}` |
| **Description** | `New customer onboarding for {{trigger.object.company.name}}. Deal value: {{trigger.object.amount}}` |
### Step 6: Notify Customer Success
1. Add **Send Email** action
2. Configure:
| Field | Value |
|-------|-------|
| **To** | customer-success@yourcompany.com |
| **Subject** | `🎉 New Customer: {{trigger.object.company.name}}` |
| **Body** | See example below |
**Email body example**:
```
Hi CS Team,
We have a new customer!
Company: {{trigger.object.company.name}}
Deal: {{trigger.object.name}}
Value: {{trigger.object.amount}}
Sales Rep: {{trigger.object.owner.name}}
Close Date: {{trigger.object.closedAt}}
An onboarding task has been created automatically.
Let's give them a great start!
```
### Step 7: Confirm to Sales Rep
1. Add another **Send Email** action
2. Configure:
| Field | Value |
|-------|-------|
| **To** | `{{trigger.object.owner.email}}` |
| **Subject** | `✅ Deal Closed: {{trigger.object.name}}` |
| **Body** | Congratulations! Your deal has been processed. The customer success team has been notified and onboarding has begun. |
### Step 8: Test and Activate
1. Test by moving a test opportunity to "Closed Won"
2. Verify:
- Company type changed to "Customer"
- Onboarding task created
- CS team received email
- Sales rep received confirmation
3. Activate when ready
## Handling Closed Lost
Create a similar workflow for lost deals:
### Trigger
- Record is Updated (Opportunities, Stage = "Closed Lost")
### Actions
1. **Create Record**: Task for "Lost Deal Analysis"
2. **Update Record**: Add lost reason to company record
3. **Send Email**: Notify manager of lost deal
## Advanced: Multi-Step Onboarding
For complex onboarding, create multiple tasks:
```javascript
export const main = async (params) => {
const tasks = [
{ title: "Welcome call", daysFromNow: 1, assignee: "CS" },
{ title: "Send onboarding materials", daysFromNow: 2, assignee: "CS" },
{ title: "Technical setup", daysFromNow: 5, assignee: "Support" },
{ title: "30-day check-in", daysFromNow: 30, assignee: "CS" }
];
return { tasks };
};
```
Use **Iterator** to create each task from the array.
## Customization Ideas
### Keep your other tools up-to-date
- Create customer in billing system with an **HTTP Request**
### Conditional actions
Use **Filter** actions to:
- Different onboarding for enterprise vs SMB
- Different assignees based on region
- Skip notifications for small deals
### Include Deal details
Use **Code** action to format:
- Deal summary documents
- Handoff notes for CS team
- Custom onboarding checklists
## Related
- [Workflow Actions](/user-guide/workflows/capabilities/workflow-actions)
- [Send Emails from Workflows](/user-guide/workflows/capabilities/send-emails-from-workflows)

View File

@@ -0,0 +1,133 @@
---
title: Detect Stale Opportunities
description: Automatically notify managers when opportunities haven't been updated.
---
Keep your pipeline healthy by alerting managers when opportunities go stale. This workflow checks for opportunities that haven't been updated in a specified number of days.
## The Problem
Opportunities sitting without updates lead to:
- Deals going cold
- Unreliable forecasts
- Lost revenue
## The Solution
Create a scheduled workflow that finds stale opportunities and emails their managers.
## Step-by-Step Setup
### Step 1: Create the Workflow
1. Go to **Settings → Workflows**
2. Click **+ New Workflow**
3. Name it "Stale Opportunity Alert"
### Step 2: Configure the Trigger
1. Select **On a Schedule**
2. Set to run daily (e.g., every day at 8 AM)
### Step 3: Search for Stale Opportunities
1. Add **Search Records** action
2. Configure:
| Field | Value |
|-------|-------|
| **Object** | Opportunities |
| **Filter** | Updated At is before (today - 7 days) |
| **Filter** | Stage is not "Closed Won" AND not "Closed Lost" |
| **Limit** | 100 |
### Step 4: Check If Any Found
1. Add **Filter** action
2. Condition: `{{searchRecords.length}}` is greater than 0
3. If no stale opportunities, the workflow stops here
### Step 5: Format the Alert (Code Action)
Add a **Code** action to format the email:
```javascript
export const main = async (params) => {
const opportunities = params.opportunities;
// Group opportunities by owner
const byOwner = {};
opportunities.forEach(opp => {
const ownerEmail = opp.owner?.email || 'unassigned';
if (!byOwner[ownerEmail]) {
byOwner[ownerEmail] = [];
}
byOwner[ownerEmail].push({
name: opp.name,
amount: opp.amount,
lastUpdated: opp.updatedAt,
stage: opp.stage
});
});
// Format summary for manager
let summary = "Stale Opportunities Report\n\n";
Object.entries(byOwner).forEach(([owner, opps]) => {
summary += `${owner}: ${opps.length} stale opportunities\n`;
opps.forEach(opp => {
summary += ` - ${opp.name} (${opp.stage})\n`;
});
summary += "\n";
});
return {
summary,
totalCount: opportunities.length
};
};
```
### Step 6: Send Alert Email
Add **Send Email** action:
| Field | Value |
|-------|-------|
| **To** | sales-manager@yourcompany.com |
| **Subject** | `🚨 {{code.totalCount}} Stale Opportunities Need Attention` |
| **Body** | `{{code.summary}}` |
### Step 7: Test and Activate
1. Click **Test** to run the workflow
2. Check that the email contains the right data
3. Activate when ready
## Customization Options
### Change Staleness Threshold
Modify the Search Records filter to change from 7 days to your preferred period:
- 3 days for high-velocity sales
- 14 days for enterprise deals
- 30 days for long sales cycles
### Alert Individual Reps
Instead of one manager email, use **Iterator** to send personalized emails to each rep about their own stale deals.
### Add Escalation
Create multiple workflows with increasing severity:
1. Day 7: Email to rep
2. Day 14: Email to rep + manager
3. Day 21: Create task for manager to intervene
### Include in Slack
Use **HTTP Request** to post to a Slack webhook instead of or in addition to email.
## Related
- [Workflow Actions](/user-guide/workflows/capabilities/workflow-actions)
- [Send Emails from Workflows](/user-guide/workflows/capabilities/send-emails-from-workflows)

View File

@@ -0,0 +1,70 @@
---
title: Display Number of Emails Received
description: Create a workflow to automatically count and display the number of emails received from each contact.
---
import { VimeoEmbed } from '/snippets/vimeo-embed.mdx';
<img src="/images/user-guide/workflows/workflow-number-of-emails-received.png" style={{width:'100%'}}/>
## Overview
This workflow triggers every time a new email is received and updates a custom field on the Person record with the total count of emails from that sender.
## Prerequisites
Before setting up this workflow, create a custom field on the **People** object:
1. Go to **Settings → Data Model → People**
2. Add a new **Number** field
3. Name it something like "Number of emails received from this person"
## Step-by-Step Setup
<VimeoEmbed videoId="1148700336" title="Video demonstration" />
### Step 1: Configure the Trigger
1. Go to **Workflows** and create a new workflow
2. Select **Record is Created** as the trigger
3. Choose **Message Participants** (available under Advanced objects)
<Note>
A Message Participant is a combination of a message ID and a person ID, creating one unique record per message. This is easier to track than Messages directly because we can access the `handle` field, which contains the sender's (or recipient's) email address.
</Note>
### Step 2: Filter on Role
1. Add a **Filter** action
2. Set the condition: **Role** equals **FROM**
This ensures you only count messages sent by this person, not messages sent to them.
### Step 3: Search All Message Participants with Same Handle
1. Add a **Search Records** action
2. Select **Message Participants** as the object
3. Add filters: **Handle** equals the handle from the trigger (the sender's email address) and **Role** equals **FROM**
4. Increase the **Limit** from 1 to **200** (the maximum)
This finds all messages from this email address to get the total count.
<Note>
The Search Records action is limited to returning 200 records maximum. However, since you're only using the `totalCount` value (not the individual records), this step will return the total number of emails sent by this person.
</Note>
### Step 4: Update the Person Record with a Create or Update Record action
1. Add a **Create or Update Record** action
2. Select **People** as the object
3. Find the person by matching their email to the `handle` from the Message Participant
4. Set your custom "Number of emails received" field to `{{searchRecords.totalCount}}`
The `totalCount` value from the Search Records action represents the total number of emails received from this person.
## Related
- [Workflow Actions](/user-guide/workflows/capabilities/workflow-actions)
- [Create Custom Fields](/user-guide/data-model/how-tos/customize-your-data-model)
- [Search Records Action](/user-guide/workflows/capabilities/workflow-actions#search-records)

View File

@@ -0,0 +1,165 @@
---
title: Display Related Record Data
description: Show data from related records (e.g., Company info on Opportunities) using workflows.
---
Display data from related records directly on your records — for example, show the employee count from a Company on its Opportunities. This workflow workaround is useful until nested fields are natively available.
## Common Use Cases
| Source | Destination | Fields to Copy |
|--------|-------------|----------------|
| Company | Opportunity | Industry, Company Size, ARR |
| Person | Opportunity | Email, Phone, Title |
| Opportunity | Company | Last Deal Amount, Last Won Date |
## Basic Field Copy
### Example: Copy Contact Email to Opportunity
**Goal**: When setting a Point of Contact on an opportunity, copy their email to the opportunity for easy access.
### Prerequisite
Create the destination fields in **Settings → Data Model → Opportunities** before building the workflow:
- Contact Email (type: Email)
- Contact Phone (type: Phone)
### Setup
1. **Trigger**: Record is Updated (Opportunities, Point of Contact field)
2. **Filter**: Check that Point of Contact is not empty
3. **Search Records**: Find the linked person
- Object: People
- Filter: ID equals `{{trigger.object.pointOfContact.id}}`
4. **Update Record**:
- Object: Opportunities
- Record: `{{trigger.object.id}}`
- Contact Email: `{{searchRecords[0].email}}`
- Contact Phone: `{{searchRecords[0].phone}}`
## Copy Multiple Fields
### Example: Sync Company Info to All Related Opportunities
**Goal**: When company details change, update all related opportunities.
### Setup
1. **Trigger**: Record is Updated (Companies)
- Fields: Industry, Company Size, Annual Revenue
2. **Search Records**: Find all opportunities for this company
- Object: Opportunities
- Filter: Company ID equals `{{trigger.object.id}}`
3. **Iterator**: Loop through each opportunity
4. **Update Record** (inside iterator):
- Object: Opportunities
- Record: `{{iterator.currentItem.id}}`
- Company Industry: `{{trigger.object.industry}}`
- Company Size: `{{trigger.object.companySize}}`
- Company ARR: `{{trigger.object.annualRevenue}}`
## Copy on Record Creation
### Example: Pre-fill Opportunity with Company Data
**Goal**: When creating an opportunity linked to a company, automatically copy key company info.
### Prerequisite
Create the destination fields in **Settings → Data Model → Opportunities**:
- Company Industry (type: Text)
- Company Size (type: Number)
### Setup
1. **Trigger**: Record is Created (Opportunities)
- Filter: Company is not empty
2. **Search Records**: Get the linked company's details
- Object: Companies
- Filter: ID equals `{{trigger.object.company.id}}`
3. **Update Record**:
- Object: Opportunities
- Record: `{{trigger.object.id}}`
- Company Industry: `{{searchRecords[0].industry}}`
- Company Size: `{{searchRecords[0].employees}}`
<Note>
**Tasks and Notes limitation**: Relations on Tasks and Notes are hardcoded as many-to-many and are not yet available in workflow triggers or actions. To access these relations, use the [API](/developers/extend/api) instead.
</Note>
## Bidirectional Sync
### Example: Keep Primary Contact in Sync
**Goal**: When a company's primary contact changes, update the contact. When a person becomes primary, update the company.
### Workflow 1: Company → Person
1. **Trigger**: Record is Updated (Companies, Primary Contact field)
2. **Update Record**: Set person's "Is Primary Contact" to true
3. **Search Records**: Find previous primary contact
4. **Update Record**: Set previous contact's "Is Primary Contact" to false
### Workflow 2: Person → Company
1. **Trigger**: Record is Updated (People, Is Primary Contact = true)
2. **Update Record**: Set company's Primary Contact to this person
<Note>
Be careful with bidirectional syncs to avoid infinite loops. Use filters to check if the value actually changed before updating.
</Note>
## Using Code for Complex Mapping
### Example: Transform Data During Copy
**Goal**: Copy and format phone number from person to opportunity.
```javascript
export const main = async (params) => {
const { phone } = params;
if (!phone) return { formattedPhone: null };
// Remove non-numeric characters
const digits = phone.replace(/\D/g, '');
// Format as (XXX) XXX-XXXX
const formatted = digits.length === 10
? `(${digits.slice(0,3)}) ${digits.slice(3,6)}-${digits.slice(6)}`
: phone;
return { formattedPhone: formatted };
};
```
## Best Practices
### Avoid Loops
- Don't create workflows that trigger each other endlessly
- Use specific field conditions
- Add checks to see if value actually changed
### Handle Missing Data
- Always check if source record exists before copying
- Provide default values for optional fields
- Use filters to skip when source field is empty
### Performance
- Batch updates when copying to many records
- Use scheduled workflows for bulk sync operations
- Consider using Iterator for multiple record updates
## Related
- [Workflow Actions](/user-guide/workflows/capabilities/workflow-actions)
- [Workflow Triggers](/user-guide/workflows/capabilities/workflow-triggers)

View File

@@ -0,0 +1,199 @@
---
title: Formula Fields
description: Create formula fields using workflows until native support is available.
---
Twenty doesn't yet support native formula fields (coming in 2026), but you can achieve the same result using workflows. This workaround lets you automatically calculate and populate field values—from simple concatenations to complex business logic.
## Common Use Cases
| Use Case | Formula Example |
|----------|-----------------|
| **Full name** | First Name + " " + Last Name |
| **Expected amount** | Amount × Probability |
| **Days until due** | Due Date - Today |
| **Days in stage** | Today - Stage Entry Date |
| **Lead score** | Points based on multiple criteria |
<Tip>
For a complete example of tracking time in pipeline stages, see [Track How Long Opportunities Stay in Each Stage](/user-guide/views-pipelines/how-tos/track-time-in-stage).
</Tip>
## Basic Formula: Concatenation
### Example: Auto-Fill Full Name
**Goal**: Automatically combine first and last name into a full name field.
### Setup
1. **Trigger**: Record is Updated or Created (People)
2. **Filter**: Check that first name or last name changed
3. **Code action**:
```javascript
export const main = async (params) => {
const { firstName, lastName } = params;
const fullName = [firstName, lastName]
.filter(Boolean)
.join(' ');
return { fullName };
};
```
4. **Update Record**: Set Full Name to `{{code.fullName}}`
## Numeric Formula: Expected Amount
### Example: Calculate Expected Revenue
**Goal**: Multiply opportunity amount by probability to get expected amount.
See [How to Show Expected Amount in Pipeline](/user-guide/views-pipelines/how-tos/show-expected-amount-in-pipeline) for the complete workflow.
### Setup
1. **Trigger**: Record is Updated (Opportunities, Amount OR Probability field)
2. **Code action**:
```javascript
export const main = async (params) => {
const { amount, probability } = params;
const expectedAmount = (amount || 0) * (probability || 0) / 100;
return { expectedAmount };
};
```
3. **Update Record**: Set Expected Amount to `{{code.expectedAmount}}`
## Date Formula: Days Calculation
### Example: Days Until Task Due
**Goal**: Calculate how many days remain until a task's due date.
### Setup
1. **Trigger**: Record is Updated or Created (Tasks, Due Date field)
2. **Code action**:
```javascript
export const main = async (params) => {
const { dueDate } = params;
if (!dueDate) {
return { daysUntilDue: null };
}
const due = new Date(dueDate);
const today = new Date();
const diffTime = due - today;
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return { daysUntilDue: diffDays };
};
```
3. **Update Record**: Set Days Until Due to `{{code.daysUntilDue}}`
<Note>
Negative values indicate overdue tasks. You can use this field to filter or sort tasks by urgency.
</Note>
## Conditional Formula: Lead Score
### Example: Calculate Lead Score Based on Criteria
**Goal**: Score leads based on company size, industry, and engagement.
### Setup
1. **Trigger**: Record is Updated (People or Companies)
2. **Code action**:
```javascript
export const main = async (params) => {
const { companySize, industry, hasEmail, hasPhone, source } = params;
let score = 0;
// Company size scoring
if (companySize === 'Enterprise') score += 30;
else if (companySize === 'Mid-Market') score += 20;
else if (companySize === 'SMB') score += 10;
// Industry scoring
const targetIndustries = ['Technology', 'Finance', 'Healthcare'];
if (targetIndustries.includes(industry)) score += 25;
// Contact info scoring
if (hasEmail) score += 10;
if (hasPhone) score += 15;
// Source scoring
if (source === 'Referral') score += 20;
else if (source === 'Website') score += 10;
return { leadScore: score };
};
```
3. **Update Record**: Set Lead Score to `{{code.leadScore}}`
## Text Formula: Domain Extraction
### Example: Extract Domain from Email
**Goal**: Automatically extract and store the email domain.
### Setup
1. **Trigger**: Record is Updated (People, Email field)
2. **Code action**:
```javascript
export const main = async (params) => {
const { email } = params;
if (!email) return { domain: null };
const domain = email.split('@')[1]?.toLowerCase();
return { domain };
};
```
3. **Update Record**: Set Domain field to `{{code.domain}}`
## Best Practices
### Performance
- Only trigger on relevant field changes
- Use filters to skip records that don't need calculation
- Avoid complex calculations in high-volume workflows
### Error Handling
- Check for null/undefined values before calculations
- Use default values when data is missing
- Return clear error messages when calculations fail
### Testing
- Test with edge cases (empty fields, zero values)
- Verify calculations manually before activating
- Monitor workflow runs for unexpected results
## Related
- [How to Show Expected Amount in Pipeline](/user-guide/views-pipelines/how-tos/show-expected-amount-in-pipeline)
- [How to Track Time in Stage](/user-guide/views-pipelines/how-tos/track-time-in-stage)
- [Workflow Actions](/user-guide/workflows/capabilities/workflow-actions)

View File

@@ -0,0 +1,126 @@
---
title: Notify Teammates of a Note to Review
description: Send automatic email notifications when assigning a colleague to review a note.
---
import { VimeoEmbed } from '/snippets/vimeo-embed.mdx';
<Note>
**Tagging teammates directly in notes is not yet supported.** This feature is planned for the second half of 2026. In the meantime, this workflow provides a practical workaround.
</Note>
## The Challenge
You want to tag a colleague in a note so they receive a notification to review it. Today, Twenty doesn't support @mentions in notes or tasks.
## The Workaround
Instead of tagging in the note text, you can:
1. Create a **Reviewer** relation field on the Notes object
2. Assign the reviewer when creating or editing the note
3. Have a workflow automatically send an email to the reviewer with a link to the note
<Warning>
**Limitation**: This approach allows assigning one reviewer per field. If you need to notify multiple people, create additional relation fields (e.g., Reviewer 1, Reviewer 2). This won't scale well if you need to tag many collaborators at once.
</Warning>
## Step-by-Step Setup
### Step 1: Create the Reviewer Field
<img src="/images/user-guide/workflows/reviewer-field-for-note.png" style={{width:'100%'}}/>
1. Go to **Settings → Data Model → Notes**
2. Click **Add Field**
3. Configure:
| Setting | Value |
|---------|-------|
| **Name** | Reviewer |
| **Type** | Relation |
| **Related Object** | Workspace Member |
| **Relation Type** | Many Notes → One Workspace Member |
4. Save the field
### Step 2: Create the Workflow
<VimeoEmbed videoId="1151897168" title="Video demonstration" />
#### Create the Workflow Trigger
1. Go to the main view and create a new workflow
2. Select **Record Updated** as the trigger
3. Choose **Note** as the object and specify the Reviewer field.
4. This will trigger whenever the reviewer of a note is updated.
#### Add a Filter to Check Reviewer Was Just Assigned
Add a **Filter** action to ensure the workflow only continues when a reviewer is assigned:
| Condition | Value |
|-----------|-------|
| **Reviewer** | is not empty |
#### Search for the Workspace Member
1. Add a **Search Records** action
2. Select **Workspace Member** (under Advanced Objects)
3. Add a filter:
| Condition | Value |
|-----------|-------|
| **ID** | equals `{{note.reviewerId}}` |
This retrieves the full workspace member record, including their email address and name.
#### Send the Notification Email
1. Add a **Send Email** action
2. Configure:
| Field | Value |
|-------|-------|
| **To** | Search Record → First Workspace Member → User Email |
| **Subject** | A note needs your review |
| **Body** | See example below |
**Email body example:**
```text
Hi {{searchRecord.firstWorkspaceMember.name.firstName}},
You've been assigned to review a note.
View the note here:
https://yourSubDomain.twenty.com/object/note/{{recordIsUpdated.id}}
Best,
Twenty
```
<Note>
Replace `yourSubDomain` with your actual Twenty workspace subdomain.
</Note>
#### Test and Activate
1. Click **Test** to run the workflow manually
2. Create or edit a note and assign a reviewer
3. Check the reviewer's inbox for the notification email
4. Activate the workflow
## Usage
Once set up, your team can use this workflow by:
1. Creating a new note (or editing an existing one)
2. Selecting a colleague in the **Reviewer** field
3. The colleague automatically receives an email with a direct link to the note
## Related
- [Workflow Triggers](/user-guide/workflows/capabilities/workflow-triggers)
- [Send Emails from Workflows](/user-guide/workflows/capabilities/send-emails-from-workflows)
- [Create Custom Fields](/user-guide/data-model/how-tos/create-custom-fields)
- [Create Relation Fields](/user-guide/data-model/how-tos/create-relation-fields)

View File

@@ -0,0 +1,105 @@
---
title: Send Email Alerts with Tasks Due
description: Automatically notify team members about their upcoming or overdue tasks.
---
import { VimeoEmbed } from '/snippets/vimeo-embed.mdx';
<img src="/images/user-guide/workflows/email_tasks_due.png" style={{width:'100%'}}/>
Send daily email reminders to each team member about their tasks due today.
## Overview
This workflow runs on a schedule and:
1. Fetches all workspace members
2. Loops through each member
3. Finds their tasks due today
4. Formats and sends a personalized email
## Step-by-Step Setup
<VimeoEmbed videoId="1147463514" title="Video demonstration" />
### Step 1: Configure the Trigger
1. Go to **Settings → Workflows** and create a new workflow
2. Select **On a Schedule** as the trigger
3. Use a cron expression for daily at 8:00 AM: `0 8 * * *`
### Step 2: Search for All Workspace Members
1. Add a **Search Records** action
2. Select **Workspace Members** (under advanced objects)
3. No filters needed — this returns all members
### Step 3: Add an Iterator
1. Add an **Iterator** action
2. Set the input array to the workspace members from the previous step
3. All actions inside the iterator will run once per member
### Step 4: Search for Tasks Due Today (Inside Iterator)
1. Inside the iterator, add a **Search Records** action
2. Select **Tasks** as the object
3. Add filters:
- **Assignee** = current workspace member (from the iterator)
- **Due Date** = today
### Step 5: Format Tasks into Email Body (Inside Iterator)
Add a **Code** action to format the tasks into a readable list with links:
```javascript
export const main = async (params: {
tasksDue?: Array<{ id: string; title: string }> | null | string;
}) => {
const tasksDue =
typeof params.tasksDue === "string"
? JSON.parse(params.tasksDue)
: params.tasksDue;
if (!Array.isArray(tasksDue) || tasksDue.length === 0) {
return {
formattedTasks: "No tasks due today."
};
}
const formattedTasks = tasksDue
.map(
t =>
`${t.title}\nhttps://yourSubDomain.twenty.com/object/task/${t.id}`
)
.join("\n\n");
return { formattedTasks };
};
```
<Note>
Replace `yourSubDomain` with your actual Twenty workspace subdomain.
</Note>
### Step 6: Send Email (Inside Iterator)
1. Add a **Send Email** action (still inside the iterator)
2. Configure:
| Field | Value |
|-------|-------|
| **To** | `{{iterator.currentItem.userEmail}}` (workspace member's email) |
| **Subject** | Your Tasks Due Today |
| **Body** | `{{code.formattedTasks}}` |
### Step 7: Test and Activate
1. Click **Test** to run the workflow manually
2. Check inboxes for the emails
3. Activate the workflow
## Related
- [Workflow Actions](/user-guide/workflows/capabilities/workflow-actions)
- [Send Emails from Workflows](/user-guide/workflows/capabilities/send-emails-from-workflows)
- [Handle Arrays in Code Actions](/user-guide/workflows/how-tos/advanced-configurations/handle-arrays-in-code-actions)

View File

@@ -0,0 +1,19 @@
---
title: Professional Services
description: Get professional help building complex workflows and automations from Twenty's team and certified partners.
---
## When Do You Need Professional Help?
Consider professional services for:
- Complex multi-system integrations
- Advanced business logic and automation rules
- Large-scale data processing workflows
- Custom API development
- Team training and workflow optimization
- When you don't have internal resources
## Service Options
### Implementation Partners
Work with certified partners for advanced customizations. Contact us at [contact@twenty.com](mailto:contact@twenty.com) to connect with our [implementation partners](https://twenty.com/partners).

View File

@@ -0,0 +1,156 @@
---
title: Workflow Troubleshooting
description: Common workflow issues and how to resolve them.
---
## Common Issues and Solutions
### Workflow Not Triggering
**Symptoms**: Your workflow doesn't run when you expect it to.
**Possible Causes**:
1. **Workflow not activated**: Ensure the workflow is set to "Active" not "Draft"
2. **Trigger conditions not met**: Verify the trigger matches your expected event
3. **Field not monitored**: For "Record is Updated" triggers, ensure the specific field is being watched
4. **Permissions**: Check you have permission to run workflows
**Solutions**:
- Verify workflow status in the workflow list
- Test with the specific action you expect to trigger it
- Review trigger configuration
- Contact your admin about permissions
### Workflow Triggers Too Early (Empty Fields)
**Symptoms**: When manually creating a record in the UI, your workflow triggers before you've had time to fill in all the fields. The workflow runs with mostly empty field values.
**Why this happens**: Twenty saves everything in real-time — there's no separate "edit" vs "read" mode. When you create a record, it's saved immediately, triggering the "Record is created" event before you can fill in additional fields.
**When "Record is created" works well**:
- Records created via API calls (fields are populated in a single request)
- Records created via import
- Automated record creation from other workflows
**Solution**: For records created manually in the UI, use **"Record is created or updated"** as your trigger instead. This way:
- The workflow triggers after the user has finished filling in and saving the fields
- You get the complete data rather than empty values
<Tip>
If you only want the workflow to run once per record, add a Filter action to check a field like `createdAt equals updatedAt` (first save) or use a custom checkbox field to track if the workflow has already run.
</Tip>
### Actions Failing
**Symptoms**: Workflow runs but some actions fail.
**Possible Causes**:
1. **Missing data**: Required fields are empty
2. **Invalid references**: Variables from previous steps don't exist
3. **API errors**: External services returning errors
4. **Permission issues**: Action requires permissions you don't have
**Solutions**:
- Check the workflow run details for error messages
- Verify all required fields have values
- Test API connections independently
- Review role permissions
### HTTP Request Errors
**Symptoms**: HTTP Request actions fail or return unexpected results.
**Common Error Codes**:
- **400**: Bad request - check your request body format
- **401**: Unauthorized - verify API key
- **403**: Forbidden - check API permissions
- **404**: Not found - verify endpoint URL
- **429**: Too many requests - implement rate limiting
- **500**: Server error - external service issue
**Solutions**:
- Verify API endpoint URL
- Check authentication headers
- Test the API call outside of Twenty first
- Add error handling in Code actions
### Code Action Errors
**Symptoms**: JavaScript code fails to execute.
**Common Issues**:
1. **Syntax errors**: Typos or invalid JavaScript
2. **Undefined variables**: Referencing variables that don't exist
3. **Type errors**: Operations on wrong data types
4. **Timeouts**: Code taking too long to execute
**Solutions**:
- Use the built-in code editor validation
- Test code logic in a JavaScript console first
- Add console.log statements for debugging
- Simplify complex operations
### Email Not Sending
**Symptoms**: Send Email action doesn't deliver emails.
**Possible Causes**:
1. **No email account connected**: Check Settings → Accounts
2. **Invalid email address**: Recipient email is malformed
3. **Sending limits**: Email provider rate limits reached
4. **Spam filters**: Emails being blocked
**Solutions**:
- Verify email account connection
- Validate recipient email addresses
- Check email provider limits
- Review email content for spam triggers
## Debugging Workflows
### Using Workflow Runs
1. Go to the workflow editor
2. Open the **Runs** panel
3. Find the failed run
4. Click to see step-by-step details
5. Review error messages and output data
### Testing Individual Steps
1. For Code actions, use the **Test** button
2. For HTTP requests, test the endpoint separately
3. Create test records to trigger workflows
4. Use manual triggers for controlled testing
### Common Debugging Patterns
**Add logging**:
Use Code actions to log intermediate values for debugging.
**Isolate steps**:
Test each step independently to identify failures.
**Check data flow**:
Verify that each step receives the expected input data.
## Best Practices to Avoid Issues
### Before Activation
- Test thoroughly in draft mode
- Validate all API connections
- Review trigger conditions carefully
- Document expected behavior
### During Development
- Use descriptive step names
- Add comments in Code actions
- Test with realistic data
- Plan for edge cases
### After Activation
- Monitor initial runs closely
- Set up alerts for failures
- Review run history regularly
- Keep workflows simple when possible

View File

@@ -0,0 +1,237 @@
---
title: Workflows FAQ
description: Frequently asked questions about workflows in Twenty.
---
<AccordionGroup>
<Accordion title="Why can't I activate a workflow?">
This is likely a permissions issue. You need access to workflows to create and activate them.
**Solution**: Contact your workspace administrator to grant you workflow access under **Settings → Members → Roles**.
If you don't see the Workflows section at all in your sidebar, this confirms it's a permissions issue.
</Accordion>
<Accordion title="Why don't I see my manual workflow button in the top navbar?">
Manual workflows only appear in the navbar if properly configured:
1. The workflow must be **activated** (not in draft mode)
2. The navbar placement must be set to **Pinned**
3. For Single/Bulk triggers, you must be on the correct object page
**To check**: Open the workflow → click the trigger → verify "Navbar placement" is set to "Pinned".
You can always access manual workflows via **Cmd + K** (or **Ctrl + K**) regardless of navbar settings.
</Accordion>
<Accordion title="What's the difference between Single, Global, and Bulk manual triggers?">
| Type | Records Required | Workflow Runs |
|------|------------------|---------------|
| **Global** | None | Once, no record input |
| **Single** | One or more selected | Once per selected record |
| **Bulk** | One or more selected | Once, with all records as an array |
- **Global**: Use when the workflow doesn't need any record context (e.g., generate a report)
- **Single**: Use when you want to process each selected record independently (e.g., send individual emails)
- **Bulk**: Use when you need to process records together or optimize credit usage (requires Iterator action)
See [Workflow Triggers](/user-guide/workflows/capabilities/workflow-triggers) for details.
</Accordion>
<Accordion title="I don't see an If/Else node. What should I do?">
An explicit If/Else node is not yet available but is on our roadmap.
**Current workaround**: Create multiple branches from your step, each starting with a **Filter** action:
```
Step 1
│
├── Branch A: Filter (condition = true) → Actions...
│
└── Branch B: Filter (condition = false) → Actions...
```
Only the branch where the filter condition passes will execute its subsequent actions.
See [How to Use Branches](/user-guide/workflows/capabilities/workflow-branches) for a step-by-step guide.
</Accordion>
<Accordion title="Are workflow branches running in parallel?">
**Yes**, branches run in parallel by default.
If you want only one branch to execute:
- Add a **Filter** action at the start of each branch
- Set opposite conditions (e.g., Branch A: status = "Open", Branch B: status ≠ "Open")
Branches that fail their filter condition stop executing, while others continue.
</Accordion>
<Accordion title="Can I merge branches back together?">
**Yes**. After your parallel branches complete, you can add a step that both branches connect to.
In the workflow editor:
1. Complete your branched actions
2. Add a new step after the branches
3. Drag connections from the end of each branch to this new step
The merged step will execute after all connected branches complete.
</Accordion>
<Accordion title="What's the limit on Search Records results?">
**Search Records returns a maximum of 200 records.**
If you need to process more:
- Add more specific filters to reduce results
- Use scheduled workflows to process in batches
- Consider using the API for bulk operations
For most workflows, 200 records is sufficient. If you regularly hit this limit, consider restructuring your automation.
</Accordion>
<Accordion title="Can I CC people on workflow emails?">
**Not yet.** CC and BCC fields for the Send Email action are on our roadmap.
**Current workaround**: Add multiple Send Email actions to send to additional recipients, or use an HTTP Request to send via an external email service that supports CC.
</Accordion>
<Accordion title="How do I use outputs from previous steps?">
Every action produces output data that can be used in subsequent steps.
**To reference previous step data**:
- Use the variable picker when configuring a field
- Or type `{{stepName.fieldName}}` directly
**Examples**:
- Trigger data: `{{trigger.object.email}}`
- Search results: `{{searchRecords[0].name}}`
- Code output: `{{code.calculatedValue}}`
Hover over any field in the action configuration to see available variables from previous steps.
</Accordion>
<Accordion title="Why does my Iterator fail?">
**Iterator requires an array input.** Common issues:
1. **Input is not an array**: Ensure you're passing results from Search Records or another action that returns an array
2. **Array is empty**: Add a filter before Iterator to check `{{searchRecords.length}} > 0`
3. **Wrong variable selected**: Make sure you select the array itself, not a single record
**Correct setup**:
1. Search Records (returns array)
2. Filter: length > 0
3. Iterator: select `{{searchRecords}}`
4. Actions inside iterator use `{{iterator.currentItem.fieldName}}`
</Accordion>
<Accordion title="What's the maximum execution time for Code actions?">
Code actions (logic functions) have a **default timeout of 5 minutes** (300 seconds).
The maximum configurable timeout is **15 minutes** (900 seconds).
If your code exceeds this limit, the action will fail with a timeout error.
**Tips to avoid timeouts**:
- Break large operations into smaller chunks using Iterator
- Avoid heavy computations; use external services via HTTP Request for intensive processing
- Optimize your code to reduce execution time
- If you need longer processing, consider using scheduled workflows that process data in batches
</Accordion>
<Accordion title="How do I read workflow runs?">
Workflow runs show the execution history and help you debug issues.
**Access runs**:
- In workflow editor → **Runs** panel on the right
- Or go to **Workflow Runs** in the sidebar
**Understanding a run**:
- **Status**: Running, Completed, Failed, Waiting
- **Steps**: See which steps executed and their output
- **Errors**: Click failed steps to see error messages
- **Data**: View input/output data at each step
See [Workflow Runs](/user-guide/workflows/capabilities/workflow-runs) for details.
</Accordion>
<Accordion title="Why don't I see my workflow runs?">
Workflow runs might be failing immediately due to rate limits. Twenty enforces a hard limit of 5,000 runs per hour per workspace.
If you exceed this limit, workflows are immediately marked as failed and won't appear in your runs list.
**Common scenarios that hit this limit**:
- Selecting more than 5,000 records with a Single manual trigger
- Multiple workflows running simultaneously across your workspace
- High-frequency automated triggers (e.g., Record Updated on a busy object)
**Solutions**:
- Use **Bulk** triggers instead of Single to process many records in one run
- Space out large batch operations
- Use filters to reduce trigger frequency
- Schedule heavy workflows during off-peak hours
</Accordion>
<Accordion title="What are the workflow rate limits?">
Twenty has two rate limits to ensure system stability:
| Limit | Value | Behavior |
|-------|-------|----------|
| **Soft limit** | 100 runs/minute | Runs queue in "Not Started" status, processed gradually |
| **Hard limit** | 5,000 runs/hour | Runs immediately fail |
**Soft limit (100/min)**: Your workflows won't fail—they just wait in the queue and are processed over time. You can trigger more than 100 records; execution will be slower.
**Hard limit (5,000/hr)**: This applies to your entire workspace. If all your workflows combined exceed 5,000 runs in an hour, additional runs will fail immediately.
**Tips to stay within limits**:
- Use Bulk triggers with Iterator instead of Single triggers for large batches
- Combine related automations into fewer workflows
- Use scheduled workflows to spread load over time
</Accordion>
<Accordion title="Can I retry a failed workflow run?">
**No, there is no automatic retry functionality at the moment.**
If a workflow run fails, you'll need to:
1. Review the error in **Settings → Workflows → [Your Workflow] → Runs**
2. Fix the issue (data, configuration, or external service)
3. Manually trigger the workflow again on the affected record(s)
**Tips to reduce failures**:
- Add **Filter** nodes to validate data before actions
- Use **Search Records** to check if related records exist
- Test thoroughly with a few records before bulk operations
Automatic retry functionality is on our roadmap for a future release.
</Accordion>
<Accordion title="Should I deactivate workflows before CSV imports?">
**Yes, if your workflows are triggered by record creation or updates.**
When you import data via CSV, each record created or updated can trigger workflows. A large import (thousands of records) could:
- Hit the 5,000 runs/hour limit
- Consume significant workflow credits
- Send unexpected emails or notifications
- Create duplicate tasks or records
**Before a mass import**:
1. Go to **Settings → Workflows**
2. Identify workflows triggered by the object you're importing
3. **Deactivate** them temporarily
4. Run your CSV import
5. **Reactivate** the workflows when done
**Alternative**: If you need the workflows to run on imported data, import in smaller batches to stay within rate limits.
</Accordion>
<Accordion title="How do I clean up my workflow layout?">
If your workflow canvas looks messy with nodes scattered around, you can automatically organize it:
1. Right-click anywhere on the workflow canvas
2. Click **Tidy up workflow**
This will automatically rearrange all nodes into a clean, organized layout.
</Accordion>
</AccordionGroup>