Skip to content

Invoice PDF Download - Frontend Integration Guide

๐Ÿ“„ Overview

The Invoice PDF Download endpoint generates professional, print-ready PDF invoices on-demand. This guide provides everything frontend developers need to integrate this feature seamlessly.

๐Ÿ”— Endpoint

GET /api/v1/invoices/{invoice_id}/download

Authentication: Required (Bearer token - Staff or Customer JWT)


๐ŸŽจ What's in the PDF?

  • Professional A4 layout optimized for printing
  • Tenant branding (company name, email, phone, website)
  • Invoice details (number, dates, status)
  • Line items table with quantities, pricing, and tax
  • Tax calculations (PPN 11% for Indonesia)
  • Totals section (subtotal, tax, total, paid amount, balance due)
  • Custom notes from the invoice
  • Generation timestamp

File Format: PDF, typically 50-200KB per invoice


๐Ÿš€ Quick Start Examples

1. Basic Download (Vanilla JavaScript)

Triggers automatic download in the browser:

async function downloadInvoice(invoiceId, authToken) {
  try {
    const response = await fetch(
      `https://api.yourapp.com/api/v1/invoices/${invoiceId}/download`,
      {
        headers: {
          'Authorization': `Bearer ${authToken}`
        }
      }
    );

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }

    // Get filename from response header
    const contentDisposition = response.headers.get('Content-Disposition');
    const filename = contentDisposition
      ? contentDisposition.split('filename=')[1].replace(/"/g, '')
      : `invoice_${invoiceId}.pdf`;

    // Convert to blob and trigger download
    const blob = await response.blob();
    const url = window.URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();

    // Cleanup
    window.URL.revokeObjectURL(url);
    document.body.removeChild(a);

    console.log('โœ… Downloaded:', filename);
  } catch (error) {
    console.error('โŒ Download failed:', error);
    alert('Failed to download invoice. Please try again.');
  }
}

// Usage
downloadInvoice('68ee2bb70db240b13fe242ab', yourAuthToken);

2. Preview in New Tab

Opens PDF in browser instead of downloading:

async function previewInvoice(invoiceId, authToken) {
  const response = await fetch(
    `/api/v1/invoices/${invoiceId}/download`,
    {
      headers: { 'Authorization': `Bearer ${authToken}` }
    }
  );

  if (!response.ok) throw new Error('Preview failed');

  const blob = await response.blob();
  const url = window.URL.createObjectURL(blob);

  // Open in new tab
  window.open(url, '_blank');

  // Cleanup after a short delay
  setTimeout(() => window.URL.revokeObjectURL(url), 100);
}

3. React Component (TypeScript)

Complete component with loading states and error handling:

import React, { useState } from 'react';
import { Download, Loader, AlertCircle } from 'lucide-react'; // Optional icons

interface InvoiceDownloadButtonProps {
  invoiceId: string;
  authToken: string;
  variant?: 'download' | 'preview';
}

export function InvoiceDownloadButton({
  invoiceId,
  authToken,
  variant = 'download'
}: InvoiceDownloadButtonProps) {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleClick = async () => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(
        `/api/v1/invoices/${invoiceId}/download`,
        {
          headers: {
            'Authorization': `Bearer ${authToken}`
          }
        }
      );

      if (!response.ok) {
        const errorData = await response.json().catch(() => ({}));
        throw new Error(errorData.detail || `Error ${response.status}`);
      }

      const blob = await response.blob();
      const url = window.URL.createObjectURL(blob);

      if (variant === 'preview') {
        window.open(url, '_blank');
        setTimeout(() => window.URL.revokeObjectURL(url), 100);
      } else {
        const contentDisposition = response.headers.get('Content-Disposition');
        const filename = contentDisposition
          ? contentDisposition.split('filename=')[1].replace(/"/g, '')
          : `invoice_${invoiceId}.pdf`;

        const a = document.createElement('a');
        a.href = url;
        a.download = filename;
        a.click();
        window.URL.revokeObjectURL(url);
      }
    } catch (err: any) {
      setError(err.message);
      console.error('Download error:', err);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="space-y-2">
      <button
        onClick={handleClick}
        disabled={loading}
        className="btn btn-primary"
      >
        {loading ? (
          <>
            <Loader className="animate-spin mr-2" size={16} />
            Generating PDF...
          </>
        ) : (
          <>
            <Download className="mr-2" size={16} />
            {variant === 'preview' ? 'Preview PDF' : 'Download PDF'}
          </>
        )}
      </button>

      {error && (
        <div className="alert alert-error">
          <AlertCircle size={16} />
          <span>{error}</span>
        </div>
      )}
    </div>
  );
}

