Developers · Server
Webhooks
Ocheso calls your server when things happen: entries, starts, rounds, results. Each delivery is signed, so you can tell it came from Ocheso and wasn't changed on the way.
Set one up
- Studio → Developers → Webhooks → Add endpoint. Give an https URL on port 443 and pick the events (none picked means all).
- Copy the signing secret (
whsec_…): it is shown once. Store it on your server asOCHESO_WEBHOOK_SECRET. - Press Send test. Studio lists the last 20 deliveries with their status, and can send any again.
Webhooks come with the Club and Federation plans. Addresses that are IPs, localhost, internal names or ocheso.com itself are refused; for local testing use a tunnel with a public https name.
What arrives
A POST with a JSON body: the event's type, when it happened, and its data. Your own users carry their external_id, so you can match them without storing Ocheso's ids.
HTTP
POST /your/webhook HTTP/1.1
Content-Type: application/json
User-Agent: Ocheso-Webhooks/1
webhook-id: msg_2b6f0c4e9a8d4f1e8c3b5a7d9e0f1a2b
webhook-timestamp: 1793023200
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"type": "participant.joined",
"timestamp": "2026-11-06T17:40:00.000Z",
"data": {
"tournament": { "id": "…", "slug": "friday-blitz" },
"participant": {
"id": "…", "status": "approved", "joined_via": "platform",
"profile_id": "…", "kind": "member", "name": "Jean-Paul M.",
"external_id": "user-42"
}
}
}Check the signature
Ocheso follows Standard Webhooks, so its libraries work too. By hand: the signature is base64(HMAC-SHA256(key, "id.timestamp.body")), where key is your secret after whsec_, base64-decoded, and body is the request body exactly as received.
import crypto from "node:crypto";
// Standard Webhooks. Pass the raw body: parsing and re-serialising changes the bytes.
export function verifyOcheso(rawBody, headers, secret = process.env.OCHESO_WEBHOOK_SECRET) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
const sig = headers["webhook-signature"];
if (!id || !ts || !sig) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // over 5 minutes old: a replay
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto.createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest("base64");
// While a secret is being rotated the header carries two signatures.
return sig.split(" ").some((part) => {
const [version, value] = part.split(",");
return version === "v1" && value?.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected));
});
}- Refuse timestamps more than five minutes from now: that stops an old delivery being replayed.
- While you rotate a secret,
webhook-signaturecarries two signatures separated by a space, one per secret, for 24 hours. Accept either. - Compare in constant time, as the snippets do.
Wired into an Express route:
Node
import express from "express";
import { verifyOcheso } from "./verify-ocheso.js"; // the Node snippet above
const app = express();
// The raw body, exactly as sent: a parsed-and-restringified body won't verify.
app.post("/webhooks/ocheso", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyOcheso(req.body.toString("utf8"), req.headers)) return res.status(400).end();
const event = JSON.parse(req.body);
// Deliveries can repeat: webhook-id is the same each time, so remember it.
if (alreadyHandled(req.headers["webhook-id"])) return res.status(200).end();
if (event.type === "participant.joined") grantBadge(event.data.participant.external_id);
res.status(200).end(); // answer fast; do slow work after
});Events
| Type | Sent when |
|---|---|
| tournament.created · updated | A tournament was made or changed. |
| tournament.published | Entries opened. |
| tournament.started | It went live: entries closed, round 1 paired. |
| tournament.postponed | Too few players at the start time; autopilot moved it (the note says to when). |
| tournament.finished · archived | It ended, or was archived or cancelled. |
| participant.joined · approved · rejected · withdrawn · removed | Someone's entry changed. |
| invite.claimed · declined · lapsed | An email invitation was taken up, turned down, or expired at the start. |
| round.published · started · finished | A round was paired, began, or all its games are done. |
| game.started · finished | A game began or ended. |
| autopilot.stalled | Autopilot couldn't take the next step; the error says why. Studio shows it too. |
| install.first_seen | A widget was seen on a new site for the first time. |
| webhook.test | You pressed Send test in Studio. |
Answering, retries and failures
- Answer with any 2xx within 10 seconds. Anything else, or no answer, is a failure.
- Failures are retried after about 30 s, 2 min, 10 min, 30 min, 2 h, 6 h, 12 h and 24 h (each ±10%). After 8 tries the delivery is marked failed; you can still send it again from Studio.
- An endpoint that has failed for 3 days, or 200 times in a row, is switched off and your admins get an email. Turn it back on in Studio once it's fixed.
- Deliveries can arrive twice or out of order. Use
webhook-idto skip repeats and the event'stimestamp, not arrival order, to decide what's newest. - Ocheso never stores your responses' bodies.