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: Twenty로 제품 데이터 가져오기
description: 일정에 따라 데이터 웨어하우스의 제품 카탈로그 데이터를 CRM으로 동기화하세요.
---
이 패턴을 사용하여 데이터 웨어하우스(예: Snowflake, BigQuery, PostgreSQL)의 제품 데이터와 Twenty를 동기화 상태로 유지하세요.
## 워크플로우 구조
1. **트리거**: 일정에 따라
2. **코드**: 데이터 웨어하우스에 질의
3. **코드**(선택): 데이터를 배열로 형식 지정
4. **Iterator**: 각 제품을 순회
5. **레코드 업서트**: Twenty에서 생성 또는 업데이트
<img src="/images/user-guide/workflows/create_users_workflow.png" style={{width:'100%'}} />
## 단계 1: 트리거 일정 설정
데이터 최신성 요구에 맞는 빈도로 워크플로우가 실행되도록 설정하세요:
* 거의 실시간 동기화를 위해 5분마다
* 중요도가 낮은 데이터의 경우 매시간
* 배치 업데이트의 경우 매일
## 단계 2: 데이터 웨어하우스 질의
최근 데이터를 가져오기 위해 **코드** 액션을 추가합니다:
```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>
`updated_at >= last X minutes`로 필터링하여 최근에 변경된 레코드만 가져옵니다. 동기화를 효율적으로 유지할 수 있습니다.
</Tip>
## 단계 3: 데이터 형식 지정(선택)
웨어하우스가 변환이 필요한 형식으로 데이터를 반환하는 경우, **코드** 액션을 하나 더 추가하세요. 일반적인 변환에는 타입 변환, 필드 이름 변경, 데이터 정리가 포함됩니다.
### 예시: 불리언 및 상태 필드를 포함한 사용자 데이터
```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),
})),
};
};
```
### 예시: 타입 변환이 포함된 제품 데이터
```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
}))
};
};
```
### 예시: 날짜 및 통화 형식 지정
```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, " ")
}))
};
};
```
### 일반적인 변환
| 소스 형식 | 대상 형식 | 코드 |
| -------------------- | ---------------- | ---------------------------------------- |
| `"true"` / `"false"` | `true` / `false` | `v === true \|\| v === "true"` |
| `"123.45"` | `123.45` | `parseFloat(value)` |
| `"active"` | `"ACTIVE"` | `value.toUpperCase()` |
| `1704067200` (Unix) | ISO 날짜 | `new Date(v * 1000).toISOString()` |
| `"$1,234.56"` | `1234.56` | `parseFloat(v.replace(/[^0-9.-]/g, ""))` |
| `null` / `undefined` | `""` | `value \|\| ""` |
## 단계 4: 제품 순회
**Iterator** 액션을 추가합니다:
* 입력: `{{code.products}}`
배열의 각 제품을 순회합니다.
## 5단계: 각 레코드를 업서트하기
이터레이터 내부에 **레코드 업서트** 액션을 추가합니다:
| 설정 | 값 |
| --------- | -------------------------- |
| **객체** | 사용자 지정 제품 객체 |
| **일치 기준** | External ID 또는 SKU(고유 식별자) |
| **Name** | `{{iterator.item.name}}` |
| **SKU** | `{{iterator.item.sku}}` |
| **Price** | `{{iterator.item.price}}` |
<Tip>
생성과 업데이트를 위한 분기 흐름을 따로 만들기보다 **Upsert**(업데이트 또는 생성)를 사용하세요. 구현이 더 빠르고 디버깅이 더 쉽습니다.
</Tip>
## 사용 예시
| 소스 | 데이터 |
| ------------- | --------------- |
| **ERP 시스템** | 제품 카탈로그, 가격, 재고 |
| **전자상거래 플랫폼** | 주문, 고객, 제품 업데이트 |
| **데이터 웨어하우스** | 집계 지표, 보강된 데이터 |
| **재고 시스템** | 재고 수준, 재주문 알림 |
## 관련 항목
* [워크플로우 트리거](/l/ko/user-guide/workflows/capabilities/workflow-triggers)
* [워크플로우 액션](/l/ko/user-guide/workflows/capabilities/workflow-actions)
* [Code 액션에서 배열 처리하기](/l/ko/user-guide/workflows/how-tos/advanced-configurations/handle-arrays-in-code-actions)

View File

@@ -0,0 +1,130 @@
---
title: Typeform 응답을 Twenty로 가져오기
description: Typeform의 Webhook 페이로드를 처리하여 폼 제출에서 리드를 생성합니다.
---
표준 Webhook 설정은 [Webhook 트리거 설정](/l/ko/user-guide/workflows/how-tos/connect-to-other-tools/set-up-a-webhook-trigger)을 참조하세요. 이 문서는 Typeform의 맞춤형 페이로드 구조에 필요한 특정 처리 방법을 다룹니다.
### 1단계: Webhook 워크플로우 생성
1. **설정 → 워크플로우**로 이동
2. **+ 새 워크플로우**를 클릭
3. 트리거로 **Webhook**을 선택합니다
4. Webhook URL을 복사합니다
### 2단계: Typeform 구성
1. Typeform에서 폼을 엽니다
2. **Connect → Webhooks**로 이동
3. Twenty Webhook URL을 붙여넣습니다
4. 저장
### 3단계: Typeform 페이로드 이해하기
Typeform은 중첩된 JSON 구조를 전송합니다. 간단한 예시는 다음과 같습니다:
```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" }
}
]
}
}
```
주요 유의 사항:
* 폼 데이터는 `form_response` 아래에 중첩됩니다
* **답변은 명명된 필드가 아니라 배열로 반환됩니다**
* 각 답변에는 참고용으로 필드 유형과 제목이 포함됩니다
### 4단계: 답변 배열에서 필드 추출
`answers`가 배열이므로 이후 단계에서는 전체 배열만 선택할 수 있으며, 개별 필드는 선택할 수 없습니다. 필요한 필드를 추출하기 위해 **Code** 액션을 추가합니다:
```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
}
};
};
```
이후 단계에서는 변수 선택기에서 `contact.firstName` 및 `contact.email`과 같은 개별 필드를 선택할 수 있습니다.
<Tip>
배열 처리에 대한 자세한 내용은 [Code 액션에서 배열 처리하기](/l/ko/user-guide/workflows/how-tos/advanced-configurations/handle-arrays-in-code-actions)를 참조하세요.
</Tip>
### 5단계: 레코드 생성
**레코드 생성** 액션을 추가합니다:
| 필드 | 값 |
| ------- | ---------------------------------------- |
| **객체** | 사람들 |
| **이름** | `{{code.contact.firstName}}` |
| **성** | `{{code.contact.lastName}}` |
| **이메일** | `{{code.contact.email}}` |
| **회사** | `{{code.contact.company}}` 기준으로 검색 또는 생성 |
### 6단계: 테스트 및 활성화
1. Typeform에서 테스트 응답을 제출합니다
2. 워크플로우 실행을 확인해 데이터가 수집되었는지 검증합니다
3. 워크플로우를 활성화합니다
## 관련 항목
* [Webhook 트리거 설정](/l/ko/user-guide/workflows/how-tos/connect-to-other-tools/set-up-a-webhook-trigger)
* [Code 액션에서 배열 처리하기](/l/ko/user-guide/workflows/how-tos/advanced-configurations/handle-arrays-in-code-actions)

View File

@@ -0,0 +1,263 @@
---
title: Twenty에서 PDF 생성
description: "레코드에 PDF(예: 견적서)를 생성해 첨부하는 워크플로우를 만드세요."
---
Twenty에서 PDF를 자동으로 생성하거나 가져와 레코드에 첨부합니다. 이는 Company, Opportunity 또는 기타 객체에 연결된 견적서, 송장, 보고서를 생성할 때 흔히 사용됩니다.
## 개요
이 워크플로우는 **수동 트리거**를 사용하므로 사용자가 선택한 레코드에 대해 필요할 때 PDF를 생성할 수 있습니다. **로직 함수**가 다음을 처리합니다:
1. URL에서 PDF를 다운로드(PDF 생성 서비스에서 제공)
2. 파일을 Twenty에 업로드
3. 레코드에 연결된 첨부 파일 생성
## 사전 준비
워크플로우를 설정하기 전에:
1. **API 키 생성**: **Settings → APIs**로 이동하여 새 API 키를 만드세요. 이 토큰은 로직 함수에 필요합니다.
2. **PDF 생성 서비스 설정**(선택 사항): PDF를 동적으로 생성하려면(예: 견적서) Carbone, PDFMonkey, DocuSeal 같은 서비스를 사용해 PDF를 만들고 다운로드 URL을 받으세요.
## 단계별 설정
### 1단계: 트리거 구성
1. **워크플로우**로 이동해 새 워크플로우를 생성합니다
2. **수동 트리거**를 선택합니다
3. PDF를 첨부할 객체를 선택하세요(예: **Company** 또는 **Opportunity**).
<Tip>
수동 트리거를 사용하면 사용자가 레코드를 선택한 후 우측 상단에 표시되는 버튼으로 이 워크플로우를 실행해 PDF를 생성하고 첨부할 수 있습니다.
</Tip>
### 2단계: 로직 함수 추가
1. **Code** 액션(로직 함수)을 추가합니다
2. 아래 코드로 새 함수를 만드세요
3. 입력 매개변수를 구성하세요
#### 입력 매개변수
| 매개변수 | 값 |
| ----------- | ----------------------- |
| `companyId` | `{{trigger.object.id}}` |
<Note>
다른 객체(Person, Opportunity 등)에 첨부하는 경우 해당 매개변수 이름을 바꾸고(예: `personId`, `opportunityId`) 로직 함수를 업데이트하세요.
</Note>
#### 로직 함수 코드
```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: Upload the file via GraphQL multipart upload
const uploadMutation = `
mutation UploadFilesFieldFile($file: Upload!, $fieldMetadataId: String!) {
uploadFilesFieldFile(file: $file, fieldMetadataId: $fieldMetadataId) {
id
}
}
`;
const uploadForm = new FormData();
uploadForm.append('operations', JSON.stringify({
query: uploadMutation,
variables: { file: null, fieldMetadataId: uploadFileFieldMetadataId },
}));
uploadForm.append('map', JSON.stringify({ '0': ['variables.file'] }));
uploadForm.append('0', pdfFile);
const uploadResponse = await fetch(metadataGraphqlEndpoint, {
method: 'POST',
headers: { Authorization: `Bearer ${authToken}` },
body: uploadForm,
});
const uploadResult = await uploadResponse.json();
if (uploadResult.errors?.length) {
throw new Error(`Upload failed: ${uploadResult.errors[0].message}`);
}
const fileId = uploadResult.data?.uploadFilesFieldFile?.id;
if (!fileId) {
throw new Error('No file id returned from upload');
}
// Step 4: 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;
};
```
### 3단계: 사용 사례에 맞게 사용자 지정
#### 다른 객체에 첨부하려면
`targetCompanyId`를 해당 필드로 바꾸세요:
| 개체 | 필드 이름 |
| --------- | -------------------------- |
| 회사 | `targetCompanyId` |
| 사람 | `targetPersonId` |
| 기회 | `targetOpportunityId` |
| 사용자 지정 개체 | `targetYourCustomObjectId` |
함수 매개변수와 첨부 생성 mutation의 `variables.data` 객체를 모두 업데이트하세요.
#### 동적 PDF URL을 사용하려면
PDF 생성 서비스를 사용하는 경우 다음을 수행할 수 있습니다:
1. 먼저 PDF를 생성하기 위해 HTTP 요청 작업을 만듭니다
2. 반환된 PDF URL을 로직 함수의 매개변수로 전달합니다
```typescript
export const main = async (
params: { companyId: string; pdfUrl: string; filename: string },
) => {
const { companyId, pdfUrl, filename } = params;
// ... rest of the function
};
```
### 4단계: 테스트 및 활성화
1. 워크플로우를 저장합니다
2. Company 레코드로 이동합니다
3. **⋮** 메뉴를 클릭하고 워크플로우를 선택합니다
4. 레코드의 **Attachments** 섹션에서 PDF가 첨부되었는지 확인합니다
5. 워크플로우를 활성화합니다
## PDF 생성 서비스와 결합하기
동적 견적서나 송장을 생성하려면:
### 예시: 견적서 생성 → PDF 첨부
| 단계 | 작업 | 목적 |
| -- | --------------- | ------------------------------- |
| 1 | 수동 트리거(Company) | 사용자가 레코드에서 시작 |
| 2 | 레코드 검색 | Opportunity 또는 라인 아이템 세부정보 가져오기 |
| 3 | HTTP 요청 | 레코드 데이터를 사용해 PDF 생성 API 호출 |
| 4 | 로직 함수 | 생성된 PDF를 다운로드해 첨부 |
### 널리 사용되는 PDF 생성 서비스
* **Carbone** - 템플릿 기반 문서 생성
* **PDFMonkey** - 템플릿 기반 동적 PDF 생성
* **DocuSeal** - 문서 자동화 플랫폼
* **Documint** - API 우선 문서 생성
각 서비스는 PDF URL을 반환하는 API를 제공하며, 이 URL을 로직 함수에 전달할 수 있습니다.
## 문제 해결
| 문제 | 해결책 |
| ------------- | --------------------------------------- |
| "PDF 다운로드 실패" | PDF URL에 접근할 수 있으며 유효한 PDF를 반환하는지 확인하세요 |
| "업로드 실패" | API 키가 유효하고 쓰기 권한이 있는지 확인하세요 |
| "첨부 파일 생성 실패" | 객체 ID 필드 이름이 대상 객체와 일치하는지 확인하세요 |
## 관련 항목
* [워크플로우 트리거](/l/ko/user-guide/workflows/capabilities/workflow-triggers)
* [로직 함수](/l/ko/user-guide/workflows/capabilities/workflow-actions#code)
* [Twenty에서 견적서 또는 송장 생성](/l/ko/user-guide/workflows/how-tos/connect-to-other-tools/generate-quote-or-invoice-from-twenty)

View File

@@ -0,0 +1,144 @@
---
title: Twenty에서 견적서 또는 인보이스 생성
description: 거래가 성사되면 외부 도구에서 인보이스를 자동으로 생성합니다.
---
거래 데이터를 인보이스 시스템(Stripe, QuickBooks, Xero 등)으로 자동으로 전송합니다. 기회가 성사되었을 때.
## 워크플로우 구조
1. **트리거**: 레코드가 업데이트됨(기회)
2. **필터**: Stage = Closed Won
3. **레코드 검색**: 회사 세부정보 가져오기
4. **코드**(선택): 페이로드 형식 지정
5. **HTTP 요청**: 인보이스 시스템으로 전송
## 단계 1: 트리거 설정
1. 새 워크플로우 만들기
2. **레코드가 업데이트됨** 트리거 선택
3. 객체로 **기회(Opportunity)** 선택
## 단계 2: Closed Won 필터링
거래가 성사된 경우에만 계속되도록 **필터** 작업을 추가합니다:
| 설정 | 값 |
| ------ | ------------------------------- |
| **필드** | 단계 |
| **조건** | 같음 |
| **값** | `CLOSED_WON` (또는 사용 중인 스테이지 이름) |
<Tip>
트리거는 모든 기회 업데이트에서 실행됩니다. 필터는 스테이지가 Closed Won으로 변경될 때만 워크플로우가 계속 진행되도록 보장합니다.
</Tip>
## 단계 3: 회사 세부정보 가져오기
기회 레코드에는 인보이스에 필요한 모든 회사 필드가 포함되지 않을 수 있습니다. **레코드 검색** 작업을 추가합니다:
| 설정 | 값 |
| --------- | -------------------------------------- |
| **객체** | 회사 |
| **일치 기준** | ID가 `{{trigger.object.companyId}}`와 같음 |
청구지 주소, 세금 ID 등과 함께 전체 회사 레코드를 가져옵니다.
## 단계 4: 페이로드 형식 지정(선택)
인보이스 시스템에서 특정 형식이 필요한 경우 **코드** 작업을 추가합니다:
```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
}
}
};
};
```
## 단계 5: 인보이스 시스템으로 전송
**HTTP 요청** 작업을 추가합니다:
| 설정 | 값 |
| ------- | ------------------------------------ |
| **방법** | POST |
| **URL** | 인보이스 API 엔드포인트 |
| **헤더** | `Authorization: Bearer YOUR_API_KEY` |
| **본문** | `{{code.invoice}}` 또는 필드를 직접 매핑 |
### 예시: Stripe 인보이스
```
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
```
### 예시: QuickBooks 인보이스
```
POST https://quickbooks.api.intuit.com/v3/company/{realmId}/invoice
Headers:
Authorization: Bearer YOUR_ACCESS_TOKEN
Content-Type: application/json
Body: {{code.invoice}}
```
## 전체 워크플로우 요약
| 단계 | 작업 | 목적 |
| -- | -------------- | --------------------------- |
| 1 | 트리거: 레코드 업데이트됨 | 어떤 기회가 변경되면 실행 |
| 2 | 필터 | Stage = Closed Won인 경우에만 진행 |
| 3 | 레코드 검색 | 청구를 위한 전체 회사 세부정보 가져오기 |
| 4 | 코드 | 인보이스 API용 데이터 형식 지정 |
| 5 | HTTP 요청 | 외부 시스템에서 인보이스 생성 |
## 팁
* **외부 ID 저장**: **레코드 업데이트** 작업을 사용하여 API가 반환한 인보이스 ID를 기회에 다시 저장합니다
* **오류 처리**: HTTP 요청이 실패하면 알림을 보내는 분기 추가
* **먼저 테스트**: 실제 운영 전에 인보이스 시스템의 샌드박스/테스트 모드를 사용하세요
## 관련 항목
* [Twenty에서 PDF 생성](/l/ko/user-guide/workflows/how-tos/connect-to-other-tools/generate-pdf-from-twenty) — 생성된 PDF를 레코드에 첨부
* [워크플로우 트리거](/l/ko/user-guide/workflows/capabilities/workflow-triggers)
* [워크플로우 액션](/l/ko/user-guide/workflows/capabilities/workflow-actions)
* [Closed Won 자동화](/l/ko/user-guide/workflows/how-tos/crm-automations/closed-won-automations)

View File

@@ -0,0 +1,170 @@
---
title: 웹훅 트리거 설정
description: 외부 서비스에서 데이터를 받아 워크플로우를 트리거합니다.
---
웹훅 트리거는 고유한 URL로 데이터를 보내 외부 서비스가 워크플로우를 시작할 수 있게 합니다. 양식, 서드파티 앱 및 커스텀 통합과 연결하는 데 사용하세요.
## 웹훅을 언제 사용할지
| 사용 사례 | 예시 |
| ---------- | --------------------- |
| **웹 폼** | 문의 폼 제출 시 리드가 생성됩니다 |
| **서드파티 앱** | Stripe 결제 → 고객 레코드 생성 |
| **커스텀 통합** | 사용자 앱 → Twenty 자동화 |
| **노코드 도구** | Zapier, Make, n8n 연결 |
## 단계별 설정
### 1단계: 워크플로우 생성
1. **설정 → 워크플로우**로 이동
2. **+ 새 워크플로우**를 클릭
3. 이름을 지정합니다(예: "웹사이트 폼 제출")
### 2단계: 웹훅 트리거 구성
1. 트리거 블록을 클릭하세요
2. **웹훅**을 선택
3. 다음과 같은 고유한 웹훅 URL이 제공됩니다:
```
https://api.twenty.com/webhooks/workflow/abc123...
```
4. 이 URL을 복사하세요—외부 서비스에 필요합니다
### 3단계: 예상 데이터 구조 정의
**POST** 요청의 경우, 예상 본문 구조를 정의하세요:
1. **예상 본문 정의**를 클릭
2. 서비스가 보내는 형식과 일치하는 샘플 JSON을 입력하세요:
```json
{
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"company": "Acme Inc",
"message": "Interested in your product"
}
```
3. **저장**을 클릭하세요—이렇게 하면 이후 단계에서 사용할 수 있는 변수가 생성됩니다
### 4단계: 액션 추가
이제 웹훅 데이터를 사용하는 액션을 추가하세요:
**예시: 사람 레코드 생성**
1. **레코드 생성** 액션을 추가
2. **People** 객체를 선택
3. 필드 매핑:
| 필드 | 값 |
| --- | ---------------------------------------- |
| 이름 | `{{trigger.body.firstName}}` |
| 성 | `{{trigger.body.lastName}}` |
| 이메일 | `{{trigger.body.email}}` |
| 회사 | `{{trigger.body.company}}` 기준으로 검색 또는 생성 |
### 5단계: 웹훅 테스트
활성화하기 전에 웹훅을 테스트하세요:
**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"}'
```
**Postman 등 사용**:
1. 웹훅 URL로 POST 요청을 생성
2. Content-Type 헤더를 `application/json`으로 설정
3. 테스트 JSON 본문을 추가
4. 전송하고 워크플로우 실행을 확인하세요.
### 6단계: 활성화
테스트가 완료되면 **활성화**를 클릭하여 워크플로우를 라이브로 전환하세요.
## 다양한 데이터 구조 처리
### 중첩 데이터
웹훅이 중첩 데이터를 보내는 경우:
```json
{
"contact": {
"name": "John Doe",
"email": "john@example.com"
},
"source": "website"
}
```
다음과 같이 참조: `{{trigger.body.contact.email}}`
### 배열
데이터에 배열이 포함된 경우:
```json
{
"items": [
{"name": "Product A", "qty": 2},
{"name": "Product B", "qty": 1}
]
}
```
배열 처리 방식은 사용 사례에 따라 다릅니다:
**항목 개수를 알 수 없음 → Iterator 사용**
배열의 각 항목을 처리해야 하는 경우(예: 각 항목마다 레코드 생성), 배열을 파싱하기 위해 **Code** 액션을 추가한 다음 **Iterator**를 사용하세요:
```javascript
export const main = async (params: { items: any }) => {
const items = typeof params.items === "string"
? JSON.parse(params.items)
: params.items;
return { items };
};
```
그런 다음 Iterator로 순회합니다: `{{code.items}}`
**알려진/특정 필드 → 명명된 필드로 추출**
배열에 개별적으로 접근하려는 특정 필드가 포함되어 있다면(예: 위치 0은 항상 "first name", 위치 1은 항상 "last name"인 폼 답변), 이를 추출하기 위해 **Code** 액션을 추가하세요:
```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
}
};
};
```
이제 이후 단계에서 `product.name` 및 `product.qty`를 개별적으로 선택할 수 있습니다.
<Tip>
배열 처리에 대한 자세한 내용은 [Code 액션에서 배열 처리하기](/l/ko/user-guide/workflows/how-tos/advanced-configurations/handle-arrays-in-code-actions)를 참조하세요.
</Tip>
## 관련 항목
* [워크플로우 트리거](/l/ko/user-guide/workflows/capabilities/workflow-triggers)
* [워크플로우 액션](/l/ko/user-guide/workflows/capabilities/workflow-actions)