· বাংলাদেশ সময়
Custom webhook ইন্টিগ্রেশন
Custom webhook দিয়ে EcomSolveBD-তে কানেক্ট করুন
Dashboard → Website → Connect / Create → হ্যাঁ → Custom / Headless → Custom webhook · যেকোনো PHP/Node/Nuxt/Landing · signed order REST।
- ১
অ্যাকাউন্ট খুলুন
আপনি- ecomsolvebd.com এ রেজিস্টার / লগইন
- ড্যাশবোর্ডে ঢুকুন
- ২
Website → Custom webhook
আপনি- Website → Connect / Create (কানেক্ট / তৈরি)
- “আপনার কি ইতিমধ্যে … ওয়েবসাইট আছে?” → হ্যাঁ
- 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 ইভেন্ট
- esb-track.js হেল্পার → সব ১৯টা ব্রাউজার ইভেন্ট (নিচের টেবিল)
- GET /feed/products.xml (+ facebook/tiktok/google) — নিচের XML ফরম্যাটে
- নিচের API রেফারেন্স — Laravel/Next.js প্যাকেজ কপি নয়
ইভেন্ট টেবিলের প্রতিটি ইভেন্ট ও প্রোডাক্ট ফিড বাধ্যতামূলক — শুধু page_view বা অর্ডার দিয়ে কাজ শেষ নয়। নিচের চেকলিস্ট ডেভেলপার/AI-কে দিন।
- ৫
টেস্ট করুন
দুজনে- প্রোডাক্ট দেখুন → কার্টে যোগ/বাদ → কার্ট → চেকআউট → প্রতিটি ফিল্ড পূরণ → অর্ডার
- Dashboard → Events: টেবিলের প্রতিটি ইভেন্ট এসেছে কিনা মিলিয়ে নিন
- টেস্ট অর্ডার → Dashboard → Orders
- Dashboard → Products → Feed URL = https://yoursite.com/feed/products.xml → Fetch now
- 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 গাইড
ডেভেলপার · বাধ্যতামূলক
সব ইভেন্ট — কখন, কী পেলোড, কোথায় কোড
Raw collect REST — নিচের প্রতিটি কোড ব্লক লাগবে (bootstrap → হেল্পার → প্রতিটি ইভেন্ট → অর্ডার)।
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-ever visit → landing_page_view only (once per visitor, no page_view on that hit); every later page → page_view.
~2s retry when cookies appear repeats the SAME event (server merges it) — never an extra page_view.
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.নিয়ম: নিচের তালিকার প্রতিটি ইভেন্ট বাধ্যতামূলক — যদি সেই UI মুহূর্ত/ফিল্ড আপনার সাইটে থাকে।
- আগে সাইটের প্রোডাক্ট পেজ, কার্ট, চেকআউট ও চেকআউট ফর্মের প্রতিটি ইনপুট লিস্ট করুন; তারপর প্রতিটির সাথে এই টেবিলের ইভেন্ট ম্যাপ করুন।
- “শর্তসাপেক্ষ” ইভেন্ট শুধু তখনই বাদ দিন যখন সাইটে সেই জিনিসটাই নেই (যেমন পোস্ট কোড ফিল্ড নেই) — কারণটা কোডে কমেন্ট করে রাখুন।
- item_id = প্রোডাক্ট ফিডের <id> = অর্ডার আইটেমের productId — তিন জায়গায় একই ID, নইলে রিপোর্টে প্রোডাক্ট মিলবে না।
- currency = ISO কোড (BDT), value/price = number (string নয়)।
Custom webhook: কোনো প্যাকেজ নেই — নিচের bootstrap-এর esbCollect() আপনার ট্রান্সপোর্ট। esb-track.js হেল্পার esbCollect-কে কল করে।
| ইভেন্ট | কখন ফায়ার করবেন | পেলোড | বাধ্যতামূলক? |
|---|---|---|---|
| ব্রাউজ ও কার্ট | |||
| landing_page_view | ভিজিটরের একদম প্রথম ভিজিটের প্রথম হিট (esb_first_touch তখনো নেই) — প্রতি ভিজিটরে জীবনে একবারই; সেই হিটে page_view যাবে না। Next.js EsbTracker / Laravel @ecomsolvebdTracker / Custom বুটস্ট্র্যাপ / Woo-Shopify ট্র্যাকার নিজেই পাঠায় — নিজে আর কখনো পাঠাবেন না | — | হ্যাঁ |
| page_view | প্রথম ভিজিটের landing হিট বাদে প্রতিটি পেজ লোড ও SPA রুট পরিবর্তনে (পরের ভিজিট, পরের পেজ — সব page_view) | — | হ্যাঁ |
| view_item | প্রোডাক্ট ডিটেইল পেজ/কুইক-ভিউ দেখানোর সময় (প্রতি প্রোডাক্টে একবার) | ecommerce { currency, value, items[1] } | হ্যাঁ |
| add_to_cart | কার্টে যোগ সফল হওয়ার পর (প্রতিটি add, Buy Now সহ) | ecommerce { currency, value = price × qty, items[1] } | হ্যাঁ |
| remove_from_cart | কার্ট থেকে লাইন সরালে বা qty কমালে | ecommerce { currency, value, items[1] (removed qty) } | শর্তসাপেক্ষ — সাইটে থাকলে হ্যাঁ |
| view_cart | কার্ট পেজ / কার্ট ড্রয়ার খোলার সময় | ecommerce { currency, value = subtotal, items[] } | শর্তসাপেক্ষ — সাইটে থাকলে হ্যাঁ |
| চেকআউট | |||
| begin_checkout | চেকআউট পেজ/স্টেপ খোলার সময় (প্রতি চেকআউট সেশনে একবার) | ecommerce { currency, value, items[] } | হ্যাঁ |
| add_shipping_info | ডেলিভারি এরিয়া/শিপিং মেথড বাছাই করলে | ecommerce { currency, value, shipping_tier, items[] } | শর্তসাপেক্ষ — সাইটে থাকলে হ্যাঁ |
| add_payment_info | পেমেন্ট মেথড (COD, bKash…) বাছাই করলে | ecommerce { currency, value, payment_type, items[] } | শর্তসাপেক্ষ — সাইটে থাকলে হ্যাঁ |
| চেকআউট ফর্ম ও ফিল্ড | |||
| checkout_form_started | চেকআউট ফর্মে প্রথম ফোকাস/টাইপ (একবার) | {} | হ্যাঁ |
| first_name_added | First name / পূর্ণ নাম ফিল্ড পূরণ হলে (change/blur) — আসল নাম পাঠান (Checkout drop-off-এ Customer) | { name, field_value_len } | শর্তসাপেক্ষ — সাইটে থাকলে হ্যাঁ |
| last_name_added | Last name ফিল্ড পূরণ হলে — আসল মান পাঠান (সার্ভার first + last জোড়া দেয়) | { last_name, field_value_len } | শর্তসাপেক্ষ — সাইটে থাকলে হ্যাঁ |
| phone_number_added | মোবাইল নম্বর পূরণ হলে — আসল নম্বর পাঠান (abandon recovery) | { phone } | হ্যাঁ |
| email_address_added | ইমেইল পূরণ হলে — আসল ইমেইল পাঠান | { email } | শর্তসাপেক্ষ — সাইটে থাকলে হ্যাঁ |
| address_added | ঠিকানা ফিল্ড পূরণ হলে | { field_value_len } | শর্তসাপেক্ষ — সাইটে থাকলে হ্যাঁ |
| city_added | City / জেলা (district) বাছাই বা পূরণ হলে | { field_value_len } | শর্তসাপেক্ষ — সাইটে থাকলে হ্যাঁ |
| state_added | State / বিভাগ / থানা-উপজেলা বাছাই হলে | { field_value_len } | শর্তসাপেক্ষ — সাইটে থাকলে হ্যাঁ |
| postal_code_added | পোস্ট কোড পূরণ হলে | { field_value_len } | শর্তসাপেক্ষ — সাইটে থাকলে হ্যাঁ |
| country_added | দেশ বাছাই হলে | { field_value_len } | শর্তসাপেক্ষ — সাইটে থাকলে হ্যাঁ |
| checkout_form_completed | ফর্ম সাবমিট (Place order চাপলে) | {} | হ্যাঁ |
Device & technology — প্রতিটা ইভেন্টে
Device, OS with Version, Device model, Browser — Details reports, সব ফিল্টার, অর্ডার জার্নি আর অ্যাডস Purchase এই চারটা ফিল্ড থেকে আসে। বুটস্ট্র্যাপের detectDeviceFromUa() প্রতিটা esbCollect বডিতে এগুলো যোগ করে — ফাংশনটা বাদ দেবেন না বা ছোট করবেন না।
// Sent on EVERY event (same labels as the WooCommerce tracker)
{
"device": "Mobile", // Mobile | Tablet | Desktop | TV
"device_model": "iPhone", // "Samsung A52", "Windows NT 10.0" — omit if unknown
"os": "iOS 18.7", // "Android 14", "Windows 10/11", "macOS 14.5"
"browser": "Safari 18.7.5" // "Chrome 155.0.0.0", "Samsung Internet 25.0"
}সাইট না পাঠালে EcomSolveBD ক্রেতার user-agent থেকে নিজে ভরে দেয় (তাই proxy অবশ্যই X-Client-User-Agent পাঠাবে); সাইটের পাঠানো মান কখনো বদলানো হয় না।
ব্রাউজার থেকে কখনো পাঠাবেন না
purchase— অর্ডার যায় সার্ভার webhook দিয়ে; অ্যাডস Purchase EcomSolveBD নিজে পাঠায়।checkout_form_abandoned— সার্ভার নিজে লেখে (ফোন/ইমেইলের পর idle টাইমার)।click,scroll— সার্ভার ফেলে দেয়।
Abandoned checkout কীভাবে তৈরি হয়
- ক্রেতা চেকআউটে ফোন বা ইমেইল লিখলে phone_number_added { phone } / email_address_added { email } পাঠান — আসল মানসহ। মান ছাড়া পাঠালে টাইমার চালু হয় না।
- সার্ভার ঐ ভিজিটরের (esb_vid) জন্য idle টাইমার চালু করে (ডিফল্ট ১০ মিনিট)। এরপর যেকোনো ইভেন্ট (page_view, অন্য ফিল্ড…) এলে টাইমার আবার শুরু থেকে গোনে।
- অর্ডার webhook এলে টাইমার বাতিল হয় — কিন্তু শুধু তখনই, যখন webhook-এর trackingUserId = ব্রাউজারের esb_vid কুকি। ভুল বা খালি হলে ক্রেতা অর্ডার দিলেও ১০ মিনিট পর তাকে abandoned দেখাবে।
- অর্ডার না এলে টাইমার শেষে সার্ভার নিজে checkout_form_abandoned লেখে → Dashboard-এর Checkout drop-off / Abandon recovery-তে নাম, ফোন ও ইমেইলসহ দেখা যায়। নাম আসে first_name_added { name } (+ last_name_added { last_name }) থেকে।
- অর্ডার পাঠান চেকআউটের সেই request থেকেই (যেখানে esb_vid কুকি আছে), অথবা esb_vid অর্ডারে সেভ করে পরে পাঠান। কুকি ছাড়া queue/cron থেকে পাঠালে visitor মিলবে না।
1) পেজ ভিউ
esbCollect('page_view')
bootstrap + স্ক্রিন/URL পরিবর্তনে ফায়ার। রুট স্ট্রাকচার আপনার।
landing.js · bootstrap + page_view// 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). */ // Device & technology (Dashboard reports + Device / OS with Version / Device model / Browser // filters). Same labels as the WooCommerce tracker — send on EVERY event. function detectDeviceFromUa() { const ua = String(navigator.userAgent || "").replace(/\s+/g, " ").trim(); let m; let device = "Desktop"; if (/iPad|Tablet|PlayBook|Silk\/|Kindle/i.test(ua) || (/Android/i.test(ua) && !/Mobile/i.test(ua))) device = "Tablet"; else if (/Mobi|iPhone|iPod|Android.*Mobile|Windows Phone|IEMobile|BlackBerry|Opera Mini/i.test(ua)) device = "Mobile"; else if (/Smart-?TV|AppleTV|GoogleTV|HbbTV|NetCast|webOS|Tizen|Roku|CrKey/i.test(ua)) device = "TV"; let browser = "Unknown"; if ((m = ua.match(/\bEdg(?:e|A|iOS)?\/([\d.]+)/i))) browser = "Edge " + m[1]; else if ((m = ua.match(/\bOPR\/([\d.]+)/i) || ua.match(/\bOpera\/([\d.]+)/i))) browser = "Opera " + m[1]; else if ((m = ua.match(/\bSamsungBrowser\/([\d.]+)/i))) browser = "Samsung Internet " + m[1]; else if ((m = ua.match(/\bFirefox\/([\d.]+)/i))) browser = "Firefox " + m[1]; else if ((m = ua.match(/\bChrome\/([\d.]+)/i))) browser = "Chrome " + m[1]; else if ((m = ua.match(/\bVersion\/([\d.]+).*Safari/i))) browser = "Safari " + m[1]; let os = "Unknown"; const win = { "10.0": "10/11", "6.3": "8.1", "6.2": "8", "6.1": "7", "6.0": "Vista", "5.1": "XP" }; if ((m = ua.match(/\bWindows NT ([\d.]+)/i))) os = "Windows " + (win[m[1]] || m[1]); else if (/\bWindows/i.test(ua)) os = "Windows"; else if ((m = ua.match(/\b(?:iPhone OS|CPU OS) ([\d_]+)/i))) os = "iOS " + m[1].replace(/_/g, "."); else if ((m = ua.match(/\bMac OS X ([\d_]+)/i))) os = "macOS " + m[1].replace(/_/g, "."); else if ((m = ua.match(/\bAndroid ([\d.]+)/i))) os = "Android " + m[1]; else if (/\bLinux/i.test(ua)) os = "Linux"; let device_model = ""; if (/\biPhone\b/i.test(ua)) device_model = "iPhone"; else if (/\biPad\b/i.test(ua)) device_model = "iPad"; else { const parts = ((ua.match(/\(([^)]+)\)/) || [])[1] || "").split(";"); for (const raw of parts) { const part = raw.replace(/\s+Build\/.+$/i, "").trim(); if (/\b(?:SM-[A-Z0-9]+|Pixel\s+\d|CPH\d+|RMX\d+|Infinix|TECNO|OPPO|vivo|Redmi|POCO|OnePlus)\b/i.test(part)) { const sm = part.toUpperCase().match(/\bSM-([AMF])(\d{2})\d[A-Z0-9]*\b/); device_model = sm ? "Samsung " + sm[1] + sm[2] : part; break; } if (!device_model && /^Windows NT/i.test(part)) device_model = part; } } return { device, // "Mobile" | "Tablet" | "Desktop" | "TV" ...(device_model ? { device_model } : {}), // "iPhone", "Samsung A52", "Windows NT 10.0" browser, // "Safari 18.7.5", "Chrome 155.0.0.0" os, // "iOS 18.7", "Android 14", "Windows 10/11" 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; } // esb-track.js (event helper) sends through this global window.esbCollect = esbCollect; async function fireInitialPageView() { await loadTrackerConfig(); const ft = ensureFirstTouch({ occurredAt: new Date().toISOString(), landingUrl: location.href, url: location.href, ...captureFromHref(location.href), }); // First-ever visit → landing_page_view only (once per visitor, never page_view on that hit) const initialEvent = ft.isNew ? "landing_page_view" : "page_view"; const initialPath = location.pathname; await esbCollect(initialEvent); // _ga cookies appear after first paint — re-hit the SAME event; server dedupe merges GA ids into it setTimeout(() => { try { if (location.pathname !== initialPath) return; // SPA moved on — route-change page_view owns it const ga = gaMetrics(); if (ga.client_id || ga.ga_session_id || ga.fbp || ga.fbc) void esbCollect(initialEvent); } 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-ever visit = landing_page_view (no page_view), later loads = page_view; // the 2s _ga retry repeats the same event and is merged server-side await fireInitialPageView(); // SPA: fire page_view on route change (not landing_page_view) // useEffect(() => { void esbCollect("page_view"); }, [pathname, searchParams]);2) ইভেন্ট হেল্পার (একবার তৈরি করুন)
esb.viewItem(…) → window.esbCollect(name, props)
সব ইভেন্টের জন্য একটাই মডিউল; bootstrap-এর window.esbCollect দিয়ে পাঠায়। নিচের প্রতিটি ইভেন্ট esb.* দিয়েও কল করা যায়।
esb-track.js// esb-track.js — any shared JS module on your site // Requires the bootstrap snippet to set window.esbCollect = esbCollect. // One helper for EVERY event — import { esb } and call it at each UI moment. const CURRENCY = "BDT"; // your store currency (ISO 4217) const queue = []; let flushing = false; function send(event, props) { // window.esbCollect is the transport (queued until it exists) if (typeof window.esbCollect === "function") { while (queue.length) window.esbCollect(...queue.shift()); window.esbCollect(event, props); return; } queue.push([event, props]); if (!flushing) { flushing = true; const t = setInterval(() => { if (typeof window.esbCollect !== "function") return; clearInterval(t); while (queue.length) window.esbCollect(...queue.shift()); }, 200); } } const lineValue = (items) => items.reduce((sum, it) => sum + Number(it.price) * Number(it.quantity || 1), 0); /** item: { item_id, item_name, price, quantity, item_variant?, item_category? } */ function ecommerce(items, extra) { const clean = items.map((it) => ({ item_id: String(it.item_id), // SAME id as product feed <id> and order productId item_name: String(it.item_name), price: Number(it.price), quantity: Number(it.quantity || 1), ...(it.item_variant ? { item_variant: String(it.item_variant) } : {}), ...(it.item_category ? { item_category: String(it.item_category) } : {}), })); return { ecommerce: { currency: CURRENCY, value: lineValue(clean), items: clean, ...(extra || {}) } }; } let formStarted = false; const sentFieldValues = new Set(); export const esb = { pageView: () => send("page_view"), viewItem: (item) => send("view_item", ecommerce([{ ...item, quantity: 1 }])), addToCart: (item) => send("add_to_cart", ecommerce([item])), removeFromCart: (item) => send("remove_from_cart", ecommerce([item])), viewCart: (items) => send("view_cart", ecommerce(items)), beginCheckout: (items) => send("begin_checkout", ecommerce(items)), addShippingInfo: (items, shippingTier) => send("add_shipping_info", ecommerce(items, { shipping_tier: String(shippingTier) })), addPaymentInfo: (items, paymentType) => send("add_payment_info", ecommerce(items, { payment_type: String(paymentType) })), checkoutFormStarted: () => { if (formStarted) return; formStarted = true; send("checkout_form_started", {}); }, /** * field: "first_name" | "last_name" | "phone" | "email" | "address" * | "city" | "state" | "postal_code" | "country" * Call on change/blur of that input with its current value. */ field: (field, value) => { const v = String(value ?? "").trim(); if (!v) return; const event = { first_name: "first_name_added", last_name: "last_name_added", phone: "phone_number_added", email: "email_address_added", address: "address_added", city: "city_added", state: "state_added", postal_code: "postal_code_added", country: "country_added", }[field]; if (!event) return; const key = event + ":" + v.toLowerCase(); if (sentFieldValues.has(key)) return; // same value already sent sentFieldValues.add(key); esb.checkoutFormStarted(); if (event === "phone_number_added") return send(event, { phone: v }); if (event === "email_address_added") return send(event, { email: v }); if (event === "first_name_added") return send(event, { name: v, field_value_len: v.length }); if (event === "last_name_added") return send(event, { last_name: v, field_value_len: v.length }); send(event, { field_value_len: v.length }); }, checkoutFormCompleted: () => send("checkout_form_completed", {}), };3) view_item
esbCollect('view_item', { ecommerce })
প্রোডাক্ট ডিটেইল দেখানোর মুহূর্তে — আপনার PDP/কম্পোনেন্ট যাই হোক।
product-detail.js// When product detail is shown — map fields from YOUR catalog model await esbCollect("view_item", { ecommerce: { currency: currencyIso4217, value: Number(unitPrice), items: [ { item_id: String(productIdOrSku), item_name: String(title), price: Number(unitPrice), quantity: 1, }, ], }, });4) add_to_cart
esbCollect('add_to_cart', { ecommerce })
কার্টে যোগ হ্যান্ডলারের ভিতরে।
cart.js// Inside your add-to-cart handler (button click, cart store action, …) await esbCollect("add_to_cart", { ecommerce: { currency: currencyIso4217, value: Number(unitPrice) * Number(qty), items: [ { item_id: String(productIdOrSku), item_name: String(title), price: Number(unitPrice), quantity: Number(qty), }, ], }, });5) view_cart
esbCollect('view_cart', { ecommerce? })
কার্ট UI দেখানোর সময়।
cart-page.js// When the cart UI is displayed await esbCollect("view_cart", { ecommerce: { currency: currencyIso4217, value: Number(cartSubtotal), // items: lines.map((l) => ({ // item_id: String(l.sku), // item_name: String(l.title), // price: Number(l.unitPrice), // quantity: Number(l.qty), // })), }, });6) begin_checkout
esbCollect('begin_checkout', { ecommerce? })
চেকআউট শুরুর UX মুহূর্তে।
checkout.js// When checkout starts (page open, step enter, drawer open — your UX) await esbCollect("begin_checkout", { ecommerce: { currency: currencyIso4217, value: Number(cartSubtotal), // items: /* same shape as view_cart */, }, });7) remove_from_cart
esbCollect('remove_from_cart', { ecommerce })
কার্ট থেকে আইটেম সরানোর সময় — Woo/Shopify funnel parity।
cart.js// When buyer removes a line from cart await esbCollect("remove_from_cart", { ecommerce: { currency: currencyIso4217, value: Number(unitPrice) * Number(qty), items: [ { item_id: String(productIdOrSku), item_name: String(title), price: Number(unitPrice), quantity: Number(qty), }, ], }, });8) add_shipping_info
esbCollect('add_shipping_info', { ecommerce })
shipping / district বেছে নেওয়ার পর — checkout funnel।
checkout-shipping.js// When shipping method / district is chosen on checkout await esbCollect("add_shipping_info", { ecommerce: { currency: currencyIso4217, value: Number(cartSubtotal), shipping_tier: String(shippingMethodOrDistrict), // items: /* optional line items */, }, });9) add_payment_info
esbCollect('add_payment_info', { ecommerce })
পেমেন্ট মেথড (COD/bKash) সিলেক্ট করলে।
checkout-payment.js// When payment method is selected (COD, bKash, …) await esbCollect("add_payment_info", { ecommerce: { currency: currencyIso4217, value: Number(cartSubtotal), payment_type: String(paymentMethod), }, });10) checkout form bind
blur → email_address_added { email } / phone_number_added { phone }
contact ইভেন্টে email/phone পাঠালে Core abandon (Checkout drop-off) একইভাবে গণনা করে।
checkout-form.js// Checkout form — one esb.field() per input that exists (works with any input names) import { esb } from "./esb-track.js"; const form = document.querySelector("#checkout-form"); // YOUR selector if (form) { form.addEventListener("focusin", () => esb.checkoutFormStarted()); form.addEventListener("submit", () => esb.checkoutFormCompleted()); // Map YOUR input names → ESB field. Delete lines for inputs the site does not have. const FIELDS = { name: "first_name", // full name / first name → first_name_added { name } (Customer) last_name: "last_name", // → last_name_added { last_name } phone: "phone", // → phone_number_added { phone } (abandon recovery) email: "email", // → email_address_added { email } address: "address", // → address_added district: "city", // city / district → city_added thana: "state", // state / division / thana / upazila → state_added postcode: "postal_code", // → postal_code_added country: "country", // → country_added }; for (const [inputName, field] of Object.entries(FIELDS)) { const el = form.querySelector('[name="' + inputName + '"]'); if (!el) continue; const evt = el.tagName === "SELECT" ? "change" : "blur"; el.addEventListener(evt, () => esb.field(field, el.value)); } } // Phone/email carry the real value → Core starts the checkout drop-off timer. // Never send checkout_form_abandoned from the browser.11) order ingest
POST /orders/channels/custom
অর্ডার persist-এর পর signed REST — ব্রাউজার purchase ইভেন্ট নয়।
checkout-success.ts// Server — after YOUR order is committed (Node, PHP, Python, …) 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) => ({ productId: String(l.productId), // SAME id as event item_id + feed <id> 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.number}`, // custom:<your order number> — also the key for two-way status sync }; 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 dispatched later from EcomSolveBD (manual / courier).
সব কল-সাইট একনজরে
import { esb } from "./esb-track"; // or window.esb = esb in a <script type="module">
// Map YOUR models → { item_id, item_name, price, quantity }.
const toItem = (p, qty = 1) => ({
item_id: String(p.id), // same id as feed <id>
item_name: p.name,
price: Number(p.price), // the price the buyer sees (sale price if any)
quantity: qty,
item_variant: p.variantName, // optional
});
// 1) Product page — once when the product is shown
esb.viewItem(toItem(product));
// 2) Add to cart — after the add succeeds (also on "Buy now")
esb.addToCart(toItem(product, qty));
// 3) Remove from cart / qty decreased — removed quantity only
esb.removeFromCart(toItem(line.product, removedQty));
// 4) Cart page or cart drawer opened
esb.viewCart(cart.lines.map((l) => toItem(l.product, l.qty)));
// 5) Checkout page/step opened (once)
esb.beginCheckout(cart.lines.map((l) => toItem(l.product, l.qty)));
// 6) Delivery area / shipping method chosen (e.g. "inside_dhaka")
esb.addShippingInfo(cartItems, shippingZone);
// 7) Payment method chosen (e.g. "cod", "bkash")
esb.addPaymentInfo(cartItems, paymentMethod);
// 8) Checkout form — every input that exists, on change/blur:
// <input name="name" onBlur={(e) => esb.field("first_name", e.target.value)} />
// <input name="lastName" onBlur={(e) => esb.field("last_name", e.target.value)} />
// <input name="phone" onBlur={(e) => esb.field("phone", e.target.value)} />
// <input name="email" onBlur={(e) => esb.field("email", e.target.value)} />
// <textarea name="address" onBlur={(e) => esb.field("address", e.target.value)} />
// <select name="district" onChange={(e) => esb.field("city", e.target.value)} />
// <select name="thana" onChange={(e) => esb.field("state", e.target.value)} />
// <input name="zip" onBlur={(e) => esb.field("postal_code", e.target.value)} />
// <select name="country" onChange={(e) => esb.field("country", e.target.value)} />
// (field() also fires checkout_form_started on the first filled input;
// call esb.checkoutFormStarted() on the form's first focus as well.)
// 9) Place order clicked (form submit) — before/while you call your order API
esb.checkoutFormCompleted();
// The order itself goes to EcomSolveBD from your SERVER (order webhook) — not from here.ডেভেলপার · ঐচ্ছিক
দুই দিকের sync — অর্ডার ও স্ট্যাটাস
চাইলে EcomSolveBD-তে তৈরি অর্ডার ও স্ট্যাটাস আপনার অ্যাডমিনেও যাবে, আর আপনার অ্যাডমিনের স্ট্যাটাস EcomSolveBD-তে আসবে। নিচের ২টা endpoint না বানালে আগের মতোই সব শুধু EcomSolveBD-তে থাকবে — কিছু ভাঙবে না।
- অর্ডার webhook-এ idempotencyKey = custom:<আপনার অর্ডার নম্বর> — দুই দিকেই এই নম্বর দিয়ে অর্ডার মেলে।
- POST {Store URL}/api/ecomsolvebd/order-create — ড্যাশবোর্ডের Create Order আপনার অ্যাডমিনে (অর্ডার নম্বর ESB-C-…)।
- POST {Store URL}/api/ecomsolvebd/order-status — EcomSolveBD-এর স্ট্যাটাস (confirm, কুরিয়ার, delivered…) আপনার অ্যাডমিনে।
- দুটোতেই x-esb-signature (HMAC-SHA256, Webhook Secret) যাচাই করে { "ok": true } ফেরত দিন।
- আপনার অ্যাডমিনে স্ট্যাটাস বদলালে POST /api/v1/orders/channels/custom/status — কিন্তু যে বদল EcomSolveBD থেকে এসেছে সেটা ফেরত পাঠাবেন না।
- Store URL = ড্যাশবোর্ডে Connect করার সময় দেওয়া URL। Nuxt-এ server/api/ecomsolvebd/ ফোল্ডারে রুট বানালে একই ডোমেইনে চলে।
// Two-way sync (optional) — any backend; Node/Express shown. Nuxt: server/api/ecomsolvebd/*.post.ts
// EcomSolveBD calls {Store base URL from the dashboard}/api/ecomsolvebd/... — keep these paths exactly.
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.ECOMSOLVEBD_WEBHOOK_SECRET; // same Webhook Secret as the order webhook
const API_BASE = "https://api.ecomsolvebd.com";
const MERCHANT_KEY = process.env.ECOMSOLVEBD_MERCHANT_KEY;
// Verify x-esb-signature = "sha256=" + HMAC-SHA256(raw body, secret) — use the RAW body bytes
function verified(rawBody, header) {
const want = "sha256=" + createHmac("sha256", SECRET).update(rawBody).digest("hex");
const got = String(header || "");
return got.length === want.length && timingSafeEqual(Buffer.from(got), Buffer.from(want));
}
app.use("/api/ecomsolvebd", express.raw({ type: "application/json" }));
// 1) EcomSolveBD dashboard "Create Order" → your admin
app.post("/api/ecomsolvebd/order-create", async (req, res) => {
if (!verified(req.body, req.get("x-esb-signature"))) return res.status(401).json({ ok: false, error: "bad signature" });
const o = JSON.parse(req.body.toString("utf8"));
// o = { orderNumber: "ESB-C-…", currency, customer { fullName, phone, email? },
// items [{ sku, title, quantity, unitPrice }], district?, upazila?, address?, notes?, shippingTotal? }
await db.orders.create({ number: o.orderNumber, source: "ecomsolvebd", /* map the rest */ });
// Do NOT post this order back to /orders/channels/custom (it already exists in EcomSolveBD).
res.json({ ok: true }); // required — anything else = "not supported"
});
// 2) EcomSolveBD status change (confirm, courier, delivered…) → your admin
app.post("/api/ecomsolvebd/order-status", async (req, res) => {
if (!verified(req.body, req.get("x-esb-signature"))) return res.status(401).json({ ok: false, error: "bad signature" });
const s = JSON.parse(req.body.toString("utf8"));
// s = { orderNumber, status: "pending|confirmed|delivered|cancelled|returned",
// masterStatus, courierCode, trackingNumber, updatedAt }
await db.orders.updateStatus(s.orderNumber, s.status, { fromEcomSolveBD: true });
res.json({ ok: true });
});
// 3) Your admin status change → EcomSolveBD (skip when the change came from EcomSolveBD — loop guard)
async function pushStatusToEcomSolveBD(orderNumber, status) {
const raw = JSON.stringify({ orderNumber, status }); // status: pending|confirmed|delivered|cancelled|returned
const sig = "sha256=" + createHmac("sha256", SECRET).update(raw).digest("hex");
await fetch(`${API_BASE}/api/v1/orders/channels/custom/status`, {
method: "POST",
headers: { "content-type": "application/json", "x-merchant-key": MERCHANT_KEY, "x-esb-signature": sig },
body: raw,
});
}
// orderNumber is the part after "custom:" in the idempotencyKey you send with each order:
// idempotencyKey: `custom:${order.number}` → EcomSolveBD and your admin talk about the same order.ডেভেলপার · বাধ্যতামূলক
প্রোডাক্ট ফিড — নিজের সার্ভারে
এই পাথে রেডিমেড প্যাকেজ নেই — আপনার ব্যাকএন্ড নিচের হুবহু XML ফরম্যাটে /feed/products.xml দেবে। EcomSolveBD প্রোডাক্ট লিস্ট, প্রফিট ও ইনবক্স প্রোডাক্ট কার্ড এটা থেকেই আসে।
- চারটা পাবলিক GET URL (লগইন/মিডলওয়্যার/লোকেল রিডাইরেক্ট ছাড়া): /feed/products.xml (EcomSolveBD), /feed/facebook.xml, /feed/tiktok.xml, /feed/google.xml।
- সব active/published প্রোডাক্ট আসবে — খালি লিস্ট বা placeholder ডাটা নয়। ভ্যারিয়েশন থাকলে প্রতিটি ভ্যারিয়েন্ট আলাদা row (id = parentId-variantId)।
- id = ইভেন্টের item_id = অর্ডারের productId। price = ক্রেতা যে দাম দেয় (সেল প্রাইস থাকলে সেটা)।
- ইমেজ ও লিংক সম্পূর্ণ https URL (relative path নয়)। Meta/TikTok/Google ফিডে ইমেজ নেই বা price ≤ 0 হলে সেই প্রোডাক্ট বাদ দিন।
- XML escape করুন (& < > " ')। description-এ HTML নয়, plain text।
- কস্ট (buying_cost ইত্যাদি) ফিডে দেবেন না — EcomSolveBD-তে সেট করা কস্ট সিঙ্কে মুছে যায় না।
// Any backend (Node/Express shown) — GET /feed/products.xml from YOUR product table
const esc = (v) =>
String(v ?? "").replace(/[<>&'"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ })[c]);
const ORIGIN = "https://your-store.example";
app.get("/feed/products.xml", async (_req, res) => {
const rows = await db.products.findAll({ where: { active: true } }); // ALL active products
const out = ['<?xml version="1.0" encoding="UTF-8"?>',
'<products generated_at="' + new Date().toISOString() + '" currency="BDT">'];
for (const p of rows) {
const inStock = p.stock == null || p.stock > 0;
out.push(
" <product>",
" <id>" + esc(p.id) + "</id>", // = event item_id = order productId
" <title>" + esc(p.name) + "</title>",
" <price>" + Number(p.salePrice ?? p.price).toFixed(2) + "</price>",
" <currency>BDT</currency>",
" <category>" + esc(p.category) + "</category>",
" <image>" + esc(new URL(p.image, ORIGIN).href) + "</image>",
" <sku>" + esc(p.sku) + "</sku>",
" <product_url>" + esc(ORIGIN + "/product/" + p.slug) + "</product_url>",
" <stock>" + (inStock ? "in_stock" : "out_of_stock") + "</stock>",
" <stock_qty>" + (p.stock == null ? "unlimited" : Math.max(0, p.stock)) + "</stock_qty>",
" </product>",
);
}
out.push("</products>");
res.set("Content-Type", "application/xml; charset=utf-8");
res.set("Cache-Control", "public, max-age=300");
res.send(out.join("\n") + "\n");
});
// /feed/facebook.xml, /feed/tiktok.xml, /feed/google.xml: same rows in the RSS format shown
// below, skipping products with price <= 0 or without an absolute https image.<?xml version="1.0" encoding="UTF-8"?>
<products generated_at="2026-10-23T10:00:00Z" currency="BDT">
<product>
<id>1042</id> <!-- required · same as event item_id + order productId -->
<title>Cotton Panjabi — Navy</title> <!-- required -->
<price>1450.00</price> <!-- required · selling price, number only -->
<currency>BDT</currency>
<category>Panjabi</category>
<image>https://shop.example/img/1042.jpg</image> <!-- absolute https -->
<sku>PJ-NAVY-42</sku>
<product_url>https://shop.example/product/cotton-panjabi-navy</product_url>
<stock>in_stock</stock> <!-- in_stock | out_of_stock -->
<stock_qty>25</stock_qty> <!-- integer, or "unlimited"; 0 = out of stock -->
</product>
</products><?xml version="1.0" encoding="utf-8"?> <rss version="2.0" xmlns:g="http://base.google.com/ns/1.0"> <channel> <title>My Shop Product Feed</title> <link>https://shop.example/</link> <description>Product Feed</description> <item> <g:id>1042</g:id> <g:title>Cotton Panjabi — Navy</g:title> <g:description>Plain-text description (no HTML)</g:description> <g:link>https://shop.example/product/cotton-panjabi-navy</g:link> <g:image_link>https://shop.example/img/1042.jpg</g:image_link> <g:brand>My Shop</g:brand> <g:condition>new</g:condition> <g:availability>in stock</g:availability> <!-- google.xml: in_stock / out_of_stock --> <g:price>1450.00 BDT</g:price> <g:product_type>Men > Panjabi</g:product_type> <!-- google.xml only: <g:identifier_exists>no</g:identifier_exists> when no GTIN/MPN --> <!-- tiktok.xml only: <sku_id>PJ-NAVY-42</sku_id> --> </item> </channel> </rss>
# Replace with your public store origin curl -sI https://your-store.example/feed/products.xml | head -n 5 # HTTP 200 + application/xml curl -s https://your-store.example/feed/products.xml | head -n 40 # real <product> rows curl -s https://your-store.example/feed/facebook.xml | head -n 30 curl -s https://your-store.example/feed/google.xml | head -n 30 # Then in EcomSolveBD: # Dashboard → Products → Feed URL = https://your-store.example/feed/products.xml # → Save → Fetch now → products appear in the list (auto re-fetch interval on the same card) # Ads catalogs: # Meta Commerce Manager → Data sources → Scheduled feed → /feed/facebook.xml # TikTok Ads → Catalog → Data feed → /feed/tiktok.xml # Google Merchant Center → Products → Feeds → Scheduled fetch → /feed/google.xml
শেষ চেকলিস্ট — ডেভেলপার / AI এজেন্টের জন্য
ডেভেলপার বা AI এজেন্টকে এই চেকলিস্টটা হুবহু দিন — শেষে প্রতিটি ইভেন্ট কোন ফাইলে ফায়ার হচ্ছে তার রিপোর্ট চাইবে।
EcomSolveBD × Custom webhook — implementation checklist (do ALL items; do not skip silently)
1. Inventory the site first: product page, quick view, cart page/drawer, checkout page,
shipping selector, payment selector, and EVERY checkout form input (name attribute + meaning).
2. Install the base (env, tracker, collect/attribution proxies, order webhook, order-status) per this page.
3. Create the event helper from this page and fire EVERY event in the events table at its UI moment:
(landing_page_view = tracker/bootstrap sends it once on the visitor's first-ever load — do not fire it yourself)
page_view, view_item, add_to_cart, remove_from_cart, view_cart, begin_checkout,
add_shipping_info, add_payment_info, checkout_form_started, first_name_added, last_name_added,
phone_number_added, email_address_added, address_added, city_added, state_added,
postal_code_added, country_added, checkout_form_completed.
If the site has no such UI/field, skip that event and write a code comment explaining why.
4. Use ONE product id everywhere: event item_id = feed <id> = order item productId.
Every event also carries device, device_model, os, browser (Device & technology section) —
the tracker/bootstrap adds them; never strip them from the collect body.
5. Product feed: Expose GET /feed/products.xml (exact schema in the Product feed section) and, for ads catalogs, /feed/facebook.xml, /feed/tiktok.xml, /feed/google.xml from the real product table.
6. Order: post the order from the SERVER after it is saved (never a browser "purchase").
trackingUserId MUST be the browser's esb_vid cookie (read it in the checkout request, or store it
on the order) — otherwise buyers who ordered still appear as abandoned checkouts.
phone_number_added / email_address_added must carry the real { phone } / { email } value,
first_name_added the real { name } and last_name_added the real { last_name } (Customer column).
Names, phones and emails stay in EcomSolveBD — they are not forwarded to GA4 / Meta / TikTok.
7. Verify (report results for each):
- Open the site, view a product, add/remove from cart, open cart, open checkout, fill every field,
place a test order.
- Dashboard → Events / Realtime: every event above appears with correct item_id, price, quantity.
- Dashboard → Orders: the test order is there with the same productId.
- curl -s https://<store>/feed/products.xml | head -40 → HTTP 200, real products, absolute https images.
- Dashboard → Products → Feed URL = https://<store>/feed/products.xml → Save → Fetch now → products listed.
8. Final report: a table "event → file:line where it fires" + list of skipped events with reasons.আরও ফিচার
অর্ডার স্ট্যাটাস
Custom webhook-এ স্ট্যাটাস EcomSolveBD ড্যাশবোর্ড থেকে ম্যানেজ হয়।
- · pending · confirmed · delivered · cancelled · returned
- · Courier বুকিং / manual Send → Meta · TikTok · GA4 · Google Ads
- · দুই দিকের sync (ঐচ্ছিক): ডেভেলপার ২টা endpoint বানালে Create Order ও স্ট্যাটাস আপনার অ্যাডমিনেও যায় — নিচে “দুই দিকের sync”
প্রোডাক্ট ও খরচ
প্রোডাক্ট আপনার সাইট/API-তেই। EcomSolveBD-এ শুধু optional cost ফিল্ড।
- · Dashboard → Products → Manual products
- · buying_cost · packaging_cost · extra_cost
- · Product feed: নিজের সার্ভারে /feed/products.xml (+ 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 পাব?
- হ্যাঁ, তবে প্যাকেজ নেই — আপনার সার্ভার নিচের হুবহু XML ফরম্যাটে /feed/products.xml দেবে, তারপর Dashboard → Products → Feed URL-এ বসাবেন।
- অর্ডার কীভাবে আসে?
- চেকআউট সফলে সার্ভার webhook — ব্রাউজার purchase ইভেন্ট নয়।
- Secret হারিয়ে গেলে?
- Website → Custom / Headless → Custom webhook (open to manage) → 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 হয়েছে কিনা চেক করুন।
· বাংলাদেশ সময়