Notix
Integrations

Send email from Firebase: your functions, and Firebase's own.

Two things send email in a Firebase project. Your Cloud Functions send receipts, welcomes and alerts; Firebase Authentication sends verification and password reset emails on its own. The first goes through the Notix API with the notix-js SDK and a secret in Secret Manager. The second goes through the SMTP relay via the SMTP settings in the Firebase console. Both end up in the same dashboard, with the same suppression list and webhooks. No extension to install.

Setup

Install the SDK, store the key as a secret.

2nd gen functions read secrets from Cloud Secret Manager. Set the value once from the CLI; nothing goes into code or a committed file.

Terminal, in your Firebase project
cd functions
npm install notix-js

# Store the API key in Cloud Secret Manager, never in code or .env
firebase functions:secrets:set NOTIX_API_KEY
Your functions

Send from an HTTPS function.

onCall when the caller is your own app and you want Firebase Auth to identify the user for you; onRequest when a third party or a webhook is the caller. Both bind the secret the same way.

functions/src/index.ts, callable from your web or mobile app
import { onCall, HttpsError } from "firebase-functions/v2/https";
import { defineSecret } from "firebase-functions/params";
import { Notix } from "notix-js";

const notixApiKey = defineSecret("NOTIX_API_KEY");

export const sendWelcome = onCall(
  { secrets: [notixApiKey], region: "europe-west1" },
  async (request) => {
    if (!request.auth?.token.email) {
      throw new HttpsError("unauthenticated", "Sign in first.");
    }

    // Construct inside the handler: the secret is only readable at runtime.
    const notix = new Notix(notixApiKey.value());

    const { data, error } = await notix.emails.send(
      {
        from: "hello@acme.com",
        to: request.auth.token.email,
        subject: "Welcome to Acme",
        html: "<p>Thanks for signing up.</p>",
      },
      // One welcome per user, however many times the client calls this.
      { idempotencyKey: `welcome-${request.auth.uid}` },
    );

    if (error) {
      throw new HttpsError("internal", error.message);
    }
    return { emailId: data.emailId };
  },
);

Send a receipt when a document is written.

The most common Firebase pattern: the app writes an order, a Firestore trigger emails the receipt. Retries are on, so the idempotency key does the work of making that safe.

functions/src/index.ts, Firestore trigger
import { onDocumentCreated } from "firebase-functions/v2/firestore";
import { defineSecret } from "firebase-functions/params";
import { Notix } from "notix-js";

const notixApiKey = defineSecret("NOTIX_API_KEY");

// Fires once per new order document. With retry: true, a failed run is
// retried with backoff for up to 24 hours, so the send must be idempotent.
export const orderReceipt = onDocumentCreated(
  { document: "orders/{orderId}", secrets: [notixApiKey], retry: true },
  async (event) => {
    const order = event.data?.data();
    if (!order) return;

    const notix = new Notix(notixApiKey.value());

    const { error } = await notix.emails.send(
      {
        from: "receipts@acme.com",
        to: order.email,
        subject: `Receipt for order ${event.params.orderId}`,
        html: `<p>Thanks, ${order.name}. Your total was ${order.total}.</p>`,
      },
      // The document id is the natural key: a retry of the same event, or
      // a second delivery of it, sends nothing new.
      { idempotencyKey: `receipt-${event.params.orderId}` },
    );

    if (error) {
      // Throwing tells Cloud Functions to retry. A refused recipient or a
      // validation error will not improve with time, so log those and
      // return; only a server-side failure is worth trying again.
      if (error.code === "INTERNAL_SERVER_ERROR") throw new Error(error.message);
      console.error("receipt refused", event.params.orderId, error.code, error.message);
    }
  },
);
Firebase’s own emails

Point Firebase Auth at the relay.

Verification, password reset and address-change emails are sent by Firebase Authentication, not by your code. The console lets you replace its default sender with an SMTP server of your own; give it the relay and they become Notix sends.

Firebase console, Authentication, Templates
# Firebase console > Authentication > Templates > SMTP settings
Enable custom SMTP:   on
Sender address:       no-reply@acme.com   (a domain you verified in Notix)
SMTP server host:     smtp.usenotix.dev
Port:                 465 (SSL/TLS) or 587 (STARTTLS)
Username:             notix
Password:             an API key from Settings, API keys
Security mode:        SSL for 465, STARTTLS for 587

