The Vatra Homes API · v1.1.0

Put a managed portfolio
on your own site

A read-first REST API over the listings an owner has published. One header to authenticate, JSON in and out, an incremental feed so you are not re-importing a catalogue every night, and one endpoint to hand a prospect back to whoever manages the unit.

01

What the API serves

The API serves exactly the properties an owner has published for listing — the ones carrying is_published in the portfolio, surfaced through the Share Config screen. Unpublished, sold and internal-only units are never returned, and no tenant, contract, cheque or financial record is reachable through any endpoint on this API.

Base URL
https://scxbhdciuguloaplqzqm.supabase.co/functions/v1/listings-api
First call — no key needed
curl https://scxbhdciuguloaplqzqm.supabase.co/functions/v1/listings-api/v1/health

Every path below is relative to that base URL, every response is JSON, and every request that reaches the API is recorded against the key that made it — successful or not — so usage is visible to the portfolio owner who issued it.

02

Authentication

Every endpoint except GET /v1/health requires an API key, sent in the x-api-key header. Keys are issued from the Vatra Homes super admin console and are shown once at creation — only a SHA-256 hash is stored, so a lost key must be replaced rather than recovered.

Every request
x-api-key: pk_live_…
  • Keys are scoped. A request for an endpoint outside the key's scopes is rejected with 403 and never touches the database.
  • A key can carry an expiry date. Requests after that moment are rejected with 401.
  • A revoked key stops working immediately — revocation is a status change, not a delete, so its request history stays intact.
  • No Supabase anon key, bearer token or cookie is needed. The API key is the whole credential.

Scopes

listings:read

Read published listings and the incremental sync feed.

GET /v1/listings · GET /v1/listings/{id} · GET /v1/sync/updated

inquiries:write

Submit an enquiry against a published listing.

POST /v1/inquiries

03

Rate limits

Each key carries its own quota, counted over a rolling 24-hour window. Every authenticated response — including errors — carries the three headers below, so a client can back off before it is refused. Exceeding the quota returns 429 with a Retry-After header; the window is rolling, so capacity returns gradually rather than all at once on a fixed reset.

X-RateLimit-LimitRequests allowed for this key per 24-hour window.
X-RateLimit-RemainingRequests still available in the current window.
X-RateLimit-ResetUnix seconds at which the oldest counted request leaves the window.
Retry-AfterSeconds to wait before retrying. Sent only with a 429 response.

The quota is set per key when it is issued, and the window is rolling 24 hours. Refused requests count towards it, so a client that keeps hammering a spent quota holds its own window open — read X-RateLimit-Remaining and wait.

04

Every response has the same shape

Success or failure, you parse one envelope. Paging lives in meta; so does the request id you quote when something looks wrong.

Success
{
  "success": true,
  "data": [ /* … */ ],
  "meta": {
    "request_id": "0f1c2a3b-4d5e-6f70-8192-a3b4c5d6e7f8",
    "timestamp": "2026-09-18T17:05:36.581Z",
    "total": 3,
    "returned": 1,
    "limit": 1,
    "offset": 0,
    "has_more": true
  },
  "total": 3,
  "limit": 1,
  "offset": 0
}
Failure
{
  "success": false,
  "error": {
    "code": "invalid_api_key",
    "message": "The API key is not recognised."
  },
  "meta": {
    "request_id": "0f1c2a3b-4d5e-6f70-8192-a3b4c5d6e7f8",
    "timestamp": "2026-09-18T17:05:36.581Z"
  }
}
  • Every response — success or failure — is a JSON object with a boolean success field and a meta object carrying request_id and timestamp. Quote request_id when reporting a problem.
  • Collection endpoints repeat total, limit and offset at the top level as well as inside meta. The top-level copies exist for backward compatibility with the first release of this API; meta is the canonical location and the one to read in new code.
  • error is an object, never a bare string. Branch on error.code, which is stable, rather than on error.message, which is written for humans.

Error codes

