Building a Subscription System using Stripe, Node.js, and MongoDB - Part 2: Webhooks

Featured Image
  • Languages: Node, HTML
  • Tools Used: Stripe
  • Time Saved: 3 weeks -> 40 mins
  • Source Code

This is Part 2 in the series of guides on adding, managing, and keeping track of subscription payments using Stripe and Mongo for your SaaS app.

Introduction

Subscription Payments are the bread and butter of a SaaS application; it's how you start generating revenue. In a well-implemented system, users need to first be able to change or cancel their plans, and second, undergo a trial phase to see if they would even like to pay for premium features. Typically, it's a hassle to set these up. In this set of guides, we will go through all the steps required to build a complete Subscription Payments system for your SaaS app.

In Part 2, we will:

  • Save customer billing data in a Mongo database
  • Display an Account Dashboard view
  • Set up the Stripe Webhook to receive events from the Stripe Dashboard
  • Update plan details when a customer pays successfully

Step 1: Start a Local Mongo DB

We need a database. MongoDB is a very good choice. Download the community version here.

In Part 5 of the guide available here, we will use MongoDB Cloud Atlas that lets us host a MongoDB instance in the cloud. Run,

mongod

A local Mongo instance should start up on mongo://localhost:27017

Step 2: Add MongoDB Connection to App

Add the MongoDB connection to app.js:

const mongoose = require("mongoose");

const db = "mongodb://localhost:27017/users";
mongoose.connect(
  db,
  {
    useUnifiedTopology: true,
    useNewUrlParser: true,
    useFindAndModify: false,
    useCreateIndex: true,
  },
  (error) => {
    if (error) console.log(error);
  }
);

Create User Schema

For each user, we will save:

  • email: The email of the user
  • billingID: The Stripe Customer ID
  • plan: The plan type the customer is currently subscribed to: None (default), Basic, or Pro
  • hasTrial: If the customer is currently on a trial
  • endDate: The date when the trial or the plan renews

Step 3: Create the User Model

Create a new file called, src/user/user.model.js:

const mongoose = require("mongoose");
const Schema = mongoose.Schema;

const userSchema = new Schema({
  email: String,
  billingID: String,
  plan: {
    type: String,
    enum: ["none", "basic", "pro"],
    default: "none",
  },
  hasTrial: {
    type: Boolean,
    default: false,
  },
  endDate: {
    type: Date,
    default: null,
  },
});

const userModel = mongoose.model("user", userSchema, "user");

module.exports = userModel;

We will also add a User Service by creating src/user/user.service.js:

const addUser =
  (User) =>
  ({ email, billingID, plan, endDate }) => {
    if (!email || !billingID || !plan) {
      throw new Error(
        "Missing Data. Please provide values for email, billingID, plan"
      );
    }

    const user = new User({
      email,
      billingID,
      plan,
      endDate,
    });
    return user.save();
  };

const getUsers = (User) => () => {
  return User.find({});
};

const getUserByEmail = (User) => async (email) => {
  return await User.findOne({
    email,
  });
};

const getUserByBillingID = (User) => async (billingID) => {
  return await User.findOne({
    billingID,
  });
};

const updatePlan = (User) => (email, plan) => {
  return User.findOneAndUpdate({
    email,
    plan,
  });
};

module.exports = (User) => {
  return {
    addUser: addUser(User),
    getUsers: getUsers(User),
    getUserByEmail: getUserByEmail(User),
    updatePlan: updatePlan(User),
    getUserByBillingID: getUserByBillingID(User),
  };
};

We can now inject the model into the service by adding src/user/index.js

const User = require("./user.model");
const UserService = require("./user.service");

module.exports = UserService(User);

Step 4: Save user sessions

When the user logs in using their email address, in addition to creating a Stripe Customer, we can save their information in the database as well.

Notice that we are now saving the email instead of the customer ID in the user session so we can look up the customer profile in the database when a new request comes in.

In app.js:

const UserService = require("./src/user");

app.post("/login", async function (req, res) {
  const { email } = req.body;
  console.log("email", email);

  let customer = await UserService.getUserByEmail(email);
  let customerInfo = {};

  if (!customer) {
    console.log(`email ${email} does not exist. Making one.`);
    try {
      customerInfo = await Stripe.addNewCustomer(email);

      customer = await UserService.addUser({
        email: customerInfo.email,
        billingID: customerInfo.id,
        hasTrial: false,
        plan: "none",
        endDate: null,
      });

      console.log(
        `A new user signed up and addded to DB. The ID for ${email} is ${JSON.stringify(
          customerInfo
        )}`
      );

      console.log(`User also added to DB. Information from DB: ${customer}`);
    } catch (e) {
      console.log(e);
      res.status(200).json({
        e,
      });
      return;
    }
  }

  req.session.email = email;
  res.redirect("/account");
});

