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" })

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