401missing_api_keyNo x-api-key header was sent.
401invalid_api_keyThe key is not recognised.
401key_revokedThe key has been revoked in the console.
401key_expiredThe key passed its expiry date.
403insufficient_scopeThe key is valid but lacks the scope this endpoint requires.
400invalid_requestA parameter or body field is missing or malformed. See error.message.
400invalid_jsonThe request body was not valid JSON.
404not_foundNo published listing with that id, or no such route.
405method_not_allowedThe route exists but not for this HTTP method.
429rate_limit_exceededThe key's 24-hour quota is spent. Retry after the seconds given in Retry-After.
500internal_errorSomething failed on our side. The request_id in meta identifies it in our logs.

05

The endpoints

Six of them. Two are service calls you will use once while wiring up; the other four are the whole integration.

GET/v1/healthNo key required

Health check

Returns the running API version and the server clock. Use it to confirm connectivity and to verify you are pointed at the right base URL before you start debugging credentials.

Request
curl "https://scxbhdciuguloaplqzqm.supabase.co/functions/v1/listings-api/v1/health"
Response · 200
{
  "success": true,
  "data": {
    "status": "ok",
    "version": "1.1.0",
    "time": "2026-09-18T17:05:36.581Z"
  },
  "meta": {
    "request_id": "0f1c2a3b-4d5e-6f70-8192-a3b4c5d6e7f8",
    "timestamp": "2026-09-18T17:05:36.581Z"
  }
}
GET/v1/meAny valid key

Inspect the calling key

Returns the key's name, prefix, scopes, quota and current usage in the rolling window. Requires a valid key but no particular scope, which makes it the right first call when wiring up a new integration: it proves the credential works without depending on any data being published.

Request
curl "https://scxbhdciuguloaplqzqm.supabase.co/functions/v1/listings-api/v1/me" \
  -H "x-api-key: pk_live_your_key_here"
Response · 200
{
  "success": true,
  "data": {
    "name": "Portal Sync",
    "key_prefix": "pk_live_ffba...",
    "scopes": ["listings:read", "inquiries:write"],
    "status": "active",
    "rate_limit": 1000,
    "window_seconds": 86400,
    "usage": {
      "used": 12,
      "remaining": 988,
      "reset_at": "2026-09-19T17:05:36.581Z"
    },
    "expires_at": null,
    "created_at": "2026-03-10T11:26:06.838Z"
  },
  "meta": {
    "request_id": "0f1c2a3b-4d5e-6f70-8192-a3b4c5d6e7f8",
    "timestamp": "2026-09-18T17:05:36.581Z"
  }
}
Can also answer
401 missing_api_key401 invalid_api_key401 key_revoked401 key_expired429 rate_limit_exceeded
GET/v1/listingslistings:read

List published listings

Returns published listings newest first. meta.total is the number of listings matching your filters across all pages — not the size of the page you are holding — so it is safe to drive a pager from it. Every filter is applied in the database before paging, so page 2 of a filtered query contains what you expect.

Parameters
type
stringrent or sale.
bedrooms
integerMinimum bedrooms. A studio is 0.
area
stringCase-insensitive partial match on the community area, e.g. Marina matches Dubai Marina.
min_price
numberMinimum listing price in AED.
max_price
numberMaximum listing price in AED.
limit
integerPage size, 1–100. Default 20. Non-numeric values are rejected with 400.
offset
integerRows to skip. Default 0. Must not be negative.
Request
curl "https://scxbhdciuguloaplqzqm.supabase.co/functions/v1/listings-api/v1/listings?type=rent&limit=20" \
  -H "x-api-key: pk_live_your_key_here"
