JavaScript
This guide wires the Catalogue API into a static website with modern JavaScript and the native fetch API. Every snippet is an ES module; load it with <script type="module">. The same client is reused by the React and Vue guides.
Prerequisites
Section titled “Prerequisites”- An agency API key — see Getting started.
- An agency with an approved licence and at least one approved developer contract; otherwise the API answers
403or returns an empty catalogue. See Authentication. - A page served over HTTP(S) from a domain you control. Browsers send an
Originheader that the key’s domain restriction checks.
Set up your key
Section titled “Set up your key”-
On estate.azizdubai.com, open API keys and issue a key named after the site, for example
Website (browser). -
Restrict the key to the domains that serve the site. List every host separately —
example.comdoes not coverwww.example.com. -
Verify the key from the terminal:
Terminal window curl https://agent-dev.azizdubai.com/whoIAm -H "X-API-Key: azk_<your key>"A restricted key needs an
Originheader here too: add-H "Origin: https://example.com".
API client
Section titled “API client”azizFetch(path, params, options) adds the key, serializes filter parameters (arrays become CSV, empty values are dropped), and unwraps the response envelope: it returns data on success and throws an AzizApiError carrying the HTTP status and the API message otherwise.
const AZIZ_API_BASE = 'https://agent-dev.azizdubai.com';const AZIZ_API_KEY = 'azk_…'; // browser key, restricted to your domains
export class AzizApiError extends Error { constructor(status, message) { super(message); this.name = 'AzizApiError'; this.status = status; }}
function toQuery(params) { const query = new URLSearchParams(); for (const [name, value] of Object.entries(params)) { if (value === undefined || value === null || value === '') continue; if (Array.isArray(value)) { if (value.length > 0) query.set(name, value.join(',')); } else { query.set(name, String(value)); } } return query.toString();}
export async function azizFetch(path, params = {}, { method = 'GET', body, signal } = {}) { const url = new URL(path, AZIZ_API_BASE); url.search = toQuery(params);
const headers = { 'X-API-Key': AZIZ_API_KEY, Accept: 'application/json' }; if (body !== undefined) headers['Content-Type'] = 'application/json';
const response = await fetch(url, { method, headers, signal, body: body === undefined ? undefined : JSON.stringify(body), });
let payload; try { payload = await response.json(); } catch { throw new AzizApiError(response.status, `unexpected ${response.status} response`); } if (payload.status !== 'success') { throw new AzizApiError(response.status, payload.message ?? 'unknown error'); } return payload.data;}
export function formatPrice(amount, currency = 'AED') { return amount == null ? 'Price on request' : `${currency} ${Math.round(amount).toLocaleString('en')}`;}
export function escapeHtml(value) { return String(value ?? '').replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);}Prefer Authorization: Bearer <key> over X-API-Key? Replace the header — both are accepted.
Recipes
Section titled “Recipes”Paginated project list with filters
Section titled “Paginated project list with filters”GET /projects returns { total, items }. Pass limit (up to 100) and offset, and use total to decide whether there is a next page. price_min and price_max are in the key’s prices; bedrooms is a CSV where 0 is a studio and 5 means 5+. See Filtering projects and Pagination.
<form id="filters"> <input name="price_min" type="number" min="0" step="1000" placeholder="Min price" /> <input name="price_max" type="number" min="0" step="1000" placeholder="Max price" /> <fieldset> <legend>Bedrooms</legend> <label><input type="checkbox" name="bedrooms" value="0" /> Studio</label> <label><input type="checkbox" name="bedrooms" value="1" /> 1</label> <label><input type="checkbox" name="bedrooms" value="2" /> 2</label> <label><input type="checkbox" name="bedrooms" value="3" /> 3</label> <label><input type="checkbox" name="bedrooms" value="4" /> 4</label> <label><input type="checkbox" name="bedrooms" value="5" /> 5+</label> </fieldset> <button type="submit">Search</button></form>
<p id="summary"></p><div id="projects"></div><button id="prev" type="button">Previous</button><button id="next" type="button">Next</button>
<script type="module" src="./projects.js"></script>import { azizFetch, escapeHtml, formatPrice } from './aziz.js';
const PAGE_SIZE = 12;const form = document.querySelector('#filters');const summary = document.querySelector('#summary');const list = document.querySelector('#projects');const prev = document.querySelector('#prev');const next = document.querySelector('#next');let offset = 0;
function card(project) { return ` <a class="project-card" href="project.html?id=${project.id}"> ${project.logo ? `<img src="${escapeHtml(project.logo)}" alt="" loading="lazy" />` : ''} <h3>${escapeHtml(project.name)}</h3> <p>${escapeHtml(project.company?.name)}</p> <p>From ${escapeHtml(formatPrice(project.price_from, project.currency))}</p> </a>`;}
async function load() { const filters = new FormData(form); summary.textContent = 'Loading…'; try { const { total, items } = await azizFetch('/projects', { price_min: filters.get('price_min'), price_max: filters.get('price_max'), bedrooms: filters.getAll('bedrooms'), limit: PAGE_SIZE, offset, }); list.innerHTML = items.map(card).join(''); summary.textContent = total ? `${offset + 1}–${offset + items.length} of ${total} projects` : 'No projects match these filters.'; prev.disabled = offset === 0; next.disabled = offset + items.length >= total; } catch (error) { list.innerHTML = ''; summary.textContent = `Could not load projects: ${error.message}`; }}
form.addEventListener('submit', (event) => { event.preventDefault(); offset = 0; load();});prev.addEventListener('click', () => { offset = Math.max(0, offset - PAGE_SIZE); load();});next.addEventListener('click', () => { offset += PAGE_SIZE; load();});
load();Project details page
Section titled “Project details page”GET /projects/{id} returns the whole project — location, cost, payment plan, media and units — in one response. A draft, deleted or unknown project answers 404.
<article id="project">Loading…</article><script type="module" src="./project.js"></script>import { AzizApiError, azizFetch, escapeHtml, formatPrice } from './aziz.js';
const root = document.querySelector('#project');const id = Number(new URLSearchParams(location.search).get('id'));
function render(project) { const currency = project.cost?.currency ?? 'AED'; const images = project.media?.images ?? []; const units = project.units ?? []; document.title = project.name; root.innerHTML = ` <h1>${escapeHtml(project.name)}</h1> <p>${escapeHtml([project.area_name, project.area_city].filter(Boolean).join(', '))}</p> <div class="gallery"> ${images .filter((image) => image.file) .map((image) => `<img src="${escapeHtml(image.file.url)}" alt="" loading="lazy" />`) .join('')} </div> <p>${escapeHtml(project.description)}</p> <table> <thead><tr><th>Unit</th><th>Bedrooms</th><th>Area, m²</th><th>Price</th><th></th></tr></thead> <tbody> ${units .map( (unit) => ` <tr> <td>${escapeHtml(unit.unit_number)}</td> <td>${unit.bedrooms === 0 ? 'Studio' : unit.bedrooms}</td> <td>${unit.unit_area}</td> <td>${escapeHtml(formatPrice(unit.price, currency))}</td> <td><a href="apply.html?unit=${unit.id}">Enquire</a></td> </tr>`, ) .join('')} </tbody> </table>`;}
if (!Number.isInteger(id) || id < 1) { root.textContent = 'Project not found.';} else { try { render(await azizFetch(`/projects/${id}`)); } catch (error) { root.textContent = error instanceof AzizApiError && error.status === 404 ? 'This project is no longer available.' : `Could not load the project: ${error.message}`; }}Unit application form
Section titled “Unit application form”POST /units/{id}/applications records the enquiry in your agency’s CRM. The body requires client.first_name and at least one of client.email or client.mobile_number; preferences.based_on_unit: true derives the client’s interests from the selected unit. The form below sends the fields a typical enquiry needs — see Submitting applications for the full body.
<form id="application"> <input name="first_name" required maxlength="255" placeholder="First name" /> <input name="last_name" maxlength="255" placeholder="Last name" /> <input name="email" type="email" maxlength="255" placeholder="Email" /> <input name="mobile_number" type="tel" maxlength="50" placeholder="+971 50 000 00 00" /> <select name="currency"> <option value="">Project currency</option> <option value="AED">AED</option> <option value="USD">USD</option> </select> <textarea name="message" maxlength="5000" placeholder="Message"></textarea> <button type="submit">Send enquiry</button> <p id="result" role="status"></p></form>
<script type="module" src="./apply.js"></script>import { AzizApiError, azizFetch } from './aziz.js';
const form = document.querySelector('#application');const result = document.querySelector('#result');const button = form.querySelector('button');const unitId = Number(new URLSearchParams(location.search).get('unit'));
const orNull = (value) => (value === null || value.trim() === '' ? null : value.trim());
form.addEventListener('submit', async (event) => { event.preventDefault(); const fields = new FormData(form); const client = { first_name: orNull(fields.get('first_name')), last_name: orNull(fields.get('last_name')), email: orNull(fields.get('email')), mobile_number: orNull(fields.get('mobile_number')), }; if (!client.email && !client.mobile_number) { result.textContent = 'Please enter an email address or a mobile number.'; return; }
button.disabled = true; // the operation is not idempotent: avoid double submits result.textContent = 'Sending…'; try { await azizFetch(`/units/${unitId}/applications`, {}, { method: 'POST', body: { client, preferences: { based_on_unit: true }, message: orNull(fields.get('message')), currency: orNull(fields.get('currency')), language: document.documentElement.lang || 'en', }, }); // Keep the button disabled: replace the form so the enquiry cannot be sent twice. result.textContent = 'Thank you! An agent will contact you shortly.'; form.replaceWith(result); } catch (error) { button.disabled = false; // let the visitor fix the form and try again if (error instanceof AzizApiError && error.status === 400) { result.textContent = `Please check the form: ${error.message}`; } else if (error instanceof AzizApiError && error.status === 404) { result.textContent = 'This unit is no longer available.'; } else { result.textContent = 'Something went wrong. Please try again later.'; console.error(error); } }});Production checklist
Section titled “Production checklist”- Handle
401and403as configuration errors.missing api key,invalid api keyandorigin is not allowed for this api keywill not fix themselves on retry — log them and show a generic message. See Troubleshooting. - Never commit the key. Inject it at build time from your hosting provider’s environment settings instead of hard-coding it in
aziz.js. - Cache
GETresponses. Catalogue data changes rarely; keep list and project responses in memory orsessionStoragefor a few minutes instead of re-requesting on every navigation. Never cachePOSTrequests. - Use separate keys for browser and server. The browser key carries a domain list; a key used from a server, cron job or CRM must have no domain list, because server requests carry no
OriginorRefererand are rejected by a restricted key. - Use a separate key for local development. A key restricted to your production domains rejects requests from
localhost.