POST
/
api
/
v1
/
chat
/
conversations
/
from-match
/
:matchId
Create conversation from match
curl --request POST \
  --url https://api.example.com/api/v1/chat/conversations/from-match/:matchId
import requests

url = "https://api.example.com/api/v1/chat/conversations/from-match/:matchId"

response = requests.post(url)

print(response.text)
const options = {method: 'POST'};

fetch('https://api.example.com/api/v1/chat/conversations/from-match/:matchId', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));
<?php

$curl = curl_init();

curl_setopt_array($curl, [
CURLOPT_URL => "https://api.example.com/api/v1/chat/conversations/from-match/:matchId",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
]);

$response = curl_exec($curl);
$err = curl_error($curl);

curl_close($curl);

if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}
package main

import (
"fmt"
"net/http"
"io"
)

func main() {

url := "https://api.example.com/api/v1/chat/conversations/from-match/:matchId"

req, _ := http.NewRequest("POST", url, nil)

res, _ := http.DefaultClient.Do(req)

defer res.Body.Close()
body, _ := io.ReadAll(res.Body)

fmt.Println(string(body))

}
HttpResponse<String> response = Unirest.post("https://api.example.com/api/v1/chat/conversations/from-match/:matchId")
.asString();
require 'uri'
require 'net/http'

url = URI("https://api.example.com/api/v1/chat/conversations/from-match/:matchId")

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true

request = Net::HTTP::Post.new(url)

response = http.request(request)
puts response.read_body

Overview

Creates a conversations 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:
  1. User A and User B both swipe-yes on each other, producing a Match row with status: "MUTUAL" (see Matching engine).
  2. Either participant POSTs to this endpoint with the matchId.
  3. The server creates one conversations row per match (enforced by a partial-unique index on matchId) with the two participants sorted into a canonical pair.
  4. Any subsequent call by either participant for the same matchId returns the same row — the operation is idempotent, so the client can retry safely on flaky networks.
See Match → Conversation handshake for the full sequence diagram.

Authentication

Bearer <accessToken> required. Additionally gated by requireOnboarded: the caller’s onboardingStatus must be COMPLETE — half-onboarded users cannot open conversations.

Path parameters

NameTypeRequiredNotesExample
matchIdstringyes24-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 the matches 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.
FieldTypeNotesExample
conversationobjectSee Conversation object.

Conversation object

