Skip to content

JavaScript Examples

This guide provides vanilla JavaScript/TypeScript examples for integrating with the EngageHQ API.

API Client Setup

Basic Client

javascript
const API_BASE = 'https://cdn.engagehq.retailsuccessplatform.com/api/v1/public';

class EngageHQClient {
  /**
   * @param {string} token - JWT token from Identity service
   * @param {string} organizationId - Organization or location hashkey (org_xxx).
   *   Use a store location hashkey for location-specific content with distribution.
   */
  constructor(token, organizationId) {
    this.token = token;
    this.organizationId = organizationId;
  }

  async request(endpoint, options = {}) {
    const url = `${API_BASE}${endpoint}`;
    const response = await fetch(url, {
      ...options,
      headers: {
        'Authorization': `Bearer ${this.token}`,
        'X-Organization-Context': this.organizationId,
        'Content-Type': 'application/json',
        ...options.headers,
      },
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'API request failed');
    }

    return response.json();
  }

  // Content methods
  async getContent(params = {}) {
    const query = new URLSearchParams(params).toString();
    return this.request(`/content${query ? `?${query}` : ''}`);
  }

  async getContentBySlug(slug) {
    return this.request(`/content/${slug}`);
  }

  // Circular methods
  async getCirculars(params = {}) {
    const query = new URLSearchParams(params).toString();
    return this.request(`/circulars${query ? `?${query}` : ''}`);
  }

  async getCircular(id) {
    return this.request(`/circulars/${id}`);
  }

  // Offer methods
  async getOffers(params = {}) {
    const query = new URLSearchParams(params).toString();
    return this.request(`/offers${query ? `?${query}` : ''}`);
  }

  async getOffer(id) {
    return this.request(`/offers/${id}`);
  }
}

// Usage — pass your store location's org hashkey for location-scoped content
const client = new EngageHQClient('your-jwt-token', 'org_00000a1B2c3D4e5');

TypeScript Client with Types

typescript
// types.ts
interface ContentItem {
  id: string;
  type: string;
  slug: string;
  title: string;
  description: string | null;
  meta_title: string | null;
  meta_description: string | null;
  tags: string[];
  category: string | null;
  featured_image: {
    url: string;
    alt: string;
  } | null;
  published_at: string;
  is_featured: boolean;
}

interface ContentDetail extends ContentItem {
  content: ContentBlock[];
  metadata: Record<string, unknown>;
  canonical_url: string | null;
  og_data: OGData | null;
  media_ids: string[];
}

interface ContentBlock {
  type: 'heading' | 'paragraph' | 'image' | 'list' | 'quote' | 'embed';
  [key: string]: unknown;
}

interface OGData {
  title: string;
  description: string;
  image: string;
  type?: string;
}

interface Circular {
  id: string;
  name: string;
  description: string | null;
  status: 'ready' | 'in_use';
  total_pages: number;
  format: 'tabloid' | 'letter' | 'custom';
  dimensions: { width: number; height: number };
  thumbnail_url: string | null;
  last_used_at: string | null;
  created_at: string;
}

interface Offer {
  id: string;
  name: string;
  description: string | null;
  type: 'percentage' | 'fixed' | 'bogo';
  discount: DiscountInfo;
  valid_from: string | null;
  valid_until: string | null;
}

interface DiscountInfo {
  type: 'percentage' | 'fixed' | 'bogo';
  value?: number;
  buy?: number;
  get?: number;
  display: string;
}

interface PaginatedResponse<T> {
  success: boolean;
  message: string;
  data: T[];
  meta: {
    current_page: number;
    last_page: number;
    per_page: number;
    total: number;
  };
}

interface SingleResponse<T> {
  success: boolean;
  message: string;
  data: T;
}

Fetching Content

List Content with Filters

javascript
async function getArticles(client, page = 1) {
  try {
    const response = await client.getContent({
      type: 'article',
      per_page: 10,
      page: page,
    });

    console.log(`Found ${response.meta.total} articles`);
    console.log(`Page ${response.meta.current_page} of ${response.meta.last_page}`);

    return response.data;
  } catch (error) {
    console.error('Failed to fetch articles:', error.message);
    throw error;
  }
}
javascript
async function getFeaturedContent(client, type = null) {
  const params = { featured: true, per_page: 5 };
  if (type) params.type = type;

  const { data } = await client.getContent(params);
  return data;
}

// Usage
const featuredArticles = await getFeaturedContent(client, 'article');
const allFeatured = await getFeaturedContent(client);

Filter by Tags

javascript
async function getContentByTags(client, tags) {
  // Tags use OR logic - matches any tag
  const params = new URLSearchParams();
  tags.forEach(tag => params.append('tags[]', tag));

  const { data } = await client.request(`/content?${params}`);
  return data;
}

// Usage
const summerContent = await getContentByTags(client, ['summer', 'sale']);

Get Single Content Item

