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, butdonedoes not mean the action succeeded. Checkresult.ok. A failed Discord call (missing permissions, target above the bot's role, no mute role configured, the bot targeting itself) is recorded asresult.ok: falsewithresult.errorexplaining 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-levelerrorfield.
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_idandtypeare required; everything else is optional.typeis 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). Setactive: trueonly for punishments that should still be enforced. created_atdefaults 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
typevalues:0MUTE,1KICK,2TEMPBAN,3SOFTBAN,4BAN,5TEMPMUTE,6UNBAN,7TEMPROLE,8WARNING,9NOTE,10MUTEHARD,11TEMPMUTEHARD,12CLEANBAN,13TIMEOUT,14SELFMUTE. Responses always carry bothtypeandtype_name. - Wrap poll loops with a timeout and treat
result.ok: falseas the authoritative failure signal -donealone is not success.