Monday, 28 August, 2023 UTC


Keeping track of the dates of all your subscriptions can be a hassle. To avoid ending up in a situation where a subscription takes me by surprise, I built a notification system that alerts me about upcoming subscriptions by phone.
In this post, you will learn how you can build something similar by using Twilio Programmable Voice, Notion databases and GitHub Actions.
There are a couple of housekeeping items you will need to take care of before you can dive right into the article.
  • First, you need a Twilio account. Sign up for a free trial if you do not have one yet.
  • You also need a Notion account. You can get one here for free.
  • A GitHub account and Git command line client
  • Node.JS installation.
Organizing your subscriptions and building a personalized notification system
Why Twilio Voice? Email notifications can be buried especially when you receive a lot of emails and are easier to miss. SMS messages can also be ignored. A phone call on the other hand is difficult to ignore. Now, what you do after getting the phone call is entirely up to you. However, it would certainly be nice to get a heads up for upcoming payments.

What you will be building.

You will be building a personalized notifier that calls you to alert you of upcoming payments based on the criteria you have chosen. The application will be built using Node.js which will run with GitHub actions. You will be using Notion as a store and data source for your subscriptions. Using a cron-job, you will query your Notion subscriptions database to check for upcoming subscriptions. When found, your script will make an outbound call to your phone number using our Voice API to alert you of the upcoming subscriptions.

Buy a Twilio phone number

If you have not done so yet, follow the guide presented when you create your account to get a Twilio number or purchase one from the Buy a number page.

Set up your ‘database’ and create a Notion integration.

As mentioned above, you will be storing your subscriptions data in a Notion database. You want to start by duplicating the Subscription Tracker template from Notion templates gallery into your workspace; you can do this by clicking on the Start with this template button from the page. Doing this will create a page called Subscription Tracker in your workspace with an accompanying database or table with some dummy data. Feel free to update the data as needed.

Create an Integration

A Notion integration is like a programmatic connection to your data in your Notion workspace that leverages the Notion API.
You need to be the admin of a Notion workspace to create an integration. If you created a personal Notion account, the chances are you are already an admin. You need to create an integration to use the Notion API. Here are the steps you need to follow to create one.
Visit the Integrations page.
  • Click the "+ New integration" button.
  • Provide a name for your integration - You can call it "(Personal) Subscriptions Tracker".
  • Select the workspace where you want to install this integration (if you have multiple workspaces).
  • Set the 'Integration type' to 'Internal' since this is an application for personal use.
  • Copy the "Internal Integration Secret" on the next page and store it somewhere secure.

Share Your Database with Your Integration

When you first create an integration, it does not have access to any of the databases in your workspace. You have to share that particular database with your integration. You can do that by following the steps below:
  • Navigate to your newly created Subscription Tracker page. Make sure it is open as a full page.
  • Click on the kebab menu ... button at the top right hand corner of the Notion web app.
  • Click on + Add connections in the Connections section of the submenu
  • Search for (Personal) Subscriptions Tracker in the list of integrations and select that option. If you don't see it as an option, refresh the page and check again.
  • Click on Confirm in the modal that appears.

Set up your application folder and environment variables

Using your terminal, create an empty directory called voice-subscriptions-tracker and change your working directory to that folder.
mkdir voice-subscriptions-tracker cd voice-subscriptions-tracker 
Next, you’ll want to initialize your node project with the following command:
npm init -y 
Now, open your new node project in your favorite code editor and create two new files called .env. In the .env file, add your API keys and environment variables as shown below:
NOTION_AUTH_TOKEN={your-internal-integration-token-from-the-previous-step} PHONE_NUMBER_FOR_REMINDERS={the-phone-number-where-you-want-to-get-personal-reminders} TWILIO_ACCOUNT_SID={your-twilio-account-sid} TWILIO_AUTH_TOKEN={your-twilio-auth-token} TWILIO_PHONE_NUMBER={your-twilio-phone-number} 

Install project dependencies

The core project dependencies for this application are the Twilio module for Node and Notion JavaScript SDK. You will also need to install dotenv to retrieve your environment variables. To install these packages run the following command in your terminal:
npm i twilio @notionhq/client dotenv 
On completion of the installation process, create a file called index.js and add the following to it
const dotenv = require("dotenv") dotenv.config() console.log(process.env.NOTION_AUTH_TOKEN) 
Run the index.js file.
node index.js 
You want to verify that our environment variables are loaded correctly. You should see your Notion API Key logged to the console.

Query your notion database.

