Skip to Content
ChannelsChannel Providers — Developer Reference

Channel Providers — Developer Reference

Audience: TypeScript developers building equivalent channel integrations.
Source: This document is generated from the .NET provider library (Leadmetrics.Providers) and describes the exact behaviour, contracts, environment variables, and data shapes so you can re-implement the same logic in TypeScript.


Table of Contents

  1. Architecture Overview
  2. Core Shared Types
  3. Package: Channels.Google
  4. Package: Channels.Social
  5. Package: Channels.Zoho
  6. Package: Channels.Microsoft
  7. Error Handling
  8. Environment Variable Summary
  9. TypeScript Migration Notes

Architecture Overview

All channel providers follow the same layered pattern:

SocialServiceBase ← base class: ExecuteSafely, ParseResponse └── GoogleServiceBase ← adds Google-specific Authorize / AuthorizeCallback / RefreshToken └── GmailService, GoogleAdsService, GoogleAnalyticsService, ... └── MicrosoftServiceBase ← adds Microsoft-specific OAuth flow └── BingWebMasterToolsService └── ZohoServiceBase ← adds Zoho-specific OAuth flow (supports dynamic accountsServer) └── ZohoBooksService └── FacebookService ← self-contained (no shared base beyond SocialServiceBase) └── InstagramService └── LinkedInService ← extra ICacheManager dependency └── TwitterService ← supports both OAuth 1.0a AND OAuth 2.0 └── SpotifyService

Every channel is registered in a DI module (Autofac) and reads credentials from environment variables at startup.

OAuth Flow (Standard)

1. Client calls Authorize(callbackUrl, state) → Returns: redirect URL (string) 2. User authenticates on the 3rd-party site and is redirected back with ?code=... 3. Client calls AuthorizeCallback(code, callbackUrl) → Returns: OAuthInfo { accessToken, refreshToken, expiresIn, expireOn, ... } 4. When token expires, call RefreshTokenAsync(refreshToken) → Returns: OAuthInfo (new accessToken, new expireOn)

Twitter is the exception — it supports both OAuth 1.0a (RequestToken → Authorize → AccessToken) and OAuth 2.0 (Authorize2 → AuthorizeCallback).


Core Shared Types

These types are defined in Leadmetrics.Provider.Channels and used across every provider.

OAuthInfo

Represents the result of any OAuth token exchange or refresh.

