Bans
Two distinct ban surfaces: game-level (covered here, applies across
every group in the game) and per-group (covered in
Groups, applies to one group).
Both surfaces enforce at the same join-and-accept paths
(POST /v1/groups/:id/join, POST /v1/invitations/:code/accept,
POST /v1/groups/:id/bulk-invite); the game-level check fires first,
then the per-group check.
Both support optional expiresAt for time-bounded bans. Expiry is
lazy: the server does not auto-clean expired rows; read paths
ignore them. A row whose expiresAt has passed appears as not-banned
to the enforcement check, and the operator can either let it sit or
delete it via the unban route.
When an enforcement site rejects a banned user it returns:
{ "code": "banned", "status": 403, "message": "user is banned from this game" }The message reflects the broader scope (game wins on tie).
POST /v1/bans
Create or replace a game-level ban for an external user id. Idempotent on a still-active ban (returns the existing row); replaces an expired ban with a fresh one.
Body (.strict, unknown keys 400)
{
"userId": "user_alice",
"reason": "cheating",
"expiresAt": "2026-06-01T00:00:00.000Z"
}| Field | Required | Notes |
|---|---|---|
userId | yes | The dev’s external user id (Clerk sub, Supabase uuid, Roblox UserId-as-string). Auto-creates the JunjoUser + ExternalIdentity if the user has never been seen in this game (preemptive ban is fine). |
reason | no | Free-form text up to 500 chars, or null. Surfaces in the dashboard. |
expiresAt | no | ISO 8601 timestamp. Omit / null for a permanent ban. Past timestamps are accepted but treated as expired (lazy expiry). |
actorUserId | no | External user id of the moderator pressing the ban button. When supplied, populates GameBan.bannedByUserId, the game.user.banned audit row’s actorUserId, and the BanHistory row’s actor. Auto-creates a JunjoUser for the actor if unknown. The response’s bannedBy field returns this same external id. |
Behavior
- New ban: creates a
GameBanrow withbannedAt = now(). - Re-banning a user with an already-active ban: keeps the original
bannedAt(timeline reads cleanly), updatesreason/expiresAt. - Re-banning a user whose previous ban expired: refreshes
bannedAtto now (counts as a fresh ban event).
Fires the game.user.banned webhook event with { junjoUserId, reason, expiresAt }.
Game-level events do not route through SSE (SSE is per-group).
Response 201 Created
{
"id": "ckxxx...",
"gameId": "ckyyy...",
"userId": "user_alice",
"bannedAt": "2026-05-09T17:00:00.000Z",
"expiresAt": "2026-06-01T00:00:00.000Z",
"reason": "cheating",
"bannedBy": null
}DELETE /v1/bans/:userId
Remove a game-level ban. 204 No Content on success. Fires the
game.user.unbanned webhook event.
Errors
| Code | Status | When |
|---|---|---|
not_found | 404 | No active ban for that user in the calling game (also when the user has never been seen). |
GET /v1/bans
List active game-level bans for the calling game, newest-first
(bannedAt DESC, id DESC). Cursor pagination.
Query parameters
| Field | Type | Default | Notes |
|---|---|---|---|
limit | int | 50 | Capped by JUNJO_MAX_PAGE_SIZE (default 100). |
cursor | string | none | The nextCursor from a prior response. |
includeExpired | "true" / "false" | false | When true, also returns rows whose expiresAt is in the past. |
Response 200 OK with Page<Ban>:
{
"items": [
{
"id": "ckxxx...",
"gameId": "ckyyy...",
"userId": "user_alice",
"bannedAt": "2026-05-09T17:00:00.000Z",
"expiresAt": null,
"reason": "cheating",
"bannedBy": null
}
],
"nextCursor": null
}GET /v1/bans/:userId
Fetch the current active game-level ban for a user. Returns 404 not_found when no row exists, when the user has never been seen in
this game, OR when the row exists but its expiresAt is in the past
(lazy expiry; same semantics as the runtime ban check). Use GET /v1/bans?includeExpired=true to surface the underlying row in that
last case.
Response 200 OK with the same Ban shape as the create response.
SDK
const ban = await junjo.bans.get(userId); // Ban | nullGET /v1/bans/:userId/history
Append-only ban-event timeline for a user in this game. Returns rows from both surfaces (game-wide and per-group) by default. Newest-first, cursor-paginated. Each row is a structured snapshot of one set/lift event; predecessors are not overwritten by re-bans, so the timeline reads cleanly across multiple bans.
Query parameters
| Field | Type | Default | Notes |
|---|---|---|---|
limit | int | 50 | Capped by JUNJO_MAX_PAGE_SIZE. |
cursor | string | none | The nextCursor from a prior response. |
scope | "game" / "group" | both | Filter to one ban surface. |
groupId | string | none | Narrow to one group’s events. Forces scope=group implicitly; combining with scope=game returns 400. |
Response 200 OK with Page<BanHistoryEntry>:
{
"items": [
{
"id": "ckxxx...",
"gameId": "ckggg...",
"userId": "user_alice",
"scope": "group",
"groupId": "ckrrr...",
"kind": "set",
"reason": "trolling",
"expiresAt": null,
"eventAt": "2026-05-09T18:00:00.000Z",
"actorUserId": null
},
{
"id": "ckyyy...",
"gameId": "ckggg...",
"userId": "user_alice",
"scope": "game",
"groupId": null,
"kind": "lifted",
"reason": null,
"expiresAt": null,
"eventAt": "2026-05-08T12:00:00.000Z",
"actorUserId": null
}
],
"nextCursor": null
}The server writes one BanHistory row per explicit operator action;
lazy-expired bans do not generate a “lifted” row (no actor took
the action). Use GET /v1/bans?includeExpired=true for the
“currently-stored ban that has elapsed” view.
SDK
const page = await junjo.bans.history(userId, { limit: 50 });
const groupOnly = await junjo.bans.history(userId, { groupId });
// Async iterator over every page
for await (const event of junjo.bans.historyAll(userId)) {
console.log(event.scope, event.kind, event.eventAt);
}SDK
// Game-level
await junjo.bans.add({ userId, reason: "cheating", expiresAt: new Date("...") });
await junjo.bans.remove(userId);
const page = await junjo.bans.list({ limit: 50, includeExpired: false });
const ban = await junjo.bans.get(userId); // Ban | null
const history = await junjo.bans.history(userId, { limit: 50 }); // Page<BanHistoryEntry>
// Async-iterator helpers (walk every page)
for await (const ban of junjo.bans.listAll()) { /* ... */ }
for await (const event of junjo.bans.historyAll(userId)) { /* ... */ }
// Per-group (see Groups reference)
await junjo.groups.ban(groupId, userId, { reason: "trolling" });
await junjo.groups.unban(groupId, userId);A 403 from any join / invite-accept call surfaces as a JunjoError
with code === "banned" so the consumer can discriminate from other
permission-denied conditions.