EcomSolveBD

· বাংলাদেশ সময়

Custom webhook ইন্টিগ্রেশন

Custom webhook দিয়ে EcomSolveBD-তে কানেক্ট করুন

Dashboard → Website → Connect / Create → হ্যাঁ → Custom / Headless → Custom webhook · যেকোনো PHP/Node/Nuxt/Landing · signed order REST।

  1. ১

    অ্যাকাউন্ট খুলুন

    আপনি
    • ecomsolvebd.com এ রেজিস্টার / লগইন
    • ড্যাশবোর্ডে ঢুকুন
  2. ২

    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 এখনই সেভ করুন।

  3. ৩

    ডেভেলপারকে পাঠান

    আপনি
    • Tracking Merchant ID
    • Webhook Secret
    • Order ingest URL
    • লাইভ শপ URL
  1. ৪

    ডেভেলপার: 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-কে দিন।

  2. ৫

    টেস্ট করুন

    দুজনে
    • প্রোডাক্ট দেখুন → কার্টে যোগ/বাদ → কার্ট → চেকআউট → প্রতিটি ফিল্ড পূরণ → অর্ডার
    • 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 শুধু সার্ভারে।

.env
# 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।

POST /api/v1/orders/channels/custom
{
  "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 ইভেন্ট নয়।

checkout-success.ts
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 দিয়ে।

post-order.php
$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-এর পর বদলাতে পারে।

GET /api/v1/custom/tracker-config
// 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 bootstrap

GA4 gtag install

Woo/Shopify/Next.js-এর মতো — site-এ gtag লাগান; esbCollect _ga cookie পড়ে। Measurement ID Dashboard বা tracker-config থেকে।

layout.html · gtag
<!-- 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_addedFirst name / পূর্ণ নাম ফিল্ড পূরণ হলে (change/blur) — আসল নাম পাঠান (Checkout drop-off-এ Customer){ name, field_value_len }শর্তসাপেক্ষ — সাইটে থাকলে হ্যাঁ
last_name_addedLast name ফিল্ড পূরণ হলে — আসল মান পাঠান (সার্ভার first + last জোড়া দেয়){ last_name, field_value_len }শর্তসাপেক্ষ — সাইটে থাকলে হ্যাঁ
phone_number_addedমোবাইল নম্বর পূরণ হলে — আসল নম্বর পাঠান (abandon recovery){ phone }হ্যাঁ
email_address_addedইমেইল পূরণ হলে — আসল ইমেইল পাঠান{ email }শর্তসাপেক্ষ — সাইটে থাকলে হ্যাঁ
address_addedঠিকানা ফিল্ড পূরণ হলে{ field_value_len }শর্তসাপেক্ষ — সাইটে থাকলে হ্যাঁ
city_addedCity / জেলা (district) বাছাই বা পূরণ হলে{ field_value_len }শর্তসাপেক্ষ — সাইটে থাকলে হ্যাঁ
state_addedState / বিভাগ / থানা-উপজেলা বাছাই হলে{ 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 বডিতে এগুলো যোগ করে — ফাংশনটা বাদ দেবেন না বা ছোট করবেন না।

collect body
// 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 কীভাবে তৈরি হয়

  1. ক্রেতা চেকআউটে ফোন বা ইমেইল লিখলে phone_number_added { phone } / email_address_added { email } পাঠান — আসল মানসহ। মান ছাড়া পাঠালে টাইমার চালু হয় না।
  2. সার্ভার ঐ ভিজিটরের (esb_vid) জন্য idle টাইমার চালু করে (ডিফল্ট ১০ মিনিট)। এরপর যেকোনো ইভেন্ট (page_view, অন্য ফিল্ড…) এলে টাইমার আবার শুরু থেকে গোনে।
  3. অর্ডার webhook এলে টাইমার বাতিল হয় — কিন্তু শুধু তখনই, যখন webhook-এর trackingUserId = ব্রাউজারের esb_vid কুকি। ভুল বা খালি হলে ক্রেতা অর্ডার দিলেও ১০ মিনিট পর তাকে abandoned দেখাবে।
  4. অর্ডার না এলে টাইমার শেষে সার্ভার নিজে checkout_form_abandoned লেখে → Dashboard-এর Checkout drop-off / Abandon recovery-তে নাম, ফোন ও ইমেইলসহ দেখা যায়। নাম আসে first_name_added { name } (+ last_name_added { last_name }) থেকে।
  5. অর্ডার পাঠান চেকআউটের সেই request থেকেই (যেখানে esb_vid কুকি আছে), অথবা esb_vid অর্ডারে সেভ করে পরে পাঠান। কুকি ছাড়া queue/cron থেকে পাঠালে visitor মিলবে না।
  1. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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).

সব কল-সাইট একনজরে

call sites
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 (example)
// 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-তে সেট করা কস্ট সিঙ্কে মুছে যায় না।
GET /feed/products.xml (example)
// Any backend (Node/Express shown) — GET /feed/products.xml from YOUR product table
const esc = (v) =>
  String(v ?? "").replace(/[<>&'"]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", '"': "&quot;" })[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.
expected · products.xml
<?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>
expected · facebook / tiktok / google.xml
<?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 &gt; 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>
verify
# 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 এজেন্টকে এই চেকলিস্টটা হুবহু দিন — শেষে প্রতিটি ইভেন্ট কোন ফাইলে ফায়ার হচ্ছে তার রিপোর্ট চাইবে।

checklist.txt
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 হয়েছে কিনা চেক করুন।

সাহায্য লাগলে

শপ URL + স্ক্রিনশট + webhook response পাঠান।

· বাংলাদেশ সময়