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.
| Aspect | Polling | Webhooks |
|---|---|---|
| Latency | Poll interval (1-10s) | Near-instant (<1s) |
| Request cost | High (many empty polls) | Low (per event only) |
| Implementation | Simple GET loop | POST endpoint + verification |
| Provider support | Universal | Limited (Sportradar, SportMonks) |
| Failure mode | Silent staleness | Missed events (need replay) |
| Infrastructure | Cron / background job | Public 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:
| Event | Trigger | Typical Latency |
|---|---|---|
| match.started | Kick-off / first pitch | < 2 seconds |
| match.score | Goal, touchdown, run scored | < 1 second |
| match.scoreboard | Clock tick / period change | Every 10-30 seconds |
| match.ended | Full-time / final whistle | < 2 seconds |
| odds.changed | Line movement | < 1 second |
| player.injury | Injury report update | Minutes |
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
WebSocket vs Polling for Sports Data
12 min readHow to Integrate a Sports API
15 min readError Handling & Retry Strategies
14 min readFind providers with webhook support
Filter providers by webhook and real-time capabilities to build a push-based architecture.