# Webhook Payload Schema # Webhooks API Reference ## Overview This document provides technical reference documentation for Sports Stack webhooks, including payload schemas, HTTP specifications, and integration details. ## Webhook Configuration Webhooks are configured through the **Destinations** system in the CMS. Each tenant can configure multiple webhook destinations with custom filtering and translation. ### Configuration Schema ```json { "tenant_id": "b79afb82-36b0-44f2-9102-494fa8e3c52f", "name": "Production Analytics Webhook", "destination_type": "webhook", "config": { "url": "https://api.example.com/webhooks/sports-stack", "shared_secret": "your-secret-key-here", "headers": { "Authorization": "Bearer token123", "X-Custom-Header": "value" }, "timeout_ms": 5000 }, "streams": ["change_log"], "entity_filters": ["event", "team", "player"], "is_active": true, "priority": 100 } ``` ### Configuration Fields | Field | Type | Required | Description | | --------------- | ------- | -------- | ----------------------------------------------- | | `url` | string | Yes | HTTPS endpoint URL to receive webhooks | | `shared_secret` | string | No | Secret for HMAC-SHA256 signature verification | | `headers` | object | No | Custom HTTP headers to include in requests | | `timeout_ms` | integer | No | Request timeout in milliseconds (default: 5000) | ## HTTP Request Specification ### Request Method ``` POST {webhook_url} ``` ### Request Headers | Header | Value | Description | | ------------------------- | -------------------------- | --------------------------------------------------- | | `Content-Type` | `application/json` | Payload content type | | `X-SportsStack-Signature` | `{hmac-sha256}` | HMAC-SHA256 signature (if shared secret configured) | | `User-Agent` | `SportsStack-Webhooks/1.0` | Webhook client identifier | ### Custom Headers Any headers specified in the destination `config.headers` will be merged with the default headers. ### Request Body The request body is a JSON object following the [Webhook Payload Schema](#webhook-payload-schema). ## Webhook Payload Schema Sports Stack supports two payload formats for webhooks: 1. **Single Event Format** (legacy, still supported) 2. **Batched Events Format** (recommended, more efficient) ### Single Event Format Used for individual event deliveries (legacy format, still supported for backward compatibility): ```json { "stream": "change_log", "resource_type": "event", "tenant_id": "b79afb82-36b0-44f2-9102-494fa8e3c52f", "entity_id": "optional-internal-id", "event": { "resource_type": "event", "common_model_id": "550e8400-e29b-41d4-a716-446655440000", "hash": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456", "changed_at": "2024-01-15T10:30:00Z" } } ``` ### Batched Events Format Used for efficient delivery of multiple events in a single request (recommended format): ```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": "2024-01-15T10:30:00Z" }, { "resource_type": "event", "common_model_id": "660e8400-e29b-41d4-a716-446655440001", "hash": "b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef12345678", "changed_at": "2024-01-15T10:31:00Z" } ], "batch_size": 2, "entity_ids": ["optional-id-1", "optional-id-2"] } ``` ### Schema Definition ```typescript // Single Event Format (legacy) interface SingleEventWebhookPayload { stream: "change_log"; resource_type: EntityType; tenant_id: string; // UUID entity_id?: string; // UUID (optional, may be null for new entities) event: { resource_type: EntityType; common_model_id: string; // UUID hash: string; // SHA-256 hex string (64 characters) changed_at: string; // ISO 8601 datetime (UTC) }; } // Batched Events Format (recommended) interface BatchedWebhookPayload { stream: "change_log"; resource_type: EntityType; tenant_id: string; // UUID events: Array<{ resource_type: EntityType; common_model_id: string; // UUID hash: string; // SHA-256 hex string (64 characters) changed_at: string; // ISO 8601 datetime (UTC) }>; batch_size: number; // Number of events in the batch entity_ids?: string[]; // Array of entity IDs (optional, may contain nulls) } // Union type - webhook handlers should support both type WebhookPayload = SingleEventWebhookPayload | BatchedWebhookPayload; type EntityType = | "team" | "player" | "event" | "league" | "season" | "sport" | "market" | "odd" | "event_scoped_stats" | "season_scoped_stats" | "standings" | "injury" | "news"; ``` ### Field Descriptions #### `stream` * **Type**: `string` * **Value**: Always `"change_log"` * **Description**: Identifies the webhook stream type #### `resource_type` * **Type**: `string` (EntityType) * **Description**: The type of entity that changed * **Possible Values**: See EntityType above #### `tenant_id` * **Type**: `string` (UUID) * **Description**: Your tenant identifier * **Format**: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` #### `entity_id` (Single Event Format only) * **Type**: `string` (UUID) | `null` * **Optional**: Yes * **Description**: Internal entity ID (may be null for new entities) * **Note**: Only present in single event format #### `events` (Batched Format only) * **Type**: `array` of event objects * **Required**: Yes (for batched format) * **Description**: Array of change events in this batch * **Note**: Only present in batched format. Use this field to detect batched payloads. #### `batch_size` (Batched Format only) * **Type**: `number` (integer) * **Required**: Yes (for batched format) * **Description**: Number of events in the `events` array * **Example**: `50` (typical batch size) #### `entity_ids` (Batched Format only) * **Type**: `array` of `string` (UUID) | `null` * **Optional**: Yes * **Description**: Array of internal entity IDs corresponding to each event in `events` array * **Note**: Array length matches `events` array length. May contain `null` values for new entities. #### `event.resource_type` (Single Event Format) * **Type**: `string` (EntityType) * **Description**: Duplicate of root-level `resource_type` for convenience #### Event Object Fields (applies to both `event` and `events[]`) Each event object contains the following fields: ##### `resource_type` * **Type**: `string` (EntityType) * **Description**: The type of entity that changed * **Note**: Duplicate of root-level `resource_type` for convenience ##### `common_model_id` * **Type**: `string` (UUID) * **Description**: Stable identifier for the entity across all tenants * **Usage**: Use this ID to fetch the updated entity from the API ##### `hash` * **Type**: `string` (hex) * **Length**: 64 characters * **Description**: SHA-256 hash of the entity (for deduplication and idempotency) * **Usage**: Use this hash to detect duplicate webhook deliveries ##### `changed_at` * **Type**: `string` (ISO 8601 datetime) * **Format**: `YYYY-MM-DDTHH:mm:ssZ` * **Description**: When the change was detected (UTC) * **Example**: `"2024-01-15T10:30:00Z"` ### Format Detection To determine which format you received, check for the presence of fields: ```typescript function isBatchedPayload(payload: WebhookPayload): payload is BatchedWebhookPayload { return 'events' in payload && Array.isArray(payload.events); } function isSingleEventPayload(payload: WebhookPayload): payload is SingleEventWebhookPayload { return 'event' in payload && typeof payload.event === 'object'; } ``` ### When Each Format Is Used * **Single Event Format**: Used by legacy `ChangeLogWorker` (deprecated, but still supported) * **Batched Events Format**: Used by `BatchedChangeLogWorker` (recommended, default for new webhooks) **Recommendation**: Update your webhook handlers to support both formats for maximum compatibility. ## Signature Verification ### Algorithm Sports Stack uses **HMAC-SHA256** for webhook signature verification. ### Signature Header The signature is sent in the `X-SportsStack-Signature` header: ``` X-SportsStack-Signature: a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456 ``` ### Verification Process 1. Get the raw request body (as bytes or UTF-8 string) 2. Compute HMAC-SHA256 using your shared secret 3. Compare with the signature header using constant-time comparison ### Code Examples #### Python ```python import hmac import hashlib def verify_signature(payload_body, signature_header, secret): """ Verify webhook signature. 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) ``` #### Node.js ```javascript const crypto = require('crypto'); function verifySignature(payloadBody, signatureHeader, secret) { const expectedSignature = crypto .createHmac('sha256', secret) .update(payloadBody) .digest('hex'); return crypto.timingSafeEqual( Buffer.from(expectedSignature), Buffer.from(signatureHeader) ); } ``` #### Ruby ```ruby require 'openssl' def verify_signature(payload_body, signature_header, secret) expected_signature = OpenSSL::HMAC.hexdigest( 'sha256', secret, payload_body ) ActiveSupport::SecurityUtils.secure_compare( expected_signature, signature_header ) end ``` #### Go ```go import ( "crypto/hmac" "crypto/sha256" "encoding/hex" ) func verifySignature(payloadBody []byte, signatureHeader, secret string) bool { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(payloadBody) expectedSignature := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expectedSignature), []byte(signatureHeader)) } ``` ## Response Requirements ### Success Response Your endpoint must return a `2xx` status code to acknowledge receipt: **Accepted Status Codes**: * `200 OK` * `201 Created` * `202 Accepted` * `204 No Content` **Response Body**: Optional (ignored) ### Error Response Non-2xx status codes will trigger retries: **Retry Behavior**: * `4xx` (Client errors) - Retried with exponential backoff * `5xx` (Server errors) - Retried with exponential backoff * Network errors - Retried with exponential backoff **Max Attempts**: 10 attempts per webhook **Retry Schedule**: * Attempt 1: Immediate * Attempt 2: After 1 minute * Attempt 3: After 2 minutes * Attempt 4: After 4 minutes * Attempt 5+: Exponential backoff up to 10 attempts ## Fetching Updated Entities After receiving a webhook, fetch the updated entity using the `common_model_id`: ### API Endpoints by Resource Type | 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}` | | `standings` | `GET /api/v1/standings/{common_model_id}` | | `league` | `GET /api/v1/leagues/{common_model_id}` | | `season` | `GET /api/v1/seasons/{common_model_id}` | | `sport` | `GET /api/v1/sports/{common_model_id}` | ### Example Request ```bash curl -X GET "https://api.sportsstack.io/api/v1/events/550e8400-e29b-41d4-a716-446655440000" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Accept: application/json" ``` ## Entity Filtering ### Configuration Specify which entity types trigger webhooks using `entity_filters`: ```json { "entity_filters": ["event", "team", "player"] } ``` ### Available Entity Types * `team` * `player` * `event` * `league` * `season` * `sport` * `market` * `odd` * `event_scoped_stats` * `season_scoped_stats` * `standings` * `injury` * `news` ### Filter Behavior * **Empty array or null**: All entity types trigger webhooks * **Non-empty array**: Only specified entity types trigger webhooks * **Case-sensitive**: Entity type names must match exactly ## Change Detection ### How It Works 1. Entity is upserted successfully 2. Entity struct/map is sanitized (timestamps, `__meta__` removed) 3. Sanitized data is hashed using SHA-256 4. Hash is compared with previous hash in `entity_change_fingerprints` 5. If hash differs (or no previous hash exists), webhook is triggered 6. New hash is stored for future comparisons ### Hash Algorithm ```elixir # Pseudocode sanitized_data = remove_volatile_fields(entity) binary_data = :erlang.term_to_binary(sanitized_data) hash = :crypto.hash(:sha256, binary_data) hex_hash = Base.encode16(hash, case: :lower) ``` ### Idempotency Use the `hash` field to implement idempotency: ```python processed_hashes = set() def handle_webhook(payload): event_hash = payload['event']['hash'] if event_hash in processed_hashes: return Response(status=200) # Already processed # Process webhook process_entity_update(payload) # Mark as processed processed_hashes.add(event_hash) return Response(status=200) ``` ## Queue & Delivery ### Queue Configuration * **Queue Name**: `webhook_delivery` * **Concurrency**: 5 concurrent deliveries * **Timeout**: 5 seconds per request (configurable) * **Max Attempts**: 10 attempts per webhook ### Delivery Guarantees * **At-Least-Once**: Webhooks may be delivered multiple times * **Ordering**: No guaranteed ordering (process independently) * **Deduplication**: Use `hash` field for idempotency ### Monitoring * **Oban Dashboard**: Monitor queue health and job status * **CMS Recent API Calls**: View delivery attempts and responses * **Webhook Tester**: Inspect payloads and test configurations ## Error Handling ### Common Errors #### 404 Not Found **Cause**: Webhook URL is invalid or endpoint doesn't exist **Action**: Verify URL is correct and endpoint is accessible #### 401 Unauthorized **Cause**: Authentication failed (if using custom auth headers) **Action**: Check authorization headers in destination config #### 403 Forbidden **Cause**: Endpoint rejected the request **Action**: Check endpoint permissions and IP allowlist #### 500 Internal Server Error **Cause**: Endpoint encountered an error **Action**: Check endpoint logs and implement proper error handling #### Timeout **Cause**: Endpoint took longer than `timeout_ms` to respond **Action**: Increase `timeout_ms` or optimize endpoint performance ### Retry Behavior * **Exponential Backoff**: Retries with increasing delays * **Max Attempts**: 10 attempts before giving up * **Queue**: Failed webhooks remain in queue for retry ## Security Considerations ### HTTPS Required All webhook URLs must use HTTPS: ``` ✅ https://api.example.com/webhooks ❌ http://api.example.com/webhooks ``` ### Signature Verification Always verify the `X-SportsStack-Signature` header: ```python if not verify_signature(request.body, signature_header, shared_secret): return Response(status=401) ``` ### Tenant Validation Verify the `tenant_id` matches your expected tenant: ```python if payload['tenant_id'] != YOUR_TENANT_ID: return Response(status=403) ``` ### Rate Limiting Implement rate limiting on your webhook endpoint: ```python from flask_limiter import Limiter limiter = Limiter( app, key_func=lambda: request.remote_addr, default_limits=["1000 per hour"] ) ``` ## Testing ### Webhook Tester Use the CMS Webhook Tester to test webhook delivery: 1. Navigate to **Admin → Webhook Tester** 2. Select your tenant 3. Copy the generated test URL 4. Configure destination with test URL 5. Trigger data changes 6. Inspect payloads in the tester interface ### Local Testing with ngrok 1. Install ngrok: `brew install ngrok` 2. Start your local server 3. Expose with ngrok: `ngrok http 8000` 4. Configure webhook with ngrok HTTPS URL 5. Test webhook delivery ## Examples ### Flask Webhook Handler ```python from flask import Flask, request, Response import hmac import hashlib import json app = Flask(__name__) SHARED_SECRET = "your-secret-key" def verify_signature(payload_body, signature_header, secret): expected_signature = hmac.new( secret.encode('utf-8'), payload_body.encode('utf-8'), hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected_signature, signature_header) def is_batched_payload(payload): """Check if payload uses batched format""" return 'events' in payload and isinstance(payload['events'], list) @app.route('/webhook', methods=['POST']) def webhook_handler(): # Verify signature signature = request.headers.get('X-SportsStack-Signature') if not verify_signature(request.data.decode('utf-8'), signature, SHARED_SECRET): return Response(status=401) # Parse payload payload = request.json # Process webhook asynchronously process_webhook_async(payload) return Response(status=200) def process_webhook_async(payload): """Process webhook payload (supports both single and batched formats)""" if is_batched_payload(payload): # Batched format - process each event for event in payload['events']: process_single_event(payload['resource_type'], event) else: # Single event format (legacy) process_single_event( payload['resource_type'], payload['event'] ) def process_single_event(resource_type, event): """Process a single change event""" common_model_id = event['common_model_id'] event_hash = event['hash'] # Check for duplicates using hash if is_duplicate(event_hash): return # Fetch updated entity and update your database update_entity(resource_type, common_model_id) # Mark as processed mark_as_processed(event_hash) ``` ### Express.js Webhook Handler ```javascript const express = require('express'); const crypto = require('crypto'); const app = express(); const SHARED_SECRET = 'your-secret-key'; function verifySignature(payloadBody, signatureHeader, secret) { const expectedSignature = crypto .createHmac('sha256', secret) .update(payloadBody) .digest('hex'); return crypto.timingSafeEqual( Buffer.from(expectedSignature), Buffer.from(signatureHeader) ); } function isBatchedPayload(payload) { return payload.events && Array.isArray(payload.events); } app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.headers['x-sportsstack-signature']; const payloadBody = req.body.toString(); if (!verifySignature(payloadBody, signature, SHARED_SECRET)) { return res.status(401).send('Invalid signature'); } const payload = JSON.parse(payloadBody); // Process webhook asynchronously processWebhookAsync(payload); res.status(200).json({ status: 'ok' }); }); function processWebhookAsync(payload) { if (isBatchedPayload(payload)) { // Batched format - process each event payload.events.forEach(event => { processSingleEvent(payload.resource_type, event); }); } else { // Single event format (legacy) processSingleEvent(payload.resource_type, payload.event); } } function processSingleEvent(resourceType, event) { const { common_model_id, hash } = event; // Check for duplicates using hash if (isDuplicate(hash)) { return; } // Fetch updated entity and update your database updateEntity(resourceType, common_model_id); // Mark as processed markAsProcessed(hash); } ``` ## Related Documentation * [Webhooks Guide](../guides/webhooks_guide.md) - User-friendly guide * [Change Log Architecture](./change_log_webhooks.md) - Technical architecture * [Destination System](./destination_system_usage.md) - Configure destinations * [API Reference](../api_reference.md) - REST API documentation