FLYER · Implementation spec · 19/09/2026

Event tracking on flyer.vn

What fires today on the Phòng Thi Ảo landing page, what each trigger should be, and the exact browser and server design that lets campaigns optimise for paid orders instead of clicks. Written against the live page and the live orders Worker.

Page
flyer.vn/phong-thi-ao
Meta dataset
490693673604378
Order API
flyer.vn/phong-thi-ao/api
Events live today
6 standard, 18 custom
Missing
event id, matching, server events
Proposal for discussion
Xuất xứ / Prompt / Giới hạn

Written on 19/09/2026 in reply to: "details event trigger in website, for example we're setting up event on https://flyer.vn/phong-thi-ao/".

The audit is not from memory. The live page source was read, a headless browser loaded it and recorded every request the pixel sent while clicking through a grade, a package and a checkout, and the order API was probed with read-only requests. Nothing was written to production. Code in this document is a proposal and has not been deployed.

Graph API version numbers move; use the current version at the time of implementation rather than copying the one written here.

1What fires today

The page carries the Meta pixel for dataset 490693673604378. The library is loaded lazily, on the first touch, key press or scroll, and otherwise when the browser goes idle or the page is hidden. Calls made before it loads are queued and flushed, so nothing is lost as long as the visitor does anything at all.

A headless run on 19/09/2026 recorded these, in this order, while loading the page with a campaign parameter, picking a grade, picking a package and creating an order.

EventTypeFires whenParameters observed
PageViewStandardPage loadnone
ViewContentStandardPage load, immediately after PageViewcontent_name, content_category
PTA_ViewGradesCustomThe grade picker scrolls into viewsrc
PTA_GradePickCustomA grade button is clickedsrc, grade
AddToCartStandardA package card is selectedvalue, currency, content_name, content_ids
PTA_PlanSelectCustomSame click as AddToCartpkg, value
PTA_Scroll25/50/75CustomScroll depth crossed, once eachsrc
PTA_PhoneFilled, PTA_EmailFilledCustomA valid phone or email is entered, once eachsrc
InitiateCheckoutStandardThe order is created and the transfer QR appearsvalue, currency, content_name, content_ids
PTA_QRShownCustomSame momentpkg, value
LeadStandardThe parent presses "Đã chuyển khoản"value, currency, content_name
PurchaseStandardThe five-second poll sees the order marked paidvalue, currency, content_name, content_ids
Also live as custom events: PTA_CTA, PTA_ZaloClick, PTA_VideoPlay, PTA_PressVideoPlay, PTA_CopyCode, PTA_ClaimTransfer, PTA_AppDownload, PTA_LeaveAfterRead, PTA_ExitOffer_Shown, PTA_ExitOffer_Click, PTA_ExitOffer_Close.

Every event carries src, taken from utm_content or utm_campaign, so behaviour can be read per ad creative. The click identifier is captured correctly: loading the page with an fbclid produced both _fbc and _fbp cookies.

Three facts that limit everything else

  1. No event identifier. Every request went out with eid empty. Without it, a server event and a browser event describing the same purchase would be counted twice, so the conversions interface cannot be turned on safely until this is fixed. This is the first change, not the last.
  2. No matching data. The pixel is initialised with the dataset alone. The page asks for a phone number, which in Vietnam is the strongest identifier there is, and never passes it to Meta. Match quality stays at whatever the browser cookie can carry.
  3. Purchase depends on an open tab. It fires only inside the polling callback. A parent who transfers the money and closes the page, or who pays the next morning, produces no purchase event at all. Every campaign optimising on purchases is therefore learning from a subset of real buyers, biased toward the fastest ones.

2The three kinds of trigger

Website events get mixed up because the word "event" covers three different things. Separating them decides where the code lives and what the campaign may optimise for.

KindWhat fires itExamples on this pageRule
Page lifecycleThe browser, with no decision by the visitor: load, scroll depth, dwell time, exitPageView, PTA_Scroll50, PTA_LeaveAfterReadRead them as interest. Never optimise a campaign on them and never attach money.
InteractionA deliberate act on an element: click, field completed, selection changed, video playedPTA_GradePick, AddToCart, PTA_ZaloClick, PTA_PhoneFilledMicro-conversions. Useful for audiences and for diagnosing where the page loses people. Optimise on them only while purchase volume is too low to learn from.
StateA record changing state in a system of record: order created, claimed, paid, cancelledInitiateCheckout, Lead, PurchaseThe only events allowed to carry value. They must be emitted by the server that owns the state, not by the page.

