@revley/upsell-sdk
v0.1.0
Published
Browser SDK for custom-coded post-purchase upsell pages
Keywords
Readme
@revley/upsell-sdk
Browser SDK for custom-coded post-purchase upsell pages (REV-1759).
A merchant's developer writes plain HTML/CSS/JS, one file per node (1.html,
2.html, …). We host those files and render them in a sandboxed iframe inside
our hosted journey. This SDK is what their code calls to read offer data and to
accept or decline.
<script src="https://cdn.example.com/upsell.js" data-revley-flow-id="flow_123"></script>Two layers
Reading data works anywhere, including on the developer's machine outside our
iframe. Acting on the offer works only inside it: the page asks the parent to
charge and progress, the parent does it, and the parent swaps the iframe's
src to the next node.
That split is forced, not chosen:
- The shopper session is an httpOnly
SameSite=laxcookie scoped to/api/checkout/upsell. It is never returned in a response body, so there is no bearer token an iframe on another origin could hold, and the cookie is not sent cross-site. sandbox="allow-scripts"(withoutallow-same-origin, which would let the frame remove its own sandbox) gives the page an opaque origin: no storage, no cookies, andOrigin: nullon every request.
So the parent holds the session and pushes data in over postMessage. Because
the frame's origin is "null", the parent cannot validate event.origin — it
must match event.source against the iframe's contentWindow.
Surface
| | |
|---|---|
| ready() | Resolves when context has arrived. Call first. |
| env() | { inFrame, mode } — branch on being previewed. |
| context() | Everything the page can read. See src/types.ts. |
| tokens() | The 13 copy tokens, verbatim from the builder. |
| fill(text) | {token} substitution. |
| format(minor) | Money in the offer's currency. |
| accept() | Charge and progress. |
| decline() | Progress down the decline path. |
| selectTier(i) | Re-quote for a quantity tier. |
| submitSurvey(n) | 1–5, order-complete page only. |
| on('change'\|'expire', cb) | Context replaced / window gone. |
| autoHeight(), setHeight(px) | The parent cannot measure a cross-origin frame. |
Every read throws until ready() resolves, and every action rejects. The
countdown in context().timer.secondsRemaining is recomputed on each call, so
a page can render it from a loop of its own without the host pushing ticks.
accept() and decline() resolve with { outcome, message? }. On
"advancing" and "completed" the host replaces this frame, so the promise
may never settle — show progress rather than hanging logic off the resolution.
"failed" means the charge did not go through and the shopper was not charged,
but the journey continues anyway; surface the message instead of treating it as
fatal.
Bind the accept control to canAccept rather than recomputing it. blockedBy
says why: "busy" and "gate" come from the host, "expired" is worked out
locally from the deadline. Decline is never blocked.
Developing a page outside the iframe
Reads work with no host. Point the script tag at a flow and the SDK fetches a preview payload:
<script src="/upsell.js"
data-revley-flow-id="flow_123"
data-revley-node="1"
data-revley-preview-origin="https://checkout.example.com"></script>To work with no network at all, set a context before the script loads:
<script>window.REVLEY_PREVIEW_CONTEXT = { /* see src/types.ts */ };</script>env() reports { inFrame: false, mode: "preview" } there. accept() and
decline() charge nothing and log what they would have done, resolving
"advancing" so a page's success path still runs.
Parity, deliberately
This mirrors what the block page builder has and nothing more. In the builder, the platform supplies the money and the product identity; the merchant supplies everything else. Two things are therefore absent on purpose:
- Product images. There is no catalog lookup at runtime. The builder's hero image is an authored URL, seeded once from the catalog when the merchant picks the product and never touched again. Custom pages ship their own art.
- Subscription trial and renewal terms.
rebillMode,rebillVal,nRenandtrialValdrive billing but are never shown to a shopper today. The only subscription disclosure on a page is the cadence suffix ($29.00/mo).
Also not exposed, because the renderer ignores them: quote.discountMinor,
quote.taxMinor (always 0), price.showWas, picker.accept, picker.per,
tracker.bandBg, receipt.orderNumber, view.receiptUrl.
Open items before this can ship
- A public preview endpoint. Every existing data route needs the shopper cookie or a CRM JWT. Local development has neither. Same payload shape, no new data.
- Three settings need a home off
page.blocks. With no blocks, these stop working, and the first two fail silently:video.gate/gateSec→hasValidVideoGate()no longer locks acceptpicker.defaultTier→ the picker loses its defaultvideo.gateSec→ the window collapses to its 15-minute floor
- Node-to-file mapping. Positional numbering means inserting a step
renumbers every file after it and silently repoints pages that are already
written. An explicit
filefield per node makes a remap a visible diff. - Accessibility. The current renderer meets a tested contract that
customer-authored HTML will not by default: 44px minimum tap targets,
luminance-based contrast on the accept button,
aria-disabledoverdisabled, andprefers-reduced-motionoverrides.
Development
pnpm install
pnpm typecheck
pnpm test
pnpm build # dist/iife/upsell.global.js is the productThe host side is not in this package. Mapping RuntimeView to Context,
answering the handshake, calling the runtime endpoints and swapping the
iframe's src all belong to the hosted journey, which already holds the
session cookie. Context in src/types.ts is the contract between the two.
The host half
Whatever frames a custom offer — the CRM's preview, then the shopper's journey — imports the trusted half from a separate entry point:
import { buildContext, createHost } from "@revley/upsell-sdk/host";
const host = createHost(iframeElement, {
getContext: () => buildContext(view, { selectedTier, busy }),
onAccept: () => acceptStep(view.step.id),
onDecline: () => declineStep(view.step.id),
onSelectTier: async (index) => buildContext(await currentUpsell(index)),
});buildContext lives here rather than in the CRM or the checkout client
because both need it, and two hand-written copies would drift the first time
the quote shape changes.
It is a separate entry so @revley/upsell-core never reaches the bundle a
shopper downloads: the IIFE stays at 7.3 KB and contains none of it.
Two things about createHost worth knowing before you write another one. A
sandboxed frame's messages arrive with origin: "null", so the origin cannot
identify anyone — the sender is matched on event.source against the frame's
contentWindow instead. And it answers every greeting, not just the first,
because the page repeats until answered and that is what stops the retry loop.
