← Back to home page Deutsch

🔌 REST API v1

Time clock integration for external applications – e.g. FiveM servers, custom bots or scripts

The public REST API lets external applications clock Discord users in and out, query their clock status and send heartbeats. All requests go through the base URL:

https://shiftwatch.de/api/v1

🔑 Authentication

Every request needs a guild-scoped API key. Server admins create keys in the Admin Dashboard → Settings → API Keys (max. 5 active keys per server).

⚠️ Important: The full key is shown only once when it is created. Store it securely (server-side config, never in client code). If a key leaks, revoke it in the dashboard – access is cut off immediately.

Send the key in one of two headers:

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

The key determines which Discord server the request applies to – there is no guild parameter.

📡 Endpoints

POST /clock-in

Clocks a user in. The user must be a member of the Discord server.

Example request

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

Response 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"
  }
}

Side effects are identical to clocking in via the Discord button: the on-duty role is assigned, a log embed is posted to the configured log channel (source: "via API"), the leaderboard refreshes and open dashboards receive a live update.

POST /clock-out

Clocks a user out.

Example request

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

Response 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

Resets the auto-clockout timer for up to 100 users in one request – the API equivalent of pressing the "I'm still here" button in Discord. Send this periodically (e.g. every 5 minutes) for all players currently online on your game server. Users who are not clocked in are simply reported back with clocked_in: false (no error), so you can use the response to keep your local state in sync.

If a user already received an auto-clockout warning in Discord (e.g. they just rejoined the game), the heartbeat clears the pending warning and deletes the warning message – they will not be auto-clocked-out.

Example request

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

Response 200

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

Returns the current clock status of a user. Unknown users return clocked_in: false.

Example request

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

Response 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
  }
}

⚠️ Errors

All errors use the shape { "error": "<code>", "message": "<human readable>" }.

Status Code Meaning
400invalid_requestMissing/invalid user_id / user_ids (must be Discord IDs, 17–20 digits; heartbeat: 1–100 per request)
401missing_api_keyNo API key header sent
401invalid_api_keyKey unknown, malformed or revoked
404user_not_in_guildThe user is not a member of the guild
409already_clocked_inClock-in refused; response includes current_session
409not_clocked_inClock-out refused; the user has no open session
429rate_limitedRate limit exceeded, retry later
503guild_unavailableThe bot is not on the guild or not ready yet

🚦 Rate limits

Standard RateLimit-* response headers are included.

💓 Auto-clockout & heartbeats – how they work together

The bot has a built-in auto-clockout: after a configurable period without activity, the user receives a warning in Discord and is automatically clocked out if they don't react in time. Without further action, a player clocked in via the API would have to confirm that warning in Discord every few hours – while actively playing. The heartbeat endpoint solves this. The recommended integration pattern:

  1. Clock-in when the player starts their duty (POST /clock-in).
  2. Heartbeat loop: every ~5 minutes, send one POST /heartbeat with the Discord IDs of all clocked-in players currently online. As long as heartbeats arrive, the timer keeps resetting and no Discord warning is ever sent.
  3. Clock-out on disconnect: hook your platform's disconnect event (FiveM: playerDropped) and call POST /clock-out – clean sessions end immediately when the player leaves.
  4. Crash safety net: if the game server crashes or the disconnect handler never runs, heartbeats simply stop. After the configured inactivity period the normal Discord flow takes over: a warning (the user can still react to it), otherwise auto-clockout. No session runs forever.

Pick a heartbeat interval well below the server's configured warning time (5 minutes is plenty) and batch all players into one request – 100 users per call keeps you far away from the rate limit.

🎮 Integration examples

The pattern is the same on every platform: resolve the user's Discord ID, call clock-in/clock-out at the right moments and send heartbeats while the user is active. The following examples are ready-made starting points for the most common use cases:

FiveM exposes each player's Discord ID via the discord: identifier. Complete server-side integration with clock-in/out commands, heartbeat loop and disconnect handling:

Set the API key via set stempeluhr_api_key "sk_..." in your server.cfg – never put it 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 has no Discord identifiers – the mapping is handled by a linking system like Bloxlink, which players link once (standard in most Roblox Discord communities anyway). The server script resolves the Discord ID via the Bloxlink API and clocks players in and out when they join or leave the game:

Requirements: enable HttpService (Game Settings → Security → "Allow HTTP Requests") and use both keys only in server scripts (ServerScriptService) – never in LocalScripts or 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)

On Minecraft servers (Paper/Spigot), DiscordSRV provides the link between Minecraft accounts and Discord IDs – players link once via /discord link. A small plugin then clocks them in on join and out on quit:

Store the API key in the plugin's config.yml – never hardcode it or commit it to your repository.

// 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);
    }
}

You can also use the API from your own Discord bot – for example for voice duty tracking: whoever joins the "on duty" voice channel gets clocked in, whoever leaves gets clocked out. The bot has the Discord IDs right at 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);

For scripts, cron jobs, custom tools or a kiosk terminal, a minimal Python client is all you need:

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"))

In general, any environment that can send HTTP requests works – e.g. Garry's Mod, Rust, alt:V, RAGE:MP, custom intranet tools or automation platforms like n8n. The only requirement: you can map the user to a Discord ID. Missing an example for your platform? Reach out on the support server.