Skip to main content
Full TypeScript implementation for integrating with Emerge. Copy these utilities into your project or use as reference.

Installation

No SDK package required. These examples use standard Node.js crypto and fetch APIs.
# For Node.js < 18, install node-fetch
npm install node-fetch
import crypto from 'crypto';

interface EmergeConfig {
  clientId: string;
  signingSecret: string;
  apiToken: string;
  redirectUri: string;
}

interface LinkParams {
  userId: string;
}

interface CallbackParams {
  status: 'success' | 'reauthorized' | 'failure';
  state: string;
  uid: string;
  errorCode?: string;
}

class EmergeLinkClient {
  private config: EmergeConfig;
  private stateStore: Map<string, { userId: string; createdAt: number }>;

  constructor(config: EmergeConfig) {
    this.config = config;
    this.stateStore = new Map();
  }

  /**
   * Create a signed link URL for the consent flow
   */
  createLinkUrl(params: LinkParams): { url: string; state: string } {
    // Use ISO 8601 timestamp
    const timestamp = new Date().toISOString();
    const state = crypto.randomBytes(16).toString('hex');

    // Store state for verification
    this.stateStore.set(state, {
      userId: params.userId,
      createdAt: Date.now()
    });

    const urlParams: Record<string, string> = {
      client_id: this.config.clientId,
      redirect_uri: this.config.redirectUri,
      state,
      timestamp,
      uid: params.userId
    };

    // Sort and create signature (raw values, NOT URL-encoded)
    const sortedKeys = Object.keys(urlParams).sort();
    const signatureBase = sortedKeys
      .map(key => `${key}=${urlParams[key]}`)
      .join('&');

    const signature = crypto
      .createHmac('sha256', this.config.signingSecret)
      .update(signatureBase)
      .digest('hex');

    // Build final URL (URLSearchParams handles encoding)
    const finalParams = new URLSearchParams(urlParams);
    finalParams.append('signature', signature);

    return {
      url: `https://link.emergedata.ai/link/start?${finalParams.toString()}`,
      state
    };
  }

  /**
   * Verify callback state parameter
   */
  verifyState(state: string): { valid: boolean; userId?: string } {
    const stored = this.stateStore.get(state);

    if (!stored) {
      return { valid: false };
    }

    // Clean up state after verification
    this.stateStore.delete(state);

    // Check expiration (1 hour)
    if (Date.now() - stored.createdAt > 3600000) {
      return { valid: false };
    }

    return { valid: true, userId: stored.userId };
  }

  /**
   * Parse callback parameters
   */
  parseCallback(query: Record<string, string>): CallbackParams {
    return {
      status: query.status as CallbackParams['status'],
      state: query.state,
      uid: query.uid,
      errorCode: query.error_code
    };
  }

  /**
   * Get consent status for a user
   */
  async getConsentStatus(uid: string): Promise<{
    consents: Array<{
      provider: string;
      scopes: string[];
      valid_until: string;
      status: string;
      issued_at: string;
    }>;
  }> {
    const response = await fetch(
      `https://link.emergedata.ai/consent/status/${uid}`,
      {
        headers: {
          'Authorization': `Bearer ${this.config.apiToken}`
        }
      }
    );

    if (!response.ok) {
      throw new Error(`Consent status failed: ${response.status}`);
    }

    return response.json();
  }

  /**
   * Get export status for a user
   */
  async getExportStatus(uid: string): Promise<{
    uid: string;
    sources: Array<{
      provider: string;
      data_ready: boolean;
      data_landed_at?: string | null;
      export_status?: string | null;
      export_completed_at?: string | null;
    }>;
  }> {
    const response = await fetch(
      `https://link.emergedata.ai/export/status/${uid}`,
      {
        headers: {
          'Authorization': `Bearer ${this.config.apiToken}`
        }
      }
    );

    if (!response.ok) {
      throw new Error(`Export status failed: ${response.status}`);
    }

    return response.json();
  }
}

Query API Client

interface QueryParams {
  uid: string;
  ingestedBegin?: string;
  ingestedEnd?: string;
  cursor?: string;
  limit?: number;
}

interface QueryResult<T> {
  data: T[];
  count: number;
  hasMore: boolean;
  nextCursor?: string;
  appliedIngestedEnd?: string;
}

interface SearchEntry {
  user_id: string;
  event_id: number;
  url: string | null;
  title: string | null;
  query: string | null;
  intent: string | null;
  category: string | null;
  brands: string[];
  timestamp: string;
  ingested_at: string;
}

