Moving a website to V2
One key, one host, the same property fields. For anyone who builds or looks after an agency website on Loop.
Today a website reads its properties from apiv3.loop.software (network.api) and sends its enquiries and reads its team through V1 at api.loop.software. Everything a website needs is now in V2, at api.loop.software/v2, with one key.
The property data is unchanged: the same fields, with the same names, plus the office's name (teamName). What changes is the key, the addresses, the paging, and a few behaviours, all listed below. The old addresses keep working while you move.
What breaks if you only change the host
- The key goes in
Authorization: Bearer, notx-api-key.- Results are in
dataandpagination, notresults.- Enum values are names (
"forSale"), not numbers (2).- Enquiries answer
201 Created, not200with{ "value": true }.
1. Get a V2 key
One client key covers every office in the agency. To show one office's properties, filter with teamIds. A team id outside your key's agency answers 403 Forbidden.
| Today | V2 | |
|---|---|---|
| Key | One team key per office, as x-api-key: <guid> | One client key for the agency, as Authorization: Bearer lp_live_… |
| Offices | Other offices' keys in teamAPIList | teamIds, a list of team ids |
| Permissions | Everything the key could reach | Scopes, from the Website preset |
curl "https://apiv3.loop.software/api/sales-properties?fetch=20" \
-H "x-api-key: 3f2a9c1e-0000-0000-0000-000000000000"curl "https://api.loop.software/v2/website/sales/properties?limit=20" \
-H "Authorization: Bearer lp_live_…"Ask support for the Website preset. It gives the key
read:sales-website,read:lettings-website,read:commercial-websiteandwrite:enquiries.
2. Change the addresses
Properties and teams
| Today | V2 | Scope |
|---|---|---|
GET apiv3.loop.software/api/sales-properties | GET /v2/website/sales/properties | read:sales-website |
GET apiv3.loop.software/api/sales-properties/{lifecycleId} | GET /v2/website/sales/properties/{id} | read:sales-website |
GET apiv3.loop.software/api/lettings-properties | GET /v2/website/lettings/properties | read:lettings-website |
GET apiv3.loop.software/api/lettings-properties/{lifecycleId} | GET /v2/website/lettings/properties/{id} | read:lettings-website |
GET apiv3.loop.software/api/sales-properties/search-stats | GET /v2/website/sales/properties/search-stats | read:sales-website |
GET apiv3.loop.software/api/lettings-properties/search-stats | GET /v2/website/lettings/properties/search-stats | read:lettings-website |
GET apiv3.loop.software/api/sales-properties/by-listing/{listingId} | GET /v2/website/sales/properties/by-listing/{listingId} | read:sales-website |
GET apiv3.loop.software/api/lettings-properties/by-listing/{listingId} | GET /v2/website/lettings/properties/by-listing/{listingId} | read:lettings-website |
GET api.loop.software/team | GET /v2/website/teams, GET /v2/website/teams/{id} | either website scope |
A property's {id} is the same lifecycle id as today: salesLifecycleId or lettingsLifecycleId in search results.
Enquiries
| Today (V1) | V2 | Scope |
|---|---|---|
POST /property/residential/sales/{id}/viewing | POST /v2/sales/enquiries with "type": "viewing" | write:enquiries |
POST /property/residential/sales/{id}/call-back | POST /v2/sales/enquiries with "type": "callBack" | write:enquiries |
POST /property/residential/sales/{id}/information | POST /v2/sales/enquiries with "type": "information" | write:enquiries |
POST /property/residential/lettings/{id}/viewing, /call-back, /information | POST /v2/lettings/enquiries with the same three types | write:enquiries |
POST /marketing/enquiries/seller | POST /v2/sales/valuation-requests | write:enquiries |
Not in V2 yet: keep using these
These network.api addresses have no V2 version so far. Keep using them, and tell us if your site depends on one.
| network.api | What it does |
|---|---|
api/sales-properties/{lifecycleId}/sales, {lifecycleId}/documents, random-images | Sales history, documents, images |
api/direct-viewings/… | Booking a viewing directly |
api/teams/… other than your own team, and api/network/… | Other teams and the network |
3. Property search
Parameters. All of today's parameters work as before, with two changes: limit replaces fetch (1 to 100, default 20), and teamIds (team ids) replaces teamAPIList (other teams' keys).
So lat, lng, range, searchText, minBeds, minPrice, maxPrice, minRent, maxRent, propertyTypes, propertyTypeList, letPeriod, webListingStatuses, refId, roleType, sortOrder and the …Only and exclude… switches are unchanged.
The response. Results come in V2's paging envelope. Each item has the same fields as today, plus teamName: the office the property belongs to, alongside its phone and email, so you can route a lead without another call.
{
"results": [ … ],
"totalResults": 57, "returnedResults": 20, "more": true,
"offset": 0, "perPage": 20, "page": 1, "totalPages": 3
}{
"data": [
{ "webMarketingStatus": "forSale", "propertyType": "semiDetached",
"teamName": "Example Estates Harrogate", … }
],
"pagination": { "offset": 0, "limit": 20, "hasMore": true, "total": 57 }
}Paging fields, old to new
| Today | V2 |
|---|---|
results | data |
totalResults | pagination.total |
more | pagination.hasMore |
offset, perPage | pagination.offset, pagination.limit |
page, totalPages, returnedResults | Gone: work them out from offset, limit, total and the length of data |
Enum values: numbers become names
This applies to every field and parameter that holds one of a fixed set of values. The API reference lists every value for each field. The matching text fields (propertyTypeText, parkingText and so on) are unchanged.
| Today | V2 |
|---|---|
"webMarketingStatus": 2 | "webMarketingStatus": "forSale" |
"propertyType": 3 | "propertyType": "semiDetached" |
sortOrder=4 | sortOrder=mostRecentlyAdded |
Search form ranges
GET /v2/website/sales/properties/search-stats and GET /v2/website/lettings/properties/search-stats return today's figures for your search form's dropdowns, with the same names: totalCount, minPrice, maxPrice and avgPrice (minRent, maxRent and avgRent for lettings), minBedrooms and maxBedrooms. They cover the same properties the search shows, and take teamIds in place of teamAPIList.
V2 adds minBathrooms, maxBathrooms and propertyTypes: the property types you have on the market, with how many of each. Each value is what the search's propertyTypes parameter takes, so a dropdown built from it never offers a type with nothing to show.
{
"totalCount": 57,
"minPrice": 125000, "maxPrice": 650000, "avgPrice": 310500.5,
"minBedrooms": 1, "maxBedrooms": 5,
"minBathrooms": 1, "maxBathrooms": 3,
"propertyTypes": [
{ "value": "detached", "name": "Detached House", "count": 20 },
{ "value": "flat", "name": "Flat", "count": 15 },
{ "value": "semiDetached", "name": "Semi-Detached House", "count": 22 }
]
}With nothing on the market, every figure is 0 and propertyTypes is empty. Today's call answers with an empty body.
4. Property pages
GET /v2/website/sales/properties/{id} and GET /v2/website/lettings/properties/{id} return the same fields as today's detail calls, plus teamName.
To look a property up by its website listing id, as today's by-listing calls do, use GET /v2/website/sales/properties/by-listing/{listingId} or GET /v2/website/lettings/properties/by-listing/{listingId}. They return the property exactly as the calls above do.
Only properties listed on the website. These calls return a property only if it has a listing on the website, whatever that listing's status. Any other property answers
404 Not Found, as does one outside your key's agency. Today these calls return any property.
5. Teams
GET /v2/website/teams lists every active office your key covers, in name order. GET /v2/website/teams/{id} returns one. With a client key, one call gives you every office, where V1 needed a key per office.
curl "https://api.loop.software/team" -H "x-api-key: <first office's key>"
curl "https://api.loop.software/team" -H "x-api-key: <second office's key>"
# …and so on for each officecurl "https://api.loop.software/v2/website/teams" \
-H "Authorization: Bearer lp_live_…"Each team has the fields V1's team returns, with the same names, and these changes:
Team fields: added and changed
| Field | Change |
|---|---|
id, clientId | Added |
members | Added: the office's current agents (leavers aren't listed) |
prospectingOutcodes, matchingOutcodes | Added: for routing a lead to an office by postcode |
primaryDarkColour and the other brand colours | Now filled in (V1 always returned them empty) |
links | Added: the office's sales and lettings properties |
apiKey | No longer returned |
dateCreated | A date rather than text |
6. Enquiries and valuation requests
Send the same details as today. The field names match what V2 returns when you read an enquiry back. Call the API from your server, never from the browser: anyone who can see the key can use it.
curl -X POST "https://api.loop.software/v2/sales/enquiries" \
-H "Authorization: Bearer lp_live_…" \
-H "Content-Type: application/json" \
-d '{
"salesLifecycleListingId": 99120,
"type": "viewing",
"firstName": "Sam",
"lastName": "Example",
"emailAddress": "[email protected]",
"preferredTimes": ["2026-11-07T10:00:00Z"]
}'$response = wp_remote_post('https://api.loop.software/v2/sales/enquiries', [
'headers' => [
'Authorization' => 'Bearer ' . LOOP_API_KEY,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode([
'salesLifecycleListingId' => 99120,
'type' => 'viewing',
'firstName' => 'Sam',
'lastName' => 'Example',
'emailAddress' => '[email protected]',
'preferredTimes' => ['2026-11-07T10:00:00Z'],
]),
]);
if (wp_remote_retrieve_response_code($response) !== 201) {
// A 400 lists each problem under the field it's about.
}const res = await fetch("https://api.loop.software/v2/sales/enquiries", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LOOP_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
salesLifecycleListingId: 99120,
type: "viewing",
firstName: "Sam",
lastName: "Example",
emailAddress: "[email protected]",
preferredTimes: ["2026-11-07T10:00:00Z"],
}),
});
if (res.status !== 201) {
// A 400 lists each problem under the field it's about.
}A valuation request with a client key also needs teamId: the office the request is for. It can include propertyType and bedrooms too.
Enquiry fields, V1 to V2
| V1 | V2 |
|---|---|
{id} in the address | Gone (V1 ignored it) |
ListingId | salesLifecycleListingId (lettings: lettingsLifecycleListingId), the same id |
Title, FirstName, LastName | title, firstName, lastName |
EmailAddress, PhoneDaytime, PhoneEvening | emailAddress, phoneDaytime, phoneEvening |
Address, Postcode, Comment | address, postcode, comment |
PossibleDate1, PossibleDate2, PossibleDate3 | preferredTimes: a list of up to three ISO 8601 times |
DateMoving, ReasonForMove | dateMoving, reasonForMove |
PropertyToSell, PropertyToRent, FinancialAdvice, PartExchange | propertyToSell, propertyToRent, financialAdvice, partExchange |
SendBrochure | Gone (V1 never used it) |
Valuation: MinSalesValuation, SalesValuation, MaxSalesValuation | salesValuation: { min, value, max } (rental: rentalValuation) |
Valuation: DateReceived, ExternalId | Gone (Loop sets the date) |
| V1 | V2 | |
|---|---|---|
| Success | 200 with { "value": true } | 201 Created with the whole enquiry and a Location header |
| A listing your key can't reach, or none given | 401, or 200 with "value": false | 400 with the reason |
| Which office gets the lead | The key's office | The office the listing belongs to |
| Alerts, automatic replies and phone notifications | Sales only | Sales, lettings and valuation requests, as for a portal lead |
| One of those failing | Could fail the request after the enquiry was saved | Never fails the request |
7. Errors
V2 errors come back as ProblemDetails JSON, with a title, a detail and the status.
| Status | Means |
|---|---|
400 | Something in the request isn't valid; errors says what |
401 | No key, or the key isn't valid |
403 | The key lacks the scope, or a filter names an office outside its agency |
404 | Not found, or not one your key can see |
429 | Too many requests; wait for the time in Retry-After |
503 | Property search is unavailable |
A
503from search is not "no results". Show the visitor "try again shortly", never an empty results page.
Checklist
- Get a client key with the Website preset.
- Send it as
Authorization: Bearer …instead ofx-api-key. - Change the addresses (step 2).
- Swap
fetchforlimitandteamAPIListforteamIds. - Read results from
dataandpagination. - Build the search form's ranges and property type list from
search-stats. - Compare enum values with names, not numbers.
- Expect
404for a property page that isn't listed on the website. - Move the enquiry forms to the V2 field names, and expect
201. - Handle
503from search as "try again", not as an empty page.
Updated 16 days ago
