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