---
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**)
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.
### 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}}` |
If attaching to a different object (Person, Opportunity, etc.), rename the parameter accordingly (e.g., `personId`, `opportunityId`) and update the logic function.
#### 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)