Skip to content

Instantly share code, notes, and snippets.

@breadthe
Last active October 2, 2023 10:53
Show Gist options
  • Save breadthe/2787c4f6d6ac805ef9eb698a91b6a750 to your computer and use it in GitHub Desktop.
Save breadthe/2787c4f6d6ac805ef9eb698a91b6a750 to your computer and use it in GitHub Desktop.
Laravel Strava webhooks based on https://github.com/RichieMcMullen/laravel-strava/
https://developers.strava.com/
https://developers.strava.com/docs/webhooks/
https://developers.strava.com/docs/webhookexample/
#...
STRAVA_PUSH_SUBSCRIPTIONS_URL="https://www.strava.com/api/v3/push_subscriptions"
# The app webhook callback URL that Strava uses to fire GET/POST events
STRAVA_WEBHOOK_CALLBACK_URL="https://www.yoursite.com/webhook"
# A random string generated by my app that Strava uses to verify the request
STRAVA_WEBHOOK_VERIFY_TOKEN=123ABCdefXYZ4567
<?php
// app/Providers/AppServiceProvider.php
namespace App\Providers;
use App\Services\StravaWebhookService;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->singleton(StravaWebhookService::class, function ($app) {
return new StravaWebhookService();
});
}
// ...
}
<?php
// config/services.php
return [
// ...
'strava' => [
'push_subscriptions_url' => env('STRAVA_PUSH_SUBSCRIPTIONS_URL'),
'webhook_callback_url' => env('STRAVA_WEBHOOK_CALLBACK_URL'),
'webhook_verify_token' => env('STRAVA_WEBHOOK_VERIFY_TOKEN'),
],
];
<?php
// app/Services/StravaWebhookService.php
namespace App\Services;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class StravaWebhookService
{
private string $url;
private string $client_id;
private string $client_secret;
private string $callback_url;
private string $verify_token;
public function __construct()
{
$this->url = config('services.strava.push_subscriptions_url');
$this->client_id = config('ct_strava.client_id');
$this->client_secret = config('ct_strava.client_secret');
$this->callback_url = config('services.strava.webhook_callback_url');
$this->verify_token = config('services.strava.webhook_verify_token');
}
public function subscribe(): int|null
{
$response = Http::post($this->url, [
'client_id' => $this->client_id,
'client_secret' => $this->client_secret,
'callback_url' => $this->callback_url,
'verify_token' => $this->verify_token,
]);
if ($response->status() === Response::HTTP_CREATED) {
return json_decode($response->body())->id;
}
Log::channel('strava')->error(json_encode($response->body()), [$response->status()]);
return null;
}
public function unsubscribe(): bool
{
$id = app(StravaWebhookService::class)->view(); // use the singleton
if (!$id) {
return false;
}
$response = Http::delete("$this->url/$id", [
'client_id' => $this->client_id,
'client_secret' => $this->client_secret,
]);
if ($response->status() === Response::HTTP_NO_CONTENT) {
return true;
}
Log::channel('strava')->error(json_encode($response->body()), [$response->status()]);
return false;
}
public function view(): int|null
{
$response = Http::get($this->url, [
'client_id' => $this->client_id,
'client_secret' => $this->client_secret,
]);
if ($response->status() === Response::HTTP_OK) {
$body = json_decode($response->body());
if ($body) {
return $body[0]->id; // each application can have only 1 subscription
} else {
return null; // no subscription found
}
}
Log::channel('strava')->error(json_encode($response->body()), [$response->status()]);
return null;
}
// GET https://mycallbackurl.com?hub.verify_token=STRAVA&hub.challenge=15f7d1a91c1f40f8a748fd134752feb3&hub.mode=subscribe
public function validate(string $mode, string $token, string $challenge): Response|JsonResponse
{
// Checks if a token and mode is in the query string of the request
if ($mode && $token) {
// Verifies that the mode and token sent are valid
if ($mode === 'subscribe' && $token === $this->verify_token) {
// Responds with the challenge token from the request
return response()->json(['hub.challenge' => $challenge]);
} else {
// Responds with '403 Forbidden' if verify tokens do not match
return response('', Response::HTTP_FORBIDDEN);
}
}
return response('', Response::HTTP_FORBIDDEN);
}
}
<?php
// app/Console/Commands/SubscribeToStravaWebhookCommand.php
namespace App\Console\Commands;
use App\Services\StravaWebhookService;
use Illuminate\Console\Command;
class SubscribeToStravaWebhookCommand extends Command
{
protected $signature = 'strava:subscribe';
protected $description = 'Subscribes to a Strava webhook';
public function __construct()
{
parent::__construct();
}
public function handle()
{
$id = app(StravaWebhookService::class)->subscribe();
if ($id) {
$this->info("Successfully subscribed ID: {$id}");
} else {
$this->warn('Unable to subscribe');
}
return 0;
}
}
<?php
// app/Console/Commands/UnsubscribeStravaWebhookCommand.php
namespace App\Console\Commands;
use App\Services\StravaWebhookService;
use Illuminate\Console\Command;
class UnsubscribeStravaWebhookCommand extends Command
{
protected $signature = 'strava:unsubscribe';
protected $description = 'Deletes a Strava webhook subscription';
public function __construct()
{
parent::__construct();
}
public function handle()
{
if (app(StravaWebhookService::class)->unsubscribe()) {
$this->info("Successfully unsubscribed");
} else {
$this->warn('Error or no subscription found');
}
return 0;
}
}
<?php
// app/Console/Commands/ViewStravaWebhookCommand.php
namespace App\Console\Commands;
use App\Services\StravaWebhookService;
use Illuminate\Console\Command;
class ViewStravaWebhookCommand extends Command
{
protected $signature = 'strava:view-subscription';
protected $description = 'Views a Strava webhook subscription';
public function __construct()
{
parent::__construct();
}
public function handle()
{
$id = app(StravaWebhookService::class)->view();
if ($id) {
$this->info("Subscription ID: $id");
} else {
$this->warn('Error or no subscription found');
}
return 0;
}
}
<?php
// routes/web.php
Route::get('/webhook', function (Request $request) {
$mode = $request->query('hub_mode'); // hub.mode
$token = $request->query('hub_verify_token'); // hub.verify_token
$challenge = $request->query('hub_challenge'); // hub.challenge
return app(StravaWebhookService::class)->validate($mode, $token, $challenge);
});
Route::post('/webhook', function (Request $request) {
$aspect_type = $request['aspect_type']; // "create" | "update" | "delete"
$event_time = $request['event_time']; // time the event occurred
$object_id = $request['object_id']; // activity ID | athlete ID
$object_type = $request['object_type']; // "activity" | "athlete"
$owner_id = $request['owner_id']; // athlete ID
$subscription_id = $request['subscription_id']; // push subscription ID receiving the event
$updates = $request['updates']; // activity update: {"title" | "type" | "private": true/false} ; app deauthorization: {"authorized": false}
Log::channel('strava')->info(json_encode($request->all()));
return response('EVENT_RECEIVED', Response::HTTP_OK);
})->withoutMiddleware(VerifyCsrfToken::class);
@Geovanek
Copy link

This will help me a lot.

One question, to test in development, did you use that Ngrok in Laravel?

@breadthe
Copy link
Author

Not at all. The webhook payload is same as a manual sync, which I can do in dev. So I just pushed it to prod (where it's just me so I don't care if it break), signed in, and waited for a new activity to be dispatched, while keeping an eye on the logs for any errors. It worked first try for me. Somehow the webhook workflow was trouble-free from the beginning.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment