Fazza Razaq Amiarso
January 27, 2023

Table of contents
A lot of open-source invoice management apps are built with Laravel. As a Javascript developer, I wanted to build the “React Solution” for devs that are familiar with React and Javascript.
A problem I found when building with services in Node.js is that there is no built-in mailer. So, I had to find a 3rd party service to do that for me. In this article, I will be integrating Courier to send emails for this project https://github.com/fazzaamiarso/invoys.
As this article isn't your typical follow-along (more like "please sit tight and see how I do it"), it's not mandatory to be familiar with all technologies used. However, familiarity with Typescript and Next.js will be beneficial for quicker understanding.
Technologies in this blog:
You can find the full source code here for reference.
Before building the features, let's define our goals.
Let's head over to the Courier Dashboard. By default, it's in a production environment. Since I want to test things out, I'm going to change to the test environment by clicking the dropdown in the top-right corner.
We can copy all templates later to production or vice-versa.
Now, I will create a brand for my email notifications.

I'm just going to add a logo (beware that the logo width is fixed to 140px) on the header and social links on the footer. The designer UI is pretty straightforward, so here is the final result.

Don't forget to publish the changes.
Currently, the send email button on the UI is doing nothing.
I'm going to create a courier.ts file in src/lib/ to keep all Courier-related code. Also, I will use courier node.js client library which already abstracted all Courier API endpoints to functions.
Before I build the functionality, let's create the email notification design within Courier's Designer and set up the Gmail provider.
On the email designer page, we will see that the created brand is already integrated. After that, let's design the template accordingly with the needed data. Here is the final result.


Notice the value with {} that becomes green, it means it's a variable that can be inserted dynamically. I also set the 'See Invoice' button (or action) with a variable.
Before I can use the template, I need to create a test event by clicking the preview tab. Then, it will show a prompt to name the event and set data in JSON format. That data field is what will populate the value of the green {} variables (the data can be set from code also). Since it's a test event, I will fill it with arbitrary values.
Next, I will publish the template so I can use it. Then, go to send tab. It will show the necessary code to send the email programmatically and the data will be populated with the previous test event that I created.

I will copy the test AUTH_TOKEN to the .env file and copy the snippet to src/lib/courier.ts.
Copied!
const authToken = process.env.COURIER_AUTH_TOKEN;// email to receive all sent notifications in DEVELOPMENT modeconst testEmail = process.env.COURIER_TEST_EMAIL;const INVOICE_TEMPLATE_ID = <TEMPLATE_ID>;const courierClient = CourierClient({authorizationToken: authToken,});
Create a sendInvoice function that will be responsible for sending an email. To send an email from the code, I use the courierClient.send() function.
Copied!
// src/lib/courier.tsexport const sendInvoice = async ({customerName,invoiceNumber,invoiceViewUrl,emailTo,productName,dueDate,}: SendInvoice) => {const recipientEmail = process.env.NODE_ENV === "production" ? emailTo : testEmail;const { requestId } = await courierClient.send({message: {to: {email: recipientEmail,},template: INVOICE_TEMPLATE_ID,// Data for courier template designerdata: {customerName,invoiceNumber,invoiceViewUrl,productName,dueDate,},},});return requestId};
Define types for the sendInvoice function.
Copied!
// src/lib/courier.tsinterface SendInvoice {productName: string;dueDate: string;customerName: string;invoiceNumber: string;invoiceViewUrl: string;emailTo: string;}
Now that I can send the email, I will call it in the sendEmail trpc endpoint that resides in src/server/trpc/router/invoice.ts.
Just remember that trpc endpoint is a Next.js API route. In this case,
sendEmailwill be the same as calling the/api/trpc/sendEmailroute withfetchunder the hood. For more explanation https://trpc.io/docs/quickstart.
Copied!
// src/server/trpc/router/invoice.tsimport { sendInvoice } from '@lib/courier';import { dayjs } from '@lib/dayjs';// .....SOMEWHERE BELOWsendEmail: protectedProcedure.input(z.object({customerName: z.string(),invoiceNumber: z.string(),invoiceViewUrl: z.string(),emailTo: z.string(),invoiceId: z.string(),productName: z.string(),dueDate: z.date(),})).mutation(async ({ input }) => {const invoiceData = {...input,dueDate: dayjs(input.dueDate).format('D MMMM YYYY'),};await sendInvoice(invoiceData);}),
For those who are unfamiliar with trpc, what I did is the same as handling a POST request. Let's break it down.
sendInvoice function.Copied!
.input(z.object({customerName: z.string(),invoiceNumber: z.string(),invoiceViewUrl: z.string(),emailTo: z.string(),invoiceId: z.string(),productName: z.string(),dueDate: z.date(),}))
POST request handler (mutation).Copied!
// input from before.mutation(async ({ input }) => {const invoiceData = {...input,// format a date to string with a defined format.dueDate: dayjs(input.dueDate).format('D MMMM YYYY'), // ex.'2 January 2023'};// send the emailawait sendInvoice(invoiceData);}),
Now, I can start to add the functionality to the send email button. I'm going to use the trpc.useMutation() function which is a thin wrapper of tanstack-query'suseMutation`.
Let's add the mutation function. On successful response, I want to send a success toast on UI.
Copied!
//src/pages/invoices/[invoiceId]/index.tsximport toast from 'react-hot-toast';const InvoiceDetail: NextPage = () => {// calling the `sendEmail` trpc endpoint with tanstack-query.const sendEmailMutation = trpc.invoice.sendEmail.useMutation({onSuccess() {toast.success('Email sent!');}});}
I can just use the function as an inline handler, but I want to create a new handler for the button.
Copied!
//src/pages/invoices/[invoiceId]/index.tsx// still inside the InvoiceDetail componentconst sendInvoiceEmail = () => {const hostUrl = window.location.origin;// prevent a user from spamming when the API call is not done.if (sendEmailMutation.isLoading) return;// send input data to `sendEmail` trpc endpointsendEmailMutation.mutate({customerName: invoiceDetail.customer.name,invoiceNumber: `#${invoiceDetail.invoiceNumber}`,invoiceViewUrl: `${hostUrl}/invoices/${invoiceDetail.id}/preview`,emailTo: invoiceDetail.customer.email,invoiceId: invoiceDetail.id,dueDate: invoiceDetail.dueDate,productName: invoiceDetail.name,});};
Now I can attach the handler to the send email button.
Copied!
//src/pages/invoices/[invoiceId]/index.tsx<Buttonvariant="primary"onClick={sendInvoiceEmail}isLoading={sendEmailMutation.isLoading}>Send to Email</Button>
Here's the working UI.

