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/configPATCH /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
:userIdandtargetJunjoUserIdare external user ids (see User-id contract). Either party can be not-yet-seen by the server; the handler auto-vivifies via the sharedfindOrCreateJunjoUserhelper 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 arequestrow and firesfriend.request.sentto the target. Response shape:{ status: "pending", request: WireFriendRequest }. - When
friends.requestsRequired = false: writes the friendship directly, firesfriend.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:
| state | meaning | since |
|---|---|---|
friends | mutual friendship exists | friendship start (respondedAt) |
request_outgoing | viewer sent a request, awaiting response | request createdAt |
request_incoming | other sent a request to viewer, awaiting | request createdAt |
blocked_by_me | viewer has blocked other | block createdAt |
blocked_by_them | other has blocked viewer | block createdAt |
none | no relationship in the visible scope | null |
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).
| code | status | when |
|---|---|---|
bad_request | 400 | viewerUserId === otherUserId |
not_found | 404 | friends.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’sallowedlist)
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:
| event | when | payload notes |
|---|---|---|
friend.request.sent | requestsRequired = true, on POST /friend-requests | actorJunjoUserId = sender, targetJunjoUserId = recipient |
friend.request.accepted | on POST /accept, or on auto-accept POST (when requestsRequired = false) | actorJunjoUserId = original sender, targetJunjoUserId = accepter, respondedAt |
friend.request.declined | on POST /decline | same actor/target as the original request (so consumers know who declined whom) |
friend.request.cancelled | on DELETE /friend-requests/:id (sender retracts) | same actor/target as the original request |
friend.removed | on DELETE /friends/:otherUserId | removedByJunjoUserId (the unfriender), otherJunjoUserId (the one removed) |
friend.blocked | on POST /blocks | byJunjoUserId (the actor), otherJunjoUserId (the target) |
friend.unblocked | on DELETE /blocks/:otherUserId | byJunjoUserId, 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.