F45 Broke My Beloved Strava Integration So I Wrote My Own

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 Strava 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. 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 temporarily, 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 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. 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. 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: CLASS_DATE - The date of the class, for example, 2025-03-12. CLASS_TIME - The time of the class, for example, 0630. STUDIO_CODE - The studio code, which was easy to find in the response payload. LIONHEART_SERIAL_NUMBER - The serial number on the back of the monitor. USER_ID - The user ID, which was also in the response payload. 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. /** * Fetches the workout data from the Lionheart API. */ async function fetchJsonData(): Promise { 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 } = res.data; const startTime = new Date(res.data.classInfo.timestamp * 1000); const root = create({ version: "1.0" })

Mar 15, 2025 - 12:22
 0
F45 Broke My Beloved Strava Integration So I Wrote My Own

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 Strava 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.

Rowing Machine

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 temporarily, 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 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

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

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:

  • CLASS_DATE - The date of the class, for example, 2025-03-12.
  • CLASS_TIME - The time of the class, for example, 0630.
  • STUDIO_CODE - The studio code, which was easy to find in the response payload.
  • LIONHEART_SERIAL_NUMBER - The serial number on the back of the monitor.
  • USER_ID - The user ID, which was also in the response payload.

Barbell

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.

/**
 * Fetches the workout data from the Lionheart API.
*/
async function fetchJsonData(): 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 } = 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: "Other" })
      .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.


 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">
  
     Sport="Other">
      2025-03-12T17:15:00
       StartTime="2025-03-12T17:15:00">
        2330
        0.0
        0.0
        572
        
          141
        
        
          160
        
        Active
        Manual
        
          
            2025-03-12T17:19:00Z
            
              98
            
          
          
            2025-03-12T17:20:00Z
            
              111
            
          
          
            2025-03-12T17:21:00Z
            
              116
            
          
          
            2025-03-12T17:22:00Z
            
              128
            
          
          
            2025-03-12T17:23:00Z
            
              143
            
          
          
            2025-03-12T17:24:00Z
            
              150
            
          
          
            2025-03-12T17:25:00Z
            
              149
            
          
          
            2025-03-12T17:26:00Z
            
              131
            
          
          
            2025-03-12T17:27:00Z
            
              129
            
          
          
            2025-03-12T17:28:00Z
            
              139
            
          
          
            2025-03-12T17:29:00Z
            
              148
            
          
          
            2025-03-12T17:30:00Z
            
              158
            
          
          
            2025-03-12T17:31:00Z
            
              152
            
          
          
            2025-03-12T17:32:00Z
            
              160
            
          
          
            2025-03-12T17:33:00Z
            
              160
            
          
          
            2025-03-12T17:34:00Z
            
              155
            
          
          
            2025-03-12T17:35:00Z
            
              151
            
          
          
            2025-03-12T17:36:00Z
            
              145
            
          
          
            2025-03-12T17:37:00Z
            
              150
            
          
          
            2025-03-12T17:38:00Z
            
              159
            
          
          
            2025-03-12T17:39:00Z
            
              160
            
          
          
            2025-03-12T17:40:00Z
            
              160
            
          
          
            2025-03-12T17:41:00Z
            
              154
            
          
          
            2025-03-12T17:42:00Z
            
              146
            
          
          
            2025-03-12T17:43:00Z
            
              148
            
          
          
            2025-03-12T17:44:00Z
            
              150
            
          
          
            2025-03-12T17:45:00Z
            
              152
            
          
          
            2025-03-12T17:46:00Z
            
              150
            
          
          
            2025-03-12T17:47:00Z
            
              152
            
          
          
            2025-03-12T17:48:00Z
            
              156
            
          
          
            2025-03-12T17:49:00Z
            
              157
            
          
          
            2025-03-12T17:50:00Z
            
              160
            
          
          
            2025-03-12T17:51:00Z
            
              158
            
          
          
            2025-03-12T17:52:00Z
            
              159
            
          
          
            2025-03-12T17:53:00Z
            
              157
            
          
          
            2025-03-12T17:54:00Z
            
              159
            
          
          
            2025-03-12T17:55:00Z
            
              153
            
          
          
            2025-03-12T17:56:00Z
            
              152
            
          
          
            2025-03-12T17:57:00Z
            
              155
            
          
        
      
    
  

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.

/**
 * 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 chart image that gets uploaded along with the workout, but I can live without that for now
so long as I have my data, which helps me track my progress.

Strava Integration

Strava Integration

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.

name: Import F45 Lionheart Data to Strava