Prime Index Inventory API
A read-only REST API that gives authorized banks normalized, published residential inventory from Saudi developers — projects, buildings, units, prices, availability and media — so they can build their own property financing marketplaces. The platform does not originate financing, approve loans or process payments.
Base URL https://primeindex.app/api/v1
Format JSON (UTF-8)
Version v1 (additive changes only; breaking changes ship as /api/v2)Authentication
Every request (except media downloads) must carry an API key issued to your institution by a Prime Index administrator. Keys are shown once at creation, stored only as hashes, and can be revoked instantly. Each bank receives its own credentials, independent from developer user accounts.
curl https://primeindex.app/api/v1/units \
-H "Authorization: Bearer $PRIME_INDEX_API_KEY"Call the API only from your servers. Never ship keys in web pages or mobile apps — expose inventory to customers through your own backend, as the demo marketplace does.
Access & visibility
You see inventory only from developers (or individual projects) your consumer has been explicitly granted, and only items that pass the public visibility policy: the developer is active, the project is published, the building (if any) is active and the unit is published. Drafts, archived records, internal notes, private documents and audit data are never returned. Out-of-scope IDs respond with 404, indistinguishable from non-existent ones.
Availability (AVAILABLE, RESERVED, SOLD, UNAVAILABLE) is a commercial attribute, not a visibility rule. GET /units defaults to AVAILABLE; the change feed always includes every status.
Money & units
Monetary values are objects with an exact decimal string and an ISO 4217 currency. Parse amounts with a decimal type (e.g. BigDecimal, decimal.Decimal, or integer halalas) — never binary floating point.
"price": { "amount": "1185000.00", "currency": "SAR" }Areas are square metres (areaUnit: "sqm"). Dates are YYYY-MM-DD; timestamps are ISO-8601 UTC.
Languages
Content is available in English (en, default) and Arabic (ar). Pass lang=ar on any read endpoint, or send Accept-Language: ar; an explicit lang wins, and an unsupported value is a 400. Responses carry Content-Language.
Developer-written text (names, titles, descriptions, locations, features and amenities) is returned in the requested language and falls back to English field by field when no translation exists. availableLanguages on each unit, project and building tells you which languages the developer actually provided. Unit titles without an Arabic version are generated in Arabic from the unit's attributes. Enum values (propertyType, availability, …), IDs, numbers and money never change with the language; GET /metadata returns display labels for them in the requested language.
The city filter accepts the canonical English name or the Arabic name. Use cities[].name from metadata as the filter value and cities[].label for display.
curl "https://primeindex.app/api/v1/units/<id>?lang=ar" -H "Authorization: Bearer $KEY"Pagination
List endpoints use opaque cursors. Pass pagination.nextCursor as cursor to fetch the next page; it is null on the last page. Ordering is deterministic (sort field, then ID), so pages never overlap. A cursor is only valid with the sort it was issued for. Page size: limit up to 100.
curl "https://primeindex.app/api/v1/units?city=Riyadh&propertyType=APARTMENT,PENTHOUSE&minBedrooms=3&maxPrice=2500000&sort=price&limit=50" \
-H "Authorization: Bearer $KEY"Synchronization
GET /units/changes is a change feed for keeping a local copy fresh. Every entry is either unit.upserted (store the embedded unit) or unit.removed (delete or hide your copy). The feed is ordered by a monotonic sequence; within a page each unit appears once with its latest state.
- Start without a cursor to receive every unit currently in your scope (a full sync).
- Keep requesting with
nextCursoruntilhasMoreisfalse. - Persist
nextCursor— it is returned even whendatais empty. - Poll with the stored cursor (every few minutes is typical).
Tombstones explain why a unit left your feed: unpublished, archived, project_unpublished, building_archived, developer_suspended or access_revoked. That is how you distinguish a removed unit from one that simply was not on a page. Price, availability, media, project and developer changes all produce new unit.upserted events. Page size up to 500. Webhooks are not available in v1.
Errors & rate limits
Errors share one envelope. Include requestId when contacting support.
HTTP/1.1 400 Bad Request
{
"error": {
"code": "invalid_request",
"message": "Invalid query parameters",
"details": { "limit": ["Too big: expected number to be <=100"] },
"requestId": "8a4c…"
}
}| 400 | invalid_request | Malformed parameters or cursor |
| 401 | unauthorized | Missing, invalid, expired or revoked key; suspended consumer |
| 404 | not_found | Unknown or not accessible with this key |
| 429 | rate_limited | Per-key limit exceeded; honour Retry-After |
| 500 | internal_error | Unexpected error; safe to retry with backoff |
Each key has a per-minute budget (default 120). Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (epoch seconds).
Example: keeping a local copy in sync
A complete Node.js sync worker. The same loop performs the initial full sync and every incremental poll.
const API = "https://primeindex.app/api/v1";
const headers = { Authorization: `Bearer ${process.env.PRIME_INDEX_API_KEY}` };
export async function syncInventory(db) {
let cursor = await db.getCursor(); // null on first run → full sync
for (;;) {
const url = new URL(`${API}/units/changes`);
url.searchParams.set("limit", "500");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers });
if (res.status === 429) { await sleep(Number(res.headers.get("retry-after") ?? 5) * 1000); continue; }
if (!res.ok) throw new Error(`Sync failed: ${res.status}`);
const { data, pagination } = await res.json();
await db.transaction(async (tx) => {
for (const event of data) {
if (event.type === "unit.upserted") await tx.upsertUnit(event.unit); // price.amount is a decimal string
else await tx.removeUnit(event.id, event.reason); // tombstone
}
await tx.saveCursor(pagination.nextCursor); // persist atomically with the data
});
cursor = pagination.nextCursor;
if (!pagination.hasMore) return;
}
}Endpoint reference
/api/v1/unitsList published units
Paginated list of published units you are authorized to see. Defaults to `availability=AVAILABLE`; pass `availability=all` or a comma-separated list for other statuses.
| Parameter | Type | Description |
|---|---|---|
| lang(query) | string | Content language (`en` default, `ar`). Missing Arabic fields fall back to English. `Accept-Language` is honoured when omitted. |
| projectId(query) | string (uuid) | |
| developerId(query) | string (uuid) | Developer (organization) ID |
| buildingId(query) | string (uuid) | |
| city(query) | string | City name in English or Arabic |
| district(query) | string | |
| propertyType(query) | string | Comma-separated, e.g. APARTMENT,PENTHOUSE |
| availability(query) | "all" | string | Comma-separated statuses or `all`. Defaults to AVAILABLE. |
| minPrice(query) | string | Minimum asking price (inclusive), decimal string |
| maxPrice(query) | string | Maximum asking price (inclusive), decimal string |
| minBedrooms(query) | integer | |
| maxBedrooms(query) | integer | |
| updatedSince(query) | string (date-time) | Only units whose public representation changed at or after this ISO-8601 timestamp |
| sort(query) | -updated_at | updated_at | price | -price | -published_at | `-updated_at` (default), `updated_at`, `price`, `-price`, `-published_at` |
| limit(query) | integer | Page size (1–100, default 20) |
| cursor(query) | string | `pagination.nextCursor` from the previous page (same `sort` required) |
- 200 A page of units
- 400 Invalid request parameters
- 401 Missing, invalid, expired or revoked API key
- 429 Rate limit exceeded. See `Retry-After` and `X-RateLimit-*` headers.
{
"data": [
{
"id": "0199f3a2-7c1e-7b55-9a1f-4f2d8c6a1e01",
"object": "unit",
"developerReference": "GVR-A-0302",
"unitNumber": "A302",
"propertyType": "APARTMENT",
"title": "3-Bedroom Apartment in Green Valley Residences",
"description": "Corner apartment with a private balcony overlooking the central gardens.",
"availability": "AVAILABLE",
"availableFrom": "2026-12-01",
"price": {
"amount": "1185000.00",
"currency": "SAR"
},
"appraisal": {
"value": {
"amount": "1150000.00",
"currency": "SAR"
},
"validUntil": "2027-03-31"
},
"specifications": {
"bedrooms": 3,
"bathrooms": 3,
"livingRooms": 1,
"kitchens": 1,
"parkingSpaces": 1,
"floorNumber": 3,
"levels": 1,
"hasMaidRoom": true,
"hasDriverRoom": false,
"internalArea": 168.5,
"externalArea": 12,
"totalArea": 180.5,
"landArea": null,
"areaUnit": "sqm",
"furnishing": "UNFURNISHED",
"finishing": "FULLY_FINISHED",
"orientation": "SOUTH_EAST",
"view": "Garden view"
},
"features": [
"Central AC",
"Private balcony"
],
"amenities": [
"Swimming pool",
"Gym"
],
"developer": {
"id": "0199f3a2-0000-7000-8000-000000000001",
"name": "Nakhla Urban Developments",
"slug": "nakhla",
"logoUrl": null
},
"project": {
"id": "0199f3a2-0000-7000-8000-000000000010",
"name": "Green Valley Residences",
"city": "Riyadh",
"district": "Al Malqa"
},
"building": {
"id": "0199f3a2-0000-7000-8000-000000000020",
"name": "Building A"
},
"location": {
"country": "SA",
"region": "Riyadh Region",
"city": "Riyadh",
"district": "Al Malqa",
"address": "Anas Ibn Malik Rd",
"latitude": 24.8093,
"longitude": 46.6156
},
"media": [
{
"id": "6f1c1f0e-3d7b-4b8e-9d6a-2c1b0a9f8e7d",
"kind": "IMAGE",
"category": "COVER_IMAGE",
"title": null,
"altText": "Living room",
"mimeType": "image/webp",
"width": 2400,
"height": 1600,
"url": "https://inventory.example.com/api/v1/media/6f1c1f0e-3d7b-4b8e-9d6a-2c1b0a9f8e7d?size=md",
"renditions": {
"sm": {
"url": "https://inventory.example.com/api/v1/media/6f1c1f0e-3d7b-4b8e-9d6a-2c1b0a9f8e7d?size=sm",
"width": 480,
"height": 320
}
}
}
],
"publishedAt": "2026-10-01T09:30:00.000Z",
"updatedAt": "2026-10-20T14:05:11.000Z"
}
],
"pagination": {
"limit": 20,
"hasMore": true,
"nextCursor": "eyJzIjoiLXVwZGF0ZWRfYXQiLC4uLn0"
}
}/api/v1/units/{id}Get a unit
| Parameter | Type | Description |
|---|---|---|
| id*(path) | string (uuid) | Unit ID |
| lang(query) | string | Content language (`en` default, `ar`). `Accept-Language` is honoured when omitted. |
- 200 The unit
- 400 Invalid request parameters
- 401 Missing, invalid, expired or revoked API key
- 404 Not found or not accessible with this key
- 429 Rate limit exceeded. See `Retry-After` and `X-RateLimit-*` headers.
{
"data": {
"id": "0199f3a2-7c1e-7b55-9a1f-4f2d8c6a1e01",
"object": "unit",
"developerReference": "GVR-A-0302",
"unitNumber": "A302",
"propertyType": "APARTMENT",
"title": "3-Bedroom Apartment in Green Valley Residences",
"description": "Corner apartment with a private balcony overlooking the central gardens.",
"availability": "AVAILABLE",
"availableFrom": "2026-12-01",
"price": {
"amount": "1185000.00",
"currency": "SAR"
},
"appraisal": {
"value": {
"amount": "1150000.00",
"currency": "SAR"
},
"validUntil": "2027-03-31"
},
"specifications": {
"bedrooms": 3,
"bathrooms": 3,
"livingRooms": 1,
"kitchens": 1,
"parkingSpaces": 1,
"floorNumber": 3,
"levels": 1,
"hasMaidRoom": true,
"hasDriverRoom": false,
"internalArea": 168.5,
"externalArea": 12,
"totalArea": 180.5,
"landArea": null,
"areaUnit": "sqm",
"furnishing": "UNFURNISHED",
"finishing": "FULLY_FINISHED",
"orientation": "SOUTH_EAST",
"view": "Garden view"
},
"features": [
"Central AC",
"Private balcony"
],
"amenities": [
"Swimming pool",
"Gym"
],
"developer": {
"id": "0199f3a2-0000-7000-8000-000000000001",
"name": "Nakhla Urban Developments",
"slug": "nakhla",
"logoUrl": null
},
"project": {
"id": "0199f3a2-0000-7000-8000-000000000010",
"name": "Green Valley Residences",
"city": "Riyadh",
"district": "Al Malqa"
},
"building": {
"id": "0199f3a2-0000-7000-8000-000000000020",
"name": "Building A"
},
"location": {
"country": "SA",
"region": "Riyadh Region",
"city": "Riyadh",
"district": "Al Malqa",
"address": "Anas Ibn Malik Rd",
"latitude": 24.8093,
"longitude": 46.6156
},
"media": [
{
"id": "6f1c1f0e-3d7b-4b8e-9d6a-2c1b0a9f8e7d",
"kind": "IMAGE",
"category": "COVER_IMAGE",
"title": null,
"altText": "Living room",
"mimeType": "image/webp",
"width": 2400,
"height": 1600,
"url": "https://inventory.example.com/api/v1/media/6f1c1f0e-3d7b-4b8e-9d6a-2c1b0a9f8e7d?size=md",
"renditions": {
"sm": {
"url": "https://inventory.example.com/api/v1/media/6f1c1f0e-3d7b-4b8e-9d6a-2c1b0a9f8e7d?size=sm",
"width": 480,
"height": 320
}
}
}
],
"publishedAt": "2026-10-01T09:30:00.000Z",
"updatedAt": "2026-10-20T14:05:11.000Z"
}
}/api/v1/units/changesIncremental unit changes
Ordered change feed. Each entry is either `unit.upserted` (store the embedded unit) or `unit.removed` (delete your copy). A unit appears at most once per page with its latest state. `nextCursor` is always returned — persist it even when `data` is empty.
| Parameter | Type | Description |
|---|---|---|
| lang(query) | string | Content language (`en` default, `ar`). Missing Arabic fields fall back to English. `Accept-Language` is honoured when omitted. |
| cursor(query) | string | Omit (or use `0`) to start a full sync from the beginning. |
| limit(query) | integer | Page size (1–500, default 100) |
- 200 A page of change events
- 400 Invalid request parameters
- 401 Missing, invalid, expired or revoked API key
- 429 Rate limit exceeded. See `Retry-After` and `X-RateLimit-*` headers.
{
"data": [
{
"type": "unit.upserted",
"id": "0199f3a2-7c1e-7b55-9a1f-4f2d8c6a1e01",
"sequence": "1042",
"occurredAt": "2026-10-20T14:05:11.000Z",
"unit": {
"id": "0199f3a2-7c1e-7b55-9a1f-4f2d8c6a1e01",
"object": "unit",
"developerReference": "GVR-A-0302",
"unitNumber": "A302",
"propertyType": "APARTMENT",
"title": "3-Bedroom Apartment in Green Valley Residences",
"description": "Corner apartment with a private balcony overlooking the central gardens.",
"availability": "AVAILABLE",
"availableFrom": "2026-12-01",
"price": {
"amount": "1185000.00",
"currency": "SAR"
},
"appraisal": {
"value": {
"amount": "1150000.00",
"currency": "SAR"
},
"validUntil": "2027-03-31"
},
"specifications": {
"bedrooms": 3,
"bathrooms": 3,
"livingRooms": 1,
"kitchens": 1,
"parkingSpaces": 1,
"floorNumber": 3,
"levels": 1,
"hasMaidRoom": true,
"hasDriverRoom": false,
"internalArea": 168.5,
"externalArea": 12,
"totalArea": 180.5,
"landArea": null,
"areaUnit": "sqm",
"furnishing": "UNFURNISHED",
"finishing": "FULLY_FINISHED",
"orientation": "SOUTH_EAST",
"view": "Garden view"
},
"features": [
"Central AC",
"Private balcony"
],
"amenities": [
"Swimming pool",
"Gym"
],
"developer": {
"id": "0199f3a2-0000-7000-8000-000000000001",
"name": "Nakhla Urban Developments",
"slug": "nakhla",
"logoUrl": null
},
"project": {
"id": "0199f3a2-0000-7000-8000-000000000010",
"name": "Green Valley Residences",
"city": "Riyadh",
"district": "Al Malqa"
},
"building": {
"id": "0199f3a2-0000-7000-8000-000000000020",
"name": "Building A"
},
"location": {
"country": "SA",
"region": "Riyadh Region",
"city": "Riyadh",
"district": "Al Malqa",
"address": "Anas Ibn Malik Rd",
"latitude": 24.8093,
"longitude": 46.6156
},
"media": [
{
"id": "6f1c1f0e-3d7b-4b8e-9d6a-2c1b0a9f8e7d",
"kind": "IMAGE",
"category": "COVER_IMAGE",
"title": null,
"altText": "Living room",
"mimeType": "image/webp",
"width": 2400,
"height": 1600,
"url": "https://inventory.example.com/api/v1/media/6f1c1f0e-3d7b-4b8e-9d6a-2c1b0a9f8e7d?size=md",
"renditions": {
"sm": {
"url": "https://inventory.example.com/api/v1/media/6f1c1f0e-3d7b-4b8e-9d6a-2c1b0a9f8e7d?size=sm",
"width": 480,
"height": 320
}
}
}
],
"publishedAt": "2026-10-01T09:30:00.000Z",
"updatedAt": "2026-10-20T14:05:11.000Z"
}
},
{
"type": "unit.removed",
"id": "0199f3a2-7c1e-7b55-9a1f-4f2d8c6a1e02",
"sequence": "1043",
"occurredAt": "2026-10-20T14:07:40.000Z",
"reason": "unpublished"
}
],
"pagination": {
"limit": 100,
"hasMore": false,
"nextCursor": "c1_1043"
}
}/api/v1/projectsList published projects
| Parameter | Type | Description |
|---|---|---|
| lang(query) | string | Content language (`en` default, `ar`). Missing Arabic fields fall back to English. `Accept-Language` is honoured when omitted. |
| developerId(query) | string (uuid) | Developer (organization) ID |
| city(query) | string | |
| limit(query) | integer | |
| cursor(query) | string |
- 200 A page of projects
- 400 Invalid request parameters
- 401 Missing, invalid, expired or revoked API key
- 429 Rate limit exceeded. See `Retry-After` and `X-RateLimit-*` headers.
/api/v1/projects/{id}Get a project
| Parameter | Type | Description |
|---|---|---|
| id*(path) | string (uuid) | Project ID |
| lang(query) | string | Content language (`en` default, `ar`). `Accept-Language` is honoured when omitted. |
- 200 The project
- 400 Invalid request parameters
- 401 Missing, invalid, expired or revoked API key
- 404 Not found or not accessible with this key
- 429 Rate limit exceeded. See `Retry-After` and `X-RateLimit-*` headers.
/api/v1/projects/{id}/buildingsList a project's buildings
Active buildings of a published project with published-unit counts. Projects without buildings return an empty list.
| Parameter | Type | Description |
|---|---|---|
| id*(path) | string (uuid) | Project ID |
| lang(query) | string | Content language (`en` default, `ar`). `Accept-Language` is honoured when omitted. |
- 200 Buildings
- 400 Invalid request parameters
- 401 Missing, invalid, expired or revoked API key
- 404 Not found or not accessible with this key
- 429 Rate limit exceeded. See `Retry-After` and `X-RateLimit-*` headers.
/api/v1/metadataFilter metadata
Property types, currencies, cities/districts, developers and price/bedroom ranges across your visible inventory. Labels follow `lang`.
| Parameter | Type | Description |
|---|---|---|
| lang(query) | string | Content language (`en` default, `ar`). `Accept-Language` is honoured when omitted. |
- 200 Metadata
- 400 Invalid request parameters
- 401 Missing, invalid, expired or revoked API key
- 429 Rate limit exceeded. See `Retry-After` and `X-RateLimit-*` headers.
/api/v1/media/{id}no API keyDownload published media
Binary download of a published photo, floor plan or shared document. URLs are returned by other endpoints; no API key is required so they can be used directly in `<img>` tags. Media is only served while its owner is published.
| Parameter | Type | Description |
|---|---|---|
| id*(path) | string (uuid) | Media ID |
| size(query) | sm | md | lg | xl |
- 200 The file
- 404 Not found or not published