Embedded PsiFi checkout iframe that loads itself the moment shipping is valid. No "Pay" button. Coupon applies / cart edits seamlessly invalidate the stale session and mint a new one. Built on Next.js 15 + React 19.
This repo is a focused reference implementation of the auto-fire UX pattern on top of PsiFi's hosted checkout. For the broader integration (catalog sync, webhook handling, reconciliation cron, etc.), see psifi-checkout-starter.
Default embedded-iframe flows ask the customer to click a "Pay" button to load the payment page. That's one extra click, one extra hesitation point, and one more thing to mis-style on mobile.
Auto-fire eliminates the click. As soon as the customer has typed a valid name, email, phone, and shipping address, the iframe loads itself in place. Apple Pay / Google Pay / card / crypto wallet buttons appear immediately. If they apply a coupon or edit the address afterward, the iframe seamlessly re-mints with the new total.
The trick is keeping the network layer disciplined: debouncing, aborting in-flight requests, and cleaning up the previous session server-side so the merchant dashboard doesn't fill with orphans.
┌───────────────────────────────────────────────────────────────────┐
│ parent page (your domain) │
│ │
│ ┌──────────────────┐ fingerprint changes │
│ │ shipping form │ ──────────────────────┐ │
│ └──────────────────┘ ▼ │
│ ┌─────────────────────────────┐ │
│ │ auto-fire useEffect │ │
│ │ · debounce 800ms │ │
│ │ · AbortController cancels │ │
│ │ stale in-flight mints │ │
│ │ · tracks previousSessionId │ │
│ └─────────────┬───────────────┘ │
│ ▼ │
│ POST /api/checkout/express-psifi │
│ ▼ │
│ ┌──────────────────┐ ┌─────────────────────────────┐ │
│ │ PsifiEmbedded │ ◀───── │ session id + hosted URL │ │
│ │ Frame (iframe) │ └─────────────────────────────┘ │
│ │ │ │
│ │ src=checkout │ ┐ │
│ │ .psifi.app/?… │ │ ┌── channel 1: postMessage from │
│ │ │ ├─┤ /embedded-bridge (same-origin) │
│ │ allow=payment │ │ │ │
│ │ │ │ ├── channel 2: poll /api/checkout/ │
│ │ │ │ │ result every 2s │
│ │ │ │ │ │
│ │ │ │ └── channel 3: iframe onload + │
│ │ │ │ same-origin URL inspection │
│ └──────────────────┘ └─────────────────────────────────────────►│
│ │
└───────────────────────────────────────────────────────────────────┘
│
│ redirect_url on completion
▼
┌───────────────────────────────────────────────────────────────────┐
│ /embedded-bridge (your domain, loaded inside the iframe) │
│ · window.parent.postMessage({type: "psifi-checkout-complete"}) │
│ · 3s top-level redirect failsafe │
└───────────────────────────────────────────────────────────────────┘
A stable hash of every input that affects the PsiFi session. Null until the form actually validates:
const fingerprint = useMemo<string | null>(() => {
if (!customer.firstName.trim()) return null;
if (!customer.email.includes("@")) return null;
// ...address validations...
return JSON.stringify({
c: { f: customer.firstName, e: customer.email, ... },
a: { a1: address.address1, c: address.city, ... },
items: items.map(i => `${i.sku}x${i.quantity}@${i.priceCents}`).join("|"),
coupon: appliedCoupon?.code || null,
});
}, [customer, address, items, appliedCoupon]);useEffect(() => {
if (!fingerprint) return; // form invalid
if (session?.fingerprint === fingerprint) return; // already minted
let cancelled = false;
const timer = setTimeout(async () => {
if (cancelled) return;
// abort any in-flight mint from a stale fingerprint
if (mintAbortRef.current) mintAbortRef.current.abort();
const ac = new AbortController();
mintAbortRef.current = ac;
// POST /api/checkout/express-psifi with previousSessionId set
// to the last successfully-minted session id...
}, 800);
return () => { cancelled = true; clearTimeout(timer); };
}, [fingerprint, retryNonce]);The cancelled flag, AbortController, and previousSessionId reference work together to guarantee at most one live session per page-load, and the previous session always gets expired server-side once a new one is confirmed.
// /api/checkout/express-psifi/route.ts
import { after } from "next/server";
import { expireCheckoutSession } from "@/lib/psifi";
// ...after minting the new session successfully...
if (previousSessionId && previousSessionId !== sessionRes.data.id) {
after(async () => {
try {
await expireCheckoutSession(previousSessionId);
} catch (err) {
console.warn("expire failed", err);
}
});
}
return NextResponse.json({ ... });Next.js's after() schedules the cleanup off the response critical path, so the customer sees the new iframe load immediately without waiting on the gateway round-trip.
| Channel | Trigger | Latency | Reliability |
|---|---|---|---|
| 1. postMessage | PsiFi redirects iframe to /embedded-bridge → bridge posts to parent |
~50ms | Highest when working. Dropped by Safari ITP occasionally. |
| 2. Polling | Parent polls /api/checkout/result every 2s |
up to 2s | Always works. Authoritative — reads PsiFi session state directly. |
| 3. onload URL inspection | iframe load fires; check contentWindow.location.href for /embedded-bridge |
~50ms | Belt-and-braces; same trigger as channel 1 but uses iframe ref instead of postMessage. |
Any channel firing first wins; the others are idempotent no-ops.
git clone https://github.com/tovesblake-max/psifi-autofire-checkout.git
cd psifi-autofire-checkout
npm installcp .env.example .env.localFill in:
PSIFI_API_KEY— get from app.psifi.app → Settings → API KeysNEXT_PUBLIC_SITE_URL—http://localhost:3000for dev
Optional but recommended:
PSIFI_WEBHOOK_SECRET— from the Webhook Portal, formatep_…orwhsec_…PSIFI_DEMO_PRODUCT_ID— id of a contextual product so the hosted page shows your product name instead of just the dollar amount
npm run devOpen http://localhost:3000, fill in the form, and watch the iframe load itself.
Try the coupon code DEMO10 to see the re-mint flow in action — the iframe will refresh with a $1.50 discount applied without you clicking anything.
npx vercelSet the same env vars in the Vercel dashboard. The next.config.ts ships with a CSP that allows *.psifi.app in frame-src and connect-src — you'll need this on any custom deployment or the browser will block the iframe.
src/
├── app/
│ ├── api/
│ │ ├── checkout/
│ │ │ ├── express-psifi/route.ts # mints + cleans up sessions
│ │ │ └── result/route.ts # polling endpoint (channel 2)
│ │ └── psifi/notify/route.ts # Svix-verified webhook receiver
│ ├── checkout/callback/page.tsx # post-purchase landing
│ ├── embedded-bridge/page.tsx # iframe → parent handoff
│ ├── globals.css # Tailwind tokens
│ ├── layout.tsx
│ └── page.tsx # the auto-fire checkout demo
├── components/
│ └── PsifiEmbeddedFrame.tsx # the inline iframe + 3-channel detection
└── lib/
├── catalog.ts # demo item + coupon
└── psifi.ts # gateway client + webhook verifier
The auto-fire logic lives in src/app/page.tsx (the fingerprint useMemo + the debounced useEffect). The iframe rendering + completion detection lives in src/components/PsifiEmbeddedFrame.tsx. The two are deliberately separable — you can drop the frame into a different controller (a multi-step wizard, a one-page form, etc.) without touching its internals.
The browser will refuse to render checkout.psifi.app in your iframe unless your Content-Security-Policy lets it through. The bundled next.config.ts sets:
"frame-src 'self' https://*.psifi.app",
"connect-src 'self' https://*.psifi.app",If you're integrating into an existing site, merge those directives into your CSP. Without them you'll see a silent failure with a console error along the lines of "Refused to frame https://checkout.psifi.app/ because it violates the following Content Security Policy directive: frame-src 'self'."
Wallet payments inside an iframe require two things:
allow="payment"on the<iframe>element. The bundledPsifiEmbeddedFramealready sets this:<iframe allow="payment; publickey-credentials-get; clipboard-write" ... />
- No
sandboxattribute, because sandboxing strips the payment permission. The bundled component intentionally omits it.
PsiFi handles the merchant-of-record requirements for Apple Pay (domain verification, capability registration). You don't need to host the .well-known/apple-developer-merchantid-domain-association file yourself.
The demo runs DB-free for simplicity. Before shipping this pattern to production, layer on:
- Pre-mint order insert. Create a pending order row in your DB BEFORE calling PsiFi so a gateway 5xx doesn't lose the cart.
- Duplicate-detection guard. Only block on
paymentStatus === "completed"matches —pendingrows are EXPECTED from auto-fire re-mints (this pattern legitimately creates several unpaid rows per session). See duplicate-detection.ts in the broader starter for the canonical implementation. - Reconciliation cron. Run every 5 minutes; for each unpaid order < 1h old, probe its PsiFi session and fire a synthetic Svix-signed webhook to your own
/api/psifi/notifyif it's actually paid. Catches missed real webhooks. - Stale-order cleanup cron. Every few hours, mark cancellable any orders that have been
pendingfor > 1h and whose PsiFi session isexpired. Tidies the auto-fire orphans theafter()path missed. - Rate limiting.
/api/checkout/express-psifishould be rate-limited per IP (e.g. 30 mints / 15 min) so an adversarial client can't burn your PsiFi quota. - Idempotency key validation. The route already requires the client-supplied key to match
^[a-fA-F0-9-]{16,64}$. Reject non-conforming. - Webhook signature verification. Already wired via Svix in
/api/psifi/notify. VerifyPSIFI_WEBHOOK_SECRETis set in production — the route fails closed if it isn't.
What if the customer is mid-typing in the card form and a re-mint replaces the iframe?
The most likely trigger is a coupon being applied while the iframe is loaded. To minimize disruption: don't auto-apply coupons. Make the customer click "Apply" deliberately. The bundled UI does this — there's no auto-coupon-on-blur or anything similar.
If a re-mint does fire while a card is being entered, the customer loses what they've typed in the iframe but keeps everything in your parent form (name, address, etc.). Re-entering card data takes ~10 seconds; it's not a great experience but it's recoverable.
Why not just use a single session for the whole checkout?
Because PsiFi locks the line items + total at session-create time. If the customer applies a coupon, you can't retroactively reduce the amount on an existing session — you have to mint a new one with the new amount.
What stops a malicious script from spoofing the psifi-checkout-complete postMessage?
Two layers of defense:
- The
PsifiEmbeddedFramecomponent validatesevent.origin === window.location.origin. The bridge page lives on your own domain, so a legitimate completion message ALWAYS comes from your own origin. Anything else (a script in a maliciously-framed parent, or a third-party iframe trying to forge completion) gets rejected at the origin check. - Even if the origin check were bypassed, the message handler validates
event.data.sessionId === sessionId. The session id is created server-side and never exposed publicly. An attacker would need to intercept your/api/checkout/express-psifiresponse to know the right id, which is same-origin XHR.
In practice the polling channel (#2) and webhook are the authoritative completion paths — the postMessage is just for sub-100ms responsiveness.
Why next/server's after() instead of a fire-and-forget Promise?
Because Vercel functions terminate as soon as the response stream closes, and any pending Promise gets killed mid-flight. We learned this the hard way when fulfillment emails stopped firing on the production site. after() is Next.js 15's official primitive for "do this after the response is sent but before the runtime shuts down."
MIT. See LICENSE.