Step 4: UI - Account Dashboard

A customer should be able to view their account information on the front-end when they log in successfully. Since we are saving everything in a database, it's trivial to create such a view.

Let's update our /account endpoint in app.js to get the customer information from the database.

app.get("/account", async function (req, res) {
  const { email } = req.session;
  const customer = await UserService.getUserByEmail(email);

  if (!customer) {
    res.redirect("/");
  } else {
    res.render("account.ejs", {
      customer,
    });
  }
});

We can use EJS to render customer information if they are already subscribed. If they are not, we can present the usual Buy a Plan view. In our views/account.ejs

<html>
  <head> </head>

  <body>
    <div>
      <h1>Account Dashboard</h1>
      <p>Hi <%- customer.email %></p>

      <%if (customer.plan=="none" ) { %>
      <p>You are currently not on any plan. Purchase a subscription below.</p>
      <input type="radio" id="basic" name="product" value="basic" />
      <label for="basic">Basic for $10</label><br />
      <input type="radio" id="pro" name="product" value="pro" />
      <label for="pro">Pro for $12</label><br />
      <button id="checkout-button" type="submit">Buy now</button>
      <% } else{ %>
      <p>You are currently on the <%- customer.plan %> plan %></p>
      <%if (customer.hasTrial) { %>

      <p>Trial active until <%- customer.endDate %></p>
      <% } else{ %>

      <p>Trial inactive. Plan will end on <%- customer.endDate %></p>
      <% } %> <% } %>
    </div>

    <script
      src="<https://code.jquery.com/jquery-3.5.1.slim.min.js>"
      integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj"
      crossorigin="anonymous"
    ></script>
    <script type="text/javascript" src="<https://js.stripe.com/v3/>"></script>
    <script>
      var customer = <%- JSON.stringify(customer) %>;
      console.log(customer);
    </script>
    <script type="text/javascript" src="./js/account.js"></script>
  </body>
  <html></html>
</html>

The view again by itself doesn't look great, so we made a better-styled version and added it to the Github project for you to use. Here is the

HTML

and the

CSS

.

Dashboard for a logged in user For better UX, we should create the checkout session with the customer information pre-filled. Edit

src/js/account.js

to add customer Billing ID and the product the customer is buying.

checkoutButton.click(function () {
  const product = $("input[name='product']:checked").val();

  fetch("/checkout", {
    // ...
    body: JSON.stringify({
      product,
      customerID: customer.billingID,
    }),
  })
    .then((result) => result.json())
    .then(({ sessionId }) =>
      stripe.redirectToCheckout({
        sessionId,
      })
    );
});

Create the session with the Customer ID and the Product ID by editing app.js with,

app.post("/checkout", async (req, res) => {
  const { customerID, product } = req.body;

  const price = productToPriceMap[product.toUpperCase()];
  const session = await Stripe.createCheckoutSession(customerID, price);

  res.send({ sessionId: session.id });
});

Step 5: Configure local webhook

Great! We can now add subscription plans for our customers. The trouble is how and when do we update our customer information in the database.

One solution is to update the information in the /checkout endpoint. This is easy to do but not recommended. Consider a scenario where a customer triggers the checkout flow but errors out. This will wrongly update the database.

A better solution is to wait for Stripe to confirm that a particular plan has been successfully assigned to a customer and then update the database accordingly. Stripe webhooks are perfect for this scenario. Whenever an event occurs on Stripe, a real-time notification is sent to the webhook endpoint. We can subscribe to this endpoint to trigger various functionalities on our application i.e. updating the database.

A few useful events that we can access with Stripe are customer.subscription.created , invoice.paid and more. The customer.subscription.created event is what we're currently interested in.

Download Stripe CLI from the official Stripe website here.

We can set up the webhook locally to capture those events by running,

./stripe login
./stripe listen --forward-to localhost:4242/webhook

Connecting Stripe webhook locally Stripe returns us a signing secret for use locally. We will use the

Webhook Secret

in the next step to listen to events.

Let's add a createWebhook in src/connect/stripe.js with STRIPE_WEBHOOK_SECRET:

const STRIPE_WEBHOOK_SECRET = "wh_xx";

// ..
const createWebhook = (rawBody, sig) => {
  const event = Stripe.webhooks.constructEvent(
    rawBody,
    sig,
    STRIPE_WEBHOOK_SECRET
  );
  return event;
};

module.exports = {
  getCustomerByID,
  addNewCustomer,
  createWebhook,
};

We'll set up a /webhook endpoint that Stripe will send events to. Edit our app.js:

app.use("/webhook", bodyParser.raw({ type: "application/json" }));

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));