interface BrowsingEntry {
  user_id: string;
  event_id: number;
  url: string | null;
  title: string | null;
  page_category: string | null;
  category: string | null;
  brands: string[];
  timestamp: string;
  ingested_at: string;
}

interface YoutubeEntry {
  user_id: string;
  event_id: number;
  url: string | null;
  title: string | null;
  category: string | null;
  brands: string[];
  timestamp: string;
  ingested_at: string;
}

interface AdsEntry {
  user_id: string;
  event_id: number;
  source_domain: string | null;
  landing_domain: string | null;
  url: string | null;
  title: string | null;
  category: string | null;
  brands: Array<string | null>;
  ad_network: string | null;
  timestamp: string;
  ingested_at: string;
}

interface ReceiptsEntry {
  user_id: string;
  event_id: number;
  subject: string | null;
  retailer_name: string | null;
  receipt_currency: string | null;
  total_value: number | null;
  category: string | null;
  brands: Array<string | null>;
  items: Array<{
    name: string | null;
    quantity: number | null;
    unit_price: number | null;
    brand: string | null;
    category: string | null;
  }>;
  timestamp: string;
  ingested_at: string;
}

class EmergeQueryClient {
  private apiToken: string;
  private baseUrl = 'https://query.emergedata.ai/v1';

  constructor(apiToken: string) {
    this.apiToken = apiToken;
  }

  private async request<T>(endpoint: string, params: QueryParams): Promise<QueryResult<T>> {
    const url = new URL(`${this.baseUrl}${endpoint}`);
    url.searchParams.set('uid', params.uid);

    if (params.ingestedBegin) {
      url.searchParams.set('ingested_begin', params.ingestedBegin);
    }
    if (params.ingestedEnd) {
      url.searchParams.set('ingested_end', params.ingestedEnd);
    }
    if (params.cursor) {
      url.searchParams.set('cursor', params.cursor);
    }
    if (params.limit) {
      url.searchParams.set('limit', params.limit.toString());
    }

    const response = await fetch(url.toString(), {
      headers: {
        'Authorization': `Bearer ${this.apiToken}`
      }
    });

    if (!response.ok) {
      throw new Error(`Query failed: ${response.status}`);
    }

    const result = await response.json();
    return {
      data: result.data,
      count: result.count,
      hasMore: result.has_more,
      nextCursor: result.next_cursor,
      appliedIngestedEnd: result.applied_ingested_end
    };
  }

  /**
   * Get search history (sync)
   */
  async getSearch(params: QueryParams): Promise<QueryResult<SearchEntry>> {
    return this.request<SearchEntry>('/sync/get_search', params);
  }

  /**
   * Get browsing history (sync)
   */
  async getBrowsing(params: QueryParams): Promise<QueryResult<BrowsingEntry>> {
    return this.request<BrowsingEntry>('/sync/get_browsing', params);
  }

  /**
   * Get YouTube history (sync)
   */
  async getYoutube(params: QueryParams): Promise<QueryResult<YoutubeEntry>> {
    return this.request<YoutubeEntry>('/sync/get_youtube', params);
  }

  /**
   * Get ad interactions (sync)
   */
  async getAds(params: QueryParams): Promise<QueryResult<AdsEntry>> {
    return this.request<AdsEntry>('/sync/get_ads', params);
  }

  /**
   * Get receipts (sync)
   */
  async getReceipts(params: QueryParams): Promise<QueryResult<ReceiptsEntry>> {
    return this.request<ReceiptsEntry>('/sync/get_receipts', params);
  }

  /**
   * Fetch all records with automatic pagination
   */
  async fetchAll<T>(
    fetcher: (params: QueryParams) => Promise<QueryResult<T>>,
    params: Omit<QueryParams, 'cursor'>
  ): Promise<{ data: T[]; appliedIngestedEnd?: string }> {
    const allData: T[] = [];
    let cursor: string | undefined;
    let appliedEnd: string | undefined;

    do {
      const result = await fetcher({ ...params, cursor });
      allData.push(...result.data);
      appliedEnd = result.appliedIngestedEnd;
      cursor = result.hasMore ? result.nextCursor : undefined;
    } while (cursor);

    return { data: allData, appliedIngestedEnd: appliedEnd };
  }
}

Webhook Handler

import crypto from 'crypto';