The third row is the one this page has not built yet, and it is exactly the model behind the Pancake teardown: a seller picks an order status, and the platform turns that status change into the conversion event. FLYER already has the equivalent state machine. The orders Worker at flyer.vn/phong-thi-ao/api holds every order in key-value storage and moves it through created, claimed and fulfilled. That Worker is the right place for the money events, and it is already written.

3The event map for this page

The target design. The live marker means it already works; to build means it does not exist yet.

StageEventExact triggerSent fromEvent id
ArrivalPageView livePage loadBrowserautomatic
ReadViewContent livePage loadBrowserview:<session> to build
ConsiderAddToCart liveA package card is selected, including the one the grade picker selects for the parentBrowsercart:<pkg>:<session> to build
IdentifyPTA_PhoneFilled liveThe phone passes validation. Also the moment to re-initialise the pixel with matching data.Browsernot needed
CheckoutInitiateCheckout liveOrder created and the transfer QR appearsBrowser, and the Worker on POST /orders to buildcheckout:<order code>
IntentLead live"Đã chuyển khoản" is pressedBrowser, and the Worker on POST /orders/<code>/claim to buildlead:<order code>
MoneyPurchaseThe order is marked paid in the Worker, by the support team today and by the bank webhook later. Never by a button on the page.Worker to build. A browser copy is optional once the ids match.purchase:<order code>
CancellednoneOrder cancelled or refundedSend nothing. Never reverse a purchase with a negative value.—

Money events carry value, currency, content_ids, external_id and the hashed phoneCurrency is always VND, value is what is actually chargedOrder code example: FLYERZ5BYUD

Why the purchase moves to the server The order code is generated on the page and travels into the QR description, so the Worker can match a bank transfer to an order. That makes the Worker, not the browser, the only place that knows a payment really arrived. Moving the purchase event there also fixes the tab problem, the ad-blocker problem and the iOS problem in one change.

4What to add, in order

4.1 Give every event an identifier

This changes nothing visible and unblocks everything else. The identifier must be derived from the order code wherever an order exists, so the browser and the Worker produce the same string without talking to each other.

const rnd = () => Math.random().toString(36).slice(2, 10);
const SESSION = rnd();                         // one per page view

// standard event, with an explicit id
const std = (name, params, eventID) => {
  try {
    if (window.fbq) fbq('track', name,
      Object.assign({}, SRC, params || {}),
      { eventID: eventID || (name.toLowerCase() + ':' + SESSION) });
  } catch (e) {}
};

// at checkout, after the order code is known
std('InitiateCheckout', { value: pk.price, currency: 'VND',
    content_ids: [k], content_name: pk.name }, 'checkout:' + o.code);

Meta treats the same event name with the same identifier, arriving within forty-eight hours, as one event. That is what makes it safe for the page and the Worker to both report a checkout.

4.2 Hand the identity to Meta

Two separate things. In the browser, re-initialise the pixel with matching data once the parent has typed a valid phone number; the library normalises and hashes the values before they leave the device.

// after phoneNorm() returns a valid number
fbq('init', '490693673604378', {
  ph: '84' + phone.slice(1),   // 0912345678 -> 84912345678
  em: email || undefined,
  external_id: orderCode
});

On the server, the Worker needs the browser cookies to attribute the event, so they have to travel with the order. Add four fields to the existing POST /orders body.

const ck = n => (document.cookie.match('(^|;)\\s*' + n + '\\s*=\\s*([^;]+)') || [])[2] || '';

body: JSON.stringify({
  phone, phone_e164: '+84' + phone.slice(1), email, grade,
  package: k, amount: pk.price, source: 'landing-v2', code: mkCode(),
  fbp: ck('_fbp'),
  fbc: ck('_fbc'),               // present only if the visit carried fbclid
  page_url: location.href,
  ua: navigator.userAgent
});

Store those four with the order. They are what lets a purchase confirmed three hours later still be attributed to the ad that produced it.

