Receive SMS Transaction Notifications for your TransferWise Account with Twilio and Python

January 25, 2021
Written by
Renato Byrro
Contributor
Opinions expressed by Twilio contributors are their own

Receive SMS Transaction Notifications for your TransferWise Account with Twilio and Python

TransferWise is a digital bank that can save us from dread expensive bank fees. I have an account and love the service, but I’m really concerned about bad actors defrauding my card or login credentials.

What if TransferWise would send proactive alerts on debit transactions? Then I could act quickly to lock it down and minimize losses in case anything bad happens.

Twilio to the rescue!

In this tutorial, we’ll build a real time bank account monitor with the Transferwise API and Twilio SMS API. The application will send SMS alerts to a phone number notifying about debit transactions.

Preparing the requirements

To follow this tutorial, you will need the following:

Python: we’ll use Python3.9 (latest stable), but the code should also run on Python3.6+. To download and install, follow instructions on the official website. Python runs on Windows, Linux/UNIX and MacOS.

TransferWise account: if you don’t already have one, registration is free.

A server to keep the account monitor running 24/7: a small-capacity server with low CPU power, and perhaps 1 or 2 GB RAM should handle the job. Networking (calls to external HTTP APIs) and a time-based job scheduler (i.e. CRON) will also be needed. The script has a very small compute resource footprint, so you may well take advantage of a current server with spare capacity lingering anywhere in the cloud.

Setting up a Python virtual environment

For this project, we’ll need third-party libraries that don’t come bundled with Python. A good practice to manage dependencies is using a virtual environment.

Create a folder for the project:

$ mkdir twilio-transferwise
$ cd twilio-transferwise

Now let’s create and activate a virtual environment:

$ python3.9 -m venv .env
$ source .env/bin/activate

If you are following this tutorial on a Windows computer, replace the source line above with the following:

$ .venv\Scripts\activate

Setting up Twilio

Login to your Twilio Console and take note of your Account SID and Auth Token:

Twilio Account SID and Auth Token

Make sure you have a phone number listed in Phone Numbers > Manage Numbers and that the number has messaging enabled. If you don’t have a number, click on the “Buy a number” button at the upper-right corner of the screen to get one.

Buy a Twilio phone number

Getting a TransferWise API token

You will need an API authentication token to connect into your TransferWise account and pull balance transactions. Follow the steps below (or check these detailed instructions):

Login your TransferWise account. In the upper-right corner, click on “Settings”:

TransferWise settings

Scroll down to the “API Tokens” section and click on “Add New Token”:

TransferWise API token

Since this project will only require read access to your TransferWise statements and following the principle of least privilege, it’s strongly recommended that you generate a read-only token and avoid damage in case your token is ever compromised:

Create TransferWise API Token

Project dependencies

Open a requirements.txt file with your preferred code/text editor and add the following lines:

requests
twilio

We’ll use requests to interact with the TransferWise API. Twilio has its own Python library, which will make our lives a lot easier.

Now save and close the requirements file and install dependencies in the local virtual environment by entering the following command in your terminal:

(.env) $ pip install -r requirements.txt

Consuming the TransferWise API

Now entering the fun part, finally! There are three endpoints we need from the TransferWise API:

  1. Get profile ID: lists all your TransferWise accounts
  2. Get account information: get currency and account ID for each account
  3. Get account statement: latest transactions for each balance

Create a Python file named main.py and open it in your text editor or IDE. Let’s begin by adding all the imports that we are going to need for this project:

import datetime
import itertools
import logging
import os
from typing import Dict, List, Union

import requests
from twilio.rest import Client as TwilioClient

In the following sections we’ll implement calls to the three endpoints listed above.

Get profile ID

The TransferWise Profile ID will be required in the subsequent API calls, so let’s get a hold onto it by creating a get_transferwise_profile_id function in the main.py file:

