Documentation menu

Docs/Information/Web API Reference

Web API Reference

Every /api/v1 endpoint - method, scope, parameters, responses, examples.

Requires the WEB_API feature; endpoints needing the moderate or write scope additionally require WEB_API_WRITE. Start with the Web API overview for keys, scopes, rate limits, and error codes.

Base URL and authentication

All endpoints live under your dashboard host, keyed by your server ID:

https://<dashboard-host>/api/v1/guilds/<guild-id>

Every request sends your key (prefix hbk_) as a bearer token:

curl -H "Authorization: Bearer hbk_..." \
  "https://<dashboard-host>/api/v1/guilds/<guild-id>/stats"

Paths below are relative to that base URL. IDs (server, user, infraction) are strings in every response - Discord snowflakes overflow common JSON number parsers. Timestamps are ISO 8601. Request bodies are JSON with a Content-Type: application/json header. Unknown body fields are rejected, not ignored.

Moderation actions can target anyone

API moderation deliberately bypasses the bot's level hierarchy. Commands run in Discord refuse to touch the server owner or anyone at or above the invoker's level - the actions endpoint has no such guard. A key with the moderate scope can ban, kick, or mute any member, including moderators, admins, and the server owner; only Discord's own role position limits what the bot can physically do. This is intentional - it lets a server build security tooling that can act on its own staff if an account is compromised - so treat moderate and write keys like the owner's password. The one refusal that always applies: the bot will not action itself. An action targeting the bot's own account comes back as a failed result instead of running.

Infractions

List infractions

GET /infractions - scope read

Query param Meaning
page page number, default 1
limit rows per page, 1 to 200, default 100
type numeric infraction type to filter by
user filter to one user ID
active 1 to return only active infractions
curl -H "Authorization: Bearer hbk_..." \
  "https://<dashboard-host>/api/v1/guilds/<guild-id>/infractions?page=1&limit=100"
{
  "guild_id": "289887088211771392",
  "page": 1,
  "pages": 4,
  "total": 371,
  "infractions": [
    {
      "id": "1204",
      "user_id": "80351110224678912",
      "actor_id": "103890994440728576",
      "type": 4,
      "type_name": "BAN",
      "reason": "spam",
      "expires_at": null,
      "created_at": "2026-01-15T18:03:12.000Z",
      "active": true
    }
  ]
}

Get one infraction

GET /infractions/{id} - scope read

Returns a single infraction in the same shape as the list rows (plus its guild_id). 404 if the ID does not exist in this server.

Edit an infraction

PATCH /infractions/{id} - scope write

Body - send at least one field:

Field Meaning
reason new reason, up to 1024 characters
expires_at new expiry as an ISO timestamp, or null for permanent
curl -X PATCH -H "Authorization: Bearer hbk_..." \
  -H "Content-Type: application/json" \
  -d '{"reason": "raid cleanup", "expires_at": null}' \
  "https://<dashboard-host>/api/v1/guilds/<guild-id>/infractions/1204"

Returns the updated infraction. The bot's expiry sweep picks up a changed expires_at on its own - no restart or extra call needed.

Moderation actions

Issue an action

POST /infractions/actions - scope moderate

Body:

Field Meaning
action one of ban, unban, kick, softban, tempban, mute, tempmute, unmute, warn, note, timeout
user_id the target user ID
reason optional, up to 1024 characters
duration_seconds required for tempban, tempmute, timeout; positive integer, max 31536000 (365 days)
delete_message_days optional, 0 to 7; applies to ban

The dashboard has no Discord connection of its own, so the action is queued durably and the bot carries it out: the response is 202 with a job_id, not the final outcome. The queue survives restarts - an accepted action is never lost.

curl -X POST -H "Authorization: Bearer hbk_..." \
  -H "Content-Type: application/json" \
  -d '{"action": "tempban", "user_id": "80351110224678912",
       "reason": "compromised account", "duration_seconds": 86400}' \
  "https://<dashboard-host>/api/v1/guilds/<guild-id>/infractions/actions"
{
  "status": "accepted",
  "job_id": 5120,
  "action": "tempban",
  "user_id": "80351110224678912",
  "poll": "/api/v1/guilds/<guild-id>/infractions/actions/5120"
}

Actions run quietly (no DM to the target) and are attributed to the API key in the infraction record and mod log.

Idempotency. Send an optional Idempotency-Key header (up to 128 characters) with a value you choose per action. A retry with the same key returns the original job (202 with "status": "duplicate" and its job_id) instead of issuing the action twice. If two requests with the same key land at the same moment, the loser gets 409 - retry shortly and you will receive the original job. Always set this header from scripts that retry on network errors; without it, a retried request is a second ban.

Poll an action's outcome

GET /infractions/actions/{job_id} - scope moderate

{
  "job_id": 5120,
  "status": "done",
  "result": {
    "ok": true,
    "action": "tempban",
    "user_id": "80351110224678912",
    "infraction_id": "1205"
  },
  "error": null,
  "created_at": "2026-01-15T18:03:12.000Z",
  "finished_at": "2026-01-15T18:03:13.000Z"
}

Status semantics - read these carefully:

  • pending / running - the action has not been applied yet; poll again.
  • done - the job ran, but done does not mean the action succeeded. Check result.ok. A failed Discord call (missing permissions, target above the bot's role, no mute role configured, the bot targeting itself) is recorded as result.ok: false with result.error explaining why - it is deliberately not retried, since re-running an ambiguous moderation action could double-issue it.
  • failed - the job machinery itself errored; the first line of the error is in the top-level error field.

On success, result.infraction_id is the new infraction's ID. One caveat: unmute is best-effort, and its ok: true carries a note saying it cannot confirm a mute role was configured or removed.

Job IDs are only visible to keys for the same server; anything else is a 404.

Export

GET /infractions/export - scope export

Returns the server's complete infraction history as one JSON document (including each row's metadata), sent as a file attachment:

curl -H "Authorization: Bearer hbk_..." \
  "https://<dashboard-host>/api/v1/guilds/<guild-id>/infractions/export" \
  -o infractions.json

Export is throttled to once per 30 days per server. Inside the window you get 429 with last_export_at, retry_after (an ISO timestamp), and retry_after_seconds. The throttle is durable - it does not reset on restarts.

Import

POST /infractions/import - scope import

Bring another bot's moderation history into HepBoat. Body:

{
  "infractions": [
    {
      "user_id": "80351110224678912",
      "type": "ban",
      "actor_id": "103890994440728576",
      "reason": "imported from previous bot",
      "created_at": "2024-06-01T12:00:00Z",
      "expires_at": null,
      "active": false,
      "metadata": {"source": "old-bot"}
    }
  ]
}
  • user_id and type are required; everything else is optional.
  • type is a numeric infraction type or its name (ban, kick, mute, warning, ...).
  • Up to 10,000 rows per request - batch larger histories.
  • Rows default to active: false (historical records, not live punishments). Set active: true only for punishments that should still be enforced.
  • created_at defaults to now, so set it to the original timestamp to keep the history's ordering.

Success returns the number imported plus your remaining quota:

{"imported": 250, "remaining_quota": 99750, "import_limit": 100000}

Imports count toward a cumulative per-server quota; a request that would exceed it is rejected whole with 409 and the remaining quota, so nothing partial lands. The API page shows your current usage.

Bans

GET /bans - scope read

The server's Discord ban list, mirrored by the bot with each ban's stored reason.

Query param Meaning
limit 1 to 1000, default 100
offset skip this many rows
{
  "guild_id": "289887088211771392",
  "total": 812,
  "limit": 100,
  "offset": 0,
  "bans": [
    {"user_id": "80351110224678912", "reason": "spam"}
  ]
}

User rap sheet

GET /users/{user_id} - scope read

One user's moderation record in this server: their infractions (up to the 100 most recent), active-punishment summaries, and any native Discord ban.

{
  "guild_id": "289887088211771392",
  "user_id": "80351110224678912",
  "total_infractions": 7,
  "active_ban": true,
  "active_mute": false,
  "native_ban": {"reason": "spam"},
  "infractions": [ ... ]
}

active_ban is true when an active ban-type infraction exists or the user is natively banned on Discord; native_ban is null when they are not.

Stats

GET /stats - scope read

{
  "guild_id": "289887088211771392",
  "infractions": 371,
  "bans": 812,
  "top_infracted_users": [
    {"user_id": "80351110224678912", "count": 7}
  ]
}

top_infracted_users is the five most-infracted members.

Levels

Leaderboard

GET /levels - scope read

Query param Meaning
track XP track name, default main
limit 1 to 100, default 25
offset skip this many rows
{
  "guild_id": "289887088211771392",
  "track": "main",
  "total": 4210,
  "tracks": ["main"],
  "limit": 25,
  "offset": 0,
  "leaderboard": [
    {"rank": 1, "user_id": "80351110224678912", "points": 52340}
  ]
}

tracks lists every track that exists in the server.

Adjust a member's XP

POST /levels/{user_id}/xp - scope write

Body: {"amount": 500, "track": "main"}. amount is an integer; a negative amount deducts, and the total is clamped at 0. track defaults to main. Returns the member's new total:

{"user_id": "80351110224678912", "track": "main", "points": 52840}

Role rewards sync on the member's next natural XP grant, not instantly.

Tags

List tags

GET /tags - scope read

{
  "guild_id": "289887088211771392",
  "tags": [
    {"name": "rules", "content": "Read #rules.", "times_used": 42,
     "created_at": "2025-11-02T09:00:00.000Z"}
  ]
}

Create or update a tag

POST /tags - scope write

Body: {"name": "rules", "content": "Read #rules."}. name is 1 to 64 characters, content 1 to 2000. Creates the tag or overwrites an existing one with the same name (an upsert). The change lands in your mod log attributed to API: <key label>.

Delete a tag

DELETE /tags?name=rules - scope write

404 if no such tag exists.

Economy

Balances leaderboard

GET /economy - scope read

Query param Meaning
limit 1 to 100, default 25
offset skip this many rows
{
  "guild_id": "289887088211771392",
  "total": 913,
  "limit": 25,
  "offset": 0,
  "balances": [
    {"rank": 1, "user_id": "80351110224678912", "balance": 12000,
     "lifetime_earned": 15000, "lifetime_spent": 3000}
  ]
}

One member's balance

GET /economy/{user_id} - scope read

{
  "guild_id": "289887088211771392",
  "user_id": "80351110224678912",
  "balance": 12000,
  "lifetime_earned": 15000,
  "lifetime_spent": 3000
}

A member who has never used the economy reads as zero, not 404.

Good to know

  • Every key is rate-limited to 120 requests per minute; see the overview for the full error table.
  • Infraction type values: 0 MUTE, 1 KICK, 2 TEMPBAN, 3 SOFTBAN, 4 BAN, 5 TEMPMUTE, 6 UNBAN, 7 TEMPROLE, 8 WARNING, 9 NOTE, 10 MUTEHARD, 11 TEMPMUTEHARD, 12 CLEANBAN, 13 TIMEOUT, 14 SELFMUTE. Responses always carry both type and type_name.
  • Wrap poll loops with a timeout and treat result.ok: false as the authoritative failure signal - done alone is not success.