To schedule a reminder that will be sent a day before an invoice's due date, I'm going to use Courier's Automation API.
First, let's design the email template in Courier designer. As I already go through the process before, here is the final result.

Before adding the function, define the types for the parameter and refactor the types.
Copied!
// src/lib/courierinterface CourierBaseData {customerName: string;invoiceNumber: string;invoiceViewUrl: string;emailTo: string;}interface SendInvoice extends CourierBaseData {productName: string;dueDate: string;}interface ScheduleReminder extends CourierBaseData {scheduledDate: Date;invoiceId: string;}
Now, I add the scheduleReminder function to src/lib/courier
Copied!
//src/pages/invoices/[invoiceId]/index.tsx// check if the development environment is productionconst __IS_PROD__ = process.env.NODE_ENV === 'production';const PAYMENT_REMINDER_TEMPLATE_ID = '<TEMPLATE_ID>';export const scheduleReminder = async ({scheduledDate,emailTo,invoiceViewUrl,invoiceId,customerName,invoiceNumber,}: ScheduleReminder) => {// delay until a day before due date in production, else 20 seconds after sent for developmentconst delayUntilDate = __IS_PROD__? scheduledDate: new Date(Date.now() + SECOND_TO_MS * 20);const recipientEmail = __IS_PROD__ ? emailTo : testEmail;// define the automation steps programmaticallyconst { runId } = await courierClient.automations.invokeAdHocAutomation({automation: {steps: [// 1. Set delay for the next steps until given date in ISO string{ action: 'delay', until: delayUntilDate.toISOString() },// 2. Send the email notification. Equivalent to `courierClient.send()`{action: 'send',message: {to: { email: recipientEmail },template: PAYMENT_REMINDER_TEMPLATE_ID,data: {invoiceViewUrl,customerName,invoiceNumber,},},},],},});return runId;};
To send the reminder, I will call scheduleReminder after a successful sendInvoice attempt. Let's modify the sendEmail trpc endpoint.
Copied!
// src/server/trpc/router/invoice.tssendEmail: protectedProcedure.input(..) // omitted for brevity.mutation(async ({ input }) => {// multiplier for converting day to milliseconds.const DAY_TO_MS = 1000 * 60 * 60 * 24;// get a day before the due dateconst scheduledDate = new Date(input.dueDate.getTime() - DAY_TO_MS * 1);const invoiceData = {..}; //omitted for brevityawait sendInvoice(invoiceData);//after the invoice is sent, schedule the reminderawait scheduleReminder({...invoiceData,scheduledDate,});}
Now if I try to send an invoice by email, I should get a reminder 20 seconds later since I'm in the development environment.

