APIFriends

Friends

Per-user social graph: friend requests, friendships, blocks, tags, and mutual-friend suggestions. Every Friends route is gated by per-game configuration; a game can disable the whole subsystem, individual features, or constrain the menu of allowed values for user-facing options.

User-id contract

Path params and body fields named userId, targetJunjoUserId, viewerUserId, otherUserId, and the wire fields actorJunjoUserId / targetJunjoUserId / junjoUserId carry your application’s EXTERNAL user id (the same string you pass to groups.create, groups.kick, etc.) — typically your auth provider’s subject (Clerk sub, Supabase uuid) or your own user-table cuid. The server resolves the external id to the internal JunjoUser.id via the same findOrCreateJunjoUser helper every other Junjo write-path uses; unseen users are auto-vivified on first reference.

The wire field names keep their historical *JunjoUserId form for backwards compatibility, but the values you send and receive are external ids. A friend request you send with targetJunjoUserId: "clerk_user_abc" returns request.targetJunjoUserId: "clerk_user_abc" verbatim.

Configuration

Each Game row carries a JSON config column with a friends branch. Defaults are applied at read time; PATCH writes only the fields you set.

{
  "friends": {
    "enabled": true,                   // hard kill switch for the subsystem
    "scope": "per-game",               // or "network"; see below
    "requestsRequired": true,          // false auto-accepts on POST
    "maxFriends": 1000,                // per-user cap (network-wide if scope=network)
    "maxPendingRequests": 100,         // per-user outbound cap
    "tags": {
      "enabled": true,
      "maxPerUser": 20
    },
    "discovery": {
      "enabled": true,
      "minMutuals": 2                  // suggestions must share >= N friends
    },
    "visibility": {
      "allowed": ["private", "friends-only"],
      "default": "private"
    }
  },
  "blocks": { "enabled": true }
}

Read or update the config via the admin endpoints:

  • GET /v1/admin/games/:gameId/config
  • PATCH /v1/admin/games/:gameId/config

The PATCH body accepts a partial { config?: PartialGameConfig, networkId?: string | null }.

Scope

friends.scope = "per-game" (default) keeps every friendship, request, and block visible only within the game it originated in.

friends.scope = "network" plus a non-null Game.networkId shares visibility across every sibling game with the same networkId AND scope = "network". Both sides must opt in: if a sibling game pins its scope to "per-game", it stays isolated even if its networkId matches.

Writes always pin to the originating game. Flipping a game’s scope back to "per-game" narrows visibility on subsequent reads but never drops data.

Friend requests

POST /v1/users/:userId/friend-requests

Send a friend request from :userId to a target.

Body

{ "targetJunjoUserId": "cmu_xyz..." }

Behavior

  • Both :userId and targetJunjoUserId are external user ids (see User-id contract). Either party can be not-yet-seen by the server; the handler auto-vivifies via the shared findOrCreateJunjoUser helper before writing.
  • 404 when friends.enabled = false, or when either party has blocked the other (silent, for both blocker and blockee).
  • 400 when already friends, when a request is pending in either direction, or when caps are exceeded.
  • When friends.requestsRequired = true (default): creates a request row and fires friend.request.sent to the target. Response shape: { status: "pending", request: WireFriendRequest }.
  • When friends.requestsRequired = false: writes the friendship directly, fires friend.request.accepted. Response shape: { status: "auto-accepted", friendship: WireFriendship }.

GET /v1/users/:userId/friend-requests?direction=in|out|both

Lists pending requests. direction defaults to "both". Returns { inbound: WireFriendRequest[], outbound: WireFriendRequest[] }.

POST /v1/friend-requests/:id/accept

Promotes the request to a friendship. Atomic: updates the original row in place (preserving createdAt as the request-originated timestamp) and creates the mirror row. Fires friend.request.accepted to the original sender.

POST /v1/friend-requests/:id/decline

Deletes the request row. Fires friend.request.declined to the original sender so their client can drop the outbound row from the UI.

DELETE /v1/friend-requests/:id

Used by the original sender to cancel their own outbound pending request. Distinct route from decline; fires friend.request.cancelled to the original target so their client can drop the inbound row.

Friendships

GET /v1/users/:userId/friends?limit=&cursor=&tagId=&viewer=

Keyset paginated list of :userId’s friends. Cursor is the last row’s respondedAt|id.

  • tagId: filters to friends tagged with that tag. Tags are per-game, so this contracts the visible scope to the calling game (no scope=network expansion).
  • viewer: optional junjoUserId. When supplied, the visibility rules are enforced from that viewer’s perspective. Without it, the API-key caller is treated as admin (visibility bypassed).

DELETE /v1/users/:userId/friends/:otherUserId

Removes both rows of the friendship in one transaction. Fires friend.removed to the OTHER party (the user who triggered the removal already knows).

GET /v1/users/:viewerUserId/friends/:otherUserId/relationship

Single-pair viewer-perspective probe. Returns the relationship between viewerUserId and otherUserId from viewerUserId’s point of view in one round trip, instead of paging through /friends.

{ "state": "friends", "since": "2026-03-12T14:22:03.000Z" }

