← Zurück zur Startseite English

🔌 REST-API v1

Stempeluhr-Anbindung für externe Anwendungen – z.B. FiveM-Server, eigene Bots oder Scripts

Über die öffentliche REST-API können externe Anwendungen Discord-User ein- und ausstempeln, ihren Stempel-Status abfragen und Heartbeats senden. Alle Requests laufen über die Basis-URL:

https://shiftwatch.de/api/v1

🔑 Authentifizierung

Jeder Request benötigt einen server-gebundenen API-Key. Server-Admins erstellen Keys im Admin-Dashboard → Einstellungen → API-Keys (max. 5 aktive Keys pro Server).

⚠️ Wichtig: Der vollständige Key wird nur einmal bei der Erstellung angezeigt. Bewahre ihn sicher auf (Server-Konfiguration, nie im Client-Code). Falls ein Key kompromittiert wird: im Dashboard widerrufen – der Zugriff endet sofort.

Der Key wird in einem von zwei Headern mitgeschickt:

Authorization: Bearer sk_<64 hex chars>
X-API-Key: sk_<64 hex chars>

Der Key bestimmt, für welchen Discord-Server der Request gilt – es gibt keinen Guild-Parameter.

📡 Endpunkte

POST /clock-in

Stempelt einen User ein. Der User muss Mitglied des Discord-Servers sein.

Beispiel-Request

curl -X POST https://shiftwatch.de/api/v1/clock-in \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{"user_id": "123456789012345678"}'

Antwort 200

{
  "success": true,
  "user_id": "123456789012345678",
  "guild_id": "987654321098765432",
  "source": "api",
  "entry": {
    "id": 42,
    "clock_in": 1751882400,
    "clock_in_iso": "2026-07-07T10:00:00.000Z"
  }
}

