Skip to content
Docs Webhooks

Documentation

Webhooks

Receive real-time notifications when ratings change in your leaderboards

What are Webhooks?

Webhooks allow your server or application to receive automatic notifications when events occur in Team Up. Instead of polling the API for changes, your endpoint receives a POST request with the event data.

Currently, webhooks are available for leaderboard_ratings events, which fire whenever a match is recorded and player or team ratings are updated.

Managing Webhooks

Web dashboard — Admin → API & Webhooks

Webhooks are set up on your league's dashboard, on the Webhooks tab of API & Webhooks under Admin. Registering a webhook is a Pro feature.

  • Register — pick an event, paste your endpoint URL, and get a signing secret.
  • Edit the URL in place — repointing an endpoint keeps the same secret, so consumers verifying it keep working.
  • Send a test — fires a signed sample payload and shows the status code your endpoint returned.
  • Pause / resume — stop delivery while your endpoint is down without discarding the config.
  • Rotate the secret — mint a new signing secret when the old one may have leaked.
Open the dashboard

/settings webhooks

From Discord you can check what's registered — event type, URL, masked secret, and whether delivery is paused. The reply carries an Edit on Web button that opens the dashboard page for everything else.

/settings webhooks

Payload Structure

leaderboard_ratings Event

Sent when a match is recorded and ratings are updated. Includes all affected rating types.

{
  "event": "leaderboard_ratings",
  "timestamp": "2024-12-10T15:30:00.000Z",
  "guild_id": "123456789012345678",

  "leaderboard": {
    "leaderboard_id": "ranked",
    "name": "Ranked"
  },

  "match": {
    "match_id": "abc123",
    "recorded_by": "987654321098765432",
    "recorded_at": "2024-12-10T15:30:00.000Z",
    "versus": "1v1",
    "teams": [
      {
        "team_number": 1,
        "result": "win",
        "place": 1,
        "players": [
          { "user_id": "111111111111111111", "username": "Winner" }
        ]
      },
      {
        "team_number": 2,
        "result": "loss",
        "place": 2,
        "players": [
          { "user_id": "222222222222222222", "username": "Loser" }
        ]
      }
    ]
  },

  "rating_updates": [
    {
      "rating_type": "player_global_all",
      "total_entries": 0,
      "affected_entries": [
        {
          "user_id": "111111111111111111",
          "username": "Winner",
          "rating_before": 1500,
          "rating_after": 1525,
          "rating_change": 25,
          "matches": 42,
          "max_elo": 1550,
          "min_elo": 1400,
          "placements": { "1": 20, "2": 15 }
        }
      ]
    }
  ]
}

Player Entry Fields

  • user_id - Discord user ID
  • username - Discord username
  • rating_before - Elo before match
  • rating_after - Elo after match
  • rating_change - Elo delta
  • matches - Total matches played
  • max_elo - Highest rating achieved
  • min_elo - Lowest rating achieved
  • placements - Placement counts

Team Entry Fields

For team rating types, entries include:

  • team_id - Team identifier
  • members - Array of team members
  • ...plus all rating fields above

Note: Leaderboard rank is not included in webhook payloads to optimize performance. Use the REST API to fetch current rankings when needed.

total_entries is not populated yet: real deliveries always send 0 (a dashboard test send fills in a sample number), so don't read the leaderboard's size from it.

HTTP Headers

Each webhook request includes the following headers:

HeaderDescription
Content-Typeapplication/json
X-Signaturesha256= followed by the lowercase hex HMAC-SHA256 of the raw request body, keyed with your signing secret
X-TimestampUnix time in seconds when the request was sent. Not covered by the signature
X-Event-TypeEvent type (e.g., leaderboard_ratings)
User-AgentTeamUpBot/1.0

Verifying Signatures

Security Note

Signature verification is optional but recommended. It ensures the webhook payload came from Team Up and hasn't been tampered with in transit.

Every delivery is signed with the secret shown on your league's Webhooks page. To check one:

  1. Read the request body as raw bytes, before any JSON parsing.
  2. Compute the HMAC-SHA256 of those bytes. The key is your whole signing secret, including the whsec_ prefix, as UTF-8 text; don't hex- or base64-decode it.
  3. Hex-encode the result in lowercase and put sha256= in front.
  4. Compare that with the X-Signature header using a constant-time comparison. Parse the JSON only once it matches.

Sign the bytes you received, not the JSON you parsed. Parsing the body and serialising it again (JSON.stringify(req.body), json.dumps(data)) isn't guaranteed to give back the same bytes: whitespace, key order, number formatting and unicode escaping can all change, and then a genuine delivery fails the check. Body-parsing middleware also consumes the request stream, so read the raw body on the webhook route before it runs.