The Notion SDK installed earlier provides a client for interacting with the Notion API endpoints you will be using. You need to set it up within your code to use it as shown below:
const dotenv = require("dotenv") const { Client } = require("@notionhq/client"); dotenv.config() const notionClient = new Client({ auth: process.env.NOTION_AUTH_TOKEN }); 
The variable notionClient is an instance of the Client class from notion. This client is what you will use to interact with your database within your application.
To get the list of subscriptions from your database, you need to make a query to the database query endpoint. This endpoint allows you to retrieve information for a particular database using the database id. To retrieve your Notion database id, head over to the Subscriptions Tracker page you duplicated. Copy the part of the URL that is between the slash following the workspace name (if applicable) and the question mark and save it to your .env file like so:
If you need additional help finding the database id, check out this page on the Notion docs: Retrieve a database.
Because you are using the client from the Notion SDK, the logic for making the API call has been abstracted away and you have this relatively convenient wrapper to use. You want to start by adding the following code to the index.js file.
(async () => { const response = await notionClient.databases.query({ database_id: process.env.NOTION_DATABASE_ID }); console.log(response); })() 
The code above is an Immediately Invoked Function Expression IIFE which when run stores the result of the resolved promise from the databases.query call in a variable called response. The databases.query method from the notionClient instance receives a JavaScript object as a parameter. In that object you can specify things like the id of the particular database you want to retrieve as well as filters to drill down to get specific items from the database.
Run node index.js on your command line. You should see an object with a structure similar to this:
{ object: 'list', results: [ { object: 'page', id: 'da7015d4-bef3-4823-a8c8-7913e32236d6', created_time: '2023-06-10T03:34:00.000Z', last_edited_time: '2023-06-10T03:59:00.000Z', created_by: [Object], last_edited_by: [Object], cover: null, icon: null, parent: [Object], archived: false, properties: [Object], url: '', public_url: null }, { object: 'page', id: '41e8b5e6-9687-4816-a407-9d468bfc8da4', created_time: '2023-06-09T23:28:00.000Z', last_edited_time: '2023-06-10T03:58:00.000Z', created_by: [Object], last_edited_by: [Object], cover: null, icon: null, parent: [Object], archived: false, properties: [Object], url: '', public_url: null } ], next_cursor: null, has_more: false, type: 'page', page: {} } 
The results key is an array of objects containing the pages in the table that hold each subscription information.

Apply filters to extract subscriptions that are due the next day

You can apply filters to refine the results of your query. You can do that by adding the filters key to the options object you passed to the Notion client method. The Subscription Tracker database has a column called Renewal Date. You are going to modify your query to filter based on that column. Update the async function you added to the following:
(async () => { const notifyMeOn = new Date() notifyMeOn.setDate(notifyMeOn.getDate() + 1) notifyMeOn.toISOString().split('T')[0] const response = await notionClient.databases.query({ database_id: process.env.NOTION_DATABASE_ID, filter: { property: "Renewal Date", date: { before: notifyMeOn, }, }, }) console.log(response) })() 
The Renewal Date column or property is a Date column. The code above checks to see if Renewal Date is before notifyMeOn;notifyMeOn is a variable that stores the date in string for the next day. Depending on the information in your database, your response should contain fewer items. If it returns an empty array in the results property, add a test subscription that is due the next day to verify that the function works as expected.
You might notice that each object in the results array does not have any of the fields or column names in the Subscription Tracker database. The reason can be found by examining the keys in one of the objects closely. The object key for each item in the results array is called page. Each of the objects in the results array is a page corresponding to individual rows in the Subscription Tracker database.
You need to extract the additional details or 'properties' for each page by using the Retrieve a page property endpoint. To do that create a new function called _extractSubscriptionDetails:
const _extractSubscriptionDetails = async (filteredSubscriptionsArray) => { let details if(filteredSubscriptionsArray) { const neededPageAndPropertiesIds= => ({ pageId:, renewalDatePropertyId:['Renewal Date'].id, namePropertyId:['Name'].id, billingPropertyId:['Billing'].id, costsPropertyId:['Costs'].id })) const promises = (pageAndPropertiesIdsObj) => { const { pageId, renewalDatePropertyId, namePropertyId, billingPropertyId, costsPropertyId } = pageAndPropertiesIdsObj; const renewalDatePropertyDetails = await{ page_id: pageId, property_id: renewalDatePropertyId }) const namePropertyDetails = await{ page_id: pageId, property_id: namePropertyId }) const billingPropertyDetails = await{ page_id: pageId, property_id: billingPropertyId }) const costsPropertyDetails = await{ page_id: pageId, property_id: costsPropertyId }) return { renewalDate:, cost: costsPropertyDetails.number, billingType:, subscription: namePropertyDetails.results[0]?.title.text.content } }) details = await Promise.all(promises) } else { throw new Error('You did not provide any array to extract data from.') } return details } 
The _extractSubscriptionDetails function is an async function that receives the filtered subscription results from the response we get. If that parameter is null, you want to throw an error and not proceed any further. In a subsequent section, more details on error handling will be addressed.
Pages and properties have ids associated with them. First, you loop through each subscriptionObject in filteredSubscriptionsArray. Then return an object with pageId, renewalDatePropertyId, namePropertyId, billingPropertyId, and costsPropertyId key corresponding to the page id and the ids for the Renewal Date, Name, Billing and Costs' properties respectively for each subscriptionObject.
Next, the code loops through the array returned and for each set of ids, calls the method. This method is a wrapper provided by the client that calls the 'Retrieve a page property' endpoint. It returns an array of promises called promises in the function. Promise.all aggregates the result of these promises which when yielded is stored in the details variable.
The next step is to update your IIFE function to use this method.
(async () => { const notifyMeOn = new Date() notifyMeOn.setDate(notifyMeOn.getDate() + 1) notifyMeOn.toISOString() const response = await notionClient.databases.query({ database_id: process.env.NOTION_DATABASE_ID, filter: { property: "Renewal Date", date: { before: notifyMeOn, }, }, }) const details = await _extractSubscriptionDetails(response.results); console.log(details) })() 
On re-running node index.js, you will see an array of objects showing the subscription, renewal date, billing type and cost of each subscription that matches your filter criteria in your console.