interface OAuthInfo { access_token: string; // Bearer token token_type: string; // Usually "Bearer" expires_in: number | null; // Seconds until token expires expireOn: Date | null; // Computed: Date.now() + expires_in seconds (UTC) refresh_token: string; // Use this to refresh when expired scope: string; id_token: string; // Present for OpenID Connect flows (Google) accessTokenSecret: string; // Twitter OAuth 1.0a only userId: string; username: string; }

Note: expireOn is always computed server-side as UTC now + expiresIn. Store this and compare Date.now() >= expireOn to decide when to refresh.

ProviderDescription

Static configuration for each channel. Holds all endpoints and OAuth scopes.

interface ProviderDescription { baseUrl: string; authorityUrl: string; accessTokenEndpoint: string; requestTokenEndpoint: string; refreshTokenEndpoint: string; userAuthorizationEndpoint: string; version: string; scopes: string; type: string; }

Base Class: SocialServiceBase

All services extend this. Provides executeSafely (error wrapper) and parseResponse (JSON deserializer).

export abstract class SocialServiceBase { protected abstract getConsumerKey(): string; protected abstract getConsumerSecret(): string; protected abstract getProviderDescription(): ProviderDescription; protected async executeSafely<T>(operation: () => Promise<T>): Promise<T | null> { try { return await operation(); } catch (err) { console.error('Operation failed:', (err as Error).message); return null; } } protected parseResponse<T>(response: Response, body: string): T { if (!body) { throw new Error(`Empty response from API`); } if (response.ok) { const parsed = JSON.parse(body) as T; if (parsed == null) throw new Error(`Failed to deserialize response: ${body}`); return parsed; } throw new ChannelApiError( `API call failed. Status: ${response.status}. Body: ${body}`, response.status ); } }

Base Class: GoogleServiceBase

Extends SocialServiceBase. Implements the full Google OAuth 2.0 flow.

import fetch from 'node-fetch'; export class GoogleServiceBase extends SocialServiceBase { constructor( private readonly clientId: string, private readonly clientSecret: string, private readonly providerDescription: ProviderDescription ) { super(); } protected getConsumerKey() { return this.clientId; } protected getConsumerSecret() { return this.clientSecret; } protected getProviderDescription() { return this.providerDescription; } // Step 1: Build the redirect URL authorize(callbackUrl: string, state: string): string { const p = this.getProviderDescription(); return ( `${p.userAuthorizationEndpoint}?response_type=code` + `&client_id=${this.clientId}` + `&redirect_uri=${callbackUrl}` + `&state=${state}` + `&scope=${encodeURIComponent(p.scopes)}` + `&access_type=offline` + `&include_granted_scopes=true` + `&prompt=consent` ); } // Step 2: Exchange auth code for tokens async authorizeCallback(code: string, callbackUrl: string): Promise<OAuthInfo> { const p = this.getProviderDescription(); const params = new URLSearchParams({ grant_type: 'authorization_code', code, client_id: this.clientId, client_secret: this.clientSecret, redirect_uri: callbackUrl, }); const res = await fetch(p.accessTokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString(), }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Google AuthorizeCallback failed: ${body}`, res.status); const data = JSON.parse(body) as { access_token: string; refresh_token: string; expires_in: number; scope: string }; return { accessToken: data.access_token, refreshToken: data.refresh_token, expiresInSeconds: data.expires_in, expireOn: new Date(Date.now() + data.expires_in * 1000), scope: p.scopes, }; } // Step 3: Refresh an expired token async refreshTokenAsync(refreshToken: string): Promise<OAuthInfo> { const p = this.getProviderDescription(); const params = new URLSearchParams({ client_id: this.clientId, client_secret: this.clientSecret, refresh_token: refreshToken, grant_type: 'refresh_token', }); const res = await fetch(p.refreshTokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString(), }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Google RefreshToken failed: ${body}`, res.status); const data = JSON.parse(body) as { access_token: string; expires_in: number; scope: string }; return { accessToken: data.access_token, refreshToken, // Google does not re-issue the refresh token expiresInSeconds: data.expires_in, expireOn: new Date(Date.now() + data.expires_in * 1000), scope: data.scope, }; } }

Package: Channels.Google

NuGet: Leadmetrics.Provider.Channels.Google
DI Module: GoogleChannelsProviderModule

All Google services inherit from GoogleServiceBase which implements IGoogleChannelService.

Common Google OAuth interface

interface IGoogleChannelService { authorize(callbackUrl: string, state: string): string; authorizeCallback(code: string, callbackUrl: string): Promise<OAuthInfo>; refreshTokenAsync(refreshToken: string): Promise<OAuthInfo>; }

How authorize builds the URL:

{userAuthorizationEndpoint} ?response_type=code &client_id={clientId} &redirect_uri={callbackUrl} &state={state} &scope={scopes} &access_type=offline &include_granted_scopes=true &prompt=consent

access_type=offline and prompt=consent ensure a refresh_token is always returned.


Gmail

Purpose: OAuth connect to Gmail for sending email on behalf of a connected user.
Service class: GmailService implements IGmailService
Inherits: GoogleServiceBase

Environment Variables

VariableDescription
gmailAppIdGoogle OAuth Client ID for Gmail app
gmailAppSecretGoogle OAuth Client Secret for Gmail app

API Endpoints

EndpointURL
Base URLhttps://gmail.googleapis.com
Authorizationhttps://accounts.google.com/o/oauth2/v2/auth
Access Tokenhttps://oauth2.googleapis.com/token
Refresh Tokenhttps://www.googleapis.com/oauth2/v4/token

OAuth Scopes

https://www.googleapis.com/auth/gmail.send

Methods

IGmailService only exposes the 3 inherited OAuth methods (no additional data methods):

MethodInputOutputDescription
authorize(callbackUrl, state)string, stringstring (redirect URL)Step 1 of OAuth
authorizeCallback(code, callbackUrl)string, stringPromise<OAuthInfo>Exchange auth code for tokens
refreshTokenAsync(refreshToken)stringPromise<OAuthInfo>Refresh an expired token

TypeScript Implementation

const GMAIL_PROVIDER: ProviderDescription = { baseUrl: 'https://gmail.googleapis.com', authorityUrl: '', requestTokenEndpoint: '', userAuthorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', accessTokenEndpoint: 'https://oauth2.googleapis.com/token', refreshTokenEndpoint: 'https://www.googleapis.com/oauth2/v4/token', version: '1.0', scopes: 'https://www.googleapis.com/auth/gmail.send', type: 'Gmail', }; export class GmailService extends GoogleServiceBase { constructor() { super( process.env.GMAIL_APP_ID!, process.env.GMAIL_APP_SECRET!, GMAIL_PROVIDER ); } // authorize(), authorizeCallback(), refreshTokenAsync() are all inherited from GoogleServiceBase } // Usage: const gmail = new GmailService(); // Step 1 - redirect user to this URL const redirectUrl = gmail.authorize('https://yourdomain.com/callback', 'random-state-value'); // Step 2 - after redirect, exchange code for tokens const tokens = await gmail.authorizeCallback(req.query.code, 'https://yourdomain.com/callback'); // tokens.accessToken, tokens.refreshToken, tokens.expireOn // Step 3 - refresh when expired const refreshed = await gmail.refreshTokenAsync(tokens.refreshToken);

Purpose: OAuth connect to Google Ads; fetch campaign/keyword/performance data.
Service class: GoogleAdsService implements IGoogleAdsService
Inherits: GoogleServiceBase

Environment Variables

VariableDescription
googleAdsAppIdGoogle OAuth Client ID for Ads
googleAdsAppSecretGoogle OAuth Client Secret for Ads
googleAdsDeveloperTokenGoogle Ads developer token (required on every API call as a header)

API Endpoints

EndpointURL
Base URLhttps://googleads.googleapis.com/v20
Authorizationhttps://accounts.google.com/o/oauth2/v2/auth
Access Tokenhttps://oauth2.googleapis.com/token
Refresh Tokenhttps://www.googleapis.com/oauth2/v4/token

OAuth Scopes

https://www.googleapis.com/auth/adwords

Important: Every API call sends two headers: Authorization: Bearer {token} and developer-token: {googleAdsDeveloperToken}.

Methods

MethodInputOutputDescription
authorize(callbackUrl, state)string, stringstringOAuth step 1
authorizeCallback(code, callbackUrl)string, stringPromise<OAuthInfo>OAuth step 2
refreshTokenAsync(refreshToken)stringPromise<OAuthInfo>Refresh token
getAllCustomers(token)stringPromise<GoogleAdsCustomer[]>List all accessible top-level ad accounts
getCustomerHierarchyDetails(customerId, token)string, stringPromise<GoogleAdsCustomer[]>Get the full manager/sub-account hierarchy for a customer
getCustomerDetails(customerId, parentId, token)string, string, stringPromise<GoogleAdsCustomer>Get a single customer (account) details
getCampaignsAsLookUp(customerId, parentId, token)string, string, stringPromise<GoogleAdsCampaignsLookUpItem[]>List campaigns as ID/name lookup pairs
getMonthlyKeywordMetrics(customerId, parentId, keywords, token)string, string, string[], stringPromise<GoogleAdsKeywordsMetricsModel>Get impressions/clicks/etc for a list of keywords
getGoogleAdsSearchTerms(customerId, parentId, campaignId, token, nextPageToken, pageNumber)string, string, string, string, string, numberPromise<GoogleAdsSearchTermsModel>Paginated list of search terms for a campaign
getGoogleAdsSearchTermsByDate(customerId, parentId, campaignId, campaignDate, token, nextPageToken, pageNumber)string, string, string, Date, string, string, numberPromise<GoogleAdsSearchTermsModel>Search terms filtered by date
getGoogleAdsPositiveKeywords(customerId, parentId, campaignId, token, nextPageToken, pageNumber)string, string, string, string, string, numberPromise<GoogleAdsPositiveKeywordsModel>Paginated list of positive (targeted) keywords
getGoogleAdsCampaignLevelNegativeKeywords(customerId, parentId, campaignId, token, nextPageToken, pageNumber)string, string, string, string, string, numberPromise<GoogleAdsCampaignNegativeKeywordsModel>Campaign-level negative keywords
getKeywordMetricsByKeyword(customerId, parentId, token, inputDTO)string, string, string, GoogleAdsKeywordMetricsInputDTOPromise<GoogleAdsKeywordMetricsResponseDTO>Clicks/impressions/CTR for a single keyword in a date range
getGoogleAdsRecommendations(customerId, parentId, token, campaignId)string, string, string, stringPromise<GoogleAdsRecommendationsModel>Google Ads optimization recommendations
getCustomerLevelLocationAssetPerformanceAsync(customerId, parentId, token, nextPageToken, startDate, endDate)string, string, string, string|null, Date, DatePromise<GoogleAdsLocationAssetPerformanceModel>Location asset performance at customer level
getCampaignLevelLocationAssetPerformanceAsync(customerId, parentId, token, nextPageToken, startDate, endDate)string, string, string, string|null, Date, DatePromise<GoogleAdsLocationAssetPerformanceModel>Location asset performance at campaign level
getAuctionInsightsAsync(customerId, parentId, token, nextPageToken, startDate, endDate)string, string, string, string|null, Date, DatePromise<GoogleAdsAuctionInsightsModel>Auction insights (competitive metrics)
getPPCPerformanceAsync(customerId, parentId, token, startDate, endDate)string, string, string, Date, DatePromise<GoogleAdsPPCPerformanceResponse>Full PPC performance summary
uploadCampaignNegativeKeywords(customerId, parentId, campaignId, negativeKeywords, token)string, string, string, GoogleAdsKeywordInfo[], stringPromise<boolean>Bulk upload campaign-level negative keywords

Key DTOs

interface GoogleAdsKeywordMetricsInputDTO { keyword: string; startDate: Date; endDate: Date; } interface GoogleAdsKeywordMetricsResponseDTO { keyword: string; clicks: number; impressions: number; ctr: number; topImpressionPercentage: number; }

TypeScript Implementation

const GOOGLE_ADS_PROVIDER: ProviderDescription = { baseUrl: 'https://googleads.googleapis.com/v20', authorityUrl: '', requestTokenEndpoint: '', userAuthorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', accessTokenEndpoint: 'https://oauth2.googleapis.com/token', refreshTokenEndpoint: 'https://www.googleapis.com/oauth2/v4/token', version: '1.0', scopes: 'https://www.googleapis.com/auth/adwords', type: 'Google Ads', }; export class GoogleAdsService extends GoogleServiceBase { private readonly developerToken: string; constructor() { super( process.env.GOOGLE_ADS_APP_ID!, process.env.GOOGLE_ADS_APP_SECRET!, GOOGLE_ADS_PROVIDER ); this.developerToken = process.env.GOOGLE_ADS_DEVELOPER_TOKEN!; } private adsHeaders(token: string, parentId?: string): Record<string, string> { const headers: Record<string, string> = { Authorization: `Bearer ${token}`, 'developer-token': this.developerToken, }; if (parentId) headers['login-customer-id'] = parentId; return headers; } private async adsQuery<T>(customerId: string, token: string, query: string, parentId?: string, nextPageToken?: string): Promise<T> { const url = `${this.getProviderDescription().baseUrl}/customers/${customerId}/googleAds:search`; const body = JSON.stringify({ query, ...(nextPageToken ? { pageToken: nextPageToken } : {}) }); const res = await fetch(url, { method: 'POST', headers: { ...this.adsHeaders(token, parentId), 'Content-Type': 'application/json' }, body, }); const text = await res.text(); if (!res.ok) throw new ChannelApiError(`Google Ads query failed: ${text}`, res.status); return JSON.parse(text) as T; } async getAllCustomers(token: string): Promise<GoogleAdsCustomer[]> { const url = `${this.getProviderDescription().baseUrl}/customers:listAccessibleCustomers`; const res = await fetch(url, { headers: this.adsHeaders(token) }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`getAllCustomers failed: ${body}`, res.status); const json = JSON.parse(body) as { resourceNames?: string[] }; const customers: GoogleAdsCustomer[] = []; for (const resourceName of json.resourceNames ?? []) { const cId = resourceName.split('/')[1]; try { const list = await this.getCustomerHierarchyDetails(cId, token); if (list) customers.push(...list); } catch { continue; } } return customers.sort((a, b) => a.descriptiveName.localeCompare(b.descriptiveName)); } async getCustomerHierarchyDetails(customerId: string, token: string): Promise<GoogleAdsCustomer[]> { const query = `SELECT customer_client.client_customer, customer_client.level, customer_client.manager, customer_client.descriptive_name, customer_client.currency_code, customer_client.time_zone, customer_client.id FROM customer_client`; const result = await this.adsQuery<{ results?: { customerClient: GoogleAdsCustomer }[] }>(customerId, token, query); return (result.results ?? []).map(r => r.customerClient); } async getCustomerDetails(customerId: string, parentId: string, token: string): Promise<GoogleAdsCustomer> { const query = `SELECT customer.id, customer.resource_name, customer.descriptive_name, customer.manager, customer.test_account, customer.currency_code FROM customer`; const result = await this.adsQuery<{ results?: { customer: GoogleAdsCustomer }[] }>(customerId, token, query, parentId); return result.results?.[0]?.customer ?? null; } async getCampaignsAsLookUp(customerId: string, parentId: string, token: string): Promise<GoogleAdsCampaignsLookUpItem[]> { const query = `SELECT campaign.name, campaign.id FROM campaign`; const result = await this.adsQuery<{ results?: GoogleAdsCampaignsLookUpItem[] }>(customerId, token, query, parentId); return result.results ?? []; } async getGoogleAdsSearchTerms( customerId: string, parentId: string, campaignId: string, token: string, nextPageToken: string, _pageNumber: number ): Promise<GoogleAdsSearchTermsModel> { const query = `SELECT search_term_view.search_term, search_term_view.resource_name, search_term_view.status, segments.keyword.info.text, segments.search_term_match_type, ad_group.id, ad_group.name, metrics.clicks, metrics.average_cpc, metrics.ctr, metrics.impressions, metrics.conversions, metrics.cost_micros, campaign.id, campaign.name FROM search_term_view WHERE segments.date DURING YESTERDAY AND campaign.id = '${campaignId}'`; return this.adsQuery<GoogleAdsSearchTermsModel>(customerId, token, query, parentId, nextPageToken); } async getGoogleAdsSearchTermsByDate( customerId: string, parentId: string, campaignId: string, campaignDate: Date, token: string, nextPageToken: string, _pageNumber: number ): Promise<GoogleAdsSearchTermsModel> { const dateStr = campaignDate.toISOString().split('T')[0]; const query = `SELECT search_term_view.search_term, search_term_view.resource_name, search_term_view.status, segments.keyword.info.text, segments.search_term_match_type, ad_group.id, ad_group.name, metrics.clicks, metrics.average_cpc, metrics.ctr, metrics.impressions, metrics.conversions, metrics.cost_micros, campaign.id, campaign.name FROM search_term_view WHERE segments.date = '${dateStr}' AND campaign.id = '${campaignId}'`; return this.adsQuery<GoogleAdsSearchTermsModel>(customerId, token, query, parentId, nextPageToken); } async getGoogleAdsPositiveKeywords( customerId: string, parentId: string, campaignId: string, token: string, nextPageToken: string, _pageNumber: number ): Promise<GoogleAdsPositiveKeywordsModel> { const query = `SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, ad_group_criterion.criterion_id, ad_group_criterion.status, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.quality_info.quality_score FROM ad_group_criterion WHERE ad_group_criterion.type = KEYWORD AND ad_group_criterion.status != REMOVED AND campaign.id = '${campaignId}'`; return this.adsQuery<GoogleAdsPositiveKeywordsModel>(customerId, token, query, parentId, nextPageToken); } async getGoogleAdsCampaignLevelNegativeKeywords( customerId: string, parentId: string, campaignId: string, token: string, nextPageToken: string, _pageNumber: number ): Promise<GoogleAdsCampaignNegativeKeywordsModel> { const query = `SELECT campaign.id, campaign.name, campaign_criterion.criterion_id, campaign_criterion.status, campaign_criterion.keyword.text, campaign_criterion.keyword.match_type FROM campaign_criterion WHERE campaign_criterion.type = KEYWORD AND campaign_criterion.negative = true AND campaign.id = '${campaignId}'`; return this.adsQuery<GoogleAdsCampaignNegativeKeywordsModel>(customerId, token, query, parentId, nextPageToken); } async getMonthlyKeywordMetrics( customerId: string, parentId: string, keywords: string[], token: string ): Promise<GoogleAdsKeywordsMetricsModel> { const url = `${this.getProviderDescription().baseUrl}/customers/${customerId}:generateKeywordHistoricalMetrics`; const now = new Date(); const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); const body = JSON.stringify({ keywords, historicalMetricsOptions: { yearMonthRange: { start: { year: lastMonth.getFullYear(), month: lastMonth.getMonth() + 1 }, end: { year: now.getFullYear(), month: now.getMonth() + 1 }, }}, includeAverageMonthlySearches: true, }); const res = await fetch(url, { method: 'POST', headers: { ...this.adsHeaders(token, parentId), 'Content-Type': 'application/json' }, body, }); const text = await res.text(); if (!res.ok) throw new ChannelApiError(`getMonthlyKeywordMetrics failed: ${text}`, res.status); return JSON.parse(text) as GoogleAdsKeywordsMetricsModel; } } // Usage: const googleAds = new GoogleAdsService(); const redirectUrl = googleAds.authorize('https://yourdomain.com/callback', 'state-xyz'); const tokens = await googleAds.authorizeCallback(code, 'https://yourdomain.com/callback'); const customers = await googleAds.getAllCustomers(tokens.accessToken); const campaigns = await googleAds.getCampaignsAsLookUp(customers[0].id, customers[0].managerId, tokens.accessToken); const searchTerms = await googleAds.getGoogleAdsSearchTerms(customers[0].id, customers[0].managerId, campaigns[0].id, tokens.accessToken, null, 1);

Google Analytics

Purpose: OAuth connect to Google Analytics 4; fetch traffic and audience data.
Service class: GoogleAnalyticsService implements IGoogleAnalyticsService
Inherits: GoogleServiceBase

Environment Variables

VariableDescription
googleAnalyticsAppIdOAuth Client ID
googleAnalyticsAppSecretOAuth Client Secret

API Endpoints

EndpointURL
Base URLhttps://analyticsadmin.googleapis.com/v1beta/
Authorizationhttps://accounts.google.com/o/oauth2/v2/auth
Access Tokenhttps://oauth2.googleapis.com/token
Refresh Tokenhttps://www.googleapis.com/oauth2/v4/token

OAuth Scopes

https://www.googleapis.com/auth/analytics.readonly

Methods

MethodInputOutputDescription
authorize(callbackUrl, state)string, stringstringOAuth step 1
authorizeCallback(code, callbackUrl)string, stringPromise<OAuthInfo>OAuth step 2
refreshTokenAsync(refreshToken)stringPromise<OAuthInfo>Refresh token
getAllAccounts(token)stringPromise<GoogleAnalyticsAccount[]>List all GA4 accounts the user has access to
getOrganicTrafficMetrics(propertyId, token, startDate, endDate)string, string, Date, DatePromise<GAOrganicTrafficMetrics>Overall organic traffic summary
getDailyOrganicTrafficMetrics(propertyId, token, startDate, endDate)string, string, Date, DatePromise<GADailyOrganicTrafficMetrics[]>Day-by-day organic traffic breakdown
getSessionsByChannelGroupsOverTime(propertyId, token, startDate, endDate, organicOnly?)string, string, Date, Date, booleanPromise<GAChannelSessionsOverTime[]>Sessions per channel group over time
getTrafficAcquisitionWithEventsAsync(propertyId, token, startDate, endDate, channels?, organicOnly?)string, string, Date, Date, string[]?, booleanPromise<GAChannelEventAndEngagementResult>Traffic acquisition with event & engagement metrics
getLandingPageTrafficAsync(propertyId, token, startDate, endDate, organicOnly?, query?)string, string, Date, Date, boolean, string?Promise<GALandingPagesTrafficResult>Landing page traffic breakdown
getCountryWiseTrafficAsync(propertyId, token, startDate, endDate, organicOnly?)string, string, Date, Date, booleanPromise<GACountryWiseTrafficResult>Traffic by country
getActiveUsersByCountryOverTimeAsync(propertyId, token, startDate, endDate, organicOnly?)string, string, Date, Date, booleanPromise<GAActiveUsersByCountryAndDate[]>Active users per country per date
getActiveUsersByCityAsync(propertyId, token, startDate, endDate, organicOnly?)string, string, Date, Date, booleanPromise<GAActiveUsersByCity[]>Active users by city
getActiveUsersByGenderAsync(propertyId, token, startDate, endDate, organicOnly?)string, string, Date, Date, booleanPromise<GAActiveUsersByGender[]>Users split by gender
getActiveUsersByAgeAsync(propertyId, token, startDate, endDate, organicOnly?)string, string, Date, Date, booleanPromise<GAActiveUsersByAge[]>Users split by age bracket
getActiveUsersByInterestsAsync(propertyId, token, startDate, endDate, organicOnly?)string, string, Date, Date, booleanPromise<GAActiveUsersByInterests[]>Users split by interest category
getTotalWebsiteTrafficAsync(propertyId, token, startDate, endDate, organicOnly?)string, string, Date, Date, booleanPromise<number>Single total user count

TypeScript Implementation

const ORGANIC_CHANNELS = ['Organic Search', 'Organic Shopping', 'Organic Social', 'Organic Video']; const GA_DATA_API = 'https://analyticsdata.googleapis.com/v1beta'; const GA_ADMIN_API = 'https://analyticsadmin.googleapis.com/v1beta'; const GOOGLE_ANALYTICS_PROVIDER: ProviderDescription = { baseUrl: GA_ADMIN_API + '/', authorityUrl: '', requestTokenEndpoint: '', userAuthorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', accessTokenEndpoint: 'https://oauth2.googleapis.com/token', refreshTokenEndpoint: 'https://www.googleapis.com/oauth2/v4/token', version: '1.0', scopes: 'https://www.googleapis.com/auth/analytics.readonly', type: 'Google Analytics', }; export class GoogleAnalyticsService extends GoogleServiceBase { constructor() { super( process.env.GOOGLE_ANALYTICS_APP_ID!, process.env.GOOGLE_ANALYTICS_APP_SECRET!, GOOGLE_ANALYTICS_PROVIDER ); } private fmt(d: Date): string { return d.toISOString().split('T')[0]; // 'yyyy-MM-dd' } private parseDate(s: string): Date { // GA returns dates as 'yyyyMMdd' return new Date(`${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`); } private async runReport(propertyId: string, token: string, body: object): Promise<any> { const res = await fetch(`${GA_DATA_API}/properties/${propertyId}:runReport`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const text = await res.text(); if (!res.ok) throw new ChannelApiError(`GA runReport failed: ${text}`, res.status); return JSON.parse(text); } async getAllAccounts(token: string): Promise<GoogleAnalyticsAccount[]> { const res = await fetch(`${GA_ADMIN_API}/accountSummaries`, { headers: { Authorization: `Bearer ${token}` }, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`getAllAccounts failed: ${body}`, res.status); const data = JSON.parse(body) as { accountSummaries?: GoogleAnalyticsAccount[] }; return data.accountSummaries ?? []; } async getOrganicTrafficMetrics(propertyId: string, token: string, startDate: Date, endDate: Date): Promise<GAOrganicTrafficMetrics> { const result = await this.runReport(propertyId, token, { dateRanges: [{ startDate: this.fmt(startDate), endDate: this.fmt(endDate) }], dimensions: [{ name: 'sessionDefaultChannelGroup' }], metrics: [{ name: 'activeUsers' }, { name: 'newUsers' }, { name: 'userEngagementDuration' }], dimensionFilter: { filter: { fieldName: 'sessionDefaultChannelGroup', inListFilter: { values: ORGANIC_CHANNELS } } }, }); const totals = result.totals?.[0]?.metricValues ?? []; return { channel: 'Organic', activeUsers: parseInt(totals[0]?.value ?? '0'), newUsers: parseInt(totals[1]?.value ?? '0'), totalEngagementTimeSeconds: parseFloat(totals[2]?.value ?? '0'), }; } async getDailyOrganicTrafficMetrics(propertyId: string, token: string, startDate: Date, endDate: Date): Promise<GADailyOrganicTrafficMetrics[]> { const result = await this.runReport(propertyId, token, { dateRanges: [{ startDate: this.fmt(startDate), endDate: this.fmt(endDate) }], dimensions: [{ name: 'date' }], metrics: [{ name: 'activeUsers' }, { name: 'newUsers' }, { name: 'userEngagementDuration' }], dimensionFilter: { filter: { fieldName: 'sessionDefaultChannelGroup', inListFilter: { values: ORGANIC_CHANNELS } } }, }); return (result.rows ?? []) .map((row: any) => ({ date: this.parseDate(row.dimensionValues[0].value), channel: 'Organic', activeUsers: parseInt(row.metricValues[0].value ?? '0'), newUsers: parseInt(row.metricValues[1].value ?? '0'), totalEngagementTimeSeconds: parseFloat(row.metricValues[2].value ?? '0'), })) .sort((a: any, b: any) => a.date - b.date); } async getSessionsByChannelGroupsOverTime( propertyId: string, token: string, startDate: Date, endDate: Date, organicOnly = false ): Promise<GAChannelSessionsOverTime[]> { const body: any = { dateRanges: [{ startDate: this.fmt(startDate), endDate: this.fmt(endDate) }], dimensions: [{ name: 'date' }, { name: 'sessionDefaultChannelGroup' }], metrics: [{ name: 'sessions' }], orderBys: [{ dimension: { dimensionName: 'date' }, desc: false }], }; if (organicOnly) { body.dimensionFilter = { filter: { fieldName: 'sessionDefaultChannelGroup', inListFilter: { values: ORGANIC_CHANNELS } } }; } const result = await this.runReport(propertyId, token, body); return (result.rows ?? []).map((row: any) => ({ date: this.parseDate(row.dimensionValues[0].value), channelGroup: row.dimensionValues[1].value ?? 'Unassigned', sessions: parseInt(row.metricValues[0].value ?? '0'), })).sort((a: any, b: any) => a.date - b.date); } async getTrafficAcquisitionWithEventsAsync( propertyId: string, token: string, startDate: Date, endDate: Date, channels?: string[], organicOnly = false ): Promise<GAChannelEventAndEngagementResult> { const body: any = { dateRanges: [{ startDate: this.fmt(startDate), endDate: this.fmt(endDate) }], dimensions: [{ name: 'sessionDefaultChannelGroup' }], metrics: [ { name: 'sessions' }, { name: 'engagedSessions' }, { name: 'engagementRate' }, { name: 'userEngagementDuration' }, { name: 'eventCount' }, { name: 'eventsPerSession' }, { name: 'keyEvents' }, { name: 'sessionKeyEventRate' }, ], }; const filterValues = organicOnly ? ORGANIC_CHANNELS : (channels ?? []); if (filterValues.length > 0) { body.dimensionFilter = { filter: { fieldName: 'sessionDefaultChannelGroup', inListFilter: { values: filterValues } } }; } const result = await this.runReport(propertyId, token, body); const items = (result.rows ?? []).map((row: any) => { const m = row.metricValues; return { channelGroup: row.dimensionValues[0].value ?? 'Unassigned', sessions: parseInt(m[0].value), engagedSessions: parseInt(m[1].value), engagementRate: Math.round(parseFloat(m[2].value) * 100 * 100) / 100, totalEngagementTimeSeconds: Math.round(parseFloat(m[3].value) * 100) / 100, eventCount: parseInt(m[4].value), eventsPerSession: Math.round(parseFloat(m[5].value) * 100) / 100, keyEvents: parseInt(m[6].value), sessionKeyEventRate: Math.round(parseFloat(m[7].value) * 100 * 100) / 100, }; }); return { metricsElements: items }; } async getLandingPageTrafficAsync( propertyId: string, token: string, startDate: Date, endDate: Date, organicOnly = false, query?: string ): Promise<GALandingPagesTrafficResult> { const body: any = { dateRanges: [{ startDate: this.fmt(startDate), endDate: this.fmt(endDate) }], dimensions: [{ name: 'landingPage' }], metrics: [ { name: 'sessions' }, { name: 'activeUsers' }, { name: 'newUsers' }, { name: 'userEngagementDuration' }, { name: 'keyEvents' }, { name: 'sessionKeyEventRate' }, ], }; const filters: any[] = []; if (organicOnly) filters.push({ fieldName: 'sessionDefaultChannelGroup', inListFilter: { values: ORGANIC_CHANNELS } }); if (query) filters.push({ fieldName: 'landingPage', stringFilter: { matchType: 'CONTAINS', value: query, caseSensitive: false } }); if (filters.length === 1) body.dimensionFilter = { filter: filters[0] }; else if (filters.length > 1) body.dimensionFilter = { andGroup: { expressions: filters.map(f => ({ filter: f })) } }; const result = await this.runReport(propertyId, token, body); const items = (result.rows ?? []).map((row: any) => { const m = row.metricValues; return { landingPage: row.dimensionValues[0].value ?? '(not set)', sessions: parseInt(m[0].value), activeUsers: parseInt(m[1].value), newUsers: parseInt(m[2].value), totalEngagementTimeSeconds: parseFloat(m[3].value), keyEvents: parseInt(m[4].value), sessionKeyEventRate: Math.round(parseFloat(m[5].value) * 100 * 100) / 100, }; }); return { items }; } } // Usage: const ga = new GoogleAnalyticsService(); const redirectUrl = ga.authorize('https://yourdomain.com/callback', 'state-xyz'); const tokens = await ga.authorizeCallback(code, 'https://yourdomain.com/callback'); const accounts = await ga.getAllAccounts(tokens.accessToken); const organic = await ga.getOrganicTrafficMetrics('properties/123456', tokens.accessToken, new Date('2026-01-01'), new Date('2026-03-31')); const daily = await ga.getDailyOrganicTrafficMetrics('properties/123456', tokens.accessToken, new Date('2026-01-01'), new Date('2026-03-31'));

Google Business Profile

Purpose: OAuth connect to Google Business Profile; fetch location metrics, create posts, manage reviews.
Service class: GoogleBusinessProfileService implements IGoogleBusinessProfileService
Inherits: GoogleServiceBase

Environment Variables

VariableDescription
googleMyBusinessAppIdOAuth Client ID
googleMyBusinessAppSecretOAuth Client Secret

API Endpoints

EndpointURL
Base URLhttps://mybusiness.googleapis.com/v4/
Authorizationhttps://accounts.google.com/o/oauth2/v2/auth
Access Tokenhttps://oauth2.googleapis.com/token
Refresh Tokenhttps://www.googleapis.com/oauth2/v4/token

OAuth Scopes

https://www.googleapis.com/auth/business.manage

Methods

MethodInputOutputDescription
authorize(callbackUrl, state)string, stringstringOAuth step 1
authorizeCallback(code, callbackUrl)string, stringPromise<OAuthInfo>OAuth step 2
refreshTokenAsync(refreshToken)stringPromise<OAuthInfo>Refresh token
getAllLocations(token)stringPromise<GBPLocation[]>Get all locations across all accounts
getAllLocationsForAccount(accountId, token)string, stringPromise<GBPLocation[]>Get locations for a specific account
getMultiDailyMetrics(locationId, startDate, endDate, token)string, Date, Date, stringPromise<GBPMultiDailyMetricsResponse>Daily impressions, clicks, calls, etc.
getSearchKeywordsImpressions(locationId, startDate, endDate, token)string, Date, Date, stringPromise<GBPSearchKeywordImpression[]>Search queries that showed the location
createPostAsync(accountId, locationId, token, model)string, string, string, GBPPostRequestModelPromise<GBPPostResponse>Create a Google Business post
getAllReviews(locationId, accountId, token, pages?)string, string, string, numberPromise<GBPReviewsResponse>Paginated list of reviews (default: 1 page)
postReviewReplyAsync(locationId, accountId, reviewId, replyText, token)string, string, string, string, stringPromise<boolean>Reply to a review

GBPPostRequestModel

interface GBPPostRequestModel { summary: string; callToAction?: GBPCallToAction; event?: GBPEvent; offer?: GBPOffer; media?: GBPPostMedia[]; }

TypeScript Implementation

const GBP_ACCOUNT_MGMT_API = 'https://mybusinessaccountmanagement.googleapis.com/v1'; const GBP_BUSINESS_INFO_API = 'https://mybusinessbusinessinformation.googleapis.com/v1'; const GBP_PERFORMANCE_API = 'https://businessprofileperformance.googleapis.com/v1'; const GOOGLE_BUSINESS_PROFILE_PROVIDER: ProviderDescription = { baseUrl: 'https://mybusiness.googleapis.com/v4/', authorityUrl: '', requestTokenEndpoint: '', userAuthorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', accessTokenEndpoint: 'https://oauth2.googleapis.com/token', refreshTokenEndpoint: 'https://www.googleapis.com/oauth2/v4/token', version: '1.0', scopes: 'https://www.googleapis.com/auth/business.manage', type: 'Google Business Profile', }; export class GoogleBusinessProfileService extends GoogleServiceBase { constructor() { super( process.env.GOOGLE_MY_BUSINESS_APP_ID!, process.env.GOOGLE_MY_BUSINESS_APP_SECRET!, GOOGLE_BUSINESS_PROFILE_PROVIDER ); } async getAllLocations(token: string): Promise<GBPLocation[]> { const res = await fetch(`${GBP_ACCOUNT_MGMT_API}/accounts`, { headers: { Authorization: `Bearer ${token}` }, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`GBP getAllLocations accounts failed: ${body}`, res.status); const accounts = JSON.parse(body) as { accounts?: { name: string }[] }; const all: GBPLocation[] = []; for (const account of accounts.accounts ?? []) { const accountId = account.name.split('/')[1]; const locs = await this.getAllLocationsForAccount(accountId, token); all.push(...locs); } return all.sort((a, b) => a.name.localeCompare(b.name)); } async getAllLocationsForAccount(accountId: string, token: string): Promise<GBPLocation[]> { const locations: GBPLocation[] = []; let pageToken: string | undefined; do { let url = `${GBP_BUSINESS_INFO_API}/accounts/${accountId}/locations` + `?read_mask=name,title,metadata.place_id,categories,phone_numbers.primary_phone,storefront_address.region_code&pageSize=100`; if (pageToken) url += `&pageToken=${encodeURIComponent(pageToken)}`; const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`GBP getAllLocationsForAccount failed: ${body}`, res.status); const data = JSON.parse(body) as { locations?: GBPLocation[]; nextPageToken?: string }; for (const loc of data.locations ?? []) { loc.accountId = accountId; locations.push(loc); } pageToken = data.nextPageToken; } while (pageToken); return locations; } async getMultiDailyMetrics(locationId: string, startDate: Date, endDate: Date, token: string): Promise<GBPMultiDailyMetricsResponse> { const metrics = [ 'WEBSITE_CLICKS', 'CALL_CLICKS', 'BUSINESS_IMPRESSIONS_DESKTOP_MAPS', 'BUSINESS_IMPRESSIONS_DESKTOP_SEARCH', 'BUSINESS_IMPRESSIONS_MOBILE_MAPS', 'BUSINESS_IMPRESSIONS_MOBILE_SEARCH', 'BUSINESS_CONVERSATIONS', 'BUSINESS_DIRECTION_REQUESTS', 'BUSINESS_BOOKINGS', 'BUSINESS_FOOD_ORDERS', 'BUSINESS_FOOD_MENU_CLICKS', ].map(m => `dailyMetrics=${m}`).join('&'); const url = `${GBP_PERFORMANCE_API}/locations/${locationId}:fetchMultiDailyMetricsTimeSeries?${metrics}` + `&dailyRange.start_date.year=${startDate.getFullYear()}&dailyRange.start_date.month=${startDate.getMonth() + 1}&dailyRange.start_date.day=${startDate.getDate()}` + `&dailyRange.end_date.year=${endDate.getFullYear()}&dailyRange.end_date.month=${endDate.getMonth() + 1}&dailyRange.end_date.day=${endDate.getDate()}`; const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`GBP getMultiDailyMetrics failed: ${body}`, res.status); return JSON.parse(body) as GBPMultiDailyMetricsResponse; } async getSearchKeywordsImpressions(locationId: string, startDate: Date, endDate: Date, token: string): Promise<GBPSearchKeywordImpression[]> { const results: GBPSearchKeywordImpression[] = []; let nextPageToken: string | undefined; do { let url = `${GBP_PERFORMANCE_API}/locations/${locationId}/searchkeywords/impressions/monthly` + `?monthlyRange.start_month.year=${startDate.getFullYear()}&monthlyRange.start_month.month=${startDate.getMonth() + 1}` + `&monthlyRange.end_month.year=${endDate.getFullYear()}&monthlyRange.end_month.month=${endDate.getMonth() + 1}`; if (nextPageToken) url += `&pageToken=${encodeURIComponent(nextPageToken)}`; const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`GBP getSearchKeywords failed: ${body}`, res.status); const data = JSON.parse(body) as { searchKeywordsCounts?: GBPSearchKeywordImpression[]; nextPageToken?: string }; results.push(...(data.searchKeywordsCounts ?? [])); nextPageToken = data.nextPageToken; } while (nextPageToken); return results; } async createPostAsync(accountId: string, locationId: string, token: string, model: GBPPostRequestModel): Promise<GBPPostResponse> { const medias = model.imageUrls?.filter(Boolean).map(url => ({ mediaFormat: 'PHOTO', sourceUrl: url })); const postBody: any = { summary: model.content, callToAction: model.callToAction, media: medias }; if (model.event) postBody.event = model.event; if (model.offer) postBody.offer = model.offer; const res = await fetch(`${this.getProviderDescription().baseUrl}accounts/${accountId}/locations/${locationId}/localPosts`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(postBody), }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`GBP createPost failed: ${body}`, res.status); return JSON.parse(body) as GBPPostResponse; } async getAllReviews(locationId: string, accountId: string, token: string, pages = 1): Promise<GBPReviewsResponse> { const res = await fetch(`${this.getProviderDescription().baseUrl}accounts/${accountId}/locations/${locationId}/reviews?pageSize=50`, { headers: { Authorization: `Bearer ${token}` }, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`GBP getAllReviews failed: ${body}`, res.status); return JSON.parse(body) as GBPReviewsResponse; } async postReviewReplyAsync(locationId: string, accountId: string, reviewId: string, replyText: string, token: string): Promise<boolean> { const res = await fetch(`${this.getProviderDescription().baseUrl}accounts/${accountId}/locations/${locationId}/reviews/${reviewId}/reply`, { method: 'PUT', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ comment: replyText }), }); return res.ok; } } // Usage: const gbp = new GoogleBusinessProfileService(); const tokens = await gbp.authorizeCallback(code, callbackUrl); const locations = await gbp.getAllLocations(tokens.accessToken); const metrics = await gbp.getMultiDailyMetrics(locations[0].name.split('/')[1], new Date('2026-01-01'), new Date('2026-03-31'), tokens.accessToken);

Google Search Console

Purpose: OAuth connect to Google Search Console; fetch keyword rankings and page performance.
Service class: GoogleSearchConsoleService implements IGoogleSearchConsoleService
Inherits: GoogleServiceBase

Environment Variables

VariableDescription
googleSearchConsoleAppIdOAuth Client ID
googleSearchConsoleAppSecretOAuth Client Secret

API Endpoints

EndpointURL
Base URLhttps://www.googleapis.com/webmasters/v3
Authorizationhttps://accounts.google.com/o/oauth2/v2/auth
Access Tokenhttps://oauth2.googleapis.com/token
Refresh Tokenhttps://www.googleapis.com/oauth2/v4/token

OAuth Scopes

https://www.googleapis.com/auth/webmasters

Methods

MethodInputOutputDescription
authorize(callbackUrl, state)string, stringstringOAuth step 1
authorizeCallback(code, callbackUrl)string, stringPromise<OAuthInfo>OAuth step 2
refreshTokenAsync(refreshToken)stringPromise<OAuthInfo>Refresh token
getUserInfo(token)stringPromise<string>Verify token and get user email
getAllSites(token)stringPromise<GoogleSiteModel[]>List verified sites in Search Console
getStats(site, token, inputDTO, size?)string, string, GSCMetricsInputDTO, numberPromise<GSCMetricsResponseDTO>Overall clicks/impressions/position over time
getPageMetrics(site, token, inputDTO)string, string, PageMetricsInputDTOPromise<PageMetricsResponseDTO>Metrics for a specific URL
getStatsByPage(site, token, inputDTO, size)string, string, GSCPageMetricsInputDTO, numberPromise<GSCPageMetricsResponseDTO>Time-series metrics for a specific page
getKeywordStats(site, token, startDate, endDate)string, string, Date, DatePromise<SearchQueryStatsModel[]>All keyword stats in a date range
getStatsByKeyword(site, token, inputDTO, size)string, string, GSCKeywordMetricsInputDTO, numberPromise<GSCKeywordMetricsResponseDTO>Time-series data for a specific keyword
getAllKeywordStats(site, token)string, stringPromise<SearchQueryStatsModel[]>All-time keyword stats (no date filter)
getLatestPopularKeywords(site, token, timeWindow, keywordsCount)string, string, number, numberPromise<string[]>Most popular queries in last N days
searchQueries(site, token, inputDTO)string, string, GSCSearchFilterInputDTOPromise<GSCKeywordMetricsDTO[]>Paginated/filtered query search
searchPages(site, token, inputDTO)string, string, GSCSearchFilterInputDTOPromise<PageMetricsResponseDTO[]>Paginated/filtered page search
getPagesStats(dimensions, rowSize, startDate, endDate, site, token)string[], number, Date, Date, string, stringPromise<DataTable>Raw tabular data for custom dimensions

Key DTOs

interface GSCMetricsInputDTO { startDate: Date | null; endDate: Date | null; } interface GSCSearchFilterInputDTO { startDate: Date; endDate: Date; query?: string; page?: number; // default 1 size?: number; // default 100 sort?: string; // default "impressions" order?: string; // default "descending" } interface PageMetricsInputDTO { url: string; startDate: Date; endDate: Date; }

TypeScript Implementation

const GSC_PROVIDER: ProviderDescription = { baseUrl: 'https://www.googleapis.com/webmasters/v3', authorityUrl: '', requestTokenEndpoint: '', userAuthorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', accessTokenEndpoint: 'https://oauth2.googleapis.com/token', refreshTokenEndpoint: 'https://www.googleapis.com/oauth2/v4/token', version: '1.0', scopes: 'https://www.googleapis.com/auth/webmasters', type: 'Google Search Console', }; export class GoogleSearchConsoleService extends GoogleServiceBase { constructor() { super( process.env.GOOGLE_SEARCH_CONSOLE_APP_ID!, process.env.GOOGLE_SEARCH_CONSOLE_APP_SECRET!, GSC_PROVIDER ); } private encodeSite(site: string): string { return site.startsWith('http') ? encodeURIComponent(site) : site; } private fmt(d: Date): string { return d.toISOString().split('T')[0]; } private async searchAnalytics(site: string, token: string, body: object): Promise<any> { const siteId = this.encodeSite(site); const res = await fetch(`${this.getProviderDescription().baseUrl}/sites/${siteId}/searchAnalytics/query`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const text = await res.text(); if (!res.ok) throw new ChannelApiError(`GSC searchAnalytics failed: ${text}`, res.status); return JSON.parse(text); } async getAllSites(token: string): Promise<GoogleSiteModel[]> { const res = await fetch(`${this.getProviderDescription().baseUrl}/sites`, { headers: { Authorization: `Bearer ${token}` }, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`GSC getAllSites failed: ${body}`, res.status); const data = JSON.parse(body) as { siteEntry?: GoogleSiteModel[] }; return data.siteEntry ?? []; } async getStats(site: string, token: string, input: GSCMetricsInputDTO, size = 1000): Promise<GSCMetricsResponseDTO> { const body: any = { dimensions: ['date'], rowLimit: size }; if (input.startDate) body.startDate = this.fmt(input.startDate); if (input.endDate) body.endDate = this.fmt(input.endDate); const result = await this.searchAnalytics(site, token, body); const data = (result.rows ?? []).map((r: any) => ({ date: r.keys[0], clicks: r.clicks, impressions: r.impressions, ctr: Math.round(r.ctr * 100) / 100, position: Math.round(r.position * 100) / 100, })); return { data }; } async getPageMetrics(site: string, token: string, input: PageMetricsInputDTO): Promise<PageMetricsResponseDTO> { const body = { startDate: this.fmt(input.startDate), endDate: this.fmt(input.endDate), dimensions: ['page'], rowLimit: 100, dimensionFilterGroups: [{ filters: [{ dimension: 'page', expression: input.url }] }], }; const result = await this.searchAnalytics(site, token, body); const row = result.rows?.[0]; if (!row) throw new Error(`No data for ${input.url}`); return { url: row.keys[0], clicks: row.clicks, impressions: row.impressions, ctr: row.ctr, position: row.position }; } async getKeywordStats(site: string, token: string, startDate: Date, endDate: Date): Promise<SearchQueryStatsModel[]> { const body = { startDate: this.fmt(startDate), endDate: this.fmt(endDate), dimensions: ['query'], rowLimit: 10000, }; const result = await this.searchAnalytics(site, token, body); return result.rows ?? []; } async getStatsByKeyword(site: string, token: string, input: GSCKeywordMetricsInputDTO, size: number): Promise<GSCKeywordMetricsResponseDTO> { const body: any = { dimensions: ['date'], rowLimit: size, dimensionFilterGroups: [{ filters: [{ dimension: 'query', expression: input.keyword }] }], }; if (input.startDate) body.startDate = this.fmt(input.startDate); if (input.endDate) body.endDate = this.fmt(input.endDate); const result = await this.searchAnalytics(site, token, body); const data = (result.rows ?? []).map((r: any) => ({ date: r.keys[0], clicks: r.clicks, impressions: r.impressions, ctr: Math.round(r.ctr * 100) / 100, position: Math.round(r.position * 100) / 100, })); return { keyword: input.keyword, data }; } async searchQueries(site: string, token: string, input: GSCSearchFilterInputDTO): Promise<GSCKeywordMetricsDTO[]> { const body: any = { startDate: this.fmt(input.startDate), endDate: this.fmt(input.endDate), dimensions: ['query'], rowLimit: input.size ?? 100, startRow: ((input.page ?? 1) - 1) * (input.size ?? 100), }; if (input.query) body.dimensionFilterGroups = [{ filters: [{ dimension: 'query', expression: input.query }] }]; const result = await this.searchAnalytics(site, token, body); return (result.rows ?? []).map((r: any) => ({ keyword: r.keys[0], clicks: r.clicks, impressions: r.impressions, ctr: r.ctr, position: r.position, })); } } // Usage: const gsc = new GoogleSearchConsoleService(); const tokens = await gsc.authorizeCallback(code, callbackUrl); const sites = await gsc.getAllSites(tokens.accessToken); const stats = await gsc.getStats(sites[0].siteUrl, tokens.accessToken, { startDate: new Date('2026-01-01'), endDate: new Date('2026-03-31') }); const kwStats = await gsc.getKeywordStats(sites[0].siteUrl, tokens.accessToken, new Date('2026-01-01'), new Date('2026-03-31'));

Package: Channels.Social

NuGet: Leadmetrics.Provider.Channels.Social
DI Module: SocialProviderModule

Note: The namespace within the .NET code is Leadmetrics.Provider.Social (not Channels.Social) for the concrete services.


Facebook

Purpose: OAuth connect via Facebook Login; manage pages and posts; fetch Meta Ads insights.
Service class: FacebookService implements IFacebookService
Reference: Facebook Manual OAuth Flow 

Environment Variables

VariableDescription
facebookAppIdFacebook App ID
facebookAppSecretFacebook App Secret

API Endpoints

EndpointURL
Base URLhttps://graph.facebook.com
Authorizationhttps://www.facebook.com/dialog/oauth
Access Tokenhttps://graph.facebook.com/oauth/access_token

OAuth Scopes

public_profile, pages_show_list, pages_read_engagement, pages_manage_posts, publish_video

Methods

MethodInputOutputDescription
authorize(callbackUrl, state)string, stringstringBuild the Facebook OAuth redirect URL
authorizeCallback(code, callbackUrl)string, stringPromise<OAuthInfo>Exchange auth code for short-lived user token
getAppToken()Promise<string>Get a long-lived app-level token (needed to debug user tokens)
debugToken(token, appToken)string, stringPromise<FacebookToken>Inspect validity and details of a token
getLongLivedToken(shortToken)stringPromise<OAuthInfo>Exchange a short-lived token for a 60-day token
getFacebookPages(token)stringPromise<FacebookSearchResult<FacebookPageInfo>>List all Facebook pages managed by the user
getPageToken(pageId, token)string, stringPromise<FacebookPageToken>Get the page-scoped token for a specific page
getUserInfo(token)FacebookTokenPromise<FacebookUserInfo>Get authenticated user’s profile info
getUserLikesCountForDay(day, token)Date, stringPromise<number>Count of likes on the user’s account for a day
getUserLikesForDay(day, token)Date, stringPromise<FacebookLikeModel[]>List of likes for a day
getUserPostsCountForDay(day, token)Date, stringPromise<number>Count of posts for a day
getUserPostsForDay(day, token)Date, stringPromise<FacebookPostModel[]>List of posts for a day
createPost(post, token)NewFacebookPost, stringPromise<NewFacebookPost>Create a post on the user’s personal profile
createPagePost(pageId, post, token)string, NewSocialPostRequestModel, stringPromise<NewSocialPostResponseModel>Create a text/image post on a page
createPageVideoPost(pageId, post, token)string, NewSocialPostRequestModel, stringPromise<NewSocialPostResponseModel>Upload and publish a video post on a page
getPostMetrics(postId, token)string, stringPromise<FacebookPostMetrics>Impressions, clicks, reactions for a post
getPageMetrics(pageId, token)string, stringPromise<FacebookPageMetrics>Aggregate metrics for a page
getMetaCampaignPerformanceSummaryAsync(adAccountId, accessToken, startDate, endDate)string, string, Date, DatePromise<MetaAdsInsightsResponse>Meta Ads campaign performance summary

Shared Post Models

interface NewSocialPostRequestModel { message: string; mediaUrls?: string[]; mediaType?: string; // "image" | "video" } interface NewSocialPostResponseModel { id: string; }

TypeScript Implementation

const FB_BASE = 'https://graph.facebook.com'; const FB_SCOPES = 'public_profile,pages_show_list,pages_read_engagement,pages_manage_posts,publish_video'; export class FacebookService { private readonly appId: string; private readonly appSecret: string; constructor() { this.appId = process.env.FACEBOOK_APP_ID!; this.appSecret = process.env.FACEBOOK_APP_SECRET!; } // Step 1 authorize(callbackUrl: string, state: string): string { return `https://www.facebook.com/dialog/oauth?client_id=${this.appId}&redirect_uri=${callbackUrl}&scope=${FB_SCOPES}&state=${state}`; } // Step 2 — returns a short-lived user token (~1 hour) async authorizeCallback(code: string, callbackUrl: string): Promise<OAuthInfo> { const res = await fetch(`${FB_BASE}/oauth/access_token?client_id=${this.appId}&client_secret=${this.appSecret}&code=${code}&redirect_uri=${callbackUrl}`); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`FB authorizeCallback failed: ${body}`, res.status); const data = JSON.parse(body); return { accessToken: data.access_token, tokenType: data.token_type, expiresInSeconds: data.expires_in, expireOn: data.expires_in ? new Date(Date.now() + data.expires_in * 1000) : null, }; } // Exchange short-lived user token for a 60-day token async getLongLivedToken(shortToken: string): Promise<OAuthInfo> { const res = await fetch(`${FB_BASE}/oauth/access_token?grant_type=fb_exchange_token&client_id=${this.appId}&client_secret=${this.appSecret}&fb_exchange_token=${shortToken}`); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`FB getLongLivedToken failed: ${body}`, res.status); const data = JSON.parse(body); return { accessToken: data.access_token, expiresInSeconds: data.expires_in, expireOn: data.expires_in ? new Date(Date.now() + data.expires_in * 1000) : null, }; } async getAppToken(): Promise<string> { const res = await fetch(`${FB_BASE}/oauth/access_token?client_id=${this.appId}&client_secret=${this.appSecret}&grant_type=client_credentials`); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`FB getAppToken failed: ${body}`, res.status); return JSON.parse(body).access_token; } async debugToken(token: string, appToken: string): Promise<FacebookToken> { const res = await fetch(`${FB_BASE}/debug_token?input_token=${token}&access_token=${appToken}`); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`FB debugToken failed: ${body}`, res.status); return JSON.parse(body); } async getFacebookPages(token: string): Promise<FacebookSearchResult<FacebookPageInfo>> { const res = await fetch(`${FB_BASE}/me/accounts?access_token=${token}`); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`FB getFacebookPages failed: ${body}`, res.status); return JSON.parse(body); } async getPageToken(pageId: string, token: string): Promise<FacebookPageToken> { const res = await fetch(`${FB_BASE}/${pageId}?fields=access_token&access_token=${token}`); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`FB getPageToken failed: ${body}`, res.status); return JSON.parse(body); } async getUserInfo(token: FacebookToken): Promise<FacebookUserInfo> { const res = await fetch(`${FB_BASE}/me?access_token=${token.accessToken}`); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`FB getUserInfo failed: ${body}`, res.status); return JSON.parse(body); } async createPost(post: NewFacebookPost, token: string): Promise<NewFacebookPost> { const params = new URLSearchParams({ access_token: token, message: post.content }); const res = await fetch(`${FB_BASE}/me/posts`, { method: 'POST', body: params }); if (!res.ok) { const text = await res.text(); throw new ChannelApiError(`FB createPost failed: ${text}`, res.status); } return post; } async createPagePost(pageId: string, post: NewSocialPostRequestModel, token: string): Promise<NewSocialPostResponseModel> { const formData = new URLSearchParams({ access_token: token, message: post.content }); if (post.medias?.[0]?.url) formData.set('link', post.medias[0].url); const res = await fetch(`${FB_BASE}/${pageId}/feed`, { method: 'POST', body: formData }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`FB createPagePost failed: ${body}`, res.status); return JSON.parse(body); } async getPostMetrics(postId: string, token: string): Promise<FacebookPostMetrics> { const res = await fetch(`${FB_BASE}/${postId}/insights?metric=post_impressions,post_clicks,post_reactions_by_type_total&access_token=${token}`); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`FB getPostMetrics failed: ${body}`, res.status); return JSON.parse(body); } async getPageMetrics(pageId: string, token: string): Promise<FacebookPageMetrics> { const res = await fetch(`${FB_BASE}/${pageId}/insights?metric=page_impressions,page_fans&access_token=${token}`); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`FB getPageMetrics failed: ${body}`, res.status); return JSON.parse(body); } async getMetaCampaignPerformanceSummaryAsync( adAccountId: string, accessToken: string, startDate: Date, endDate: Date ): Promise<MetaAdsInsightsResponse> { const since = Math.floor(startDate.getTime() / 1000); const until = Math.floor(endDate.getTime() / 1000); const res = await fetch( `${FB_BASE}/v19.0/act_${adAccountId}/insights?fields=campaign_name,impressions,clicks,spend,reach,cpc,ctr&time_range={"since":"${startDate.toISOString().split('T')[0]}","until":"${endDate.toISOString().split('T')[0]}"}&level=campaign&access_token=${accessToken}` ); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`FB getCampaignPerformance failed: ${body}`, res.status); return JSON.parse(body); } } // Usage: const fb = new FacebookService(); const redirectUrl = fb.authorize('https://yourdomain.com/callback', 'state-xyz'); const shortToken = await fb.authorizeCallback(code, 'https://yourdomain.com/callback'); const longToken = await fb.getLongLivedToken(shortToken.accessToken); const pages = await fb.getFacebookPages(longToken.accessToken); const pageToken = await fb.getPageToken(pages.data[0].id, longToken.accessToken); await fb.createPagePost(pages.data[0].id, { content: 'Hello World!' }, pageToken.accessToken);

Instagram

Purpose: OAuth connect to Instagram Business Account (via Facebook graph); manage and publish posts/stories.
Service class: InstagramService implements IInstagramService

Environment Variables

VariableDescription
instagramAppIdInstagram App ID (same as Facebook App ID for Business accounts)
instagramAppSecretInstagram App Secret

API Endpoints

EndpointURL
Base URLhttps://graph.instagram.com
Authorizationhttps://www.instagram.com/oauth/authorize
Access Tokenhttps://api.instagram.com
Token Exchange (FB graph)https://graph.facebook.com

OAuth Scopes

instagram_business_basic, instagram_business_content_publish, instagram_business_manage_comments, instagram_business_manage_messages, instagram_business_manage_insights

Methods

MethodInputOutputDescription
authorize(callbackUrl, state)string, stringstringBuild Instagram OAuth redirect URL
authorizeCallback(code, callbackUrl)string, stringPromise<OAuthInfo>Exchange auth code for short-lived token
getInstagramAccountFromFacebookPage(pageId, token)string, stringPromise<string>Resolve the Instagram Business Account ID linked to a Facebook page
getLongLivedToken(shortToken)stringPromise<OAuthInfo>Upgrade to a 60-day token
refreshToken(token)stringPromise<OAuthInfo>Refresh a long-lived token (before it expires)
getUserLikesCountForDay(day, token)Date, stringPromise<number>Count likes for a day
getUserLikesForDay(day, token)Date, stringPromise<InstagramLikeModel[]>List likes for a day
getUserPostsCountForDay(day, token)Date, stringPromise<number>Count posts for a day
getUserPostsForDay(day, token)Date, stringPromise<InstagramPostModel[]>List posts for a day
createCarouselPost(userId, post, token)string, NewSocialPostRequestModel, stringPromise<NewSocialPostResponseModel>Publish a multi-image carousel post
createImagePost(userId, post, token)string, NewSocialPostRequestModel, stringPromise<NewSocialPostResponseModel>Publish a single image post
createStoryImages(userId, post, token)string, NewSocialPostRequestModel, stringPromise<NewSocialPostResponseModel>Publish an image story
createStoryVideo(userId, post, token)string, NewSocialPostRequestModel, stringPromise<NewSocialPostResponseModel>Publish a video story
createVideoPosts(userId, post, token)string, NewSocialPostRequestModel, stringPromise<NewSocialPostResponseModel>Publish a video feed post
getPostMetrics(postId, token)string, stringPromise<InstaPostMetrics>Impressions, reach, likes, comments for a post
getAccountMetrics(accountId, token)string, stringPromise<InstagramInsights>Aggregate account-level insights

TypeScript Implementation

const IG_GRAPH_BASE = 'https://graph.instagram.com'; const IG_API_BASE = 'https://api.instagram.com'; const IG_FB_GRAPH = 'https://graph.facebook.com'; const IG_SCOPES = 'instagram_business_basic,instagram_business_content_publish,instagram_business_manage_comments,instagram_business_manage_messages,instagram_business_manage_insights'; export class InstagramService { private readonly appId: string; private readonly appSecret: string; constructor() { this.appId = process.env.INSTAGRAM_APP_ID!; this.appSecret = process.env.INSTAGRAM_APP_SECRET!; } // Step 1 authorize(callbackUrl: string, state: string): string { return `https://www.instagram.com/oauth/authorize?client_id=${this.appId}&redirect_uri=${callbackUrl}&response_type=code&scope=${IG_SCOPES}&state=${state}`; } // Step 2 — exchange for short-lived token then immediately upgrade async authorizeCallback(code: string, callbackUrl: string): Promise<OAuthInfo> { const params = new URLSearchParams({ client_id: this.appId, client_secret: this.appSecret, grant_type: 'authorization_code', redirect_uri: callbackUrl, code, }); const res = await fetch(`${IG_API_BASE}/oauth/access_token`, { method: 'POST', body: params }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`IG authorizeCallback failed: ${body}`, res.status); const short = JSON.parse(body) as { access_token: string; user_id: string }; const longLived = await this.getLongLivedToken(short.access_token); longLived.userId = short.user_id; return longLived; } // Upgrade short-lived → 60-day token async getLongLivedToken(shortToken: string): Promise<OAuthInfo> { const res = await fetch(`${IG_GRAPH_BASE}/access_token?grant_type=ig_exchange_token&client_secret=${this.appSecret}&access_token=${shortToken}`); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`IG getLongLivedToken failed: ${body}`, res.status); const data = JSON.parse(body); return { accessToken: data.access_token, expiresInSeconds: data.expires_in, expireOn: data.expires_in ? new Date(Date.now() + data.expires_in * 1000) : null, }; } // Refresh before expiry (call when < 30 days remaining) async refreshToken(token: string): Promise<OAuthInfo> { const res = await fetch(`${IG_GRAPH_BASE}/refresh_access_token?grant_type=ig_refresh_token&access_token=${token}`); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`IG refreshToken failed: ${body}`, res.status); const data = JSON.parse(body); return { accessToken: data.access_token, expiresInSeconds: data.expires_in, expireOn: data.expires_in ? new Date(Date.now() + data.expires_in * 1000) : null, }; } async getInstagramAccountFromFacebookPage(pageId: string, token: string): Promise<string> { const res = await fetch(`${IG_FB_GRAPH}/${pageId}?fields=instagram_business_account&access_token=${token}`); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`IG getAccountFromPage failed: ${body}`, res.status); const data = JSON.parse(body) as { instagram_business_account: { id: string } }; return data.instagram_business_account.id; } // Single image post async createImagePost(userId: string, post: NewSocialPostRequestModel, token: string): Promise<NewSocialPostResponseModel> { // Step 1: create media container const containerRes = await fetch(`${IG_GRAPH_BASE}/v14.0/${userId}/media`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ image_url: post.medias[0].url, caption: post.content }), }); const containerBody = await containerRes.text(); if (!containerRes.ok) throw new ChannelApiError(`IG createImagePost container failed: ${containerBody}`, containerRes.status); const { id: containerId } = JSON.parse(containerBody); // Step 2: publish container const publishRes = await fetch(`${IG_GRAPH_BASE}/v14.0/${userId}/media_publish`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ creation_id: containerId }), }); const publishBody = await publishRes.text(); if (!publishRes.ok) throw new ChannelApiError(`IG createImagePost publish failed: ${publishBody}`, publishRes.status); return JSON.parse(publishBody); } // Multi-image carousel post async createCarouselPost(userId: string, post: NewSocialPostRequestModel, token: string): Promise<NewSocialPostResponseModel> { const mediaIds: string[] = []; for (const media of post.medias) { const res = await fetch(`${IG_GRAPH_BASE}/v14.0/${userId}/media`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ image_url: media.url, caption: post.content }), }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`IG carousel item upload failed: ${body}`, res.status); mediaIds.push(JSON.parse(body).id); } // Create carousel container const carRes = await fetch(`${IG_GRAPH_BASE}/v14.0/${userId}/media`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ media_type: 'CAROUSEL', caption: post.content, children: mediaIds }), }); const carBody = await carRes.text(); if (!carRes.ok) throw new ChannelApiError(`IG carousel container failed: ${carBody}`, carRes.status); const { id: carouselId } = JSON.parse(carBody); // Publish const pubRes = await fetch(`${IG_GRAPH_BASE}/v14.0/${userId}/media_publish`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ creation_id: carouselId }), }); const pubBody = await pubRes.text(); if (!pubRes.ok) throw new ChannelApiError(`IG carousel publish failed: ${pubBody}`, pubRes.status); return JSON.parse(pubBody); } async getPostMetrics(postId: string, token: string): Promise<InstaPostMetrics> { const res = await fetch(`${IG_GRAPH_BASE}/${postId}/insights?metric=impressions,reach,likes,comments&access_token=${token}`); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`IG getPostMetrics failed: ${body}`, res.status); return JSON.parse(body); } async getAccountMetrics(accountId: string, token: string): Promise<InstagramInsights> { const res = await fetch(`${IG_GRAPH_BASE}/${accountId}/insights?metric=impressions,reach,follower_count&period=day&access_token=${token}`); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`IG getAccountMetrics failed: ${body}`, res.status); return JSON.parse(body); } } // Usage: const ig = new InstagramService(); const redirectUrl = ig.authorize('https://yourdomain.com/callback', 'state-xyz'); const tokens = await ig.authorizeCallback(code, 'https://yourdomain.com/callback'); // tokens.userId is the Instagram user ID const igAccountId = await ig.getInstagramAccountFromFacebookPage(fbPageId, fbPageToken); await ig.createImagePost(igAccountId, { content: 'Hello!', medias: [{ url: 'https://img.example.com/photo.jpg' }] }, tokens.accessToken);

LinkedIn

Purpose: OAuth connect to LinkedIn organization pages; publish posts, fetch follower and share statistics.
Service class: LinkedInService implements ILinkedInService
Extra dependency: ICacheManager (Redis) — used internally to cache access tokens.

Environment Variables

VariableDescription
linkedInAppIdLinkedIn App Client ID
linkedInAppSecretLinkedIn App Client Secret

LinkedInService also requires ICacheManager (Redis). Register RedisCacheModule in the DI container before this service.

API Endpoints

EndpointURL
Base URLhttps://api.linkedin.com
Authorizationhttps://www.linkedin.com/oauth/v2/authorization
Access Tokenhttps://www.linkedin.com/oauth/v2/accessToken

API Version Header

Every request sends: LinkedIn-Version: 202601 and X-Restli-Protocol-Version: 2.0.0

OAuth Scopes

rw_organization_admin%20w_organization_social

Methods

MethodInputOutputDescription
authorize(callbackUrl, state)string, stringstringBuild LinkedIn OAuth redirect URL
authorizeCallback(code, callbackUrl)string, stringPromise<OAuthInfo>Exchange auth code for access token
getLinkedInPages(token)stringPromise<PageInfo[]>List all organization pages the user administers
createPageShare(pageId, share, token)string, NewShare, stringPromise<LinkedInPost>Legacy share endpoint (text + link)
createPost(pageId, model, token)string, NewSocialPostRequestModel, stringPromise<NewSocialPostResponseModel>Create a post on an organization page
getPageStatisticsAsync(organizationUrn, startDate, endDate, timeGranularity, token)string, Date, Date, string, stringPromise<LinkedInPageStatisticsModel>Page impressions/clicks/engagement over time
getCurrentFollowersCountAsync(organizationUrn, token)string, stringPromise<number>Current total follower count
getFollowersCountByDateAsync(organizationUrn, date, token)string, Date, stringPromise<number>Follower count at a specific date
getFollowersGainStatisticsAsync(organizationUrn, startDate, endDate, timeGranularity, token)string, Date, Date, string, stringPromise<LinkedInFollowersGainStatisticsModel>Follower gains over a period
getFollowersStatisticsAsync(organizationUrn, startDate, endDate, timeGranularity, token)string, Date, Date, string, stringPromise<LinkedInFollowersStatisticsModel>Follower demographic breakdown
getShareStatisticsAsync(organizationUrn, startDate, endDate, timeGranularity, token)string, Date, Date, string, stringPromise<LinkedInShareStatisticsModel>Post shares, comments, reactions breakdown

timeGranularity values: "DAY", "MONTH"

TypeScript Implementation

const LI_AUTH_BASE = 'https://www.linkedin.com/oauth/v2'; const LI_API_BASE = 'https://api.linkedin.com'; const LI_SCOPES = 'rw_organization_admin w_organization_social'; export class LinkedInService { private readonly appId: string; private readonly appSecret: string; constructor() { this.appId = process.env.LINKEDIN_APP_ID!; this.appSecret = process.env.LINKEDIN_APP_SECRET!; } private get headers() { return { 'X-Restli-Protocol-Version': '2.0.0', 'LinkedIn-Version': '202601', 'Content-Type': 'application/json', }; } // Step 1 authorize(callbackUrl: string, state: string): string { return `${LI_AUTH_BASE}/authorization?response_type=code&client_id=${this.appId}&redirect_uri=${encodeURIComponent(callbackUrl)}&scope=${encodeURIComponent(LI_SCOPES)}&state=${state}`; } // Step 2 async authorizeCallback(code: string, callbackUrl: string): Promise<OAuthInfo> { const params = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: callbackUrl, client_id: this.appId, client_secret: this.appSecret, }); const res = await fetch(`${LI_AUTH_BASE}/accessToken`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`LI authorizeCallback failed: ${body}`, res.status); const data = JSON.parse(body); return { accessToken: data.access_token, refreshToken: data.refresh_token, expiresInSeconds: data.expires_in, expireOn: data.expires_in ? new Date(Date.now() + data.expires_in * 1000) : null, }; } async getLinkedInPages(token: string): Promise<PageInfo[]> { const res = await fetch(`${LI_API_BASE}/rest/organizationAcls?q=roleAssignee&role=ADMINISTRATOR&state=APPROVED`, { headers: { ...this.headers, Authorization: `Bearer ${token}` }, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`LI getLinkedInPages failed: ${body}`, res.status); return JSON.parse(body).elements ?? []; } async createPost(pageId: string, post: NewSocialPostRequestModel, token: string): Promise<NewSocialPostResponseModel> { const payload: Record<string, unknown> = { author: `urn:li:organization:${pageId}`, commentary: post.content, visibility: 'PUBLIC', distribution: { feedDistribution: 'MAIN_FEED', targetEntities: [], thirdPartyDistributionChannels: [] }, lifecycleState: 'PUBLISHED', isReshareDisabledByAuthor: false, }; if (post.medias?.length) { payload.content = { media: { id: post.medias[0].assetUrn ?? post.medias[0].url }, }; } const res = await fetch(`${LI_API_BASE}/rest/posts`, { method: 'POST', headers: { ...this.headers, Authorization: `Bearer ${token}` }, body: JSON.stringify(payload), }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`LI createPost failed: ${body}`, res.status); // Post ID is in the x-restli-id response header const postId = res.headers.get('x-restli-id') ?? JSON.parse(body).id; return { id: postId }; } async getPageStatisticsAsync( organizationUrn: string, startDate: Date, endDate: Date, timeGranularity: 'DAY' | 'MONTH', token: string ): Promise<LinkedInPageStatisticsModel> { const params = new URLSearchParams({ q: 'organizationalEntity', organizationalEntity: organizationUrn, 'timeIntervals.timeGranularityType': timeGranularity, 'timeIntervals.timeRange.start': startDate.getTime().toString(), 'timeIntervals.timeRange.end': endDate.getTime().toString(), }); const res = await fetch(`${LI_API_BASE}/rest/organizationPageStatistics?${params}`, { headers: { ...this.headers, Authorization: `Bearer ${token}` }, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`LI getPageStatistics failed: ${body}`, res.status); return JSON.parse(body); } async getCurrentFollowersCountAsync(organizationUrn: string, token: string): Promise<number> { const res = await fetch(`${LI_API_BASE}/rest/networkSizes/${encodeURIComponent(organizationUrn)}?edgeType=CompanyFollowedByMember`, { headers: { ...this.headers, Authorization: `Bearer ${token}` }, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`LI getCurrentFollowersCount failed: ${body}`, res.status); return JSON.parse(body).firstDegreeSize ?? 0; } async getFollowersGainStatisticsAsync( organizationUrn: string, startDate: Date, endDate: Date, timeGranularity: 'DAY' | 'MONTH', token: string ): Promise<LinkedInFollowersGainStatisticsModel> { const params = new URLSearchParams({ q: 'organizationalEntity', organizationalEntity: organizationUrn, 'timeIntervals.timeGranularityType': timeGranularity, 'timeIntervals.timeRange.start': startDate.getTime().toString(), 'timeIntervals.timeRange.end': endDate.getTime().toString(), }); const res = await fetch(`${LI_API_BASE}/rest/followerStatistics?${params}`, { headers: { ...this.headers, Authorization: `Bearer ${token}` }, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`LI getFollowersGainStatistics failed: ${body}`, res.status); return JSON.parse(body); } async getShareStatisticsAsync( organizationUrn: string, startDate: Date, endDate: Date, timeGranularity: 'DAY' | 'MONTH', token: string ): Promise<LinkedInShareStatisticsModel> { const params = new URLSearchParams({ q: 'organizationalEntity', organizationalEntity: organizationUrn, 'timeIntervals.timeGranularityType': timeGranularity, 'timeIntervals.timeRange.start': startDate.getTime().toString(), 'timeIntervals.timeRange.end': endDate.getTime().toString(), }); const res = await fetch(`${LI_API_BASE}/rest/organizationalEntityShareStatistics?${params}`, { headers: { ...this.headers, Authorization: `Bearer ${token}` }, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`LI getShareStatistics failed: ${body}`, res.status); return JSON.parse(body); } } // Usage: const li = new LinkedInService(); const redirectUrl = li.authorize('https://yourdomain.com/callback', 'state-xyz'); const tokens = await li.authorizeCallback(code, 'https://yourdomain.com/callback'); const pages = await li.getLinkedInPages(tokens.accessToken); await li.createPost(pages[0].organization.split(':').pop()!, { content: 'Hello LinkedIn!' }, tokens.accessToken);

Twitter / X

Purpose: OAuth connect to Twitter/X; post tweets, fetch tweet metrics.
Service class: TwitterService implements ITwitterService
Reference: Twitter OAuth 2.0 , OAuth 1.0a 

Environment Variables

Twitter credentials are currently hardcoded in SocialProviderModule. They should be moved to environment variables:

VariableDescription
twitterApiKeyTwitter/X API Key (Consumer Key) — currently hardcoded
twitterApiSecretTwitter/X API Secret (Consumer Secret) — currently hardcoded

API Endpoints

EndpointURL
Base URLhttps://api.twitter.com/
OAuth 1.0a Authorizationhttps://api.twitter.com/oauth/authorize
OAuth 1.0a Request Tokenhttps://api.twitter.com/oauth/request_token
OAuth 2.0 Access Tokenhttps://api.twitter.com/2/oauth2/token

OAuth Scopes (OAuth 2.0)

tweet.read%20tweet.write%20users.read%20offline.access

OAuth Flows

Twitter supports two distinct flows:

OAuth 1.0a (legacy, user context):

1. requestToken(callbackUrl) → RequestTokenInfo { oauthToken, oauthSecret, oauthCallbackConfirmed } 2. authorize(oauthToken) → redirect URL 3. accessToken(oauthToken, verifier, state) → OAuthInfo (store accessToken AND accessTokenSecret — both needed for API calls)

OAuth 2.0 (PKCE):

1. authorize2(callbackUrl, state) → redirect URL Uses code_challenge="challenge" and code_challenge_method="plain" (plain challenge, not S256) 2. authorizeCallback(code, callbackUrl, state) → OAuthInfo 3. refreshToken(refreshToken) → OAuthInfo

Methods

MethodInputOutputDescription
requestToken(callbackUrl)stringPromise<RequestTokenInfo>OAuth 1.0a Step 1: Get a request token
authorize(oauthToken)stringstringOAuth 1.0a Step 2: Build redirect URL using request token
authorize2(callbackUrl, state)string, stringstringOAuth 2.0 Step 1: Build PKCE redirect URL
accessToken(token, verifier, state)string, string, stringPromise<OAuthInfo>OAuth 1.0a Step 3: Exchange verifier for tokens
authorizeCallback(code, callbackUrl, state)string, string, stringPromise<OAuthInfo>OAuth 2.0 Step 2: Exchange auth code for tokens
refreshToken(refreshToken)stringPromise<OAuthInfo>Refresh an OAuth 2.0 token
createTweetV2(post, token)NewTweet, stringPromise<NewTweet>Post a tweet using OAuth 2.0 Bearer token
createTweet(post, token, tokenSecret)NewTweet, string, stringPromise<NewTweet>Post a tweet using OAuth 1.0a (requires both token and secret)
getTweetMetrics(tweetId, token, tokenSecret)string, string, stringPromise<TwitterMetrics>Get engagement metrics for a tweet
interface RequestTokenInfo { oauthToken: string; oauthSecret: string; oauthCallbackConfirmed: string; } interface NewTweet { text: string; id?: string; // populated on response }

TypeScript Implementation

Note: OAuth 1.0a request signing typically requires a library such as oauth-1.0a. The example below uses a minimal HMAC-SHA1 implementation outline.

const TW_BASE = 'https://api.twitter.com'; const TW_SCOPES = 'tweet.read tweet.write users.read offline.access'; export class TwitterService { private readonly apiKey: string; private readonly apiSecret: string; constructor() { this.apiKey = process.env.TWITTER_API_KEY!; this.apiSecret = process.env.TWITTER_API_SECRET!; } // ── OAuth 1.0a ────────────────────────────────────────────── async requestToken(callbackUrl: string): Promise<RequestTokenInfo> { // Build OAuth 1.0a Authorization header for the request-token endpoint const authHeader = this.buildOAuth1Header('POST', `${TW_BASE}/oauth/request_token`, { oauth_callback: callbackUrl, }); const res = await fetch(`${TW_BASE}/oauth/request_token`, { method: 'POST', headers: { Authorization: authHeader }, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`TW requestToken failed: ${body}`, res.status); const params = new URLSearchParams(body); return { oauthToken: params.get('oauth_token')!, oauthSecret: params.get('oauth_token_secret')!, oauthCallbackConfirmed: params.get('oauth_callback_confirmed')!, }; } authorize(oauthToken: string): string { return `${TW_BASE}/oauth/authorize?oauth_token=${oauthToken}`; } async accessToken(token: string, verifier: string, _state: string): Promise<OAuthInfo> { const authHeader = this.buildOAuth1Header('POST', `${TW_BASE}/oauth/access_token`, { oauth_token: token, oauth_verifier: verifier, }); const res = await fetch(`${TW_BASE}/oauth/access_token`, { method: 'POST', headers: { Authorization: authHeader }, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`TW accessToken failed: ${body}`, res.status); const params = new URLSearchParams(body); return { accessToken: params.get('oauth_token')!, refreshToken: params.get('oauth_token_secret')!, // store the secret as refreshToken }; } // Post using OAuth 1.0a (requires token + tokenSecret) async createTweet(post: NewTweet, token: string, tokenSecret: string): Promise<NewTweet> { const url = `${TW_BASE}/2/tweets`; const authHeader = this.buildOAuth1Header('POST', url, {}, token, tokenSecret); const res = await fetch(url, { method: 'POST', headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify({ text: post.text }), }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`TW createTweet (OAuth1) failed: ${body}`, res.status); const data = JSON.parse(body); return { text: post.text, id: data.data?.id }; } // ── OAuth 2.0 PKCE ────────────────────────────────────────── authorize2(callbackUrl: string, state: string): string { // Twitter uses plain challenge (not S256) — challenge === verifier return `${TW_BASE}/i/oauth2/authorize?response_type=code&client_id=${this.apiKey}&redirect_uri=${encodeURIComponent(callbackUrl)}&scope=${encodeURIComponent(TW_SCOPES)}&state=${state}&code_challenge=challenge&code_challenge_method=plain`; } async authorizeCallback(code: string, callbackUrl: string, _state: string): Promise<OAuthInfo> { const params = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: callbackUrl, code_verifier: 'challenge', }); const basicAuth = btoa(`${this.apiKey}:${this.apiSecret}`); const res = await fetch(`${TW_BASE}/2/oauth2/token`, { method: 'POST', headers: { Authorization: `Basic ${basicAuth}`, 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`TW authorizeCallback failed: ${body}`, res.status); const data = JSON.parse(body); return { accessToken: data.access_token, refreshToken: data.refresh_token, expiresInSeconds: data.expires_in, expireOn: data.expires_in ? new Date(Date.now() + data.expires_in * 1000) : null, }; } async refreshToken(token: string): Promise<OAuthInfo> { const params = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: token }); const basicAuth = btoa(`${this.apiKey}:${this.apiSecret}`); const res = await fetch(`${TW_BASE}/2/oauth2/token`, { method: 'POST', headers: { Authorization: `Basic ${basicAuth}`, 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`TW refreshToken failed: ${body}`, res.status); const data = JSON.parse(body); return { accessToken: data.access_token, refreshToken: data.refresh_token, expiresInSeconds: data.expires_in, expireOn: data.expires_in ? new Date(Date.now() + data.expires_in * 1000) : null, }; } // Post using OAuth 2.0 Bearer token async createTweetV2(post: NewTweet, token: string): Promise<NewTweet> { const res = await fetch(`${TW_BASE}/2/tweets`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ text: post.text }), }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`TW createTweetV2 failed: ${body}`, res.status); const data = JSON.parse(body); return { text: post.text, id: data.data?.id }; } async getTweetMetrics(tweetId: string, token: string, _tokenSecret: string): Promise<TwitterMetrics> { const res = await fetch(`${TW_BASE}/2/tweets/${tweetId}?tweet.fields=public_metrics`, { headers: { Authorization: `Bearer ${token}` }, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`TW getTweetMetrics failed: ${body}`, res.status); return JSON.parse(body); } // Minimal OAuth 1.0a header builder (production use: replace with oauth-1.0a library) private buildOAuth1Header( method: string, url: string, extraParams: Record<string, string> = {}, token = '', tokenSecret = '' ): string { const nonce = Math.random().toString(36).substring(2); const timestamp = Math.floor(Date.now() / 1000).toString(); const oauthParams = { oauth_consumer_key: this.apiKey, oauth_nonce: nonce, oauth_signature_method: 'HMAC-SHA1', oauth_timestamp: timestamp, oauth_version: '1.0', ...(token ? { oauth_token: token } : {}), ...extraParams, }; // NOTE: Signature calculation omitted — use the 'oauth-1.0a' npm package for production const headerParts = Object.entries(oauthParams) .map(([k, v]) => `${k}="${encodeURIComponent(v)}"`) .join(', '); return `OAuth ${headerParts}`; } } // Usage: const tw = new TwitterService(); // OAuth 2.0 flow: const redirectUrl = tw.authorize2('https://yourdomain.com/callback', 'state-xyz'); const tokens = await tw.authorizeCallback(code, 'https://yourdomain.com/callback', 'state-xyz'); await tw.createTweetV2({ text: 'Hello Twitter!' }, tokens.accessToken); // OAuth 1.0a flow: const reqToken = await tw.requestToken('https://yourdomain.com/callback'); const oauthRedirect = tw.authorize(reqToken.oauthToken); // After user authorizes and callback fires with oauth_token + oauth_verifier: const oauth1Tokens = await tw.accessToken(oauthToken, verifier, ''); await tw.createTweet({ text: 'Hello!' }, oauth1Tokens.accessToken, oauth1Tokens.refreshToken!);

Purpose: OAuth connect to Spotify; fetch recently played tracks.
Service class: SpotifyService implements ISpotifyService

Environment Variables

Spotify credentials are currently hardcoded in the module. They should be environment variables:

VariableDescription
spotifyClientIdSpotify App Client ID
spotifyClientSecretSpotify App Client Secret

API Endpoints

EndpointURL
Base URL (auth)https://accounts.spotify.com
Authorizationhttps://accounts.spotify.com/authorize
Recently Playedhttps://api.spotify.com/v1/me/player/recently-played?after=

OAuth Scopes

user-read-private user-read-email user-read-recently-played

Note: Spotify’s authorize method does not take a state parameter (unlike other providers).

Methods

MethodInputOutputDescription
authorize(callbackUrl)stringstringBuild Spotify OAuth redirect URL (no state param)
authorizeCallback(code, callbackUrl)string, stringPromise<OAuthInfo>Exchange auth code for tokens
refreshTokenAsync(refreshToken)stringPromise<OAuthInfo>Refresh an expired token
getHoursPlayedCountForDay(day, accessToken)Date, stringPromise<number>Count of tracks played on a given day
getHoursPlayedForDay(day, accessToken)Date, stringPromise<TrackModel[]>List of tracks played on a given day

TypeScript Implementation

const SPOTIFY_ACCOUNTS = 'https://accounts.spotify.com'; const SPOTIFY_API = 'https://api.spotify.com/v1'; const SPOTIFY_SCOPES = 'user-read-private user-read-email user-read-recently-played'; export class SpotifyService { private readonly clientId: string; private readonly clientSecret: string; constructor() { this.clientId = process.env.SPOTIFY_CLIENT_ID!; this.clientSecret = process.env.SPOTIFY_CLIENT_SECRET!; } private get basicAuth(): string { return `Basic ${btoa(`${this.clientId}:${this.clientSecret}`)}`; } // Note: Spotify does not use a state parameter authorize(callbackUrl: string): string { return `${SPOTIFY_ACCOUNTS}/authorize?client_id=${this.clientId}&response_type=code&redirect_uri=${encodeURIComponent(callbackUrl)}&scope=${encodeURIComponent(SPOTIFY_SCOPES)}`; } async authorizeCallback(code: string, callbackUrl: string): Promise<OAuthInfo> { const params = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: callbackUrl, }); const res = await fetch(`${SPOTIFY_ACCOUNTS}/api/token`, { method: 'POST', headers: { Authorization: this.basicAuth, 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Spotify authorizeCallback failed: ${body}`, res.status); const data = JSON.parse(body); return { accessToken: data.access_token, refreshToken: data.refresh_token, tokenType: data.token_type, expiresInSeconds: data.expires_in, expireOn: data.expires_in ? new Date(Date.now() + data.expires_in * 1000) : null, }; } async refreshTokenAsync(refreshToken: string): Promise<OAuthInfo> { const params = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken }); const res = await fetch(`${SPOTIFY_ACCOUNTS}/api/token`, { method: 'POST', headers: { Authorization: this.basicAuth, 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Spotify refreshToken failed: ${body}`, res.status); const data = JSON.parse(body); return { accessToken: data.access_token, refreshToken: data.refresh_token ?? refreshToken, expiresInSeconds: data.expires_in, expireOn: data.expires_in ? new Date(Date.now() + data.expires_in * 1000) : null, }; } // Fetch all recently played tracks for a given day (paginates via `after` cursor) async getHoursPlayedForDay(day: Date, accessToken: string): Promise<TrackModel[]> { const dayStart = new Date(day); dayStart.setUTCHours(0, 0, 0, 0); const dayEnd = new Date(day); dayEnd.setUTCHours(23, 59, 59, 999); const tracks: TrackModel[] = []; let after = dayStart.getTime(); // milliseconds unix timestamp while (true) { const res = await fetch(`${SPOTIFY_API}/me/player/recently-played?after=${after}&limit=50`, { headers: { Authorization: `Bearer ${accessToken}` }, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Spotify getHoursPlayedForDay failed: ${body}`, res.status); const data = JSON.parse(body) as { items: Array<{ track: TrackModel; played_at: string }>; cursors?: { after: string } }; if (!data.items?.length) break; for (const item of data.items) { const playedAt = new Date(item.played_at).getTime(); if (playedAt > dayEnd.getTime()) break; tracks.push(item.track); } if (!data.cursors?.after) break; after = parseInt(data.cursors.after); } return tracks; } async getHoursPlayedCountForDay(day: Date, accessToken: string): Promise<number> { const tracks = await this.getHoursPlayedForDay(day, accessToken); return tracks.length; } } // Usage: const spotify = new SpotifyService(); const redirectUrl = spotify.authorize('https://yourdomain.com/callback'); const tokens = await spotify.authorizeCallback(code, 'https://yourdomain.com/callback'); const tracks = await spotify.getHoursPlayedForDay(new Date(), tokens.accessToken); console.log(`Tracks played today: ${tracks.length}`);

Gmail (Social package)

Purpose: Read Gmail emails (read-only access) for analytics purposes.
Note: This is a different Gmail integration from the one in Channels.Google. This one uses the Google API Client library for .NET directly (not the shared GoogleServiceBase).
Service class: GmailService implements IGmailService (Social namespace)

Credentials (currently hardcoded)

The Social GmailService has hardcoded credentials in SocialProviderModule. In TypeScript, these should come from environment variables:

VariableDescription
gmailSocialClientIdGoogle OAuth Client ID
gmailSocialClientSecretGoogle OAuth Client Secret
gmailSocialConsumerKeyAdditional consumer key
gmailSocialConsumerSecretAdditional consumer secret

OAuth Scopes

https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile

Methods

MethodInputOutputDescription
authorize(callbackUrl)stringAuthorizationCodeRequestUrlReturns an auth code request URL object
authorizeCallbackAsync(code, callbackUrl)string, stringPromise<OAuthInfo>Exchange auth code for tokens
refreshTokenAsync(refreshToken)stringPromise<OAuthInfo>Refresh an expired token
getUserInfo(token)stringPromise<string>Get the authenticated user’s email address
getSentEmailsCountForDay(day, token, userId)Date, string, stringPromise<number>Count sent emails for a given day
getSentEmailsForDay(day, token, userId)Date, string, stringPromise<EmailModel[]>List sent emails for a given day

TypeScript Implementation

const GMAIL_SCOPES = [ 'https://www.googleapis.com/auth/gmail.readonly', 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', ].join(' '); const GMAIL_TOKEN_URL = 'https://oauth2.googleapis.com/token'; const GMAIL_API = 'https://gmail.googleapis.com/gmail/v1'; export class GmailSocialService { private readonly clientId: string; private readonly clientSecret: string; constructor() { this.clientId = process.env.GMAIL_SOCIAL_CLIENT_ID!; this.clientSecret = process.env.GMAIL_SOCIAL_CLIENT_SECRET!; } authorize(callbackUrl: string): string { return `https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=${this.clientId}&redirect_uri=${encodeURIComponent(callbackUrl)}&scope=${encodeURIComponent(GMAIL_SCOPES)}&access_type=offline&prompt=consent`; } async authorizeCallbackAsync(code: string, callbackUrl: string): Promise<OAuthInfo> { const params = new URLSearchParams({ grant_type: 'authorization_code', code, client_id: this.clientId, client_secret: this.clientSecret, redirect_uri: callbackUrl, }); const res = await fetch(GMAIL_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Gmail authorizeCallback failed: ${body}`, res.status); const data = JSON.parse(body); return { accessToken: data.access_token, refreshToken: data.refresh_token, expiresInSeconds: data.expires_in, expireOn: data.expires_in ? new Date(Date.now() + data.expires_in * 1000) : null, }; } async refreshTokenAsync(refreshToken: string): Promise<OAuthInfo> { const params = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: this.clientId, client_secret: this.clientSecret, }); const res = await fetch(GMAIL_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Gmail refreshToken failed: ${body}`, res.status); const data = JSON.parse(body); return { accessToken: data.access_token, refreshToken: data.refresh_token ?? refreshToken, expiresInSeconds: data.expires_in, expireOn: data.expires_in ? new Date(Date.now() + data.expires_in * 1000) : null, }; } async getUserInfo(token: string): Promise<string> { const res = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { headers: { Authorization: `Bearer ${token}` }, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Gmail getUserInfo failed: ${body}`, res.status); return JSON.parse(body).email as string; } async getSentEmailsForDay(day: Date, token: string, userId: string): Promise<EmailModel[]> { const start = new Date(day); start.setUTCHours(0, 0, 0, 0); const end = new Date(day); end.setUTCHours(23, 59, 59, 999); const q = `in:sent after:${Math.floor(start.getTime() / 1000)} before:${Math.floor(end.getTime() / 1000)}`; const listRes = await fetch(`${GMAIL_API}/users/${userId}/messages?q=${encodeURIComponent(q)}`, { headers: { Authorization: `Bearer ${token}` }, }); const listBody = await listRes.text(); if (!listRes.ok) throw new ChannelApiError(`Gmail list messages failed: ${listBody}`, listRes.status); const { messages = [] } = JSON.parse(listBody) as { messages?: Array<{ id: string }> }; const emails: EmailModel[] = []; for (const msg of messages) { const msgRes = await fetch(`${GMAIL_API}/users/${userId}/messages/${msg.id}?format=metadata&metadataHeaders=Subject,To,Date`, { headers: { Authorization: `Bearer ${token}` }, }); const msgBody = await msgRes.text(); if (!msgRes.ok) continue; emails.push(JSON.parse(msgBody) as EmailModel); } return emails; } async getSentEmailsCountForDay(day: Date, token: string, userId: string): Promise<number> { const emails = await this.getSentEmailsForDay(day, token, userId); return emails.length; } } // Usage: const gmail = new GmailSocialService(); const redirectUrl = gmail.authorize('https://yourdomain.com/callback'); const tokens = await gmail.authorizeCallbackAsync(code, 'https://yourdomain.com/callback'); const email = await gmail.getUserInfo(tokens.accessToken); const sentToday = await gmail.getSentEmailsForDay(new Date(), tokens.accessToken, 'me');

NuGet: Leadmetrics.Provider.Channels.Zoho
DI Module: ZohoChannelsProviderModule

Zoho-Specific OAuth Note

Zoho requires a accountsServer parameter on authorizeCallback and refreshTokenAsync. This is because Zoho routes different users through region-specific auth servers (e.g. https://accounts.zoho.in vs https://accounts.zoho.com). The correct accountsServer value is returned in the initial authorization redirect and must be persisted.

Common Zoho OAuth interface

interface IZohoChannelService { authorize(callbackUrl: string, state: string): string; authorizeCallback(code: string, callbackUrl: string, accountsServer: string): Promise<OAuthInfo>; refreshTokenAsync(refreshToken: string, accountsServer: string): Promise<OAuthInfo>; }

Zoho Books

Purpose: OAuth connect to Zoho Books; manage customers, invoices, and payments.
Service class: ZohoBooksService implements IZohoBooksService
Inherits: ZohoServiceBase

Environment Variables

VariableDescription
zohoAppIdZoho OAuth Client ID
zohoAppSecretZoho OAuth Client Secret

API Endpoints

EndpointURL
Base URLhttps://www.zohoapis.in/books/v3
Authorizationhttps://accounts.zoho.com/oauth/v2/auth
Access Tokenhttps://accounts.zoho.com/oauth/v2/token
Refresh Tokenhttps://accounts.zoho.com/oauth/v2/token

The base URL uses .zohoapis.in (India region). Other regions use .zohoapis.com, .zohoapis.eu, etc.

OAuth Scopes

ZohoBooks.contacts.ALL, ZohoBooks.invoices.ALL, ZohoBooks.customerpayments.ALL, ZohoBooks.settings.READ, ZohoBooks.settings.ALL

Methods

All data methods require accessToken and organizationId as the first two parameters.

Customer Methods

MethodInputOutputDescription
authorize(callbackUrl, state)string, stringstringOAuth step 1
authorizeCallback(code, callbackUrl, accountsServer)string, string, stringPromise<OAuthInfo>OAuth step 2
refreshTokenAsync(refreshToken, accountsServer)string, stringPromise<OAuthInfo>Refresh token
getAllCustomersAsync(accessToken, organizationId)string, stringPromise<ZohoContact[]>List all customer contacts
getCustomerByContactIdAsync(accessToken, organizationId, contactId)string, string, stringPromise<ZohoCustomer>Get a specific customer
createCustomerAsync(accessToken, organizationId, customer)string, string, ZohoCustomerRequestPromise<ZohoCustomer>Create a new customer
deleteCustomerAsyn(accessToken, organizationId, contactId)string, string, stringPromise<void>Delete a customer
updateCustomerNameAsync(accessToken, organizationId, contactId, contactName, companyName)string, string, string, string, stringPromise<ZohoCustomer>Update customer name/company
updateCustomerBillingAddressAsync(accessToken, organizationId, contactId, billingAddress, gstNumber, pan)string, string, string, ZohoBillingAddress, string, stringPromise<ZohoCustomer>Update billing address + tax IDs
updateCustomerTdsTaxIdAsync(accessToken, organizationId, contactId, tdsTaxId)string, string, string, stringPromise<ZohoCustomer>Update TDS tax ID

Invoice Methods

MethodInputOutputDescription
searchInvoicesAsync(accessToken, organizationId, page, perPage)string, string, number, numberPromise<ZohoInvoicesResponse>Paginated invoice list
getInvoiceByIdAsync(accessToken, organizationId, invoiceId)string, string, stringPromise<ZohoInvoice>Get invoice details
getExistingInvoiceNumbersAsync(accessToken, organizationId, query)string, string, stringPromise<string[]>Search invoice numbers by query
createInvoiceAsync(accessToken, organizationId, invoice)string, string, ZohoInvoiceRequestPromise<ZohoInvoice>Create a new invoice
updateInvoiceAsync(accessToken, organizationId, invoiceId, invoice)string, string, string, ZohoInvoiceUpdateRequestPromise<ZohoInvoice>Update an existing invoice
approveInvoiceAsync(accessToken, organizationId, invoiceId)string, string, stringPromise<void>Approve a draft invoice
markInvoiceAsSentAsync(accessToken, organizationId, invoiceId)string, string, stringPromise<void>Mark invoice as sent to customer
deleteInvoiceAsync(accessToken, organizationId, invoiceId)string, string, stringPromise<void>Delete an invoice
voidInvoiceAsync(accessToken, organizationId, invoiceId)string, string, stringPromise<void>Void (cancel) an invoice
createInvoiceLineItemAsync(accessToken, organizationId, lineItemDto)string, string, ZohoItemRequestDTOPromise<ZohoItem>Add a line item to an invoice

Payment Methods

MethodInputOutputDescription
getCustomerPaymentsForInvoiceAsync(accessToken, organizationId, invoiceId)string, string, stringPromise<ZohoInvoicePaymentsItem[]>List payments applied to an invoice
getCustomerPaymentAsync(accessToken, organizationId, paymentId)string, string, stringPromise<ZohoInvoicePayment>Get a specific payment
createCustomerPaymentAsync(accessToken, organizationId, paymentRequest)string, string, ZohoInvoicePaymentRequestPromise<ZohoInvoicePayment>Record a payment against an invoice

Tax Methods

MethodInputOutputDescription
getAllTaxesAsync(accessToken, organizationId)string, stringPromise<ZohoTax[]>List all configured tax rates

Pagination Model

interface ZohoPageContext { page: number; perPage: number; hasMorePage: boolean; reportName: string; appliedFilter: string | null; sortColumn: string; sortOrder: string; }

TypeScript Implementation

const ZOHO_BOOKS_BASE = 'https://www.zohoapis.in/books/v3'; const ZOHO_AUTH_BASE = 'https://accounts.zoho.com/oauth/v2'; const ZOHO_SCOPES = 'ZohoBooks.contacts.ALL,ZohoBooks.invoices.ALL,ZohoBooks.customerpayments.ALL,ZohoBooks.settings.READ,ZohoBooks.settings.ALL'; export class ZohoBooksService { private readonly clientId: string; private readonly clientSecret: string; constructor() { this.clientId = process.env.ZOHO_APP_ID!; this.clientSecret = process.env.ZOHO_APP_SECRET!; } private authHeader(accessToken: string) { return { Authorization: `Zoho-oauthtoken ${accessToken}`, 'Content-Type': 'application/json' }; } // Step 1 authorize(callbackUrl: string, state: string): string { return `${ZOHO_AUTH_BASE}/auth?scope=${encodeURIComponent(ZOHO_SCOPES)}&client_id=${this.clientId}&response_type=code&redirect_uri=${encodeURIComponent(callbackUrl)}&access_type=offline&state=${state}`; } // Step 2 — accountsServer is returned by Zoho in the callback (e.g. "https://accounts.zoho.in") async authorizeCallback(code: string, callbackUrl: string, accountsServer: string): Promise<OAuthInfo> { const params = new URLSearchParams({ grant_type: 'authorization_code', code, client_id: this.clientId, client_secret: this.clientSecret, redirect_uri: callbackUrl, }); const res = await fetch(`${accountsServer}/oauth/v2/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Zoho authorizeCallback failed: ${body}`, res.status); const data = JSON.parse(body); return { accessToken: data.access_token, refreshToken: data.refresh_token, expiresInSeconds: data.expires_in }; } async refreshTokenAsync(refreshToken: string, accountsServer: string): Promise<OAuthInfo> { const params = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: this.clientId, client_secret: this.clientSecret, }); const res = await fetch(`${accountsServer}/oauth/v2/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Zoho refreshToken failed: ${body}`, res.status); const data = JSON.parse(body); return { accessToken: data.access_token, expiresInSeconds: data.expires_in }; } // ── Customer methods ───────────────────────────────────────── async getAllCustomersAsync(accessToken: string, organizationId: string): Promise<ZohoContact[]> { const contacts: ZohoContact[] = []; let page = 1; while (true) { const res = await fetch(`${ZOHO_BOOKS_BASE}/contacts?contact_type=customer&page=${page}&organization_id=${organizationId}`, { headers: this.authHeader(accessToken), }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Zoho getAllCustomers failed: ${body}`, res.status); const data = JSON.parse(body) as { contacts: ZohoContact[]; page_context: ZohoPageContext }; contacts.push(...data.contacts); if (!data.page_context.hasMorePage) break; page++; } return contacts; } async getCustomerByContactIdAsync(accessToken: string, organizationId: string, contactId: string): Promise<ZohoCustomer> { const res = await fetch(`${ZOHO_BOOKS_BASE}/contacts/${contactId}?organization_id=${organizationId}`, { headers: this.authHeader(accessToken), }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Zoho getCustomer failed: ${body}`, res.status); return JSON.parse(body).contact as ZohoCustomer; } async createCustomerAsync(accessToken: string, organizationId: string, customer: ZohoCustomerRequest): Promise<ZohoCustomer> { const res = await fetch(`${ZOHO_BOOKS_BASE}/contacts?organization_id=${organizationId}`, { method: 'POST', headers: this.authHeader(accessToken), body: JSON.stringify(customer), }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Zoho createCustomer failed: ${body}`, res.status); return JSON.parse(body).contact as ZohoCustomer; } async deleteCustomerAsync(accessToken: string, organizationId: string, contactId: string): Promise<void> { const res = await fetch(`${ZOHO_BOOKS_BASE}/contacts/${contactId}?organization_id=${organizationId}`, { method: 'DELETE', headers: this.authHeader(accessToken), }); if (!res.ok) { const body = await res.text(); throw new ChannelApiError(`Zoho deleteCustomer failed: ${body}`, res.status); } } async updateCustomerNameAsync(accessToken: string, organizationId: string, contactId: string, contactName: string, companyName: string): Promise<ZohoCustomer> { const res = await fetch(`${ZOHO_BOOKS_BASE}/contacts/${contactId}?organization_id=${organizationId}`, { method: 'PUT', headers: this.authHeader(accessToken), body: JSON.stringify({ contact_name: contactName, company_name: companyName }), }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Zoho updateCustomerName failed: ${body}`, res.status); return JSON.parse(body).contact as ZohoCustomer; } async updateCustomerBillingAddressAsync(accessToken: string, organizationId: string, contactId: string, billingAddress: ZohoBillingAddress, gstNumber: string, pan: string): Promise<ZohoCustomer> { const res = await fetch(`${ZOHO_BOOKS_BASE}/contacts/${contactId}?organization_id=${organizationId}`, { method: 'PUT', headers: this.authHeader(accessToken), body: JSON.stringify({ billing_address: billingAddress, gst_no: gstNumber, pan_no: pan }), }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Zoho updateBillingAddress failed: ${body}`, res.status); return JSON.parse(body).contact as ZohoCustomer; } async updateCustomerTdsTaxIdAsync(accessToken: string, organizationId: string, contactId: string, tdsTaxId: string): Promise<ZohoCustomer> { const res = await fetch(`${ZOHO_BOOKS_BASE}/contacts/${contactId}?organization_id=${organizationId}`, { method: 'PUT', headers: this.authHeader(accessToken), body: JSON.stringify({ tds_tax_id: tdsTaxId }), }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Zoho updateTdsTaxId failed: ${body}`, res.status); return JSON.parse(body).contact as ZohoCustomer; } // ── Invoice methods ────────────────────────────────────────── async searchInvoicesAsync(accessToken: string, organizationId: string, page = 1, perPage = 25): Promise<ZohoInvoicesResponse> { const res = await fetch(`${ZOHO_BOOKS_BASE}/invoices?organization_id=${organizationId}&page=${page}&per_page=${perPage}`, { headers: this.authHeader(accessToken), }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Zoho searchInvoices failed: ${body}`, res.status); return JSON.parse(body) as ZohoInvoicesResponse; } async getInvoiceByIdAsync(accessToken: string, organizationId: string, invoiceId: string): Promise<ZohoInvoice> { const res = await fetch(`${ZOHO_BOOKS_BASE}/invoices/${invoiceId}?organization_id=${organizationId}`, { headers: this.authHeader(accessToken), }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Zoho getInvoiceById failed: ${body}`, res.status); return JSON.parse(body).invoice as ZohoInvoice; } async createInvoiceAsync(accessToken: string, organizationId: string, invoice: ZohoInvoiceRequest): Promise<ZohoInvoice> { const res = await fetch(`${ZOHO_BOOKS_BASE}/invoices?organization_id=${organizationId}`, { method: 'POST', headers: this.authHeader(accessToken), body: JSON.stringify(invoice), }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Zoho createInvoice failed: ${body}`, res.status); return JSON.parse(body).invoice as ZohoInvoice; } async updateInvoiceAsync(accessToken: string, organizationId: string, invoiceId: string, invoice: ZohoInvoiceUpdateRequest): Promise<ZohoInvoice> { const res = await fetch(`${ZOHO_BOOKS_BASE}/invoices/${invoiceId}?organization_id=${organizationId}`, { method: 'PUT', headers: this.authHeader(accessToken), body: JSON.stringify(invoice), }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Zoho updateInvoice failed: ${body}`, res.status); return JSON.parse(body).invoice as ZohoInvoice; } async approveInvoiceAsync(accessToken: string, organizationId: string, invoiceId: string): Promise<void> { const res = await fetch(`${ZOHO_BOOKS_BASE}/invoices/${invoiceId}/approve?organization_id=${organizationId}`, { method: 'POST', headers: this.authHeader(accessToken), }); if (!res.ok) { const body = await res.text(); throw new ChannelApiError(`Zoho approveInvoice failed: ${body}`, res.status); } } async markInvoiceAsSentAsync(accessToken: string, organizationId: string, invoiceId: string): Promise<void> { const res = await fetch(`${ZOHO_BOOKS_BASE}/invoices/${invoiceId}/status/sent?organization_id=${organizationId}`, { method: 'POST', headers: this.authHeader(accessToken), }); if (!res.ok) { const body = await res.text(); throw new ChannelApiError(`Zoho markInvoiceAsSent failed: ${body}`, res.status); } } async deleteInvoiceAsync(accessToken: string, organizationId: string, invoiceId: string): Promise<void> { const res = await fetch(`${ZOHO_BOOKS_BASE}/invoices/${invoiceId}?organization_id=${organizationId}`, { method: 'DELETE', headers: this.authHeader(accessToken), }); if (!res.ok) { const body = await res.text(); throw new ChannelApiError(`Zoho deleteInvoice failed: ${body}`, res.status); } } async voidInvoiceAsync(accessToken: string, organizationId: string, invoiceId: string): Promise<void> { const res = await fetch(`${ZOHO_BOOKS_BASE}/invoices/${invoiceId}/status/void?organization_id=${organizationId}`, { method: 'POST', headers: this.authHeader(accessToken), }); if (!res.ok) { const body = await res.text(); throw new ChannelApiError(`Zoho voidInvoice failed: ${body}`, res.status); } } // ── Payment methods ────────────────────────────────────────── async getCustomerPaymentsForInvoiceAsync(accessToken: string, organizationId: string, invoiceId: string): Promise<ZohoInvoicePaymentsItem[]> { const res = await fetch(`${ZOHO_BOOKS_BASE}/invoices/${invoiceId}/payments?organization_id=${organizationId}`, { headers: this.authHeader(accessToken), }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Zoho getPaymentsForInvoice failed: ${body}`, res.status); return JSON.parse(body).payments as ZohoInvoicePaymentsItem[]; } async createCustomerPaymentAsync(accessToken: string, organizationId: string, payment: ZohoInvoicePaymentRequest): Promise<ZohoInvoicePayment> { const res = await fetch(`${ZOHO_BOOKS_BASE}/customerpayments?organization_id=${organizationId}`, { method: 'POST', headers: this.authHeader(accessToken), body: JSON.stringify(payment), }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Zoho createPayment failed: ${body}`, res.status); return JSON.parse(body).payment as ZohoInvoicePayment; } async getAllTaxesAsync(accessToken: string, organizationId: string): Promise<ZohoTax[]> { const res = await fetch(`${ZOHO_BOOKS_BASE}/settings/taxes?organization_id=${organizationId}`, { headers: this.authHeader(accessToken), }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Zoho getAllTaxes failed: ${body}`, res.status); return JSON.parse(body).taxes as ZohoTax[]; } } // Usage: const zoho = new ZohoBooksService(); const redirectUrl = zoho.authorize('https://yourdomain.com/callback', 'state-xyz'); // accountsServer comes from the callback URL parameter "accounts-server" const tokens = await zoho.authorizeCallback(code, 'https://yourdomain.com/callback', 'https://accounts.zoho.in'); const customers = await zoho.getAllCustomersAsync(tokens.accessToken, 'your-org-id'); const invoice = await zoho.createInvoiceAsync(tokens.accessToken, 'your-org-id', { customer_id: customers[0].contact_id, line_items: [] });

NuGet: Leadmetrics.Provider.Channels.Microsoft
DI Module: MicrosoftChannelsProviderModule

Common Microsoft OAuth interface

interface IMicrosoftChannelService { authorize(callbackUrl: string, state: string): string; authorizeCallback(code: string, callbackUrl: string): Promise<OAuthInfo>; refreshTokenAsync(refreshToken: string): Promise<OAuthInfo>; }

How authorize builds the URL (note: callbackUrl is URL-encoded):

{userAuthorizationEndpoint} ?response_type=code &client_id={clientId} &redirect_uri={encodedCallbackUrl} &scope={scopes} &state={state}

Bing Webmaster Tools

Purpose: OAuth connect to Bing Webmaster Tools; fetch verified sites.
Service class: BingWebMasterToolsService implements IBingWebMasterToolsService
Inherits: MicrosoftServiceBase

Environment Variables

VariableDescription
bingWebMasterToolsServiceAppIdBing/Microsoft OAuth Client ID
bingWebMasterToolsServiceAppSecretBing/Microsoft OAuth Client Secret

API Endpoints

EndpointURL
Base URLhttps://ssl.bing.com/webmaster/api.svc
Authorizationhttps://www.bing.com/webmasters/OAuth/authorize
Access Tokenhttps://www.bing.com/webmasters/oauth/token
Refresh Tokenhttps://www.bing.com/webmasters/token

OAuth Scopes

webmaster.manage

Methods

MethodInputOutputDescription
authorize(callbackUrl, state)string, stringstringOAuth step 1
authorizeCallback(code, callbackUrl)string, stringPromise<OAuthInfo>OAuth step 2
refreshTokenAsync(refreshToken)stringPromise<OAuthInfo>Refresh token
getAllSites(token)stringPromise<BingSiteModel[]>List all verified sites in Bing Webmaster Tools

TypeScript Implementation

const BING_AUTH_BASE = 'https://www.bing.com/webmasters/OAuth'; const BING_API_BASE = 'https://ssl.bing.com/webmaster/api.svc/json'; export class BingWebMasterToolsService { private readonly clientId: string; private readonly clientSecret: string; constructor() { this.clientId = process.env.BING_WMT_APP_ID!; this.clientSecret = process.env.BING_WMT_APP_SECRET!; } // Step 1 authorize(callbackUrl: string, state: string): string { return `${BING_AUTH_BASE}/authorize?response_type=code&client_id=${this.clientId}&redirect_uri=${encodeURIComponent(callbackUrl)}&scope=webmaster.manage&state=${state}`; } // Step 2 async authorizeCallback(code: string, callbackUrl: string): Promise<OAuthInfo> { const params = new URLSearchParams({ grant_type: 'authorization_code', code, client_id: this.clientId, client_secret: this.clientSecret, redirect_uri: callbackUrl, }); const res = await fetch('https://www.bing.com/webmasters/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Bing authorizeCallback failed: ${body}`, res.status); const data = JSON.parse(body); return { accessToken: data.access_token, refreshToken: data.refresh_token, expiresInSeconds: data.expires_in, expireOn: data.expires_in ? new Date(Date.now() + data.expires_in * 1000) : null, }; } async refreshTokenAsync(refreshToken: string): Promise<OAuthInfo> { const params = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: this.clientId, client_secret: this.clientSecret, }); const res = await fetch('https://www.bing.com/webmasters/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Bing refreshToken failed: ${body}`, res.status); const data = JSON.parse(body); return { accessToken: data.access_token, refreshToken: data.refresh_token ?? refreshToken, expiresInSeconds: data.expires_in, expireOn: data.expires_in ? new Date(Date.now() + data.expires_in * 1000) : null, }; } async getAllSites(token: string): Promise<BingSiteModel[]> { const res = await fetch(`${BING_API_BASE}/GetUserSites`, { headers: { Authorization: `Bearer ${token}` }, }); const body = await res.text(); if (!res.ok) throw new ChannelApiError(`Bing getAllSites failed: ${body}`, res.status); return JSON.parse(body).d as BingSiteModel[]; } } // Usage: const bing = new BingWebMasterToolsService(); const redirectUrl = bing.authorize('https://yourdomain.com/callback', 'state-xyz'); const tokens = await bing.authorizeCallback(code, 'https://yourdomain.com/callback'); const sites = await bing.getAllSites(tokens.accessToken); console.log(sites.map(s => s.Url));

Each provider family defines its own exception hierarchy. The base exceptions all carry statusCode and errorCode:

Google Errors (GoogleApiBaseException)

  • GoogleApiException — API responded with an error
  • GoogleApiConnectionException — network/connectivity failure

Zoho Errors (ZohoApiBaseException)

  • ZohoServiceExceptions — Zoho API returned a non-success response
  • ZohoConnectionException — network/connectivity failure

Microsoft Errors (MicrosoftApiBaseException)

  • Single class, no sub-types currently

General Errors (from SocialServiceBase)

  • ApiException — non-successful HTTP response with message
  • UnExpectedResponseException — response cannot be deserialized

TypeScript equivalent pattern

class ChannelApiError extends Error { statusCode: number; errorCode: number; constructor(message: string, statusCode: number, errorCode?: number) { super(message); this.statusCode = statusCode; this.errorCode = errorCode ?? statusCode; } } class ChannelConnectionError extends ChannelApiError {}

Environment Variable Summary

VariableProviderDescription
gmailAppIdGmail (Google)OAuth Client ID
gmailAppSecretGmail (Google)OAuth Client Secret
googleAdsAppIdGoogle AdsOAuth Client ID
googleAdsAppSecretGoogle AdsOAuth Client Secret
googleAdsDeveloperTokenGoogle AdsDeveloper token (sent as request header)
googleAnalyticsAppIdGoogle AnalyticsOAuth Client ID
googleAnalyticsAppSecretGoogle AnalyticsOAuth Client Secret
googleMyBusinessAppIdGoogle Business ProfileOAuth Client ID
googleMyBusinessAppSecretGoogle Business ProfileOAuth Client Secret
googleSearchConsoleAppIdGoogle Search ConsoleOAuth Client ID
googleSearchConsoleAppSecretGoogle Search ConsoleOAuth Client Secret
facebookAppIdFacebook / Instagram (shared)App ID
facebookAppSecretFacebook / Instagram (shared)App Secret
instagramAppIdInstagramApp ID
instagramAppSecretInstagramApp Secret
linkedInAppIdLinkedInClient ID
linkedInAppSecretLinkedInClient Secret
zohoAppIdZoho BooksOAuth Client ID
zohoAppSecretZoho BooksOAuth Client Secret
bingWebMasterToolsServiceAppIdBing Webmaster ToolsOAuth Client ID
bingWebMasterToolsServiceAppSecretBing Webmaster ToolsOAuth Client Secret

Hardcoded credentials (should be moved to env vars in TypeScript):
Twitter, Spotify, and Social Gmail currently have hardcoded credentials in the DI module. In TypeScript implementations they should be read from environment variables.


TypeScript Migration Notes

1. Replace Autofac DI with a simple factory or tsyringe/inversify

In .NET, each service is registered via Autofac modules. In TypeScript, a simple factory function per provider works well:

function createGoogleAdsService(): IGoogleAdsService { return new GoogleAdsService({ clientId: process.env.GOOGLE_ADS_APP_ID!, clientSecret: process.env.GOOGLE_ADS_APP_SECRET!, developerToken: process.env.GOOGLE_ADS_DEVELOPER_TOKEN!, }); }

2. ExecuteSafely pattern

The .NET base class wraps every async operation in a try/catch that returns null (default) on error. In TypeScript, prefer explicit error propagation — only swallow errors where returning null | undefined is a documented behaviour.

3. Token expiry tracking

Always compute expireOn = new Date(Date.now() + (expiresIn * 1000)) when storing tokens. Before each API call, check Date.now() >= expireOn.getTime() - 60_000 (60-second buffer) and refresh proactively.

4. Zoho region-aware tokens

Zoho returns an accounts_server in the initial callback payload. This must be stored alongside the tokens and passed to every refreshTokenAsync call. Do not assume accounts.zoho.com — always use the stored server.

5. LinkedIn cache dependency

LinkedInService uses ICacheManager (Redis) internally. When porting to TypeScript, you will need to manage token caching explicitly (e.g. store access token in Redis/memory with TTL matching expiresIn).

6. Twitter dual-flow

Twitter is the only provider supporting two completely different OAuth flows. If you only need one, prefer OAuth 2.0 (authorize2 / authorizeCallback / refreshToken) for new integrations as it is simpler. OAuth 1.0a is kept for backward compatibility.

7. Pagination

Zoho Books uses explicit page/perPage cursors (no cursor token). Google Ads and Search Console use nextPageToken (string nullable). LinkedIn and Google Analytics have no explicit pagination on analytics endpoints — they return all rows within the requested date range.

© 2026 Leadmetrics — Internal use only