Deployment: Firebase, Vercel, Cloudflare
Where the API key lives on each platform, and how to receive webhooks there.
@tratto/email has no dependencies and uses only the global fetch: no
Buffer, no crypto, no node: imports. It runs unchanged on Node, on
Firebase Functions, on Vercel's Node and Edge runtimes, on Cloudflare Workers and
on Deno.
That makes the interesting part of deployment not whether the SDK runs, but two things that differ on every platform:
- Where the API key lives, so it stays out of the client bundle and out of git.
- How to read the raw request body, which webhook signature verification depends on.
Where the key goes
| Platform | Store it in | The mistake to avoid |
|---|---|---|
| Firebase Functions | Secret Manager via defineSecret() | functions.config() is removed in Gen2 |
| Vercel | Project Environment Variables | Any NEXT_PUBLIC_ prefix publishes it |
| Cloudflare Workers | wrangler secret put | vars in wrangler.toml is committed to git |
| Netlify | Environment variables | Build logs echo build-time variables |
| Cloud Run / containers | Secret Manager, mounted as env | Baking it into the image layer |
The rule underneath all of them: the key is read at runtime from the environment, never inlined at build time.
Firebase Functions (Gen2)
Store the key in Secret Manager and declare it on the function. Firebase injects it as an environment variable at runtime and never writes it into your source.
firebase functions:secrets:set TRATTO_API_KEY// functions/src/sendWelcome.ts
import { onCall, HttpsError } from 'firebase-functions/v2/https';
import { defineSecret } from 'firebase-functions/params';
import { Tratto } from '@tratto/email';
const trattoApiKey = defineSecret('TRATTO_API_KEY');
export const sendWelcome = onCall(
{ secrets: [trattoApiKey], region: 'europe-west1' },
async (request) => {
// Callable functions carry the Firebase Auth context, use it.
if (!request.auth) {
throw new HttpsError('unauthenticated', 'Sign in first');
}
const email = request.auth.token.email;
if (!email) {
throw new HttpsError('failed-precondition', 'No email on account');
}
const tratto = new Tratto(trattoApiKey.value());
const result = await tratto.emails.send({
from: 'Acme <[email protected]>',
to: email,
subject: 'Welcome to Acme',
templateId: 'tpl_welcome',
variables: { name: request.auth.token.name ?? 'there' },
});
return { id: result.id };
},
);functions.config() does not exist in Gen2. If you are migrating from Gen1,
every functions.config().tratto.key has to become a declared secret. The old
values are not carried over.
Construct the client inside the handler, not at module scope:
trattoApiKey.value() is only populated once the function has started with the
secret bound.
Firestore trigger
The same applies to background functions:
import { onDocumentCreated } from 'firebase-functions/v2/firestore';
import { defineSecret } from 'firebase-functions/params';
import { Tratto } from '@tratto/email';
const trattoApiKey = defineSecret('TRATTO_API_KEY');
export const onSignup = onDocumentCreated(
{ document: 'users/{userId}', secrets: [trattoApiKey] },
async (event) => {
const user = event.data?.data();
if (!user?.email) return;
const tratto = new Tratto(trattoApiKey.value());
await tratto.emails.send({
from: 'Acme <[email protected]>',
to: user.email,
subject: 'Welcome to Acme',
templateId: 'tpl_welcome',
// The document id makes retries idempotent: a re-fired trigger will not
// send a second copy.
variables: { name: user.name ?? 'there' },
}, event.params.userId);
},
);Passing the document id as the idempotency key matters here. Firestore triggers are at-least-once: the same event can fire twice, and without a key that is two emails. See Idempotency.
Vercel
Add TRATTO_API_KEY under Project Settings → Environment Variables. Do not
prefix it with NEXT_PUBLIC_.
// app/api/send/route.ts
import { NextResponse } from 'next/server';
import { Tratto } from '@tratto/email';
const tratto = new Tratto(process.env.TRATTO_API_KEY!);
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
if (!body || typeof body.email !== 'string') {
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
}
const result = await tratto.emails.send({
from: 'Acme <[email protected]>',
to: body.email,
subject: 'Welcome to Acme',
templateId: 'tpl_welcome',
});
return NextResponse.json({ id: result.id });
}Because the SDK is fetch-only, the same file works on the Edge runtime:
export const runtime = 'edge';For Server Actions and Server Components, see React.
Vercel functions are short-lived. Do not fire an email and return before the
promise settles: the runtime may freeze the instance first. Always await the
send.
Cloudflare Workers
Secrets are set through Wrangler, not through wrangler.toml. Anything under
[vars] is committed to your repository.
npx wrangler secret put TRATTO_API_KEYIn Workers the key arrives on env, not on process.env, so the client is
constructed per request:
// src/index.ts
import { Tratto } from '@tratto/email';
export interface Env {
TRATTO_API_KEY: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
const body = await request.json().catch(() => null);
if (!body || typeof body.email !== 'string') {
return Response.json({ error: 'Invalid payload' }, { status: 400 });
}
const tratto = new Tratto(env.TRATTO_API_KEY);
const result = await tratto.emails.send({
from: 'Acme <[email protected]>',
to: body.email,
subject: 'Welcome to Acme',
templateId: 'tpl_welcome',
});
return Response.json({ id: result.id });
},
};Receiving webhooks
Signature verification hashes the raw body. Parsing and re-serialising JSON changes whitespace and breaks the signature, so every platform needs its own way of getting at the bytes before anything touches them.
Express and Firebase Functions
Firebase's onRequest populates req.rawBody for you. Under plain Express you
have to ask for it:
import express from 'express';
import crypto from 'node:crypto';
const app = express();
// Keep the raw bytes alongside the parsed body.
app.use(express.json({ verify: (req, _res, buf) => { (req as any).rawBody = buf; } }));
// The header is `t=<ms>,v1=<hex>`, and the signed string is `{t}.{rawBody}`.
function verify(rawBody: Buffer, header: string, secret: string): boolean {
const parts = Object.fromEntries(
header.split(',').map((part) => part.split('=').map((s) => s.trim())),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() - timestamp) > 5 * 60_000) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1 ?? '', 'hex');
// timingSafeEqual throws on length mismatch, so check length first.
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
app.post('/webhooks/tratto', (req, res) => {
const header = req.header('x-tratto-signature') ?? '';
if (!verify((req as any).rawBody, header, process.env.TRATTO_WEBHOOK_SECRET!)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Acknowledge immediately, process afterwards: Tratto retries on timeout.
res.json({ ok: true });
});Vercel and Cloudflare Workers
Edge runtimes have no node:crypto. Use WebCrypto, which is available on all of
them, and on Node 18+ too, so this version is portable:
async function verify(
rawBody: string,
header: string,
secret: string,
): Promise<boolean> {
const parts = Object.fromEntries(
header.split(',').map((part) => part.split('=').map((s) => s.trim())),
);
const timestamp = Number(parts.t);
const signature = parts.v1 ?? '';
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() - timestamp) > 5 * 60_000) return false;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const mac = await crypto.subtle.sign(
'HMAC',
key,
encoder.encode(`${timestamp}.${rawBody}`),
);
const expected = [...new Uint8Array(mac)]
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
if (expected.length !== signature.length) return false;
// Constant-time comparison: never use === on a signature.
let diff = 0;
for (let i = 0; i < expected.length; i++) {
diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
}
return diff === 0;
}
export async function POST(request: Request) {
// Read the body as text, once, before parsing it.
const rawBody = await request.text();
const header = request.headers.get('x-tratto-signature') ?? '';
if (!(await verify(rawBody, header, process.env.TRATTO_WEBHOOK_SECRET!))) {
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(rawBody);
// …handle event
return Response.json({ ok: true });
}request.text() can only be called once per request. Read it first, verify, then
JSON.parse the same string. Never call request.json() as well.
See Webhook Verification for the full algorithm, replay protection and secret rotation.
Cold starts and connection reuse
The SDK holds no sockets and no connection pool, so there is nothing to warm up. Construct it wherever the key is available:
- Vercel, Cloudflare, Node servers: module scope is fine and avoids re-allocating per request.
- Firebase Functions: inside the handler, because
defineSecret().value()is only resolved once the function has started.
Checklist
- The key is read from the environment at runtime, never inlined at build time
- No variable holding it carries a client-exposed prefix such as
NEXT_PUBLIC_ - Cloudflare secrets are set with
wrangler secret put, not[vars] - Firebase Gen2 functions declare
secrets: [...]on every function that sends - Background triggers pass an idempotency key, since they fire at least once
- Webhook handlers parse
tandv1and sign{t}.{rawBody}, not the body alone - Webhook handlers verify against the raw body, before parsing
- Signature comparison is constant-time and length-checked
- Public endpoints validate input and are rate-limited
Related: Node.js SDK · React · Angular · Webhook Verification
Edit this page on GitHub
Last updated on