Die Nebeneffekte sind identisch zum Einstempeln per Discord-Button: On-Duty-Rolle wird vergeben, ein Log-Embed erscheint im konfigurierten Log-Channel (Quelle: „via API"), die Rangliste aktualisiert sich und offene Dashboards erhalten ein Live-Update.

POST /clock-out

Stempelt einen User aus.

Beispiel-Request

curl -X POST https://shiftwatch.de/api/v1/clock-out \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{"user_id": "123456789012345678"}'

Antwort 200

{
  "success": true,
  "user_id": "123456789012345678",
  "guild_id": "987654321098765432",
  "source": "api",
  "entry": {
    "id": 42,
    "clock_in": 1751882400,
    "clock_out": 1751911200,
    "clock_in_iso": "2026-07-07T10:00:00.000Z",
    "clock_out_iso": "2026-07-07T18:00:00.000Z",
    "duration_minutes": 480
  }
}
POST /heartbeat

Setzt den Auto-Clockout-Timer für bis zu 100 User in einem Request zurück – das API-Äquivalent zum „Ich bin noch da"-Button in Discord. Regelmäßig senden (z.B. alle 5 Minuten) für alle Spieler, die gerade auf dem Game-Server online sind. Nicht eingestempelte User werden ohne Fehler mit clocked_in: false zurückgemeldet – so lässt sich der lokale Zustand synchron halten.

Hat ein User bereits eine Auto-Clockout-Warnung in Discord erhalten (z.B. gerade erst wieder ins Spiel gekommen), räumt der Heartbeat die Warnung auf und löscht die Warn-Nachricht – der User wird nicht automatisch ausgestempelt.

Beispiel-Request

curl -X POST https://shiftwatch.de/api/v1/heartbeat \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{"user_ids": ["123456789012345678", "234567890123456789"]}'

Antwort 200

{
  "success": true,
  "guild_id": "987654321098765432",
  "results": [
    { "user_id": "123456789012345678", "clocked_in": true },
    { "user_id": "234567890123456789", "clocked_in": false }
  ]
}
GET /status/:userId

Liefert den aktuellen Stempel-Status eines Users. Unbekannte User liefern clocked_in: false.

Beispiel-Request

curl https://shiftwatch.de/api/v1/status/123456789012345678 \
  -H "Authorization: Bearer sk_..."

Antwort 200

{
  "user_id": "123456789012345678",
  "guild_id": "987654321098765432",
  "clocked_in": true,
  "session": {
    "id": 42,
    "clock_in": 1751882400,
    "clock_in_iso": "2026-07-07T10:00:00.000Z",
    "duration_minutes": 125
  }
}

⚠️ Fehler

Alle Fehler haben die Form { "error": "<code>", "message": "<Beschreibung>" }.

Status Code Bedeutung
400invalid_requestFehlende/ungültige user_id / user_ids (Discord-IDs, 17–20 Ziffern; Heartbeat: 1–100 pro Request)
401missing_api_keyKein API-Key-Header gesendet
401invalid_api_keyKey unbekannt, fehlerhaft oder widerrufen
404user_not_in_guildDer User ist kein Mitglied des Servers
409already_clocked_inEinstempeln abgelehnt; die Antwort enthält current_session
409not_clocked_inAusstempeln abgelehnt; der User hat keine offene Sitzung
429rate_limitedRate-Limit überschritten, später erneut versuchen
503guild_unavailableDer Bot ist nicht auf dem Server oder noch nicht bereit

🚦 Rate-Limits

Standard-RateLimit-*-Header sind in den Antworten enthalten.

💓 Auto-Clockout & Heartbeats – so greifen sie ineinander

Der Bot hat einen eingebauten Auto-Clockout: Nach einer konfigurierbaren Zeit ohne Aktivität erhält der User eine Warnung in Discord und wird automatisch ausgestempelt, wenn er nicht rechtzeitig reagiert. Ohne Gegenmaßnahme müsste ein per API eingestempelter Spieler diese Warnung alle paar Stunden in Discord bestätigen – mitten im Spiel. Der Heartbeat-Endpunkt löst das. Empfohlenes Integrationsmuster:

  1. Clock-in beim Dienstantritt des Spielers (POST /clock-in).
  2. Heartbeat-Loop: Alle ~5 Minuten ein POST /heartbeat mit den Discord-IDs aller eingestempelten Spieler, die gerade online sind. Solange Heartbeats ankommen, wird der Timer zurückgesetzt und es gibt nie eine Discord-Warnung.
  3. Clock-out beim Disconnect: Den Disconnect-Event der Plattform abfangen (FiveM: playerDropped) und POST /clock-out aufrufen – saubere Sitzungen enden sofort, wenn der Spieler geht.
  4. Absicherung bei Crashes: Stürzt der Game-Server ab oder läuft der Disconnect-Handler nie, bleiben die Heartbeats einfach aus. Nach der konfigurierten Inaktivitätszeit greift der normale Discord-Ablauf: Warnung (auf die der User noch reagieren kann), sonst Auto-Clockout. Keine Sitzung läuft ewig.

Das Heartbeat-Intervall deutlich unter der konfigurierten Warnzeit des Servers wählen (5 Minuten reichen völlig) und alle Spieler in einen Request packen – mit 100 Usern pro Aufruf bleibt man weit unter dem Rate-Limit.

🎮 Integrationsbeispiele

Das Muster ist auf jeder Plattform gleich: Discord-ID des Users ermitteln, an den passenden Stellen Clock-in/Clock-out aufrufen und Heartbeats senden, solange der User aktiv ist. Die folgenden Beispiele sind fertige Startpunkte für die gängigsten Anwendungsfälle:

FiveM liefert die Discord-ID jedes Spielers über den discord:-Identifier. Vollständige serverseitige Integration mit Ein-/Ausstempel-Commands, Heartbeat-Loop und Disconnect-Handling:

Den API-Key per set stempeluhr_api_key "sk_..." in der server.cfg hinterlegen – niemals in Client-Code!

local API_BASE = "https://shiftwatch.de/api/v1"
local API_KEY = GetConvar("stempeluhr_api_key", "")

local function getDiscordId(src)
    for _, id in ipairs(GetPlayerIdentifiers(src)) do
        if id:sub(1, 8) == "discord:" then
            return id:sub(9)
        end
    end
end

local function apiPost(path, payload, cb)
    PerformHttpRequest(API_BASE .. path, function(status, body)
        if cb then cb(status, body and json.decode(body) or nil) end
    end, "POST", json.encode(payload), {
        ["Content-Type"] = "application/json",
        ["Authorization"] = "Bearer " .. API_KEY
    })
end

-- Discord ID -> true for every clocked-in player
local clockedIn = {}

RegisterCommand("clockin", function(src)
    local discordId = getDiscordId(src)
    if not discordId then return end
    apiPost("/clock-in", { user_id = discordId }, function(status)
        if status == 200 then
            clockedIn[discordId] = true
            -- TODO: notify the player in-game
        end
    end)
end, false)

RegisterCommand("clockout", function(src)
    local discordId = getDiscordId(src)
    if not discordId then return end
    apiPost("/clock-out", { user_id = discordId }, function(status)
        if status == 200 or status == 409 then
            clockedIn[discordId] = nil
        end
    end)
end, false)

-- Disconnect: clock out immediately
AddEventHandler("playerDropped", function()
    local discordId = getDiscordId(source)
    if discordId and clockedIn[discordId] then
        apiPost("/clock-out", { user_id = discordId })
        clockedIn[discordId] = nil
    end
end)

-- Heartbeat loop: every 5 minutes for all clocked-in online players.
-- Prevents the auto-clockout warning in Discord while playing.
CreateThread(function()
    while true do
        Wait(5 * 60 * 1000)
        local ids = {}
        for _, src in ipairs(GetPlayers()) do
            local discordId = getDiscordId(src)
            if discordId and clockedIn[discordId] then
                ids[#ids + 1] = discordId
            end
        end
        if #ids > 0 then
            apiPost("/heartbeat", { user_ids = ids }, function(status, body)
                if status == 200 and body then
                    -- Sync local state (e.g. an admin clocked the
                    -- player out in the dashboard -> clocked_in: false)
                    for _, r in ipairs(body.results) do
                        if not r.clocked_in then
                            clockedIn[r.user_id] = nil
                        end
                    end
                end
            end)
        end
    end
end)

Roblox kennt keine Discord-IDs – die Zuordnung läuft über ein Verknüpfungssystem wie Bloxlink, mit dem sich Spieler einmalig verknüpfen (in den meisten Roblox-Discord-Communities ohnehin Standard). Das Server-Script löst die Discord-ID über die Bloxlink-API auf und stempelt beim Betreten und Verlassen des Spiels:

Voraussetzungen: HttpService aktivieren (Game Settings → Security → „Allow HTTP Requests") und beide Keys nur in Server-Scripts verwenden (ServerScriptService) – nie in LocalScripts oder ReplicatedStorage.

-- Server script (ServerScriptService)
local HttpService = game:GetService("HttpService")
local Players = game:GetService("Players")

local API_BASE = "https://shiftwatch.de/api/v1"
local API_KEY = "sk_..."           -- e.g. from a private server module
local BLOXLINK_KEY = "..."         -- Bloxlink server key (Developer API)
local DISCORD_GUILD_ID = "987654321098765432"

-- Resolve the Discord ID of a linked player via the Bloxlink API
local discordIdCache = {}
local function getDiscordId(player)
    if discordIdCache[player.UserId] ~= nil then
        return discordIdCache[player.UserId]
    end
    local ok, res = pcall(function()
        return HttpService:RequestAsync({
            Url = "https://api.blox.link/v4/public/guilds/" .. DISCORD_GUILD_ID
                .. "/roblox-to-discord/" .. player.UserId,
            Method = "GET",
            Headers = { Authorization = BLOXLINK_KEY }
        })
    end)
    local discordId
    if ok and res.Success then
        local data = HttpService:JSONDecode(res.Body)
        discordId = data.discordIDs and data.discordIDs[1] or nil
    end
    discordIdCache[player.UserId] = discordId
    return discordId
end

local function apiPost(path, payload)
    pcall(function()
        HttpService:RequestAsync({
            Url = API_BASE .. path,
            Method = "POST",
            Headers = {
                ["Content-Type"] = "application/json",
                ["Authorization"] = "Bearer " .. API_KEY
            },
            Body = HttpService:JSONEncode(payload)
        })
    end)
end

-- Clock in on join, clock out on leave
Players.PlayerAdded:Connect(function(player)
    local discordId = getDiscordId(player)
    if discordId then
        apiPost("/clock-in", { user_id = discordId })
    end
end)

Players.PlayerRemoving:Connect(function(player)
    local discordId = discordIdCache[player.UserId]
    if discordId then
        apiPost("/clock-out", { user_id = discordId })
    end
end)

-- Heartbeat loop: every 5 minutes for all linked online players
task.spawn(function()
    while true do
        task.wait(300)
        local ids = {}
        for _, player in ipairs(Players:GetPlayers()) do
            local discordId = discordIdCache[player.UserId]
            if discordId then
                table.insert(ids, discordId)
            end
        end
        if #ids > 0 then
            apiPost("/heartbeat", { user_ids = ids })
        end
    end
end)

Auf Minecraft-Servern (Paper/Spigot) liefert DiscordSRV die Verknüpfung zwischen Minecraft-Account und Discord-ID – Spieler verknüpfen sich einmalig per /discord link. Ein kleines Plugin stempelt dann beim Join und Quit:

Den API-Key in der config.yml des Plugins ablegen – nie im Code hardcoden oder ins Repository committen.

// Paper/Spigot plugin, DiscordSRV as the account link source
public final class StempeluhrHook implements Listener {

    private static final String API_BASE = "https://shiftwatch.de/api/v1";
    private final String apiKey;   // load from config.yml
    private final HttpClient http = HttpClient.newHttpClient();

    public StempeluhrHook(String apiKey) {
        this.apiKey = apiKey;
    }

    private void apiPost(String path, String json) {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(API_BASE + path))
                .header("Authorization", "Bearer " + apiKey)
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(json))
                .build();
        http.sendAsync(request, HttpResponse.BodyHandlers.ofString());
    }

    private String getDiscordId(Player player) {
        return DiscordSRV.getPlugin().getAccountLinkManager()
                .getDiscordId(player.getUniqueId());
    }

    @EventHandler
    public void onJoin(PlayerJoinEvent event) {
        String discordId = getDiscordId(event.getPlayer());
        if (discordId != null) {
            apiPost("/clock-in", "{\"user_id\": \"" + discordId + "\"}");
        }
    }

    @EventHandler
    public void onQuit(PlayerQuitEvent event) {
        String discordId = getDiscordId(event.getPlayer());
        if (discordId != null) {
            apiPost("/clock-out", "{\"user_id\": \"" + discordId + "\"}");
        }
    }

    // Heartbeat every 5 minutes for all linked online players
    public void startHeartbeat(JavaPlugin plugin) {
        Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, () -> {
            List<String> ids = new ArrayList<>();
            for (Player player : Bukkit.getOnlinePlayers()) {
                String discordId = getDiscordId(player);
                if (discordId != null) {
                    ids.add(discordId);
                }
            }
            if (!ids.isEmpty()) {
                String json = "{\"user_ids\": [\""
                        + String.join("\", \"", ids) + "\"]}";
                apiPost("/heartbeat", json);
            }
        }, 20L * 60, 20L * 300);
    }
}

