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
Link API Client
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');
}
});