Why a handshake

Two users swiping yes on each other produces a Match row in the matches collection with status: "MUTUAL". A match is not a conversation — it’s just a server-side fact that two users are willing to talk. The conversation itself (the conversations row that backs the inbox, the message thread, and the conversation:<id> socket room) is lazily created the first time either participant explicitly chooses to “contact” the other. We separate the two for three reasons:
  1. Mutual ≠ active. A user can have a hundred mutual matches and only chat with a few of them. Lazy creation keeps the conversations collection small and the inbox un-cluttered.
  2. Either party can initiate. Whichever participant taps “Contact” first is the one who triggers the insert. The other participant sees the new conversation in their inbox the next time they refresh or via a push notification (Phase 5).
  3. Idempotency. A flaky mobile network can produce duplicate “Contact” taps. The handshake is designed so retries are safe — see Idempotency & race-safety below.

The endpoint

POST /api/v1/chat/conversations/from-match/:matchId is the only way to create a conversation. There is no other endpoint that inserts into conversations. The caller passes the matchId; the server resolves the two participants from the match row and creates the conversation with both participantIds sorted into a canonical pair (smaller hex string first). The canonical sort means the same two users always produce the same participantIds array order regardless of who initiates — convenient for client-side equality checks.

The full sequence

The diagram below traces the user-A-initiates flow from the swipe to the first message. User B’s experience is symmetric — either user can be the initiator. The handshake itself (steps 6–14) is one HTTP round-trip. The first-message flow (steps 15–20) hits the SAME service path whether triggered by REST or by a chat:message:send socket event — see Socket events for the WS variant.

Idempotency and race-safety

The handshake is idempotent by matchId. Two concurrent “Contact” taps — same match, both clients fire the POST at the same millisecond — must converge on exactly one conversations row. The design uses two cooperating layers:
  1. Database-level uniqueness. Migration 20260524000000-chat-realtime provisions a partial unique index on conversations.{ matchId: 1 } with partialFilterExpression: { matchId: { $exists: true } }. A duplicate insert E11000s at the storage edge.
  2. Service-level read-then-create-then-fall-back-to-read. conversationService.createOnMatchHandshake runs:
    existing = findByMatchId(matchId)
    if existing: return existing                           // fast path
    try {
      return createForMatch(...)                            // happy path
    } catch (E11000) {
      winner = findByMatchId(matchId)
      if winner: return winner                              // race recovery
      else throw                                            // shouldn't happen
    }
    
    The fast-path read avoids the insert round-trip in the common case (the row already exists). The catch-block read handles the race where two parallel calls both miss the fast path, both attempt insert, and one E11000s.
Both clients see the same ConversationRow regardless of which one’s insert won. No Idempotency-Key header is required — the match id IS the idempotency key.

Failure modes

The handshake can fail at three gates, each with a distinct error code so the client UX can branch correctly. See Errors for the full catalog.

404 NOT_FOUND

The matchId doesn’t reference a row in matches. Two causes:
  • Stale client state — the match row was deleted (e.g. by tenancy-rejected reaping or by an admin moderation action). The client should re-fetch the matches list.
  • Type confusion — the client passed a conversationId where a matchId was expected.

403 FORBIDDEN

The caller is authenticated but is neither userA nor userB of the match. This shouldn’t happen with a well-behaved client (matches are only ever surfaced to participants), but the gate is enforced server-side as defense-in-depth. There is no recovery — the client should not retry.

403 CONTACT_NOT_ALLOWED

match.contactEnabled is false. In Phase 4 every persisted match has contactEnabled: true, so this gate never fires in production today. It exists as a defensive hook for future iterations:
  • A “safety hold” placed by an admin who flagged the match for manual review.
  • A future “non-mutual” match state where the match exists but contact is not yet enabled (e.g. premium-only contact in a paid tier).
The mobile UI should treat this as a permanent dead end for that match — surface “Contact unavailable” and stop retrying.

403 CONVERSATION_BLOCKED

A blocks row exists between the two users in either direction. Phase 4’s BlocksLookupPort.areBlockedEither(a, b) does one Mongo exists with $or over both { blockerId: a, blockedId: b } and { blockerId: b, blockedId: a }. The gate fires if either side has called block-user. This is recoverable only if the BLOCKER (not the caller) calls unblock-user — which the caller cannot do on someone else’s behalf. The mobile UI should surface “This user is unavailable” without distinguishing direction (otherwise users can probe each other’s block status).

What does NOT happen at handshake time

A common misunderstanding worth pre-empting:
  • No push notification fires when the conversation is created. Push fan-out is triggered by the first message, not by the conversation row. The handshake is silent — the other participant only learns the conversation exists when they refresh their inbox or receive the first message notification (Phase 5).
  • No chat:message:new broadcast. The conversation row has lastMessage: null and the conversation:<id> room has nothing to broadcast yet. The room only matters once messages start flowing.
  • No matchmaking side effects. The matches row is unchanged. Specifically, contactEnabled is not flipped — the conversation row’s existence is the signal that contact has been initiated, not a flag on the match.

See also