· বাংলাদেশ সময়
Custom webhook ইন্টিগ্রেশন
Custom webhook দিয়ে EcomSolveBD-তে কানেক্ট করুন
Dashboard → Custom / Headless → Custom webhook · যেকোনো PHP/Node/Landing · signed order REST।
- ১
অ্যাকাউন্ট খুলুন
আপনি- ecomsolvebd.com এ রেজিস্টার / লগইন
- ড্যাশবোর্ডে ঢুকুন
- ২
Custom / Headless → Custom webhook
আপনি- Integrations → Custom / Headless → Custom webhook
- Store / landing base URL দিন — https://shop.example.com
- GA4 Measurement ID (optional) — G-XXXXXXXX
- Connect custom webhook চাপুন
Tracking Merchant ID + Webhook Secret কপি করুন — Secret এখনই সেভ করুন।
- ৩
ডেভেলপারকে পাঠান
আপনি- Tracking Merchant ID
- Webhook Secret
- Order ingest URL
- লাইভ শপ URL
- ৪
ডেভেলপার: webhook + ট্র্যাকিং
ডেভেলপার- চেকআউট সফলে POST /api/v1/orders/channels/custom
- Header x-merchant-key + x-esb-signature (HMAC-SHA256)
- trackingUserId = ব্রাউজার esb_vid — fabricate করবেন না
- GET /api/v1/custom/tracker-config → collect ইভেন্ট
- নিচের API রেফারেন্স — Laravel/Next.js প্যাকেজ কপি নয়
প্রতি স্টোরের checkout আলাদা — শুধু signed REST কন্ট্রাক্ট ইমপ্লিমেন্ট করুন।
- ৫
টেস্ট করুন
দুজনে- ল্যান্ডিং খুলে Events-এ page_view দেখুন
- টেস্ট অর্ডার → Dashboard → Orders
- Products-এ manual buying cost যোগ করুন (optional)
- SaaS-এ স্ট্যাটাস বদলিয়ে courier/ads ফ্লো চেক করুন
ডেভেলপার রেফারেন্স
Custom webhook API
কোনো npm/Composer প্যাকেজ নেই — আপনার PHP, Node, Python বা অন্য স্ট্যাক signed REST দিয়ে অর্ডার পাঠায়।
Environment
Merchant Key + Webhook Secret ড্যাশবোর্ড Connect থেকে। Secret শুধু সার্ভারে।
# Copy from Dashboard → Integrations → Custom / Headless → Custom webhook ECOMSOLVEBD_API_BASE=https://api.ecomsolvebd.com ECOMSOLVEBD_MERCHANT_KEY=<tracking_merchant_id> ECOMSOLVEBD_WEBHOOK_SECRET=<webhook_secret> # ECOMSOLVEBD_DEPLOY_ENV=staging # when connected via staging dashboard # Staging collect/order: header x-fc-deploy-env: staging (same as Next.js package)
Order payload
customer.email বা customer.phone — অন্তত একটি। trackingUserId = ব্রাউজার esb_vid।
{
"currency": "BDT",
"trackingUserId": "<esb_vid from browser cookie>",
"clientIp": "<buyer IP from checkout request>",
"customer": {
"fullName": "Customer name",
"phone": "01700000000"
},
"items": [
{
"sku": "SKU-001",
"title": "Product title",
"quantity": 1,
"unitPrice": "990"
}
],
"district": "Dhaka",
"upazila": "Mirpur",
"address": "House, road, area",
"shippingTotal": "60",
"idempotencyKey": "custom:order-1001",
"attribution": {
"fbclid": "<from stored click ids>",
"gclid": "<from stored click ids>",
"ttclid": "<from stored click ids>",
"utmSource": "<utm_source>",
"utmMedium": "<utm_medium>",
"utmCampaign": "<utm_campaign>",
"lastClick": {
"utm_content": "<utm_content>",
"utm_term": "<utm_term>",
"wbraid": "<wbraid>",
"gbraid": "<gbraid>",
"msclkid": "<msclkid>",
"gclid": "<gclid>",
"fbclid": "<fbclid>",
"ttclid": "<ttclid>"
}
}
}Node.js — signed POST
চেকআউট সফল হলে সার্ভার থেকে — ব্রাউজার purchase ইভেন্ট নয়।
import { createHmac } from "node:crypto";
const apiBase = process.env.ECOMSOLVEBD_API_BASE!;
const merchantKey = process.env.ECOMSOLVEBD_MERCHANT_KEY!;
const secret = process.env.ECOMSOLVEBD_WEBHOOK_SECRET!;
const body = {
currency: "BDT",
trackingUserId: req.cookies.esb_vid, // read from checkout request — do not invent
clientIp: req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.socket?.remoteAddress,
customer: { fullName: order.name, phone: order.phone },
items: order.lines.map((l) => ({
sku: l.sku,
title: l.title,
quantity: l.qty,
unitPrice: String(l.unitPrice),
})),
district: order.district,
upazila: order.upazila,
address: order.address,
shippingTotal: String(order.shipping),
idempotencyKey: `custom:${order.id}`,
attribution: {
fbclid: order.fbclid,
gclid: order.gclid || order.gbraid || order.wbraid,
ttclid: order.ttclid,
utmSource: order.utm_source,
utmMedium: order.utm_medium,
utmCampaign: order.utm_campaign,
lastClick: {
utm_content: order.utm_content,
utm_term: order.utm_term,
wbraid: order.wbraid,
gbraid: order.gbraid,
msclkid: order.msclkid,
gclid: order.gclid,
fbclid: order.fbclid,
ttclid: order.ttclid,
},
},
};
const raw = JSON.stringify(body);
const sig = createHmac("sha256", secret).update(raw).digest("hex");
const res = await fetch(`${apiBase}/api/v1/orders/channels/custom`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-merchant-key": merchantKey,
"x-esb-signature": `sha256=${sig}`,
},
body: raw,
});
if (!res.ok) throw new Error(await res.text());
// Ingest only — ads Purchase is sent later from the EcomSolveBD dashboard / courier flow.PHP — signed POST
একই HMAC কন্ট্রাক্ট — Laravel package না থাকলেও raw PHP/Node দিয়ে।
$apiBase = getenv('ECOMSOLVEBD_API_BASE');
$key = getenv('ECOMSOLVEBD_MERCHANT_KEY');
$secret = getenv('ECOMSOLVEBD_WEBHOOK_SECRET');
$body = [
'currency' => 'BDT',
'trackingUserId' => $_COOKIE['esb_vid'] ?? '', // do not invent
'clientIp' => $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '',
'customer' => ['fullName' => $name, 'phone' => $phone],
'items' => [[
'sku' => $sku,
'title' => $title,
'quantity' => 1,
'unitPrice' => (string) $price,
]],
'idempotencyKey' => 'custom:' . $orderId,
'attribution' => [
'fbclid' => $fbclid ?? null,
'gclid' => $gclid ?? $gbraid ?? $wbraid ?? null,
'ttclid' => $ttclid ?? null,
'utmSource' => $utm_source ?? null,
'utmMedium' => $utm_medium ?? null,
'utmCampaign' => $utm_campaign ?? null,
'lastClick' => [
'utm_content' => $utm_content ?? null,
'utm_term' => $utm_term ?? null,
'wbraid' => $wbraid ?? null,
'gbraid' => $gbraid ?? null,
'msclkid' => $msclkid ?? null,
'gclid' => $gclid ?? null,
'fbclid' => $fbclid ?? null,
'ttclid' => $ttclid ?? null,
],
],
];
$raw = json_encode($body, JSON_UNESCAPED_UNICODE);
$sig = hash_hmac('sha256', $raw, $secret);
$ch = curl_init("$apiBase/api/v1/orders/channels/custom");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $raw,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
"x-merchant-key: $key",
"x-esb-signature: sha256=$sig",
],
CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($ch);Tracker config
স্ট্যাটিক ল্যান্ডিং/SPA — collect URL Dynamic DNS verify-এর পর বদলাতে পারে।
// Landing / static site — discover collect + attribution URLs
const res = await fetch(
`${process.env.ECOMSOLVEBD_API_BASE}/api/v1/custom/tracker-config`,
{ headers: { "x-merchant-key": process.env.ECOMSOLVEBD_MERCHANT_KEY! } },
);
const cfg = await res.json();
// cfg.data.endpoint → POST collect events
// cfg.data.fallbackEndpoint → api.ecomsolvebd.com collect if first-party DNS blips
// cfg.data.ga4MeasurementId → paste into gtag config (G-XXXXXXXX)
// cfg.data.accessCheckUrl → GET on page load (IP block gate — same as Woo tracker)
// cfg.data.attributionConfigUrl → GET attribution bootstrapGA4 gtag install
Woo/Shopify/Next.js-এর মতো — site-এ gtag লাগান; esbCollect _ga cookie পড়ে। Measurement ID Dashboard বা tracker-config থেকে।
<!-- Paste Measurement ID from Dashboard → Custom webhook (or cfg.data.ga4MeasurementId) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXX"><\/script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag("js", new Date());
gtag("config", "G-XXXXXXXX");
<\/script>
<!-- Load BEFORE esbCollect bootstrap — esbCollect reads _ga + _ga_* cookies (same as Woo/Next.js) -->ফানেল ইভেন্ট কোড: ইভেন্ট কোড উদাহরণ · Events API: Events API · First-party DNS: DNS verify গাইড
ইভেন্ট কোড উদাহরণ
ফানেল ইভেন্ট পাঠান
Next.js SDK-র esbTrack()-এর মতো — এখানে raw collect REST। প্রতিটি কার্ডে কপি-পেস্ট কোড।
কন্ট্রাক্ট
Transport (you implement in the browser — no npm package on this path):
esbCollect(eventName, props?) → POST cfg.data.endpoint
Header: x-merchant-key: <tracking merchant id>
Body: { event, source: "custom_tracker", user_id, client_id, ga_session_id, ga_session_number,
sass_client_id, session_started_at, journey_id, first_seen_at, last_seen_at,
device, device_model, browser, os, language, screen_*,
click_ids, raw_query_params, normalized_click_ids, normalized_attribution,
first_touch, last_touch, session_touch, identity { ga_client_id, fbp, fbc },
client_event_id, … }
Bootstrap once (landing / SPA shell):
GET /api/v1/custom/tracker-config (x-merchant-key)
→ cfg.data.endpoint for collect
→ cfg.data.ga4MeasurementId — paste into gtag (G-XXXXXXXX)
→ cfg.data.accessCheckUrl — call on load; if blocked, show full-screen message
→ esbCollect must handle 403 IP_BLOCKED (same as Woo/Shopify tracker)
GA4 (same parity as Woo / Shopify / Next.js):
Install gtag on YOUR site with Measurement ID from dashboard / tracker-config.
esbCollect reads _ga + _ga_* cookies — do not invent client_id or session ids.
First visit → landing_page_view; returning → page_view; retry page_view ~2s after load when cookies appear.
Host responsibility:
Map YOUR domain moments → event names below.
Do not invent visitor ids / fbp / gclid — read esb_vid + browser cookies + URL params.
Microsoft Clarity: read _clck cookie → clarity_id on every collect + one esb_clarity_sync per session.
Google Ads: persist gclid/wbraid/gbraid across pages (final_google_click_id on every hit).
Meta: synthesize _fbc from fbclid landing (same as Woo tracker).
Forward esb_vid on order webhook as trackingUserId (+ clientIp + attribution object for purchase payload).
Every esbCollect row: source: "custom_tracker" (store-scoped reports + abandon recovery).
Order webhook attribution (same Core as Woo):
POST /orders/channels/custom with attribution { fbclid, gclid, ttclid, utmSource, utmMedium, utmCampaign,
lastClick { utm_content, utm_term, wbraid, gbraid, msclkid, … } } from stored URL params / session.
Abandon (Checkout drop-off — same Core as Woo / Shopify / Next.js):
On blur send email_address_added { email } or phone_number_added { phone } — value required.
Core starts idle timer; after N minutes writes checkout_form_abandoned. Never send abandon from browser.
Call sites are yours (vanilla JS, React, Vue, static HTML, PHP template).
This guide does not prescribe routes, ORM, CMS, or UI libraries.পেজ ভিউ
bootstrap + স্ক্রিন/URL পরিবর্তনে ফায়ার। রুট স্ট্রাকচার আপনার।
// Run once on landing / SPA shell — cache endpoint + merchant key
const MERCHANT_KEY = "<tracking_merchant_id>"; // Dashboard → Custom webhook
const API_BASE = "https://api.ecomsolvebd.com";
let collectEndpoint = "";
let fallbackCollectEndpoint = "";
let accessCheckUrl = "";
let ga4MeasurementId = ""; // cfg.data.ga4MeasurementId → use in gtag below
function showIpBlockedOverlay(message) {
if (typeof document === "undefined" || document.getElementById("esb-ip-block-overlay")) return;
const msg = (message && String(message).trim()) || "Please contact the store administrator.";
const el = document.createElement("div");
el.id = "esb-ip-block-overlay";
el.setAttribute("role", "alertdialog");
el.setAttribute("aria-modal", "true");
el.style.cssText =
"position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;padding:24px;background:#0f172a;color:#f8fafc;font-family:system-ui,sans-serif;text-align:center;font-weight:600";
el.textContent = msg;
document.documentElement.style.overflow = "hidden";
document.body.style.overflow = "hidden";
(document.body || document.documentElement).appendChild(el);
}
async function checkIpAccessGate() {
if (!accessCheckUrl) return false;
try {
const res = await fetch(accessCheckUrl, {
headers: { Accept: "application/json", "x-merchant-key": MERCHANT_KEY },
cache: "no-store",
credentials: "omit",
});
if (!res.ok) return false;
const json = await res.json();
if (json && json.blocked === true) {
showIpBlockedOverlay(json.message);
return true;
}
} catch {
/* fail open */
}
return false;
}
async function loadTrackerConfig() {
const res = await fetch(`${API_BASE}/api/v1/custom/tracker-config`, {
headers: { "x-merchant-key": MERCHANT_KEY },
});
const cfg = await res.json();
collectEndpoint = cfg.data.endpoint; // may switch after first-party DNS verify
fallbackCollectEndpoint = cfg.data.fallbackEndpoint || "";
accessCheckUrl = cfg.data.accessCheckUrl || "";
ga4MeasurementId = (cfg.data.ga4MeasurementId || "").trim();
await checkIpAccessGate();
syncClarityOnce();
installEngagementTracking();
}
function readCookie(name) {
const m = document.cookie.match(new RegExp("(?:^|;\\s*)" + name + "=([^;]+)"));
return m?.[1] ? decodeURIComponent(m[1]) : "";
}
function visitorId() {
let v = (localStorage.getItem("esb_vid") || readCookie("esb_vid") || "").toLowerCase();
if (!/^[a-z0-9]{5,8}$/.test(v)) {
v = Array.from(crypto.getRandomValues(new Uint8Array(6)))
.map((b) => "0123456789abcdefghijklmnopqrstuvwxyz"[b % 36])
.join("");
localStorage.setItem("esb_vid", v);
document.cookie = `esb_vid=${encodeURIComponent(v)};path=/;max-age=63072000;SameSite=Lax`;
}
return v;
}
/** GA4-like session — 30 min inactivity rotate (same as Woo tracker). */
function getOrCreateSession() {
const SESS_META = "esb_sess_meta";
const SESS_NUM = "esb_sess_number";
const SESS_COOKIE = "esb_sid";
const timeoutMs = 30 * 60 * 1000;
const now = Date.now();
const nowSec = Math.floor(now / 1000);
let id = readCookie(SESS_COOKIE) || "";
if (id && !/^\d{9,16}$/.test(id)) id = "";
let meta = {};
try {
meta = JSON.parse(localStorage.getItem(SESS_META) || "{}") || {};
} catch {
meta = {};
}
const fresh = meta.id === id && typeof meta.last === "number" && now - meta.last < timeoutMs;
if (!fresh) {
const prev = Number(localStorage.getItem(SESS_NUM) || 0);
const session_number = Math.max(1, (Number.isFinite(prev) ? prev : 0) + 1);
localStorage.setItem(SESS_NUM, String(session_number));
id = String(nowSec);
if (readCookie(SESS_COOKIE) === id) id = String(nowSec) + String(Math.floor(Math.random() * 900) + 100);
meta = { id, last: now, started_at: nowSec };
document.cookie = `${SESS_COOKIE}=${encodeURIComponent(id)};path=/;max-age=1800;SameSite=Lax`;
localStorage.setItem(SESS_META, JSON.stringify(meta));
return { id, session_number, started_at: nowSec };
}
meta.last = now;
if (typeof meta.started_at !== "number") meta.started_at = nowSec;
localStorage.setItem(SESS_META, JSON.stringify(meta));
document.cookie = `${SESS_COOKIE}=${encodeURIComponent(id)};path=/;max-age=1800;SameSite=Lax`;
const session_number = Math.max(1, Number(localStorage.getItem(SESS_NUM) || 1) || 1);
return { id, session_number, started_at: meta.started_at };
}
function getOrCreateSassClientId() {
const key = "esb_sass_client_id";
let id = localStorage.getItem(key) || readCookie("esb_sass_cid") || "";
if (!/^\d+\.\d+$/.test(id)) {
id = String(Math.floor(Math.random() * 2147483646) + 1) + "." + String(Math.floor(Date.now() / 1000));
}
localStorage.setItem(key, id);
document.cookie = `esb_sass_cid=${encodeURIComponent(id)};path=/;max-age=63072000;SameSite=Lax`;
return id;
}
function readGaClientId() {
const raw = readCookie("_ga");
if (!raw || !raw.startsWith("GA1.")) return null;
const parts = raw.split(".");
if (parts.length < 4) return null;
const cid = `${parts[2]}.${parts[3]}`;
return cid && cid !== "." ? cid.slice(0, 128) : null;
}
function readGa4SessionCookieRaw() {
for (const seg of String(document.cookie || "").split(";")) {
const eq = seg.indexOf("=");
if (eq < 0) continue;
const name = seg.slice(0, eq).trim();
if (!name.startsWith("_ga_") || name.length <= 4) continue;
if (ga4MeasurementId) {
const suffix = ga4MeasurementId.trim().toUpperCase().replace(/^G-/, "");
if (suffix && !name.toUpperCase().endsWith(suffix)) continue;
}
let val = seg.slice(eq + 1);
try {
val = decodeURIComponent(val);
} catch {
/* keep */
}
if (val) return val.trim();
}
return null;
}
function parseGa4Session(raw) {
const out = { ga_session_id: null, ga_session_number: null };
if (!raw) return out;
const v = raw.trim();
const dotMatch = v.match(/^GS\d+\.\d+\.(\d+)\.(\d+)/);
if (dotMatch) {
out.ga_session_id = String(dotMatch[1]).slice(0, 32);
out.ga_session_number = String(dotMatch[2]).slice(0, 16);
return out;
}
const sPrefix = v.match(/^GS\d+\.\d+\.s(\d+)/i);
if (sPrefix) {
out.ga_session_id = String(sPrefix[1]).slice(0, 32);
const oAfterS = v.match(/\$o(\d+)\$/i);
if (oAfterS?.[1]) out.ga_session_number = String(oAfterS[1]).slice(0, 16);
return out;
}
const prefixMatch = v.match(/^GS\d+\.\d+\./);
if (!prefixMatch) return out;
const rest = v.slice(prefixMatch[0].length);
const dollar = rest.indexOf("$");
if (dollar > 0) {
const sid = rest.slice(0, dollar);
if (/^\d+$/.test(sid)) out.ga_session_id = sid.slice(0, 32);
}
const oMatch = v.match(/\$o(\d+)\$/i);
if (oMatch?.[1]) out.ga_session_number = String(oMatch[1]).slice(0, 16);
return out;
}
function gaMetrics() {
const session = parseGa4Session(readGa4SessionCookieRaw() || "");
const clientId = readGaClientId();
const out = {};
out.sass_client_id = getOrCreateSassClientId();
out.timestamp_micros = Date.now() * 1000;
out.engagement_time_msec = getEngagementTimeMsec();
if (clientId) out.client_id = clientId;
if (session.ga_session_id) out.ga_session_id = session.ga_session_id;
if (session.ga_session_number) out.ga_session_number = session.ga_session_number;
return out;
}
function buildGoogleClickPayload(clickIds) {
const KEY = "esb_google_click_ids";
let persisted = {};
try {
persisted = JSON.parse(localStorage.getItem(KEY) || "{}") || {};
} catch {
persisted = {};
}
let gclid = clickIds.gclid || persisted.gclid || "";
let wbraid = clickIds.wbraid || persisted.wbraid || "";
let gbraid = clickIds.gbraid || persisted.gbraid || "";
if (clickIds.gclid) gclid = clickIds.gclid;
if (clickIds.wbraid) wbraid = clickIds.wbraid;
if (clickIds.gbraid) gbraid = clickIds.gbraid;
const final = gclid || wbraid || gbraid || "";
const source = gclid ? "gclid" : wbraid ? "wbraid" : gbraid ? "gbraid" : "";
try {
localStorage.setItem(
KEY,
JSON.stringify({
gclid,
wbraid,
gbraid,
final_google_click_id: final,
google_click_source: source,
}),
);
} catch {
/* ignore */
}
const out = {};
if (gclid) out.gclid = gclid;
if (wbraid) out.wbraid = wbraid;
if (gbraid) out.gbraid = gbraid;
if (final) {
out.final_google_click_id = final;
out.google_click_source = source;
}
return out;
}
function processMetaOnFbclidLanding(clickIds) {
const fbclid = clickIds?.fbclid;
if (!fbclid) return;
const KEY = "esb_meta_browser_ids";
let store = {};
try {
store = JSON.parse(localStorage.getItem(KEY) || "{}") || {};
} catch {
store = {};
}
if (store.fbclid_processed === fbclid) return;
const ts = Date.now();
let fbc = readCookie("_fbc");
let fbp = readCookie("_fbp");
if (fbc) store.original_fbc = fbc;
if (fbp) store.original_fbp = fbp;
if (!fbc) {
const gen = `fb.1.${ts}.${fbclid}`;
document.cookie = `_fbc=${encodeURIComponent(gen)};path=/;max-age=${90 * 24 * 3600};SameSite=Lax`;
store.generated_fbc = gen;
fbc = gen;
}
if (!fbp) {
const gen = `fb.1.${ts}.${Math.floor(Math.random() * 9000000000) + 1000000000}`;
document.cookie = `_fbp=${encodeURIComponent(gen)};path=/;max-age=${90 * 24 * 3600};SameSite=Lax`;
store.generated_fbp = gen;
fbp = gen;
}
store.fbclid_processed = fbclid;
try {
localStorage.setItem(KEY, JSON.stringify(store));
} catch {
/* ignore */
}
}
function metaBrowserFields(clickIds) {
processMetaOnFbclidLanding(clickIds);
const out = {};
const fbp = readCookie("_fbp");
const fbc = readCookie("_fbc");
if (fbp) {
out.fbp = fbp;
out.original_fbp = fbp;
}
if (fbc) {
out.fbc = fbc;
out.original_fbc = fbc;
}
return out;
}
function clientEventId() {
try {
const b = new Uint8Array(8);
crypto.getRandomValues(b);
return Array.from(b)
.map((x) => x.toString(16).padStart(2, "0"))
.join("");
} catch {
return String(Date.now()) + Math.random().toString(36).slice(2, 8);
}
}
function journeyId() {
const KEY = "esb_journey_id";
let j = localStorage.getItem(KEY) || "";
if (!/^[a-f0-9-]{16,36}$/i.test(j)) {
j =
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: clientEventId() + clientEventId();
localStorage.setItem(KEY, j);
}
return j;
}
let engagementMs = 0;
let engagementLast = 0;
let engagementRunning = true;
function syncEngagementTick() {
if (!engagementRunning) return;
const n = Date.now();
if (!engagementLast) {
engagementLast = n;
return;
}
const delta = n - engagementLast;
if (delta > 0 && delta < 600000) engagementMs += delta;
engagementLast = n;
}
function getEngagementTimeMsec() {
syncEngagementTick();
return Math.max(0, Math.round(engagementMs));
}
function installEngagementTracking() {
if (typeof document === "undefined" || window.__esbEngagementInstalled) return;
window.__esbEngagementInstalled = true;
const pause = () => {
syncEngagementTick();
engagementRunning = false;
};
const resume = () => {
engagementRunning = true;
engagementLast = Date.now();
};
document.addEventListener("visibilitychange", () => {
document.hidden ? pause() : resume();
});
window.addEventListener("blur", pause);
window.addEventListener("focus", resume);
}
function captureFromHref(href) {
const click_ids = {};
const utm = {};
const raw_query_params = {};
const normalized_click_ids = {};
const normalized_attribution = {};
try {
const u = new URL(href);
for (const [k, v] of u.searchParams.entries()) {
if (!v) continue;
raw_query_params[k] = v;
const lk = k.toLowerCase();
if (["fbclid", "gclid", "ttclid", "wbraid", "gbraid", "msclkid"].includes(lk)) {
click_ids[lk] = v;
normalized_click_ids[lk] = v;
} else if (lk.startsWith("utm_")) {
utm[lk] = v;
normalized_attribution[lk] = v;
}
}
} catch {
/* ignore */
}
return { click_ids, utm, raw_query_params, normalized_click_ids, normalized_attribution };
}
function readVisitorMeta() {
const KEY = "esb_visitor_meta";
const now = Math.floor(Date.now() / 1000);
try {
const raw = localStorage.getItem(KEY);
if (raw) {
const parsed = JSON.parse(raw);
parsed.last_seen_at = now;
localStorage.setItem(KEY, JSON.stringify(parsed));
return parsed;
}
} catch {
/* ignore */
}
const snap = { first_seen_at: now, last_seen_at: now };
try {
localStorage.setItem(KEY, JSON.stringify(snap));
} catch {
/* ignore */
}
return snap;
}
/** Best-effort device/browser/os from navigator.userAgent (Woo parity). */
function detectDeviceFromUa() {
const ua = String(navigator.userAgent || "");
const device = /Mobi|Android.*Mobile|iPhone/i.test(ua)
? "Mobile"
: /iPad|Tablet/i.test(ua)
? "Tablet"
: "Desktop";
const chrome = ua.match(/Chrome\/(\d+)/i);
const browser = chrome ? "Chrome " + chrome[1] : "Browser";
const android = ua.match(/Android (\d+)/i);
const ios = ua.match(/iPhone OS (\d+_\d+)/i);
const os = android
? "Android " + android[1]
: ios
? "iOS " + ios[1].replace("_", ".")
: "OS";
return {
device,
browser,
os,
language: navigator.language ? String(navigator.language).slice(0, 32) : undefined,
screen_resolution:
screen.width && screen.height ? Math.floor(screen.width) + "x" + Math.floor(screen.height) : undefined,
};
}
function ensureFirstTouch(snap) {
const FT_KEY = "esb_first_touch";
try {
const raw = localStorage.getItem(FT_KEY);
if (raw) return { snap: JSON.parse(raw), isNew: false };
localStorage.setItem(FT_KEY, JSON.stringify(snap));
return { snap, isNew: true };
} catch {
return { snap, isNew: false };
}
}
/** Microsoft Clarity _clck — first segment before ^ (same as Woo/Shopify tracker). */
function readClckRaw() {
const m = document.cookie.match(/(?:^|;\s*)_clck=([^;]*)/);
if (!m?.[1]) return "";
try {
return decodeURIComponent(m[1]);
} catch {
return m[1];
}
}
function readClarityCookieValue() {
let s = readClckRaw().trim();
if (!s) return "";
try {
s = decodeURIComponent(s);
} catch {
/* keep */
}
const first = s.split("^")[0]?.trim() || "";
return first.slice(0, 64);
}
/** Clarity writes _clck after load — poll once per session (esb_clarity_sync). */
function syncClarityOnce() {
const trySync = () => {
const clarityId = readClarityCookieValue();
if (!clarityId) return false;
try {
const sk = "esb:clarity_synced:" + MERCHANT_KEY.slice(0, 12);
if (sessionStorage.getItem(sk) === clarityId) return true;
sessionStorage.setItem(sk, clarityId);
} catch {
/* ignore */
}
void esbCollect("esb_clarity_sync", { clarity_id: clarityId });
return true;
};
if (trySync()) return;
const started = Date.now();
const timer = setInterval(() => {
if (trySync() || Date.now() - started >= 20000) clearInterval(timer);
}, 500);
}
async function esbCollect(eventName, extra = {}) {
if (!collectEndpoint) await loadTrackerConfig();
if (document.getElementById("esb-ip-block-overlay")) return false;
const now = captureFromHref(location.href);
const ft = ensureFirstTouch({
occurredAt: new Date().toISOString(),
landingUrl: location.href,
url: location.href,
clickIds: now.click_ids,
utm: now.utm,
});
const sess = getOrCreateSession();
const ga = gaMetrics();
const google = buildGoogleClickPayload(now.click_ids);
const meta = metaBrowserFields(now.click_ids);
const clarity_id = readClarityCookieValue() || undefined;
const identity = {
...(ga.client_id ? { ga_client_id: ga.client_id } : {}),
...(meta.fbp ? { fbp: meta.fbp } : {}),
...(meta.fbc ? { fbc: meta.fbc } : {}),
};
const visitorMeta = readVisitorMeta();
const device = detectDeviceFromUa();
const body = {
event: eventName,
source: "custom_tracker",
timestamp: new Date().toISOString(),
client_event_id: clientEventId(),
journey_id: journeyId(),
user_id: visitorId(),
session_id: sess.id,
session_number: sess.session_number,
sass_session_id: sess.id,
sass_session_number: sess.session_number,
session_started_at: sess.started_at,
first_seen_at: visitorMeta.first_seen_at,
last_seen_at: visitorMeta.last_seen_at,
url: location.href,
path: location.pathname + location.search,
referrer: document.referrer || undefined,
title: typeof document !== "undefined" ? document.title : undefined,
client_user_agent: navigator.userAgent,
first_touch: ft.snap,
...(Object.keys(now.utm).length ? { utm: now.utm } : {}),
...(clarity_id ? { clarity_id } : {}),
...(Object.keys(now.click_ids).length ? { click_ids: now.click_ids } : {}),
...(Object.keys(now.raw_query_params || {}).length ? { raw_query_params: now.raw_query_params } : {}),
...(Object.keys(now.normalized_click_ids || {}).length ? { normalized_click_ids: now.normalized_click_ids } : {}),
...(Object.keys(now.normalized_attribution || {}).length ? { normalized_attribution: now.normalized_attribution } : {}),
...(Object.keys(identity).length ? { identity } : {}),
...device,
...ga,
...google,
...meta,
...extra,
};
async function postCollect(endpoint) {
return fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
"x-merchant-key": MERCHANT_KEY,
},
body: JSON.stringify(body),
keepalive: true,
});
}
let res;
try {
res = await postCollect(collectEndpoint);
} catch {
if (fallbackCollectEndpoint && fallbackCollectEndpoint !== collectEndpoint) {
res = await postCollect(fallbackCollectEndpoint);
} else {
return false;
}
}
if (res.status === 403) {
try {
const j = await res.clone().json();
if (j && (j.blocked === true || j.error?.code === "IP_BLOCKED")) {
showIpBlockedOverlay(j.error?.message || j.message);
return false;
}
} catch {
/* ignore */
}
}
return res.ok;
}
async function fireInitialPageView() {
await loadTrackerConfig();
const ft = ensureFirstTouch({
occurredAt: new Date().toISOString(),
landingUrl: location.href,
url: location.href,
...captureFromHref(location.href),
});
await esbCollect(ft.isNew ? "landing_page_view" : "page_view");
setTimeout(() => {
try {
const ga = gaMetrics();
if (ga.client_id || ga.ga_session_id || ga.fbp || ga.fbc) void esbCollect("page_view");
} catch {
/* ignore */
}
}, 2000);
}
// GA4 gtag — paste Measurement ID from Dashboard / tracker-config (before esbCollect)
// <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXX"><\/script>
// <script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}
// gtag('js', new Date()); gtag('config', 'G-XXXXXXXX');<\/script>
// Fire once on load — first visit = landing_page_view, then retry page_view when _ga cookies appear
await fireInitialPageView();
// SPA: fire page_view on route change (not landing_page_view)
// useEffect(() => { void esbCollect("page_view"); }, [pathname, searchParams]);আরও ফিচার
অর্ডার স্ট্যাটাস
Custom webhook-এ স্ট্যাটাস EcomSolveBD ড্যাশবোর্ড থেকে ম্যানেজ হয়।
- · pending · confirmed · delivered · cancelled · returned
- · Courier বুকিং / manual Send → Meta · TikTok · GA4 · Google Ads
- · Store-এ auto push নেই — Laravel/Next.js package নিন যদি দুই-দিক sync দরকার
প্রোডাক্ট ও খরচ
প্রোডাক্ট আপনার সাইট/API-তেই। EcomSolveBD-এ শুধু optional cost ফিল্ড।
- · Dashboard → Products → Manual products
- · buying_cost · packaging_cost · extra_cost
- · Product feed (Meta/TikTok/Google XML) এই পাথে নেই
First-party ট্র্যাকিং
স্ট্যাটিক ল্যান্ডিং — DNS verify করলে collect আপনার subdomain দিয়ে।
- · Default: api.ecomsolvebd.com/collect (x-merchant-key)
- · Dashboard: CNAME → Verify DNS → tracking.{domain}
- · ডেভ: tracker-config URL; owner: DNS (কোড নয়)
সাধারণ প্রশ্ন
- Laravel বা Next.js package লাগবে?
- না। Custom webhook = raw signed REST। package চাইলে Laravel/Next.js পাথ বেছে নিন।
- Product feed পাব?
- না। Custom webhook-এ XML catalog feed নেই। Laravel/Next.js package সেই পাথে feed দেয়।
- অর্ডার কীভাবে আসে?
- চেকআউট সফলে সার্ভার webhook — ব্রাউজার purchase ইভেন্ট নয়।
- Secret হারিয়ে গেলে?
- Integrations → Custom webhook → Create new secret।
সমস্যা হলে
401 Invalid signature
Raw JSON body দিয়ে HMAC করুন — parsed object নয়। Secret মিলছে কিনা দেখুন।
page_view আসছে না
tracker-config থেকে endpoint নিন। x-merchant-key header ঠিক আছে কিনা।
অর্ডার আসছে না
Connect custom webhook আগে। trackingUserId = esb_vid। customer.phone বা email দিন।
Attribution backup দেখাচ্ছে
Order webhook-এ esb_vid পাঠান। Collect ইভেন্ট আগে landing-এ fire হয়েছে কিনা চেক করুন।
· বাংলাদেশ সময়