← back to Interiordesignershowroom
lib/adapters/shareasale.js
236 lines
// ShareASale product-search adapter (ShareASale / Awin / Impact family).
//
// WHAT SHAREASALE IS
// ShareASale is an affiliate network (now owned by Awin) that connects
// publishers ("affiliates") with merchants. A publisher earns commission by
// driving tracked traffic/sales to a merchant. ShareASale exposes an HTTP
// API at https://api.shareasale.com/w.cfm for affiliates to pull merchant
// catalogs, run product searches, fetch tracked deep-links, etc.
//
// REQUIRED ENV VARS (all three must be present for this adapter to run):
// SHAREASALE_AFFILIATE_ID your ShareASale affiliate account id (numeric)
// SHAREASALE_API_TOKEN the API "token" issued in the ShareASale UI
// SHAREASALE_API_SECRET the API "secret key" paired with that token
//
// APPROVAL NOTE
// Using this adapter requires (1) an approved ShareASale PUBLISHER account
// with API access enabled, AND (2) per-merchant approval for each merchant
// whose products you surface + link to. An affiliate link only pays / is
// permitted for merchants that have accepted you into their program. This
// adapter reads the catalog; honoring per-merchant approval + FTC disclosure
// is the caller's responsibility.
//
// AUTH MECHANICS (verified: token + sha256 signature header, pipe-delimited
// response). Signature = sha256Hex(token + ':' + timestamp + ':' + action +
// ':' + secretKey); sent alongside the timestamp in two request headers:
// x-ShareASale-Date: <timestamp>
// x-ShareASale-Authentication: <sig>
//
// CONTRACT: module.exports = { network, enabled, fetch }
// fetch(env, { limit }) -> array of rawProduct objects (see lib/adapters/index.js)
//
// Pure Node: global fetch (Node 20+) + built-in crypto. No npm deps.
// Never throws on missing creds — enabled() is false and fetch() returns [].
'use strict';
const crypto = require('crypto');
const NETWORK = 'shareasale';
const API_URL = 'https://api.shareasale.com/w.cfm';
const API_VERSION = '3.0'; // ShareASale API version string. TODO(verify): confirm current version.
const ACTION = 'productSearch';
const DEFAULT_KEYWORD = 'furniture';
const DEFAULT_LIMIT = 50;
// --- helpers ---------------------------------------------------------------
function sha256Hex(str) {
return crypto.createHash('sha256').update(str, 'utf8').digest('hex');
}
// ShareASale expects a UTC date string as the timestamp. Their examples use a
// full UTC datetime; new Date().toUTCString() yields e.g.
// "Sat, 01 Aug 2026 18:04:11 GMT", which the signature and header must match
// exactly (same value used in both places).
function utcTimestamp() {
return new Date().toUTCString();
}
function signature(token, timestamp, action, secret) {
return sha256Hex(`${token}:${timestamp}:${action}:${secret}`);
}
function toNumber(v) {
if (v == null || v === '') return undefined;
const n = Number(String(v).replace(/[^0-9.\-]/g, ''));
return Number.isFinite(n) ? n : undefined;
}
function toBool(v) {
if (v == null || v === '') return undefined;
const s = String(v).trim().toLowerCase();
if (['1', 'true', 'yes', 'y', 'instock', 'in stock', 'in-stock'].includes(s)) return true;
if (['0', 'false', 'no', 'n', 'outofstock', 'out of stock'].includes(s)) return false;
return undefined;
}
// Parse ShareASale's delimited response defensively. The productSearch action
// returns a header row + data rows. The delimiter is typically a pipe "|" but
// can be a tab; sniff whichever the header row contains. Columns are mapped by
// lowercased header name so we don't depend on positional order.
function parseDelimited(text) {
const lines = String(text)
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.length > 0);
if (lines.length < 2) return [];
const header = lines[0];
// Sniff delimiter: prefer pipe, else tab, else comma.
let delim = '|';
if (!header.includes('|')) {
if (header.includes('\t')) delim = '\t';
else if (header.includes(',')) delim = ',';
}
const cols = header.split(delim).map((c) => c.trim().toLowerCase());
const rows = [];
for (let i = 1; i < lines.length; i++) {
const parts = lines[i].split(delim);
// Skip API error lines (ShareASale prefixes errors with a code word/number).
if (parts.length < 2) continue;
const row = {};
for (let c = 0; c < cols.length; c++) {
row[cols[c]] = parts[c] != null ? parts[c].trim() : '';
}
rows.push(row);
}
return rows;
}
// Column-name resolver — ShareASale column labels vary by feed; try the common
// aliases for each field. TODO(verify): confirm exact productSearch column names.
function pick(row, names) {
for (const n of names) {
if (row[n] != null && row[n] !== '') return row[n];
}
return undefined;
}
function mapRow(row) {
const external_id = pick(row, ['productid', 'product_id', 'sku', 'id']);
const title = pick(row, ['name', 'productname', 'title']);
const affiliate_url = pick(row, ['buyurl', 'affiliateurl', 'affiliate_url', 'link', 'url']);
const price = toNumber(pick(row, ['price', 'retailprice']));
const sale_price = toNumber(pick(row, ['saleprice', 'sale_price']));
return {
advertiser: pick(row, ['merchantname', 'merchant', 'advertiser', 'brandname']),
external_id: external_id != null ? String(external_id) : undefined,
title,
description: pick(row, ['description', 'shortdescription', 'longdescription']),
brand: pick(row, ['brand', 'brandname', 'manufacturer']),
category: pick(row, ['category', 'merchantcategory', 'primarycategory']),
price,
sale_price,
currency: pick(row, ['currency', 'currencycode']) || 'USD',
image_url: pick(row, ['imageurl', 'image', 'thumburl', 'thumbnail']),
affiliate_url,
in_stock: toBool(pick(row, ['instock', 'in_stock', 'stockstatus', 'status'])),
};
}
// --- contract --------------------------------------------------------------
function enabled(env) {
const e = env || {};
return Boolean(
e.SHAREASALE_AFFILIATE_ID &&
e.SHAREASALE_API_TOKEN &&
e.SHAREASALE_API_SECRET
);
}
async function fetch_(env, opts) {
const e = env || {};
if (!enabled(e)) return []; // never throw on missing creds
const limit = Math.max(1, Number((opts && opts.limit) || DEFAULT_LIMIT));
const affiliateId = e.SHAREASALE_AFFILIATE_ID;
const token = e.SHAREASALE_API_TOKEN;
const secret = e.SHAREASALE_API_SECRET;
const keyword = e.SHAREASALE_KEYWORD || DEFAULT_KEYWORD;
const timestamp = utcTimestamp();
const sig = signature(token, timestamp, ACTION, secret);
const params = new URLSearchParams({
version: API_VERSION,
action: ACTION,
affiliateId: String(affiliateId),
token: String(token),
keyword,
// Paging: request enough rows to satisfy limit. TODO(verify): exact paging
// param names for productSearch (records/page vs XMLOut/recordcount).
records: String(limit),
page: '1',
});
const url = `${API_URL}?${params.toString()}`;
let text;
try {
const res = await fetch(url, {
method: 'GET',
headers: {
'x-ShareASale-Date': timestamp,
'x-ShareASale-Authentication': sig,
Accept: 'text/plain',
},
});
if (!res.ok) return []; // bad creds / rate-limit / error -> empty, don't throw
text = await res.text();
} catch (_err) {
return []; // network failure -> empty
}
let rows;
try {
rows = parseDelimited(text);
} catch (_err) {
return [];
}
const products = [];
for (const row of rows) {
const p = mapRow(row);
// Enforce the required fields of a rawProduct.
if (!p.external_id || !p.title || !p.affiliate_url) continue;
products.push(p);
if (products.length >= limit) break;
}
return products;
}
module.exports = {
network: NETWORK,
enabled,
fetch: fetch_,
};
// --- self-test -------------------------------------------------------------
if (require.main === module) {
(async () => {
const on = enabled(process.env);
console.log(`[shareasale] enabled=${on}`);
const out = await fetch_(process.env, { limit: 3 });
console.log(`[shareasale] fetched ${out.length} product(s)`);
if (out.length) console.log(JSON.stringify(out[0], null, 2));
})().catch((err) => {
console.error('[shareasale] self-test error:', err && err.message);
process.exit(1);
});
}