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.
Proposal for discussionWritten 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.
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.
| Event | Type | Fires when | Parameters observed |
|---|---|---|---|
PageView | Standard | Page load | none |
ViewContent | Standard | Page load, immediately after PageView | content_name, content_category |
PTA_ViewGrades | Custom | The grade picker scrolls into view | src |
PTA_GradePick | Custom | A grade button is clicked | src, grade |
AddToCart | Standard | A package card is selected | value, currency, content_name, content_ids |
PTA_PlanSelect | Custom | Same click as AddToCart | pkg, value |
PTA_Scroll25/50/75 | Custom | Scroll depth crossed, once each | src |
PTA_PhoneFilled, PTA_EmailFilled | Custom | A valid phone or email is entered, once each | src |
InitiateCheckout | Standard | The order is created and the transfer QR appears | value, currency, content_name, content_ids |
PTA_QRShown | Custom | Same moment | pkg, value |
Lead | Standard | The parent presses "Đã chuyển khoản" | value, currency, content_name |
Purchase | Standard | The five-second poll sees the order marked paid | value, 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.
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.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.
| Kind | What fires it | Examples on this page | Rule |
|---|---|---|---|
| Page lifecycle | The browser, with no decision by the visitor: load, scroll depth, dwell time, exit | PageView, PTA_Scroll50, PTA_LeaveAfterRead | Read them as interest. Never optimise a campaign on them and never attach money. |
| Interaction | A deliberate act on an element: click, field completed, selection changed, video played | PTA_GradePick, AddToCart, PTA_ZaloClick, PTA_PhoneFilled | Micro-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. |
| State | A record changing state in a system of record: order created, claimed, paid, cancelled | InitiateCheckout, Lead, Purchase | The 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.
The target design. The live marker means it already works; to build means it does not exist yet.
| Stage | Event | Exact trigger | Sent from | Event id |
|---|---|---|---|---|
| Arrival | PageView live | Page load | Browser | automatic |
| Read | ViewContent live | Page load | Browser | view:<session> to build |
| Consider | AddToCart live | A package card is selected, including the one the grade picker selects for the parent | Browser | cart:<pkg>:<session> to build |
| Identify | PTA_PhoneFilled live | The phone passes validation. Also the moment to re-initialise the pixel with matching data. | Browser | not needed |
| Checkout | InitiateCheckout live | Order created and the transfer QR appears | Browser, and the Worker on POST /orders to build | checkout:<order code> |
| Intent | Lead live | "Đã chuyển khoản" is pressed | Browser, and the Worker on POST /orders/<code>/claim to build | lead:<order code> |
| Money | Purchase | The 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> |
| Cancelled | none | Order cancelled or refunded | Send 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
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.
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.
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 endpoint | Event to send | Event id |
|---|---|---|
POST /orders | InitiateCheckout | checkout:<code> |
POST /orders/<code>/claim | Lead | lead:<code> |
POST /admin/fulfil with an activation code | Purchase | purchase:<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.
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.
PTA_ already does. It keeps one dataset readable when several FLYER pages report into it.AddToCart and PTA_PlanSelect currently fire on the same click. That is acceptable because one is standard and one carries the diagnostic detail, but do not let the pattern spread.src on everything. It is the only way to see which creative produced which behaviour once several ads point at the same page.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.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.
The measurement work only pays off if the campaigns are told to use it.
| Weekly volume | Optimise for | Why |
|---|---|---|
| Under about 20 purchases | InitiateCheckout | Too 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 50 | Lead, meaning the claim press | Stronger intent than a checkout, still enough volume. |
| Above about 50 | Purchase, with value | Only 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