FieldTypeAllowed values / NotesExample
idstring24-char hex ObjectId of the conversation. Used by every other chat endpoint and by the conversation:<id> socket room.66400a8f1c2b4d5e6f7a9000
matchIdstring24-char hex ObjectId of the originating matches row. Unique across the collection.66400a8f1c2b4d5e6f7a8e00
participantIdsstring[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"]
lastMessageobject | nullnull until the first message is sent. See LastMessage object.null
lastMessageAtstring | nullISO 8601 UTC of the last message’s sentAt. null until the first message is sent.null
participantStateobject[]Per-user denormalised counters. See ParticipantState object. One entry per participantId.
statusenumACTIVE, ARCHIVED, BLOCKED. Phase 4 only ever returns ACTIVE; BLOCKED flips when block-user is called.ACTIVE
createdAtstringISO 8601 UTC with millisecond precision.2026-05-22T14:32:08.412Z
updatedAtstringISO 8601 UTC with millisecond precision.2026-05-22T14:32:08.412Z
peerobject | nullThe 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.
propertyobject | nullThe 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

FieldTypeNotesExample
idstring24-char hex ObjectId of the other participant. One of participantIds.66400a8f1c2b4d5e6f7a8b01
firstNamestring | nullThe peer’s first name. null if not set.Bob
avatarUrlstring | nullPublic 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

FieldTypeNotesExample
idstring24-char hex ObjectId of the peer’s currentHomes listing.66400a8f1c2b4d5e6f7a8c01
addressstringThe listing’s address line.12 Camden High St
addressDetailsstringOwner-typed house/flat detail; "" when not set.Flat 4
postcodestringThe listing’s postcode.NW1 0JH
coverUrlstring | nullCover photo URL (falls back to the first photo’s thumbnail, else null).https://cdn.swappr.co.uk/homes/...jpg

LastMessage object

FieldTypeNotesExample
textstringThe last message body (≤ 4000 chars). Used by the inbox preview.Hi! Loved your kitchen.
senderIdstring24-char hex ObjectId of the sender.66400a8f1c2b4d5e6f7a8b00
sentAtstringISO 8601 UTC. Equals the message’s createdAt.2026-05-22T14:35:12.001Z

ParticipantState object

FieldTypeNotesExample
userIdstringOne of the two participantIds.66400a8f1c2b4d5e6f7a8b00
unreadCountintegerNumber 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
lastReadMessageIdstring | null24-char hex ObjectId of the most recent message this user has marked read. null until the first mark-read.null
mutedAtstring | nullISO 8601 UTC if this user has muted the conversation (Phase 5). null in Phase 4.null
blockedAtstring | nullISO 8601 UTC if this user blocked the conversation (Phase 5 — block is currently global at user level). null in Phase 4.null
clearedAtstring | nullISO 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

{
  "conversation": {
    "id": "66400a8f1c2b4d5e6f7a9000",
    "matchId": "66400a8f1c2b4d5e6f7a8e00",
    "participantIds": [
      "66400a8f1c2b4d5e6f7a8b00",
      "66400a8f1c2b4d5e6f7a8b01"
    ],
    "lastMessage": null,
    "lastMessageAt": null,
    "participantState": [
      {
        "userId": "66400a8f1c2b4d5e6f7a8b00",
        "unreadCount": 0,
        "lastReadMessageId": null,
        "mutedAt": null,
        "blockedAt": null
      },
      {
        "userId": "66400a8f1c2b4d5e6f7a8b01",
        "unreadCount": 0,
        "lastReadMessageId": null,
        "mutedAt": null,
        "blockedAt": null
      }
    ],
    "status": "ACTIVE",            // enum: "ACTIVE" | "ARCHIVED" | "BLOCKED"
    "createdAt": "2026-05-22T14:32:08.412Z",
    "updatedAt": "2026-05-22T14:32:08.412Z",
    "peer": {
      "id": "66400a8f1c2b4d5e6f7a8b01",
      "firstName": "Bob",
      "avatarUrl": "https://cdn.swappr.co.uk/avatars/bob.jpg"
    },
    "property": {
      "id": "66400a8f1c2b4d5e6f7a8c01",
      "address": "12 Camden High St",
      "postcode": "NW1 0JH",
      "coverUrl": "https://cdn.swappr.co.uk/homes/cover.jpg"
    }
  }
}

Error responses

StatusCodeMeaning
400VALIDATION_FAILEDmatchId is missing or not a 24-char hex ObjectId.
401UNAUTHENTICATEDMissing, malformed, or expired access token.
403ONBOARDING_INCOMPLETECaller has not finished onboarding — chat is gated.
403FORBIDDENCaller is authenticated but is not userA or userB of this match.
403CONTACT_NOT_ALLOWEDThe 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).
403CONVERSATION_BLOCKEDA block exists between the two participants in either direction (see block-user).
404NOT_FOUNDNo matches row with that id.

Example error — 403 FORBIDDEN

{
  "type": "https://api.swappr.co.uk/errors/forbidden",
  "title": "Forbidden",
  "status": 403,
  "code": "FORBIDDEN",
  "detail": "caller is not a participant of this match",
  "instance": "/api/v1/chat/conversations/from-match/66400a8f1c2b4d5e6f7a8e00",
  "requestId": "01HZQ7K3M4N5P6Q7R8S9T0V1W2"
}

Idempotency & race-safety

The endpoint is idempotent by matchId. Internally:
  • A unique partial index (matchId) on conversations makes a duplicate insert E11000.
  • The service reads-then-creates-then-falls-back-to-read on duplicate-key: if two from-match calls 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.
You do not need to pass an Idempotency-Key header; the match id IS the idempotency key.

Side effects

  • If the row didn’t exist: one conversations document is inserted with status: "ACTIVE", both participantState entries zeroed.
  • No notifications fire at this point — push fan-out happens on the first message, not on handshake creation (Phase 5).

See also

curl

curl -X POST https://api.swappr.co.uk/api/v1/chat/conversations/from-match/66400a8f1c2b4d5e6f7a8e00 \
  -H "Authorization: Bearer $ACCESS_TOKEN"