Type /invoice Acme Corp 5000 in Slack and have a ready-to-send invoice PDF land in your channel within seconds. That's the kind of workflow this guide will help you build.
In this article, you'll learn how to connect a Slack Bot with the FUNBREW PDF API to generate PDFs directly from chat. We'll cover slash command implementation, interactive option selection with Block Kit, file uploads to channels, automated scheduled report posting with cron, and security best practices — all with complete, runnable code.
For the basics of the API itself, see the quickstart guide.
Use Cases: What Can You Automate?
Combining a Slack Bot with the PDF API unlocks a wide range of workflows.
| Slash Command | Generated Document | Target Users |
|---|---|---|
/invoice Client 5000 |
Invoice PDF | Sales, Finance |
/report weekly |
Weekly KPI report | Managers, Marketing |
/certificate John Doe |
Completion certificate | HR, Training |
/quote Product 10 |
Quote PDF | Sales |
/summary meeting |
Meeting minutes PDF | Project Managers |
For more PDF generation use cases, see the use cases page.
Setup: Creating a Slack App and Configuring the Bot
1. Create a Slack App
Go to Slack API and click Create New App. Choose From scratch, then set the app name and target workspace.
2. Add Required Bot Token Scopes
Under OAuth & Permissions, add the following scopes.
| Scope | Purpose |
|---|---|
commands |
Receive slash commands |
chat:write |
Send messages |
files:write |
Upload PDF files |
channels:read |
Read channel info |
3. Register Slash Commands
Under Slash Commands, click Create New Command and fill in the details.
Command: /invoice
Request URL: https://your-app.example.com/slack/commands
Short Desc: Generate an invoice PDF and post it to the channel
Usage Hint: [client name] [amount]
4. Enable Interactivity
To use Block Kit interactive buttons, enable Interactivity & Shortcuts and set a Request URL.
Request URL: https://your-app.example.com/slack/interactions
5. Save Credentials to .env
SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_SIGNING_SECRET=your-signing-secret
FUNBREW_API_KEY=your-funbrew-api-key
FUNBREW_API_URL=https://api.pdf.funbrew.cloud/v1
PORT=3000
Project Setup
Install Dependencies
We'll use Slack Bolt for JavaScript — it's lightweight and makes slash command and interaction handling straightforward.
mkdir slack-pdf-bot && cd slack-pdf-bot
npm init -y
npm install @slack/bolt axios dotenv
npm install --save-dev nodemon
Directory Structure
slack-pdf-bot/
├── src/
│ ├── index.js # Bolt app entry point
│ ├── commands/
│ │ ├── invoice.js # /invoice command
│ │ └── report.js # /report command
│ ├── interactions/
│ │ └── pdf-options.js # Block Kit interactions
│ ├── pdf/
│ │ └── generator.js # FUNBREW PDF API calls
│ └── templates/
│ ├── invoice.html # Invoice template
│ └── report.html # Report template
├── .env
└── package.json
Step 1: FUNBREW PDF API Integration Module
Start with the core PDF generation module.
// src/pdf/generator.js
const axios = require('axios');
const API_KEY = process.env.FUNBREW_API_KEY;
const API_URL = process.env.FUNBREW_API_URL || 'https://api.pdf.funbrew.cloud/v1';
/**
* Generate a PDF binary from HTML
*/
async function generatePdfFromHtml(html, options = {}) {
const {
engine = 'quality',
format = 'A4',
margin = { top: '20mm', bottom: '20mm', left: '20mm', right: '20mm' },
} = options;
const response = await axios.post(
`${API_URL}/pdf/from-html`,
{ html, engine, format, margin },
{
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
responseType: 'arraybuffer', // Receive binary
timeout: 30000,
}
);
return Buffer.from(response.data);
}
/**
* Expand template variables in an HTML string
*/
function renderTemplate(template, variables) {
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
return variables[key] !== undefined ? String(variables[key]) : '';
});
}
module.exports = { generatePdfFromHtml, renderTemplate };
For a deep dive into HTML templates, see the template engine guide.
Step 2: Implementing the /invoice Slash Command
Invoice HTML Template
<!-- src/templates/invoice.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 40px; color: #1f2937; }
.header { display: flex; justify-content: space-between; margin-bottom: 40px; border-bottom: 2px solid #3b82f6; padding-bottom: 20px; }
.title { font-size: 32px; font-weight: 700; color: #3b82f6; }
.meta { color: #6b7280; font-size: 14px; }
table { width: 100%; border-collapse: collapse; margin: 24px 0; }
th { background: #f3f4f6; text-align: left; padding: 12px; font-size: 13px; }
td { padding: 12px; border-bottom: 1px solid #e5e7eb; }
.total { text-align: right; font-size: 18px; font-weight: 700; color: #1f2937; margin-top: 16px; }
.footer { margin-top: 40px; font-size: 12px; color: #9ca3af; text-align: center; }
</style>
</head>
<body>
<div class="header">
<div>
<div class="title">INVOICE</div>
<div class="meta">Invoice #: {{invoice_number}}</div>
<div class="meta">Issue Date: {{issue_date}}</div>
<div class="meta">Due Date: {{due_date}}</div>
</div>
<div style="text-align:right">
<strong>{{company_name}}</strong><br>
<span class="meta">{{company_address}}</span>
</div>
</div>
<p><strong>Bill To: {{customer_name}}</strong></p>
<table>
<thead>
<tr>
<th>Description</th>
<th style="text-align:right">Qty</th>
<th style="text-align:right">Unit Price</th>
<th style="text-align:right">Subtotal</th>
</tr>
</thead>
<tbody>{{line_items_html}}</tbody>
</table>
<div class="total">
<div>Subtotal: ${{subtotal}}</div>
<div>Tax (10%): ${{tax}}</div>
<div style="font-size:22px; color:#3b82f6; margin-top:8px">Total: ${{total}}</div>
</div>
<div class="footer">Questions? Contact {{contact_email}}</div>
</body>
</html>
/invoice Command Handler
// src/commands/invoice.js
const fs = require('fs');
const path = require('path');
const { generatePdfFromHtml, renderTemplate } = require('../pdf/generator');
const templatePath = path.join(__dirname, '../templates/invoice.html');
const invoiceTemplate = fs.readFileSync(templatePath, 'utf-8');
/**
* Handle the /invoice slash command
* Usage: /invoice [client name] [amount]
* Example: /invoice "Acme Corp" 5000
*/
async function handleInvoiceCommand({ command, ack, respond, client }) {
// Must acknowledge within 3 seconds (Slack requirement)
await ack();
const args = command.text.trim().split(/\s+/);
if (args.length < 2) {
await respond({
text: 'Invalid format. Example: `/invoice "Acme Corp" 5000`',
response_type: 'ephemeral',
});
return;
}
const customerName = args.slice(0, -1).join(' ').replace(/^"|"$/g, '');
const amount = parseFloat(args[args.length - 1]);
if (isNaN(amount)) {
await respond({ text: 'Amount must be a number.', response_type: 'ephemeral' });
return;
}
// Send a "working on it" message immediately
await respond({
text: 'Generating your invoice... :hourglass_flowing_sand:',
response_type: 'ephemeral',
});
try {
const tax = Math.round(amount * 0.1 * 100) / 100;
const total = amount + tax;
const invoiceNumber = `INV-${Date.now()}`;
const issueDate = new Date().toLocaleDateString('en-US');
const dueDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US');
const html = renderTemplate(invoiceTemplate, {
invoice_number: invoiceNumber,
issue_date: issueDate,
due_date: dueDate,
company_name: 'FUNBREW Inc.',
company_address: '123 Main Street, San Francisco, CA',
customer_name: customerName,
line_items_html: `<tr><td>Service Fee</td><td style="text-align:right">1</td><td style="text-align:right">$${amount.toFixed(2)}</td><td style="text-align:right">$${amount.toFixed(2)}</td></tr>`,
subtotal: amount.toFixed(2),
tax: tax.toFixed(2),
total: total.toFixed(2),
contact_email: 'billing@funbrew.cloud',
});
// Generate PDF
const pdfBuffer = await generatePdfFromHtml(html, { engine: 'quality', format: 'A4' });
// Upload PDF to Slack
await client.files.uploadV2({
channel_id: command.channel_id,
file: pdfBuffer,
filename: `invoice-${invoiceNumber}.pdf`,
initial_comment: `Invoice for *${customerName}* is ready :page_facing_up:\nInvoice #: ${invoiceNumber} / Total: $${total.toFixed(2)}`,
});
} catch (error) {
console.error('Invoice generation failed:', error);
await respond({
text: `Failed to generate PDF: ${error.message}`,
response_type: 'ephemeral',
});
}
}
module.exports = { handleInvoiceCommand };
Step 3: Bolt App Entry Point
// src/index.js
require('dotenv').config();
const { App } = require('@slack/bolt');
const { handleInvoiceCommand } = require('./commands/invoice');
const { handleReportCommand } = require('./commands/report');
const { handlePdfOptions } = require('./interactions/pdf-options');
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET,
socketMode: false, // HTTP mode
port: process.env.PORT || 3000,
});
// Register slash commands
app.command('/invoice', handleInvoiceCommand);
app.command('/report', handleReportCommand);
// Register Block Kit interactions
app.action('pdf_format_select', handlePdfOptions);
app.action('pdf_generate_confirm', handlePdfOptions);
app.action('pdf_generate_cancel', handlePdfOptions);
// Global error handler
app.error(async (error) => {
console.error('Bolt app error:', error);
});
(async () => {
await app.start();
console.log(`Slack PDF Bot is running on port ${process.env.PORT || 3000}`);
})();
Step 4: Uploading PDFs to Slack Channels
Use files.uploadV2 (the recommended API since 2024) to post PDFs to channels.
// src/pdf/uploader.js
/**
* Upload a PDF buffer to a Slack channel
*/
async function uploadPdfToSlack(client, { channelId, pdfBuffer, filename, message }) {
try {
const result = await client.files.uploadV2({
channel_id: channelId,
file: pdfBuffer,
filename: filename,
initial_comment: message,
});
return {
ok: true,
fileId: result.file?.id,
permalink: result.file?.permalink,
};
} catch (error) {
// Handle missing scope errors clearly
if (error.code === 'slack_webapi_platform_error' && error.data?.error === 'missing_scope') {
throw new Error('Missing files:write scope. Check your Slack App configuration.');
}
throw error;
}
}
/**
* Build a Block Kit message with a PDF download button
*/
function buildPdfCompletionMessage(filename, fileUrl, metadata) {
return {
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `:page_facing_up: *${filename}* has been generated`,
},
},
{
type: 'section',
fields: Object.entries(metadata).map(([label, value]) => ({
type: 'mrkdwn',
text: `*${label}:*\n${value}`,
})),
},
{
type: 'actions',
elements: [
{
type: 'button',
text: { type: 'plain_text', text: 'Open PDF' },
url: fileUrl,
style: 'primary',
},
],
},
],
};
}
module.exports = { uploadPdfToSlack, buildPdfCompletionMessage };
Step 5: Interactive Option Selection with Block Kit
Let users choose PDF format (paper size, orientation) before generating.
Sending the Options Message
// src/commands/report.js
const { generatePdfFromHtml } = require('../pdf/generator');
const { uploadPdfToSlack } = require('../pdf/uploader');
/**
* Handle the /report slash command
* Usage: /report [weekly|monthly|custom]
*/
async function handleReportCommand({ command, ack, respond }) {
await ack();
// Show interactive Block Kit UI
await respond({
response_type: 'ephemeral',
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: ':bar_chart: *Configure your report PDF*',
},
},
{
type: 'section',
block_id: 'report_type_block',
text: { type: 'mrkdwn', text: '*Report Type*' },
accessory: {
type: 'static_select',
action_id: 'report_type_select',
placeholder: { type: 'plain_text', text: 'Select type' },
options: [
{ text: { type: 'plain_text', text: 'Weekly KPI Report' }, value: 'weekly' },
{ text: { type: 'plain_text', text: 'Monthly Summary Report' }, value: 'monthly' },
{ text: { type: 'plain_text', text: 'Custom Report' }, value: 'custom' },
],
},
},
{
type: 'section',
block_id: 'format_block',
text: { type: 'mrkdwn', text: '*Paper Size*' },
accessory: {
type: 'static_select',
action_id: 'pdf_format_select',
placeholder: { type: 'plain_text', text: 'Select size' },
options: [
{ text: { type: 'plain_text', text: 'A4 (Portrait)' }, value: 'A4' },
{ text: { type: 'plain_text', text: 'A4 (Landscape)' }, value: 'A4-landscape' },
{ text: { type: 'plain_text', text: 'Letter' }, value: 'Letter' },
],
},
},
{
type: 'actions',
elements: [
{
type: 'button',
action_id: 'pdf_generate_confirm',
text: { type: 'plain_text', text: 'Generate PDF' },
style: 'primary',
value: JSON.stringify({ channelId: command.channel_id, userId: command.user_id }),
},
{
type: 'button',
action_id: 'pdf_generate_cancel',
text: { type: 'plain_text', text: 'Cancel' },
},
],
},
],
});
}
module.exports = { handleReportCommand };
Interaction Handler
// src/interactions/pdf-options.js
const { generatePdfFromHtml } = require('../pdf/generator');
const { uploadPdfToSlack } = require('../pdf/uploader');
// Temporary state per user (use Redis in production)
const userSelections = new Map();
async function handlePdfOptions({ action, body, ack, respond, client }) {
await ack();
const userId = body.user.id;
if (action.action_id === 'report_type_select') {
userSelections.set(userId, {
...userSelections.get(userId),
reportType: action.selected_option.value,
});
return;
}
if (action.action_id === 'pdf_format_select') {
userSelections.set(userId, {
...userSelections.get(userId),
format: action.selected_option.value,
});
return;
}
if (action.action_id === 'pdf_generate_confirm') {
const selection = userSelections.get(userId) || {};
const { channelId } = JSON.parse(action.value || '{}');
await respond({ text: 'Generating your report... :hourglass_flowing_sand:', response_type: 'ephemeral' });
try {
const html = buildReportHtml(selection.reportType || 'weekly');
const pdfBuffer = await generatePdfFromHtml(html, {
engine: 'quality',
format: selection.format || 'A4',
});
await uploadPdfToSlack(client, {
channelId: channelId || body.channel.id,
pdfBuffer,
filename: `report-${selection.reportType || 'weekly'}-${Date.now()}.pdf`,
message: `:bar_chart: *${selection.reportType || 'Weekly'} Report* has been generated`,
});
userSelections.delete(userId);
} catch (error) {
await respond({ text: `Error: ${error.message}`, response_type: 'ephemeral' });
}
}
if (action.action_id === 'pdf_generate_cancel') {
userSelections.delete(userId);
await respond({ text: 'Cancelled.', response_type: 'ephemeral', replace_original: true });
}
}
function buildReportHtml(reportType) {
const titles = {
weekly: 'Weekly KPI Report',
monthly: 'Monthly Summary Report',
custom: 'Custom Report',
};
return `
<html><head><meta charset="UTF-8">
<style>body{font-family:Arial,sans-serif;padding:40px;} h1{color:#3b82f6;}</style>
</head><body>
<h1>${titles[reportType] || 'Report'}</h1>
<p>Generated: ${new Date().toLocaleString('en-US')}</p>
<p>(Insert your actual data here)</p>
</body></html>
`;
}
module.exports = { handlePdfOptions };
Step 6: Scheduled Report Auto-Posting
Use node-cron to automatically post a weekly report every Monday at 9 AM.
// src/scheduler.js
const cron = require('node-cron');
const { generatePdfFromHtml } = require('./pdf/generator');
const { uploadPdfToSlack } = require('./pdf/uploader');
const { fetchWeeklyMetrics } = require('./data/metrics');
const REPORT_CHANNEL_ID = process.env.SLACK_REPORT_CHANNEL_ID;
/**
* Weekly report cron job
* Runs every Monday at 9:00 AM in the configured timezone
*/
function startScheduledReports(client) {
cron.schedule('0 9 * * 1', async () => {
console.log('[Scheduler] Starting weekly report generation');
try {
const metrics = await fetchWeeklyMetrics();
const html = buildWeeklyReportHtml(metrics);
const pdfBuffer = await generatePdfFromHtml(html, {
engine: 'quality',
format: 'A4',
});
const weekLabel = getWeekLabel();
await uploadPdfToSlack(client, {
channelId: REPORT_CHANNEL_ID,
pdfBuffer,
filename: `weekly-report-${weekLabel}.pdf`,
message: `:calendar: *${weekLabel} Weekly KPI Report* posted automatically.`,
});
console.log(`[Scheduler] Weekly report posted: ${weekLabel}`);
} catch (error) {
console.error('[Scheduler] Weekly report error:', error);
// Notify the channel even on failure
await client.chat.postMessage({
channel: REPORT_CHANNEL_ID,
text: `:warning: Automated weekly report failed: ${error.message}`,
});
}
}, {
timezone: 'America/New_York', // Adjust to your timezone
});
console.log('[Scheduler] Weekly report cron job registered (every Monday 9:00 AM)');
}
function getWeekLabel() {
const now = new Date();
const year = now.getFullYear();
const weekNum = Math.ceil(
((now - new Date(year, 0, 1)) / 86400000 + new Date(year, 0, 1).getDay() + 1) / 7
);
return `${year}-W${String(weekNum).padStart(2, '0')}`;
}
function buildWeeklyReportHtml(metrics) {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; padding: 40px; color: #1f2937; }
h1 { color: #3b82f6; border-bottom: 2px solid #3b82f6; padding-bottom: 8px; }
.metric { display: inline-block; background: #f3f4f6; border-radius: 8px; padding: 16px 24px; margin: 8px; text-align: center; }
.metric-value { font-size: 28px; font-weight: 700; color: #3b82f6; }
.metric-label { font-size: 13px; color: #6b7280; margin-top: 4px; }
table { width: 100%; border-collapse: collapse; margin-top: 24px; }
th { background: #f3f4f6; padding: 10px 12px; text-align: left; font-size: 13px; }
td { padding: 10px 12px; border-bottom: 1px solid #e5e7eb; }
</style>
</head>
<body>
<h1>Weekly KPI Report</h1>
<p>Period: ${metrics.periodStart} – ${metrics.periodEnd}</p>
<div>
<div class="metric">
<div class="metric-value">$${metrics.totalRevenue?.toLocaleString() || '-'}</div>
<div class="metric-label">Total Revenue</div>
</div>
<div class="metric">
<div class="metric-value">${metrics.newUsers || '-'}</div>
<div class="metric-label">New Users</div>
</div>
<div class="metric">
<div class="metric-value">${metrics.conversionRate || '-'}%</div>
<div class="metric-label">Conversion Rate</div>
</div>
<div class="metric">
<div class="metric-value">${metrics.pdfGenerated || '-'}</div>
<div class="metric-label">PDFs Generated</div>
</div>
</div>
<h2>Channel Breakdown</h2>
<table>
<thead>
<tr><th>Channel</th><th>Sessions</th><th>Conversions</th><th>Revenue</th></tr>
</thead>
<tbody>
${(metrics.channels || []).map(ch => `
<tr>
<td>${ch.name}</td>
<td>${ch.sessions?.toLocaleString()}</td>
<td>${ch.conversions}</td>
<td>$${ch.revenue?.toLocaleString()}</td>
</tr>
`).join('')}
</tbody>
</table>
</body>
</html>
`;
}
module.exports = { startScheduledReports };
Install the cron package and wire up the scheduler.
npm install node-cron
// Add to the bottom of src/index.js
const { startScheduledReports } = require('./scheduler');
(async () => {
await app.start();
startScheduledReports(app.client);
console.log(`Slack PDF Bot is running on port ${process.env.PORT || 3000}`);
})();
Step 7: Security and Access Control
Slack Request Signature Verification
Bolt SDK handles signature verification automatically. If you use Express directly, verify manually.
const crypto = require('crypto');
/**
* Verify Slack request signature
* Use this when NOT using Bolt SDK
*/
function verifySlackSignature(req) {
const signingSecret = process.env.SLACK_SIGNING_SECRET;
const timestamp = req.headers['x-slack-request-timestamp'];
const signature = req.headers['x-slack-signature'];
// Prevent replay attacks (reject requests older than 5 minutes)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
throw new Error('Request timestamp is too old');
}
const rawBody = req.rawBody; // Requires express.raw() middleware
const baseString = `v0:${timestamp}:${rawBody}`;
const hmac = crypto.createHmac('sha256', signingSecret);
const expectedSignature = `v0=${hmac.update(baseString).digest('hex')}`;
if (!crypto.timingSafeEqual(
Buffer.from(signature, 'utf-8'),
Buffer.from(expectedSignature, 'utf-8')
)) {
throw new Error('Signature mismatch');
}
}
Channel and User-Based Access Control
In production, you typically want to restrict who can trigger PDF generation.
// src/middleware/auth.js
// Comma-separated Slack user IDs allowed to use commands
const ALLOWED_USER_IDS = (process.env.ALLOWED_SLACK_USER_IDS || '').split(',').filter(Boolean);
// Comma-separated channel IDs where commands are permitted
const ALLOWED_CHANNEL_IDS = (process.env.ALLOWED_SLACK_CHANNEL_IDS || '').split(',').filter(Boolean);
/**
* Middleware: only allow listed users
*/
async function requireAllowedUser({ command, ack, respond, next }) {
if (ALLOWED_USER_IDS.length > 0 && !ALLOWED_USER_IDS.includes(command.user_id)) {
await ack();
await respond({
text: ':no_entry: You do not have permission to use this command. Contact your admin.',
response_type: 'ephemeral',
});
return;
}
await next();
}
/**
* Middleware: only allow listed channels
*/
async function requireAllowedChannel({ command, ack, respond, next }) {
if (ALLOWED_CHANNEL_IDS.length > 0 && !ALLOWED_CHANNEL_IDS.includes(command.channel_id)) {
await ack();
await respond({
text: ':no_entry: This command is not available in this channel.',
response_type: 'ephemeral',
});
return;
}
await next();
}
module.exports = { requireAllowedUser, requireAllowedChannel };
Apply the middleware to your commands.
// src/index.js — applying middleware
const { requireAllowedUser, requireAllowedChannel } = require('./middleware/auth');
app.command('/invoice',
requireAllowedUser,
requireAllowedChannel,
handleInvoiceCommand
);
API Key Management
Never embed the FUNBREW PDF API key in source code. Always use environment variables.
# Production (e.g., Railway, Render, Fly.io)
railway variables set FUNBREW_API_KEY=sk-your-key
# or
fly secrets set FUNBREW_API_KEY=sk-your-key
# Local development
echo "FUNBREW_API_KEY=sk-your-key" >> .env
echo ".env" >> .gitignore # Always add to .gitignore
For comprehensive API key and security practices, see the security guide.
Deployment
Deploying to Railway / Render / Fly.io
Slack Bots require an HTTPS-accessible endpoint, so you need a hosted environment.
// package.json
{
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js"
}
}
# Dockerfile (production)
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY src/ ./src/
EXPOSE 3000
CMD ["node", "src/index.js"]
For detailed Docker and Kubernetes deployment, see the Docker & Kubernetes operations guide.
Using ngrok for Local Development
Slack webhooks require a public URL. ngrok is the easiest solution during development.
# Install and start ngrok
brew install ngrok
ngrok http 3000
# Copy the forwarding URL (e.g., https://abc123.ngrok.io)
# Paste it into your Slack App's Request URL fields:
# - Slash Commands: https://abc123.ngrok.io/slack/commands
# - Interactivity: https://abc123.ngrok.io/slack/interactions
Step 8: Error Handling and Retry Strategy
When PDF generation fails, return clear error messages to the Slack user and retry transient failures automatically.
PDF Generation with Retry
// src/pdf/generator.js — with retry support
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 1000;
/**
* Generate a PDF with exponential backoff retries
*/
async function generatePdfWithRetry(html, options = {}, attempt = 1) {
try {
return await generatePdfFromHtml(html, options);
} catch (error) {
// 4xx errors indicate a problem with the request — don't retry
if (error.response?.status >= 400 && error.response?.status < 500) {
const message = error.response?.data?.message || error.message;
throw new Error(`PDF generation error (check your input): ${message}`);
}
if (attempt >= MAX_RETRIES) {
throw new Error(`PDF generation failed after ${MAX_RETRIES} attempts: ${error.message}`);
}
// Exponential backoff: 1s → 2s → 4s
const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1);
console.warn(`[PDF] Attempt ${attempt} failed. Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
return generatePdfWithRetry(html, options, attempt + 1);
}
}
module.exports = { generatePdfFromHtml, generatePdfWithRetry, renderTemplate };
Error Type Classification
Map errors to user-friendly Slack messages:
// src/utils/error-handler.js
function formatErrorMessage(error) {
// Slack API errors
if (error.code === 'slack_webapi_platform_error') {
switch (error.data?.error) {
case 'missing_scope':
return ':lock: Missing Slack permission. Ensure the Bot has the `files:write` scope.';
case 'channel_not_found':
return ':x: Channel not found. Invite the Bot to the channel first (`/invite @YourBot`).';
case 'not_in_channel':
return ':x: Bot is not in this channel. Run `/invite @YourBot` to add it.';
default:
return `:warning: Slack API error: ${error.data?.error}`;
}
}
// Request input errors
if (error.message?.includes('check your input')) {
return `:x: ${error.message}`;
}
// Timeout
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
return ':hourglass: PDF generation timed out. Try the `fast` engine for simpler templates.';
}
// Unknown server errors
return `:warning: An unexpected error occurred. Please try again later.\nDetails: ${error.message}`;
}
async function handleCommandError(error, { respond, command }) {
console.error(`[SlackBot] Error in command "${command?.command}":`, {
userId: command?.user_id,
channelId: command?.channel_id,
error: error.message,
stack: error.stack,
});
await respond({
text: formatErrorMessage(error),
response_type: 'ephemeral',
});
}
module.exports = { formatErrorMessage, handleCommandError };
Step 9: Distributing Your Slack App
To let other teams install the Bot into their own workspaces, implement the OAuth flow.
Multi-Workspace OAuth Flow
// src/oauth.js
const { App, ExpressReceiver } = require('@slack/bolt');
const receiver = new ExpressReceiver({
signingSecret: process.env.SLACK_SIGNING_SECRET,
clientId: process.env.SLACK_CLIENT_ID,
clientSecret: process.env.SLACK_CLIENT_SECRET,
stateSecret: process.env.SLACK_STATE_SECRET || 'my-secret',
scopes: ['commands', 'chat:write', 'files:write', 'channels:read'],
installationStore: {
storeInstallation: async (installation) => {
// Save to database keyed by team ID
await db.saveInstallation(installation.team.id, installation);
},
fetchInstallation: async (installQuery) => {
return await db.getInstallation(installQuery.teamId);
},
deleteInstallation: async (installQuery) => {
await db.deleteInstallation(installQuery.teamId);
},
},
});
const app = new App({ receiver });
// Landing page with "Add to Slack" button
receiver.router.get('/', (req, res) => {
res.send(`
<html><body>
<h1>Slack PDF Bot</h1>
<a href="/slack/install">
<img src="https://platform.slack-edge.com/img/add_to_slack.png" />
</a>
</body></html>
`);
});
module.exports = { app, receiver };
App Manifest for One-Click Setup
Use a Slack App Manifest to automate all slash command configuration:
# manifest.yaml
display_information:
name: PDF Bot
description: Generate PDFs from Slack slash commands
background_color: "#1a1a2e"
features:
bot_user:
display_name: PDF Bot
always_online: true
slash_commands:
- command: /invoice
url: https://your-app.example.com/slack/commands
description: Generate an invoice PDF and post it to the channel
usage_hint: "[client name] [amount]"
should_escape: false
- command: /report
url: https://your-app.example.com/slack/commands
description: Generate a KPI report PDF
usage_hint: "[weekly|monthly]"
should_escape: false
oauth_config:
scopes:
bot:
- commands
- chat:write
- files:write
- channels:read
settings:
interactivity:
is_enabled: true
request_url: https://your-app.example.com/slack/interactions
socket_mode_enabled: false
In the Slack API dashboard, go to "Your Apps" → "Create from Manifest" and paste this YAML to apply all settings at once.
Production Deployment Checklist
| Item | What to Check |
|---|---|
| HTTPS endpoint | Slack requires HTTPS — no plain HTTP |
| Environment variables | SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET, FUNBREW_API_KEY |
| Signature verification | Handled automatically by Bolt SDK |
| Timeout handling | PDF generation can take up to 30s. ACK Slack within 3s and process asynchronously |
| File scope | Confirm files:write is granted |
| Error monitoring | Use Sentry or Datadog to catch errors in production |
| Rate limits | Follow Slack's Tier 1–3 limits; use a queue for bulk sends |
Summary
In this guide, we built a complete Slack Bot that generates PDFs from chat using FUNBREW PDF API.
| Feature | What We Built |
|---|---|
| Slash commands | /invoice generates a PDF instantly from Slack |
| Block Kit UI | Interactive paper size and report type selection |
| File upload | files.uploadV2 posts PDFs directly to channels |
| Scheduled reports | cron auto-posts weekly reports every Monday |
| Security | Signature verification, user/channel ACL, API key management |
| Error handling | Retries, error-type messages, and structured logging |
| App distribution | OAuth flow and App Manifest for team-wide rollout |
As a next step, combine this with batch processing to generate hundreds of invoices at month-end and notify your team in Slack automatically. Use webhook integration to receive real-time push notifications when each PDF finishes generating — a useful complement to slash-command-based on-demand generation. Try the FUNBREW PDF API in the playground to get started.
Related Guides
- HTML to PDF: Complete Guide — Full landscape of conversion approaches
- Invoice PDF Automation — Template design and variable binding
- Automated Report PDF Generation — Scheduled report output
- PDF API Error Handling — Retry strategies and error responses
- PDF API Production Checklist — Monitoring and scaling best practices
- Google Sheets Integration — Generate PDFs from spreadsheet data
- GitHub Actions Integration — PDF generation in CI/CD pipelines
- Webhook Integration — Receive real-time notifications when PDFs are ready
- API Documentation — Full API reference
- Japanese version of this guide — Slack BotでPDF自動生成(日本語版)