Integration · 10 min read

Setting Up Webhooks for Real-Time Sports Data

Stop polling. Webhooks let the API push score updates, match status changes, and odds movements to your server the moment they happen. This guide covers endpoint setup, signature verification, retry logic, and the events that matter.

Last updated: August 2026

Overview

Polling an API every few seconds for live scores works at small scale, but it is wasteful and slow. If you have 1,000 users watching a match and you poll every 5 seconds, you burn 200 requests per minute even when nothing has changed. Webhooks flip the model: the provider sends you an HTTP POST only when an event occurs, so your request count scales with real-world events, not with your user count.

Sportradar and SportMonks both offer webhook delivery for match events. Not every provider does — API-Sports is polling-only — so part of your architecture decision is choosing a provider that supports push delivery if real-time is a hard requirement.

Pro tip: Webhooks and WebSockets solve different problems. Webhooks push server-to-server; WebSockets push server-to-browser. Many production apps use both: webhooks to ingest data, WebSockets to fan it out to clients. See our WebSocket vs Polling guide for the full comparison.

Webhook vs Polling: When to Use Each

Polling is simpler to implement but burns quota and adds latency. Webhooks are more efficient but require a publicly reachable endpoint and careful handling of failures. The right choice depends on your latency tolerance, provider support, and infrastructure.

AspectPollingWebhooks
LatencyPoll interval (1-10s)Near-instant (<1s)
Request costHigh (many empty polls)Low (per event only)
ImplementationSimple GET loopPOST endpoint + verification
Provider supportUniversalLimited (Sportradar, SportMonks)
Failure modeSilent stalenessMissed events (need replay)
InfrastructureCron / background jobPublic HTTPS endpoint

Setting Up a Webhook Endpoint

A webhook is just a POST endpoint on your server. The provider sends an event payload and expects a 200 response within a few seconds. If you do not respond quickly, the provider retries, which can cause duplicate processing. The golden rule: respond 200 immediately, then process the event asynchronously.

Next.js Route Handler

// app/api/webhooks/sports/route.js
import { NextResponse } from "next/server";
import { verifySignature } from "@/lib/webhook-verify";
import { enqueueEvent } from "@/lib/queue";

export async function POST(request) {
  const rawBody = await request.text(); // raw body for signature check
  const signature = request.headers.get("x-webhook-signature") || "";
  const eventType = request.headers.get("x-webhook-type") || "";

  // 1. Verify the signature (see next section)
  const valid = verifySignature(rawBody, signature, process.env.WEBHOOK_SECRET);
  if (!valid) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  // 2. Parse the payload
  const payload = JSON.parse(rawBody);

  // 3. Respond 200 IMMEDIATELY — do not do heavy work here
  //    Push the event to a queue and process it asynchronously
  await enqueueEvent({
    type: eventType,
    payload,
    receivedAt: new Date().toISOString(),
  });

  return NextResponse.json({ received: true }, { status: 200 });
}

// Health check for provider verification
export async function GET() {
  return NextResponse.json({ status: "ok" });
}

Signature Verification

A public endpoint receives traffic from anyone. To prove a request genuinely came from your provider — and not an attacker spoofing score updates — providers sign each payload with an HMAC using a shared secret. You recompute the signature over the raw request body and compare it to the header. Use a constant-time comparison to prevent timing attacks.

// lib/webhook-verify.js
import crypto from "crypto";

export function verifySignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;

  // Providers send "sha256=<hex>" — split the prefix if present
  const parts = signatureHeader.split("=");
  const algo = parts[0] === "sha256" ? "sha256" : "sha256";
  const provided = parts.length > 1 ? parts[1] : parts[0];

  const expected = crypto
    .createHmac(algo, secret)
    .update(rawBody) // must be the RAW body, not re-serialized JSON
    .digest("hex");

  // Constant-time comparison prevents timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(provided, "hex"),
    Buffer.from(expected, "hex")
  );
}

The critical detail is signing the raw request body, not the parsed-and-re-serialised JSON. JSON key ordering can differ between the provider and your parser, which would make the signature mismatch. Always capture the raw text first, as the route handler above does with request.text().