Die API lässt sich auch aus einem eigenen Discord-Bot nutzen – zum Beispiel für Voice-Dienstzeit: Wer den „Im Dienst"-Voice-Channel betritt, wird eingestempelt, wer ihn verlässt, wird ausgestempelt. Die Discord-IDs hat der Bot direkt zur Hand:

const { Client, GatewayIntentBits } = require('discord.js');

const API_BASE = 'https://shiftwatch.de/api/v1';
const API_KEY = process.env.STEMPELUHR_API_KEY;
const DUTY_CHANNEL_ID = '123456789012345678'; // "on duty" voice channel

async function apiPost(path, payload) {
    const res = await fetch(API_BASE + path, {
        method: 'POST',
        headers: {
            'Authorization': 'Bearer ' + API_KEY,
            'Content-Type': 'application/json'
        },
        body: JSON.stringify(payload)
    });
    return res.json();
}

const client = new Client({
    intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates]
});

// Joining the duty channel clocks in, leaving clocks out
client.on('voiceStateUpdate', async (oldState, newState) => {
    const joined = newState.channelId === DUTY_CHANNEL_ID
        && oldState.channelId !== DUTY_CHANNEL_ID;
    const left = oldState.channelId === DUTY_CHANNEL_ID
        && newState.channelId !== DUTY_CHANNEL_ID;

    if (joined) await apiPost('/clock-in', { user_id: newState.id });
    if (left) await apiPost('/clock-out', { user_id: newState.id });
});