Response · 200
{
  "success": true,
  "data": [
    {
      "id": "sunset-2a",
      "unit": "Apt 2A",
      "building": "Sunset Apartments",
      "area": "Dubai Marina",
      "developer": "Ellington Properties",
      "type": "rent",
      "bedrooms": 0,
      "bathrooms": 1,
      "sqft": 520,
      "price": 52000,
      "description": "Bright studio on the second floor, fully fitted kitchen, chiller-free, one minute from the tram. Available now.",
      "parking": [],
      "published_at": "2026-09-06T09:00:00+00:00"
    }
  ],
  "meta": {
    "request_id": "0f1c2a3b-4d5e-6f70-8192-a3b4c5d6e7f8",
    "timestamp": "2026-09-18T17:05:36.581Z",
    "total": 3,
    "returned": 1,
    "limit": 1,
    "offset": 0,
    "has_more": true
  },
  "total": 3,
  "limit": 1,
  "offset": 0
}
Can also answer
401 missing_api_key401 invalid_api_key403 insufficient_scope400 invalid_request429 rate_limit_exceeded
GET/v1/listings/{id}listings:read

Get one listing

Everything the summary carries, plus address, view, ownership, building amenities and the media gallery. Gallery URLs are signed server-side and expire one hour after the response is generated — download or re-fetch rather than storing them. A listing that exists but is not published returns 404, exactly like an unknown id.

Parameters
idRequired
string · pathThe listing id returned by GET /v1/listings.
Request
curl "https://scxbhdciuguloaplqzqm.supabase.co/functions/v1/listings-api/v1/listings/sunset-2a" \
  -H "x-api-key: pk_live_your_key_here"
Response · 200
{
  "success": true,
  "data": {
    "id": "sunset-2a",
    "unit": "Apt 2A",
    "building": "Sunset Apartments",
    "developer": "Ellington Properties",
    "area": "Dubai Marina",
    "address": "Sunset Apartments, Unit 2A, Dubai Marina",
    "type": "rent",
    "bedrooms": 0,
    "bathrooms": 1,
    "sqft": 520,
    "view": "Community View",
    "ownership": "Freehold",
    "price": 52000,
    "description": "Bright studio on the second floor, fully fitted kitchen, chiller-free, one minute from the tram.",
    "amenities": ["infinity_pool", "gym", "concierge"],
    "parking": [],
    "gallery": [
      {
        "url": "https://…/storage/v1/object/sign/property-media/…",
        "type": "photo",
        "caption": "Living area",
        "is_cover": true
      }
    ],
    "published_at": "2026-09-06T09:00:00+00:00"
  },
  "meta": {
    "request_id": "0f1c2a3b-4d5e-6f70-8192-a3b4c5d6e7f8",
    "timestamp": "2026-09-18T17:05:36.581Z"
  }
}
Can also answer
401 missing_api_key401 invalid_api_key403 insufficient_scope404 not_found429 rate_limit_exceeded
POST/v1/inquiriesinquiries:write

Submit an enquiry

Records an enquiry against a published listing and surfaces it in the owner's activity feed. property_id must name a currently published listing; anything else is refused with 404 rather than silently stored. The response carries the created enquiry id so you can reconcile duplicates on your side.

Request body · application/json
property_idRequired
stringId of a published listing.
nameRequired
stringEnquirer's name. 2–120 characters.
emailRequired
stringEnquirer's email address. Must be a valid address, max 200 characters.
phone
stringContact number, max 40 characters.
message
stringFree text, max 2000 characters.
Request
curl -X POST "https://scxbhdciuguloaplqzqm.supabase.co/functions/v1/listings-api/v1/inquiries" \
  -H "x-api-key: pk_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "property_id": "sunset-2a", "name": "Layla Haddad", "email": "layla@example.com", "phone": "+971 50 123 4567", "message": "Is the studio still available for a September move-in?" }'
Response · 201
{
  "success": true,
  "data": {
    "id": "a9e501bb-43a9-4741-9d92-29d88675ec32",
    "property_id": "sunset-2a",
    "received_at": "2026-09-18T17:06:37.027Z"
  },
  "meta": {
    "request_id": "0f1c2a3b-4d5e-6f70-8192-a3b4c5d6e7f8",
    "timestamp": "2026-09-18T17:06:37.027Z"
  }
}
Can also answer
401 missing_api_key401 invalid_api_key403 insufficient_scope400 invalid_request400 invalid_json404 not_found429 rate_limit_exceeded
GET/v1/sync/updatedlistings:read

