Appearance
Channel Messaging
Feature Type
PWA Editor Feature - Real-time messaging from the PWA editor to your application.
The PWA editor sends real-time messages to the parent application at key points during the user journey - the project was saved, the user is leaving, checkout has started, an order completed.
Messages are delivered over postMessage or the platform's native bridge:
| Platform | Transport |
|---|---|
| Web (iframe) | postMessage events - see the demo |
| React Native | window.ReactNativeWebView.postMessage(...) |
iOS (WKWebView) | window.webkit.messageHandlers.appHandler.postMessage(...) |
Android (WebView) | window.AndroidPbaiAtelier.sendToNative(...) |
Android Bridge Name
The Android interface is registered as AndroidPbaiAtelier. Registering a different name silently drops every message.
Embedding In A Native App
Receiving these messages is only part of hosting the editor in a native shell. Back navigation, JavaScript dialogs and the file picker each need host-side wiring - see WebView Integration.
Overview
Channel messages tell you about:
- Save events during editing
- The user arriving in, and leaving, the editor
- Navigation between editor and checkout
- Progress through checkout steps
- Order completion
Interactive Demo
Try the Channel Messaging Demo for a generic sample of how channel messages work.
Which Checkout Flow Applies To You
This is the biggest branch in the integration, and it is set by your client configuration rather than chosen at runtime. Establish which one you are on before reading further - it decides whether half of this document applies to you.
| Flow A: Internal Checkout | Flow B: External Checkout | |
|---|---|---|
| Who handles checkout | The editor's built-in UI - cart, address, payment, confirmation | Your application, with your own UI |
On order | close: false - keep the editor open | close: true - tear the editor down |
| Checkout step events | You receive them all | None are ever sent |
| Order signal | confirmation channel message | Your own systems, or Server-Side Order Tracking |
Both flows send the same editor events. They diverge from the moment the user clicks checkout - see User Initiates Checkout.
Message Format
Every message except openExternalUrl carries the same base fields:
json
{
"close": false,
"checkout": false,
"action": "save",
"ui": "mobile",
"route": {
"view": "editor",
"step": 1
}
}Base Message Fields
| Field | Type | Description |
|---|---|---|
close | boolean | Whether the editor is finished and the view should be closed - see below |
checkout | boolean | Whether the user has entered the checkout process |
action | string | What happened - see the Action Reference |
ui | string | UI mode: "mobile" or "desktop" |
route.view | string | Current page name (e.g. "editor", "checkout", "upload") |
route.step | integer | Step number for multi-step processes |
The close Field
close is the field with consequences: it is the editor telling you it has finished with the user and your shell should take over. Everything else is informational.
Act on close, not on action. The set of actions will grow over time, and a handler written as if (close) closeWebview() keeps working when it does. A handler that lists exit actions by name will silently miss new ones.
The one thing close does not do is save anything for you - it fires after the editor has persisted its own state, so the project is already safe by the time you receive it.
Action Reference
Every action the editor can send. close and checkout are the values that accompany it.
| Action | Fires when | close | checkout |
|---|---|---|---|
editorOpened | Every editor page load | false | false |
projectStarted | User arrives in the editor from photo selection | false | false |
save | Periodically, as the user edits | false | false |
saveAndExit | User saves and confirms exit | true | false |
clearPagebuild | User discards progress and exits | true | false |
deleteProject | User deletes the project and exits | true | false |
order (Flow A) | User starts checkout, editor handles it | false | true |
order (Flow B) | User starts checkout, you handle it | true | true |
summary | Checkout step 1 → 2 | false | true |
address | Checkout step 2 → 3 | false | true |
payment | Checkout step 3 → 4 | false | true |
confirmation | User lands on the confirmation page | true | true |
summaryBack | User leaves summary, back to the editor | false | true |
addressBack | User leaves address, back to summary | false | true |
paymentBack | User leaves payment, back to address | false | true |
openExternalUrl | A URL must be opened outside the webview | absent | absent |
order Means Two Different Things
order is sent in both checkout flows, and the flows want opposite behaviour from you. Flow A sends close: false and expects the editor to stay open so it can run checkout itself; Flow B sends close: true and expects you to tear it down and take the user to your own checkout.
Branch on close, never on action === 'order' alone.
Additional Data
Depending on the current route, messages include either project data or checkout data.
Project Data
Included during editor events:
json
{
"title": "Family Vacation 2024",
"preview": "https://pwa-pbai-dev-attachments.s3-accelerate.amazonaws.com/u-abc/jonh123456/projects/p-def/preview.jpeg",
"link": "https://{frontend}.photobook.ai/editor?id=p-abc123",
"id": "p-abc123",
"endUserId": "user-provided-id",
"product": { /* product details */ },
"metadata": {
"currentPages": 24,
"photoCount": 40,
"usedPhotoCount": 18,
"productName": "Premium Photo Book"
}
}| Field | Type | Description |
|---|---|---|
title | string | User-entered title (or product SKU if not set) |
preview | string | URL to project preview image |
link | string | Direct link to editor for this project |
id | string | Unique project ID |
endUserId | string | User ID provided by parent client |
metadata.currentPages | integer | Number of pages in project |
metadata.photoCount | integer | Photos in the project's pool, whether or not they have finished uploading |
metadata.usedPhotoCount | integer | Distinct photos placed in the book - a photo used on several pages counts once |
metadata.productName | string | Name of the product |
The Two Photo Counts Answer Different Questions
photoCount is the size of the user's photo pool - everything they have committed to the project, counted the moment they commit it and regardless of whether the upload has finished. It does not climb during an upload.
usedPhotoCount is how many distinct photos have actually been placed in the book. It is always less than or equal to photoCount, and it counts a photo once no matter how many pages it appears on.
Checkout Data
Included during checkout events, if your client configuration uses the editor's internal checkout. Contents vary by step:
Summary Step:
json
{
"results": [
{
"quantity": 2,
"extraCostPerPage": 0.50,
"price": 29.99,
"total": 65.98
}
]
}Address Step:
json
{
"results": {
"firstName": "John",
"lastName": "Doe",
"address1": "123 Main St",
"address2": "Apt 4B",
"city": "New York",
"state": "NY",
"postcode": "10001",
"country": "United States",
"isoCode2": "US",
"isoCode3": "USA",
"telephone": "+1234567890"
}
}Payment Step:
json
{
"orderId": 12345,
"voucher": "SAVE10",
"paymentMethod": "stripe",
"cartSubTotal": 65.98,
"shippingCostTotal": 5.99,
"taxTotal": 3.60,
"couponTotal": 6.60,
"grandTotal": 68.97,
"items": [ /* line items */ ],
"shippingAddress": { /* address object */ }
}Message Scenarios
The first scenario below shows a complete payload. The rest show only the fields that distinguish them - every editor event otherwise carries the Project Data shape above.
Editor Events
Auto-Save During Editing
Sent periodically as the user edits. This is the full shape every other editor event follows:
json
{
"title": "Summer Memories",
"preview": "https://pwa-pbai-dev-attachments.s3-accelerate.amazonaws.com/u-abc/jonh123456/projects/p-def/preview.jpeg",
"link": "https://{frontend}.photobook.ai/editor?id=p-def",
"id": "p-def",
"createdAt": 1774959510786,
"updatedAt": 1774959594788,
"expiresAt": 1790770710,
"product": {
/* Product configuration including dimensions, specifications, pricing, and PSP details */
},
"endUserId": "jonh123456",
"clientName": "yourclient",
"metadata": {
"costPerPage": 0.5,
"currentPages": 42,
"basePages": 24,
"basePrice": 12,
"photoCount": 43,
"usedPhotoCount": 43,
"productName": "Hard-21-S-190gsm"
},
"close": false,
"checkout": false,
"action": "save",
"ui": "desktop",
"route": {
"view": "editor",
"step": 1
}
}What to do:
- Update project status in your database
- Show save indicator to user
- Track progress
Editor Opened
Sent on every editor page load, once project data has resolved. This is the dependable "the user is now in the editor" signal - it fires on a fresh visit, a reload, a return from checkout, and the hand-off from photo selection alike.
json
{
"close": false,
"checkout": false,
"action": "editorOpened",
"route": { "view": "editor", "step": 1 }
// ... plus the full projectData shape
}What to do:
- Adjust your app's chrome for the editor view
- Record that the user resumed an existing project
- Use it as the anchor for session or engagement timing
Project Started
Sent when the user arrives in the editor directly from photo selection - a brand-new book beginning its build. It is followed immediately by editorOpened, so on this path your handler sees both, in that order.
The upload is still running at this moment, but photoCount already reports the full pool the user committed:
json
{
"close": false,
"checkout": false,
"action": "projectStarted",
"metadata": {
"photoCount": 40,
"currentPages": 24,
"productName": "Hard-21-S-190gsm"
}
// ... plus the rest of the projectData shape
}What to do:
- Mark the project as active in your database - this is the point a selection becomes a real book
- Show progress affordances, since the upload and page build run for a while after this
- Record how many photos the user committed to
This Is Not An Upload-Complete Signal
projectStarted marks the beginning of the upload, not its end - photos continue uploading and being processed for some time afterwards. photoCount tells you how many the user committed, not how many have landed, and usedPhotoCount is absent until the book has been laid out.
User Saves and Exits Editor
Sent when the user saves and confirms exit:
json
{
"close": true,
"checkout": false,
"action": "saveAndExit",
"route": { "view": "editor", "step": 1 }
// ... plus the full projectData shape
}What to do:
- Close the webview/iframe
- Save project state
- Bring the user back to the page you want them to land
User Discards Progress and Exits Editor (leaving any uploaded photos intact)
Sent when the user discards progress - the book layout, not the photos - and confirms exit:
json
{
"close": true,
"checkout": false,
"action": "clearPagebuild",
"route": { "view": "editor", "step": 1 }
// ... plus the full projectData shape
}What to do:
- Close the webview/iframe
- Save project state (project is reset to an empty state)
- Bring the user back to the page you want them to land
User Deletes Project and Exits Editor
Sent when the user deletes the project and confirms exit, usually when a new empty project was generated and the user leaves before uploading anything. Note the route.view is upload, and no project data is included - there is no project left to describe:
json
{
"close": true,
"checkout": false,
"action": "deleteProject",
"ui": "desktop",
"route": {
"view": "upload",
"step": 1
}
}What to do:
- Close the webview/iframe
- The project is deleted, so there's no need to save it
- Bring the user back to the page you want them to land
User Initiates Checkout
This is where the two flows diverge. Both send action: "order" and checkout: true; they differ in close.
Flow A: Internal Checkout (Editor handles checkout)
json
{
"close": false,
"checkout": true,
"action": "order",
"route": { "view": "editor", "step": 1 }
// ... plus the full projectData shape
}What to do:
- Track conversion funnel
- Keep the webview/iframe open - the checkout flow begins inside the editor
- The editor will guide the user through the checkout steps below
Flow B: External Checkout (Your application handles checkout)
json
{
"close": true,
"checkout": true,
"action": "order",
"route": { "view": "editor", "step": 1 }
// ... plus the full projectData shape
}What to do:
- Close the webview/iframe
- Use the project and product data to add the item to your cart
- Navigate to your application's checkout flow
- Process the order using your own payment and fulfillment system
Checkout Events (Flow A Only)
Applies to Internal Checkout Only
These events are only sent under Flow A. Under Flow B they never occur - checkout happens entirely on your systems.
Each step forward carries that step's Checkout Data.
| Transition | action | route.step | Payload |
|---|---|---|---|
| Summary → Address | summary | 1 | results: cart items |
| Address → Payment | address | 2 | results: shipping address |
| Payment → Confirmation | payment | 3 | Full order details |
All carry close: false, checkout: true and route.view: "checkout".
json
{
"close": false,
"checkout": true,
"action": "payment",
"ui": "desktop",
"route": {
"view": "checkout",
"step": 3
},
"orderId": 12345,
"grandTotal": 68.97,
"items": [ /* order items */ ],
"shippingAddress": { /* address */ }
}What to do:
- Save order details
- Track successful conversions
- Prepare confirmation UI
Order Confirmation Page
Sent when the user lands on the confirmation page. This is the one checkout event carrying close: true:
json
{
"close": true,
"checkout": true,
"action": "confirmation",
"ui": "desktop",
"route": {
"view": "checkout",
"step": 4
}
}What to do:
- Close webview/iframe
- Show order confirmation in your app
- Display order tracking information
Going Back A Step
The user can retreat through the checkout steps. These mirror the forward transitions and carry no payload beyond the base fields.
| The user leaves | Returning to | action | route.step |
|---|---|---|---|
| Summary | Editor | summaryBack | 1 |
| Address | Summary | addressBack | 2 |
| Payment | Address | paymentBack | 3 |
json
{
"close": false,
"checkout": true,
"action": "summaryBack",
"ui": "desktop",
"route": {
"view": "checkout",
"step": 1
}
}What to do:
- Adjust any funnel tracking - the user has stepped backwards, not abandoned
- No action needed on the view itself; the editor handles the navigation
Opening External URLs
Different Message Shape
Unlike every message above, this one does not include the Base Message Fields (close, checkout, route). It is a minimal, standalone payload.
Some interactions started inside the editor need to open a URL outside the embedded webview/iframe - the Google Photos picker, OAuth consent screens. Native wrappers silently swallow window.open, so the editor asks the host shell to open the URL instead.
json
{
"action": "openExternalUrl",
"url": "https://photos.google.com/picker/session-id/autoclose"
}| Field | Type | Description |
|---|---|---|
action | string | Always "openExternalUrl" for this message |
url | string | The external URL to open |
What to do:
React Native: openurlviaLinking.openURL(url)or an in-app browser (e.g.expo-web-browser)iOS/Android: openurlin the system browser or an in-app browser tabWeb(iframe): no action needed - see below
Web Fallback
On web, if no native bridge (ReactNativeWebView, webkit.messageHandlers.appHandler, or AndroidPbaiAtelier) is detected, the editor falls back to window.open(url, '_blank') directly, so this message is only sent from native contexts.
Implementation
The handler shape is the same everywhere; only the transport differs. Branch on close first, then on action for the events you care about.
javascript
// Web (iframe)
window.addEventListener('message', (event) => {
// Verify origin for security
if (event.origin !== 'https://{frontend}.photobook.ai') return
handleChannelMessage(event.data)
})
function handleChannelMessage(message) {
const { close, checkout, action } = message
// openExternalUrl carries none of the base fields, so it is handled first
if (action === 'openExternalUrl') {
openInSystemBrowser(message.url)
return
}
// the editor is finished - this covers saveAndExit, clearPagebuild,
// deleteProject, confirmation, and Flow B's order, without naming any of them
if (close) {
closeWebview()
return
}
if (action === 'projectStarted') markProjectActive(message.id, message.metadata)
if (action === 'save') saveProjectProgress(message.id, message.metadata)
if (checkout && action === 'order') analytics.track('checkout_started')
}React Native receives the same payload as a JSON string:
javascript
import { WebView } from 'react-native-webview'
function EditorWebView({ url }) {
const handleMessage = (event) => {
handleChannelMessage(JSON.parse(event.nativeEvent.data))
}
return <WebView source={{ uri: url }} onMessage={handleMessage} javaScriptEnabled={true} />
}iOS delivers messages to WKScriptMessageHandler on the appHandler name; Android to the AndroidPbaiAtelier interface as a JSON string. Both then follow the same branching as above - see WebView Integration for the surrounding host-side setup.