state is one of:

statemeaningsince
friendsmutual friendship existsfriendship start (respondedAt)
request_outgoingviewer sent a request, awaiting responserequest createdAt
request_incomingother sent a request to viewer, awaitingrequest createdAt
blocked_by_meviewer has blocked otherblock createdAt
blocked_by_themother has blocked viewerblock createdAt
noneno relationship in the visible scopenull

Priority (when multiple row types coexist, viewer-side wins): viewer-side block → other-side block → friendship → pending request → none. The viewer-side block takes precedence on the both-blocked edge case so the viewer’s UI shows the block they can act on.

The visible scope follows the calling game’s friends.scope (per-game reads only the calling game; network reads every game in the same network).

codestatuswhen
bad_request400viewerUserId === otherUserId
not_found404friends.enabled = false for the calling game

Blocks

POST /v1/users/:userId/blocks

Adds a one-directional block. Implicitly removes any friendship rows in either direction AND any pending requests in either direction (one transaction). Idempotent: re-blocking the same user returns the existing row.

Both :userId and targetJunjoUserId are external user ids; both are auto-vivified on first reference, so a moderator can pre-emptively block a user the system has not yet seen.

DELETE /v1/users/:userId/blocks/:otherUserId

Removes the block.

GET /v1/users/:userId/blocks

Lists :userId’s outbound blocks.

POST /blocks fires friend.blocked; DELETE /blocks/:otherUserId fires friend.unblocked. Both carry byJunjoUserId (the actor) and otherJunjoUserId (the target). Consumers typically suppress any in-app notification to the blocked party — that policy lives in your webhook handler, not in Junjo.

Tags

Per-(user, game) labels for organizing one’s friend list. Tags are private to the owner: only the user that created the tag sees it applied. Tags only attach to the OWNER-side friend row, so each party in a friendship can tag independently.

GET /v1/users/:userId/friend-tags

Lists :userId’s tags in the calling game, sorted by name.

POST /v1/users/:userId/friend-tags

{ "name": "Close friends", "color": "#ff5050" }

color is optional (#rrggbb). Capped by friends.tags.maxPerUser.

PATCH /v1/friend-tags/:id

Update name and/or color. color: null clears it.

DELETE /v1/friend-tags/:id

Cascades to all UserRelationshipTag rows.

PUT /v1/users/:userId/friends/:otherUserId/tags

Replace the tag set for one friendship.

{ "tagIds": ["tag_close", "tag_guild"] }

Empty array clears all tags. The handler validates that every supplied tagId belongs to :userId in the calling game; cross-game tagging is rejected with 400.

Mutual-friend suggestions

GET /v1/users/:userId/friends/suggestions?limit=N

Ranked candidates who share at least friends.discovery.minMutuals friends with :userId, excluding existing friends and anyone blocked in either direction.

{
  "items": [
    {
      "junjoUserId": "cmu_xyz",
      "mutualCount": 5,
      "sampleMutualJunjoUserIds": ["cmu_a", "cmu_b", "cmu_c"]
    }
  ]
}

sampleMutualJunjoUserIds carries up to 5 mutual friends so the dashboard can render “you know A, B, +N others” without a follow-up fetch. 404 when friends.discovery.enabled = false.

Visibility

GET /v1/users/:userId/visibility

Returns the user’s friendsListVisibility setting in the calling game, falling back to config.friends.visibility.default when no row exists. The response also surfaces the allowed set so the dashboard can render the right radio options.

PATCH /v1/users/:userId/visibility

{ "friendsListVisibility": "friends-only" }

Validates against config.friends.visibility.allowed. Visibility values:

  • "private": only owner + admin can list this user’s friends
  • "friends-only": confirmed friends can also see
  • "public": any caller can see (subject to the calling game’s allowed list)

Enforcement on GET /v1/users/:userId/friends runs only when a viewer query parameter is supplied (admin callers bypass).

Webhook events

Seven JunjoEvent types fire from this subsystem:

eventwhenpayload notes
friend.request.sentrequestsRequired = true, on POST /friend-requestsactorJunjoUserId = sender, targetJunjoUserId = recipient
friend.request.acceptedon POST /accept, or on auto-accept POST (when requestsRequired = false)actorJunjoUserId = original sender, targetJunjoUserId = accepter, respondedAt
friend.request.declinedon POST /declinesame actor/target as the original request (so consumers know who declined whom)
friend.request.cancelledon DELETE /friend-requests/:id (sender retracts)same actor/target as the original request
friend.removedon DELETE /friends/:otherUserIdremovedByJunjoUserId (the unfriender), otherJunjoUserId (the one removed)
friend.blockedon POST /blocksbyJunjoUserId (the actor), otherJunjoUserId (the target)
friend.unblockedon DELETE /blocks/:otherUserIdbyJunjoUserId, otherJunjoUserId

All seven are user-scoped (UserEventBase): no groupId, not delivered over the per-group SSE stream. Webhook delivery is the transport. Consumers that need real-time UI updates should re-broadcast these events to the relevant user’s session(s) on their own bus.