Strava webhooks implementation
This document describes the complete webhook implementation for real-time Strava activity notifications.
Overview
The webhook system eliminates the need for continuous polling by receiving push notifications from Strava when activities are created, updated, or deleted. This provides:
- Instant notifications - Activities appear in your app seconds after upload.
- Reduced API usage - No more polling every 4 hours.
- Better user experience - Real-time sync without manual intervention.
- Scalable architecture - Handles multiple users efficiently.
Architecture
Strava Activity Upload
↓
Strava Webhook Event (POST /api/strava/webhook)
↓
ActivityFetchJob created in database
↓
Activity Fetch Worker processes job
↓
Fetch activity details from Strava API
↓
Save to database + create thumbnail job
Components
- Webhook Handler (
/api/strava/webhook) - Receives events from Strava. - Job Queue (
ActivityFetchJobmodel) - Queues activities for processing. - Activity Fetch Worker - Processes jobs with rate limiting.
- Rate Limiter - Token bucket algorithm for API limits.
- Admin Endpoints - Manage subscriptions and monitor queue.
Database schema
ActivityFetchJob model
CREATE TABLE "ActivityFetchJob" (
"id" BIGSERIAL PRIMARY KEY,
"activityId" BIGINT NOT NULL UNIQUE, -- Strava activity ID
"athleteId" BIGINT NOT NULL, -- Strava athlete ID
"reason" TEXT NOT NULL, -- "webhook", "polling", "manual"
"force" BOOLEAN DEFAULT false, -- Force re-fetch existing
"status" TEXT DEFAULT 'pending', -- pending | working | done | failed
"attempts" INTEGER DEFAULT 0, -- Retry counter
"maxAttempts" INTEGER DEFAULT 5, -- Max retries
"nextRunAt" TIMESTAMP DEFAULT NOW(), -- When to try next (backoff)
"error" TEXT, -- Last error message
"createdAt" TIMESTAMP DEFAULT NOW(),
"updatedAt" TIMESTAMP DEFAULT NOW()
);
CREATE INDEX "ActivityFetchJob_status_nextRunAt_idx" ON "ActivityFetchJob"("status", "nextRunAt");
CREATE INDEX "ActivityFetchJob_athleteId_idx" ON "ActivityFetchJob"("athleteId");
CREATE INDEX "ActivityFetchJob_reason_idx" ON "ActivityFetchJob"("reason");
Setup instructions
1. Environment variables
Add these to your .env file:
# Required - Strava API credentials
STRAVA_CLIENT_ID=your_client_id
STRAVA_CLIENT_SECRET=your_client_secret
NEXT_PUBLIC_BASE_URL=https://your-domain.com
# Webhook verification token (generate a random string)
STRAVA_VERIFY_TOKEN=your-random-string-123
# Optional - Rate limiting (defaults shown)
STRAVA_RATE_LIMIT_CAPACITY=600 # 600 requests per 15 minutes
STRAVA_RATE_LIMIT_INTERVAL=900000 # 15 minutes in ms
STRAVA_ATHLETE_COOLDOWN=15000 # 15 seconds between athlete fetches
2. Database migration
The migration was applied through this command:
npx prisma migrate dev --name add_activity_fetch_jobs
3. Start the workers
The new activity fetch worker runs alongside existing workers:
# Development (includes all 4 workers)
npm run dev
# Production
npm start
You should see:
[activity-fetch-worker] Started. Checking for jobs every 2000 ms
[thumb-worker] started. Debug: false | Size: 256 | NoBasemap: false | mapSource: OpenStreetMap
[strava-worker] started. interval: 240min checking every 4h | auto-sync enabled (stopAfter=20)
4. Create webhook subscription
Option A: Using admin API
curl -X POST http://localhost:3000/api/admin/webhooks \
-H "Content-Type: application/json" \
-d '{
"callbackUrl": "https://your-domain.com/api/strava/webhook",
"verifyToken": "your-random-string-123"
}'
Option B: Using setup script
# List existing subscriptions
npm run webhook:list
# Create new subscription
npm run webhook:create
# Clean up all subscriptions
npm run webhook:clean
Option C: Manual setup script
# First, get your access token from the database
npx tsx -e "import { prisma } from './src/lib/db'; prisma.stravaToken.findMany().then(console.log)"
# Set token and run setup
STRAVA_ACCESS_TOKEN=your_token npx tsx scripts/setup-webhooks.ts create
5. Verify webhook is operational
- Upload an activity to Strava.
- Check the job queue:
curl http://localhost:3000/api/admin/activity-jobs. - Activity should appear in your app within seconds.
Monitoring and management
Job queue statistics
# Get queue stats and recent jobs
curl http://localhost:3000/api/admin/activity-jobs
# Filter by status
curl http://localhost:3000/api/admin/activity-jobs?status=failed&limit=10
Webhook subscriptions
# List current subscriptions
curl http://localhost:3000/api/admin/webhooks
# Delete specific subscription
curl -X DELETE http://localhost:3000/api/admin/webhooks?id=12345
# Delete all subscriptions
curl -X DELETE http://localhost:3000/api/admin/webhooks
Manual job creation
# Create manual fetch job
curl -X POST http://localhost:3000/api/admin/activity-jobs \
-H "Content-Type: application/json" \
-d '{
"activityId": "12345678",
"athleteId": "87654321",
"reason": "manual",
"force": true
}'
Cleanup completed jobs
# Delete completed jobs older than 24 hours
curl -X DELETE "http://localhost:3000/api/admin/activity-jobs?status=done&olderThan=24"
# Delete all failed jobs
curl -X DELETE "http://localhost:3000/api/admin/activity-jobs?status=failed"
Configuration
Rate limiting
The system uses a token bucket algorithm to respect Strava's 600 requests per 15 minutes limit:
// Global rate limiter (shared across all athletes)
const stravaRateLimiter = new TokenBucket(600, 15 * 60 * 1000);
// Per-athlete cooldown (prevents spam for single athlete)
const athleteCooldown = new AthleteCooldown(15000); // 15 seconds
Worker behavior
- Concurrency: Max 3 concurrent API calls.
- Job claiming: Atomic database transactions prevent race conditions.
- Stale job recovery: Jobs stuck in "working" state are auto-reclaimed after 5 minutes.
- Backoff strategy: Exponential backoff with 30s base delay, capped at 1 hour.
Error handling
| Error Type | Action |
|---|---|
| HTTP 404 (Not Found) | Mark failed permanently |
| HTTP 429 (Rate Limited) | Honor Retry-After header, reduce capacity |
| HTTP 401/403 (Auth) | Mark failed permanently |
| Network/5xx errors | Exponential backoff retry |
| Activity already exists | Skip if not forced |
Migration from polling
The webhook system works alongside the existing polling system:
- Webhooks handle real-time notifications for new activities.
- Polling provides fallback reconciliation every 4 hours.
- Manual sync still available for bulk operations.
To fully migrate:
- Set up webhooks (this guide).
- Optionally disable auto-sync:
STRAVA_AUTO_SYNC_ENABLED=false. - Keep polling as weekly reconciliation:
STRAVA_WORKER_INTERVAL_MS=604800000(7 days).
Troubleshooting
Webhook not receiving events
-
Check subscription status:
npm run webhook:list -
Verify callback URL is accessible:
curl https://your-domain.com/api/strava/webhook?hub.mode=subscribe&hub.challenge=test&hub.verify_token=your-token -
Check webhook logs:
tail -f logs/webhook.log
Jobs getting stuck
-
Check worker is running:
ps aux | grep activity-fetch-worker -
Look for rate limiting:
curl http://localhost:3000/api/admin/activity-jobs?status=pending&limit=5 -
Manually reclaim stale jobs:
UPDATE "ActivityFetchJob"
SET status = 'pending'
WHERE status = 'working'
AND "updatedAt" < NOW() - INTERVAL '5 minutes';
High error rate
-
Check token validity:
curl -H "Authorization: Bearer YOUR_TOKEN" https://www.strava.com/api/v3/athlete -
Monitor rate limits:
# Check for 429 errors in job queue
curl http://localhost:3000/api/admin/activity-jobs?status=failed | grep "rate limited" -
Adjust worker concurrency:
// In activity-fetch-worker.ts
const MAX_CONCURRENT_JOBS = 1; // Reduce from 3 to 1
Performance Impact
Before webhooks (polling)
- 1 API call every 4 hours per athlete.
- 20-50 activity detail fetches per sync.
- 4-6 hour delay for new activities.
- Wasted API calls when no new activities.
After webhooks
- 0 scheduled API calls.
- 1 activity detail fetch per new activity.
- <30 second delay for new activities.
- 95%+ reduction in API usage.
Queue processing times
- Typical job: 200-500ms (API call + database write).
- With thumbnails: +2-5 seconds for route rendering.
- Rate limited: 60-300 seconds delay.
- Failed job retry: 30s, 60s, 2m, 4m, 8m intervals.
Success metrics
Once webhooks are working, you should see:
- Near-instant activity appearance - Activities show up within 30 seconds of Strava upload.
- Reduced API usage - From 600+ calls/day to <50 calls/day.
- Lower latency - No more 4-hour delays.
- Better reliability - No missed activities due to sync windows.
Monitor these endpoints for health:
/api/admin/activity-jobs- Job queue health./api/admin/webhooks- Subscription status.- Worker logs - Processing efficiency.
The app receives real-time activity notifications! Upload an activity to Strava and watch it appear in the app within seconds.