# Webhooks

RoundsWon webhooks send every tournament event to your server as it happens: players joining, matches ready, results and finished tournaments, signed and retried for four days.

A webhook is an address on your server that RoundsWon sends every tournament event to, the moment it happens. Use it to post results in Discord, update your own site, start the next match on a game server, or keep your own copy of a tournament.

## Add a webhook

On your [developer page](https://roundswon.com/developer), add your address under Webhooks. Pick one tournament or every tournament you host, and every event or only some. Your address must be public and use https.

Press **Send a test** to check it works. Your server should answer with any 2xx status within 10 seconds.

## What arrives

Each event is a POST with a JSON body:

```json
{
  "id": "evt_6745f1c2a9d3e8b1f0a2c4d6",
  "object": "event",
  "tournament": { "id": "6745f1...", "slug": "abc123" },
  "seq": 14,
  "type": "match.result",
  "at": "2026-11-14T20:15:00.000Z",
  "by": { "kind": "host", "id": "6745f0..." },
  "refs": { "stage": 0, "match": "r1-0" },
  "data": { "scores": [2, 1] },
  "match": { "key": "r1-0", "status": "done", "sides": [{ "entry": "a1b2c3", "score": 2, "result": "win" }, { "entry": "d4e5f6", "score": 1, "result": "loss" }] }
}
```

- `id` is unique. You may get an event twice, so skip an `id` you have handled.
- `seq` is the event's place in the tournament's list. The engine's consequences of an event (`match.ready`, `tournament.finished`) share its `seq` and arrive right after it, with ids ending `_1`, `_2`.
- `by.kind` is who sent it: `host`, `admin`, `scorer`, `player`, or `system` for the engine's own consequences.
- `match` is the match as it is now, when the event is about one.

## Every event type

| Type | When |
| --- | --- |
| `tournament.created`, `tournament.updated` | The tournament was made or its settings changed |
| `tournament.started`, `tournament.finished` | The first stage started, everyone was placed |
| `tournament.cancelled`, `tournament.verified` | The host cancelled it, or confirmed the results |
| `staff.added`, `staff.removed` | An admin or scorer was added or removed |
| `entry.added`, `entry.updated`, `entry.removed` | A player or team entered, changed seed, or left |
| `entry.checked_in`, `entry.disqualified`, `entry.opted_in` | Check in, disqualification, a sponsor email opt in |
| `stage.started`, `stage.finished`, `stage.round_opened` | A stage started or ended, the next swiss round was drawn |
| `match.ready` | Both sides of a match are known, it can be played |
| `match.reported`, `match.disputed` | A captain reported a score, two reports disagree |
| `match.result` | A match has its result |
| `score.submitted`, `score.rejected` | A leaderboard score came in or was turned down |
| `payout.sent`, `payout.unsent`, `payout.received` | A prize was paid, taken back, or the winner says it arrived |

## Check it came from RoundsWon

Every request has a `RoundsWon-Signature` header like `t=1763150100,v1=5f8a...`. `v1` is an HMAC SHA256 of the timestamp, a dot and the raw body, keyed with your webhook's signing secret (`whsec_...`, on your developer page). Check it before trusting the event, and turn down old timestamps so a recorded request can not be replayed.

```js
import crypto from 'node:crypto'
import express from 'express'

const app = express()
app.post('/roundswon', express.text({ type: '*/*' }), (req, res) => {
  const [, t, v1] = req.get('RoundsWon-Signature').match(/t=(\d+),v1=(\w+)/)
  const expected = crypto.createHmac('sha256', process.env.ROUNDSWON_SECRET).update(`${t}.${req.body}`).digest('hex')
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300
  if (!fresh || !crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) return res.sendStatus(400)
  const event = JSON.parse(req.body)
  if (event.type === 'match.result') console.log(`Match ${event.refs.match} finished ${event.data.scores.join(' to ')}`)
  res.sendStatus(200)
})
app.listen(3000)
```

```python
import hashlib, hmac, json, os, time
from flask import Flask, request

app = Flask(__name__)

@app.post('/roundswon')
def roundswon():
    parts = dict(p.split('=') for p in request.headers['RoundsWon-Signature'].split(','))
    body = request.get_data(as_text=True)
    expected = hmac.new(os.environ['ROUNDSWON_SECRET'].encode(), f"{parts['t']}.{body}".encode(), hashlib.sha256).hexdigest()
    if abs(time.time() - int(parts['t'])) > 300 or not hmac.compare_digest(parts['v1'], expected):
        return '', 400
    event = json.loads(body)
    return '', 200
```

## Retries

If your server does not answer with a 2xx, RoundsWon tries again after 1 minute, 5 minutes, 30 minutes, 2, 6 and 12 hours, then once a day, for about four days in all. After the last try the webhook is switched off and you get an email. Turn it back on from your developer page once it is fixed, then catch up with `GET /tournaments/{code}/events?after={seq}` from the [API](https://roundswon.com/docs/api).

The developer page shows the last 50 sends for each webhook, with what your server answered.

## Order

Events usually arrive in order, but a retry can land after a newer event. Use `seq` to put them in order, and the event list to fill any gap.
