PDF options for Firebase projects
Firebase apps usually create documents in Cloud Functions, because the client should never hold the data or the credentials needed to build them. 2nd gen functions run on Cloud Run, so a container can technically include a browser. Whether it should is another matter: memory, cold starts and deploy size all grow with it.
| Approach | Where it runs | What it costs you |
|---|---|---|
| Puppeteer inside a Cloud Function | Your function, with Chromium downloaded at install | A much larger deploy, far more memory than the default, and slow cold starts on every new instance |
| PDFKit or pdfmake in the function | Your function, pure JavaScript | Light and fast; you lay out pages in code and manage page breaks yourself |
| Client side generation | The user’s device | Inconsistent output across devices, and the data and logic sit in the client |
| CastPDF | Called from your function | One HTTPS request per document; a plan above the free allowance |
Puppeteer in a function tends to follow a familiar pattern: the first document after a quiet period is slow, memory has to be raised well above the default, and an upgrade to the Node runtime breaks the browser download. Calling an API turns the function back into a small piece of glue code that starts quickly on the default memory setting.
Set the secret and the parameters
Firebase stores secrets in Google Cloud Secret Manager. Set the CastPDF key with the CLI (it prompts for the value, so the key does not land in your shell history). Start with a test key beginning cpdf_test_. The template ID is not secret, so it can be a plain string parameter read from functions/.env. Make sure package.json in the functions folder targets Node 20 or later with "engines": { "node": "20" }.
firebase functions:secrets:set CASTPDF_API_KEY
echo "BOOKING_TEMPLATE_ID=$BOOKING_TEMPLATE_ID" >> functions/.env
firebase deploy --only functionsA callable function that stores a booking confirmation
The example is a holiday rental app. When a guest taps “Download confirmation”, the app calls this function. It checks that the guest is signed in and owns the booking, renders the confirmation from a saved template, writes the PDF to the default Cloud Storage bucket and returns a signed URL that is valid for an hour.
import { onCall, HttpsError } from 'firebase-functions/v2/https';
import { defineSecret, defineString } from 'firebase-functions/params';
import { initializeApp } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';
import { getStorage } from 'firebase-admin/storage';
initializeApp();
const castpdfKey = defineSecret('CASTPDF_API_KEY');
const bookingTemplateId = defineString('BOOKING_TEMPLATE_ID');
export const bookingConfirmationPdf = onCall(
{ secrets: [castpdfKey], timeoutSeconds: 120, memory: '256MiB', region: 'europe-west3' },
async (request) => {
if (!request.auth) {
throw new HttpsError('unauthenticated', 'Sign in to download your confirmation.');
}
const bookingId = String(request.data.bookingId);
const snap = await getFirestore().doc(`bookings/${bookingId}`).get();
const booking = snap.data();
if (!booking || booking.guestUid !== request.auth.uid) {
throw new HttpsError('not-found', 'Booking not found.');
}
const res = await fetch('https://api.castpdf.com/v1/pdf', {
method: 'POST',
headers: {
Authorization: `Bearer ${castpdfKey.value()}`,
'Content-Type': 'application/json',
'Idempotency-Key': `booking-confirmation-${bookingId}-${booking.revision}`,
},
body: JSON.stringify({
template_id: bookingTemplateId.value(),
data: {
booking: { reference: booking.reference, check_in: booking.checkIn, check_out: booking.checkOut, guests: booking.guests },
property: booking.property,
guest: { name: booking.guestName },
charges: booking.charges,
},
filename: `confirmation-${booking.reference}`,
}),
signal: AbortSignal.timeout(90_000),
});
if (!res.ok) {
const { error } = await res.json();
console.error('CastPDF', res.status, error.code, error.message);
const busy = res.status === 429 || res.status === 503;
throw new HttpsError(busy ? 'unavailable' : 'internal', 'The confirmation could not be created.');
}
const file = getStorage().bucket().file(`confirmations/${request.auth.uid}/${booking.reference}.pdf`);
await file.save(Buffer.from(await res.arrayBuffer()), { contentType: 'application/pdf', resumable: false });
const [url] = await file.getSignedUrl({ action: 'read', expires: Date.now() + 60 * 60 * 1000 });
return { path: file.name, url };
},
);Listing the secret in secrets: [castpdfKey] is what makes castpdfKey.value() work at runtime; without it the value is empty. timeoutSeconds: 120 lifts the function above the 60 second default, so a long document never gets cut off by the platform. HttpsError codes travel to the client SDK intact, which lets your app tell a busy moment (unavailable) apart from a real failure. Saving with resumable: false is the efficient choice for small files like this.
On the client, call it with httpsCallable(functions, 'bookingConfirmationPdf') from the firebase/functions package, pass { bookingId } and open result.data.url. The Firebase SDK attaches the user’s ID token for you, which is where request.auth comes from.
onRequest: stream the PDF straight back
Sometimes you want a plain URL that returns the file, for example a link in an admin tool. onRequest gives you an Express style request and response. Unlike onCall, it does not check Firebase Auth for you, so verify the ID token yourself before rendering anything.
import { onRequest } from 'firebase-functions/v2/https';
import { defineSecret, defineString } from 'firebase-functions/params';
import { getAuth } from 'firebase-admin/auth';
const castpdfKey = defineSecret('CASTPDF_API_KEY');
const bookingTemplateId = defineString('BOOKING_TEMPLATE_ID');
export const bookingConfirmationDownload = onRequest(
{ secrets: [castpdfKey], timeoutSeconds: 120 },
async (req, res) => {
const token = (req.get('Authorization') ?? '').replace('Bearer ', '');
const user = await getAuth().verifyIdToken(token).catch(() => null);
if (!user?.admin) {
res.status(403).send('Forbidden');
return;
}
const upstream = await fetch('https://api.castpdf.com/v1/pdf', {
method: 'POST',
headers: { Authorization: `Bearer ${castpdfKey.value()}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ template_id: bookingTemplateId.value(), data: req.body, filename: 'confirmation' }),
signal: AbortSignal.timeout(90_000),
});
if (!upstream.ok) {
res.status(502).send('Could not create the PDF');
return;
}
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', 'inline; filename="confirmation.pdf"');
res.send(Buffer.from(await upstream.arrayBuffer()));
},
);Here user.admin is a custom claim you set on staff accounts. Prefer onCall for anything your customers trigger: it handles auth, CORS and error mapping, and it pairs naturally with Cloud Storage. To create confirmations automatically when a booking is written, use onDocumentCreated from firebase-functions/v2/firestore and call the same rendering code.
The confirmation template and its JSON
Design the confirmation once in the dashboard: HTML and CSS with a live preview, and a Simple mode form for anyone who only needs to change the sample text. Your function then sends this kind of payload, built from the Firestore document:
{
"template_id": "5e7a9c1b-3d5f-4b8a-9c2e-4f6a8b0d2e3f",
"data": {
"booking": { "reference": "BK-77120", "check_in": "2026-12-20", "check_out": "2026-12-27", "guests": 4 },
"property": { "name": "Larch Cabin", "address": "Glenridding, Cumbria", "host": "Fiona Bell" },
"guest": { "name": "Daniel Okafor" },
"charges": [
{ "label": "7 nights", "amount": 1260 },
{ "label": "Cleaning", "amount": 85 },
{ "label": "Security deposit (refundable)", "amount": 200 }
]
},
"filename": "confirmation-BK-77120"
}Firestore timestamps are objects, not strings. Convert them with toDate().toISOString() before sending, then format them in the template with format_date. Amounts should be plain numbers so the money filter can format them for the guest’s currency.
Production checklist for Firebase
- Keep the key in
defineSecretonly. Do not copy it intofunctions/.env, Remote Config or Firestore, all of which are easier to leak. - Set
timeoutSecondsto at least 120. A render may take up to 30 seconds, and the client abort signal of 90 seconds should stay below the function timeout. - Grant the function’s service account the Service Account Token Creator role if
getSignedUrlfails. Signing URLs needs that permission. - Enable App Check on callable functions to stop scripts outside your app from triggering renders on your account.
- Run the function in a region close to your Firestore database to keep reads fast. CastPDF itself runs on servers in Germany, so a European region is a natural fit for EU apps.
- Respect the per document limits of 50 pages and 40 MB. Use Cloud Storage lifecycle rules to delete old confirmations you no longer need.
Troubleshooting Firebase functions
| Symptom | Likely cause | Fix |
|---|---|---|
invalid_api_key (401) from CastPDF | The secret is not listed in the function options, so value() is empty | Add it to secrets: [...] and redeploy |
Permission iam.serviceAccounts.signBlob denied | The runtime service account cannot sign URLs | Grant it the Service Account Token Creator role |
deadline-exceeded on the client | The function timed out before the PDF arrived | Raise timeoutSeconds and keep the abort signal below it |
template_render_error (422) | A Firestore timestamp object was sent where the template expects a date string | Convert timestamps to ISO strings before sending |
unauthenticated from the callable | The client called it before sign in completed | Wait for the auth state, then call the function |
rate_limited (429) in the emulator | Test keys allow 20 requests per minute | Pause for the Retry-After seconds between bursts |
The quickstart covers keys and starters, and the API reference documents each field used above. The Supabase page shows the same flow with an Edge Function and Supabase Storage, the AWS Lambda page uses S3, and the Node.js page explains the core calls outside any platform.