Usage:

<InvoiceDownloadButton
  invoiceId="68ee2bb70db240b13fe242ab"
  authToken={session.token}
  variant="download"
/>


4. Axios Implementation

If using Axios for HTTP requests:

import axios from 'axios';

async function downloadInvoiceWithAxios(invoiceId, authToken) {
  try {
    const response = await axios.get(
      `/api/v1/invoices/${invoiceId}/download`,
      {
        headers: {
          'Authorization': `Bearer ${authToken}`
        },
        responseType: 'blob' // โš ๏ธ CRITICAL: Must specify blob for binary data
      }
    );

    // Extract filename from Content-Disposition header
    const contentDisposition = response.headers['content-disposition'];
    const filename = contentDisposition
      ? contentDisposition.split('filename=')[1].replace(/"/g, '')
      : `invoice_${invoiceId}.pdf`;

    // Create download
    const url = window.URL.createObjectURL(response.data);
    const link = document.createElement('a');
    link.href = url;
    link.download = filename;
    link.click();
    window.URL.revokeObjectURL(url);

    return { success: true, filename };
  } catch (error) {
    console.error('Download failed:', error);
    throw error;
  }
}

5. Vue 3 Composition API

<template>
  <button
    @click="downloadInvoice"
    :disabled="loading"
    class="btn btn-primary"
  >
    <span v-if="loading">Generating PDF...</span>
    <span v-else>Download PDF</span>
  </button>
  <p v-if="error" class="error">{{ error }}</p>
</template>

<script setup lang="ts">
import { ref } from 'vue';

const props = defineProps<{
  invoiceId: string;
  authToken: string;
}>();

const loading = ref(false);
const error = ref<string | null>(null);

const downloadInvoice = async () => {
  loading.value = true;
  error.value = null;

  try {
    const response = await fetch(
      `/api/v1/invoices/${props.invoiceId}/download`,
      {
        headers: {
          'Authorization': `Bearer ${props.authToken}`
        }
      }
    );

    if (!response.ok) {
      throw new Error('Download failed');
    }

    const blob = await response.blob();
    const url = window.URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `invoice_${props.invoiceId}.pdf`;
    a.click();
    window.URL.revokeObjectURL(url);
  } catch (err: any) {
    error.value = err.message;
  } finally {
    loading.value = false;
  }
};
</script>

๐Ÿ”’ Access Control

Staff Users

  • Can download any invoice within their tenant
  • Requires: UserRole.STAFF or higher
  • Token type: user_type: "staff"

Customer Users

  • Can only download their own invoices
  • Attempting to access another customer's invoice returns 403 Forbidden
  • Token type: user_type: "customer" or is_customer: true

Super Admin

  • Can download invoices across all tenants
  • Requires: UserRole.SUPER_ADMIN

๐Ÿ“Š HTTP Response Codes

Code Meaning Action
200 Success - PDF generated Display/download PDF
400 Invalid invoice ID format Validate invoice ID (must be valid ObjectId)
401 Not authenticated Redirect to login
403 Forbidden (wrong invoice) Show "Access denied" message
404 Invoice not found Show "Invoice not found"
500 PDF generation failed Retry or contact support

โš ๏ธ Common Pitfalls & Solutions

โŒ Pitfall 1: Not Setting Blob Response Type

// WRONG
const response = await axios.get('/invoices/123/download');
// Result: Corrupted PDF, garbled text

// CORRECT
const response = await axios.get('/invoices/123/download', {
  responseType: 'blob' // โœ… Must specify for binary data
});

โŒ Pitfall 2: Missing Authorization Header

// WRONG
await fetch('/invoices/123/download');
// Result: 401 Unauthorized

// CORRECT
await fetch('/invoices/123/download', {
  headers: { 'Authorization': `Bearer ${token}` } // โœ…
});

โŒ Pitfall 3: Memory Leaks from Blob URLs

// WRONG - URL never cleaned up
const url = window.URL.createObjectURL(blob);
window.open(url);
// Result: Memory leak on repeated downloads

// CORRECT
const url = window.URL.createObjectURL(blob);
window.open(url);
setTimeout(() => window.URL.revokeObjectURL(url), 100); // โœ… Cleanup

โŒ Pitfall 4: Not Handling Errors

// WRONG - Silent failures
await fetch('/invoices/123/download');

// CORRECT - Proper error handling
try {
  const response = await fetch('/invoices/123/download');
  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.detail);
  }
} catch (err) {
  console.error(err);
  showErrorToast(err.message);
}