Finally, all the features are ready. However, I got a problem, what if a client had paid before the scheduled date for payment reminder? Currently, the reminder email will still be sent. That's not a great user experience and potentially a confused client. Thankfully, Courier has an automation cancellation feature.
Let's add cancelAutomationWorkflow function that can cancel any automation workflow in src/lib/courier.ts.
Copied!
export const cancelAutomationWorkflow = async ({cancelation_token,}: {cancelation_token: string;}) => {const { runId } = await courierClient.automations.invokeAdHocAutomation({automation: {// define a cancel action, that sends a cancelation_tokensteps: [{ action: 'cancel', cancelation_token }],},});return runId;};
What is a cancelation_token? It's a unique token that can be set to an automation workflow, so it's cancelable by sending a cancel action with a matching cancelation_token.
Add cancelation_token to scheduleReminder, I use the invoice's Id as a token.
Copied!
// src/lib/courier.tsexport const scheduleReminder = async(..) => {// ...omitted for brevityconst { runId } = await courierClient.automations.invokeAdHocAutomation({automation: {// add cancelation token herecancelation_token: `${invoiceId}-reminder`,steps: [{ action: 'delay', until: delayUntilDate.toISOString() },// ... omitted for brevity
I will call cancelAutomationWorkflow when an invoice's status is updated to PAID in the updateStatus trpc endpoint.
Copied!
// src/server/trpc/router/invoice.tsupdateStatus: protectedProcedure.input(..) // omitted for brevity.mutation(async ({ ctx, input }) => {const { invoiceId, status } = input;// update an invoice's status in databaseconst updatedInvoice = await ctx.prisma.invoice.update({where: { id: invoiceId },data: { status },});// cancel payment reminder automation workflow if the status is paid.if (updatedInvoice.status === 'PAID') {//call the cancel workflow to cancel the payment reminder for matching cancelation_token.await cancelAutomationWorkflow({cancelation_token: `${invoiceId}-reminder`,});}return updatedStatus;}),
Here is the working UI.

An important note when doing network requests is there are possibilities of failed requests/errors. I want to handle the error by throwing it to the client, so it can be reflected in UI.
On error, Courier API throws an error with CourierHttpClientError type by default. I will also have all functions' return value in src/lib/courier.ts consistent with the below format.
Copied!
// On Successtype SuccessResponse = { data: any, error: null }// On Errortype ErrorResponse = { data: any, error: string }
Now, I can handle errors by adding a try-catch block to all functions in src/lib/courier.ts.
Copied!
try {// ..function code// modified return examplereturn { data: runId, error: null };} catch (error) {// make sure it's an error from Courierif (error instanceof CourierHttpClientError) {return { data: error.data, error: error.message };} else {return { data: null, error: "Something went wrong!" };}}
Let's see a handling example on the sendEmail trpc endpoint.
Copied!
// src/server/trpc/router/invoice.tsconst { error: sendError } = await sendInvoice(..);if (sendError) throw new TRPCClientError(sendError);const { error: scheduleError } = await scheduleReminder(..);if (scheduleError) throw new TRPCClientError(scheduleError);
Now that all templates are ready, I will copy all assets in the test environment to production. Here is an example.

Finally, all the features are integrated with Courier. We've gone through a workflow of integrating Courier API to a Next.js application. Although it's in Next.js and trpc, the workflow will be pretty much the same with any other technology. I hope now you can integrate Courier into your application by yourself.
Get started now: https://app.courier.com/signup
I'm Fazza Razaq Amiarso, a full-stack web developer from Indonesia. I'm also an Open Source enthusiast. I love to share my knowledge and learning on my blog. I occasionally help other developers on FrontendMentor in my free time.
Connect with me on LinkedIn.

The Notification Platform Developers Choose
Most notification platforms built dashboards first and added developer tools later. Courier did the opposite. With a CLI that handles real workflows, MCP integration with setup management, typed SDKs in seven languages, and SOC 2 Type 2 certification, Courier is built for teams that ship. This isn't marketing copy: Twilio chose Courier to unify notifications across their 10M+ developer platform. LaunchDarkly uses Courier to power feature release workflows. When the companies that build developer infrastructure choose your notification platform, that says something about the technical foundation.
By Kyle Seyler
January 26, 2026

Vibe Coding Notifications: How to Use Courier with Cursor or Claude Code
Courier's MCP server lets AI coding tools like Cursor and Claude Code interact directly with your notification infrastructure. Unlike Knock and Novu's MCP servers that focus on API operations, Courier's includes embedded installation guides for Node, Python, Flutter, React, and other platforms. When you prompt "add Courier to my app," your AI assistant pulls accurate setup instructions rather than relying on outdated training data. OneSignal's MCP is community-maintained, not official. Courier supports 50+ providers, native Slack/Teams integration, drop-in inbox and preference components, and a free tier of 10,000 notifications/month. Configure in Cursor with "url": "https://mcp.courier.com" and "headers": { "api_key": "YOUR_KEY" }.
By Kyle Seyler
January 22, 2026

How Top Notification Platforms Handle Quiet Hours & Delivery Windows in 2026
No platform offers per-template delivery windows in 2026—it's either per-workflow (Customer.io, Knock), per-campaign (Braze), or global settings. This comparison shows exactly how six platforms handle quiet hours and send time controls based on their documentation and API specs. Braze leads on AI timing (23% open rate lift from Intelligent Timing across their customer base). Novu is the only platform letting subscribers set their own delivery windows. Customer.io and Knock require manual workflow configuration. OneSignal's strength is push-specific optimization across 300K+ apps. Courier combines per-node flexibility with API control. Includes feature matrix, timezone handling, and frequency capping differences.
By Kyle Seyler
January 16, 2026
© 2026 Courier. All rights reserved.