Build Something in 10 Minutes
ProA concrete first integration. Pull open enforcement cases from the last 24 hours and post a moderator briefing to a Discord webhook. Demonstrates auth, a read endpoint, response shape handling, and a webhook write — all the pieces you reuse in any larger script.
What is this?
One end-to-end example you can run in your terminal. Authenticate against the public REST API, query open enforcement cases issued in the last 24 hours, format them as a human-readable summary, and post it to a Discord webhook so your moderation team sees it in #mod-briefing every morning.
Why you might want it
The REST Quickstart proves the API works for your guild. This page proves you can build something real with it in under ten minutes. Reuse the auth, request, and response- handling patterns for any larger script. Real ops use case, no Claude / MCP required.
What this is for
A daily "what happened yesterday" digest for your mod team. Useful as a standalone cron job, an opener for a longer integration, or just the worked example that proves your token + script can do real work.
Before you start
- ·Your guild is on Pro and you have owner access.
- ·You have an
arkpat_*REST token with theenforcement_viewcapability. If you don't, mint one in Settings → API & MCP. - ·You have a Discord webhook URL for the channel you want the digest in. Right-click the channel in Discord → Edit Channel → Integrations → Webhooks → New Webhook.
- ·Python 3.9+ or any HTTP-capable language. The example below is Python; the same shape works in Node, Go, Bash + curl + jq.
5-minute setup
- 1Save the script below as briefing.py.
- 2Set the three environment variables: ARKANIS_TOKEN, ARKANIS_GUILD_ID, DISCORD_WEBHOOK.
- 3Run it:
python briefing.py. You should see the digest land in your Discord channel within a few seconds. - 4To make it daily: schedule it via
cron, GitHub Actions, or your CI of choice. Aim for once every 24h.
Common failure modes
- HTTP 401 from /api/public/v1/...Token wrong shape (must start with
arkpat_) or expired. Re-mint in Settings → API & MCP and replace the env var. - HTTP 403 / "MISSING_CAPABILITY"Token doesn't have
enforcement_view. Edit the token, tick that capability, save. - Discord returns 401 on the webhook POSTWebhook URL was rotated or the webhook was deleted. Re-create in the channel settings; the URL changes each time.
The Script
Copy this into a file called briefing.py. Set three environment variables before running, named ARKANIS_TOKEN, ARKANIS_GUILD_ID, and DISCORD_WEBHOOK. It uses only the standard library so there's nothing to install.
#!/usr/bin/env python3
"""Daily moderator briefing. Walks paginated lists for strikes and bans
from the last 24 hours, then posts a digest to a Discord webhook."""
import json
import os
import urllib.error
import urllib.parse
import urllib.request
import uuid
from datetime import datetime, timedelta, timezone
ARKANIS_TOKEN = os.environ["ARKANIS_TOKEN"] # arkpat_...
ARKANIS_GUILD_ID = os.environ["ARKANIS_GUILD_ID"] # snowflake string
DISCORD_WEBHOOK = os.environ["DISCORD_WEBHOOK"] # https://discord.com/api/webhooks/...
API_BASE = "https://api.arkanis.gg"
def api_get(path: str) -> dict:
"""GET a public REST endpoint. Returns the parsed JSON envelope.
Success envelope (list endpoints): { items, next_cursor, total_count }
Success envelope (single resource): the resource object directly
Error envelope (any 4xx/5xx): { error: { code, message, retryable, request_id } }
X-Request-Id is optional but recommended — it lands in the API's logs
so support can correlate your retries.
"""
req = urllib.request.Request(
API_BASE + path,
headers={
"Authorization": "Bearer " + ARKANIS_TOKEN,
"Accept": "application/json",
"X-Request-Id": str(uuid.uuid4()),
},
)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
# Error bodies are nested under "error":
# { error: { code, message, retryable, request_id } }
body = json.loads(e.read()) if e.fp else {}
err = body.get("error", {}) if isinstance(body, dict) else {}
raise RuntimeError(f"HTTP {e.code}: {err.get('code', '?')} - {err.get('message', e.reason)}")
def list_since(endpoint: str, time_field: str, since: datetime) -> list:
"""Paginate a list endpoint (sorted by id DESC) until items go older
than 'since'. limit=100 is the cap; the cursor walks back through pages."""
out = []
cursor = None
while True:
q = {"limit": "100"}
if cursor:
q["cursor"] = cursor
body = api_get(f"{endpoint}?{urllib.parse.urlencode(q)}")
for item in body.get("items", []):
when = datetime.fromisoformat(item[time_field].replace("Z", "+00:00"))
if when < since:
return out # rest of the page is older too — DESC sort
out.append(item)
cursor = body.get("next_cursor")
if not cursor:
return out
def post_webhook(content: str) -> None:
"""Post a plain-text message to a Discord channel webhook."""
req = urllib.request.Request(
DISCORD_WEBHOOK,
data=json.dumps({"content": content}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req) as resp:
resp.read()
def main() -> None:
since = datetime.now(timezone.utc) - timedelta(hours=24)
# Strikes use created_at + admin_name; bans use banned_at + banned_by_name.
strikes = list_since(
f"/api/public/v1/guilds/{ARKANIS_GUILD_ID}/strikes", "created_at", since
)
bans = list_since(
f"/api/public/v1/guilds/{ARKANIS_GUILD_ID}/bans", "banned_at", since
)
if not strikes and not bans:
post_webhook("**Daily Mod Briefing** | last 24h: nothing to report. Good day.")
return
lines = ["**Daily Mod Briefing** | last 24h"]
if strikes:
lines.append(f"\n__Strikes ({len(strikes)})__")
for s in strikes[:10]:
who = f"<@{s['user_id']}>" if s.get("user_id") else s.get("player_name", "?")
lines.append(f" - {who} by {s['admin_name']}: {s['reason']}")
if len(strikes) > 10:
lines.append(f" - ...and {len(strikes) - 10} more")
if bans:
lines.append(f"\n__Bans ({len(bans)})__")
for b in bans[:10]:
who = f"<@{b['user_id']}>" if b.get("user_id") else b.get("player_name", "?")
lines.append(f" - {who} by {b['banned_by_name']}: {b['reason']}")
if len(bans) > 10:
lines.append(f" - ...and {len(bans) - 10} more")
post_webhook("\n".join(lines))
if __name__ == "__main__":
main()Field names mirror the OpenAPI 3.1 spec at API Reference. Strikes carry admin_id / admin_name / created_at; bans carry banned_by_id / banned_by_name / banned_at. Both lists sort by id DESC (newest first) and paginate via the HMAC-signednext_cursor token. Neither endpoint supports asince query parameter today, which is why the helper walks the cursor and filters client-side on the timestamp field.
What it's doing
Four small pieces, each useful on its own:
- Auth.
Authorization: Bearer arkpat_...is all you need for the REST API. No OAuth dance, no signed requests. The token carries the capability scope you set when minting it, plus optionally the CIDR allowlist. - Envelope. List endpoints return
{ items, next_cursor, total_count }; single-resource endpoints return the resource object directly. Errors come back as{ error: { code, message, retryable, request_id } }with the appropriate HTTP status, caught here viaHTTPError. Same shape for every endpoint. - Time-window filter. Strikes and bans are sorted by id DESC; there is no
sincequery parameter. Thelist_since()helper walks the cursor pulling 100 rows at a time and stops the moment it sees an item older than the cutoff. For a typical guild, a 24-hour digest finishes on the first page. - Webhook. Posting back to Discord uses Discord's own webhook API, not Arkanis. The webhook URL is the credential — treat it like a password (env var, never committed). Webhook posts don't cost an Arkanis API call against your rate limit; they go directly to Discord.
Swap it for something useful to your team
The script above is the worked example. The actual value is theapi_get() / list_since() /post_webhook() helpers. Reuse them:
- Weekly recap. Same script, change the
timedelta(hours=24)totimedelta(days=7), run on Mondays. - Audit-log digest. Same pagination shape, different endpoint —
/api/public/v1/guilds/{guild_id}/audit-logwalks every state change (config edits, role-perm changes, strike issues, etc.) with the sameitems/next_cursorenvelope. Filter client-side oncreated_atand the action type that matters to your team. - Open-tickets summary.
/api/public/v1/guilds/{guild_id}/ticketslists open cases. Pull the page, count by category or by claimer, post a daily "here's the queue" ping to your staff channel. - Cross-platform player passport. Two calls:
/api/public/v1/guilds/{guild_id}/players/lookup?q={snowflake-or-name-or-provider-id}resolves any identifier (Discord snowflake, username, Steam id, Alderon id) into the canonical player record + linked identities. Then/api/public/v1/guilds/{guild_id}/players/{playerId}/historyreturns their strike, ban, mute, and warning history across linked accounts in one response. Useful for staff handoffs and incident reports. See the full schema in the API Reference.
When the integration grows past one-off scripts and you want a conversational interface to the same capabilities, the MCP Quickstart has the Claude Desktop / Code path.