Invalid Date

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

Powered by FUNBREW PDF