EcomSolveBD

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

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

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

Dashboard → Custom / Headless → Custom webhook · যেকোনো PHP/Node/Landing · signed order REST।

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

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

    আপনি
    • Integrations → Custom / Headless → Custom webhook
    • Store / landing base URL দিন — https://shop.example.com
    • GA4 Measurement ID (optional) — G-XXXXXXXX
    • Connect custom webhook চাপুন

    Tracking Merchant ID + Webhook Secret কপি করুন — Secret এখনই সেভ করুন।

  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 ইভেন্ট
    • নিচের API রেফারেন্স — Laravel/Next.js প্যাকেজ কপি নয়

    প্রতি স্টোরের checkout আলাদা — শুধু signed REST কন্ট্রাক্ট ইমপ্লিমেন্ট করুন।

  2. টেস্ট করুন

    দুজনে
    • ল্যান্ডিং খুলে Events-এ page_view দেখুন
    • টেস্ট অর্ডার → Dashboard → Orders
    • Products-এ manual buying cost যোগ করুন (optional)
    • SaaS-এ স্ট্যাটাস বদলিয়ে courier/ads ফ্লো চেক করুন

ডেভেলপার রেফারেন্স

Custom webhook API

কোনো npm/Composer প্যাকেজ নেই — আপনার PHP, Node, Python বা অন্য স্ট্যাক signed REST দিয়ে অর্ডার পাঠায়।

Environment

Merchant Key + Webhook Secret ড্যাশবোর্ড Connect থেকে। Secret শুধু সার্ভারে।

.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 গাইড

ইভেন্ট কোড উদাহরণ

ফানেল ইভেন্ট পাঠান

Next.js SDK-র esbTrack()-এর মতো — এখানে raw collect REST। প্রতিটি কার্ডে কপি-পেস্ট কোড।

কন্ট্রাক্ট

Transport (you implement in the browser — no npm package on this path):
  esbCollect(eventName, props?)  →  POST cfg.data.endpoint
  Header: x-merchant-key: <tracking merchant id>
  Body: { event, source: "custom_tracker", user_id, client_id, ga_session_id, ga_session_number,
          sass_client_id, session_started_at, journey_id, first_seen_at, last_seen_at,
          device, device_model, browser, os, language, screen_*,
          click_ids, raw_query_params, normalized_click_ids, normalized_attribution,
          first_touch, last_touch, session_touch, identity { ga_client_id, fbp, fbc },
          client_event_id, … }

Bootstrap once (landing / SPA shell):
  GET /api/v1/custom/tracker-config  (x-merchant-key)
  → cfg.data.endpoint for collect
  → cfg.data.ga4MeasurementId — paste into gtag (G-XXXXXXXX)
  → cfg.data.accessCheckUrl — call on load; if blocked, show full-screen message
  → esbCollect must handle 403 IP_BLOCKED (same as Woo/Shopify tracker)

GA4 (same parity as Woo / Shopify / Next.js):
  Install gtag on YOUR site with Measurement ID from dashboard / tracker-config.
  esbCollect reads _ga + _ga_* cookies — do not invent client_id or session ids.
  First visit → landing_page_view; returning → page_view; retry page_view ~2s after load when cookies appear.

Host responsibility:
  Map YOUR domain moments → event names below.
  Do not invent visitor ids / fbp / gclid — read esb_vid + browser cookies + URL params.
  Microsoft Clarity: read _clck cookie → clarity_id on every collect + one esb_clarity_sync per session.
  Google Ads: persist gclid/wbraid/gbraid across pages (final_google_click_id on every hit).
  Meta: synthesize _fbc from fbclid landing (same as Woo tracker).
  Forward esb_vid on order webhook as trackingUserId (+ clientIp + attribution object for purchase payload).
  Every esbCollect row: source: "custom_tracker" (store-scoped reports + abandon recovery).

Order webhook attribution (same Core as Woo):
  POST /orders/channels/custom with attribution { fbclid, gclid, ttclid, utmSource, utmMedium, utmCampaign,
  lastClick { utm_content, utm_term, wbraid, gbraid, msclkid, … } } from stored URL params / session.

Abandon (Checkout drop-off — same Core as Woo / Shopify / Next.js):
  On blur send email_address_added { email } or phone_number_added { phone } — value required.
  Core starts idle timer; after N minutes writes checkout_form_abandoned. Never send abandon from browser.

Call sites are yours (vanilla JS, React, Vue, static HTML, PHP template).
This guide does not prescribe routes, ORM, CMS, or UI libraries.

পেজ ভিউ