javascript
async function renderArticle(client, slug) {
  try {
    const { data: article } = await client.getContentBySlug(slug);

    // Render the article
    document.title = article.meta_title || article.title;

    const container = document.getElementById('article');
    container.innerHTML = `
      <article>
        <h1>${article.title}</h1>
        ${article.featured_image ? `
          <img src="${article.featured_image.url}"
               alt="${article.featured_image.alt}" />
        ` : ''}
        <div class="content">
          ${renderContentBlocks(article.content)}
        </div>
      </article>
    `;
  } catch (error) {
    if (error.message === 'Content not found') {
      // Handle 404
      document.getElementById('article').innerHTML = '<p>Article not found</p>';
    } else {
      throw error;
    }
  }
}

Rendering Content Blocks

javascript
function renderContentBlocks(blocks) {
  if (!blocks || !Array.isArray(blocks)) return '';

  return blocks.map(block => {
    switch (block.type) {
      case 'heading':
        return `<h${block.level}>${escapeHtml(block.text)}</h${block.level}>`;

      case 'paragraph':
        return `<p>${block.text}</p>`; // HTML allowed in paragraphs

      case 'image':
        return `
          <figure>
            <img src="${block.url}" alt="${escapeHtml(block.alt || '')}"
                 ${block.width ? `width="${block.width}"` : ''}
                 ${block.height ? `height="${block.height}"` : ''} />
            ${block.caption ? `<figcaption>${escapeHtml(block.caption)}</figcaption>` : ''}
          </figure>
        `;

      case 'list':
        const tag = block.ordered ? 'ol' : 'ul';
        const items = block.items.map(item => `<li>${escapeHtml(item)}</li>`).join('');
        return `<${tag}>${items}</${tag}>`;

      case 'quote':
        return `
          <blockquote>
            <p>${escapeHtml(block.text)}</p>
            ${block.attribution ? `<cite>— ${escapeHtml(block.attribution)}</cite>` : ''}
          </blockquote>
        `;

      case 'embed':
        return renderEmbed(block);

      default:
        console.warn(`Unknown block type: ${block.type}`);
        return '';
    }
  }).join('\n');
}

function escapeHtml(text) {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

function renderEmbed(block) {
  switch (block.embed_type) {
    case 'video':
      // Extract video ID for YouTube/Vimeo embeds
      if (block.url.includes('youtube.com') || block.url.includes('youtu.be')) {
        const videoId = extractYouTubeId(block.url);
        return `
          <div class="video-embed">
            <iframe src="https://www.youtube.com/embed/${videoId}"
                    frameborder="0" allowfullscreen></iframe>
          </div>
        `;
      }
      return `<a href="${block.url}">${block.title || 'Watch video'}</a>`;

    default:
      return `<a href="${block.url}">${block.title || 'View content'}</a>`;
  }
}

function extractYouTubeId(url) {
  const match = url.match(/(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|embed\/))([^&?]+)/);
  return match ? match[1] : '';
}

Working with Circulars

Display Circular Thumbnails

javascript
async function displayCircularThumbnails(client) {
  const { data: circulars } = await client.getCirculars({ per_page: 10 });

  const container = document.getElementById('circulars');
  container.innerHTML = circulars.map(circular => `
    <div class="circular-card" data-id="${circular.id}">
      <img src="${circular.thumbnail_url}" alt="${circular.name}" />
      <h3>${circular.name}</h3>
      <p>${circular.total_pages} pages • ${circular.format}</p>
    </div>
  `).join('');

  // Add click handlers
  container.querySelectorAll('.circular-card').forEach(card => {
    card.addEventListener('click', () => {
      openCircularViewer(card.dataset.id);
    });
  });
}

Render Circular Pages

javascript
async function openCircularViewer(client, circularId) {
  const { data: circular } = await client.getCircular(circularId);

  const viewer = document.getElementById('circular-viewer');
  viewer.innerHTML = `
    <div class="circular-header">
      <h2>${circular.name}</h2>
      <span>Page <span id="current-page">1</span> of ${circular.total_pages}</span>
    </div>
    <div class="circular-pages" id="pages-container"></div>
    <div class="circular-nav">
      <button id="prev-page" disabled>Previous</button>
      <button id="next-page">Next</button>
    </div>
  `;

  // Render first page
  let currentPage = 1;
  renderCircularPage(circular.layout_data.pages[0]);

  // Navigation
  document.getElementById('prev-page').addEventListener('click', () => {
    if (currentPage > 1) {
      currentPage--;
      renderCircularPage(circular.layout_data.pages[currentPage - 1]);
      updateNavigation();
    }
  });

  document.getElementById('next-page').addEventListener('click', () => {
    if (currentPage < circular.total_pages) {
      currentPage++;
      renderCircularPage(circular.layout_data.pages[currentPage - 1]);
      updateNavigation();
    }
  });

  function updateNavigation() {
    document.getElementById('current-page').textContent = currentPage;
    document.getElementById('prev-page').disabled = currentPage === 1;
    document.getElementById('next-page').disabled = currentPage === circular.total_pages;
  }
}

function renderCircularPage(page) {
  const container = document.getElementById('pages-container');
  container.innerHTML = page.regions.map(region => `
    <div class="region" style="
      position: absolute;
      left: ${region.position.x}px;
      top: ${region.position.y}px;
      width: ${region.dimensions.width}px;
      height: ${region.dimensions.height}px;
    ">
      ${renderRegionContent(region.content)}
    </div>
  `).join('');
}

