Create conversation from match
Chat
Create conversation from match
Idempotent Match → Conversation handshake. Either participant of a mutual match calls this to open (or re-open) the chat row.
POST
Create conversation from match
Overview
Creates aconversations row for a mutual matches row, or returns the existing one if the handshake has already happened. This is the single entry point into chat — there is no other endpoint that creates a conversation. The flow is:
- User A and User B both swipe-yes on each other, producing a
Matchrow withstatus: "MUTUAL"(see Matching engine). - Either participant POSTs to this endpoint with the
matchId. - The server creates one
conversationsrow per match (enforced by a partial-unique index onmatchId) with the two participants sorted into a canonical pair. - Any subsequent call by either participant for the same
matchIdreturns the same row — the operation is idempotent, so the client can retry safely on flaky networks.
Authentication
Bearer <accessToken> required. Additionally gated by requireOnboarded: the caller’s onboardingStatus must be COMPLETE — half-onboarded users cannot open conversations.
Path parameters
| Name | Type | Required | Notes | Example |
|---|---|---|---|---|
matchId | string | yes | 24-char hex ObjectId of an existing matches row. The caller MUST be one of the two participants (userA or userB). | 66400a8f1c2b4d5e6f7a8e00 |
Query parameters
None.Request body
None. The match itself identifies both participants; the server resolves them from thematches row.
Response — 200 OK
Returns the conversation row, freshly created or pre-existing. The response is the same shape in both cases — the client cannot (and shouldn’t) tell whether this call inserted or just read.| Field | Type | Notes | Example |
|---|---|---|---|
conversation | object | See Conversation object. |
Conversation object
| Field | Type | Allowed values / Notes | Example |
|---|---|---|---|
id | string | 24-char hex ObjectId of the conversation. Used by every other chat endpoint and by the conversation:<id> socket room. | 66400a8f1c2b4d5e6f7a9000 |
matchId | string | 24-char hex ObjectId of the originating matches row. Unique across the collection. | 66400a8f1c2b4d5e6f7a8e00 |
participantIds | string[2] | Exactly two 24-char hex ObjectIds, sorted ascending (canonical pair — the same two users always produce the same array order regardless of who called first). | ["66400a8f...8b00", "66400a8f...8b01"] |
lastMessage | object | null | null until the first message is sent. See LastMessage object. | null |
lastMessageAt | string | null | ISO 8601 UTC of the last message’s sentAt. null until the first message is sent. | null |
participantState | object[] | Per-user denormalised counters. See ParticipantState object. One entry per participantId. | |
status | enum | ACTIVE, ARCHIVED, BLOCKED. Phase 4 only ever returns ACTIVE; BLOCKED flips when block-user is called. | ACTIVE |
createdAt | string | ISO 8601 UTC with millisecond precision. | 2026-05-22T14:32:08.412Z |
updatedAt | string | ISO 8601 UTC with millisecond precision. | 2026-05-22T14:32:08.412Z |
peer | object | null | The OTHER participant, resolved server-side so the inbox / thread header render without a per-row lookup. See Peer object. null only if the peer’s user record has been deleted. | |
property | object | null | The peer’s listing for this match (the “home you’d swap into”). See Property object. null if the listing has been removed. |
peer and property are enrichment fields added by the API for the mobile client. They are NOT stored on the conversations document — they are joined at read time from the users, matches, and currentHomes collections. Which listing is surfaced follows the same rule as the matches feed: it is always the OTHER participant’s listing (if the caller is the match’s userA, the property is listingB, and vice-versa).Peer object
| Field | Type | Notes | Example |
|---|---|---|---|
id | string | 24-char hex ObjectId of the other participant. One of participantIds. | 66400a8f1c2b4d5e6f7a8b01 |
firstName | string | null | The peer’s first name. null if not set. | Bob |
avatarUrl | string | null | Public CDN URL of the peer’s profile photo. null if they haven’t set one — the client renders an initial. | https://cdn.swappr.co.uk/avatars/...jpg |
Property object
| Field | Type | Notes | Example |
|---|---|---|---|
id | string | 24-char hex ObjectId of the peer’s currentHomes listing. | 66400a8f1c2b4d5e6f7a8c01 |
address | string | The listing’s address line. | 12 Camden High St |
addressDetails | string | Owner-typed house/flat detail; "" when not set. | Flat 4 |
postcode | string | The listing’s postcode. | NW1 0JH |
coverUrl | string | null | Cover photo URL (falls back to the first photo’s thumbnail, else null). | https://cdn.swappr.co.uk/homes/...jpg |
LastMessage object
| Field | Type | Notes | Example |
|---|---|---|---|
text | string | The last message body (≤ 4000 chars). Used by the inbox preview. | Hi! Loved your kitchen. |
senderId | string | 24-char hex ObjectId of the sender. | 66400a8f1c2b4d5e6f7a8b00 |
sentAt | string | ISO 8601 UTC. Equals the message’s createdAt. | 2026-05-22T14:35:12.001Z |
ParticipantState object
| Field | Type | Notes | Example |
|---|---|---|---|
userId | string | One of the two participantIds. | 66400a8f1c2b4d5e6f7a8b00 |
unreadCount | integer | Number of messages this user has not yet receipted via mark-read. Starts at 0. Incremented when the OTHER participant sends; reset to 0 on mark-read. | 0 |
lastReadMessageId | string | null | 24-char hex ObjectId of the most recent message this user has marked read. null until the first mark-read. | null |
mutedAt | string | null | ISO 8601 UTC if this user has muted the conversation (Phase 5). null in Phase 4. | null |
blockedAt | string | null | ISO 8601 UTC if this user blocked the conversation (Phase 5 — block is currently global at user level). null in Phase 4. | null |
clearedAt | string | null | ISO 8601 UTC when this user “deleted for me” the conversation (see delete-conversation). While set, the conversation is hidden from this user’s inbox unless a newer message arrives (lastMessageAt > clearedAt). null by default. | null |
Example response
Error responses
| Status | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_FAILED | matchId is missing or not a 24-char hex ObjectId. |
| 401 | UNAUTHENTICATED | Missing, malformed, or expired access token. |
| 403 | ONBOARDING_INCOMPLETE | Caller has not finished onboarding — chat is gated. |
| 403 | FORBIDDEN | Caller is authenticated but is not userA or userB of this match. |
| 403 | CONTACT_NOT_ALLOWED | The match’s contactEnabled flag is false. In Phase 4 every persisted match has contactEnabled: true, so this fires only if a future iteration flips the flag (e.g. a non-mutual safety hold). |
| 403 | CONVERSATION_BLOCKED | A block exists between the two participants in either direction (see block-user). |
| 404 | NOT_FOUND | No matches row with that id. |
Example error — 403 FORBIDDEN
Idempotency & race-safety
The endpoint is idempotent bymatchId. Internally:
- A unique partial index
(matchId)onconversationsmakes a duplicate insert E11000. - The service reads-then-creates-then-falls-back-to-read on duplicate-key: if two
from-matchcalls fire simultaneously on the same match, both observers see the same winning row. - Re-calling from either participant after the row exists is a single read — no mutation.
Idempotency-Key header; the match id IS the idempotency key.
Side effects
- If the row didn’t exist: one
conversationsdocument is inserted withstatus: "ACTIVE", bothparticipantStateentries zeroed. - No notifications fire at this point — push fan-out happens on the first message, not on handshake creation (Phase 5).
See also
- Match → Conversation handshake — the prose explainer with sequence diagram.
- Send message — the next call you’ll make once you have a conversationId.
- Socket events — how the same conversation surfaces over WebSocket.
- Matching engine — how a Match becomes
MUTUALin the first place.