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
{
"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
Sports Stack supports two payload formats for webhooks:
- Single Event Format (legacy, still supported)
- Batched Events Format (recommended, more efficient)
Single Event Format
Used for individual event deliveries (legacy format, still supported for backward compatibility):
{
"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):
{
"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
// 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
stream- Type:
string - Value: Always
"change_log" - Description: Identifies the webhook stream type
resource_type
resource_type- Type:
string(EntityType) - Description: The type of entity that changed
- Possible Values: See EntityType above
tenant_id
tenant_id- Type:
string(UUID) - Description: Your tenant identifier
- Format:
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
entity_id (Single Event Format only)
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)
events (Batched Format only)- Type:
arrayof 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)
batch_size (Batched Format only)- Type:
number(integer) - Required: Yes (for batched format)
- Description: Number of events in the
eventsarray - Example:
50(typical batch size)
entity_ids (Batched Format only)
entity_ids (Batched Format only)- Type:
arrayofstring(UUID) |null - Optional: Yes
- Description: Array of internal entity IDs corresponding to each event in
eventsarray - Note: Array length matches
eventsarray length. May containnullvalues for new entities.
event.resource_type (Single Event Format)
event.resource_type (Single Event Format)- Type:
string(EntityType) - Description: Duplicate of root-level
resource_typefor convenience
Event Object Fields (applies to both event and events[])
event and events[])Each event object contains the following fields:
resource_type
resource_type- Type:
string(EntityType) - Description: The type of entity that changed
- Note: Duplicate of root-level
resource_typefor convenience
common_model_id
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
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
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:
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
- Get the raw request body (as bytes or UTF-8 string)
- Compute HMAC-SHA256 using your shared secret
- Compare with the signature header using constant-time comparison
Code Examples
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
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
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
)
endGo
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 OK201 Created202 Accepted204 No Content
Response Body: Optional (ignored)
Error Response
Non-2xx status codes will trigger retries:
Retry Behavior:
4xx(Client errors) - Retried with exponential backoff5xx(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
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:
{
"entity_filters": ["event", "team", "player"]
}Available Entity Types
teamplayereventleagueseasonsportmarketoddevent_scoped_statsseason_scoped_statsstandingsinjurynews
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
- Entity is upserted successfully
- Entity struct/map is sanitized (timestamps,
__meta__removed) - Sanitized data is hashed using SHA-256
- Hash is compared with previous hash in
entity_change_fingerprints - If hash differs (or no previous hash exists), webhook is triggered
- New hash is stored for future comparisons
Hash Algorithm
# 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:
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
hashfield 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:
if not verify_signature(request.body, signature_header, shared_secret):
return Response(status=401)Tenant Validation
Verify the tenant_id matches your expected tenant:
if payload['tenant_id'] != YOUR_TENANT_ID:
return Response(status=403)Rate Limiting
Implement rate limiting on your webhook endpoint:
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:
- Navigate to Admin → Webhook Tester
- Select your tenant
- Copy the generated test URL
- Configure destination with test URL
- Trigger data changes
- Inspect payloads in the tester interface
Local Testing with ngrok
- Install ngrok:
brew install ngrok - Start your local server
- Expose with ngrok:
ngrok http 8000 - Configure webhook with ngrok HTTPS URL
- Test webhook delivery
Examples
Flask Webhook Handler
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
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 - User-friendly guide
- Change Log Architecture - Technical architecture
- Destination System - Configure destinations
- API Reference - REST API documentation
Updated 29 days ago