def get_transferwise_profile_ids() -> List[int]:
    response = requests.get(
        url='https://api.transferwise.com/v1/profiles',
        headers={
            'Authorization': f'Bearer {os.environ["TRANSFERWISE_API_TOKEN"]}',
        },
    )

    return [profile['id'] for profile in response.json()]

Notice we’re getting the TransferWise API Token from an environment variable, which you can set in your terminal with:

(.env) $ export TRANSFERWISE_API_TOKEN="xxxx-xxxx-xxxx-xxxx-xxxx"

The above command should work on Linux/MacOS. On Windows, if you are using a command prompt window you must replace export with set. If you are using a PowerShell console, then use this command instead:

$Env:TRANSFERWISE_API_TOKEN = "xxxx-xxxx-xxxx-xxxx-xxxx"

You might ask: why not just grab my profile ID once and hard code it? It’s sensible and you can certainly do that. The advantage of keeping the profile ID dynamic is in case you open other accounts (e.g. a new business). Besides, the API is pretty fast and one extra request every few minutes won’t hurt.

Save your main.py file and then test the get_transferwise_profile_ids function from your console with:

(.env) $ python -c "import main; print(main.get_transferwise_profile_ids())"

You should see an output like the one below (each “account” you have on TransferWise will have one ID in the list printed):

'12345678'

Get account information

The next step is getting the different currencies you trade in and the account IDs for each profile ID. If you’re like me, you’ll be using lots of them and we definitely want all greenies safe.

def get_transferwise_balance_currencies(profile_id: int) -> List[dict]:
    response = requests.get(
        url='https://api.transferwise.com/v1/borderless-accounts',
        params={
            'profileId': profile_id,
        },
        headers={
            'Authorization': f'Bearer {os.environ["TRANSFERWISE_API_TOKEN"]}',
        },
    )

    return [
        {
            'profile_id': profile_id,
            'account_id': account['id'],
            'currency': balance['currency'],
        }
        for account in response.json()
        for balance in account['balances']
    ]

Run it in your console (replace “12345678” with an actual profile ID retrieved by the previous function):

(.env) $ python -c "import main; print(main.get_transferwise_balance_currencies(12345678))"

You should see a list of dictionaries, each containing a profile_id, account_id and currency keys. Each currency (e.g. "USD", "EUR") has its own independent transaction history and we’ll need to retrieve statements from them separately in the next step.

Get account statements

The last task with the TransferWise API is getting the actual statements we want to be alerted about.

There is one detail to take care of before we do that: how far back in time should we look into TransferWise statements? We will set this code to run on a recurring schedule. Say, every five minutes. Thus, looking into statements recorded in the last, well, five minutes (!) sounds reasonable.

But, not so fast... Relying solely on this “last X minutes” as the source of time interval is a somewhat naive approach. The external APIs our code relies on may fail. Our own server may go offline for some time. If any of these situations happen, our script is likely to fail alerting some transactions. For this reason, we need to keep track of the last time we checked our statements.

The following two functions will take care of storing and retrieving the latest_datetime object in the local disk:

def get_latest_datetime() -> Union[None, str]:
    try:
        with open('latest-transferwise-twilio-datetime.txt', 'r') as file:
            return datetime.datetime.strptime(
                file.read(),
                '%Y-%m-%d %H:%M:%S %Z',
            )
    except FileNotFoundError:
        return None
    except (TypeError, ValueError) as exc:
        logging.error('Invalid datetime type or value', exc_info=exc)
        return None

def update_latest_datetime(datetime_object: datetime.datetime) -> None:
    filepath = 'latest-transferwise-twilio-datetime.txt'
    mode = 'w' if os.path.isfile(filepath) else 'x'

    with open(filepath, mode) as file:
        file.write(datetime.datetime.strftime(
            datetime_object,
            '%Y-%m-%d %H:%M:%S %Z',
        ))