๐Ÿš€ Performance Considerations

Metric Value Notes
Generation Time 100-300ms Warm server
Cold Start 1-2 seconds First request (serverless)
File Size 50-200KB Depends on line items
Concurrent Downloads Unlimited No performance degradation

Optimization Tips: - Show loading spinner for better UX - Consider caching invoice data (not PDF) on frontend - Use optimistic UI patterns when possible


๐Ÿ’ก UX Best Practices

1. Loading States

// Show clear loading feedback
{loading ? 'Generating PDF...' : 'Download Invoice'}

2. Success Notification

toast.success('Invoice downloaded successfully!');

3. Filename Display

const filename = getFilenameFromResponse(response);
toast.success(`Downloaded: ${filename}`);

4. Download History

// Track downloads for user convenience
localStorage.setItem('lastDownload', JSON.stringify({
  invoiceId,
  filename,
  timestamp: Date.now()
}));

5. Mobile Considerations

// On mobile, "download" may trigger preview instead
// This is browser behavior, not an error
if (isMobile()) {
  toast.info('PDF will open in a new tab');
}

๐Ÿงช Testing Checklist

  • [ ] Download works with valid invoice ID
  • [ ] Preview in new tab works
  • [ ] Filename extracts correctly from header
  • [ ] 403 error shown when customer accesses wrong invoice
  • [ ] 404 error shown for non-existent invoice
  • [ ] Loading state displays during generation
  • [ ] Error messages are user-friendly
  • [ ] Works on Chrome, Firefox, Safari
  • [ ] Works on mobile browsers
  • [ ] Blob URLs cleaned up (no memory leaks)
  • [ ] Multiple rapid downloads don't crash browser

๐Ÿ“ž Support

If you encounter issues:

  1. Check invoice ID format: Must be valid MongoDB ObjectId (24 hex chars)
  2. Verify authentication: Token must be valid and not expired
  3. Check access permissions: Customer can only access own invoices
  4. Browser console: Look for network errors or CORS issues
  5. Contact backend team: Provide invoice ID and error message

  • GET /api/v1/invoices - List invoices
  • GET /api/v1/invoices/{id} - Get invoice details
  • POST /api/v1/invoices/{id}/send - Email invoice (future)

๐Ÿ“ Example Error Responses

403 Forbidden (Customer accessing wrong invoice)

{
  "detail": "You can only download your own invoices"
}

404 Not Found

{
  "detail": "Invoice not found"
}

500 Server Error

{
  "detail": "Failed to generate PDF: TenantInDB object has no attribute 'name'"
}

Questions? Contact the backend team or check the API docs at /docs