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

FieldTypeRequiredDescription
urlstringYesHTTPS endpoint URL to receive webhooks
shared_secretstringNoSecret for HMAC-SHA256 signature verification
headersobjectNoCustom HTTP headers to include in requests
timeout_msintegerNoRequest timeout in milliseconds (default: 5000)

HTTP Request Specification

Request Method

POST {webhook_url}

Request Headers

HeaderValueDescription
Content-Typeapplication/jsonPayload content type
X-SportsStack-Signature{hmac-sha256}HMAC-SHA256 signature (if shared secret configured)
User-AgentSportsStack-Webhooks/1.0Webhook 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:

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

{
  "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

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

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

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
  )
end

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 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}
standingsGET /api/v1/standings/{common_model_id}
leagueGET /api/v1/leagues/{common_model_id}
seasonGET /api/v1/seasons/{common_model_id}
sportGET /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

  • 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

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

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:

  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

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