Incremental sync

Returns listings whose published_at is at or after since, oldest change last. Store meta.sync_timestamp from each response and pass it back as since on the next run. This feed reports additions and re-publications only: a listing withdrawn from the market stops appearing in GET /v1/listings but does not appear here as a deletion, so reconcile against the full catalogue periodically — daily is enough for most portals.

Parameters
sinceRequired
stringISO 8601 timestamp, e.g. 2026-09-01T00:00:00Z. An unparseable value is rejected with 400.
limit
integerPage size, 1–500. Default 100.
offset
integerRows to skip. Default 0.
Request
curl "https://scxbhdciuguloaplqzqm.supabase.co/functions/v1/listings-api/v1/sync/updated?since=2026-09-01T00%3A00%3A00Z&limit=100" \
  -H "x-api-key: pk_live_your_key_here"
Response · 200
{
  "success": true,
  "data": [
    {
      "id": "sunset-2a",
      "unit": "Apt 2A",
      "building": "Sunset Apartments",
      "area": "Dubai Marina",
      "developer": "Ellington Properties",
      "type": "rent",
      "bedrooms": 0,
      "bathrooms": 1,
      "sqft": 520,
      "price": 52000,
      "description": "Bright studio on the second floor, fully fitted kitchen, chiller-free, one minute from the tram. Available now.",
      "parking": [],
      "published_at": "2026-09-06T09:00:00+00:00"
    }
  ],
  "meta": {
    "request_id": "0f1c2a3b-4d5e-6f70-8192-a3b4c5d6e7f8",
    "timestamp": "2026-09-18T17:05:36.581Z",
    "sync_timestamp": "2026-09-18T17:05:36.581Z",
    "total": 3,
    "returned": 3,
    "limit": 100,
    "offset": 0,
    "has_more": false
  },
  "sync_timestamp": "2026-09-18T17:05:36.581Z"
}
Can also answer
401 missing_api_key401 invalid_api_key403 insufficient_scope400 invalid_request429 rate_limit_exceeded

06

Keeping a portal in step

Import the catalogue once, then ask only for what changed. The one thing to watch is that a withdrawal is not a change you can see — so reconcile occasionally.

  1. 01On first run, call GET /v1/listings and page through it with limit and offset until meta.has_more is false. That is your baseline catalogue.
  2. 02Store meta.sync_timestamp from the first GET /v1/sync/updated call, or the timestamp of your baseline import.
  3. 03On every run after that, call GET /v1/sync/updated?since=<stored timestamp>. Upsert what comes back by id and store the new meta.sync_timestamp.
  4. 04The feed pages like the catalogue: 100 rows by default, up to 500. Keep requesting with a rising offset while meta.has_more is true, and only then store the new cursor — a partial import with an advanced cursor loses the rows you skipped.
  5. 05Because withdrawals are not reported as deletions, re-run the full catalogue walk on a slower cadence — nightly is plenty — and retire any id that has disappeared.
A sync run, in full
let cursor = localStorage.getItem("vatra_sync_cursor") ?? "2026-01-01T00:00:00Z";

const res = await fetch(
  `${BASE}/v1/sync/updated?since=${encodeURIComponent(cursor)}`,
  { headers: { "x-api-key": KEY } }
);
const body = await res.json();

if (!body.success) throw new Error(body.error.message);

for (const listing of body.data) await upsertListing(listing);
localStorage.setItem("vatra_sync_cursor", body.meta.sync_timestamp);

07

Generate your client

The full OpenAPI 3.1 description of everything above — every path, parameter, schema and example — is served as a static file. Point your generator at it rather than transcribing this page.

/openapi.json

OpenAPI 3.1 · version 1.1.0

Download OpenAPI

Keys are issued by the portfolio owner from the Vatra Homes console, with the scopes and quota they choose. If you are integrating on behalf of an owner, ask them for one — we do not issue keys directly to third parties.