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.
/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 webhooksPayload 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 IDusername- Discord usernamerating_before- Elo before matchrating_after- Elo after matchrating_change- Elo deltamatches- Total matches playedmax_elo- Highest rating achievedmin_elo- Lowest rating achievedplacements- Placement counts
Team Entry Fields
For team rating types, entries include:
team_id- Team identifiermembers- 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:
| Header | Description |
|---|---|
Content-Type | application/json |
X-Signature | sha256= followed by the lowercase hex HMAC-SHA256 of the raw request body, keyed with your signing secret |
X-Timestamp | Unix time in seconds when the request was sent. Not covered by the signature |
X-Event-Type | Event type (e.g., leaderboard_ratings) |
User-Agent | TeamUpBot/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:
- Read the request body as raw bytes, before any JSON parsing.
- 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. - Hex-encode the result in lowercase and put
sha256=in front. - Compare that with the
X-Signatureheader 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', 200Best 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...