app.post("/webhook", async (req, res) => {
  let event;

  try {
    event = Stripe.createWebhook(req.body, req.header("Stripe-Signature"));
  } catch (err) {
    console.log(err);
    return res.sendStatus(400);
  }

  const data = event.data.object;
  console.log(event.type, data);

  res.sendStatus(200);
});

Notice the bodyParser.raw line needs to be placed before the /webhook endpoint, otherwise, Stripe will throw an error. Stripe webhook requires the raw request body to verify the signature.

Start the server by running,

npm start

Go to the Stripe dashboard. A webhook event will be fired when you Add a New Customer. The event will be caught by our application and printed in the terminal.

customer.subscription.created {
    id: 'sub_IahKmOJSUeCJ5b',
    object: 'subscription',
    application_fee_percent: null,
    billing_cycle_anchor: 1608247822,
    billing_thresholds: null,
    cancel_at: null,
    cancel_at_period_end: false,
    canceled_at: null,
    collection_method: 'send_invoice',
    created: 1608247822,
    current_period_end: 1610926222,
    current_period_start: 1608247822,
    customer: 'cus_IV5wklBSZWztsG',
    days_until_due: 30,
    default_payment_method: null,
    default_source: null,
    default_tax_rates: [],
    discount: null,
    ended_at: null,
    items: {
        object: 'list',
        data: [
            [Object]
        ],
        has_more: false,
        total_count: 1,
        url: '/v1/subscription_items?subscription=sub_IahKmOJSUeCJ5b'
    },
    latest_invoice: 'in_1HzVvOKelOFxScryYDXXoBEj',
    livemode: false,
    metadata: {},
    next_pending_invoice_item_invoice: null,
    pause_collection: null,
    pending_invoice_item_interval: null,
    pending_setup_intent: null,
    pending_update: null,
    plan: {
        id: 'price_1Hu1OhKelOFxScryyLwJXiWV',
        object: 'plan',
        active: true,
        aggregate_usage: null,
        amount: 1200,
        amount_decimal: '1200',
        billing_scheme: 'per_unit',
        created: 1606938835,
        currency: 'cad',
        interval: 'month',
        interval_count: 1,
        livemode: false,
        metadata: {},
        nickname: null,
        product: 'prod_IV1RHfJZ3sQ8GM',
        tiers_mode: null,
        transform_usage: null,
        trial_period_days: null,
        usage_type: 'licensed'
    },
    quantity: 1,
    schedule: null,
    start_date: 1608247822,
    status: 'active',
    transfer_data: null,
    trial_end: null,
    trial_start: null
}

Step 6: Update customer plan

When the customer buys a new subscription, we will see a customer.subscription.created. We can catch this event and use the attached information to update the customer plan in the database.

app.post("/webhook", async (req, res) => {
  let event;

  try {
    event = Stripe.createWebhook(req.body, req.header("Stripe-Signature"));
  } catch (err) {
    console.log(err);
    return res.sendStatus(400);
  }

  const data = event.data.object;
  console.log(event.type, data);

  switch (event.type) {
    case "customer.subscription.created": {
      const user = await UserService.getUserByBillingID(data.customer);

      if (data.plan.id === productToPriceMap.BASIC) {
        user.plan = "basic";
      }

      if (data.plan.id === productToPriceMap.PRO) {
        user.plan = "pro";
      }

      user.hasTrial = true;
      user.endDate = new Date(data.current_period_end * 1000);
      await user.save();

      break;
    }
    default:
  }

  res.sendStatus(200);
});

Awesome! Now our database is in sync with the actual customer billing information.

We also need to check if the trial should be over. We don't have to check it asynchronously in the background with a cron process, that would be overkill.

A better way to do it would be to check if the trial should stay active or not when the customer logs in.

In app.js:

app.post("/login", async function (req, res) {
  const { email } = req.body;
  console.log("email", email);

  let customer = await UserService.getUserByEmail(email);
  let customerInfo = {};

  if (!customer) {
    // ..
  } else {
    const isTrialExpired =
      customer.plan !== "none" && customer.endDate < new Date().getTime();

    if (isTrialExpired) {
      customer.hasTrial = false;
      customer.save();
    } else {
      console.log(
        "no trial information",
        customer.hasTrial,
        customer.plan !== "none",
        customer.endDate < new Date().getTime()
      );
    }
  }
  req.session.email = email;
  res.send(customerInfo);
});

Done!

Next Steps

In this guide we learned how to:

  • Save customer data in a Mongo database
  • Set up the Stripe Webhook to receive events from the Stripe Dashboard
  • Update plan details when customer payment goes through

Our customers can now buy subscriptions but they cannot cancel or update their plans yet. We will look into implementing this in the next guide.

I'm building a new SaaS to automate content marketing for your SaaS

Check it out →

Tools for SaaS Devs