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
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:
- Hash Comparison: Each entity is hashed after successful upsert
- Fingerprint Storage: Previous hash is stored in
entity_change_fingerprints - Change Detection: Only entities with changed hashes trigger webhooks
- 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
-
Navigate to Integrations → Destinations in the CMS
-
Click "New Destination"
-
Select "Webhook" as the destination type
-
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_logstream - 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:
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)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:
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:
{
"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:
{
"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
{
"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)
{
"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
{
"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:
{
"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:
{
"status": "ok"
}Status Codes Accepted:
200 OK201 Created202 Accepted204 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 backoff429 Too Many Requests- Retried with backoff- Network errors (DNS, connection refused, timeout) - Retried with backoff
Status Codes That Immediately Discard (No Retry):
400 Bad Request401 Unauthorized403 Forbidden404 Not Found405 Method Not Allowed406 Not Acceptable410 Gone415 Unsupported Media Type422 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
# 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:
# 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
{
"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
teamplayereventleagueseasonsportmarketoddevent_scoped_statsseason_scoped_statsstandingsinjurynews
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:
# 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:
if payload['tenant_id'] != YOUR_TENANT_ID:
return Response(status=403)4. Rate Limiting
Implement rate limiting on your webhook endpoint to prevent abuse:
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:
# 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
- Recent API Calls: View webhook delivery attempts in Integrations → Recent API Calls
- Webhook Tester: Use Admin → Webhook Tester to inspect payloads
- Destination Status: Check destination health in Integrations → Destinations
Common Issues
Webhooks Not Firing
- Check Destination Status: Ensure destination is
is_active: true - Verify Stream Subscription: Confirm
change_logstream is selected - Check Entity Filters: Ensure entity type is included in filters
- Verify Change Detection: Entity hash must actually change
Webhooks Firing Too Often
- Check Hash Comparison: Verify change detection is working
- Review Entity Filters: Narrow filters to specific entity types
- Check Provider Updates: Multiple provider updates may trigger multiple webhooks
Signature Verification Failing
- Verify Shared Secret: Ensure secret matches in destination config
- Check Payload Encoding: Use raw request body (bytes, not parsed JSON) for signature verification
- 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
hashfield 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:
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:
curl -X POST "https://api.sportsstack.io/api/v1/webhooks/test?destination_id=YOUR_DEST_UUID" \
-H "Authorization: Bearer YOUR_API_KEY"Using ngrok
- Install ngrok:
brew install ngrok(or download from ngrok.com) - Start your local server:
python app.py - Expose your local server:
ngrok http 8000 - Copy the HTTPS URL:
https://abc123.ngrok.io - Configure webhook destination with ngrok URL
- Use the test endpoint or trigger real data changes
Using a Request Catcher
For quick inspection without writing any code:
- Visit webhook.site to get a temporary URL
- Configure it as your webhook destination URL
- Use the test endpoint to send a test payload
- 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:
# 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:
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:
@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 retried4. Log Everything
Log webhook deliveries for debugging:
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:
- Check Admin → Webhook Tester for payload inspection
- Review Integrations → Recent API Calls for delivery status
- Check destination configuration in Integrations → Destinations
- Review logs for error messages
Updated 18 days ago
