WordPress
This guide builds a single-file plugin, aziz-catalogue.php. WordPress calls the API from the server, caches responses in transients, renders projects with shortcodes, and forwards enquiries through its own REST route — the key never reaches the browser.
Prerequisites
Section titled “Prerequisites”- WordPress 6.0 or later on PHP 7.4 or later, with outgoing HTTPS allowed.
- An agency API key — see Getting started.
- An agency with an approved licence and at least one approved developer contract. See Authentication.
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
WordPress (server). Do not add a domain list. -
Add the key to
wp-config.php, above the/* That's all, stop editing! */line:wp-config.php define( 'AZIZ_API_KEY', 'azk_…' ); -
Verify the key from the server:
Terminal window curl https://agent-dev.azizdubai.com/whoIAm -H "X-API-Key: azk_<your key>"
API client
Section titled “API client”Save the plugin as wp-content/plugins/aziz-catalogue/aziz-catalogue.php and activate it under Plugins. aziz_api_get() adds the key, serializes parameters (arrays become CSV, empty values are dropped), unwraps the response envelope and caches successful responses for 10 minutes. Errors are returned as WP_Error with the API message and the HTTP status, and are not cached.
The key is read from the AZIZ_API_KEY constant. To manage it from the admin instead, store it with update_option( 'aziz_api_key', 'azk_…', false ) and remove the constant — aziz_api_key() falls back to the option.
<?php/** * Plugin Name: Aziz Catalogue * Description: Projects from the Aziz Catalogue API and a server-side enquiry endpoint. * Version: 1.0.0 */
defined( 'ABSPATH' ) || exit;
if ( ! defined( 'AZIZ_API_BASE' ) ) { define( 'AZIZ_API_BASE', 'https://agent-dev.azizdubai.com' );}
function aziz_api_key() { return defined( 'AZIZ_API_KEY' ) ? AZIZ_API_KEY : (string) get_option( 'aziz_api_key', '' );}
/** * GET an API path. Returns `data` from the envelope or a WP_Error. */function aziz_api_get( $path, $params = array() ) { $params = array_filter( $params, static function ( $value ) { return null !== $value && '' !== $value && array() !== $value; } ); $params = array_map( static function ( $value ) { return is_array( $value ) ? implode( ',', $value ) : $value; }, $params );
$url = AZIZ_API_BASE . $path . ( $params ? '?' . http_build_query( $params ) : '' ); $cache_key = 'aziz_' . md5( $url ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; }
$response = wp_remote_get( $url, array( 'timeout' => 10, 'headers' => array( 'X-API-Key' => aziz_api_key(), 'Accept' => 'application/json', ), ) ); if ( is_wp_error( $response ) ) { return $response; }
$code = wp_remote_retrieve_response_code( $response ); $payload = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $payload ) || 'success' !== ( $payload['status'] ?? '' ) ) { $message = $payload['message'] ?? "unexpected $code response"; return new WP_Error( 'aziz_api_error', $message, array( 'status' => $code ) ); }
set_transient( $cache_key, $payload['data'], 10 * MINUTE_IN_SECONDS ); return $payload['data'];}
function aziz_format_price( $amount, $currency = 'AED' ) { return null === $amount ? 'Price on request' : $currency . ' ' . number_format_i18n( $amount );}
function aziz_error_html( WP_Error $error ) { // The API message is for logs; visitors get a generic text. error_log( 'Aziz API: ' . $error->get_error_message() ); $status = $error->get_error_data()['status'] ?? 0; $text = 404 === $status ? 'This project is no longer available.' : 'The catalogue is temporarily unavailable.'; return '<p class="aziz-error">' . esc_html( $text ) . '</p>';}
/** * Number from the query string, or '' when absent. */function aziz_query_number( $name ) { $value = isset( $_GET[ $name ] ) ? sanitize_text_field( wp_unslash( $_GET[ $name ] ) ) : ''; return is_numeric( $value ) && $value >= 0 ? $value : '';}
/** * [aziz_projects limit="12" project_page="/project/"] */add_shortcode( 'aziz_projects', 'aziz_projects_shortcode' );
function aziz_projects_shortcode( $atts ) { $atts = shortcode_atts( array( 'limit' => 12, 'project_page' => '/project/', ), $atts, 'aziz_projects' ); $limit = min( 100, max( 1, (int) $atts['limit'] ) ); $page = max( 1, isset( $_GET['aziz_page'] ) ? absint( $_GET['aziz_page'] ) : 1 );
$bedrooms = isset( $_GET['bedrooms'] ) ? array_map( 'absint', (array) wp_unslash( $_GET['bedrooms'] ) ) : array(); $filters = array( 'price_min' => aziz_query_number( 'price_min' ), 'price_max' => aziz_query_number( 'price_max' ), 'bedrooms' => array_values( array_unique( array_filter( $bedrooms, static fn( $n ) => $n <= 5 ) ) ), );
$data = aziz_api_get( '/projects', $filters + array( 'limit' => $limit, 'offset' => ( $page - 1 ) * $limit ) ); if ( is_wp_error( $data ) ) { return aziz_error_html( $data ); }
ob_start(); ?> <form class="aziz-filters" method="get"> <input name="price_min" type="number" min="0" step="1000" placeholder="Min price" value="<?php echo esc_attr( $filters['price_min'] ); ?>"> <input name="price_max" type="number" min="0" step="1000" placeholder="Max price" value="<?php echo esc_attr( $filters['price_max'] ); ?>"> <?php foreach ( array( 0 => 'Studio', 1 => '1', 2 => '2', 3 => '3', 4 => '4', 5 => '5+' ) as $value => $label ) : ?> <label> <input type="checkbox" name="bedrooms[]" value="<?php echo esc_attr( $value ); ?>" <?php checked( in_array( $value, $filters['bedrooms'], true ) ); ?>> <?php echo esc_html( $label ); ?> </label> <?php endforeach; ?> <button type="submit">Search</button> </form>
<?php if ( 0 === (int) $data['total'] ) : ?> <p>No projects match these filters.</p> <?php endif; ?>
<div class="aziz-grid"> <?php foreach ( $data['items'] as $project ) : ?> <a class="aziz-card" href="<?php echo esc_url( add_query_arg( 'project_id', $project['id'], home_url( $atts['project_page'] ) ) ); ?>"> <?php if ( ! empty( $project['logo'] ) ) : ?> <img src="<?php echo esc_url( $project['logo'] ); ?>" alt="" loading="lazy"> <?php endif; ?> <h3><?php echo esc_html( $project['name'] ); ?></h3> <p><?php echo esc_html( $project['company']['name'] ?? '' ); ?></p> <p>From <?php echo esc_html( aziz_format_price( $project['price_from'], $project['currency'] ) ); ?></p> </a> <?php endforeach; ?> </div>
<nav class="aziz-pagination"> <?php if ( $page > 1 ) : ?> <a href="<?php echo esc_url( add_query_arg( 'aziz_page', $page - 1 ) ); ?>">Previous</a> <?php endif; ?> <?php if ( $page * $limit < $data['total'] ) : ?> <a href="<?php echo esc_url( add_query_arg( 'aziz_page', $page + 1 ) ); ?>">Next</a> <?php endif; ?> </nav> <?php return ob_get_clean();}
/** * [aziz_project id="42" apply_page="/apply/"] — without id, reads ?project_id=. */add_shortcode( 'aziz_project', 'aziz_project_shortcode' );
function aziz_project_shortcode( $atts ) { $atts = shortcode_atts( array( 'id' => isset( $_GET['project_id'] ) ? absint( $_GET['project_id'] ) : 0, 'apply_page' => '/apply/', ), $atts, 'aziz_project' ); $id = absint( $atts['id'] ); if ( ! $id ) { return '<p class="aziz-error">Project not found.</p>'; }
$project = aziz_api_get( "/projects/$id" ); if ( is_wp_error( $project ) ) { return aziz_error_html( $project ); }
$currency = $project['cost']['currency'] ?? 'AED'; $images = array_filter( $project['media']['images'] ?? array(), static fn( $image ) => ! empty( $image['file'] ) );
ob_start(); ?> <article class="aziz-project"> <h2><?php echo esc_html( $project['name'] ); ?></h2> <p><?php echo esc_html( implode( ', ', array_filter( array( $project['area_name'], $project['area_city'] ) ) ) ); ?></p> <div class="aziz-gallery"> <?php foreach ( $images as $image ) : ?> <img src="<?php echo esc_url( $image['file']['url'] ); ?>" alt="" loading="lazy"> <?php endforeach; ?> </div> <?php echo wpautop( esc_html( $project['description'] ) ); ?> <table> <thead><tr><th>Unit</th><th>Bedrooms</th><th>Area, m²</th><th>Price</th><th></th></tr></thead> <tbody> <?php foreach ( $project['units'] as $unit ) : ?> <tr> <td><?php echo esc_html( $unit['unit_number'] ); ?></td> <td><?php echo 0 === $unit['bedrooms'] ? 'Studio' : esc_html( $unit['bedrooms'] ); ?></td> <td><?php echo esc_html( $unit['unit_area'] ); ?></td> <td><?php echo esc_html( aziz_format_price( $unit['price'], $currency ) ); ?></td> <td><a href="<?php echo esc_url( add_query_arg( 'unit_id', $unit['id'], home_url( $atts['apply_page'] ) ) ); ?>">Enquire</a></td> </tr> <?php endforeach; ?> </tbody> </table> </article> <?php return ob_get_clean();}
/** * POST /wp-json/aziz/v1/apply — forwards an enquiry to POST /units/{id}/applications. */add_action( 'rest_api_init', static function () { register_rest_route( 'aziz/v1', '/apply', array( 'methods' => 'POST', 'permission_callback' => '__return_true', 'callback' => 'aziz_rest_apply', 'args' => array( 'unit_id' => array( 'required' => true, 'type' => 'integer', 'minimum' => 1 ), 'first_name' => array( 'required' => true, 'type' => 'string', 'maxLength' => 255 ), 'last_name' => array( 'type' => 'string', 'maxLength' => 255 ), 'email' => array( 'type' => 'string', 'maxLength' => 255 ), 'mobile_number' => array( 'type' => 'string', 'maxLength' => 50 ), 'currency' => array( 'type' => 'string', 'enum' => array( '', 'AED', 'USD' ) ), 'message' => array( 'type' => 'string', 'maxLength' => 5000 ), ), ) ); });
function aziz_rest_apply( WP_REST_Request $request ) { $body = array_filter( array( 'client' => array_filter( array( 'first_name' => sanitize_text_field( $request['first_name'] ), 'last_name' => sanitize_text_field( $request['last_name'] ?? '' ), 'email' => sanitize_email( $request['email'] ?? '' ), 'mobile_number' => sanitize_text_field( $request['mobile_number'] ?? '' ), ) ), 'preferences' => array( 'based_on_unit' => true ), 'message' => sanitize_textarea_field( $request['message'] ?? '' ), 'currency' => $request['currency'] ?? '', ) );
$response = wp_remote_post( AZIZ_API_BASE . '/units/' . $request['unit_id'] . '/applications', array( 'timeout' => 15, 'headers' => array( 'X-API-Key' => aziz_api_key(), 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( $body ), ) ); if ( is_wp_error( $response ) ) { error_log( 'Aziz API: ' . $response->get_error_message() ); return new WP_REST_Response( array( 'status' => 'error', 'message' => 'service unavailable' ), 502 ); }
$code = wp_remote_retrieve_response_code( $response ); $payload = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 201 === $code ) { // The result holds CRM identifiers; the visitor only needs to know it worked. return new WP_REST_Response( array( 'status' => 'success' ), 201 ); } if ( 400 === $code || 404 === $code ) { // Validation and "unit not found" messages are safe to show. return new WP_REST_Response( array( 'status' => 'error', 'message' => $payload['message'] ?? 'invalid request' ), $code ); }
// 401, 403 and 500 are configuration problems: log them, do not expose them. error_log( "Aziz API: $code " . ( $payload['message'] ?? '' ) ); return new WP_REST_Response( array( 'status' => 'error', 'message' => 'service unavailable' ), 502 );}Recipes
Section titled “Recipes”Paginated project list with filters
Section titled “Paginated project list with filters”Add the shortcode to any page:
[aziz_projects limit="12" project_page="/project/"]It renders a filter form with price_min, price_max and bedrooms, a grid of projects, and Previous / Next links. Filters travel in the page’s query string, and the page number in aziz_page, so every filtered page has its own URL and its own cache entry. Prices are in the key’s prices; bedrooms value 5 means 5+. See Filtering projects and Pagination.
Project details page
Section titled “Project details page”Create a page at /project/ with:
[aziz_project apply_page="/apply/"]Cards from [aziz_projects] link to it with ?project_id=…. To pin a page to one project, pass it explicitly: [aziz_project id="42"]. A draft, deleted or unknown project answers 404, which the shortcode renders as “no longer available”.
Unit application form
Section titled “Unit application form”Each unit row links to /apply/?unit_id=…. On that page, add a Custom HTML block with the form below. It posts to the plugin’s REST route, which forwards the enquiry to POST /units/{id}/applications with the key added on the server. client.first_name is required, plus at least one of client.email or client.mobile_number; 400 validation messages are passed through for display. See Submitting applications for the full body.
<form id="aziz-apply"> <input type="hidden" name="unit_id" /> <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="aziz-apply-result" role="status"></p></form><script type="module"> const form = document.querySelector('#aziz-apply'); const result = document.querySelector('#aziz-apply-result'); const button = form.querySelector('button'); form.elements.unit_id.value = new URLSearchParams(location.search).get('unit_id') ?? '';
form.addEventListener('submit', async (event) => { event.preventDefault(); const fields = Object.fromEntries(new FormData(form)); if (!fields.email.trim() && !fields.mobile_number.trim()) { result.textContent = 'Please enter an email address or a mobile number.'; return; }
button.disabled = true; // applications are not idempotent result.textContent = 'Sending…'; try { const response = await fetch('/wp-json/aziz/v1/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(fields), }); const payload = await response.json(); if (response.ok) { // 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); return; } button.disabled = false; // let the visitor fix the form and try again if (response.status === 400) { result.textContent = `Please check the form: ${payload.message}`; } else if (response.status === 404) { result.textContent = 'This unit is no longer available.'; } else { result.textContent = 'Something went wrong. Please try again later.'; } } catch { button.disabled = false; result.textContent = 'Something went wrong. Please try again later.'; } });</script>If WordPress is installed in a subdirectory or uses plain permalinks, replace /wp-json/aziz/v1/apply with the URL of rest_url( 'aziz/v1/apply' ).
Production checklist
Section titled “Production checklist”- Handle
401and403as configuration errors. The plugin logs them witherror_log()and shows visitors a generic message. Check the log after deploying; see Troubleshooting for each message. - Never commit the key. Keep
wp-config.phpout of version control, or read the key from an environment variable there:define( 'AZIZ_API_KEY', getenv( 'AZIZ_API_KEY' ) );. - Cache
GETresponses. The plugin caches each URL for 10 minutes in transients; with a persistent object cache (Redis, Memcached) they stay out of the database. Enquiries are never cached. - Use separate keys for browser and server. This plugin needs only a server key without a domain list. If the same site also calls the API from JavaScript, issue a second, domain-restricted key for that.
- Protect the enquiry endpoint.
aziz/v1/applyis public and each call creates a CRM record. Add a CAPTCHA or a honeypot field and rate-limit the route, for example with your security plugin.