Retry Logic and Idempotency

Providers retry webhooks that do not return a 200 within their timeout (typically 5-10 seconds). If your endpoint is down or slow, you will receive the same event multiple times. This means every webhook handler must be idempotent: processing the same event twice must not create duplicate database rows or double-increment a score.

// lib/process-event.js
import { upsertScore } from "@/lib/db";

export async function processEvent(event) {
  const { type, payload } = event;

  // Use the provider's event ID as an idempotency key.
  // If we have already processed this ID, skip it.
  const eventId = payload.event_id || payload.id;
  if (await isAlreadyProcessed(eventId)) {
    console.log(`Skipping duplicate event ${eventId}`);
    return;
  }

  switch (type) {
    case "match.score":
      // upsert = insert or update, safe to call repeatedly
      await upsertScore({
        matchId: payload.match_id,
        home: payload.home_score,
        away: payload.away_score,
        minute: payload.minute,
        updatedAt: payload.timestamp,
      });
      break;

    case "match.started":
      await updateMatchStatus(payload.match_id, "in_progress");
      break;

    case "match.ended":
      await updateMatchStatus(payload.match_id, "finished");
      break;
  }

  // Record the event ID so retries are skipped
  await markProcessed(eventId);
}

Common Webhook Events

Providers expose different event taxonomies, but the core set is consistent across Sportradar, SportMonks, and API-Football. Here are the events you should handle in any live-score or betting application:

EventTriggerTypical Latency
match.startedKick-off / first pitch< 2 seconds
match.scoreGoal, touchdown, run scored< 1 second
match.scoreboardClock tick / period changeEvery 10-30 seconds
match.endedFull-time / final whistle< 2 seconds
odds.changedLine movement< 1 second
player.injuryInjury report updateMinutes

Code Examples

Full Webhook Pipeline with Queue and Replay

This example ties together verification, queueing, idempotent processing, and a manual replay endpoint so you can re-process events after a deployment or database migration.

// app/api/webhooks/sports/route.js
import { NextResponse } from "next/server";
import { verifySignature } from "@/lib/webhook-verify";
import { enqueue, replayEvent } from "@/lib/queue";

export async function POST(request) {
  const rawBody = await request.text();
  const sig = request.headers.get("x-webhook-signature") || "";
  const type = request.headers.get("x-webhook-type") || "";

  if (!verifySignature(rawBody, sig, process.env.WEBHOOK_SECRET)) {
    console.error("Webhook signature mismatch");
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const payload = JSON.parse(rawBody);
  const eventId = payload.event_id;

  await enqueue({ id: eventId, type, payload });

  // Always 200 so the provider does not retry and flood the queue
  return NextResponse.json({ ok: true, eventId }, { status: 200 });
}

// Manual replay endpoint for ops/debugging
export async function PUT(request) {
  const { eventId } = await request.json();
  await replayEvent(eventId);
  return NextResponse.json({ replayed: true, eventId });
}

Best Practices

Respond 200 before processing

Acknowledge receipt within 2 seconds by returning 200, then process asynchronously via a queue. Slow handlers cause the provider to time out and retry, creating duplicate events that burn your idempotency logic.

Always verify the signature

Never process a webhook without validating its HMAC signature. An unauthenticated endpoint lets anyone push fake score updates into your system. Use a constant-time comparison and sign the raw body.

Make handlers idempotent

Store processed event IDs and use upserts instead of inserts. Providers retry aggressively, and duplicate events are guaranteed in production. Idempotency is not optional — it is the only thing standing between you and corrupted data.

Keep a dead-letter queue and a replay endpoint

Events that fail processing should land in a dead-letter queue, not disappear. Build a replay endpoint so you can re-process failed events after fixing a bug or deploying a schema migration.

Use polling as a fallback reconciliation

Webhooks can be missed during outages or deploys. Run a periodic polling job every few minutes to reconcile the latest state. This catches any events the webhook missed and keeps your data authoritative.

Related Guides

Find providers with webhook support

Filter providers by webhook and real-time capabilities to build a push-based architecture.