The following function will calculate the time interval we need in the TransferWise API request. If the datetime object from get_latest_datetime is not available (i.e. first execution of the script), it automatically falls back to the “last X minutes” option using an environment variable setting.

def calculate_time_interval() -> Dict[str, datetime.datetime]:
    latest_datetime = get_latest_datetime()

    now = datetime.datetime.now(datetime.timezone.utc)

    if not latest_datetime:
        delta = {'minutes': int(os.environ['SCHEDULE_EVERY_MINUTES'])}
        latest_datetime = now - datetime.timedelta(**delta)

    return {
        'start': latest_datetime,
        'end': now,
    }

Note that UTC should be used as the timezone in all API calls, as indicated by the TransferWise documentation.

A function to format the Python datetime object into a string recognizable by the TransferWise API will also be handy:

def format_time(datetime_object: datetime.datetime) -> str:
    return datetime_object.strftime('%Y-%m-%dT%H:%M:%SZ')

Finally, we can now write the function to get account statements. This one gets slightly convoluted and we would be better off breaking it into multiple single-purpose functions. Nonetheless, I’m keeping everything together for the sake of simplicity in this tutorial:

def get_transferwise_account_statements(
    profile_id: int,
    account_id: int,
    currency: str,
    time_interval: Dict[str, datetime.datetime] = None,
) -> List[dict]:
    if time_interval is None:
        time_interval = calculate_time_interval()

    response = requests.get(
        url=f'https://api.transferwise.com/v3/profiles/{profile_id}'
            f'/borderless-accounts/{account_id}/statement.json',
        params={
            'currency': currency,
            'intervalStart': format_time(time_interval['start']),
            'intervalEnd': format_time(time_interval['end']),
            'type': 'COMPACT',
        },
        headers={
            'Authorization': f'Bearer {os.environ["TRANSFERWISE_API_TOKEN"]}',
        }
    )

    if not response.ok:
        response.raise_for_status()

    account = response.json()

    if account['accountHolder']['type'] == 'PERSONAL':
        name = account['accountHolder']['lastName']
    elif account['accountHolder']['type'] == 'BUSINESS':
        name = account['accountHolder']['businessName']
    else:
        name = 'Undetermined'

    return [
        {
            'account_name': name,
            'transaction': transaction['details']['description'],
        }
        for transaction in account['transactions']
        if transaction['type'] == 'DEBIT'
    ]

Before you can test this function, set the SCHEDULE_EVERY_MINUTES environment variable:

(.env) $ export SCHEDULE_EVERY_MINUTES=5

Remember that on Windows the environment variable is set with a different command, either set if you use the command prompt, or $Env if you use PowerShell.

To test the function in your console, use the following command (replace with your own account_id, profile_id and currency argument values):

(.env) $ python -c "import main; print(main.get_transferwise_account_statements(account_id=123, profile_id=123, currency='XYZ'))"

The response is going to be a list of debit transactions in the last time interval. Since you probably did not use your card in the last five minutes, you may want to temporarily set the SCHEDULE_EVERY_MINUTES to a larger value so that you can see some transactions reported.

Sending alerts through Twilio

All right, now that we have all bank transactions in our hands, let’s send over an alert to our phones with the Twilio API.

First, instantiate the Twilio  client:

def get_twilio_client():
    return TwilioClient(
        username=os.environ['TWILIO_ACCOUNT_SID'],
        password=os.environ['TWILIO_AUTH_TOKEN'],
    )

Now the part that makes our phones ring:

def send_message(
    client: TwilioClient,
    account_name: str,
    transaction: str,
) -> dict:
    return client.messages.create(
        body=f'TransferWise ({account_name}): {transaction}',
        from_=os.environ['TWILIO_SENDER_NUMBER'],
        to=os.environ['TWILIO_DESTINATION_NUMBER'],
    )

The account_name and transaction variables used to format the body of the SMS message come from the get_transferwise_account_statements response payload.

