Rate Limits and Errors
Per-token and per-IP rate limits, the canonical error envelope, and the full error code reference for the Arkanis public token surface.
What is this?
The complete error envelope (code,message,retryable,request_id,details), every error code REST or MCP can return, and the per-IP + per-token rate-limit tables. Worked examples for the codes you're most likely to hit:RATE_LIMITED,CAPABILITY_DENIED,RE_AUTH_REQUIRED,TOKEN_REVOKED.
Why you might want it
Your client needs one error handler that works for both REST and MCP. This page is what you wire it against. Theretryableflag is the only thing your retry loop needs to inspect; theRetry-Afterheader tells you how long to wait.
Per-IP Rate Limits
Every request to the public surface is counted against the source IP address. These limits sit alongside the per-token token-bucket and catch rapid-fire abuse from a single host before token revocation can propagate. The api uses Fastify's trust-proxy mode, so a client behind a reverse proxy is identified by its real IP, not the proxy's.
| Path | Limit | Window | Purpose |
|---|---|---|---|
| /api/public/v1/* | 600 requests | 5 minutes | Catches rapid-fire on a leaked token before revocation propagates. |
| /oauth/device/code | 20 requests | 10 minutes | Limits device-code generation. Abusers cannot flood the user_code namespace. |
| /oauth/device/token | 120 requests | 10 minutes | Limits polling abuse against the device-token endpoint. |
| /api-tokens (POST) | 20 requests / user | 1 hour | Limits token-creation flooding from a compromised dashboard session. |
When an IP exceeds its budget, the api returns 429 RATE_LIMITED with a Retry-After header in seconds. Standard X-RateLimit-Limit and X-RateLimit-Remaining headers are set on every /api/public/v1/* response so clients can self-pace without waiting for a 429.
Per-Token Rate Limits
Each token holds a token-bucket per risk tier. A read tool and a destructive tool do not share the same bucket. Aggregate caps per user per guild sit one tier above the per-token limit, so a single user with three tokens cannot multiply their destructive budget by three.
| Tier | Default capacity | Refill |
|---|---|---|
| Read / utility | 120 calls | 60 / minute |
| Reversible write | 30 calls | 10 / minute |
| Destructive write | 6 calls | 1 / minute |
A typical Pro guild driving the API from a single integration will never see a per-token 429 under normal use. There are no published per-tool quotas in v1. The tier of a given endpoint or MCP tool is derived from endpoint-policy.ts and tool-policy.ts, which are the canonical source for that mapping.
/api/public/v1/* are blocked by CORS. Public REST tokens are designed for server-side scripts. If a customer-facing browser needs Arkanis data, route it through a server-side proxy.Error Envelope
Every error from the public surface uses the same shape. REST returns the envelope with the matching HTTP status (401, 403, 404, 409, 422, 429, 5xx). The MCP server returns the same envelope inside the tool result body. Clients can write a single error handler and reuse it for both surfaces.
{
"error": {
"code": "RE_AUTH_REQUIRED",
"message": "Re-authentication is required before running this operation.",
"retryable": false,
"request_id": "req_01HW9X8Y7Z6V5T",
"details": {
"reauth_url": "https://arkanis.gg/dashboard/123/settings/api-mcp#approve-window"
}
}
}Field reference:
code: machine-readable error code. Drawn from the enum below.message: human-readable explanation. Safe to surface to operators, never to an end user.retryable: when true, the operation can be retried with backoff. When false, the client needs to fix something first.request_id: trace id for the request. Include it when reporting issues to support.details: optional structured payload that varies by code. The shape per code is documented below.
Retry Semantics
The retryable flag is the only thing a client needs to inspect to decide whether to retry. Codes that are transient set retryable: true. Codes that need a client-side fix set retryable: false.
| retryable: true | retryable: false |
|---|---|
RATE_LIMITED, UPSTREAM_UNAVAILABLE, INTERNAL_ERROR | Everything else. The client must fix the request, refresh the token, or open a re-auth window before retrying. |
For retryable codes, honour Retry-After when present. Otherwise use exponential backoff starting at 1 second, doubling, capped at 30 seconds, with a small jitter.
Error Code Reference
The full enum of error codes the public surface can emit. The list is locked at v1 and lives in api/src/lib/public/error-envelope.ts as PUBLIC_ERROR_CODES.
| Code | HTTP | Retryable | Meaning |
|---|---|---|---|
| UNAUTHORIZED | 401 | No | Token missing, malformed, or unrecognised. |
| FORBIDDEN | 403 | No | The token is valid but the caller cannot act on the target resource. |
| CAPABILITY_DENIED | 403 | No | The token's capability snapshot does not include the required capability. |
| INVALID_CONFIRMATION | 422 | No | Destructive call sent without a matching sentinel _confirmation string. |
| RE_AUTH_REQUIRED | 403 | No | Destructive call attempted outside an open re-auth window. |
| NOT_FOUND | 404 | No | The target resource does not exist or is not visible to this token. |
| VALIDATION_FAILED | 400 / 422 | No | The request body or query failed schema validation. |
| RATE_LIMITED | 429 | Yes | Per-IP or per-token rate limit exceeded. See Retry-After. |
| UPSTREAM_UNAVAILABLE | 502 / 503 | Yes | The api could not reach Discord, the bot, or another upstream dependency. |
| TOKEN_EXPIRED | 401 | No | The token reached its expiry date. Rotate it from the dashboard. |
| TOKEN_REVOKED | 401 | No | The token was revoked by the issuer or by an owner. Re-issue. |
| TOKEN_IP_NOT_ALLOWED | 403 | No | Caller IP is outside the token's configured CIDR allowlist. Either widen the allowlist in the dashboard or call from a permitted source. |
| CORS_DENIED | 403 | No | Direct browser call to /api/public/v1/*. Use a server-side proxy. |
| INTERNAL_ERROR | 500 | Yes | Unhandled server error. The request_id is logged on the api side. |
Example: RATE_LIMITED
Returned with HTTP 429 when the per-IP or per-token budget is exhausted. The Retry-After header carries the wait duration in seconds.
$ curl -i https://api.arkanis.gg/api/public/v1/strikes \
-H "Authorization: Bearer arkpat_..."
HTTP/2 429
content-type: application/json
retry-after: 47
x-ratelimit-limit: 600
x-ratelimit-remaining: 0
{
"error": {
"code": "RATE_LIMITED",
"message": "Rate limit exceeded. Retry in 47s.",
"retryable": true,
"request_id": "req_01HW9X8Y7Z6V5T"
}
}Wait the suggested cooldown and retry. Persistent 429s on a token point at a runaway loop on the client; an integration burning the destructive budget every minute is a sign of misconfigured automation, not a tuning problem.
Example: CAPABILITY_DENIED
The token is valid, but its capability snapshot does not include the capability required by the endpoint. Capabilities are inherited from the issuing user at token-creation time and re-checked on every call. If the issuer's capabilities changed after the token was issued, a new token is needed.
HTTP/2 403
content-type: application/json
{
"error": {
"code": "CAPABILITY_DENIED",
"message": "Missing capability: enforcement_strike_add.",
"retryable": false,
"request_id": "req_01HW9X8Y7Z6V5T",
"details": { "missing": ["enforcement_strike_add"] }
}
}The fix is to issue a fresh token from a user who currently holds the missing capability, or to adjust the role configuration in the dashboard and re-issue. Do not retry the same call with the same token — the result will not change.
Example: RE_AUTH_REQUIRED
Destructive operations require an open re-auth window. The default window is 15 minutes, opened by clicking Approve next destructive action in the dashboard. Outside the window, destructive calls return this envelope with a reauth_url pointing at the dashboard.
HTTP/2 403
content-type: application/json
{
"error": {
"code": "RE_AUTH_REQUIRED",
"message": "Re-authentication is required before running this operation.",
"retryable": false,
"request_id": "req_01HW9X8Y7Z6V5T",
"details": {
"reauth_url": "https://arkanis.gg/dashboard/123/settings/api-mcp#approve-window"
}
}
}For fully scripted workflows, an owner can also open the window via the authenticated dashboard API at POST /api-tokens/{tokenId}/reauth-window. The window then applies to the next destructive call from any client holding that token. See Destructive Actions for the full flow.
Example: INVALID_CONFIRMATION
Destructive operations require a sentinel string in the _confirmation body field. The string must exactly match the template for the operation, with placeholders substituted using the caller's actual arguments. The api returns the expected formats in details so a script can self-correct.
HTTP/2 422
content-type: application/json
{
"error": {
"code": "INVALID_CONFIRMATION",
"message": "Confirmation string does not match the expected sentinel for this action.",
"retryable": false,
"request_id": "req_01HW9X8Y7Z6V5T",
"details": {
"expected_format": "BAN USER {user_id} IN GUILD {guildId} {PERMANENT|DURATION N}",
"expected_concrete": "BAN USER 123456789012345678 IN GUILD 987654321098765432 PERMANENT"
}
}
}Set _confirmation to expected_concrete and retry. The sentinel is case-sensitive and whitespace-sensitive. The full sentinel catalog lives in Destructive Actions.
Example: TOKEN_REVOKED
The token was revoked by its issuer, by a guild owner, or by an automated guard such as a downgrade off Pro. The dashboard records the revocation event in the audit log. Once revoked, a token is unusable.
HTTP/2 401
content-type: application/json
{
"error": {
"code": "TOKEN_REVOKED",
"message": "This token has been revoked.",
"retryable": false,
"request_id": "req_01HW9X8Y7Z6V5T"
}
}Issue a new token from the dashboard and update any clients that still hold the old one. Do not attempt to revive a revoked token; revocation is final.
actor_source=token and code TOKEN_REVOKED are worth alerting on if a previously stable integration suddenly starts failing.