Handling errors

It is necessary to catch and manage any errors that the client throws for a more robust system. The Notion client rejects with a APIResponseError object. You can add a try-catch block at the top level of your application to catch any errors that happen when you query the database.
Here’s how:
(async () => { //... try { const response = await notionClient.databases.query({ database_id: process.env.NOTION_DATABASE_ID, filter: { property: "Renewal Date", date: { before: notifyMeOn, }, }, }) const details = await _extractSubscriptionDetails(response.results); console.log(details) } catch (error) { console.log(error) } })() 
When an error occurs on the client end, the error object contains different properties from the API response, one of which is the code. If the error is an instance of the class APIResponseError, it would be great to log a message indicating that it is from the API. Otherwise, log the error message.
const { Client, APIResponseError } = require("@notionhq/client") (async () => { try { //... } catch (error) { if (error instanceof APIResponseError) { console.error("Unable to fetch items from database. An error was raised by the API client.") console.error("Error code: " + error.code) console.error(error.message) } else { console.error(error.message) } } })() 
You can try this out by changing your database id to some made up value. You will get a warning message from the client as well as the specified messages on the console.

Sending phone call reminders

By now, you should have the details of the subscriptions that are due the next day, you can implement the logic to make a phone call to your phone as a reminder.
First, you want to add the Twilio SDK to the list of imports at the top of index.js while adding in your account SID and Auth Token:
const twilio = require('twilio')(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN); 
The twilio client provides the interface for making calls with the Twilio SDK. When making an outgoing call, you would typically have a to number which is the external number you want to call. In this case, it is your personal phone number. You would also have the number that makes that phone call which in this case is your Twilio number. Lastly, you want a message to be read out to you as the recipient of the phone call. That is where Twilio’s TwiML or Twilio Markup Language comes in handy. TwiML is the format in which you will write instructions that should happen when you pick up a phone call from your Twilio phone number.
Therefore, the next step you need to take is to generate this message. You are going to create another function called _generateTwiMLInstructions in your index.js file.
const _generateTwiMLInstructions = (details) => { let message = `<Response><Say>You have ${ details.length } upcoming subscription payments tomorrow.` details.forEach((detail, index) => { message = `${ message } ${ index+1 }, ${ detail.billingType } ${ detail.subscription } online subscription payment of ${ detail.cost }.` }) message = `${ message } </Say></Response>` return message } 
Next, within the try block of your IIFE function, after the details variable, add the following code:
if(details.length > 0) { const message = _generateTwiMLInstructions(details) twilio.calls.create({ twiml: message, to: process.env.PHONE_NUMBER_FOR_REMINDERS, from: process.env.TWILIO_PHONE_NUMBER }) .then(call => console.log(call.sid)) .catch((error) => { console.error('An error occurred from the Twilio client.' + error) }) } 
If the details array is not empty, generate a message and use the twilio.calls.create method to make a call. That method receives an object with the message you generated, your phone number and your Twilio number. If the call to that method fails, log the error to the console.
Run node index.js in your terminal. You should get an incoming call from your Twilio phone number. If you are on a trial account, a preliminary message gets read out before you can receive your notification.
Now, you can get a phone call by running the index.js file. However, that is not very exciting and it sort of defeats the purpose of this exercise.
In the next section, you will learn how to put in place the final piece of this project - automation using GitHub Actions.

Automating the flow with GitHub Actions

To automate your current flow with GitHub Actions, you are going to create a GitHub workflow.
  • At the root of your project, create a folder called .github.
  • Within that folder create another folder called workflows.
  • Create a file called run-subscription-tracker.yml
  • Add the following code to the file you created
name: Check for subscriptions due the next day and notify me flow. on: schedule: - cron: '0 0 * * *' workflow_dispatch: jobs: check_and_notify: name: Check for upcoming subscriptions and remind me by phone runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: Set up Node uses: actions/setup-node@v3 with: node-version: 16.15.1 - name: Install dependencies run: yarn install - name: Run index.js run: node index.js 
The code above is a configuration on how the workflow should run. The name property is an identifier for the workflow. This is what you will see in the GitHub web interface when you click on the Actions tab for your repository. The next line specifies when the flow should run. GitHub Actions can be triggered by push or pull request events as well as cron expressions. The cron expression in the code specifies that the workflow should run 12 am each day. You can modify the cron expression to whatever you’re comfortable with. Keep in mind that GitHub servers use the UTC time zone and that could cause some discrepancy between the time the call goes out and the time at which you might expect to receive your phone call.
Under the jobs property, there is a list of tasks for this workflow:
  1. Checkout the repository.
  2. Set up a Node environment in the runner with version 16.15.1.
  3. Then, install the dependencies
  4. Finally, run the index.js file.
The next thing you want to do is publish your project to GitHub. Initialize a git repository, stage and commit your file changes and push them to GitHub.
Click on the Actions tab of your newly created GitHub repository. You might notice that nothing is happening. The first time this workflow would run is 12am. However, there is a property in the configuration file above that allows you to manually run this flow at any time from the GitHub web interface.

Setting Environment Variables for the workflow with GitHub Secrets

Your application makes use of several API keys, tokens and secrets which should be kept safe. You do not want to commit them to GitHub. Hence they were added to the .env file at the beginning of this tutorial. However, your actions on GitHub do not have access to these environment variables.
How then do you ensure that they are loaded correctly when your code runs during the flow while protecting them? GitHub secrets allows you to be able to do that for your workflows. When you create a secret, the GitHub Actions environment variables encrypt the values such that it is not understandable by anyone else.
To create your GitHub Secrets, follow the steps below:
  1. Click on the Settings tab for your GitHub repo.
  2. Scroll to the Security section and click on Secrets and Variables.
  3. Click on Actions.
  4. Click on New repository secret
  5. Add the NOTION_AUTH_TOKEN environment variable and its corresponding value from the .env file.
  6. Repeat the process for all other environment variables.

Using the GitHub Secrets in the workflow

Simply creating the secrets on the web interface won’t do. You need to be able to access those secrets within your workflow. You need to make a minor modification to the run-subscription-tracker.yml file.
name: Check for subscriptions due the next day and notify me flow. on: schedule: - cron: '0 0 * * *' workflow_dispatch: jobs: check_and_notify: name: Check for upcoming subscriptions and remind me by phone runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: Set up Node uses: actions/setup-node@v3 with: node-version: 16.15.1 - name: Install dependencies run: yarn install - name: Run index.js run: node index.js env: NOTION_AUTH_TOKEN: ${{ secrets.NOTION_AUTH_TOKEN }} NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }} TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }} TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_AUTH_TOKEN }} TWILIO_PHONE_NUMBER: ${{ secrets.TWILIO_PHONE_NUMBER }} PHONE_NUMBER_FOR_NOTIFICATIONS: ${{ secrets.PHONE_NUMBER_FOR_NOTIFICATIONS }} 
Using the env property, you have set the different environment variables to their equivalent secrets. Henceforth, anywhere in our code where those environment variables are accessed through process.env, the value of the GitHub secret is passed down to the job so that it uses it.
Now you are ready to test your flow.
Running your GitHub workflow
To verify that your workflow runs as expected, head over to the Actions tab of your GitHub repository. Click on Check for subscriptions due the next day and notify me flow. GitHub informs you that the workflow has a workflow_dispatch event trigger and provides you with a button to run the workflow.
Click on the Run workflow button to start your workflow.
If you have different branches you can choose the branch from which you want to run your workflow. However, this project has a single branch so you can click on the green Run workflow button directly.
When you do that you will see that workflow is queued. If it runs successfully, you should see a green checkmark and receive a phone call from your Twilio number almost immediately.
Kindly remember that for trial accounts, you can only make calls to verified phone numbers. Therefore you need to add your personal phone number as a verified number for you to be able to receive calls.
In this tutorial, you built a notification system for keeping track of your upcoming subscription payments. There are different directions in which you could take to extend this system. For example, you could add a way to update the dates of those subscriptions by following a voice prompt when your Twilio number calls. Happy building!
Adaobi Aniuchi is a Front-end Application Developer developing accessible and interactive banking user experiences for the modern web. She is also a technical writer and technology hobbyist who loves to share her findings when she gets the opportunity to come up for some air. You can connect with her on LinkedIn.