# Webhooks Guide # Webhooks Guide ## Overview Sports Stack webhooks allow you to receive real-time notifications when entities in your tenant's data change. This guide covers how to set up, configure, and use webhooks to stay synchronized with your sports data. ## What Are Webhooks? Webhooks are HTTP callbacks that notify your application when specific events occur in Sports Stack. Instead of polling our API for changes, you can configure a webhook endpoint to receive automatic notifications when: * Teams are created or updated * Events are scheduled or their status changes * Player statistics are updated * Market odds change * And more... ## How It Works ```mermaid flowchart LR A[Data Provider] --> B[Sports Stack Processing] B --> C{Entity Changed?} C -->|Yes| D[Change Detection] D --> E[Webhook Publisher] E --> F[Batching & Queuing] F --> G[HTTP POST to Your Endpoint] G --> H[Your Application] ``` ### Change Detection Sports Stack uses intelligent change detection to ensure you only receive notifications when data actually changes: 1. **Hash Comparison**: Each entity is hashed after successful upsert 2. **Fingerprint Storage**: Previous hash is stored in `entity_change_fingerprints` 3. **Change Detection**: Only entities with changed hashes trigger webhooks 4. **Deduplication**: Prevents duplicate notifications from repeated provider updates This means you won't receive webhook notifications for: * Failed upserts * Identical data updates (same hash) * Validation errors * Compensating writes ### Batching Webhook events are batched for efficiency. Instead of sending one HTTP request per entity change, Sports Stack groups changes by resource type and destination, flushing batches when: * The batch reaches **50 events**, or * **10 seconds** have elapsed since the first event in the batch This reduces the number of HTTP requests your endpoint receives while keeping latency low. ## Setting Up Webhooks ### Step 1: Configure Your Webhook Endpoint 1. Navigate to **Integrations → Destinations** in the CMS 2. Click **"New Destination"** 3. Select **"Webhook"** as the destination type 4. Configure your webhook: * **Name**: Descriptive name (e.g., "Production Analytics Webhook") * **URL**: Your HTTPS endpoint URL * **Shared Secret**: Secret for signature verification * **Streams**: Select `change_log` stream * **Entity Types**: Filter which entities trigger webhooks (leave empty for all) * **Priority**: Lower numbers = higher priority (default: 100) ### Step 2: Verify Signature (Recommended) If you configured a shared secret, verify the webhook signature: ```python import hmac import hashlib def verify_webhook_signature(payload_body, signature_header, secret): """ Verify webhook signature from Sports Stack. Args: payload_body: Raw request body (bytes or string) signature_header: X-SportsStack-Signature header value secret: Your shared secret Returns: bool: True if signature is valid """ if isinstance(payload_body, str): payload_body = payload_body.encode('utf-8') expected_signature = hmac.new( secret.encode('utf-8'), payload_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected_signature, signature_header) ``` ```javascript const crypto = require('crypto'); function verifyWebhookSignature(payloadBody, signatureHeader, secret) { const expectedSignature = crypto .createHmac('sha256', secret) .update(payloadBody) .digest('hex'); return crypto.timingSafeEqual( Buffer.from(expectedSignature), Buffer.from(signatureHeader) ); } ``` ### Step 3: Test Your Webhook Use the test endpoint to send a test payload directly to your configured webhook, bypassing the full pipeline: ```bash curl -X POST "https://api.sportsstack.io/api/v1/webhooks/test" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"destination_id": "YOUR_DESTINATION_UUID"}' ``` The test payload uses `resource_type: "webhook_test"` and includes `"test": true` so you can distinguish it from real data: ```json { "stream": "change_log", "resource_type": "webhook_test", "tenant_id": "b79afb82-36b0-44f2-9102-494fa8e3c52f", "events": [ { "resource_type": "webhook_test", "common_model_id": "550e8400-e29b-41d4-a716-446655440000", "hash": "a1b2c3d4e5f6789012345678901234567890", "changed_at": "2025-01-15T10:30:00Z", "test": true } ], "batch_size": 1 } ``` ## Webhook Payload Structure ### Standard Payload Format Webhooks are delivered as batched payloads. Each delivery contains one or more events grouped by resource type: ```json { "stream": "change_log", "resource_type": "event", "tenant_id": "b79afb82-36b0-44f2-9102-494fa8e3c52f", "events": [ { "resource_type": "event", "common_model_id": "550e8400-e29b-41d4-a716-446655440000", "hash": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456", "changed_at": "2025-01-15T10:30:00Z", "league_id": "58e8c89b-72f8-4427-adbc-10f193c9b462" }, { "resource_type": "event", "common_model_id": "660e8400-e29b-41d4-a716-446655440001", "hash": "b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef12345678", "changed_at": "2025-01-15T10:30:02Z", "league_id": "58e8c89b-72f8-4427-adbc-10f193c9b462" } ], "batch_size": 2 } ``` ### Top-Level Fields | Field | Type | Description | | --------------- | ------- | ----------------------------------------------------------------------------------- | | `stream` | string | Always `"change_log"` for change log webhooks | | `resource_type` | string | Entity type for all events in this batch: `team`, `event`, `player`, `market`, etc. | | `tenant_id` | UUID | Your tenant identifier | | `events` | array | Array of change event objects (see below) | | `batch_size` | integer | Number of events in the `events` array | ### Event Object Fields Each object in the `events` array contains: | Field | Type | Description | | ----------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `resource_type` | string | Entity type (same as top-level `resource_type`) | | `common_model_id` | UUID | Stable identifier for the entity across all tenants | | `hash` | string | Hex-encoded SHA-256 fingerprint of the entity data (for deduplication) | | `changed_at` | ISO 8601 datetime | When the change was detected (UTC, second precision) | | `league_id` | UUID (optional) | League the entity belongs to. Present on most entity types. For league entities, this is the league's own `common_model_id`. | ### Example Payloads #### Event Update ```json { "stream": "change_log", "resource_type": "event", "tenant_id": "b79afb82-36b0-44f2-9102-494fa8e3c52f", "events": [ { "resource_type": "event", "common_model_id": "550e8400-e29b-41d4-a716-446655440000", "hash": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456", "changed_at": "2025-01-15T10:30:00Z", "league_id": "58e8c89b-72f8-4427-adbc-10f193c9b462" } ], "batch_size": 1 } ``` #### Team Update (Batched) ```json { "stream": "change_log", "resource_type": "team", "tenant_id": "b79afb82-36b0-44f2-9102-494fa8e3c52f", "events": [ { "resource_type": "team", "common_model_id": "660e8400-e29b-41d4-a716-446655440001", "hash": "b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef12345678", "changed_at": "2025-01-15T10:31:00Z", "league_id": "58e8c89b-72f8-4427-adbc-10f193c9b462" }, { "resource_type": "team", "common_model_id": "770e8400-e29b-41d4-a716-446655440002", "hash": "c3d4e5f6789012345678901234567890abcdef1234567890abcdef1234567890", "changed_at": "2025-01-15T10:31:01Z", "league_id": "58e8c89b-72f8-4427-adbc-10f193c9b462" } ], "batch_size": 2 } ``` #### Market Odds Update ```json { "stream": "change_log", "resource_type": "market", "tenant_id": "b79afb82-36b0-44f2-9102-494fa8e3c52f", "events": [ { "resource_type": "market", "common_model_id": "880e8400-e29b-41d4-a716-446655440003", "hash": "d4e5f6789012345678901234567890abcdef1234567890abcdef123456789012", "changed_at": "2025-01-15T10:32:00Z", "league_id": "58e8c89b-72f8-4427-adbc-10f193c9b462" } ], "batch_size": 1 } ``` ## HTTP Request Details ### Request Method * **Method**: `POST` * **Content-Type**: `application/json` * **Body**: JSON payload (see payload structure above) ### Headers Sports Stack includes the following headers: | Header | Description | | ------------------------- | ------------------------------------------------------------------------ | | `Content-Type` | Always `application/json` | | `X-SportsStack-Signature` | HMAC-SHA256 hex digest of the request body (if shared secret configured) | Any custom headers configured on your destination are also included. ### Custom Headers You can configure custom headers in your destination configuration: ```json { "url": "https://api.example.com/webhooks", "headers": { "Authorization": "Bearer your-api-key", "X-Custom-Header": "custom-value" } } ``` ## Response Handling ### Success Response Your endpoint should return a `2xx` status code to acknowledge receipt: ```json { "status": "ok" } ``` **Status Codes Accepted**: * `200 OK` * `201 Created` * `202 Accepted` * `204 No Content` ### Error Handling If your endpoint returns a non-2xx status code, Sports Stack will retry the webhook delivery: * **Retry Strategy**: Fixed delay buckets with increasing intervals * **Max Attempts**: 5 attempts * **Timeout**: 10 seconds per request (configurable per destination) **Status Codes That Trigger Retries**: * `5xx` (Server errors) - Retried with backoff * `429 Too Many Requests` - Retried with backoff * Network errors (DNS, connection refused, timeout) - Retried with backoff **Status Codes That Immediately Discard (No Retry)**: * `400 Bad Request` * `401 Unauthorized` * `403 Forbidden` * `404 Not Found` * `405 Method Not Allowed` * `406 Not Acceptable` * `410 Gone` * `415 Unsupported Media Type` * `422 Unprocessable Entity` These client error status codes are discarded immediately since retrying won't resolve the issue. Check your endpoint URL and configuration if you see these errors. ## Fetching Updated Data When you receive a webhook notification, you'll need to fetch the updated entity from our API: ### Using the Common Model ID ```bash # Example: Fetch updated event curl -X GET "https://api.sportsstack.io/api/v1/events/{common_model_id}" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ### Using Resource Type The `resource_type` field tells you which API endpoint to call: | Resource Type | API Endpoint | | --------------------- | ---------------------------------------------------------------------------- | | `event` | `GET /api/v1/events/{common_model_id}` | | `team` | `GET /api/v1/teams/{common_model_id}` | | `player` | `GET /api/v1/players/{common_model_id}` | | `market` | `GET /api/v1/markets/{common_model_id}` | | `event_scoped_stats` | `GET /api/v1/event-stats/{common_model_id}` | | `season_scoped_stats` | `GET /api/v1/seasons/{season_year}/season-stats?player_id={common_model_id}` | ### Using League ID for Filtering The `league_id` in each event can be used to filter API calls: ```bash # Fetch all teams for a specific league curl -X GET "https://api.sportsstack.io/api/v1/teams?league_id={league_id}" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## Entity Filtering You can configure webhooks to only trigger for specific entity types: ### Configuration Example ```json { "entity_filters": ["event", "team", "player"] } ``` This webhook will only fire for `event`, `team`, and `player` changes. All other entity types will be ignored. ### Available Entity Types * `team` * `player` * `event` * `league` * `season` * `sport` * `market` * `odd` * `event_scoped_stats` * `season_scoped_stats` * `standings` * `injury` * `news` **Note**: Leave `entity_filters` empty to receive notifications for all entity types. ## Security Best Practices ### 1. Use HTTPS Always configure webhook URLs with HTTPS: ``` https://api.example.com/webhooks ``` ### 2. Verify Signatures Always verify the `X-SportsStack-Signature` header: ```python # Example verification if not verify_webhook_signature( request.body, request.headers.get('X-SportsStack-Signature'), shared_secret ): return Response(status=401) ``` ### 3. Validate Tenant ID Verify the `tenant_id` matches your expected tenant: ```python if payload['tenant_id'] != YOUR_TENANT_ID: return Response(status=403) ``` ### 4. Rate Limiting Implement rate limiting on your webhook endpoint to prevent abuse: ```python from flask_limiter import Limiter limiter = Limiter( app, key_func=lambda: request.remote_addr, default_limits=["1000 per hour"] ) ``` ### 5. Idempotency Use the `hash` field to implement idempotency. Since webhooks provide at-least-once delivery, you may receive the same event more than once: ```python # Store processed hashes (use a database or cache in production) def handle_webhook(payload): for event in payload['events']: event_hash = event['hash'] entity_id = event['common_model_id'] if is_already_processed(entity_id, event_hash): continue # Skip duplicate # Process the change update_entity(event) mark_as_processed(entity_id, event_hash) ``` ## Monitoring & Debugging ### CMS Monitoring 1. **Recent API Calls**: View webhook delivery attempts in **Integrations → Recent API Calls** 2. **Webhook Tester**: Use **Admin → Webhook Tester** to inspect payloads 3. **Destination Status**: Check destination health in **Integrations → Destinations** ### Common Issues #### Webhooks Not Firing 1. **Check Destination Status**: Ensure destination is `is_active: true` 2. **Verify Stream Subscription**: Confirm `change_log` stream is selected 3. **Check Entity Filters**: Ensure entity type is included in filters 4. **Verify Change Detection**: Entity hash must actually change #### Webhooks Firing Too Often 1. **Check Hash Comparison**: Verify change detection is working 2. **Review Entity Filters**: Narrow filters to specific entity types 3. **Check Provider Updates**: Multiple provider updates may trigger multiple webhooks #### Signature Verification Failing 1. **Verify Shared Secret**: Ensure secret matches in destination config 2. **Check Payload Encoding**: Use raw request body (bytes, not parsed JSON) for signature verification 3. **Verify Algorithm**: Sports Stack uses HMAC-SHA256 with hex-encoded output ## Delivery Guarantees ### At-Least-Once Delivery Sports Stack provides **at-least-once delivery** semantics: * Webhooks may be delivered multiple times * Use the `hash` field for idempotency * Implement idempotent handlers in your application ### Retry Behavior Failed deliveries are retried with increasing delays: | Attempt | Delay | | ------- | ---------- | | 1 | 30 seconds | | 2 | 60 seconds | | 3 | 2 minutes | | 4 | 5 minutes | | 5 | 10 minutes | After 5 failed attempts, the webhook is moved to a dead letter queue and will not be retried further. ### Delivery Configuration * **Timeout**: 10 seconds per request (configurable per destination via `timeout_ms`) * **Max Attempts**: 5 attempts per webhook * **Batch Size**: Up to 50 events per delivery ## Testing Webhooks Locally ### Using the Test Endpoint The fastest way to verify your setup: ```bash curl -X POST "https://api.sportsstack.io/api/v1/webhooks/test" \ -H "Authorization: Bearer YOUR_API_KEY" ``` This sends a test payload to your first active webhook destination. You can also target a specific destination: ```bash curl -X POST "https://api.sportsstack.io/api/v1/webhooks/test?destination_id=YOUR_DEST_UUID" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ### Using ngrok 1. Install ngrok: `brew install ngrok` (or download from ngrok.com) 2. Start your local server: `python app.py` 3. Expose your local server: `ngrok http 8000` 4. Copy the HTTPS URL: `https://abc123.ngrok.io` 5. Configure webhook destination with ngrok URL 6. Use the test endpoint or trigger real data changes ### Using a Request Catcher For quick inspection without writing any code: 1. Visit [webhook.site](https://webhook.site) to get a temporary URL 2. Configure it as your webhook destination URL 3. Use the test endpoint to send a test payload 4. Inspect the full payload and headers in the browser ## Best Practices ### 1. Process Webhooks Asynchronously Don't perform long-running operations in your webhook handler: ```python # Good: Queue for async processing @app.route('/webhook', methods=['POST']) def webhook_handler(): payload = request.json queue.enqueue(process_webhook, payload) return Response(status=202) # Bad: Long-running operation blocks response @app.route('/webhook', methods=['POST']) def webhook_handler(): payload = request.json process_heavy_computation(payload) # Blocks! return Response(status=200) ``` ### 2. Implement Idempotency Use the `hash` field to prevent duplicate processing: ```python def process_webhook(payload): for event in payload['events']: event_hash = event['hash'] if is_already_processed(event_hash): continue # Skip duplicate # Process webhook update_entity(event) mark_as_processed(event_hash) ``` ### 3. Handle Failures Gracefully Implement proper error handling: ```python @app.route('/webhook', methods=['POST']) def webhook_handler(): try: payload = request.json verify_signature(request) process_webhook(payload) return Response(status=200) except SignatureError: return Response(status=401) except Exception as e: logger.error(f"Webhook processing failed: {e}") return Response(status=500) # Will be retried ``` ### 4. Log Everything Log webhook deliveries for debugging: ```python logger.info(f"Received webhook: {payload['resource_type']} batch_size={payload['batch_size']}") for event in payload['events']: logger.debug(f" Entity changed: {event['common_model_id']} hash={event['hash']}") ``` ## Support For webhook issues or questions: 1. Check **Admin → Webhook Tester** for payload inspection 2. Review **Integrations → Recent API Calls** for delivery status 3. Check destination configuration in **Integrations → Destinations** 4. Review logs for error messages