// Heartbeat every 5 minutes for everyone in the duty channel
setInterval(async () => {
    const channel = await client.channels.fetch(DUTY_CHANNEL_ID);
    const ids = [...channel.members.keys()];
    if (ids.length > 0) {
        await apiPost('/heartbeat', { user_ids: ids });
    }
}, 5 * 60 * 1000);

client.login(process.env.DISCORD_TOKEN);

Für Scripts, Cron-Jobs, eigene Tools oder ein Kiosk-Terminal reicht ein minimaler Python-Client:

import os
import requests

API_BASE = "https://shiftwatch.de/api/v1"

session = requests.Session()
session.headers.update({
    "Authorization": "Bearer " + os.environ["STEMPELUHR_API_KEY"]
})

def clock_in(user_id):
    return session.post(API_BASE + "/clock-in",
                        json={"user_id": user_id}).json()

def clock_out(user_id):
    return session.post(API_BASE + "/clock-out",
                        json={"user_id": user_id}).json()

def heartbeat(user_ids):
    return session.post(API_BASE + "/heartbeat",
                        json={"user_ids": user_ids}).json()

def status(user_id):
    return session.get(API_BASE + "/status/" + user_id).json()

# Example: clock a user in and show the running session
print(clock_in("123456789012345678"))
print(status("123456789012345678"))

Grundsätzlich funktioniert jede Umgebung, die HTTP-Requests senden kann – z.B. auch Garry's Mod, Rust, alt:V, RAGE:MP, eigene Intranet-Tools oder Automatisierungen wie n8n. Einzige Voraussetzung: Du kannst den User einer Discord-ID zuordnen. Fehlt dir ein Beispiel für deine Plattform? Melde dich auf dem Support-Server.