bootstrap + স্ক্রিন/URL পরিবর্তনে ফায়ার। রুট স্ট্রাকচার আপনার।

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). */
function detectDeviceFromUa() {
  const ua = String(navigator.userAgent || "");
  const device = /Mobi|Android.*Mobile|iPhone/i.test(ua)
    ? "Mobile"
    : /iPad|Tablet/i.test(ua)
      ? "Tablet"
      : "Desktop";
  const chrome = ua.match(/Chrome\/(\d+)/i);
  const browser = chrome ? "Chrome " + chrome[1] : "Browser";
  const android = ua.match(/Android (\d+)/i);
  const ios = ua.match(/iPhone OS (\d+_\d+)/i);
  const os = android
    ? "Android " + android[1]
    : ios
      ? "iOS " + ios[1].replace("_", ".")
      : "OS";
  return {
    device,
    browser,
    os,
    language: navigator.language ? String(navigator.language).slice(0, 32) : undefined,
    screen_resolution:
      screen.width && screen.height ? Math.floor(screen.width) + "x" + Math.floor(screen.height) : undefined,
  };
}

function ensureFirstTouch(snap) {
  const FT_KEY = "esb_first_touch";
  try {
    const raw = localStorage.getItem(FT_KEY);
    if (raw) return { snap: JSON.parse(raw), isNew: false };
    localStorage.setItem(FT_KEY, JSON.stringify(snap));
    return { snap, isNew: true };
  } catch {
    return { snap, isNew: false };
  }
}

/** Microsoft Clarity _clck — first segment before ^ (same as Woo/Shopify tracker). */
function readClckRaw() {
  const m = document.cookie.match(/(?:^|;\s*)_clck=([^;]*)/);
  if (!m?.[1]) return "";
  try {
    return decodeURIComponent(m[1]);
  } catch {
    return m[1];
  }
}

function readClarityCookieValue() {
  let s = readClckRaw().trim();
  if (!s) return "";
  try {
    s = decodeURIComponent(s);
  } catch {
    /* keep */
  }
  const first = s.split("^")[0]?.trim() || "";
  return first.slice(0, 64);
}

/** Clarity writes _clck after load — poll once per session (esb_clarity_sync). */
function syncClarityOnce() {
  const trySync = () => {
    const clarityId = readClarityCookieValue();
    if (!clarityId) return false;
    try {
      const sk = "esb:clarity_synced:" + MERCHANT_KEY.slice(0, 12);
      if (sessionStorage.getItem(sk) === clarityId) return true;
      sessionStorage.setItem(sk, clarityId);
    } catch {
      /* ignore */
    }
    void esbCollect("esb_clarity_sync", { clarity_id: clarityId });
    return true;
  };
  if (trySync()) return;
  const started = Date.now();
  const timer = setInterval(() => {
    if (trySync() || Date.now() - started >= 20000) clearInterval(timer);
  }, 500);
}

async function esbCollect(eventName, extra = {}) {
  if (!collectEndpoint) await loadTrackerConfig();
  if (document.getElementById("esb-ip-block-overlay")) return false;
  const now = captureFromHref(location.href);
  const ft = ensureFirstTouch({
    occurredAt: new Date().toISOString(),
    landingUrl: location.href,
    url: location.href,
    clickIds: now.click_ids,
    utm: now.utm,
  });
  const sess = getOrCreateSession();
  const ga = gaMetrics();
  const google = buildGoogleClickPayload(now.click_ids);
  const meta = metaBrowserFields(now.click_ids);
  const clarity_id = readClarityCookieValue() || undefined;
  const identity = {
    ...(ga.client_id ? { ga_client_id: ga.client_id } : {}),
    ...(meta.fbp ? { fbp: meta.fbp } : {}),
    ...(meta.fbc ? { fbc: meta.fbc } : {}),
  };
  const visitorMeta = readVisitorMeta();
  const device = detectDeviceFromUa();
  const body = {
    event: eventName,
    source: "custom_tracker",
    timestamp: new Date().toISOString(),
    client_event_id: clientEventId(),
    journey_id: journeyId(),
    user_id: visitorId(),
    session_id: sess.id,
    session_number: sess.session_number,
    sass_session_id: sess.id,
    sass_session_number: sess.session_number,
    session_started_at: sess.started_at,
    first_seen_at: visitorMeta.first_seen_at,
    last_seen_at: visitorMeta.last_seen_at,
    url: location.href,
    path: location.pathname + location.search,
    referrer: document.referrer || undefined,
    title: typeof document !== "undefined" ? document.title : undefined,
    client_user_agent: navigator.userAgent,
    first_touch: ft.snap,
    ...(Object.keys(now.utm).length ? { utm: now.utm } : {}),
    ...(clarity_id ? { clarity_id } : {}),
    ...(Object.keys(now.click_ids).length ? { click_ids: now.click_ids } : {}),
    ...(Object.keys(now.raw_query_params || {}).length ? { raw_query_params: now.raw_query_params } : {}),
    ...(Object.keys(now.normalized_click_ids || {}).length ? { normalized_click_ids: now.normalized_click_ids } : {}),
    ...(Object.keys(now.normalized_attribution || {}).length ? { normalized_attribution: now.normalized_attribution } : {}),
    ...(Object.keys(identity).length ? { identity } : {}),
    ...device,
    ...ga,
    ...google,
    ...meta,
    ...extra,
  };
  async function postCollect(endpoint) {
    return fetch(endpoint, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "x-merchant-key": MERCHANT_KEY,
      },
      body: JSON.stringify(body),
      keepalive: true,
    });
  }
  let res;
  try {
    res = await postCollect(collectEndpoint);
  } catch {
    if (fallbackCollectEndpoint && fallbackCollectEndpoint !== collectEndpoint) {
      res = await postCollect(fallbackCollectEndpoint);
    } else {
      return false;
    }
  }
  if (res.status === 403) {
    try {
      const j = await res.clone().json();
      if (j && (j.blocked === true || j.error?.code === "IP_BLOCKED")) {
        showIpBlockedOverlay(j.error?.message || j.message);
        return false;
      }
    } catch {
      /* ignore */
    }
  }
  return res.ok;
}

