
Claude Code didn’t start by writing code. It read Strava’s webhook documentation, then looked at my real data. It called Garmin through the OAuth2 provider already in my module, using ddev drush php:eval, and read Strava through the Strava connector. Lining the two up answered most of the design questions:
NULL and the run gets no description. A wrong plan is worse than no plan.public function onTerminate(TerminateEvent $event): void {
$route = $event->getRequest()->attributes->get(RouteObjectInterface::ROUTE_NAME);
if ($route !== StravaWebhook::ROUTE || !$event->getResponse()->isSuccessful()) {
return;
}
$queue = $this->queueFactory->get(StravaRunName::ID);
$worker = $this->queueWorkerManager->createInstance(StravaRunName::ID);
assert($worker instanceof QueueWorkerInterface);
while (($item = $queue->claimItem(self::LEASE)) instanceof stdClass) {
$worker->processItem($item->data);
$queue->deleteItem($item);
}
}
As the 2018 post points out, this needs PHP-FPM. That’s where fastcgi_finish_request() closes the connection, so Strava gets its 200 before the terminate work starts.
Inside the request,
kernel.terminateis the clean option. I wrote about it back in 2018 in Flush and run, using Kernel::TERMINATE to improve page speed performance. ADestructableInterfaceservice runs in the same terminate step, and a crontab line runningdrush queue:runevery minute adds a second place to maintain. So I went withkernel.terminate:
My Garmin pushes to Strava but Strava has generic titles. I was wondering if there could be a webhook from Strava that hits my website, it fetches the Garmin activity (strava should track the external ID… I think) and gets the run title/meta from Garmin. Can we try that out?
| Started | Strava | Garmin |
|---|---|---|
| Nov 4, 2:10 PM | Afternoon Run | Kenosha – Base |
| Nov 3, 11:22 AM | Lunch Run | Kenosha – Threshold |
| Oct 24, 5:40 AM | Morning Run | Kenosha – Long Run |
| Nov 4, 7:30 AM | Morning Walk | Kenosha Walking |
The PR was +1,147/-6 across 14 files, and about 530 of those lines are tests. It was open for 79 minutes. The whole thing took under two hours, on the same afternoon as the run.
- Garmin’s default names, like “Kenosha Running,” are no better than Strava’s. Skip them.
- I often rename runs by hand, like “Track Tuesday.” Only replace Strava’s generic names.
- I write a lot of descriptions. Never overwrite one.
My external ID idea was wrong
I also asked for hours on long runs, so 150 minutes reads 2:30:00, and for the pace on the cool-down.{
"object_type": "activity",
"object_id": 12345678987,
"aspect_type": "create",
"owner_id": 1234567,
"subscription_id": 123456,
"event_time": 1762287001,
"updates": {}
}
Writing code faster isn’t the interesting part. Finding out got cheap. Does Strava have webhooks? Is external_id the Garmin ID? Does Garmin keep the workout? What’s in the FIT file? Each of those is a small research task I’d normally put off, and each one got answered against my real accounts before any code depended on it. Ideas like this usually stay in my head, because the first hour is reading API docs to see if it’s even possible.public function getRunStartedAt(AccessToken $token, string $start): ?array {
$date = substr($start, 0, 10);
foreach ($this->searchActivities($token, 'running', $date, $date) as $activity) {
$id = $activity['activityId'] ?? NULL;
$name = $activity['activityName'] ?? NULL;
if (($activity['startTimeLocal'] ?? NULL) === $start && is_int($id) && is_string($name)) {
return ['id' => $id, 'name' => $name];
}
}
return NULL;
}
To check the formatter, Claude Code looped over every 2026 run with no delay between requests, and Garmin answered with a 429. Production’s fitness cron logged a failed Garmin refresh at 5:00 PM, right around then. That loop is the likely cause.$token = Drupal::service(Drupalmglaman_devOAuth2AccessTokenRepository::class)->get('strava');
$strava = Drupal::service(Drupalmglaman_devOAuth2ProviderFactory::class)->get('strava');
foreach ($activity_ids as $id) {
$url = "https://www.strava.com/api/v3/activities/$id";
$activity = $strava->getParsedResponse($strava->getAuthenticatedRequest('GET', $url, $token));
echo json_encode(array_intersect_key($activity, array_flip(['name', 'start_date_local', 'external_id']))) . PHP_EOL;
}
Next, I want strength workouts. “P/L/P: Legs” is a lot better than “Morning Weight Training.”public function receive(Request $request): JsonResponse {
$event = json_decode((string) $request->getContent(), TRUE);
if (
is_array($event)
&& ($event['object_type'] ?? NULL) === 'activity'
&& ($event['aspect_type'] ?? NULL) === 'create'
&& is_int($event['object_id'] ?? NULL)
) {
$this->queueFactory->get(StravaRunName::ID)->createItem($event['object_id']);
}
return new JsonResponse(['status' => 'ok']);
}
Drupal helps here. Queue workers, route access checks, event subscribers, and the key/value store are well-worn patterns, and my module already had examples of each. Kernel tests run the real routes through http_kernel. PHPStan at level 9 and PHPCS caught real problems, and CI still had to pass before anything merged. AGENTS.md tells the agent how the project works, and now it also records what we learned, like not matching on external_id.15:00 warm-up at 9:02-10:10/mi
3 x (4 x 0:40 at 6:19-6:45/mi, 3:00 recovery), 5:00 recovery
10:00 cool-down at 9:07-10:16/mi
Strava’s API has an external_id on each activity, and its documentation sample shows garmin_push_12345678987654321. I assumed that was the Garmin activity ID. A forum post suggested it might not be anymore. My local site had a Garmin token but no Strava token, so Claude Code read Strava through production, over SSH with drush php:eval, using the OAuth2 services my module already had:$token = Drupal::service(Drupalmglaman_devOAuth2AccessTokenRepository::class)->get('garmin');
$garmin = Drupal::service(Drupalmglaman_devOAuth2ProviderFactory::class)->get('garmin');
foreach ($external_ids as $id) {
try {
$url = "https://personal.connectapi.garmin.com/activity-service/activity/$id";
$garmin->getParsedResponse($garmin->getAuthenticatedRequest('GET', $url, $token));
echo "$id => found" . PHP_EOL;
}
catch (Throwable $e) {
echo "$id => HTTP " . $e->getCode() . PHP_EOL;
}
}
The useful part was the workout block, and it turned out nothing needed to parse FIT at all. Garmin’s /activity-service/activity/{id}/workouts endpoint returns the same plan as JSON, including target speeds and repeat steps:A Strava app gets one webhook subscription, and it covers everything that app’s athletes do. Each event is a small JSON payload that says what changed and gives you IDs, not the activity itself:
More than a name
Running the worker by hand on that afternoon’s run proved the Garmin match and the write to Strava. It now reads “Kenosha – Base,” with 1:04:00 at 9:07-10:16/mi as its description.Three rules came out of that data:I made the calls. I decided what the feature should do. I pointed Claude Code at a PR in ha-garmin that explained the FBT_ADAPTIVE flag on my runs, which marks an activity that came from a generated workout. I decided when to merge. The answers were only useful because I knew what to ask next.My Garmin runs sync to Strava with generic names like “Lunch Run.” I built a Strava webhook in Drupal that renames them with the Garmin workout name and adds the workout plan to the description, working through it with Claude Code in one afternoon.The webhook works the same way. If a Garmin request fails while renaming a run, the worker logs it and moves on, and that run keeps Strava’s name.That left the real test: an event from Strava, and the update running after the response is sent. My next run passed it. It wasn’t much of a structured workout, but it worked without me touching anything.Next I asked whether Garmin’s FIT file had anything worth putting in the description. It parsed that run’s file, skipping GPS. There’s plenty in there, including my weight and resting heart rate, which should stay out of Strava.
Expect Garmin to say no sometimes
The first version let cron process the queue. My cron runs every 19 minutes, so a run could keep its generic name that long. A streamed response doesn’t help: the request stays open until the callback finishes, and Strava wants a completed response.Nothing broke, because the site already expects Garmin to fail now and then. The fitness page serves a stored copy, and the next cron run refreshed it without trouble. Claude Code flagged the 429 on its own, redid the sampling with one request every three seconds over ten runs, and added a note to AGENTS.md about Garmin’s rate limit.Claude Code first wrote the subscription setup as a curl command, which meant exporting my Strava app secrets into the shell. The site’s PHP code already had them, so I had it swapped for a drush php:eval script. Creating the subscription is a change to my Strava account, not my site, so when Claude Code tried to run it over SSH, auto mode blocked the command as an account-level change. It stopped and gave me the command, without looking for another way in. I read it, approved that exact command, and it ran.
What changed for me
public function access(string $token): AccessResultInterface {
$secret = $_ENV['STRAVA_WEBHOOK_TOKEN'] ?? '';
$allowed = is_string($secret) && $secret !== '' && hash_equals($secret, $token);
return AccessResult::allowedIf($allowed)->setCacheMaxAge(0);
}
Strava wants a 200 within two seconds, and it doesn’t sign its events. There’s no signature header to check, so anyone who finds the callback URL can post to it. The usual advice is to make the URL itself the secret, with a long random token in the path. That’s what this route does. Its access check compares the token against an environment variable, and if the variable isn’t set, every request is rejected:{
"stepIndex": 3,
"intensity": "ACTIVE",
"durationType": "REPEAT_UNTIL_STEPS_CMPLT",
"durationValue": 1,
"targetValue": 3
}
10:00 warm-up at 9:07-10:16/mi
3 x 7:00 at 7:33-8:11/mi, 2:00 recovery
10:00 cool-down at 9:07-10:16/mi
On November 4th, I finished a run at about 3:15 PM. Around 4 PM, I asked Claude Code a question about it. At 5:49 PM, that run on Strava had its real name and its workout plan in the description, set by my Drupal site.I had recently added Strava and Garmin connections to this site for my fitness page. So I asked:When Garmin syncs a run to Strava, it doesn’t send a title or description. Strava falls back to its automatic titles, so a run my coach plan calls “Kenosha – Threshold” shows up as “Lunch Run.”





