APIBans

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"
}
FieldRequiredNotes
userIdyesThe 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).
reasonnoFree-form text up to 500 chars, or null. Surfaces in the dashboard.
expiresAtnoISO 8601 timestamp. Omit / null for a permanent ban. Past timestamps are accepted but treated as expired (lazy expiry).
actorUserIdnoExternal 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 GameBan row with bannedAt = now().
  • Re-banning a user with an already-active ban: keeps the original bannedAt (timeline reads cleanly), updates reason / expiresAt.
  • Re-banning a user whose previous ban expired: refreshes bannedAt to 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

CodeStatusWhen
not_found404No 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

FieldTypeDefaultNotes
limitint50Capped by JUNJO_MAX_PAGE_SIZE (default 100).
cursorstringnoneThe nextCursor from a prior response.
includeExpired"true" / "false"falseWhen 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 | null

GET /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

FieldTypeDefaultNotes
limitint50Capped by JUNJO_MAX_PAGE_SIZE.
cursorstringnoneThe nextCursor from a prior response.
scope"game" / "group"bothFilter to one ban surface.
groupIdstringnoneNarrow 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.