Skip to main content

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

  1. Webhook Handler (/api/strava/webhook) - Receives events from Strava.
  2. Job Queue (ActivityFetchJob model) - Queues activities for processing.
  3. Activity Fetch Worker - Processes jobs with rate limiting.
  4. Rate Limiter - Token bucket algorithm for API limits.
  5. 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

  1. Upload an activity to Strava.
  2. Check the job queue: curl http://localhost:3000/api/admin/activity-jobs.
  3. 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 TypeAction
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 errorsExponential backoff retry
Activity already existsSkip if not forced

Migration from polling

The webhook system works alongside the existing polling system:

  1. Webhooks handle real-time notifications for new activities.
  2. Polling provides fallback reconciliation every 4 hours.
  3. Manual sync still available for bulk operations.

To fully migrate:

  1. Set up webhooks (this guide).
  2. Optionally disable auto-sync: STRAVA_AUTO_SYNC_ENABLED=false.
  3. Keep polling as weekly reconciliation: STRAVA_WORKER_INTERVAL_MS=604800000 (7 days).

Troubleshooting

Webhook not receiving events

  1. Check subscription status:

    npm run webhook:list
  2. Verify callback URL is accessible:

    curl https://your-domain.com/api/strava/webhook?hub.mode=subscribe&hub.challenge=test&hub.verify_token=your-token
  3. Check webhook logs:

    tail -f logs/webhook.log

Jobs getting stuck

  1. Check worker is running:

    ps aux | grep activity-fetch-worker
  2. Look for rate limiting:

    curl http://localhost:3000/api/admin/activity-jobs?status=pending&limit=5
  3. Manually reclaim stale jobs:

    UPDATE "ActivityFetchJob"
    SET status = 'pending'
    WHERE status = 'working'
    AND "updatedAt" < NOW() - INTERVAL '5 minutes';

High error rate

  1. Check token validity:

    curl -H "Authorization: Bearer YOUR_TOKEN" https://www.strava.com/api/v3/athlete
  2. Monitor rate limits:

    # Check for 429 errors in job queue
    curl http://localhost:3000/api/admin/activity-jobs?status=failed | grep "rate limited"
  3. 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:

  1. Near-instant activity appearance - Activities show up within 30 seconds of Strava upload.
  2. Reduced API usage - From 600+ calls/day to <50 calls/day.
  3. Lower latency - No more 4-hour delays.
  4. 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.