 [ ← blog / drupal ](/) Nov 06, 2026  ·  \#drupal  ·  11 min read 

# Naming my Strava runs from Garmin with a Drupal webhook

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.



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

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.

## It started with a question

I had recently added Strava and Garmin connections to this site for my fitness page. So I asked:

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

Claude Code didn't start by writing code. It read Strava's [webhook documentation](https://developers.strava.com/docs/webhooks/), 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:

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

Three rules came out of that data:

- 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

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:

```php
$token = \Drupal::service(\Drupal\mglaman_dev\OAuth2\AccessTokenRepository::class)->get('strava');
$strava = \Drupal::service(\Drupal\mglaman_dev\OAuth2\ProviderFactory::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;
}
```

```json
{"name":"Afternoon Run","start_date_local":"2026-11-04T14:10:01Z","external_id":"garmin_ping_6528…"}
{"name":"Lunch Run","start_date_local":"2026-11-03T11:22:12Z","external_id":"garmin_ping_6522…"}
{"name":"Morning Walk","start_date_local":"2026-11-04T07:30:20Z","external_id":"garmin_ping_6526…"}
```

Then it asked Garmin about those numbers from my local DDEV site, with `ddev drush php:eval`:

```php
$token = \Drupal::service(\Drupal\mglaman_dev\OAuth2\AccessTokenRepository::class)->get('garmin');
$garmin = \Drupal::service(\Drupal\mglaman_dev\OAuth2\ProviderFactory::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;
  }
}
```

```bash
6528… => HTTP 404
6522… => HTTP 404
6526… => HTTP 404
```

All three were 404s. The real Garmin ID for the first run is `2496…`, and none of the `external_id` numbers showed up in the activity details or search results either. The start time matches. Strava and Garmin agreed to the second on all 40 recent activities we compared, so the code looks up the run on Garmin by its local start time:

```php
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;
}
```

The first version tried `external_id` and fell back to start time. Once we had the real value, the `external_id` path was just a failed Garmin request on every run, so it was deleted before the PR was opened.

## Receiving the webhook

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:

```json
{
  "object_type": "activity",
  "object_id": 12345678987,
  "aspect_type": "create",
  "owner_id": 1234567,
  "subscription_id": 123456,
  "event_time": 1762287001,
  "updates": {}
}
```

`object_type` is `activity` or `athlete`, and `aspect_type` is `create`, `update`, or `delete`. An `update` lists what changed in `updates`, like a new title. To get the run's start time, you call the API with `object_id`.

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](https://hookdeck.com/webhooks/platforms/guide-to-strava-webhooks-features-and-best-practices) 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:

```php
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);
}
```

The controller only queues new activities. Updating an activity makes Strava send an `update` event, and ignoring those keeps this from looping:

```php
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']);
}
```

When you create the subscription, Strava checks the callback URL once with a GET request carrying `hub.mode`, `hub.challenge`, and `hub.verify_token`, and your endpoint has to echo `hub.challenge` back as JSON. There's a small PHP trap there. PHP turns the dots in query parameter names into underscores, so you read `hub_challenge` and answer with `hub.challenge`.

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.

## Reply first, then do the work

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.

Inside the request, `kernel.terminate` is the clean option. I wrote about it back in 2018 in [Flush and run, using Kernel::TERMINATE to improve page speed performance](https://mglaman.dev/blog/flush-and-run-using-kernelterminate-improve-page-speed-performance). A `DestructableInterface` service runs in the same terminate step, and a crontab line running `drush queue:run` every minute adds a second place to maintain. So I went with `kernel.terminate`:

```php
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);
  }
}
```

The queue is still the safety net. If the process dies, the item is still there, and cron picks it up when the two-minute lease expires.

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.

## More than a name

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.

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:

```json
{
  "stepIndex": 3,
  "intensity": "ACTIVE",
  "durationType": "REPEAT_UNTIL_STEPS_CMPLT",
  "durationValue": 1,
  "targetValue": 3
}
```

That step means "go back to step 1, three times through." The speeds are in meters per second, so `WorkoutDescription` turns them into paces. The November 3 threshold workout comes out like this:

```bash
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
```

The numbers check out against the run. That plan adds up to 45:00 if the last recovery is skipped, and I ran 45:04. Repeats can also nest, which my anaerobic workouts do:

```bash
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
```

I typed that same structure into a Strava description by hand in October. This is a chore I was already doing.

I also asked for hours on long runs, so 150 minutes reads `2:30:00`, and for the pace on the cool-down.

If the formatter finds a step it doesn't understand, it returns `NULL` and the run gets no description. A wrong plan is worse than no plan.

## Expect Garmin to say no sometimes

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.

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.

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.

## What changed for me

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.

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.

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

I made the calls. I decided what the feature should do. I pointed Claude Code at [a PR in ha-garmin](https://github.com/cyberjunky/ha-garmin/pull/32) 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.

## What's next

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.

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 want strength workouts. "P/L/P: Legs" is a lot better than "Morning Weight Training."

 

  ![](/themes/custom/mglaman/images/headshot.webp)Thanks for reading

I write about Drupal, PHP, and the quiet infrastructure behind large sites — caching, config, and testing. New posts land every week or two.

 [Subscribe via RSS →](/rss.xml) [About me →](/about-me) 

 



 

  [← Older

ContribKanban now tracks GitLab work items

 ](/blog/contribkanban-now-tracks-gitlab-work-items)