Quote-sender
Posted on June 13, 2023 • 16 minutes • 3344 words
APIs provide a world of opportunities for developers seeking to create innovative business solutions. In this post, we will discuss how to use Stripe’s API to send quotes to customers via text message, as well as how to employ Nodemailer to deliver the PDF quotes to customers via email. Additionally, we will demonstrate how to create code that enables tracking when customers have opened the quotes.
Stripe is an incredibly powerful tool for business owners, functioning as a billing system, customer database, and more. While its dashboard offers a plethora of features, the true potential is unlocked through its API.
Stripe recently introduced a Quotes feature to its dashboard as part of its Invoicing Plus product. This feature costs a bit extra, but you can experiment with it in Stripe’s test mode . Although similar to their invoicing system, it has some limitations, such as the lack of automatic quote sending to customers. Instead, Stripe provides a PDF link that users must share manually.
DISCLAIMER
This post uses the now outdated Next.js Pages Router. I am still learning the App router. The code from this post is on GitHub . I will be using the branch named app-router for converting the code to the app router system.
The Tools
Before we dive into the code, let’s briefly go over the tools we’ll be using.
- Stripe API : A powerful API for handling online payments, subscriptions, and more. We will use it to create and manage quotes.
- Twilio API API: A cloud communications platform that allows us to send SMS messages programmatically.
- Nodemailer : A Node.js module for sending emails using SMTP, which is simple to set up and easy to use.
- Next.js : A React framework that makes it easy to build server-rendered React applications.
- UUID : Universally unique identifiers, which we will use to secure our transactions by generating unique IDs for each quote.
Pre-Requisite
You will have to sign up for a couple of services and get API keys for your .env
file. Go ahead and sign up for Stripe
, Twilio
, and email provider that is compatible with nodemailer
. In this example, I use another Twilio product, SendGrid
.
Methodology
The plan is to create a webhook that will listen for the event: “quotes.finalized” which will be triggered when you finishing creating a quote, either through the dashboard or with the Stripe API.
The webhook will create a UUID and add it to the quote’s metadata. The webhook will then send the customer a link to the PDF via text message and email. The link will be to a unique page. The link must match the UUID and the ID of the quote, this is a simple way of securing access to the quote. When the page is visited, the page will update the quote’s metadata to confirm that the customer has received the quote.
Setting Up
Now, let’s set up our project with the required dependencies. I would suggest following the Next.js setup instructions if you haven’t, or you can implement this into an existing codebase.
From the Next.js docs
npx create-next-app@latest
Then, you can use npm
or yarn
or pnpm
to install the following dependencies:
npm install stripe twilio nodemailer uuid mongodb micro micro-cors @chakra-ui/react @chakra-ui/icons @emotion/react @emotion/styled framer-motion
Keep in mind that some of this code will be quick and dirty, just to get yourself started and familiar with the functionality.
Creating a Quote
Next, we’ll create a quote using the Stripe API. We can do this by calling the stripe.quotes.create()
method and provide the necessary information. Or by creating one in the Stripe dashboard. Understanding how Stripe creates invoices behind the scenes will help you immensely here, whether you use the dashboard or decide to code your own integration. I suggest getting familiar with the dashboard before writing the code.
Stripe has a testing dashboard that allows you to experiment with all of its functionality in a testing environment
Stripe handles quotes similarly to invoices. Quotes are the bare-bones functionality of invoices. First, you will have to have pre-made products, with prices. These products will become the line item in your invoice.
The Stripe dashboard’s quote editing page is a bit clunky at the time of writing this. Invoice line items can be edited after they have been made, but the quote pricing is a bit locked in so you will have to delete the line item and make a new one if you decide to change the price before finalizing the invoice.
Sending Quotes via Email
Now that we have our quote, let’s write the code that will send the quote to your customers. We still start with the email method.
I won’t delve deep into Nodemailer too much, but it’s worth mentioning that it’s a powerful tool for sending emails from Node.js applications. Essentially, Nodemailer is a module that gives you the ability to easily send emails from your server.
You will need to make a module called a transporter. A Nodemailer transporter allows you to send emails using service providers, like Gmail, mail gun, etc. Transporters are also where you configure what email address the email will come from. There are many different Nodemailer transports available, each of which uses a different protocol to send emails. Some of the most popular Nodemailer transports include:
- SMTP: The most common protocol for sending emails. SMTP is a simple, text-based protocol that is supported by most email servers.
- SES: Amazon Simple Email Service (SES) is a cloud-based email service that provides a reliable and scalable way to send emails.
- SendGrid: SendGrid is a cloud-based email service that provides a variety of features, such as the ability to track opens and clicks, and the ability to send bulk emails.
When you create a Nodemailer transport, you need to specify the protocol that you want to use. While there are pre-made transporters. I prefer to make my own You also need to specify the credentials that you must use to connect to the email server. Once you have created a Nodemailer transport, you can use it to send emails.
Here’s a basic configuration example:
import nodemailer from "nodemailer";
import { EmailOptions } from "@/types/EmailOptions";
import { ErrorHandler } from "./ErrorHandler";
export async function sendEmailWithDocumentLink(
options: EmailOptions,
documentLink: string
) {
const { to, documentType, documentId, pdfStream, message, email } = options; // Create a Nodemailer transporter
const transporter = nodemailer.createTransport({
host: "smtp.sendgrid.net", //Change this to your provider
port: 587,
secure: false,
auth: {
user: process.env.SENDGRID_USER,
pass: process.env.SENDGRID_PASSWORD,
},
}); // Prepare the email content
const emailOptions = {
from: process.env.EMAIL_GRID_FROM,
to,
subject: `Your ${
documentType.charAt(0).toUpperCase() + documentType.slice(1)
} ${documentId}`,
text: message,
html: email,
};
try {
// Send the email
const info = await transporter.sendMail(emailOptions); // return a response object with the status and messageId
return {
status: "success",
messageId: info.messageId,
};
} catch (error: unknown) {
return ErrorHandler(error);
}
}
In this example, we’re using SendGrid as our service, and we’re sending a simple text email. The SendMail
function does the actual sending of the email. If there’s an error, it will be logged to the console; otherwise, it will log that the email was sent successfully.
SMS helper function
First, we import the Twilio module and initialize it with our account SID and authentication token, which are stored as environment variables for security.
We then export an asynchronous function called send text
. This function takes two parameters: body
, which is the text of the message we want to send, and dest
, which is the destination phone number.
Inside the function, we first check if either body
or dest
is undefined
or null
. If either of them is, we return an object with a message saying “no message”. This is a simple error-handling step to ensure we don’t try to send a message without a body or a destination.
If both body
and dest
is defined and not null, we proceed to create and send the message. We do this by calling client.messages.create
and passing an object with body
, from
, and to
properties. The from
property is our Twilio phone number, which is also stored as an environment variable.
The client.messages.create
function is asynchronous and returns a Promise, so we use the await
keyword to wait for it to complete. The result of this function is the sent message, which we return from our sendText
function.
This function provides a simple way to send text messages from a Node.js application using Twilio.
const accountSid = process.env.TWILIO_ACCOUNT_SID;
const authToken = process.env.TWILIO_AUTH;
const client = require("twilio")(accountSid, authToken);
export const sendText = async (body: string, dest: string) => {
if (body === undefined || null || dest === undefined || null) {
return { message: "no message" };
} else {
const send = await client.messages.create({
body: body,
from: process.env.TWILO_PHONE,
to: dest,
});
return send;
}
};
Monitoring Quote Accesses
In this step, we aim to create a page that displays the quote and enables customers to download the quote PDF via the link provided by the Stripe API. To achieve this, we’ll set up two routes - one on the backend and another on the front end. These routes will share some similarities, but also have distinct characteristics.
Both routes will leverage Next.js’s dynamic routing system to locate the invoice using the quote ID and the UUID we previously generated. Let’s begin by creating the necessary file.
touch /pages/[id]/[uuid]/quote.ts
If you’re new to Next.js, here’s a brief explanation of how its routing works: Each folder and file within the ‘pages’ or ‘app’ directory corresponds to a URL path. For instance, if your URL is:
legitbuisness.com/qt_446568904/8550-65634-35/quote
Here, ‘[id]’ represents the ID of your quote, and ‘[UUID]’ stands for the unique identifier.
This URL will serve the webpage that we’ll construct in the upcoming section.
Displaying The Quote
This code is a Next.js page component that displays the details of a quote. It uses Chakra UI for styling and layout and interacts with the Stripe API to retrieve quote details.
The QuotePage
component receives a quote
prop, which is an object containing details about a quote, including its ID, customer, PDF link, total amount, line items, description, and header.
The handleDownloadClick
function is used to open the PDF version of the quote in a new browser tab when the “Download Quote” button is clicked.
The displayLineItems
function is responsible for rendering the line items of the quote in a table. If the quote
object is not available, it displays a loading spinner and a message. Otherwise, it returns a table with each line item as a row.
The getServerSideProps
function is a special Next.js function that fetches data server-side before rendering the page. It retrieves the quote details from Stripe using the quote ID from the page’s context (URL parameters). It also retrieves the customer details associated with the quote. If the quote or customer is not found, or if the quote ID is not valid, it returns a 404 error. If everything is successful, it returns the quote details as props to the QuotePage
component.
The QuotePage
component then renders the quote details in a structured layout. It displays the quote header and description and calls the displayLineItems
function to render the line items. It also provides a “Download Quote” button for users to download the quote as a PDF.
This component is a great example of how to use Next.js and Stripe together to create a dynamic, server-rendered page for displaying detailed information about a quote.
import {
Box,
Button,
Container,
Flex,
Heading,
VStack,
Text,
Spinner,
Table,
TableCaption,
Tbody,
Td,
Th,
Thead,
Tr,
HStack,
} from "@chakra-ui/react";
import { DownloadIcon } from "@chakra-ui/icons";
import { GetServerSideProps } from "next";
import * as React from "react";
import Stripe from "stripe";
import { sendText } from "../../../../lib/twilio/sms/sendText";
import { isValidStripeQuoteId } from "../../../../lib/util";
import { stripeVer } from "@/lib/Stripe/lib";
import { ROOT_URL } from "@/lib";
type Quote = {
id: string;
customer: string;
pdf: string;
amount_total: number;
line_items: {
data: [
{
description?: String;
price: { unit_amount_decimal: string };
quantity: number;
}
];
};
description?: string;
header?: string;
};
type QuotePageProps = {
quote: Quote;
};
const QuotePage = ({ quote }: QuotePageProps) => {
const handleDownloadClick = () => {
window.open(quote.pdf, "_blank");
};
const displayLineItems = () => {
if (!quote) {
return (
<Box textAlign="center">
<Spinner /> <Text>Loading line items...</Text>
{" "}
</Box>
);
}
return (
<Table variant="simple">
{" "}
<TableCaption>
{" "}
<Box>
{" "}
<Button
leftIcon={<DownloadIcon />}
colorScheme="blue"
onClick={handleDownloadClick}
>
Download Quote {" "}
</Button>
{" "}
</Box>
{" "}
</TableCaption>
<Thead>
{" "}
<VStack spacing={4} align="start">
{" "}
<Heading as="h1" size="lg">
<strong>{quote.customer} </strong> {" "}
</Heading>
<Text>
<strong>Total Price:</strong> {quote.amount_total}
{" "}
</Text> {" "}
</VStack>
<br /> <br /> <Tr>
<Th>Description</Th> <Th>Price</Th>
<Th>Quantity</Th> <Th>Total</Th> {" "}
</Tr> {" "}
</Thead> <Tbody>
{" "}
{quote.line_items.data.map((item, index) => (
<Tr key={index}>
<Td>{item.description}</Td> {" "}
<Td>
$
{(Number(item.price.unit_amount_decimal) / 100).toFixed(2)}
{" "}
</Td>
<Td>{item.quantity}</Td> {" "}
<Td>
$
{(
(Number(item.price.unit_amount_decimal) / 100) *
item.quantity
).toFixed(2)}
{" "}
</Td>
{" "}
</Tr>
))}
{" "}
</Tbody> {" "}
</Table>
);
};
return (
<>
{" "}
<Container>
{" "}
<HStack spacing={83} align="start" width={"100%"}>
{" "}
<Heading as="h2" size="lg">
Quote Details {" "}
</Heading>
{" "}
</HStack>
{" "}
</Container>
<VStack spacing={4} align="start" width="100%">
{" "}
{quote && (
<Heading as="h1" size="lg">
{quote.header} {" "}
</Heading>
)}
{quote && <Text>{quote.description}</Text>} {displayLineItems()}
{" "}
</VStack> {" "}
</>
);
};
export const getServerSideProps: GetServerSideProps = async (context) => {
const stripe = stripeConfig;
const {
query: { key, id },
} = context; // Replace with a valid quote ID
const isIDValid = isValidStripeQuoteId(id);
if (isIDValid !== true) {
console.log("ID Error");
return {
notFound: true,
};
}
const quote = await stripe.quotes.retrieve(quoteID, {
expand: ["line_items"],
}); // if Key Doesn't match drop request
const quoteKEY = quote.metadata.key;
if (quoteKEY != key) {
return {
notFound: true,
};
}
const customer = (await stripe.customers.retrieve(
quote.customer as string
)) as Stripe.Customer;
if (!customer) {
console.log("Customer not found");
return {
notFound: true,
};
}
const pdf = await stripe.quotes.pdf(quoteID);
if (!pdf) {
console.log("PDF Error");
return {
notFound: true,
};
}
return {
props: {
quote: {
id: quote.id,
customer: customer.name,
header: quote.header,
amount_total: quote.amount_total,
line_items: quote.line_items,
description: quote.description,
pdf: link,
quote: quote,
},
},
};
};
export default QuotePage;
Webhook Handler
Webhooks are functions that are triggered when something happens. Stripe has extensive documentation and functionality built around webhooks. In this case. We will make a webhook and trigger when a quote is finalized. The hook will create the UUID for the quote, save it in its metadata, and send the quote via email and text to the customer.
Stripe is so powerful because just about everything that happens in Stripe can trigger a webhook. Stripe has extensive documentation about its webhooks. The example below is from the example in their docs, with some changes for Next.js.
At the start, we define our Stripe webhook secret and initialize the Stripe client with the version we’re using. We also configure the API to not parse the request body, as we’ll be doing that manually later.
Next, we set up Cross-Origin Resource Sharing (CORS) to allow POST and HEAD requests. This is important for security and to ensure our server can handle requests from different origins.
The handler
function is where the main logic resides. It’s an asynchronous function that takes a request and a response. If the request method is POST, it means we’ve received an event from Stripe.
We then get the raw body of the request as a buffer and the Stripe signature from the headers. The Stripe signature is used to verify that the event was sent by Stripe and not some malicious third party.
Next, we use the stripe.webhooks.constructEvent
function to construct the event. If the signature is valid, this function will return the event. If it’s not valid, it will throw an error. We catch this error and return a 400 status code with an error message.
Once we’ve successfully constructed the event, we handle it based on its type. In this case, we’re handling the “quote.finalized” event. This event is triggered when a quote is finalized in Stripe.
When this event is triggered, we retrieve the quote and the customer from Stripe. We then update the quote with a unique token in the metadata. This token can be used later to retrieve the quote.
We then generate a link to the quote and create a message to send to the customer. This message is sent via both email and text messages. The email is sent using the sendEmailWithDocumentLink
function and the text message is sent using the sendText
function.
Finally, we return a JSON response with the sent message, the sent email, and the updated quote.
This code provides a robust way to handle Stripe events and communicate with customers when a quote is finalized. It uses modern JavaScript features and practices, such as async/await and try/catch, for error handling.
const webhookSecret= "whsec_..."; //this should be in your .env
const stripe = stripeVer;
export const config = {
api: {
bodyParser: false,
},
};
const cors: any = Cors({
allowMethods: ["POST", "HEAD"],
});
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "POST") {
const buf = await buffer(req);
const sig = req.headers["stripe-signature"];
let event: Stripe.Event;
if (!sig) return res.status(400).send("No sig");
try {
event = stripe.webhooks.constructEvent(
buf.toString(),
sig,
webhookSecret
);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Unknown error";
// On error, log and return the error message.
if (err! instanceof Error) console.log(err);
console.log(`❌ Error message: ${errorMessage}`);
res.status(400).send(`Webhook Error: ${errorMessage}`);
return;
}
// Successfully constructed event.
console.log("✅ Success:", event.id);
case "quote.finalized":
try {
const quote = event.data.object as Stripe.Quote;
const quoteHeader =
typeof quote.header === "string" ? quote.header : "you";
const customer = (await stripe.customers.retrieve(
quote.customer as string
)) as Stripe.Customer;
// Check if customer phone number is available
if (!customer.phone) {
console.log("Customer phone number not found");
}
const uniqueToken = uuidv4();
const addKey = await stripe.quotes.update(quote.id, {
metadata: {
key: uniqueToken,
},
});
// Generate the quote link
const link = `${ROOT_URL}/${uniqueToken}/${quote.id.substring(3)}/quote`;
const quoteMessage = `Hello ${customer.name}, we have prepared a customized quote for your ${quoteHeader} project. Please don't hesitate to ask any questions or share your concerns. You can review the quote here: ${link}`;
// Send quote via email
const quoteemail = `<p>Hello ${customer.name},</p>
<p>We have prepared a customized quote for your ${quoteHeader} project. Please don't hesitate to ask any questions or share your concerns. You can review the quote by clicking the button below:</p>
<p><a href="${link}" style="background-color: #4CAF50; border: none; color: white; padding: 10px 20px; text-align: center; text-decoration: none; display: inline-block; font-size: 16px; margin: 4px 2px; cursor: pointer; border-radius: 8px;">View Quote</a></p>
<p>Thank you for considering us for your project.</p>
<p>Best regards,</p>`;
const options = {
to: customer.email as string,
documentType: "quote",
documentId: quote.id as string,
message: quoteMessage,
email: quoteemail,
};
const emailQuote = await sendEmailWithDocumentLink(options, link);
// Send the quote message via text
const sentQuote = await sendText(
quoteMessage,
customer.phone as string
);
return res.json({
message: sentQuote,
email: emailQuote,
updateKey: addKey,
});
} catch (err) {
console.error(err);
const errorMessage =
err instanceof Error ? err.message : "Unknown error";
return res.status(400).send(`Error: ${errorMessage}`);
}
Conclusion
In this blog post, we’ve demonstrated how to leverage APIs from Stripe, Twilio, and Nodemailer to create a powerful business solution. By combining these tools, we can generate quotes, send them via email and SMS, and track when customers open the quotes. This showcases the power of APIs in solving real-world problems and streamlining business processes. Additionally, it highlights the importance of using modern frameworks like Next.js and securing our transactions with unique identifiers like UUID.
With these tools and techniques in hand, you can create even more powerful and innovative solutions for your business using APIs.
P.S. For bonus points. You can use the methods in this post to apply a similar system for sending and tracking invoices. Check out the Stripe docs and see if you can set up a route to deliver and track your invoices.
Happy coding!