GET
/
api
/
v1
/
matches
List my matches
curl --request GET \
  --url https://api.example.com/api/v1/matches
import requests

url = "https://api.example.com/api/v1/matches"

response = requests.get(url)

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

fetch('https://api.example.com/api/v1/matches', 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/matches",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "GET",
]);

$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/matches"

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

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

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

fmt.Println(string(body))

}
HttpResponse<String> response = Unirest.get("https://api.example.com/api/v1/matches")
.asString();
require 'uri'
require 'net/http'

url = URI("https://api.example.com/api/v1/matches")

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

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

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

Overview

Returns the authenticated user’s mutual-match feed — every matches row where the caller is a participant and has not dismissed the match. Each entry surfaces the other user’s listing (the property the caller could swap into) plus the caller’s own saved state. A row exists only when both users pass each other’s hard filters (the matching engine persists mutual handshakes only), so every result here is a genuine two-way match. There is no public listing-browse endpoint by design — properties are only ever visible through match context, which prevents scraping. Results are sorted by score descending, then computedAt descending, and paginated with an opaque cursor.

Authentication

Bearer <accessToken> required. Scope: user. The caller must have completed onboarding and have an approved tenancy (requireOnboarded) — otherwise 403.

Path parameters

None.

Query parameters

ParamTypeDefaultMaxNotes
cursorstringThe nextCursor from a prior page. Omit on the first page. Opaque (base64url); do not parse.
limitinteger2050Items per page.
qstring200 charsCase-insensitive filter on the other property’s postcode or address. Free text only — see the note below on area searches.
latnumber-90..90Centre of an area search. Only applied when lat, lng and radiusMiles are all present. Also the point results are sorted nearest-first from, in every scoping mode.
lngnumber-180..180See lat.
radiusMilesnumber0.1..100The distance window — used only when neither the map window nor a borough applies (see Area scoping).
swLat, swLng, neLat, neLngnumberThe map window to search, as the box’s south-west and north-east corners. All four must be sent together, with swLat <= neLat. swLng > neLng is legal and means the box crosses the antimeridian.
scopeenumautoauto | bounds. bounds forces the map window to win however small it is; auto lets the server choose (see below).
savedbooleanWhen true, restricts the feed to matches the caller has saved (savedAt != null). Powers the Saved tab.

Area scoping

The filters are chosen from the size of the box the caller sends:
Box sentBox filterBorough filterMeaning
scope=bounds (any size)yesnoMap re-search — the visible rectangle is the question.
>= 15 milesyesnoA region (“London, UK”). No single borough represents it.
>= 3 milesyesyesA borough pick (“Tower Hamlets”).
< 3 milesyesnoA neighbourhood (“Kilburn”, “Camden Town”).
no box at allnoyesLegacy clients — borough first, then radiusMiles.
radiusMiles applies only on that last row when the point resolves to no borough.
Why a neighbourhood search must ignore the borough. Areas routinely straddle council boundaries. Kilburn is mostly Brent, not Camden — so scoping a “Kilburn” search to the borough its centre lands in filtered to a council with no listings and returned nothing, while searching “Camden” returned the whole borough, Kilburn included. Users search by area, not by council.Checked against a 75-area sample across Camden, Islington, Hackney and Tower Hamlets, 7 areas resolve to a different borough than the one they are commonly listed under — Highgate (Haringey), Kilburn (Brent), Tufnell Park (Islington), De Beauvoir Town (Hackney), Nag’s Head (Westminster), Newington Green (Hackney) and Millwall (Lewisham). Every one of them returned zero results under borough scoping.Why borough scoping is still kept for borough-scale picks. A borough’s bounding box is not the borough. Camden is a wedge, so its box clips Islington and Westminster at the corners — the bleed that made “Camden” show Islington results. Keeping the borough equality for boxes >= 3 miles preserves that fix.Why 3 miles separates them. Google returns neighbourhoods as sublocality with viewports of ~0.2–2.1mi, and boroughs as administrative_area_level_3 at ~4mi+ (Tower Hamlets is 4.06mi). Of the 75 areas sampled, all 75 fell below 3 miles.

Request body

None.

Response — 200 OK

FieldTypeNotes
matchesobject[]Array of Match summary.
nextCursorstring | nullPass as cursor to fetch the next page. null on the last page.

Match summary object

FieldTypeNotes
matchIdstring24-char ObjectId of the match row. Use in the detail / save endpoints.
scoreinteger0..100 compatibility score. Drives the feed ordering; the app no longer renders it.
perfectMatchbooleantrue only when score === 100.
matchTierenumPERFECT | GREAT | GOOD — the label the app shows in place of the percentage. See Match tiers.
savedAtstring | nullISO 8601 UTC when the caller saved this match, or null.
computedAtstringISO 8601 UTC when the match was last (re)computed. Rendered as “Active <relative> ago” on the match card — there is no separate user-presence signal, so the recompute time is the freshness proxy.
matchedAtstringISO 8601 UTC when the match first appeared (the row’s creation time). Never rewritten by a recompute, so this — not computedAt — is what the app’s newest/oldest sort orders on: a peer editing their listing would otherwise float a months-old match to the top of “Newest”.
theyWantSummarystring | nullOne-line summary of the peer’s desired home, e.g. "2-3 bedroom flat or terraced in Camden". null when the peer has no preferences row. The full structured version is on the detail endpoint’s theyWant.
peerobject{ id, firstName } — the other user. firstName may be null.
propertyobjectThe other user’s listing. See Property object.

