Server API

Webhooks

With a v2 key, Uplink can send your server a signed JSON POST whenever something happens: a new case, a mod call, a session starting, a staff member clocking in.

#Adding one

  • Add up to 3 webhooks per key with POST /webhooks, giving an https url and the events you want.
  • The answer has the webhook's secret. It is shown once, so save it next to your key.
  • POST /webhooks/:id/test sends a ping so you can check your address works.
  • After 20 failed deliveries in a row the webhook is switched off. Fix your address, then call POST /webhooks/:id/enable.
Terminal
curl -X POST https://uplinkrblx.xyz/api/server/v2/webhooks \
  -H "Authorization: Bearer usk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://bot.example.com/uplink","events":["case.created","modcall"]}'

#Every delivery

Every delivery looks like {"id", "event", "server", "at", "data"}. Answer with any 2xx within 5 seconds.

HeaderWhat it holds
x-uplink-eventThe event name, like case.created
x-uplink-timestampWhen it was sent, in seconds
x-uplink-signaturesha256= and the HMAC-SHA256 of timestamp.body with your webhook secret

#Events

EventWhen it is sent
case.createdA case was made, in Uplink, in game or through the API
case.voidedA case was voided
session.startedA session started
session.endedA session ended, with its stats
modcallA player called for a moderator in game
shift.startedA staff member clocked in
shift.endedA staff member clocked out

#case.created

JSON
{
  "id": "b1f0c3e2-…",
  "event": "case.created",
  "server": "1234567890123456789",
  "at": 1760000000000,
  "data": {
    "case": {
      "number": 42,
      "type": "warn",
      "status": "active",
      "target": { "robloxId": "1520318804", "name": "DashCamDan" },
      "moderator": { "discordId": "200000000000000007", "name": "Ava (API: Staff bot)" },
      "reason": "Driving on the sidewalk",
      "evidence": null,
      "inGame": true,
      "createdAt": 1760000000000,
      "voidedAt": null,
      "voidedBy": null
    }
  }
}

#case.voided

JSON
{
  "id": "b1f0c3e2-…",
  "event": "case.voided",
  "server": "1234567890123456789",
  "at": 1760000300000,
  "data": {
    "case": {
      "number": 42,
      "type": "warn",
      "status": "voided",
      "target": { "robloxId": "1520318804", "name": "DashCamDan" },
      "reason": "Driving on the sidewalk",
      "voidedAt": 1760000300000,
      "voidedBy": "200000000000000007"
    }
  }
}

#session.started

JSON
{
  "id": "b1f0c3e2-…",
  "event": "session.started",
  "server": "1234567890123456789",
  "at": 1760000000000,
  "data": {
    "session": {
      "id": 7,
      "name": "Evening patrol",
      "message": "Peacetime is on.",
      "startedBy": { "discordId": "200000000000000007", "name": "Ava" },
      "startedAt": 1760000000000,
      "endedAt": null,
      "endedBy": null,
      "peakPlayers": 0
    }
  }
}

#session.ended

JSON
{
  "id": "b1f0c3e2-…",
  "event": "session.ended",
  "server": "1234567890123456789",
  "at": 1760007200000,
  "data": {
    "session": { "id": 7, "name": "Evening patrol", "endedAt": 1760007200000 },
    "stats": { "joins": 48, "cases": 3 }
  }
}

#modcall

JSON
{
  "id": "b1f0c3e2-…",
  "event": "modcall",
  "server": "1234567890123456789",
  "at": 1760000000000,
  "data": {
    "caller": { "robloxId": "1520318802", "name": "RiverCityRP" },
    "at": 1760000000000
  }
}

#shift.started

JSON
{
  "id": "b1f0c3e2-…",
  "event": "shift.started",
  "server": "1234567890123456789",
  "at": 1760000000000,
  "data": {
    "shift": {
      "id": 12,
      "discordId": "200000000000000001",
      "name": "builderman",
      "robloxId": "1520318801",
      "startedAt": 1760000000000,
      "endedAt": null,
      "seconds": null
    }
  }
}

#shift.ended

JSON
{
  "id": "b1f0c3e2-…",
  "event": "shift.ended",
  "server": "1234567890123456789",
  "at": 1760003600000,
  "data": {
    "shift": {
      "id": 12,
      "discordId": "200000000000000001",
      "name": "builderman",
      "robloxId": "1520318801",
      "startedAt": 1760000000000,
      "endedAt": 1760003600000,
      "seconds": 3600
    }
  }
}

#Check every delivery

Check the signature on every delivery, and refuse anything older than five minutes. Use the raw body exactly as it arrived, before parsing it.

Node.js
import crypto from "node:crypto";

function verify(req, rawBody, secret) {
  const t = req.headers["x-uplink-timestamp"];
  const want = "sha256=" + crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const got = String(req.headers["x-uplink-signature"] ?? "");
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
  return fresh && got.length === want.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want));
}