Appearance
Analytics & Event Tracking
Feature Type
PWA Editor Feature - Google Tag Manager (GTM) / GA4 event tracking, and how it behaves across webview, iframe, and mobile deployments.
The PWA Editor sends product-analytics events (page views, ecommerce events, in-editor engagement) to Google Analytics 4. This guide covers what's sent by default, how to receive it in your own GTM/GA4 setup, and - the part that trips most integrations up - how user identity does or doesn't carry across the boundary between your application and the embedded editor.
Two Separate Questions
Everything below answers one of two independent questions. Keep them apart - they don't affect each other.
| Question | What answers it |
|---|---|
| Routing - which GA4 property receives a given event | Your GA4 Measurement ID, on file with PBAI (see Receiving Our Events) |
| Identity - which user a given event is attributed to | Cookie sharing or manual linking, depending on your deployment (see Cross-Frame Identity) |
Receiving Our Events
Default Behavior
Once your GA4 Measurement ID is on file with PBAI, standard editor and ecommerce events are already routed to your property. Nothing below is required just to receive events - it's what you need if you want to build your own GTM tags/triggers around them (in your own GTM container, if you embed one - see Embedding Your Own GTM Container), or explore them meaningfully in GA4.
Event & Field Reference
Internal Checkout Only (Flow A)
The ecommerce events below (begin_checkout through purchase) only fire when your integration uses Flow A: Internal Checkout (see Channel Messaging). If you use Flow B: External Checkout, none of these fire - checkout happens entirely on your own systems, outside the editor. See Server-Side Order Tracking for the equivalent signal in that flow.
ℹ️
This list is continuously updated, and new items will be added over time.
| Event | Fires when | Key fields |
|---|---|---|
spa_page_view | User navigates between editor routes | page_path |
image_upload | User selects photos to upload (an attempt — fires when files are chosen, not when the upload succeeds) | upload_source, upload_count |
upload_picker_opened | User taps an upload method and the photo picker is about to open | upload_source, upload_method |
upload_picker_dismissed | Photo picker closed without returning photos (user cancelled, or iOS failed to export them) | upload_source, upload_method |
upload_error | An upload attempt could not start | upload_source, upload_error_reason, upload_method |
template_change | User swaps the project's template | from_template, to_template |
relayout | User relays out a page | relayout_page_number, relayout_total_page_count, relayout_page_photo_count |
pagebuild | Initial page build completes | pagebuild_curate, pagebuild_photos_available, pagebuild_photos_used |
density_change | User changes page density | density_type, density_page_count |
not_found | A route errors as a 404 (not fired for a plain URL navigation to a not-found path, since that's already captured by page-view tracking) | page_path |
back_gesture | User presses Back or uses the iOS edge-swipe on the upload or editor page, and is asked to confirm rather than leaving the project | back_from_route, back_view, back_action |
upload_abandoned | User leaves or backgrounds the upload page while photos are still uploading (best effort — some departures cannot be captured, so this undercounts) | upload_abandon_phase, upload_abandon_selected, upload_abandon_analyzed, upload_abandon_signed, upload_abandon_how |
begin_checkout | User starts checkout (Flow A only) | currency, value, items |
add_shipping_info | Shipping method selected (Flow A only) | currency, value, shipping_tier, items |
add_payment_info | Payment method selected (Flow A only) | currency, value, payment_type, items |
purchase | Order completes (Flow A only) | transaction_id, currency, value, items |
Custom Dimensions
Register a custom dimension (Admin → Custom definitions → Create custom dimensions, scope Event) on your GA4 property for any field above you want to explore or filter by - registration is a one-time step per property and doesn't require the event to have fired first.
Numeric Fields Are Metrics, Not Dimensions
Fields holding a count - upload_count, upload_abandon_selected, upload_abandon_analyzed, relayout_*, pagebuild_photos_*, density_page_count, order_project_count - belong under Create custom metrics, a separate tab in the same screen. Registered as a dimension instead, 99 becomes a text value and you get one row per distinct number rather than a total you can average. A registered definition can't be repointed at a different field afterwards, so a mistake here means retiring the name and starting over.
Exploring These Events in GA4
Registration makes a field available; Explorations are where it becomes an answer. This section walks through the setups for the two exit-signal events, back_gesture and upload_abandoned, which is where most people get stuck - they describe why someone left, so they behave differently from the progression events around them.
Not Retroactive
An exploration only sees data from the moment each definition was registered. Set the date range to start there, not to a rolling "last 28 days" - otherwise the days before registration read as zero rather than as "not collected."
Before Building Anything
Fire one real event and confirm it arrives, using GA4 DebugView with GTM Preview on, or DevTools → Network filtered to collect. An exploration built against data that never landed looks identical to a misconfigured one, and you'll spend the time debugging the wrong half.
Free-form: Upload Abandonment
Explore → Blank, then:
| Where | Set |
|---|---|
| Variables → Dimensions | upload_abandon_phase, upload_abandon_signed, upload_abandon_how, Device category |
| Variables → Metrics | Event count, Total users, upload_abandon_selected, upload_abandon_analyzed |
| Tab settings → Technique | Free form |
| Tab settings → Filters | Event name exactly matches upload_abandoned |
| Tab settings → Rows | upload_abandon_phase, then upload_abandon_signed, Nested rows on |
| Tab settings → Columns | Device category |
| Tab settings → Values | Event count, Total users, and the two count metrics |
Apply The Event Filter First
Without it, every row is diluted by all other events, where these dimensions are simply (not set). This is the single most common reason an exploration looks broken.
Reading it. upload_abandon_signed distinguishes the two failure modes: false means signing never completed, so nothing was ever sent; true means the upload started and stalled partway. Crossed with upload_abandon_phase, the cell to watch is generating_fastbook × false - a long, silent wait with nothing happening behind it.
Custom metrics aggregate as sums in Explorations, so upload_abandon_selected reads as a total across events rather than an average. Divide by Event count, or build a Calculated metric under Admin → Custom definitions.
Free-form: Back Gesture
Same shape, different fields:
| Where | Set |
|---|---|
| Variables → Dimensions | back_from_route, back_view, back_action, Device category |
| Variables → Metrics | Event count, Total users |
| Tab settings → Filters | Event name exactly matches back_gesture |
| Tab settings → Rows | back_from_route, then back_view, Nested rows on |
| Tab settings → Columns | back_action |
Reading it. Event count ÷ Total users is the number that matters. A user pressing Back repeatedly is arguing with the interface, not leaving it - a high ratio on a given route says the confirmation isn't answering the question they're asking.
Segments, And Why These Events Aren't Funnel Steps
Neither event belongs as a step in a conversion funnel. A step measures progress toward a goal; these measure departure from it. Inserting one into a sequence fragments the counts and makes the step's completion rate meaningless.
Use a segment instead, applied to the funnel you already have:
- In any exploration, Variables column → Segments → +.
- Choose Session segment. (Scope matters: session answers "what else happened in that visit"; user answers "did that person ever come back". See below.)
- Name it, e.g.
Sessions with upload abandonment. - Add new condition → search for and select the event
upload_abandoned. - Optionally narrow it with a parameter - e.g.
upload_abandon_phaseexactly matchesgenerating_fastbook- to segment one failure mode rather than all of them. - Save and apply.
- Open your funnel exploration → Tab settings → Segment comparisons → drag the new segment in, keeping
All Usersalongside it. Applied alone, the funnel simply restricts to those sessions and there is nothing to compare against.
Switch the scope to User segment to ask the follow-up question instead: do people who hit this ever return and complete an order? That's a different and often more actionable answer than the within-session one.
Path Exploration: Useful For One, Not The Other
back_gesture as a starting point - worth building. It answers what a Free-form structurally cannot: after the confirmation was raised, did the user stay? Explore → Path exploration → set the start point to the back_gesture event, and set the node type on following steps to Event name rather than the default page/screen, since these all occur within a single route. If the next node is dominated by ordinary engagement events, the confirmation is doing its job; if it's session end, it only delayed the exit.
upload_abandoned as a starting point - don't. It fires as the page is going away, so there is no "after" to trace. The path renders empty and reads as a broken report.
As an ending point it technically works, but the preceding steps are almost always the known upload sequence, so it rarely earns its place.
These Are Shapes, Not Totals
upload_abandoned is best-effort: browsers do not guarantee that a tag fires while a page is unloading, so it undercounts by an unknown margin. back_gesture counts only intercepted Back presses, not every Back press. Read both for distribution - which phase, which device, which route - and never as a complete count of how often something happened.
Embedding Your Own GTM Container
If you want full independent control over these events - your own non-GA4 tags (remarketing pixels, etc.), or you'd simply rather manage your own GTM workspace than depend on PBAI's default routing - PBAI can load your GTM container directly alongside its own inside the editor.
Requesting This
Coordinate with the PBAI team and provide your GTM Container ID. Once enabled, your container runs in the same document as the editor, sharing the same dataLayer - every event in the reference table above is visible to it automatically, no code changes needed per event.
To consume an event in your own container, either:
- Build a Custom Event trigger + GA4 Event tag per event you care about, mapping fields via dot-notation Data Layer Variables (e.g.
payload.upload_count) - ecommerce events (begin_checkoutthroughpurchase) instead use the Send Ecommerce data checkbox on the GA4 Event tag, which readscurrency/value/itemsautomatically with no manual field mapping, or - Build a generic forwarder tag that reads any event's
payloadobject and forwards it to GA4 as-is, if you'd rather not maintain a trigger/tag pair per event.
Avoid Double-Counting
Once your own container's tags are confirmed working, tell your account manager so PBAI can stop its own default routing to your property. Otherwise the same event lands twice - once from PBAI's routing, once from your own container.
Cross-Frame Identity
This section is about identity (which user an event is attributed to), not routing. It only matters if you care about session-level continuity between your own site and the embedded editor - e.g. connecting "a user who was reading a product page on my site" to "the events that happened inside the editor" as one continuous session in your analytics. If you don't need that - event counts and revenue are unaffected either way - you can skip this section.
Why It's Not Automatic
A GA4 property identifies users via a client_id, stored in a cookie (_ga). Cookies are scoped per domain, not per GTM container or per GA4 property - so whether identity carries across a boundary depends entirely on the domain relationship between your site and wherever the editor is running, not on any GTM configuration by itself.
Your site
(client.com)
|
-------------------------------
| |
Editor (iframe/webview) Checkout (external only)
| |
domain relationship domain relationship
to your site determines to your site determines
whether identity carries whether identity carriesTwo Independent Hops
When your integration uses external checkout, the checkout hop branches directly off your site, not through the editor. Bridging identity for the editor doesn't automatically bridge it for checkout too - they're evaluated separately.
Same Domain Family (subdomain of your own domain)
If the editor is hosted on a subdomain you own (e.g. app.your-domain.com, embedded in your-domain.com), identity carries automatically. GA4's default cookie behavior scopes _ga to the broadest domain it's permitted to use - from a subdomain, that's your shared registrable domain, the same cookie your own site's session already uses. Nothing to configure.
Requesting This
This requires PBAI to serve your deployment from a subdomain you control. Coordinate with your account manager - it's an infrastructure/DNS step (a CNAME to PBAI), not a dashboard setting.
Different Domain (default deployment)
If the editor stays on PBAI's own domain, there's no native cookie sharing - the two sessions are independent by default. To bridge them, your site needs to pass its own linked identity into the editor's URL when constructing the iframe/webview:
javascript
// before setting the iframe/webview src:
gtag('get', YOUR_GA4_MEASUREMENT_ID, 'linker_param', (param) => {
iframeEl.src = editorUrl + (editorUrl.includes('?') ? '&' : '?') + param
})This requires Cross Domain Linking enabled on your own GA4 configuration tag (with PBAI's domain listed), and the same enabled on PBAI's side with your domain listed - coordinate with your account manager to confirm this is configured on PBAI's end. Once linked at load, the identity carries through the whole in-editor session automatically, including checkout if you use Flow A (Internal Checkout).
Verify Before Relying On It
This applies GA4's cross-domain linking to an embed rather than a page redirect - not the textbook use case Google documents it for. Confirm it behaves as expected in GTM Preview / GA4 DebugView before depending on it.
Mobile WebView
A native app WebView isn't the same problem as a browser iframe, even though PBAI's own code treats both as "embedded" for UI purposes. There's no parent web page here - the parent is a native app shell, which has no GTM container and no cookies of its own to share.
- Cookies do exist inside a WebView (it's a real browser engine), but the cookie jar is sandboxed to your app installation and commonly not persisted across app restarts unless your app explicitly configures that - relying on
_gacontinuity here is unreliable regardless of any domain question. - Cross-domain linking (above) doesn't apply either - there's no second web page generating or receiving a linker param.
- The practical fix: skip cookies entirely. Have your app hold a stable identifier (your own persisted install ID, or a Firebase App Instance ID if you already run Firebase Analytics), and append it to the editor URL as a
nativeClientIdquery parameter when loading it:
https://{frontend}.photobook.ai/editor?id=p-xyz789&nativeClientId=YOUR_STABLE_IDThe editor picks this up automatically and uses it in place of a cookie-generated GA4 client_id - no other setup needed on your end for this specific piece. Omit the parameter entirely for ordinary sessions; normal cookie-based behavior applies whenever it's absent.
Server-Side Order Tracking
For Flow B: External Checkout integrations, none of the ecommerce events above ever fire, since checkout happens entirely on your own systems. If you want a signal from PBAI's side that an order was actually submitted for production - independent of your own checkout instrumentation - PBAI can send print and purchase events server-side (via the GA4 Measurement Protocol, not GTM) when you call the Print / Complete Job API.
Requesting This
Provide your GA4 property's Measurement ID and API Secret (GA4 Admin → Data Streams → choose your stream → Measurement Protocol API secrets) to your account manager.
| Event | Fires on | Key fields |
|---|---|---|
print | Every order, regardless of checkout channel | checkout_source, order_project_count |
purchase | External checkout orders only | checkout_source, transaction_id, value, items |
purchase is only sent here for external checkout - if you use Flow A (Internal Checkout), the client-side purchase event above already covers it, and sending it again here would double-count revenue.
Truth Table
| Hosting | Checkout | Deployment | Status | Recommended steps |
|---|---|---|---|---|
| n/a | n/a | Standalone (no parent site) | Automatic | Nothing needed - one continuous session by construction |
| Same domain family | Flow A (Internal) | iframe/webview | Automatic | Nothing needed - cookie_domain: auto covers the whole session |
| Same domain family | Flow B, checkout same domain as your site | iframe/webview | Automatic | Nothing needed - both hops automatic |
| Same domain family | Flow B, checkout on a different domain | iframe/webview | Bridge needed | Editor hop automatic. Checkout hop needs standard cross-domain linking between your site and checkout domain |
| Different domain | Flow A (Internal) | iframe/webview | Optional | Accept the gap, or bridge with the linker param passed into the editor URL at load |
| Different domain | Flow B, checkout same domain as your site | iframe/webview | Bridge needed for editor hop | Editor hop: linker param into URL. Checkout hop: automatic (same-origin navigation) |
| Different domain | Flow B, checkout on a different domain | iframe/webview | Bridge needed for both hops | Editor hop: linker param into URL. Checkout hop: standard cross-domain linking, configured separately |
| n/a | Either | Mobile WebView | Bridge needed | Skip cookies - inject a stable ID from your app and set it as GA4 client_id explicitly |
Worked Example
Scenario: Your GTM container embedded in the editor, deployed on a subdomain you own, using your own (external) checkout.
- Confirm the subdomain hosting is live - this is what makes identity continuity automatic; verify with your account manager rather than assume it.
- Reconsider whether you need the embed at all for GA4 purposes - on a shared domain family, PBAI's default routing to your property already carries correctly-linked identity with no embed required. Embed your own container specifically if you want independent tags (non-GA4 pixels) or your own workspace - not solely to "see" GA4 events, which you'd already get.
- Build triggers/tags in your container for the events in the reference table - only the in-editor engagement events apply here, since Flow B never fires the ecommerce events.
- Register custom dimensions on your GA4 property for whichever fields you use.
- Don't mark engagement events as key events - they're not conversions. For an actual order signal, see Server-Side Order Tracking instead.
- Tell your account manager once your tags are verified working, so PBAI can stop its default routing to your property and avoid double-counting.
Common Issues
Events Not Appearing for a Client Property
Confirm the client's GA4 Measurement ID is on file with PBAI, and check the raw network request (DevTools → Network → filter collect) rather than relying solely on GA4 DebugView/Realtime, which can lag for brand-new parameter names.
Session Data Looks Disconnected From My Site
This is expected on a different domain without the linker param bridge configured - see Cross-Frame Identity. Event counts and revenue are unaffected; only session/user-level metrics are.
Same Event Appears Twice
Usually means both PBAI's default routing and your own embedded container are sending the same event to the same property - see the warning in Embedding Your Own GTM Container.