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:

  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:

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

FieldTypeDescription
streamstringAlways "change_log" for change log webhooks
resource_typestringEntity type for all events in this batch: team, event, player, market, etc.
tenant_idUUIDYour tenant identifier
eventsarrayArray of change event objects (see below)
batch_sizeintegerNumber of events in the events array

Event Object Fields

Each object in the events array contains:

FieldTypeDescription
resource_typestringEntity type (same as top-level resource_type)
common_model_idUUIDStable identifier for the entity across all tenants
hashstringHex-encoded SHA-256 fingerprint of the entity data (for deduplication)
changed_atISO 8601 datetimeWhen the change was detected (UTC, second precision)
league_idUUID (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:

HeaderDescription
Content-TypeAlways application/json
X-SportsStack-SignatureHMAC-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 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

# 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 TypeAPI Endpoint
eventGET /api/v1/events/{common_model_id}
teamGET /api/v1/teams/{common_model_id}
playerGET /api/v1/players/{common_model_id}
marketGET /api/v1/markets/{common_model_id}
event_scoped_statsGET /api/v1/event-stats/{common_model_id}
season_scoped_statsGET /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

  • 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:

# 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

  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:

AttemptDelay
130 seconds
260 seconds
32 minutes
45 minutes
510 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

  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 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:

# 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 retried

4. 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:

  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