type WebhookEvent =
  | 'consent.given'
  | 'consent.revoked'
  | 'consent.expiring'
  | 'consent.reauthorized'
  | 'data.ready'
  | 'data.failed';

interface WebhookPayload {
  event: WebhookEvent;
  timestamp: string;
  uid: string;
  client_id: string;
  sources: Array<Record<string, unknown>>;
}

class EmergeWebhookHandler {
  private secret: string;

  constructor(webhookSecret: string) {
    this.secret = webhookSecret;
  }

  /**
   * Verify webhook signature
   */
  verifySignature(payload: Buffer | string, signature: string): boolean {
    const expected = crypto
      .createHmac('sha256', this.secret)
      .update(payload)
      .digest('hex');

    const signatureBuf = Buffer.from(signature, 'hex');
    const expectedBuf = Buffer.from(expected, 'hex');

    if (signatureBuf.length !== expectedBuf.length) {
      return false;
    }

    return crypto.timingSafeEqual(signatureBuf, expectedBuf);
  }

  /**
   * Parse and verify webhook
   */
  parseWebhook(rawBody: Buffer, signature: string): WebhookPayload {
    if (!this.verifySignature(rawBody, signature)) {
      throw new Error('Invalid webhook signature');
    }

    return JSON.parse(rawBody.toString());
  }
}

Usage Example

// Initialize clients
const linkClient = new EmergeLinkClient({
  clientId: process.env.EMERGE_CLIENT_ID!,
  signingSecret: process.env.EMERGE_SIGNING_SECRET!,
  apiToken: process.env.EMERGE_API_TOKEN!,
  redirectUri: 'https://yourapp.com/emerge/callback'
});

const queryClient = new EmergeQueryClient(process.env.EMERGE_API_TOKEN!);

// Create consent link
const { url, state } = linkClient.createLinkUrl({
  userId: 'psub_d4e5f6789012345678901234abcdef01'
});

// After consent, query data
const searchHistory = await queryClient.getSearch({
  uid: 'psub_d4e5f6789012345678901234abcdef01'
});

// Fetch all with pagination
const { data: allBrowsing, appliedIngestedEnd } = await queryClient.fetchAll(
  (params) => queryClient.getBrowsing(params),
  { uid: 'psub_d4e5f6789012345678901234abcdef01' }
);

// Delta sync - use appliedIngestedEnd for next sync
const nextSyncStart = appliedIngestedEnd;

Express.js Integration

import express from 'express';

const app = express();

// Initialize clients
const linkClient = new EmergeLinkClient({
  clientId: process.env.EMERGE_CLIENT_ID!,
  signingSecret: process.env.EMERGE_SIGNING_SECRET!,
  apiToken: process.env.EMERGE_API_TOKEN!,
  redirectUri: 'https://yourapp.com/emerge/callback'
});

const webhookHandler = new EmergeWebhookHandler(
  process.env.EMERGE_WEBHOOK_SECRET!
);

// Create consent link
app.get('/connect', (req, res) => {
  const userId = req.session.userId;
  const { url, state } = linkClient.createLinkUrl({ userId });

  // Store state in session for verification
  req.session.emergeState = state;

  res.redirect(url);
});

// Handle callback
app.get('/emerge/callback', async (req, res) => {
  const params = linkClient.parseCallback(req.query as Record<string, string>);

  // Verify state
  const { valid } = linkClient.verifyState(params.state);
  if (!valid) {
    return res.status(400).send('Invalid state');
  }

  if (params.status === 'success' || params.status === 'reauthorized') {
    res.redirect('/dashboard?connected=true');
  } else {
    res.redirect(`/error?code=${params.errorCode}`);
  }
});

// Handle webhooks
app.post('/webhooks/emerge', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-signature'] as string;

  try {
    const payload = webhookHandler.parseWebhook(req.body, signature);

    switch (payload.event) {
      case 'consent.given':
        console.log(`Consent granted for user ${payload.uid}`);
        break;
      case 'consent.revoked':
        console.log(`Consent revoked for user ${payload.uid}`);
        break;
      case 'data.ready':
        console.log(`Data ready for user ${payload.uid}`, payload.sources);
        break;
      case 'data.failed':
        console.error(`Data export failed for user ${payload.uid}`, payload.sources);
        break;
      default:
        console.log(`Unhandled webhook event: ${payload.event}`);
    }

    res.status(200).send('OK');
  } catch (error) {
    res.status(401).send('Invalid signature');
  }
});