async function fireInitialPageView() {
  await loadTrackerConfig();
  const ft = ensureFirstTouch({
    occurredAt: new Date().toISOString(),
    landingUrl: location.href,
    url: location.href,
    ...captureFromHref(location.href),
  });
  await esbCollect(ft.isNew ? "landing_page_view" : "page_view");
  setTimeout(() => {
    try {
      const ga = gaMetrics();
      if (ga.client_id || ga.ga_session_id || ga.fbp || ga.fbc) void esbCollect("page_view");
    } catch {
      /* ignore */
    }
  }, 2000);
}

// GA4 gtag — paste Measurement ID from Dashboard / tracker-config (before esbCollect)
// <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXX"><\/script>
// <script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}
// gtag('js', new Date()); gtag('config', 'G-XXXXXXXX');<\/script>

// Fire once on load — first visit = landing_page_view, then retry page_view when _ga cookies appear
await fireInitialPageView();

// SPA: fire page_view on route change (not landing_page_view)
// useEffect(() => { void esbCollect("page_view"); }, [pathname, searchParams]);

আরও ফিচার

অর্ডার স্ট্যাটাস

Custom webhook-এ স্ট্যাটাস EcomSolveBD ড্যাশবোর্ড থেকে ম্যানেজ হয়।

  • · pending · confirmed · delivered · cancelled · returned
  • · Courier বুকিং / manual Send → Meta · TikTok · GA4 · Google Ads
  • · Store-এ auto push নেই — Laravel/Next.js package নিন যদি দুই-দিক sync দরকার

প্রোডাক্ট ও খরচ

প্রোডাক্ট আপনার সাইট/API-তেই। EcomSolveBD-এ শুধু optional cost ফিল্ড।

  • · Dashboard → Products → Manual products
  • · buying_cost · packaging_cost · extra_cost
  • · Product feed (Meta/TikTok/Google XML) এই পাথে নেই

First-party ট্র্যাকিং

স্ট্যাটিক ল্যান্ডিং — DNS verify করলে collect আপনার subdomain দিয়ে।

  • · Default: api.ecomsolvebd.com/collect (x-merchant-key)
  • · Dashboard: CNAME → Verify DNS → tracking.{domain}
  • · ডেভ: tracker-config URL; owner: DNS (কোড নয়)

সাধারণ প্রশ্ন

Laravel বা Next.js package লাগবে?
না। Custom webhook = raw signed REST। package চাইলে Laravel/Next.js পাথ বেছে নিন।
Product feed পাব?
না। Custom webhook-এ XML catalog feed নেই। Laravel/Next.js package সেই পাথে feed দেয়।
অর্ডার কীভাবে আসে?
চেকআউট সফলে সার্ভার webhook — ব্রাউজার purchase ইভেন্ট নয়।
Secret হারিয়ে গেলে?
Integrations → Custom webhook → Create new secret।

সমস্যা হলে

401 Invalid signature

Raw JSON body দিয়ে HMAC করুন — parsed object নয়। Secret মিলছে কিনা দেখুন।

page_view আসছে না

tracker-config থেকে endpoint নিন। x-merchant-key header ঠিক আছে কিনা।

অর্ডার আসছে না

Connect custom webhook আগে। trackingUserId = esb_vid। customer.phone বা email দিন।

Attribution backup দেখাচ্ছে

Order webhook-এ esb_vid পাঠান। Collect ইভেন্ট আগে landing-এ fire হয়েছে কিনা চেক করুন।

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

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

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