How to create a habit tracker with Twilio Functions and Notion
Notion is a productivity tool that lets you create documents and build systems such as to-do lists and project management boards. It allows you to create databases, which makes it a good choice for building a habit tracker.
However, it can be hard to interact with Notion databases quickly, especially through their mobile app. One solution is to host a habit tracker in Notion and interact with it via SMS.
In this post, you’ll learn how to build a habit tracker with Twilio Functions and the Notion JavaScript SDK. You’ll mark habits as complete, add new habits, and get a daily summary of your habits by texting a Twilio phone number. Here’s what the finished project will look like:
Prerequisites
Before you begin this tutorial, you will need:
- A Twilio account – sign up for a free Twilio account using this link and get a $10 credit when you upgrade
- A Twilio Phone Number
- A Notion account and prior experience using Notion databases
- The Twilio CLI and Twilio Serverless Toolkit installed on your machine
- Node.js v14 or later installed on your machine
- A personal phone number
- A code editor
You should also have some prior experience with JavaScript. In particular, you’ll see async functions, Promises, and ECMAScript 5 array methods in the project code.
You can view the finished project’s source code on GitHub.
Project overview
This project uses a Notion database to track habits and accesses it via Notion’s JavaScript SDK. It also uses Twilio Functions to receive incoming SMS messages and TwiML to respond to messages.
The habit tracker accepts three commands via SMS:
log <habit name>
: marks a habit as complete for today’s dateadd <habit name>
: adds a new habit to the databasesummary
: lists all habits and indicates which ones have been completed today
Below is an overview of the different services you’ll be using in this tutorial and how they fit into the project. Feel free to skip past this section if you’re already familiar with them.
Notion SDK
The Notion SDK is a JavaScript client for the Notion API. It provides a simple way of interacting with your Notion workspace and lets you call any of Notion’s API endpoints. In this tutorial, you’ll use the SDK for common actions such as querying a database, creating a page, and updating a database’s properties.
Twilio Functions and TwiML
Twilio Functions is a serverless environment for Node.js programs and it allows you to quickly host autoscaling and secure code. You’ll get up and running with Twilio Functions using the Twilio CLI and the Serverless Toolkit which allows you to develop and deploy Functions locally. A Function is a JavaScript function that gets invoked in response to an HTTP request.
Note: To avoid confusion in this blog post, I will always capitalize Functions when referring to Twilio Functions and lowercase functions when referring to JavaScript functions.
Your Twilio Phone Number will get configured to call a Function as a webhook when it receives a message; the Function will then parse the command and call the Notion SDK to modify the habit tracker database appropriately.
Lastly, the Function will send a response with TwiML (the Twilio Markup Language). TwiML is a set of XML instructions that tells Twilio how to handle incoming SMS messages and voice calls. You’ll use the Node.js helper library that comes with functions to create the TwiML.
Note: You can learn more about TwiML for Programmable Messaging in the Programmable Messaging documentation.
Now that you have an understanding of the project and the tools you’ll be using, it’s time to get started!
Create the habit tracker database in Notion
Instead of creating a habit tracker database from scratch, you’ll start from Notion’s habit tracker template.
First, ensure that you’re logged into Notion, then click Duplicate template on the template page to create a habit tracker database in your Notion workspace.
After duplicating the template, you should see a page with the database:
This is a fairly simple database: each date has one entry identified by the Name and Date properties. Habits are represented by checkbox properties; if a habit is checked, then it’s been completed for that day, otherwise, it’s incomplete.
Next, delete all the rows to clean things up. You can select all the rows with the cmd/ctrl + a
shortcut and delete them with backspace
. This should leave you with an empty database:
For simplicity, you’ll modify the habits so that you’re left with three: Exercise, Meditate, and Journal. Delete the ✍🏼Journaling and 📱Screen Time (minutes) properties by clicking their name and then Delete property in the menu that appears:
You can rename the remaining three properties by clicking the other property names and editing the title in the same menu. Rename the properties to remove the emojis.
Your cleaned-up database should now look like this:
While you’re on this page, you’ll want to note the database ID, which you’ll need when accessing the database with the SDK. If you’re not using Notion in a browser, click Share in the upper-right of the page, click Copy link, then go to the address in your browser.
The 32-character database ID is the part of the URL before the start of any query parameters (if you have a workspace name configured, you’ll see that in the URL before the database ID): https://www.notion.so/<workspace name if configured>/<database ID>
.
Create the Twilio Serverless project
With your database set up, it’s time to create your local Twilio Serverless project with the Twilio CLI and Serverless Toolkit. Run the following command to scaffold a project with the name habit-tracker
:
twilio serverless:init habit-tracker && cd habit-tracker
serverless:init
creates a file structure that the Serverless Toolkit requires, configuration files, template Functions, and other files needed for your Function. Open the habit-tracker
directory in your code editor and observe the following structure (some files omitted for brevity):
assets/ functions/ ├─ sms/ │ ├─ reply.protected.js node_modules/ .env .gitignore .nvmrc .twilioserverlessrc package-lock.json package.json
The project items that this tutorial is most concerned with are assets, functions, and .env.
assets
This directory contains static files, such as HTML files and images, that are not functions but should be accessible to Functions. For this project, you’ll add JavaScript modules to this directory that will be required
ed in a Function. You can learn more about assets in the documentation.
.env
Environment variables accessible to Functions at runtime are added to the .env file. You can learn more about the behavior of certain environment variables in the documentation.
functions
The functions directory contains Functions – JavaScript files that handle incoming HTTP requests.
The path to each function is determined by the directory structure: for example, the reply.protected.js function is available at /sms/reply
.
Note: The protected.js
extension: this means that the Function can only be called by Twilio. A Function with a regular .js
extension is publicly accessible.
An asset can also have a private.js
extension, which makes it a private asset. Private assets are only accessible by Functions; they cannot be accessed publicly
You can learn about the visibility of Functions and Assets in the documentation.
Each Function file exports a handler
function which gets called when the Function receives a request. The handler receives three arguments: context
, event
, and callback
.
context
The context
object contains information about the current execution environment such as environment variables stored in the .env file.
event
This argument has the HTTP GET or POST parameters passed to the Function. This project will use the Function as an SMS webhook, so in this case, event
also contains data such as the sender’s phone number and the text content of the SMS.
callback
The callback
function must be called when your code is finished to terminate the execution of the function and emit responses.
You can learn more about the handler function in the documentation.
Set up the Notion SDK
Before you can access Notion through the SDK, you need to create a Notion integration and install the JavaScript client locally.
A Notion integration gives your program access to certain capabilities within your workspace. To access pages with the API, pages must be shared with the integration (similar to how you would share a page with another email address).
In this case, you’ll share the habit tracker database that you created earlier with your integration. You’ll then use the integration’s Internal Integration Token with the SDK to authorize requests.
Note: You can learn more about Notion’s authorization in their documentation.
Create the Notion integration
To create an integration for your habit tracker:
- Navigate to your Notion Integrations
- Click New integration
- Enter “Habit Tracker” in the Name field
- Ensure the appropriate workspace is selected for Associated workspace
- For Content Capabilities, ensure Read content, Update content, and Insert content are selected
- Click Submit
This should create your integration and take you to the integrations detail page:
Next, you’ll need to share your habit tracker with your integration. Navigate back to your database and:
- Click Share in the upper right corner
- In the text field, search for your “Habit Tracker” integration and click it
- Click Invite
You should now see your integration appear below:
Set up the Notion SDK
Now you’ll need to install the Notion SDK package. In the root of your project directory, run the following command:
npm install @notionhq/client --save
Lastly, add your integration’s Internal Integration Token and database ID to your .env file:
NOTION_AUTH_TOKEN=<Your Internal Integration Token> NOTION_DATABASE_ID=<Your database ID>
Set up the SMS webhook Function
Next, you’ll add the initial code to your Function; this is the code that responds to an incoming text message. Remove the boilerplate code from functions/sms/reply.protected.js and add the following:
const { Client: NotionClient } = require('@notionhq/client');
exports.handler = async function (context, event, callback) {
const notionClient = new NotionClient({ auth: context.NOTION_AUTH_TOKEN });
const databaseId = context.NOTION_DATABASE_ID;
const twiml = new Twilio.twiml.MessagingResponse();
const message = event.Body.trim();
const parts = message.split(' ');
const command = parts[0].toLowerCase();
let response;
switch (command) {
case 'log':
const habit = capitalize(parts[1]);
// TODO: Log habit in DB
response = `Logged ${habit}. Way to go!`;
break;
case 'add':
const newHabit = capitalize(parts[1]);
// TODO: Add habit to DB
response = `Added new habit ${newHabit}`;
break;
case 'summary':
// TODO: Get summary from DB
const summary = '';
response = summary;
break;
default:
response = 'Unknown command.';
}
twiml.message(response);
callback(null, twiml);
};
function capitalize(string) {
return string.charAt(0).toUpperCase() + string.slice(1).toLowerCase();
}
Note that the code has TODO
's where you’ll access Notion.
The first thing this code does is import the Notion SDK client. Then, within the handler
function, it initializes the NotionClient
with the NOTION_AUTH_TOKEN
environment variable you added in the previous section. The next lines set up the MessagingResponse
object to build the TwiML response and get the command from the incoming message.
Recall that the habit tracker accepts three commands: log <habit name>
, add <habit name>
, and summary
. The switch
handles each command, gets the habit name when appropriate, and sets the response
text.
Finally, the handler
function sets the TwiML response and terminates the execution of the Function by calling the callback
.
Implement the Notion helper functions
Next, you’ll implement the helper functions that the main webhook Function calls in response to a command; these get called where the TODO
's are now. Each helper function will correspond to one of our three commands:
logHabit
will log a habit in response tolog <habit name>
addHabit
will add a habit in response toadd <habit name>
summary
will return a summary of the current day’s habits in response tosummary
First, create a file called notion.private.js in the assets folder for these functions.
Implement logHabit
Add the following code to assets/notion.private.js:
async function logHabit(client, databaseId, habit) {
const response = await client.databases.query({
database_id: databaseId,
filter: {
property: 'Date',
date: {
equals: getTodayAsIsoDate(),
},
},
});
const hasPageForToday = response.results.length === 1;
// Updates the existing page for today's date if it exists, otherwise creates a new page
if (hasPageForToday) {
const page = response.results[0];
await client.pages.update({
page_id: page.id,
properties: {
[habit]: {
checkbox: true,
},
},
});
} else {
const currentDayOfWeek = new Intl.DateTimeFormat('en-US', {
weekday: 'long',
}).format(new Date());
await client.pages.create({
parent: {
type: 'database_id',
database_id: databaseId,
},
properties: {
Name: {
title: [
{
text: {
content: currentDayOfWeek,
},
},
],
},
Date: {
date: {
start: getTodayAsIsoDate(),
},
},
[habit]: {
checkbox: true,
},
},
});
}
}
function getTodayAsIsoDate() {
const now = new Date();
const localizedDate = new Date(
now.getTime() - now.getTimezoneOffset() * 60000
);
return localizedDate.toISOString().split('T')[0];
}
module.exports = {
logHabit,
};
This code first queries the database with client.database.query
for a page whose Date
property is equal to the current day. If there is a page for the current day, it calls client.pages.update
and marks the page’s checkbox property as true for the habit
that is passed in.
If there isn’t a page for the current day, the function creates a new page with client.pages.create
and sets its Name
property (i.e., the page’s title) to the day of the week (e.g., “Friday”). It also sets the Date
property to the current date and toggles the checkbox property for the passed in habit
.
Note that the caller of this function will pass in the Notion client
, databaseId
, and habit
. The first two parameters come from the Function’s environment variables and the habit
is retrieved from the message body.
Implement addHabit
After your logHabit
implementation, add this addHabit
function:
// ...
async function addHabit(client, databaseId, habit) {
await client.databases.update({
database_id: databaseId,
properties: {
[habit]: {
checkbox: {},
},
},
});
}
Then, add addHabit
to the exports:
module.exports = {
logHabit,
addHabit,
};
This code calls client.databases.update
to add a new checkbox property whose name is the value of habit
to the database. Note that you don’t need to specify the other existing properties.
Implement summary
Finally, add the following code after the addHabit
function:
async function getDailySummary(client, databaseId) {
const pageForTodayRequest = client.databases.query({
database_id: databaseId,
filter: {
property: 'Date',
date: {
equals: getTodayAsIsoDate(),
},
},
});
const databaseRequest = client.databases.retrieve({
database_id: databaseId,
});
const [pageForTodayResponse, databaseResponse] = await Promise.all([
pageForTodayRequest,
databaseRequest,
]);
// Map habit property IDs to their names for easy lookup
const propertyIdToNameMap = Object.entries(databaseResponse.properties)
.filter(([name]) => name !== 'Name' && name !== 'Date')
.reduce((acc, curr) => ({ ...acc, [curr[1].id]: curr[0] }), {});
const hasPageForToday = pageForTodayResponse.results.length === 1;
// If there's no page for today's date, return false for all habit properties
if (!hasPageForToday) {
const propertyNames = Object.values(propertyIdToNameMap);
return propertyNames.reduce(
(acc, curr) => ({
...acc,
[curr]: false,
}),
{}
);
}
// Otherwise, get all habit property data and create summary object
const page = pageForTodayResponse.results[0];
const propertyIds = Object.keys(propertyIdToNameMap);
const propertyRequests = propertyIds.map((id) =>
client.pages.properties.retrieve({
page_id: page.id,
property_id: id,
})
);
const propertyResponses = await Promise.all(propertyRequests);
const stats = propertyResponses.reduce(
(acc, curr) => ({
...acc,
[propertyIdToNameMap[curr.id]]: curr.checkbox,
}),
{}
);
return stats;
}
Then, add getDailySummary
to the exports:
module.exports = {
logHabit,
addHabit,
getDailySummary,
};
This function returns a habit summary for the current date. It returns an object whose property names are the habit tracker’s habits and whose values are true
or false
.
It first queries the database for an entry for the current date and retrieves the database with client.databases.retrieve
. The databaseResponse
is used to map habit property IDs to their names for easy lookup later on.
If there’s no entry for today, it returns early and creates a summary object with false
for each habit. If a page for today exists, it gets the habit property information with client.pages.properties.retrieve
and constructs and returns the summary object.
Complete the webhook Function implementation
Now that you’ve implemented the Notion helper functions, it’s time to wire everything together and call them in the Function handler. Replace the TODO
comments with the appropriate functions in the switch
statement:
// ...
const assets = Runtime.getAssets();
// notion.private.js is renamed to notion.js by the runtime
const { logHabit, addHabit, getDailySummary } = require(assets['/notion.js']
.path);
exports.handler = async function (context, event, callback) {
// ...
switch (command) {
case 'log':
const habit = capitalize(parts[1]);
await logHabit(notionClient, databaseId, habit);
response = `Logged ${habit}. Way to go!`;
break;
case 'add':
const newHabit = capitalize(parts[1]);
await addHabit(notionClient, databaseId, newHabit);
response = `Added new habit ${newHabit}`;
break;
case 'summary':
const stats = await getDailySummary(notionClient, databaseId);
const summary = Object.entries(stats)
.map(
([name, completed]) => `${completed ? '✅' : '❌'} ${name}`
)
.join('\n');
response = summary;
break;
default:
response = 'Unknown command.';
}
// ...
};
// ...
Recall that the private.js extension makes assets/notion.private.js a private asset. The runtime will rename private assets, so you need to require
the path provided by Runtime.getAssets()
instead. You can learn more about importing private assets in the documentation.
Set up the SMS webhook to call the Function
With the Function fully implemented, it’s time to set up your Twilio Phone Number’s webhook to call the Function when a text message is received.
The Serverless Toolkit comes with ngrok, which you’ll use to make your local Function server publicly available to the Internet. Run the following command to start the Function locally with an ngrok connection:
twilio serverless:start --ngrok=""
Once the server starts, copy the ngrok URL for the /sms/reply route listed under Twilio functions available.
Next, you’ll specify the ngrok URL as the webhook URL for your Twilio Phone Number. In the Twilio Console, navigate to your phone number:
- Click Phone Numbers under the Develop tab in the left navigation (if this isn’t available to you in the navigation, you can go to the Numbers page using this link)
- Click Manage
- Click Active numbers and select your number
- Scroll down to the Messaging header
Under A MESSAGE COMES IN, select Webhook, enter the URL, and click Save:
Test the habit tracker locally
That’s it; you’re ready to go! Try texting your Twilio Phone Number with the following commands:
log exercise
add read
summary
You should see the Notion database updated in real-time in response to the log
and add
commands.
Next steps
With your habit tracker working locally, a good next step is deploying your code to Twilio Functions. You can learn how to deploy your Twilio Function at Twilio’s documentation.
A great way to learn is to take a project and modify it or add more features. Here are a few ideas of ways you can extend this project:
- Allow multi-word habits
- Respond with a random encouraging message when a habit is logged (e.g., “Way to go!”, “Keep it up!”)
- Add a command to retrieve a weekly habit summary
- Send a reminder to complete a habit if it hasn’t been completed by a certain time each day
- Send a habit summary at the end of the day or a time codified in Notion
- Allow habit tracking at irregular intervals (e.g., habit x is completed daily and habit y is completed weekly)
- Reply with a habit’s streak when it’s logged (e.g., “You’re on an x day streak!”)
- Add better error handling
Conclusion
Congrats – you’ve built a habit tracker with Twilio Functions and Notion! I hope you’ll use this project to build up a habit or reach a goal. Here’s a summary of everything you learned:
- How to create a Twilio Function with the Twilio CLI and Serverless Toolkit
- How to use the Notion SDK to query, retrieve, create, and update a database; create and update a page, and retrieve a database’s properties
- How to receive and respond to incoming SMS messages locally with Twilio Functions and TwiML
Zach is a software engineer based in Washington State. He works with JavaScript and React and enjoys writing about code on his blog. You can get in touch with him on LinkedIn, Twitter, or at me[at]zachsnoek.com.
This post was originally published on Twilio.
Let's connect
Come connect with me on LinkedIn, Twitter, and GitHub!
If you found this post helpful, please consider supporting my work financially:
☕️Buy me a coffee!