The sender address must be on a domain you verified in Notix, with SPF and DKIM in place, or the relay refuses the message rather than sending it unauthenticated. Firebase’s own custom domain setting for Authentication emails is separate and controls the action links, not the sending server.

Local development

What the emulator does and does not do.

The Functions emulator runs your send code for real. The Authentication emulator never sends email at all.

Terminal
# .env.local is read by the emulator; a secret set with
# functions:secrets:set is not. Use a separate, revocable key for local work.
echo "NOTIX_API_KEY=nx_test_..." > functions/.env.local
firebase emulators:start --only functions,firestore,auth
The usual problems

Three things people hit, and the fix for each.

Nothing arrives from the emulator.

Two different causes. The Authentication emulator never sends email; it prints verification and reset links to the terminal for you to open, so the SMTP settings above are not exercised locally at all. The Functions emulator does run your code, but a secret defined with functions:secrets:set is not available to it unless a local value is provided, so the SDK is constructed with an empty key and every send answers 401. Put a separate key in functions/.env.local and revoke it when you are done.

The first call after a quiet spell times out.

A 2nd gen function that has not run for a while starts from cold: the runtime boots, your imports load, and only then does the send begin. Keep the Notix client construction inside the handler but the imports at module scope, raise timeoutSeconds if your function does more than one send, and set minInstances to 1 on the one function whose latency users can see. A warm instance costs Cloud Run's idle rate; most projects only need it on the sign-up path.

One order, two receipts.

Event-driven functions run at least once. With retry off, a crash drops the event silently; with retry on, a transient failure after the send succeeded runs the handler again and sends again. Pass an idempotency key built from the document id and Notix answers the repeat with the original email's id instead of sending it. Then only throw for errors that a retry can fix, so a permanent 4xx does not loop for 24 hours.

The SDK’s full send options, the webhook events and the idempotency rules are in the docs.

FAQ

Questions, answered.

How do I send email from Cloud Functions for Firebase?
Install notix-js in your functions directory, store the API key with firebase functions:secrets:set, declare it with defineSecret and list it in the trigger's secrets option. Inside the handler, construct the client with the secret's value and call emails.send with from, to, subject and html or react. The from address must be on a domain you verified in Notix. An onCall function is the usual entry point from a web or mobile app; a Firestore trigger is the usual way to send a receipt when an order document is written.
Can Firebase Auth send its verification and password reset emails through Notix?
Yes. The Firebase console's Authentication section has a Templates tab with SMTP settings, where you can point Firebase's own emails at a custom SMTP server instead of the default sender. Enter the relay host, port 465 or 587, the username notix and an API key as the password, and a sender address on a domain you verified in Notix. From then on every verification, reset and address-change email Firebase sends is a Notix send: tracked in the dashboard, subject to the suppression list, and reported to your webhooks.
Where do I keep the API key?
In Cloud Secret Manager, via firebase functions:secrets:set NOTIX_API_KEY. Declare it in code with defineSecret and bind it with the secrets option on each function that sends; the value is only readable inside the handler with .value(). Do not put it in firebase functions:config, in a committed .env file, or in any client-side Firebase config. For the emulator, use a separate key in functions/.env.local and revoke it afterwards.
Why did my Firestore trigger send the same email twice?
Cloud Functions delivers events at least once, and a function with retry enabled runs again after any failure, including a timeout that happened after your send had already gone out. Pass an idempotency key derived from the document id on every send. Notix keeps the key for 24 hours and answers a repeat with the original email rather than sending a second one, so the retry is harmless.
Is there a Notix Firebase Extension?
No. Notix does not publish a Firebase Extension, and this page does not need one: the SDK call is a few lines inside a function you already control, and the Auth emails go through SMTP settings Firebase provides. If you currently use the Trigger Email extension, its SMTP connection string can point at the relay with the same host, port and credentials, and your mail collection keeps working.
Does the relay work from the Firebase Hosting rewrite to a function?
Yes, because the relay and the API are reached from the function, not from the browser. A Hosting rewrite only changes the URL your client calls; the function still runs on Cloud Run with the secret bound to it. What you must not do is call the Notix API from client code with the key in a Firebase web config, where anyone can read it.

Wire it up before the next deploy.

One secret, one SDK call in a function, one SMTP form in the console. The free plan's 5,000 emails a month covers the testing. No card.