Match tiers

matchTier buckets score into the label the app renders on the match card. The raw percentage read badly — a genuine mutual match with no overlapping optional features scores 0 and showed as “0% match”, which looks like a rejection even though every mandatory requirement was met on both sides.
TierScoreMeaningApp copy
PERFECT100Mandatory filters + every preferred feature”Perfect Match” (star)
GREAT199Mandatory filters + some preferred features”Great Match” (filled tick)
GOOD0Mandatory filters only — no preferred feature overlap”Good Match” (outlined tick)
There is no “no match” tier: a matches row only exists when every hard filter passed on both sides, so GOOD is the floor. A browse/search listing that is not a match carries match: null (see the nearby endpoint) and the app labels that “No Match” itself.

Property object

FieldTypeNotes
idstring24-char ObjectId of the listing.
propertyTypeenumDETACHED, SEMI_DETACHED, TERRACED, FLAT, MAISONETTE, BUNGALOW.
bedroomsinteger0..10.
bathroomsinteger0..10.
addressstringFree-text address.
addressDetailsstringOwner-typed house/flat detail (e.g. Flat 4); "" when not set.
postcodestringUK postcode, uppercased.
locationobject{ lng, lat }.
rentobject{ amountMinor: integer, frequency: "WEEKLY" | "MONTHLY" }.
rentMonthlyMinorintegerServer-derived monthly pence.
featuresstring[]Subset of: GARDEN, PARKING, BALCONY, LIFT, GROUND_FLOOR, PETS_ALLOWED, WHEELCHAIR_ACCESS.
photosobject[]{ id, url, thumbnailUrl, orderIndex, isCover, blurhash, width, height }, ordered by orderIndex. blurhash is a placeholder string ("" when absent); width/height are the cropped pixel dims (0 when absent).

Example response

{
  "matches": [
    {
      "matchId": "66400a8f1c2b4d5e6f7a8e00",
      "score": 88,
      "perfectMatch": false,
      "matchTier": "GREAT",                             // enum: "PERFECT" | "GREAT" | "GOOD"
      "savedAt": null,                                  // ISO 8601 UTC or null
      "computedAt": "2026-05-22T12:00:00.000Z",         // ISO 8601 UTC — last recompute
      "matchedAt": "2026-05-02T09:14:31.000Z",          // ISO 8601 UTC — first appeared
      "theyWantSummary": "2-3 bedroom flat or terraced in Camden",  // or null
      "peer": {
        "id": "66400a8f1c2b4d5e6f7a8b02",
        "firstName": "Sam"                              // may be null
      },
      "property": {
        "id": "66400a8f1c2b4d5e6f7a8c02",
        "propertyType": "FLAT",                         // enum: "DETACHED" | "SEMI_DETACHED" | "TERRACED" | "FLAT" | "MAISONETTE" | "BUNGALOW"
        "bedrooms": 2,
        "bathrooms": 1,
        "address": "9 Peer Road, London",
        "postcode": "E1 6AN",
        "location": { "lng": -0.0712, "lat": 51.5176 },
        "rent": { "amountMinor": 150000, "frequency": "MONTHLY" },
        "rentMonthlyMinor": 150000,
        "features": ["GARDEN"],
        "photos": [
          {
            "id": "66400a8f1c2b4d5e6f7a8d10",
            "url": "https://cdn.swappr.co.uk/listings/.../01.jpg",
            "thumbnailUrl": "https://cdn.swappr.co.uk/listings/.../01.jpg",
            "orderIndex": 0,
            "isCover": true,
            "blurhash": "L6PZfSi_.AyE_3t7t7R**0o#DgR4",
            "width": 1600,
            "height": 1200
          }
        ]
      }
    }
  ],
  "nextCursor": "eyJzY29yZSI6ODgsImNvbXB1dGVkQXQiOiIyMDI2LTA1LTIyVDEyOjAwOjAwLjAwMFoiLCJpZCI6IjY2NDAwYThmMWMyYjRkNWU2ZjdhOGUwMCJ9"
}

Error responses

StatusCodeMeaning
400VALIDATION_FAILEDlimit out of range, or other malformed query param.
401UNAUTHENTICATEDMissing, malformed, or expired access token.
403ONBOARDING_INCOMPLETE / TENANCY_PENDING / TENANCY_REJECTEDCaller has not completed onboarding / tenancy is not approved.

Side effects

None — this is a pure read.

See also

curl

curl -X GET "https://api.swappr.co.uk/api/v1/matches?limit=20&q=NW1" \
  -H "Authorization: Bearer $ACCESS_TOKEN"