4.3 Send the money events from the Worker

One function, called from the three places the order changes state. It needs two secrets on the Worker: the dataset identifier and a conversions access token generated in Events Manager.

const sha256 = async (v) => {
  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(v));
  return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
};

async function metaEvent(env, name, order, eventId, extra = {}) {
  const user_data = {
    ph: [await sha256(String(order.phone_e164 || '').replace(/\D/g, ''))],
    client_user_agent: order.ua,
    fbp: order.fbp || undefined,
    fbc: order.fbc || undefined,
    external_id: [await sha256(order.code)]
  };
  if (order.email) user_data.em = [await sha256(order.email.trim().toLowerCase())];

  const payload = { data: [{
    event_name: name,
    event_time: Math.floor(Date.now() / 1000),
    event_id: eventId,
    action_source: 'website',
    event_source_url: order.page_url || 'https://flyer.vn/phong-thi-ao/',
    user_data,
    custom_data: Object.assign({
      value: order.amount, currency: 'VND',
      content_ids: [order.package], content_type: 'product'
    }, extra)
  }] };

  await fetch(`https://graph.facebook.com/v21.0/${env.PIXEL_ID}/events?access_token=${env.CAPI_TOKEN}`,
    { method: 'POST', headers: { 'content-type': 'application/json' },
      body: JSON.stringify(payload) });
}

Wire it to the endpoints that already exist in the Worker.

Worker endpointEvent to sendEvent id
POST /ordersInitiateCheckoutcheckout:<code>
POST /orders/<code>/claimLeadlead:<code>
POST /admin/fulfil with an activation codePurchasepurchase:<code>
POST /admin/fulfil with action: 'cancel'nothing—

Send it with waitUntil so a slow call to Meta never delays the response the support page or the parent is waiting for. Write the identifier into the stored order as well, so a retry cannot produce a second purchase with a new identifier.

4.4 Close the loop with the bank

The purchase event is only as timely as the moment someone marks the order paid. Today that is a person on the support page. The SePay webhook matching the transfer description to the order code is the last piece, and it is the same piece the activation-code work is waiting on. When it lands, point it at the fulfil path so the purchase event and the activation code come from one place.

5Naming and parameter rules

6How to verify

  1. Test events. In Events Manager open the dataset, take the test code, and pass it as test_event_code in the Worker payload while testing. Browser and server events appear in the same live view, which is where deduplication can be seen working: one purchase, two sources, one row.
  2. Check the overlap. After a week, the dataset's diagnostics report a deduplication rate for each event name. If the browser and server purchase counts are both present but the deduplication rate is near zero, the identifiers do not match.
  3. Watch match quality. Events Manager scores each event's customer information. Adding the hashed phone should move the purchase event well up that scale.
  4. Reconcile against the order book weekly. The Worker's admin list is the truth. Compare its paid orders against purchases in Ads Manager. A gap is normal because of the attribution window; a gap that grows means events are failing.
  5. Re-run the browser trace after any change to the page. Loading the page with a campaign parameter and clicking through to the QR should produce the same ordered list of events as section 1.

7Google, TikTok and Zalo

The page currently has no analytics tag, no TikTok pixel and no Google tag. That is a defensible choice while only Meta campaigns point here, and it keeps the page fast. Three things are worth knowing before that changes.

8What changes in the ad account

The measurement work only pays off if the campaigns are told to use it.

Weekly volumeOptimise forWhy
Under about 20 purchasesInitiateCheckoutToo few purchases for delivery to learn from. A checkout is close enough to money to be a useful proxy and frequent enough to teach the system.
Roughly 20 to 50Lead, meaning the claim pressStronger intent than a checkout, still enough volume.
Above about 50Purchase, with valueOnly now does optimising on the real outcome, and reading return on ad spend, become stable.

Until the server events are live, none of these rows can be trusted, because the purchase count in the account is missing every buyer who closed the tab. Fix section 4.1 and 4.3 first, let a week of data accumulate, then pick the row that matches the volume.


Source: the live page and its script, a headless browser trace of the pixel requests on 19/09/2026, and read-only probes of the order API · Related: Learn from others, Pancake study · Meta Ads dashboard · Roadmap