X-Timestamp is not part of what's signed, and Team Up enforces no time window on it, so it can't protect you from a replayed request on its own. If each event must be processed once, deduplicate on match.match_id.

Node.js (Express)

const crypto = require('crypto');
const express = require('express');

const app = express();
const SECRET = process.env.TEAMUP_WEBHOOK_SECRET; // whsec_…

function verifySignature(rawBody, signature, secret) {
  const expected = 'sha256=' +
    crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature || '');
  // timingSafeEqual throws when the lengths differ, so check that first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw() keeps the body as a Buffer: the exact bytes Team Up signed.
// Register this route BEFORE any app.use(express.json()), or the JSON parser
// consumes the body first.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifySignature(req.body, req.get('X-Signature'), SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Process the webhook...
  res.status(200).send('OK');
});

Fetch API (Workers, Deno, Bun, Next.js)

// Any runtime with the Fetch API Request and Web Crypto. handleWebhook takes
// the signing secret (whsec_…) as an argument; wire it into yours:
//
// Cloudflare Workers:
//   export default { fetch: (req, env) => handleWebhook(req, env.TEAMUP_WEBHOOK_SECRET) };
// Next.js route handler (app/api/teamup/route.js):
//   export const POST = (req) => handleWebhook(req, process.env.TEAMUP_WEBHOOK_SECRET);
// Deno:
//   Deno.serve((req) => handleWebhook(req, Deno.env.get('TEAMUP_WEBHOOK_SECRET')));
// Bun:
//   Bun.serve({ fetch: (req) => handleWebhook(req, process.env.TEAMUP_WEBHOOK_SECRET) });

async function verifySignature(rawBody, signature, secret) {
  if (!signature || !signature.startsWith('sha256=')) return false;
  const hex = signature.slice('sha256='.length);
  if (!/^[0-9a-f]{64}$/.test(hex)) return false;
  const given = new Uint8Array(hex.match(/../g).map((h) => parseInt(h, 16)));

  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['verify']
  );
  // subtle.verify compares in constant time.
  return crypto.subtle.verify('HMAC', key, given, rawBody);
}

async function handleWebhook(request, secret) {
  // Read the bytes before parsing anything.
  const rawBody = await request.arrayBuffer();
  const ok = await verifySignature(rawBody, request.headers.get('X-Signature'), secret);
  if (!ok) return new Response('Invalid signature', { status: 401 });

  const event = JSON.parse(new TextDecoder().decode(rawBody));
  // Process the webhook...
  return new Response('OK');
}

Python (Flask)

import hashlib
import hmac
import os

from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ['TEAMUP_WEBHOOK_SECRET']  # whsec_…

def verify_signature(raw_body: bytes, signature, secret: str) -> bool:
    expected = 'sha256=' + hmac.new(
        secret.encode('utf-8'),
        raw_body,
        hashlib.sha256
    ).hexdigest()
    # Compare bytes: a missing header becomes b'' rather than an error.
    return hmac.compare_digest(expected.encode(), (signature or '').encode())

@app.route('/webhook', methods=['POST'])
def webhook():
    raw_body = request.get_data()  # the bytes as received
    if not verify_signature(raw_body, request.headers.get('X-Signature'), SECRET):
        return 'Invalid signature', 401

    event = request.get_json()
    # Process the webhook...
    return 'OK', 200

Best Practices

✅

Do

  • Use HTTPS endpoints
  • Verify signatures in production
  • Respond quickly (<10 seconds)
  • Return 2xx status codes on success
  • Process webhooks asynchronously
  • Store your secret securely
❌

Don't

  • Expose your webhook secret
  • Do heavy processing synchronously
  • Rely on webhook order
  • Ignore signature verification
  • Use HTTP in production

Delivery Behavior

Fire and Forget

Webhooks are sent asynchronously and do not block match recording. If your endpoint fails to respond, the match is still recorded successfully. Webhooks are not retried on failure.

Timeout

Your endpoint must respond within 10 seconds. If processing takes longer, return a 200 status immediately and process the data asynchronously.

Redirects

Redirects are not followed for security reasons. Ensure your webhook URL points directly to your final endpoint.

Test Payloads

When you press Send Test on the dashboard, the payload includes a "test": true field. You can use this to identify test requests in your webhook handler.

// In the Express handler above, once verifySignature() has passed:
const event = JSON.parse(req.body.toString('utf8'));

if (event.test === true) {
  console.log('Received test webhook');
  return res.status(200).send('Test received');
}

// Process real webhook...