Overview

I've been going to F45 for over a year now, and it has completely changed my workout routine. I go almost every day, and I love it. I also love data and tracking my progression, so when they announced a Strava integration in 2024, I was very excited to use it. I wear the Lionheart monitor every time I go to track my heart rate and calories burned, and I love that it syncs to the Strava app so I can see my workouts in one place combined with my other exercises. However, since they updated to the Lionheart 2.0 system, they broke the Strava integration, and I'm not happy about it. Fortunately, where there's a will, there's a way.

Person at F45
Complete sweaty mess πŸ˜…

It started off with the official integration randomly breaking on a Monday. I tried the usual troubleshooting steps, such as disconnecting and reconnecting my Strava account, but nothing worked. After some digging, I found a support article on the F45 support page stating that it would be temporarily unavailable.

New and existing connections will not work at this time. We’ve decided to pause support for this feature and will revisit it at a later date.

Cool, I could live with temporary, but eventually, that message was replaced with another and I wasn't sure if it would, or would not be eventually supported.

We’re no longer supporting Strava integration for now. As part of the Lionheart 2.0 update, we’ve made changes to our Strava integration, and it is no longer functional

It's baffling to me that such a new feature could be forgotten about so quickly, but knowing how product development works, I'm sure they have their reasons. I'm not going to sit around and wait for them to fix it, though. I want my data, and I want it now.

Getting The Data

This is the most unorthodox part of this whole process. There is no advertised public API for Lionheart that I know of or a functional website; the only place to get the data as of the 2.0 update is the F45 mobile app. I used a Proxy to see if I could intercept the app's requests to their servers to find a usable payload. This process is similar to using the Network tab in Chrome's Developer Tools.

Lionheart App
This page shows almost everything I need.

After digging around for a while and figuring out how to set up SSL proxying, I finally found the request containing the data I needed whenever I clicked on one of the workouts in the Lionheart tab.

Charles Proxy showing the Lionheart API request
Gasp! πŸ‘€

Lo and behold, it contained everything I wanted, including calories burned, heart rates at given times, and even the workout name, in a nice JSON format. Turns out an API for Lionheart seems to exist but is undocumented for public use. Thankfully, I don't need any sort of credentials to query it, so nobody can stop me. Looking at the path, I tried to determine what data point each parameter was referring to.

https://api.lionheart.f45.com/v3/sessions/${CLASS_DATE}_${CLASS_TIME}:studio:${STUDIO_CODE}:serial:${LIONHEART_SERIAL_NUMBER}?user_id=${USER_ID}

I eventually figured out that I needed:

https://api.lionheart.f45.com/v3/profile/sessions/summary?user_id=${USER_ID}

I was also able to find another endpoint I could use which contained the profile, allowing me to maintain the same level of information for average scores and total sessions as the official integration did.

F45 Lionheart Leaderboard in the Studi
❀️‍πŸ”₯ Heart rate data is shown on the monitors during classes.

Building The Integration

Now that I have my data source, I need to upload it to Strava. Strava has an /upload endpoint that allows you to upload a workout using a .tcx formatted file. These files contain metadata about a workout, including the start time, duration, and heart rate data by timestamp. Converting the JSON data into a .tcx file is pretty simple; I just need to iterate over the data and format it correctly to match the schema. For this, I used the xmlbuilder library.

index.ts
/**
 * Fetches the workout data from the Lionheart Session API.
*/
async function fetchLionheartSession(): Promise<ILionheartSession> {
  const CLASS_DATE = process.env.F45_CLASS_DATE;
  const STUDIO_CODE = process.env.F45_STUDIO_CODE;
  const USER_ID = process.env.F45_USER_ID;
  const LIONHEART_SERIAL_NUMBER = process.env.F45_LIONHEART_SERIAL_NUMBER;
  let CLASS_TIME = process.env.F45_CLASS_TIME;

  if (CLASS_TIME) {
    CLASS_TIME = CLASS_TIME.replace(":", "");
  }

  try {
    const url = `https://api.lionheart.f45.com/v3/sessions/${CLASS_DATE}_${CLASS_TIME}:studio:${STUDIO_CODE}:serial:${LIONHEART_SERIAL_NUMBER}?user_id=${USER_ID}`;
    const response = await fetch(url);

    if (!response.ok) {
      throw new Error(
        `Error fetching data from Lionheart API: ${response.statusText}`
      );
    }

    const data = (await response.json()) as ILionheartSession;

    return data;
  } catch (error) {
    console.error(`Failed to fetch data from Lionheart API: ${error} ❌`);
    throw error
  }
}

/**
 * Reformats the fetched data as a TCX file.
 */