Also notice we’re using a few additional environment variables:

  • TWILIO_ACCOUNT_SID
  • TWILIO_AUTH_TOKEN
  • TWILIO_SENDER_NUMBER
  • TWILIO_DESTINATION_NUMBER

To set them, use the same export (Linux/MacOS), set or $Env (Windows) commands suggested above. For the sender and destination phone numbers, enter the complete phone number in E.164 format.

Test the SMS function from console with a sample message like the following one:

(.env) $ python -c "import main; client = main.get_twilio_client(); print(main.send_message(client=client, account_name='Myself', transaction='Paid to XYZ Store'))"

Twilio SMS

Finally, let’s write a main function combining all other functions we wrote before:

def main():
    time_interval = calculate_time_interval()

    accounts = [
        get_transferwise_balance_currencies(profile_id=profile_id)
        for profile_id in get_transferwise_profile_ids()
    ]

    statements = list(itertools.chain(*[
        get_transferwise_account_statements(
            **balance_args,
            time_interval=time_interval,
        )
        for account in accounts
        for balance_args in account
    ]))

    if len(statements) == 0:
        update_latest_datetime(time_interval['end'])
        return {
            'statusCode': 204,
            'body': {
                'transactions': None,
                'twilio_response': None,
            },
        }

    twilio_client = get_twilio_client()

    # Bundle all transactions in a single message, in case there are many
    message = send_message(
        client=twilio_client,
        account_name=', '.join(
            set([statement['account_name'] for statement in statements])),
        transaction=', '.join(
            set([statement['transaction'] for statement in statements])),
    )

    twilio_response = {
        'sid': message.sid,
        'body': message.body,
        'error_code': message.error_code,
        'error_message': message.error_message,
    }

    status_code = 200 if not message.error_code else 500

    if status_code == 200:
        update_latest_datetime(time_interval['end'])

    return {
        'statusCode': status_code,
        'body': {
            'transactions_count': len(statements),
            'twilio_response': twilio_response,
        },
    }


if __name__ == '__main__':
    main()

To test the script, close the Python file and run it with:

(.env) $ python main.py

Scheduling our Twilio monitor

We don’t know when someone will jump over the security fences around our TransferWise account and banking secrets, so it’s best to have this monitor running all day long in an automated fashion.

A popular tool for scheduling recurring jobs on Linux is CRON. Since most servers run on top of a Linux flavor, let’s ring our phones with CRON.

Make sure crontab is running with:

service cron status

If it’s not, start the service with service cron start. You may need to throw in a “sudo sandwich” for the command to work.

XKCD

Attribution: XKCD (Creative Commons Attribution-NonCommercial 2.5)

Let’s open CRON for editing:

$ crontab -e

Schedule the script to run every five minutes (or whatever frequency suits you best) by adding the following line to the cron job list:

*/5 * * * * ~/twilio-transferwise/.env/bin/python ~/twilio-transferwise/main.py

In case you were not into moving money in the last five minutes and you’d like to see some action, customize the contents of the latest-transferwise-twilio-datetime.txt file. Set the datetime string to a few days or weeks ago (whenever you see the latest transactions in your TransferWise statements). The script should capture these in the next job run and send you an SMS.

If you happen to be running an OS X-powered server (or if you plan to keep your Macbook online 24/7), launchd is your friend. On Windows/Powershell, ScheduledTasks may well save your day.

And that is pretty much it!

We’ve written a simple script that retrieves debit transactions from TransferWise accounts and turns them into SMS alerts using the Twilio SMS API.

The main advantage we have on our side now is time. If a fraudster happens to debit money from our account in the future, we can act quickly to lock it down, notify the TransferWise security team and, in case of a card transaction, maybe even have the merchant nullifying it and returning our money.

I’m a backend software developer and a father of two amazing kids who won’t let me sleep so that he can enjoy spending nights connecting APIs around. Keep track of other projects fresh from dawn on my Github profile. My direct contact and other social links in byrro.dev.