Displaying Offers

Active Offers List

javascript
async function displayActiveOffers(client) {
  const { data: offers } = await client.getOffers();

  const container = document.getElementById('offers');
  container.innerHTML = offers.map(offer => `
    <div class="offer-card offer-${offer.type}">
      <div class="offer-badge">${offer.discount.display}</div>
      <h3>${offer.name}</h3>
      <p>${offer.description || ''}</p>
      ${offer.valid_until ? `
        <p class="offer-expires">
          Expires: ${formatDate(offer.valid_until)}
        </p>
      ` : ''}
    </div>
  `).join('');
}

function formatDate(isoString) {
  return new Date(isoString).toLocaleDateString('en-US', {
    month: 'short',
    day: 'numeric',
    year: 'numeric'
  });
}

Countdown Timer for Expiring Offers

javascript
function displayOfferWithCountdown(offer) {
  if (!offer.valid_until) return displayOffer(offer);

  const expiresAt = new Date(offer.valid_until);

  function updateCountdown() {
    const now = new Date();
    const diff = expiresAt - now;

    if (diff <= 0) {
      container.innerHTML = '<p class="expired">This offer has expired</p>';
      return;
    }

    const days = Math.floor(diff / (1000 * 60 * 60 * 24));
    const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
    const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));

    document.getElementById(`countdown-${offer.id}`).textContent =
      `${days}d ${hours}h ${minutes}m remaining`;
  }

  // Update every minute
  updateCountdown();
  setInterval(updateCountdown, 60000);
}

Pagination Helper

javascript
async function* paginateAll(client, endpoint, params = {}) {
  let page = 1;
  let hasMore = true;

  while (hasMore) {
    const response = await client.request(
      `${endpoint}?${new URLSearchParams({ ...params, page })}`
    );

    for (const item of response.data) {
      yield item;
    }

    hasMore = page < response.meta.last_page;
    page++;
  }
}

// Usage
async function getAllArticles(client) {
  const articles = [];
  for await (const article of paginateAll(client, '/content', { type: 'article' })) {
    articles.push(article);
  }
  return articles;
}

Platform Elements

When rendering content sections, the content array may include platform_element entries that represent components owned by your platform. These are resolved client-side by mapping platform_key and element_key to local components.

Component Registry

javascript
// Register your platform's components
const platformComponents = {
  'ecom360:popular-sale-items': renderPopularSaleItems,
  'ecom360:weekly-ad-banner': renderWeeklyAdBanner,
  'ecom360:store-locator-map': renderStoreLocatorMap,
};

function renderFallback(section) {
  console.warn(`Unknown platform element: ${section.platform_key}:${section.element_key}`);
  return '';
}

Section Resolver

javascript
function resolveSection(section) {
  if (section.type === 'platform_element') {
    const key = `${section.platform_key}:${section.element_key}`;
    const renderer = platformComponents[key] || renderFallback;
    return renderer(section);
  }

  // Fall back to built-in section rendering
  return renderBuiltInSection(section);
}

function renderSections(sections) {
  if (!sections || !Array.isArray(sections)) return '';

  return sections.map(section => {
    const html = resolveSection(section);
    const padding = section.settings?.padding || 'medium';
    const anchor = section.settings?.anchor_id || '';
    return `
      <div class="section section--${padding}" ${anchor ? `id="${anchor}"` : ''}>
        ${html}
      </div>
    `;
  }).join('\n');
}

Example Component

javascript
function renderPopularSaleItems(section) {
  const { max_items = 12, layout = 'carousel' } = section.config || {};

  // Fetch and render your platform-specific data
  return `
    <div class="popular-sale-items popular-sale-items--${layout}"
         data-max-items="${max_items}">
      <!-- Hydrated client-side with product data -->
    </div>
  `;
}

See the Platform Elements reference for the full API and storage format.


Error Handling

javascript
class APIError extends Error {
  constructor(message, status, errors = null) {
    super(message);
    this.name = 'APIError';
    this.status = status;
    this.errors = errors;
  }
}

async function fetchWithErrorHandling(client, endpoint) {
  try {
    return await client.request(endpoint);
  } catch (error) {
    if (error.status === 401) {
      // Token expired - redirect to login
      window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
      return;
    }

    if (error.status === 404) {
      // Resource not found - show friendly message
      showNotification('The requested content was not found', 'warning');
      return null;
    }

    if (error.status === 429) {
      // Rate limited - wait and retry
      await sleep(5000);
      return fetchWithErrorHandling(client, endpoint);
    }

    // Unknown error - log and show generic message
    console.error('API Error:', error);
    showNotification('Something went wrong. Please try again.', 'error');
    throw error;
  }
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

Changelog
DateChange
2026-03-24Added platform elements component registry and resolution examples.
2026-02-23Updated examples for CloudFront CDN and Lambda@Edge delivery.
2026-01-28Expanded documentation.
2026-01-15Initial publication.

EngageHQ Public Content Delivery API