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¶
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.STAFFor 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"oris_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¶
2. Success Notification¶
3. Filename Display¶
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:
- Check invoice ID format: Must be valid MongoDB ObjectId (24 hex chars)
- Verify authentication: Token must be valid and not expired
- Check access permissions: Customer can only access own invoices
- Browser console: Look for network errors or CORS issues
- Contact backend team: Provide invoice ID and error message
๐ Related Endpoints¶
GET /api/v1/invoices- List invoicesGET /api/v1/invoices/{id}- Get invoice detailsPOST /api/v1/invoices/{id}/send- Email invoice (future)
๐ Example Error Responses¶
403 Forbidden (Customer accessing wrong invoice)¶
404 Not Found¶
500 Server Error¶
Questions? Contact the backend team or check the API docs at /docs