function generateTcx(res: ILionheartSession): string {
  try {
    const { summary, graph, workout } = res.data;
    const startTime = new Date(res.data.classInfo.timestamp * 1000);

    const root = create({ version: "1.0" })
      .ele("TrainingCenterDatabase", {
        xmlns: "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2",
      })
      .ele("Activities")
      .ele("Activity", { Sport: convertWorkoutTypeToActivity(workout.type.name) })
      .ele("Id")
      .txt(startTime.toISOString())
      .up()
      .ele("Lap", { StartTime: startTime.toISOString() })
      .ele("TotalTimeSeconds")
      .txt((res.data.classInfo.durationInMinutes * 60).toString())
      .up()
      .ele("DistanceMeters")
      .txt("0")
      .up()
      .ele("Calories")
      .txt(summary.estimatedCalories.toString())
      .up()
      .ele("AverageHeartRateBpm")
      .ele("Value")
      .txt(summary.heartrate.average.toString())
      .up()
      .up()
      .ele("MaximumHeartRateBpm")
      .ele("Value")
      .txt(summary.heartrate.max.toString())
      .up()
      .up()
      .ele("Intensity")
      .txt("Active")
      .up()
      .ele("TriggerMethod")
      .txt("Manual")
      .up()
      .ele("Track");

    graph.timeSeries.forEach((entry) => {
      if (entry.type === "recordedBpm") {
        const timePoint = new Date(startTime.getTime() + entry.minute * 60000);
        if (entry.bpm) {
          root
            .ele("Trackpoint")
            .ele("Time")
            .txt(timePoint.toISOString())
            .up()
            .ele("HeartRateBpm")
            .ele("Value")
            .txt(entry.bpm.max.toString())
            .up()
            .up();
        }
      }
    });

    return root.end({ prettyPrint: true });
  } catch (error) {
    console.error(`Error generating TCX file: ${error} ❌`);
    throw error;
  }
}

The resulting .tcx file ends up looking something like this.

workout.tcx
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2 http://www.garmin.com/xmlschemas/TrainingCenterDatabasev2.xsd">
  <Activities>
    <Activity Sport="Other">
      <Id>2025-03-12T17:15:00</Id>
      <Lap StartTime="2025-03-12T17:15:00">
        <TotalTimeSeconds>2330</TotalTimeSeconds>
        <DistanceMeters>0.0</DistanceMeters>
        <MaximumSpeed>0.0</MaximumSpeed>
        <Calories>572</Calories>
        <AverageHeartRateBpm>
          <Value>141</Value>
        </AverageHeartRateBpm>
        <MaximumHeartRateBpm>
          <Value>160</Value>
        </MaximumHeartRateBpm>
        <Intensity>Active</Intensity>
        <TriggerMethod>Manual</TriggerMethod>
        <Track>
          <Trackpoint>
            <Time>2025-03-12T17:19:00Z</Time>
            <HeartRateBpm>
              <Value>98</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:20:00Z</Time>
            <HeartRateBpm>
              <Value>111</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:21:00Z</Time>
            <HeartRateBpm>
              <Value>116</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:22:00Z</Time>
            <HeartRateBpm>
              <Value>128</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:23:00Z</Time>
            <HeartRateBpm>
              <Value>143</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:24:00Z</Time>
            <HeartRateBpm>
              <Value>150</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:25:00Z</Time>
            <HeartRateBpm>
              <Value>149</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:26:00Z</Time>
            <HeartRateBpm>
              <Value>131</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:27:00Z</Time>
            <HeartRateBpm>
              <Value>129</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:28:00Z</Time>
            <HeartRateBpm>
              <Value>139</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:29:00Z</Time>
            <HeartRateBpm>
              <Value>148</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:30:00Z</Time>
            <HeartRateBpm>
              <Value>158</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:31:00Z</Time>
            <HeartRateBpm>
              <Value>152</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:32:00Z</Time>
            <HeartRateBpm>
              <Value>160</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:33:00Z</Time>
            <HeartRateBpm>
              <Value>160</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:34:00Z</Time>
            <HeartRateBpm>
              <Value>155</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:35:00Z</Time>
            <HeartRateBpm>
              <Value>151</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:36:00Z</Time>
            <HeartRateBpm>
              <Value>145</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:37:00Z</Time>
            <HeartRateBpm>
              <Value>150</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:38:00Z</Time>
            <HeartRateBpm>
              <Value>159</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:39:00Z</Time>
            <HeartRateBpm>
              <Value>160</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:40:00Z</Time>
            <HeartRateBpm>
              <Value>160</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:41:00Z</Time>
            <HeartRateBpm>
              <Value>154</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:42:00Z</Time>
            <HeartRateBpm>
              <Value>146</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:43:00Z</Time>
            <HeartRateBpm>
              <Value>148</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:44:00Z</Time>
            <HeartRateBpm>
              <Value>150</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:45:00Z</Time>
            <HeartRateBpm>
              <Value>152</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:46:00Z</Time>
            <HeartRateBpm>
              <Value>150</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:47:00Z</Time>
            <HeartRateBpm>
              <Value>152</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:48:00Z</Time>
            <HeartRateBpm>
              <Value>156</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:49:00Z</Time>
            <HeartRateBpm>
              <Value>157</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:50:00Z</Time>
            <HeartRateBpm>
              <Value>160</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:51:00Z</Time>
            <HeartRateBpm>
              <Value>158</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:52:00Z</Time>
            <HeartRateBpm>
              <Value>159</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:53:00Z</Time>
            <HeartRateBpm>
              <Value>157</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:54:00Z</Time>
            <HeartRateBpm>
              <Value>159</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:55:00Z</Time>
            <HeartRateBpm>
              <Value>153</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:56:00Z</Time>
            <HeartRateBpm>
              <Value>152</Value>
            </HeartRateBpm>
          </Trackpoint>
          <Trackpoint>
            <Time>2025-03-12T17:57:00Z</Time>
            <HeartRateBpm>
              <Value>155</Value>
            </HeartRateBpm>
          </Trackpoint>
        </Track>
      </Lap>
    </Activity>
  </Activities>
