Skip to content
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 as OCHESO_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-signature carries 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

TypeSent when
tournament.created · updatedA tournament was made or changed.
tournament.publishedEntries opened.
tournament.startedIt went live: entries closed, round 1 paired.
tournament.postponedToo few players at the start time; autopilot moved it (the note says to when).
tournament.finished · archivedIt ended, or was archived or cancelled.
participant.joined · approved · rejected · withdrawn · removedSomeone's entry changed.
invite.claimed · declined · lapsedAn email invitation was taken up, turned down, or expired at the start.
round.published · started · finishedA round was paired, began, or all its games are done.
game.started · finishedA game began or ended.
autopilot.stalledAutopilot couldn't take the next step; the error says why. Studio shows it too.
install.first_seenA widget was seen on a new site for the first time.
webhook.testYou 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-id to skip repeats and the event's timestamp, not arrival order, to decide what's newest.
  • Ocheso never stores your responses' bodies.