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
- Architecture Overview
- Core Shared Types
- Package: Channels.Google
- Package: Channels.Social
- Package: Channels.Zoho
- Package: Channels.Microsoft
- Error Handling
- Environment Variable Summary
- 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
└── SpotifyServiceEvery 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:
expireOnis always computed server-side asUTC now + expiresIn. Store this and compareDate.now() >= expireOnto 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=offlineandprompt=consentensure arefresh_tokenis 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
| Variable | Description |
|---|---|
gmailAppId | Google OAuth Client ID for Gmail app |
gmailAppSecret | Google OAuth Client Secret for Gmail app |
API Endpoints
| Endpoint | URL |
|---|---|
| Base URL | https://gmail.googleapis.com |
| Authorization | https://accounts.google.com/o/oauth2/v2/auth |
| Access Token | https://oauth2.googleapis.com/token |
| Refresh Token | https://www.googleapis.com/oauth2/v4/token |
OAuth Scopes
https://www.googleapis.com/auth/gmail.sendMethods
IGmailService only exposes the 3 inherited OAuth methods (no additional data methods):
| Method | Input | Output | Description |
|---|---|---|---|
authorize(callbackUrl, state) | string, string | string (redirect URL) | Step 1 of OAuth |
authorizeCallback(code, callbackUrl) | string, string | Promise<OAuthInfo> | Exchange auth code for tokens |
refreshTokenAsync(refreshToken) | string | Promise<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);Google Ads
Purpose: OAuth connect to Google Ads; fetch campaign/keyword/performance data.
Service class: GoogleAdsService implements IGoogleAdsService
Inherits: GoogleServiceBase
Environment Variables
| Variable | Description |
|---|---|
googleAdsAppId | Google OAuth Client ID for Ads |
googleAdsAppSecret | Google OAuth Client Secret for Ads |
googleAdsDeveloperToken | Google Ads developer token (required on every API call as a header) |
API Endpoints
| Endpoint | URL |
|---|---|
| Base URL | https://googleads.googleapis.com/v20 |
| Authorization | https://accounts.google.com/o/oauth2/v2/auth |
| Access Token | https://oauth2.googleapis.com/token |
| Refresh Token | https://www.googleapis.com/oauth2/v4/token |
OAuth Scopes
https://www.googleapis.com/auth/adwordsImportant: Every API call sends two headers:
Authorization: Bearer {token}anddeveloper-token: {googleAdsDeveloperToken}.
Methods
| Method | Input | Output | Description |
|---|---|---|---|
authorize(callbackUrl, state) | string, string | string | OAuth step 1 |
authorizeCallback(code, callbackUrl) | string, string | Promise<OAuthInfo> | OAuth step 2 |
refreshTokenAsync(refreshToken) | string | Promise<OAuthInfo> | Refresh token |
getAllCustomers(token) | string | Promise<GoogleAdsCustomer[]> | List all accessible top-level ad accounts |
getCustomerHierarchyDetails(customerId, token) | string, string | Promise<GoogleAdsCustomer[]> | Get the full manager/sub-account hierarchy for a customer |
getCustomerDetails(customerId, parentId, token) | string, string, string | Promise<GoogleAdsCustomer> | Get a single customer (account) details |
getCampaignsAsLookUp(customerId, parentId, token) | string, string, string | Promise<GoogleAdsCampaignsLookUpItem[]> | List campaigns as ID/name lookup pairs |
getMonthlyKeywordMetrics(customerId, parentId, keywords, token) | string, string, string[], string | Promise<GoogleAdsKeywordsMetricsModel> | Get impressions/clicks/etc for a list of keywords |
getGoogleAdsSearchTerms(customerId, parentId, campaignId, token, nextPageToken, pageNumber) | string, string, string, string, string, number | Promise<GoogleAdsSearchTermsModel> | Paginated list of search terms for a campaign |
getGoogleAdsSearchTermsByDate(customerId, parentId, campaignId, campaignDate, token, nextPageToken, pageNumber) | string, string, string, Date, string, string, number | Promise<GoogleAdsSearchTermsModel> | Search terms filtered by date |
getGoogleAdsPositiveKeywords(customerId, parentId, campaignId, token, nextPageToken, pageNumber) | string, string, string, string, string, number | Promise<GoogleAdsPositiveKeywordsModel> | Paginated list of positive (targeted) keywords |
getGoogleAdsCampaignLevelNegativeKeywords(customerId, parentId, campaignId, token, nextPageToken, pageNumber) | string, string, string, string, string, number | Promise<GoogleAdsCampaignNegativeKeywordsModel> | Campaign-level negative keywords |
getKeywordMetricsByKeyword(customerId, parentId, token, inputDTO) | string, string, string, GoogleAdsKeywordMetricsInputDTO | Promise<GoogleAdsKeywordMetricsResponseDTO> | Clicks/impressions/CTR for a single keyword in a date range |
getGoogleAdsRecommendations(customerId, parentId, token, campaignId) | string, string, string, string | Promise<GoogleAdsRecommendationsModel> | Google Ads optimization recommendations |
getCustomerLevelLocationAssetPerformanceAsync(customerId, parentId, token, nextPageToken, startDate, endDate) | string, string, string, string|null, Date, Date | Promise<GoogleAdsLocationAssetPerformanceModel> | Location asset performance at customer level |
getCampaignLevelLocationAssetPerformanceAsync(customerId, parentId, token, nextPageToken, startDate, endDate) | string, string, string, string|null, Date, Date | Promise<GoogleAdsLocationAssetPerformanceModel> | Location asset performance at campaign level |
getAuctionInsightsAsync(customerId, parentId, token, nextPageToken, startDate, endDate) | string, string, string, string|null, Date, Date | Promise<GoogleAdsAuctionInsightsModel> | Auction insights (competitive metrics) |
getPPCPerformanceAsync(customerId, parentId, token, startDate, endDate) | string, string, string, Date, Date | Promise<GoogleAdsPPCPerformanceResponse> | Full PPC performance summary |
uploadCampaignNegativeKeywords(customerId, parentId, campaignId, negativeKeywords, token) | string, string, string, GoogleAdsKeywordInfo[], string | Promise<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
| Variable | Description |
|---|---|
googleAnalyticsAppId | OAuth Client ID |
googleAnalyticsAppSecret | OAuth Client Secret |
API Endpoints
| Endpoint | URL |
|---|---|
| Base URL | https://analyticsadmin.googleapis.com/v1beta/ |
| Authorization | https://accounts.google.com/o/oauth2/v2/auth |
| Access Token | https://oauth2.googleapis.com/token |
| Refresh Token | https://www.googleapis.com/oauth2/v4/token |
OAuth Scopes
https://www.googleapis.com/auth/analytics.readonlyMethods
| Method | Input | Output | Description |
|---|---|---|---|
authorize(callbackUrl, state) | string, string | string | OAuth step 1 |
authorizeCallback(code, callbackUrl) | string, string | Promise<OAuthInfo> | OAuth step 2 |
refreshTokenAsync(refreshToken) | string | Promise<OAuthInfo> | Refresh token |
getAllAccounts(token) | string | Promise<GoogleAnalyticsAccount[]> | List all GA4 accounts the user has access to |
getOrganicTrafficMetrics(propertyId, token, startDate, endDate) | string, string, Date, Date | Promise<GAOrganicTrafficMetrics> | Overall organic traffic summary |
getDailyOrganicTrafficMetrics(propertyId, token, startDate, endDate) | string, string, Date, Date | Promise<GADailyOrganicTrafficMetrics[]> | Day-by-day organic traffic breakdown |
getSessionsByChannelGroupsOverTime(propertyId, token, startDate, endDate, organicOnly?) | string, string, Date, Date, boolean | Promise<GAChannelSessionsOverTime[]> | Sessions per channel group over time |
getTrafficAcquisitionWithEventsAsync(propertyId, token, startDate, endDate, channels?, organicOnly?) | string, string, Date, Date, string[]?, boolean | Promise<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, boolean | Promise<GACountryWiseTrafficResult> | Traffic by country |
getActiveUsersByCountryOverTimeAsync(propertyId, token, startDate, endDate, organicOnly?) | string, string, Date, Date, boolean | Promise<GAActiveUsersByCountryAndDate[]> | Active users per country per date |
getActiveUsersByCityAsync(propertyId, token, startDate, endDate, organicOnly?) | string, string, Date, Date, boolean | Promise<GAActiveUsersByCity[]> | Active users by city |
getActiveUsersByGenderAsync(propertyId, token, startDate, endDate, organicOnly?) | string, string, Date, Date, boolean | Promise<GAActiveUsersByGender[]> | Users split by gender |
getActiveUsersByAgeAsync(propertyId, token, startDate, endDate, organicOnly?) | string, string, Date, Date, boolean | Promise<GAActiveUsersByAge[]> | Users split by age bracket |
getActiveUsersByInterestsAsync(propertyId, token, startDate, endDate, organicOnly?) | string, string, Date, Date, boolean | Promise<GAActiveUsersByInterests[]> | Users split by interest category |
getTotalWebsiteTrafficAsync(propertyId, token, startDate, endDate, organicOnly?) | string, string, Date, Date, boolean | Promise<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
| Variable | Description |
|---|---|
googleMyBusinessAppId | OAuth Client ID |
googleMyBusinessAppSecret | OAuth Client Secret |
API Endpoints
| Endpoint | URL |
|---|---|
| Base URL | https://mybusiness.googleapis.com/v4/ |
| Authorization | https://accounts.google.com/o/oauth2/v2/auth |
| Access Token | https://oauth2.googleapis.com/token |
| Refresh Token | https://www.googleapis.com/oauth2/v4/token |
OAuth Scopes
https://www.googleapis.com/auth/business.manageMethods
| Method | Input | Output | Description |
|---|---|---|---|
authorize(callbackUrl, state) | string, string | string | OAuth step 1 |
authorizeCallback(code, callbackUrl) | string, string | Promise<OAuthInfo> | OAuth step 2 |
refreshTokenAsync(refreshToken) | string | Promise<OAuthInfo> | Refresh token |
getAllLocations(token) | string | Promise<GBPLocation[]> | Get all locations across all accounts |
getAllLocationsForAccount(accountId, token) | string, string | Promise<GBPLocation[]> | Get locations for a specific account |
getMultiDailyMetrics(locationId, startDate, endDate, token) | string, Date, Date, string | Promise<GBPMultiDailyMetricsResponse> | Daily impressions, clicks, calls, etc. |
getSearchKeywordsImpressions(locationId, startDate, endDate, token) | string, Date, Date, string | Promise<GBPSearchKeywordImpression[]> | Search queries that showed the location |
createPostAsync(accountId, locationId, token, model) | string, string, string, GBPPostRequestModel | Promise<GBPPostResponse> | Create a Google Business post |
getAllReviews(locationId, accountId, token, pages?) | string, string, string, number | Promise<GBPReviewsResponse> | Paginated list of reviews (default: 1 page) |
postReviewReplyAsync(locationId, accountId, reviewId, replyText, token) | string, string, string, string, string | Promise<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
| Variable | Description |
|---|---|
googleSearchConsoleAppId | OAuth Client ID |
googleSearchConsoleAppSecret | OAuth Client Secret |
API Endpoints
| Endpoint | URL |
|---|---|
| Base URL | https://www.googleapis.com/webmasters/v3 |
| Authorization | https://accounts.google.com/o/oauth2/v2/auth |
| Access Token | https://oauth2.googleapis.com/token |
| Refresh Token | https://www.googleapis.com/oauth2/v4/token |
OAuth Scopes
https://www.googleapis.com/auth/webmastersMethods
| Method | Input | Output | Description |
|---|---|---|---|
authorize(callbackUrl, state) | string, string | string | OAuth step 1 |
authorizeCallback(code, callbackUrl) | string, string | Promise<OAuthInfo> | OAuth step 2 |
refreshTokenAsync(refreshToken) | string | Promise<OAuthInfo> | Refresh token |
getUserInfo(token) | string | Promise<string> | Verify token and get user email |
getAllSites(token) | string | Promise<GoogleSiteModel[]> | List verified sites in Search Console |
getStats(site, token, inputDTO, size?) | string, string, GSCMetricsInputDTO, number | Promise<GSCMetricsResponseDTO> | Overall clicks/impressions/position over time |
getPageMetrics(site, token, inputDTO) | string, string, PageMetricsInputDTO | Promise<PageMetricsResponseDTO> | Metrics for a specific URL |
getStatsByPage(site, token, inputDTO, size) | string, string, GSCPageMetricsInputDTO, number | Promise<GSCPageMetricsResponseDTO> | Time-series metrics for a specific page |
getKeywordStats(site, token, startDate, endDate) | string, string, Date, Date | Promise<SearchQueryStatsModel[]> | All keyword stats in a date range |
getStatsByKeyword(site, token, inputDTO, size) | string, string, GSCKeywordMetricsInputDTO, number | Promise<GSCKeywordMetricsResponseDTO> | Time-series data for a specific keyword |
getAllKeywordStats(site, token) | string, string | Promise<SearchQueryStatsModel[]> | All-time keyword stats (no date filter) |
getLatestPopularKeywords(site, token, timeWindow, keywordsCount) | string, string, number, number | Promise<string[]> | Most popular queries in last N days |
searchQueries(site, token, inputDTO) | string, string, GSCSearchFilterInputDTO | Promise<GSCKeywordMetricsDTO[]> | Paginated/filtered query search |
searchPages(site, token, inputDTO) | string, string, GSCSearchFilterInputDTO | Promise<PageMetricsResponseDTO[]> | Paginated/filtered page search |
getPagesStats(dimensions, rowSize, startDate, endDate, site, token) | string[], number, Date, Date, string, string | Promise<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(notChannels.Social) for the concrete services.
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
| Variable | Description |
|---|---|
facebookAppId | Facebook App ID |
facebookAppSecret | Facebook App Secret |
API Endpoints
| Endpoint | URL |
|---|---|
| Base URL | https://graph.facebook.com |
| Authorization | https://www.facebook.com/dialog/oauth |
| Access Token | https://graph.facebook.com/oauth/access_token |
OAuth Scopes
public_profile, pages_show_list, pages_read_engagement, pages_manage_posts, publish_videoMethods
| Method | Input | Output | Description |
|---|---|---|---|
authorize(callbackUrl, state) | string, string | string | Build the Facebook OAuth redirect URL |
authorizeCallback(code, callbackUrl) | string, string | Promise<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, string | Promise<FacebookToken> | Inspect validity and details of a token |
getLongLivedToken(shortToken) | string | Promise<OAuthInfo> | Exchange a short-lived token for a 60-day token |
getFacebookPages(token) | string | Promise<FacebookSearchResult<FacebookPageInfo>> | List all Facebook pages managed by the user |
getPageToken(pageId, token) | string, string | Promise<FacebookPageToken> | Get the page-scoped token for a specific page |
getUserInfo(token) | FacebookToken | Promise<FacebookUserInfo> | Get authenticated user’s profile info |
getUserLikesCountForDay(day, token) | Date, string | Promise<number> | Count of likes on the user’s account for a day |
getUserLikesForDay(day, token) | Date, string | Promise<FacebookLikeModel[]> | List of likes for a day |
getUserPostsCountForDay(day, token) | Date, string | Promise<number> | Count of posts for a day |
getUserPostsForDay(day, token) | Date, string | Promise<FacebookPostModel[]> | List of posts for a day |
createPost(post, token) | NewFacebookPost, string | Promise<NewFacebookPost> | Create a post on the user’s personal profile |
createPagePost(pageId, post, token) | string, NewSocialPostRequestModel, string | Promise<NewSocialPostResponseModel> | Create a text/image post on a page |
createPageVideoPost(pageId, post, token) | string, NewSocialPostRequestModel, string | Promise<NewSocialPostResponseModel> | Upload and publish a video post on a page |
getPostMetrics(postId, token) | string, string | Promise<FacebookPostMetrics> | Impressions, clicks, reactions for a post |
getPageMetrics(pageId, token) | string, string | Promise<FacebookPageMetrics> | Aggregate metrics for a page |
getMetaCampaignPerformanceSummaryAsync(adAccountId, accessToken, startDate, endDate) | string, string, Date, Date | Promise<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);Purpose: OAuth connect to Instagram Business Account (via Facebook graph); manage and publish posts/stories.
Service class: InstagramService implements IInstagramService
Environment Variables
| Variable | Description |
|---|---|
instagramAppId | Instagram App ID (same as Facebook App ID for Business accounts) |
instagramAppSecret | Instagram App Secret |
API Endpoints
| Endpoint | URL |
|---|---|
| Base URL | https://graph.instagram.com |
| Authorization | https://www.instagram.com/oauth/authorize |
| Access Token | https://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_insightsMethods
| Method | Input | Output | Description |
|---|---|---|---|
authorize(callbackUrl, state) | string, string | string | Build Instagram OAuth redirect URL |
authorizeCallback(code, callbackUrl) | string, string | Promise<OAuthInfo> | Exchange auth code for short-lived token |
getInstagramAccountFromFacebookPage(pageId, token) | string, string | Promise<string> | Resolve the Instagram Business Account ID linked to a Facebook page |
getLongLivedToken(shortToken) | string | Promise<OAuthInfo> | Upgrade to a 60-day token |
refreshToken(token) | string | Promise<OAuthInfo> | Refresh a long-lived token (before it expires) |
getUserLikesCountForDay(day, token) | Date, string | Promise<number> | Count likes for a day |
getUserLikesForDay(day, token) | Date, string | Promise<InstagramLikeModel[]> | List likes for a day |
getUserPostsCountForDay(day, token) | Date, string | Promise<number> | Count posts for a day |
getUserPostsForDay(day, token) | Date, string | Promise<InstagramPostModel[]> | List posts for a day |
createCarouselPost(userId, post, token) | string, NewSocialPostRequestModel, string | Promise<NewSocialPostResponseModel> | Publish a multi-image carousel post |
createImagePost(userId, post, token) | string, NewSocialPostRequestModel, string | Promise<NewSocialPostResponseModel> | Publish a single image post |
createStoryImages(userId, post, token) | string, NewSocialPostRequestModel, string | Promise<NewSocialPostResponseModel> | Publish an image story |
createStoryVideo(userId, post, token) | string, NewSocialPostRequestModel, string | Promise<NewSocialPostResponseModel> | Publish a video story |
createVideoPosts(userId, post, token) | string, NewSocialPostRequestModel, string | Promise<NewSocialPostResponseModel> | Publish a video feed post |
getPostMetrics(postId, token) | string, string | Promise<InstaPostMetrics> | Impressions, reach, likes, comments for a post |
getAccountMetrics(accountId, token) | string, string | Promise<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);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
| Variable | Description |
|---|---|
linkedInAppId | LinkedIn App Client ID |
linkedInAppSecret | LinkedIn App Client Secret |
LinkedInServicealso requiresICacheManager(Redis). RegisterRedisCacheModulein the DI container before this service.
API Endpoints
| Endpoint | URL |
|---|---|
| Base URL | https://api.linkedin.com |
| Authorization | https://www.linkedin.com/oauth/v2/authorization |
| Access Token | https://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_socialMethods
| Method | Input | Output | Description |
|---|---|---|---|
authorize(callbackUrl, state) | string, string | string | Build LinkedIn OAuth redirect URL |
authorizeCallback(code, callbackUrl) | string, string | Promise<OAuthInfo> | Exchange auth code for access token |
getLinkedInPages(token) | string | Promise<PageInfo[]> | List all organization pages the user administers |
createPageShare(pageId, share, token) | string, NewShare, string | Promise<LinkedInPost> | Legacy share endpoint (text + link) |
createPost(pageId, model, token) | string, NewSocialPostRequestModel, string | Promise<NewSocialPostResponseModel> | Create a post on an organization page |
getPageStatisticsAsync(organizationUrn, startDate, endDate, timeGranularity, token) | string, Date, Date, string, string | Promise<LinkedInPageStatisticsModel> | Page impressions/clicks/engagement over time |
getCurrentFollowersCountAsync(organizationUrn, token) | string, string | Promise<number> | Current total follower count |
getFollowersCountByDateAsync(organizationUrn, date, token) | string, Date, string | Promise<number> | Follower count at a specific date |
getFollowersGainStatisticsAsync(organizationUrn, startDate, endDate, timeGranularity, token) | string, Date, Date, string, string | Promise<LinkedInFollowersGainStatisticsModel> | Follower gains over a period |
getFollowersStatisticsAsync(organizationUrn, startDate, endDate, timeGranularity, token) | string, Date, Date, string, string | Promise<LinkedInFollowersStatisticsModel> | Follower demographic breakdown |
getShareStatisticsAsync(organizationUrn, startDate, endDate, timeGranularity, token) | string, Date, Date, string, string | Promise<LinkedInShareStatisticsModel> | Post shares, comments, reactions breakdown |
timeGranularityvalues:"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:
| Variable | Description |
|---|---|
twitterApiKey | Twitter/X API Key (Consumer Key) — currently hardcoded |
twitterApiSecret | Twitter/X API Secret (Consumer Secret) — currently hardcoded |
API Endpoints
| Endpoint | URL |
|---|---|
| Base URL | https://api.twitter.com/ |
| OAuth 1.0a Authorization | https://api.twitter.com/oauth/authorize |
| OAuth 1.0a Request Token | https://api.twitter.com/oauth/request_token |
| OAuth 2.0 Access Token | https://api.twitter.com/2/oauth2/token |
OAuth Scopes (OAuth 2.0)
tweet.read%20tweet.write%20users.read%20offline.accessOAuth 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) → OAuthInfoMethods
| Method | Input | Output | Description |
|---|---|---|---|
requestToken(callbackUrl) | string | Promise<RequestTokenInfo> | OAuth 1.0a Step 1: Get a request token |
authorize(oauthToken) | string | string | OAuth 1.0a Step 2: Build redirect URL using request token |
authorize2(callbackUrl, state) | string, string | string | OAuth 2.0 Step 1: Build PKCE redirect URL |
accessToken(token, verifier, state) | string, string, string | Promise<OAuthInfo> | OAuth 1.0a Step 3: Exchange verifier for tokens |
authorizeCallback(code, callbackUrl, state) | string, string, string | Promise<OAuthInfo> | OAuth 2.0 Step 2: Exchange auth code for tokens |
refreshToken(refreshToken) | string | Promise<OAuthInfo> | Refresh an OAuth 2.0 token |
createTweetV2(post, token) | NewTweet, string | Promise<NewTweet> | Post a tweet using OAuth 2.0 Bearer token |
createTweet(post, token, tokenSecret) | NewTweet, string, string | Promise<NewTweet> | Post a tweet using OAuth 1.0a (requires both token and secret) |
getTweetMetrics(tweetId, token, tokenSecret) | string, string, string | Promise<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:
| Variable | Description |
|---|---|
spotifyClientId | Spotify App Client ID |
spotifyClientSecret | Spotify App Client Secret |
API Endpoints
| Endpoint | URL |
|---|---|
| Base URL (auth) | https://accounts.spotify.com |
| Authorization | https://accounts.spotify.com/authorize |
| Recently Played | https://api.spotify.com/v1/me/player/recently-played?after= |
OAuth Scopes
user-read-private user-read-email user-read-recently-playedNote: Spotify’s
authorizemethod does not take astateparameter (unlike other providers).
Methods
| Method | Input | Output | Description |
|---|---|---|---|
authorize(callbackUrl) | string | string | Build Spotify OAuth redirect URL (no state param) |
authorizeCallback(code, callbackUrl) | string, string | Promise<OAuthInfo> | Exchange auth code for tokens |
refreshTokenAsync(refreshToken) | string | Promise<OAuthInfo> | Refresh an expired token |
getHoursPlayedCountForDay(day, accessToken) | Date, string | Promise<number> | Count of tracks played on a given day |
getHoursPlayedForDay(day, accessToken) | Date, string | Promise<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:
| Variable | Description |
|---|---|
gmailSocialClientId | Google OAuth Client ID |
gmailSocialClientSecret | Google OAuth Client Secret |
gmailSocialConsumerKey | Additional consumer key |
gmailSocialConsumerSecret | Additional consumer secret |
OAuth Scopes
https://www.googleapis.com/auth/gmail.readonly
https://www.googleapis.com/auth/userinfo.email
https://www.googleapis.com/auth/userinfo.profileMethods
| Method | Input | Output | Description |
|---|---|---|---|
authorize(callbackUrl) | string | AuthorizationCodeRequestUrl | Returns an auth code request URL object |
authorizeCallbackAsync(code, callbackUrl) | string, string | Promise<OAuthInfo> | Exchange auth code for tokens |
refreshTokenAsync(refreshToken) | string | Promise<OAuthInfo> | Refresh an expired token |
getUserInfo(token) | string | Promise<string> | Get the authenticated user’s email address |
getSentEmailsCountForDay(day, token, userId) | Date, string, string | Promise<number> | Count sent emails for a given day |
getSentEmailsForDay(day, token, userId) | Date, string, string | Promise<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
| Variable | Description |
|---|---|
zohoAppId | Zoho OAuth Client ID |
zohoAppSecret | Zoho OAuth Client Secret |
API Endpoints
| Endpoint | URL |
|---|---|
| Base URL | https://www.zohoapis.in/books/v3 |
| Authorization | https://accounts.zoho.com/oauth/v2/auth |
| Access Token | https://accounts.zoho.com/oauth/v2/token |
| Refresh Token | https://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.ALLMethods
All data methods require accessToken and organizationId as the first two parameters.
Customer Methods
| Method | Input | Output | Description |
|---|---|---|---|
authorize(callbackUrl, state) | string, string | string | OAuth step 1 |
authorizeCallback(code, callbackUrl, accountsServer) | string, string, string | Promise<OAuthInfo> | OAuth step 2 |
refreshTokenAsync(refreshToken, accountsServer) | string, string | Promise<OAuthInfo> | Refresh token |
getAllCustomersAsync(accessToken, organizationId) | string, string | Promise<ZohoContact[]> | List all customer contacts |
getCustomerByContactIdAsync(accessToken, organizationId, contactId) | string, string, string | Promise<ZohoCustomer> | Get a specific customer |
createCustomerAsync(accessToken, organizationId, customer) | string, string, ZohoCustomerRequest | Promise<ZohoCustomer> | Create a new customer |
deleteCustomerAsyn(accessToken, organizationId, contactId) | string, string, string | Promise<void> | Delete a customer |
updateCustomerNameAsync(accessToken, organizationId, contactId, contactName, companyName) | string, string, string, string, string | Promise<ZohoCustomer> | Update customer name/company |
updateCustomerBillingAddressAsync(accessToken, organizationId, contactId, billingAddress, gstNumber, pan) | string, string, string, ZohoBillingAddress, string, string | Promise<ZohoCustomer> | Update billing address + tax IDs |
updateCustomerTdsTaxIdAsync(accessToken, organizationId, contactId, tdsTaxId) | string, string, string, string | Promise<ZohoCustomer> | Update TDS tax ID |
Invoice Methods
| Method | Input | Output | Description |
|---|---|---|---|
searchInvoicesAsync(accessToken, organizationId, page, perPage) | string, string, number, number | Promise<ZohoInvoicesResponse> | Paginated invoice list |
getInvoiceByIdAsync(accessToken, organizationId, invoiceId) | string, string, string | Promise<ZohoInvoice> | Get invoice details |
getExistingInvoiceNumbersAsync(accessToken, organizationId, query) | string, string, string | Promise<string[]> | Search invoice numbers by query |
createInvoiceAsync(accessToken, organizationId, invoice) | string, string, ZohoInvoiceRequest | Promise<ZohoInvoice> | Create a new invoice |
updateInvoiceAsync(accessToken, organizationId, invoiceId, invoice) | string, string, string, ZohoInvoiceUpdateRequest | Promise<ZohoInvoice> | Update an existing invoice |
approveInvoiceAsync(accessToken, organizationId, invoiceId) | string, string, string | Promise<void> | Approve a draft invoice |
markInvoiceAsSentAsync(accessToken, organizationId, invoiceId) | string, string, string | Promise<void> | Mark invoice as sent to customer |
deleteInvoiceAsync(accessToken, organizationId, invoiceId) | string, string, string | Promise<void> | Delete an invoice |
voidInvoiceAsync(accessToken, organizationId, invoiceId) | string, string, string | Promise<void> | Void (cancel) an invoice |
createInvoiceLineItemAsync(accessToken, organizationId, lineItemDto) | string, string, ZohoItemRequestDTO | Promise<ZohoItem> | Add a line item to an invoice |
Payment Methods
| Method | Input | Output | Description |
|---|---|---|---|
getCustomerPaymentsForInvoiceAsync(accessToken, organizationId, invoiceId) | string, string, string | Promise<ZohoInvoicePaymentsItem[]> | List payments applied to an invoice |
getCustomerPaymentAsync(accessToken, organizationId, paymentId) | string, string, string | Promise<ZohoInvoicePayment> | Get a specific payment |
createCustomerPaymentAsync(accessToken, organizationId, paymentRequest) | string, string, ZohoInvoicePaymentRequest | Promise<ZohoInvoicePayment> | Record a payment against an invoice |
Tax Methods
| Method | Input | Output | Description |
|---|---|---|---|
getAllTaxesAsync(accessToken, organizationId) | string, string | Promise<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
| Variable | Description |
|---|---|
bingWebMasterToolsServiceAppId | Bing/Microsoft OAuth Client ID |
bingWebMasterToolsServiceAppSecret | Bing/Microsoft OAuth Client Secret |
API Endpoints
| Endpoint | URL |
|---|---|
| Base URL | https://ssl.bing.com/webmaster/api.svc |
| Authorization | https://www.bing.com/webmasters/OAuth/authorize |
| Access Token | https://www.bing.com/webmasters/oauth/token |
| Refresh Token | https://www.bing.com/webmasters/token |
OAuth Scopes
webmaster.manageMethods
| Method | Input | Output | Description |
|---|---|---|---|
authorize(callbackUrl, state) | string, string | string | OAuth step 1 |
authorizeCallback(code, callbackUrl) | string, string | Promise<OAuthInfo> | OAuth step 2 |
refreshTokenAsync(refreshToken) | string | Promise<OAuthInfo> | Refresh token |
getAllSites(token) | string | Promise<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 errorGoogleApiConnectionException— network/connectivity failure
Zoho Errors (ZohoApiBaseException)
ZohoServiceExceptions— Zoho API returned a non-success responseZohoConnectionException— network/connectivity failure
Microsoft Errors (MicrosoftApiBaseException)
- Single class, no sub-types currently
General Errors (from SocialServiceBase)
ApiException— non-successful HTTP response with messageUnExpectedResponseException— 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
| Variable | Provider | Description |
|---|---|---|
gmailAppId | Gmail (Google) | OAuth Client ID |
gmailAppSecret | Gmail (Google) | OAuth Client Secret |
googleAdsAppId | Google Ads | OAuth Client ID |
googleAdsAppSecret | Google Ads | OAuth Client Secret |
googleAdsDeveloperToken | Google Ads | Developer token (sent as request header) |
googleAnalyticsAppId | Google Analytics | OAuth Client ID |
googleAnalyticsAppSecret | Google Analytics | OAuth Client Secret |
googleMyBusinessAppId | Google Business Profile | OAuth Client ID |
googleMyBusinessAppSecret | Google Business Profile | OAuth Client Secret |
googleSearchConsoleAppId | Google Search Console | OAuth Client ID |
googleSearchConsoleAppSecret | Google Search Console | OAuth Client Secret |
facebookAppId | Facebook / Instagram (shared) | App ID |
facebookAppSecret | Facebook / Instagram (shared) | App Secret |
instagramAppId | App ID | |
instagramAppSecret | App Secret | |
linkedInAppId | Client ID | |
linkedInAppSecret | Client Secret | |
zohoAppId | Zoho Books | OAuth Client ID |
zohoAppSecret | Zoho Books | OAuth Client Secret |
bingWebMasterToolsServiceAppId | Bing Webmaster Tools | OAuth Client ID |
bingWebMasterToolsServiceAppSecret | Bing Webmaster Tools | OAuth 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.