</TrainingCenterDatabase>

Now when the script runs, it will request an authorization code for the user, request the data from the Lionheart API, generate the .tcx file, and upload it.

index.ts
/**
 * Gets a new access token from Strava, allowing us to make an API request.
 */
const getAccessToken = async (): Promise<IStravaTokenResponse> => {
  try {
    const response = await fetch(STRAVA_TOKEN_ENDPOINT, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        grant_type: "refresh_token",
        refresh_token: STRAVA_REFRESH_TOKEN,
        client_id: STRAVA_CLIENT_ID,
        client_secret: STRAVA_CLIENT_SECRET,
      }),
    });

    if (!response.ok) {
      throw new Error(`Error fetching access token: ${response.statusText}`);
    }

    return response.json() as Promise<IStravaTokenResponse>;
  } catch (error) {
    console.error(`Failed to get access token: ${error} ❌`);
    throw error;
  }
};

/**
 * Uploads a TCX file to Strava.
 * The TCX file contains all of the information about the workout
 * including graph data, heart rates, and more.
 */
async function uploadTcxFile(
  accessToken: string,
  filePath: string,
  data: IStravaUploadParameters
) {
  try {
    const file = fs.createReadStream(filePath);
    const formData = new FormData();
    formData.append("file", file);
    formData.append("name", data.name);
    formData.append("description", data.description);
    formData.append("data_type", data.data_type);

    const response = await fetch(STRAVA_UPLOAD_ENDPOINT, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
      body: formData,
    });

    if (!response.ok) {
      const errorBody = await response.text();
      throw new Error(
        `Error uploading TCX file: ${response.statusText} - ${errorBody}`
      );
    }

    return response.json();
  } catch (error) {
    console.error(`Failed to upload TCX file: ${error} ❌`);
    throw error;
  }
}

As a result, I now have a workout similar to what I had before with the official integration in Strava. Sure, I'm missing a nice graphic that gets uploaded along with the workout, but I can live without that for now as I have the same level of detail anyway.

Strava App
I am able to maintain the same level of detail as the original graphic, just without the graphic.
Strava App
I use Strava to track things like relative effort.
Strava App
I also like how Strava displays its heart rate graph and zones.

I decided to take this one step further and convert the script into a reusable GitHub Action that I can manually trigger from a workflow. This way, I can run it whenever I want to upload a workout, including when traveling, for that extra bit of convenience. It also means that you can run this if you have all the information on hand.

.github/workflows/import.yml
name: Import F45 Lionheart Data to Strava πŸ“Š

on:
  workflow_dispatch:
    inputs:
      F45_CLASS_DATE:
        description: "The date of the F45 class you want to import (YYYY-MM-DD)"
        required: true
        default: "2023-01-01"
      F45_CLASS_TIME:
        description: "The time the class was scheduled that you want to import (HH:MM), 24-hour format"
        required: true
        default: "09:30"
      F45_STUDIO_CODE:
        description: "Override the stored environment variable for the studio code. This is useful if passporting at another studio."

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Run Node.js script
        uses: JamesIves/f45-lionheart-strava-importer@main
        with:
          F45_CLASS_DATE: ${{ github.event.inputs.F45_CLASS_DATE }}
          F45_CLASS_TIME: ${{ github.event.inputs.F45_CLASS_TIME }}
          F45_STUDIO_CODE: ${{ github.event.inputs.F45_STUDIO_CODE || secrets.F45_STUDIO_CODE }}
          F45_USER_ID: ${{ secrets.F45_USER_ID }}
          F45_LIONHEART_SERIAL_NUMBER: ${{ secrets.F45_LIONHEART_SERIAL_NUMBER }}
          STRAVA_REFRESH_TOKEN: ${{ secrets.STRAVA_REFRESH_TOKEN }}
          STRAVA_CLIENT_SECRET: ${{ secrets.STRAVA_CLIENT_SECRET }}
          STRAVA_CLIENT_ID: ${{ secrets.STRAVA_CLIENT_ID }}

Conclusion

I don't love that I have to do this, but I'm glad I can. Honestly, I wish F45 would fix this themselves because it's clear they have the data available, and it took me no time to get this working. While the app updates are nice and appreciated, I want to keep everything in one place.

While working on this I also discovered a security vulnerability in one of the API's that I reported to F45. I'm not going to disclose the details here even though they fixed it quite quickly, but it's a good reminder that even if you're not a security researcher, you can still find issues in the wild.

Rowing Machine