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 3s

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,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)