Standalone Payment Widget
Zero DependenciesA self-contained, embeddable payment solution that transforms any webpage into a secure payment processor with just a few lines of code. Perfect for developers who need flexible payment integration.
🚀 Build Your Payment Solution in Minutes
The Standalone Payment Widget is designed for developers who want to quickly add payment functionality without complex integrations. Just copy, paste, and customize.
Instant Setup
Copy & paste HTML to start accepting payments
Secure by Default
PCI-compliant Stripe processing built-in
Self-Contained
No dependencies or complex integrations
Works Anywhere
Static sites, CMSs, or custom apps
⚡ Quick Start - Copy & Paste
Get started in 30 seconds with this ready-to-use code:
<!-- Add this anywhere in your page -->
<h1>Make a Payment</h1>
<!-- Standalone Payment Widget -->
<div id="payment-widget"
data-widget="payment"
data-tenant="YOUR_TENANT_ID"
data-container="payment-widget"
data-amount="99.00"
data-service="Professional Service"
data-reference="INV-2024-001">
</div>
<!-- Load the widget -->
<script type="module" src="https://cdn.widgetfied.com/portal.js"></script>Replace YOUR_TENANT_ID with your actual tenant ID. That's it - you're ready to accept payments!
🧭 Two Trigger Contracts — Pick the Right One
The Payment Widget supports two distinct integration patterns. The trigger contract you use depends on the displayMode you render. Mixing them up is the #1 source of "why isn't my widget responding" issues.
displayMode = inline / button
When: Widget renders a Pay button (or inline form) on the page. User clicks it directly.
Trigger: Write data-* attributes on the placeholder div via setAttribute. The widget reads them live when the user clicks the rendered button — no re-init required.
Inputs
data-amountdata-referencedata-servicedata-customer-namedata-customer-emaildata-customer-phone
displayMode = modal
When: Your own UI element (button, link, programmatic action) opens the payment modal. The widget renders nothing visible until you trigger it.
Trigger: Call qtPortalPayment.emit("payment:open", { reference, amount, type, ... }). The widget opens Stripe Checkout immediately.
Inputs
Pass data inline on the emit payload — no DOM attrs needed.
Both contracts use the same reconciliation listener
Subscribe to payment:complete on window.qtPortalPayment (or your custom data-global-name). Echo back data.reference to match the payment to your DB row. Use payment:closed for cancel/close handling.
🧱 Pattern A — Inline / Button (data-* sync)
Use when the widget renders its own Pay button on the page. You drive it by writing data-* attributes; the widget reads them live when the user clicks.
<!doctype html>
<html>
<body>
<!-- Your own form UI -->
<input id="amt" type="number" placeholder="Amount" />
<input id="email" type="email" placeholder="Customer email" />
<!-- Widgetfied placeholder. portal.js scans for these on load
and mounts the widget here. The data-* attrs below are
live — the widget re-reads them when the user clicks Pay. -->
<div
id="my-payment-widget"
data-widget="payment"
data-tenant="MY_TENANT_ABCDE"
data-container="my-payment-widget"
data-display-mode="inline"
data-global-name="qtPortalPayment"
data-amount=""
data-reference=""
data-service=""
data-customer-email="">
</div>
<p id="status"></p>
<!-- Load portal.js. The IIFE auto-runs and scans the DOM. -->
<script type="module" src="https://cdn.widgetfied.com/portal.js"></script>
<script>
const widget = document.getElementById('my-payment-widget');
// Sync data-* attrs on every change. The widget reads these
// at click time — no re-init, no debounce needed.
function sync() {
const amount = document.getElementById('amt').value;
const email = document.getElementById('email').value;
widget.setAttribute('data-amount', amount || '');
widget.setAttribute('data-reference', amount ? `INV-${Date.now()}` : '');
widget.setAttribute('data-service', amount ? `Payment — $${amount}` : '');
widget.setAttribute('data-customer-email', email || '');
}
document.getElementById('amt').addEventListener('input', sync);
document.getElementById('email').addEventListener('input', sync);
// Reconciliation listener. Polls for the bus internally so
// we don't need a "ready" gate. payment:complete fires after
// Stripe checkout finishes and posts back to the consumer tab.
function attachListener(retriesLeft = 20) {
const bus = window.qtPortalPayment;
if (!bus?.on) {
if (retriesLeft <= 0) return;
return setTimeout(() => attachListener(retriesLeft - 1), 200);
}
bus.on('payment:complete', async (data) => {
// data: { success, type, reference, amount, sessionId, tenant, ... }
if (!data.success) return;
// Reconcile against your backend using data.reference / data.sessionId
const resp = await fetch('/api/credit-purchase', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
purchaseId: data.reference,
amount: data.amount,
sessionId: data.sessionId,
}),
});
document.getElementById('status').textContent = resp.ok
? `$${data.amount} captured.`
: 'Capture ok, credit failed — contact support.';
});
}
attachListener();
</script>
</body>
</html>🧱 Pattern B — Modal (payment:open emit)
Use when YOUR UI triggers the modal directly — no rendered widget button. The placeholder stays hidden; you call emit() to open the payment modal programmatically.
<!doctype html>
<html>
<body>
<button id="buy-25">Buy $25 tokens</button>
<!-- Hidden placeholder. CRITICAL: use visibility:hidden NOT
display:none — portal.js scans for paint-laid-out elements
and skips display:none ancestors. -->
<div
id="my-payment-widget"
data-widget="payment"
data-tenant="MY_TENANT_ABCDE"
data-container="my-payment-widget"
data-display-mode="modal"
data-global-name="qtPortalPayment"
style="visibility:hidden; position:absolute; pointer-events:none;">
</div>
<script type="module" src="https://cdn.widgetfied.com/portal.js"></script>
<script>
function withBus(cb, retriesLeft = 20) {
const bus = window.qtPortalPayment;
if (bus?.emit) return cb(bus);
if (retriesLeft <= 0) return;
setTimeout(() => withBus(cb, retriesLeft - 1), 200);
}
// Subscribe before user can click
withBus((bus) => {
bus.on('payment:complete', async (data) => {
if (!data.success) return;
await fetch('/api/credit-purchase', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
purchaseId: data.reference,
amount: data.amount,
sessionId: data.sessionId,
}),
});
});
});
// Emit payment:open on click. Generate + persist your purchase
// row first so reference is auditable.
document.getElementById('buy-25').addEventListener('click', async () => {
const intent = await fetch('/api/create-purchase-intent', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ amount: 25 }),
}).then((r) => r.json());
withBus((bus) => bus.emit('payment:open', {
type: 'custom', // 'job' | 'deposit' | 'estimate' | 'cart' | 'custom'
reference: intent.purchaseId, // YOUR id — echoed back on payment:complete
amount: 25.00,
service: 'Token pack — $25',
customerEmail: 'jane@example.com',
}));
});
</script>
</body>
</html>⚛️ Production Pattern — React Hook (useWidgetfied)
A reusable hook + components module. Drop into your React/Next.js app at app/hooks/useWidgetfied.tsx. Module-level pre-warm fetches portal.js the moment this file is imported, before any component mounts.
"use client";
import { forwardRef, useCallback, useEffect, useRef } from "react";
const TENANT_ID = process.env.NEXT_PUBLIC_WIDGETFIED_TENANT_ID ?? "";
const CDN_URL =
process.env.NEXT_PUBLIC_WIDGETFIED_CDN_URL ??
"https://cdn.widgetfied.com/portal.js";
const GLOBAL_NAME =
process.env.NEXT_PUBLIC_WIDGETFIED_GLOBAL_NAME ?? "qtPortalPayment";
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
let scriptLoaded = false;
let scriptLoading = false;
let scriptError = false;
let loadAttempts = 0;
const pendingCallbacks: Array<() => void> = [];
interface PaymentEventBus {
emit: (event: string, data?: unknown) => void;
on: (event: string, handler: (data: unknown) => void) => void;
off: (event: string, handler: (data: unknown) => void) => void;
}
interface WidgetfiedGlobal {
init?: () => void;
}
declare global {
interface Window {
Widgetfied?: WidgetfiedGlobal;
qtPortalPayment?: PaymentEventBus;
[key: string]: unknown;
}
}
function loadScript(callback?: () => void): void {
if (typeof window === "undefined") return;
if (scriptLoaded) {
callback?.();
return;
}
if (callback) pendingCallbacks.push(callback);
if (scriptLoading) return;
if (scriptError && loadAttempts >= MAX_RETRIES) {
scriptError = false;
loadAttempts = 0;
}
scriptLoading = true;
loadAttempts++;
const existing = document.querySelector(`script[src="${CDN_URL}"]`);
if (existing) existing.remove();
const script = document.createElement("script");
script.src = CDN_URL;
script.async = true;
script.onload = () => {
scriptLoaded = true;
scriptLoading = false;
scriptError = false;
// 150ms paint delay — gives portal.js time to register
// window.Widgetfied (defensive — global may exist in future
// versions), then flushes pending callbacks.
window.setTimeout(() => {
window.Widgetfied?.init?.();
while (pendingCallbacks.length) {
const cb = pendingCallbacks.shift();
cb?.();
}
}, 150);
};
script.onerror = () => {
scriptLoading = false;
scriptError = true;
if (loadAttempts < MAX_RETRIES) {
window.setTimeout(() => loadScript(), RETRY_DELAY * loadAttempts);
} else {
while (pendingCallbacks.length) {
const cb = pendingCallbacks.shift();
cb?.();
}
}
};
document.body.appendChild(script);
}
// Pre-warm — start fetching portal.js the moment this module
// imports, before any component mounts.
if (typeof window !== "undefined") {
loadScript();
}
/**
* useWidgetInit — call from any component that mounts a
* <div data-widget="..."> placeholder. Fires Widgetfied.init() on
* EVERY render (no dep array) so SPA navigation / lazy-mounted
* modals get re-scanned. The 50ms setTimeout gives React one tick
* to paint the placeholder before the scan runs.
*/
function useWidgetInit(): void {
const mountedRef = useRef<boolean>(false);
const initWidget = useCallback(() => {
if (typeof window === "undefined") return;
window.Widgetfied?.init?.();
}, []);
useEffect(() => {
mountedRef.current = true;
if (scriptLoaded) {
const timer = window.setTimeout(initWidget, 50);
return () => {
mountedRef.current = false;
window.clearTimeout(timer);
};
}
loadScript(() => {
if (mountedRef.current) {
window.setTimeout(initWidget, 50);
}
});
return () => {
mountedRef.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}); // INTENTIONAL: no dep array — fires on every render so SPA nav re-inits.
}
export interface PaymentWidgetProps {
displayMode?: "inline" | "button" | "modal";
id?: string;
className?: string;
globalName?: string;
}
/**
* Renders a Widgetfied placeholder. Parent grabs the underlying
* div via forwardRef so it can drive the widget by writing data-*
* attributes directly. Empty defaults are filled by
* syncWidgetfiedAttrs on every input change.
*/
export const PaymentWidget = forwardRef<HTMLDivElement, PaymentWidgetProps>(
function PaymentWidget(
{
displayMode = "button",
id = "payment-widget",
className = "",
globalName = GLOBAL_NAME,
...rest
},
ref,
) {
useWidgetInit();
return (
<div
ref={ref}
id={id}
data-widget="payment"
data-tenant={TENANT_ID}
data-container={id}
data-display-mode={displayMode}
data-global-name={globalName}
data-amount=""
data-reference=""
data-service=""
data-customer-name=""
data-customer-email=""
className={className}
{...rest}
/>
);
},
);
/**
* Sync values to a Widgetfied placeholder's data-* attributes.
* The widget reads these live when the user clicks its pay button
* — no re-init required. Call from a useEffect on every relevant
* state change. setAttribute is cheap; no debounce needed.
*/
export function syncWidgetfiedAttrs(
el: HTMLElement | null,
attrs: {
amount?: number | string;
reference?: string;
service?: string;
customerName?: string;
customerEmail?: string;
},
): void {
if (!el) return;
if (attrs.amount !== undefined) el.setAttribute("data-amount", String(attrs.amount));
if (attrs.reference !== undefined) el.setAttribute("data-reference", attrs.reference);
if (attrs.service !== undefined) el.setAttribute("data-service", attrs.service);
if (attrs.customerName !== undefined) el.setAttribute("data-customer-name", attrs.customerName);
if (attrs.customerEmail !== undefined) el.setAttribute("data-customer-email", attrs.customerEmail);
}
/**
* Subscribe to a Widgetfied payment event. Internally polls for
* the bus (~4s) so callers don't need to gate on a "ready" flag —
* the subscription attaches the moment portal.js exposes the bus.
*
* Returns an unsubscribe function.
*/
export function onWidgetfiedPaymentEvent(
event: string,
handler: (data: unknown) => void,
globalName: string = GLOBAL_NAME,
): () => void {
let bus: PaymentEventBus | undefined;
function attach(): void {
if (typeof window === "undefined") return;
bus = window[globalName] as PaymentEventBus | undefined;
if (bus && typeof bus.on === "function") bus.on(event, handler);
}
function tryAttach(retriesLeft: number): void {
if (typeof window === "undefined") return;
const candidate = window[globalName] as PaymentEventBus | undefined;
if (candidate && typeof candidate.on === "function") {
attach();
return;
}
if (retriesLeft <= 0) return;
window.setTimeout(() => tryAttach(retriesLeft - 1), 200);
}
tryAttach(20); // up to ~4s
return () => {
if (bus && typeof bus.off === "function") bus.off(event, handler);
};
}
export function isWidgetfiedConfigured(): boolean {
return TENANT_ID.length > 0;
}🎯 Consumer Pattern — useEffect-Driven Sync
Once useWidgetfied is in place, consuming components grab the placeholder via ref and call syncWidgetfiedAttrs on every state change. No "ready" flag needed — onWidgetfiedPaymentEvent polls internally.
"use client";
import { useEffect, useRef, useState } from "react";
import {
PaymentWidget,
isWidgetfiedConfigured,
syncWidgetfiedAttrs,
onWidgetfiedPaymentEvent,
} from "../../hooks/useWidgetfied";
const PAYG_AMOUNTS_USD: ReadonlyArray<number> = [25, 50, 250];
interface PaymentCompleteEvent {
success?: boolean;
reference?: string;
amount?: number;
sessionId?: string;
type?: string;
}
export interface BuyTokensModalProps {
isOpen: boolean;
onClose: () => void;
tenantId: string | null;
customerEmail: string | null;
clerkUserId: string | null;
}
export function BuyTokensModal({
isOpen,
onClose,
tenantId,
customerEmail,
clerkUserId,
}: BuyTokensModalProps): JSX.Element {
const [selectedAmount, setSelectedAmount] = useState<number | null>(null);
const [customAmount, setCustomAmount] = useState<string>("");
// Dedup ref for payment:complete events (rare but possible if the
// user re-emits during a widget re-render).
const completedSessionsRef = useRef<Set<string>>(new Set());
// Ref to the Widgetfied placeholder div. Drive the widget by
// calling syncWidgetfiedAttrs(widgetRef.current, …) on every
// amount change.
const widgetRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) {
setSelectedAmount(null);
setCustomAmount("");
completedSessionsRef.current.clear();
}
}, [isOpen]);
// payment:complete listener — onWidgetfiedPaymentEvent already
// polls for the bus internally, so no `ready` gate needed here.
useEffect(() => {
if (!isOpen || !tenantId) return;
const onComplete = async (raw: unknown): Promise<void> => {
const e = (raw ?? {}) as PaymentCompleteEvent;
const sessionId = typeof e.sessionId === "string" ? e.sessionId : null;
if (!sessionId) return;
if (completedSessionsRef.current.has(sessionId)) return;
completedSessionsRef.current.add(sessionId);
if (!e.success) return;
const amountUsd = typeof e.amount === "number" ? e.amount : 0;
if (amountUsd <= 0 || !tenantId) return;
// Host-app reconciliation: create the purchase row
// (idempotent on sessionId), credit balance, refetch, close.
// ...
};
const offComplete = onWidgetfiedPaymentEvent("payment:complete", onComplete);
return () => offComplete();
}, [isOpen, tenantId, onClose]);
function handleTileClick(amt: number): void {
setSelectedAmount(amt);
setCustomAmount("");
}
const effectiveAmount =
selectedAmount ?? (customAmount ? Number(customAmount) : 0);
const hasAmount = effectiveAmount > 0;
// ============================================================
// CORE PATTERN — sync data-* attrs on every amount change.
// No debouncing. No "ready" gate. setAttribute is cheap.
// ============================================================
useEffect(() => {
if (!isOpen) return;
if (!hasAmount) {
syncWidgetfiedAttrs(widgetRef.current, {
amount: "",
reference: "",
service: "",
customerName: "",
customerEmail: "",
});
return;
}
syncWidgetfiedAttrs(widgetRef.current, {
amount: effectiveAmount,
reference: `lga-${tenantId ?? "anon"}-${clerkUserId ?? "anon"}-${effectiveAmount}-${Date.now()}`,
service: `HandyMiner tokens — $${effectiveAmount}`,
customerEmail: customerEmail ?? "",
customerName: customerEmail?.split("@")[0] ?? "",
});
}, [isOpen, hasAmount, effectiveAmount, tenantId, clerkUserId, customerEmail]);
return (
<div
className={`buy-tokens-modal-root${isOpen ? " is-open" : ""}`}
role={isOpen ? "dialog" : undefined}
aria-modal={isOpen ? "true" : undefined}
aria-hidden={!isOpen}
>
<div className="buy-tokens-backdrop" onClick={onClose} aria-hidden="true" />
<div className="buy-tokens-card">
<div className="buy-tokens-tiles" role="radiogroup">
{PAYG_AMOUNTS_USD.map((amt) => (
<button
key={amt}
type="button"
role="radio"
aria-checked={selectedAmount === amt}
onClick={() => handleTileClick(amt)}
disabled={!isWidgetfiedConfigured()}
>
${amt}
</button>
))}
</div>
<input
type="number"
value={customAmount}
onChange={(e) => {
setCustomAmount(e.target.value);
setSelectedAmount(null);
}}
/>
{/* The Widgetfied placeholder. ref={widgetRef} is what makes
the data-* sync work — parent grabs the DOM node via
forwardRef and writes attributes directly. */}
{isWidgetfiedConfigured() && (
<PaymentWidget
ref={widgetRef}
id="lga-buy-tokens-widget"
displayMode="inline"
/>
)}
</div>
</div>
);
}⚠️ Three things will silently break your integration
Before you ship, read the Critical Gotchas, Next.js Rewrites, and CSP sections below. Each documents a non-obvious behavior that produces no useful error message — the widget just doesn't respond, or shows "Access Required" with no other clue.
⚠️ Critical Gotchas
These are the non-obvious behaviors that will break your integration if you skip them. Every one was discovered the hard way.
display: none breaks Widgetfied.init()
criticalportal.js scans for placeholders via the layout tree. Any ancestor with display: none gives the placeholder a 0×0 painted box and the scan skips it. For modal-style flows, hide the modal with visibility: hidden + opacity: 0 + pointer-events: none — never display: none.
CSS — modal closed/open states
.buy-tokens-modal-root {
position: fixed;
inset: 0;
z-index: 9000;
/* DO NOT use display:none here.
portal.js's scan ignores 0×0 painted boxes. */
visibility: hidden;
opacity: 0;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
}
.buy-tokens-modal-root.is-open {
visibility: visible;
opacity: 1;
pointer-events: auto;
}Trigger contracts are not interchangeable
criticalFor displayMode="inline" or "button", the trigger is data-* attribute sync via setAttribute(). For displayMode="modal", the trigger is qtPortalPayment.emit("payment:open", ...). Mixing them up — calling emit on an inline widget, or syncing data-* attrs on a modal widget — silently does nothing.
SPA navigation needs no-deps useEffect
highportal.js scans the DOM only once per page load. In React/Next.js SPAs, components mount and unmount during route changes — the scan never re-runs. Solution: call window.Widgetfied?.init?.() on every render via useEffect with NO dependency array. The 50ms setTimeout gives React a paint tick before the scan.
Module-level pre-warm beats useEffect
mediumLoading portal.js inside useEffect adds latency before the first widget can render. Better: kick off loadScript() at module-import time (outside any component), so the CDN fetch starts the moment the page begins rendering.
No "ready" gate needed in consumers
lowonWidgetfiedPaymentEvent polls for the bus internally (up to ~4s). Consumers don't need their own ready state. syncWidgetfiedAttrs writes to the DOM unconditionally — if the widget hasn't mounted yet, the attrs are still set and will be read when it does.
🔀 Next.js Rewrites — REQUIRED for Same-Origin API Calls
portal.js makes RELATIVE-PATH API calls back through the consumer domain (the browser hits localhost:3000/api/payments/... NOT widgetfied.com/api/payments/...). Without these rewrites in next.config.mjs, you will get 404s and "Access Required / Widget Not Available" errors with no obvious cause.
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
reactStrictMode: true,
async rewrites() {
return [
// ============================================================
// Widgetfied portal.js makes RELATIVE-PATH API calls.
// These proxy them to the real Widgetfied API. Without them,
// portal.js gets 404s and refuses to render the inline form.
// ============================================================
{
source: "/api/payments/:path*",
destination: "https://widgetfied.com/api/payments/:path*",
},
{
source: "/api/tenant-config",
destination: "https://widgetfied.com/api/tenant-config",
},
{
source: "/api/tenant-config/:path*",
destination: "https://widgetfied.com/api/tenant-config/:path*",
},
{
source: "/api/portal/:path*",
destination: "https://widgetfied.com/api/portal/:path*",
},
{
source: "/api/calendar/:path*",
destination: "https://widgetfied.com/api/calendar/:path*",
},
{
source: "/api/email/:path*",
destination: "https://widgetfied.com/api/email/:path*",
},
{
source: "/api/estimates/:path*",
destination: "https://widgetfied.com/api/estimates/:path*",
},
// ============================================================
// Per-tenant config endpoints (white-label, services,
// email-templates). Listed EXPLICITLY rather than blanket-
// rewriting /api/tenants/:tenantId/* because the host app may
// own its own subpaths under that prefix. Add a new rewrite
// here if a future portal.js release calls another path.
// ============================================================
{
source: "/api/tenants/:tenantId/white-label",
destination: "https://widgetfied.com/api/tenants/:tenantId/white-label",
},
{
source: "/api/tenants/:tenantId/services",
destination: "https://widgetfied.com/api/tenants/:tenantId/services",
},
{
source: "/api/tenants/:tenantId/email-templates",
destination:
"https://widgetfied.com/api/tenants/:tenantId/email-templates",
},
];
},
};
export default nextConfig;If your host app already owns a path under /api/payments/ or /api/portal/, list per-route rewrites instead of the broad :path* version above so your routes win.
🔐 Content Security Policy
If your site uses CSP, allow the following sources. The worker-src directive is non-obvious and required if you use any SDK that creates blob: workers (Clerk session-token poller is a common one).
// next.config.mjs — headers() function
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.widgetfied.com https://*.widgetfied.com",
// worker-src — REQUIRED if you use any SDK that creates blob:
// workers (e.g. Clerk session-token poller). Without an explicit
// worker-src, the browser falls back to script-src which doesn't
// whitelist blob:, killing the worker.
"worker-src 'self' blob:",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com data:",
"img-src 'self' data: https: blob:",
"connect-src 'self' ws: wss: https: https://widgetfied.com https://*.widgetfied.com",
"frame-src 'self' https://widgetfied.com https://*.widgetfied.com",
"media-src 'self' blob: data:",
"base-uri 'self'",
"form-action 'self'",
].join("; "),
}frame-src covers iframe embeds. Stripe Checkout opens in a popup window (not an iframe), so https://*.stripe.com is not required in frame-src.
🌍 Environment Variables
Consumer-side variables. NEXT_PUBLIC_* values are inlined at next build time, NOT at container start — if you ship via Docker, thread them through Dockerfile ARG/ENV declarations and docker-compose args:.
# .env (consumer app)
NEXT_PUBLIC_WIDGETFIED_TENANT_ID=<your-tenant-id-from-widgetfied.com>
NEXT_PUBLIC_WIDGETFIED_CDN_URL=https://cdn.widgetfied.com/portal.js
NEXT_PUBLIC_WIDGETFIED_GLOBAL_NAME=qtPortalPaymentGLOBAL_NAME defaults to qtPortalPayment. Override only if you mount multiple Payment Widget instances on the same page.
🎮 Live Demo
Try the payment widget with dynamic data binding. Update the fields below and watch the widget update in real-time.
HTML
<!-- Payment Widget Container -->
<div id="payment-demo"
data-widget="payment"
data-tenant="YOUR_TENANT_ID"
data-container="payment-demo"
data-reference="INV-001"
data-amount="25.00"
data-service="Test Service"
data-customer-email="test@example.com">
</div>
<script type="module" src="https://cdn.widgetfied.com/portal.js"></script>JavaScript Events
// Update widget data dynamically
// (read at click time, so updates take effect on next user click)
function updatePaymentWidget(field, value) {
const widget = document.getElementById('payment-demo');
widget.setAttribute(`data-${field}`, value);
}
// Listen for payment events on the global event bus.
// Default global is qtPortalPayment (override with data-global-name).
window.addEventListener('DOMContentLoaded', () => {
if (window.qtPortalPayment) {
// Fires when checkout finishes; payload: { success, type, reference, amount, sessionId }
qtPortalPayment.on('payment:complete', (data) => {
if (data.success) {
console.log('✅ Payment complete:', data);
}
});
// Fires when the modal closes (success or cancel).
// Gate cancel handling on whether payment:complete fired first.
qtPortalPayment.on('payment:closed', () => {
console.log('Payment modal closed');
});
// Trigger checkout programmatically (alternative to the rendered button)
// qtPortalPayment.emit('payment:open', {
// type: 'job',
// reference: 'INV-001',
// baseAmount: 75,
// tenant: 'YOUR_TENANT_ID',
// metadata: { service: 'Service description' }
// });
}
});The widget dynamically responds to data attribute changes. Update data-amount, data-reference, or data-customer-email via JavaScript and the widget updates instantly.
💡 Perfect For
Invoice Payment Pages
Create simple payment links for outstanding invoices
yoursite.com/pay/invoice-123Service Payment Portals
Accept payments for completed services with optional tipping
Lawn care, cleaning, repairsDonation Forms
Quick donation pages for non-profits or fundraising
One-time or custom amountsEvent Registration
Collect payments for workshops, classes, or events
Registration with confirmationDigital Product Sales
Simple checkout for digital downloads or services
E-books, courses, consultations🛠️ Configuration Options
Customize the widget behavior with these HTML data attributes:
| Attribute | Required | Type | Description | Example |
|---|---|---|---|---|
data-widget | Required | string | Must be "payment" for payment widget | "payment" |
data-tenant | Required | string | Your unique tenant/merchant ID | "TENANT_ABC123" |
data-container | Optional | string | ID of the container element. Strongly recommended in custom HTML — the default only matches one element. | "payment-widget" |
data-display-mode | Optional | string | Trigger contract: "inline" or "button" → drive via data-* attribute sync. "modal" → drive via qtPortalPayment.emit("payment:open", ...). The two contracts are NOT interchangeable. | "inline" | "button" | "modal" |
data-amount | Optional | number | Pre-set payment amount in dollars | "150.00" |
data-service | Optional | string | Service or product description | "Premium Package" |
data-reference | Optional | string | Invoice or reference number | "INV-2024-001" |
data-customer-name | Optional | string | Pre-fill customer name | "John Doe" |
data-customer-email | Optional | string | Pre-fill customer email | "john@example.com" |
data-customer-phone | Optional | string | Pre-fill customer phone | "555-0100" |
data-global-name | Optional | string | Window global used to subscribe to payment events (override only when running multiple instances) | "qtPortalPayment" |
📋 Real-World Examples
Simple Invoice Payment
Basic payment page for a specific invoice amount
<!-- Invoice Payment Page -->
<div id="invoice-payment"
data-widget="payment"
data-tenant="YOUR_TENANT_ID"
data-container="invoice-payment"
data-reference="INV-2024-001"
data-amount="250.00"
data-service="Web Design Services"
data-customer-name="Jane Smith"
data-customer-email="jane@company.com">
</div>
<script type="module" src="https://cdn.widgetfied.com/portal.js"></script>Service Payment with Tipping
Perfect for service businesses that accept tips
<!-- Service Payment with Optional Tip -->
<div id="service-payment"
data-widget="payment"
data-tenant="YOUR_TENANT_ID"
data-container="service-payment"
data-amount="75.00"
data-service="House Cleaning Service"
data-reference="JOB-2024-456">
</div>
<script type="module" src="https://cdn.widgetfied.com/portal.js"></script>Donation Form
Let users choose their donation amount, then redirect on success via event listener
<!-- Donation Widget (User Enters Amount) -->
<div id="donation-widget"
data-widget="payment"
data-tenant="YOUR_TENANT_ID"
data-container="donation-widget"
data-service="One-Time Donation">
</div>
<script type="module" src="https://cdn.widgetfied.com/portal.js"></script>
<script>
window.addEventListener('DOMContentLoaded', () => {
qtPortalPayment.on('payment:complete', (data) => {
if (data.success) window.location.href = '/thank-you-donation';
});
});
</script>Event Registration
Collect payment for workshop or event registration, then redirect on success
<!-- Event Registration Payment -->
<div id="event-payment"
data-widget="payment"
data-tenant="YOUR_TENANT_ID"
data-container="event-payment"
data-amount="199.00"
data-service="Digital Marketing Workshop - March 2024"
data-reference="EVENT-MAR-2024">
</div>
<script type="module" src="https://cdn.widgetfied.com/portal.js"></script>
<script>
window.addEventListener('DOMContentLoaded', () => {
qtPortalPayment.on('payment:complete', (data) => {
if (data.success) window.location.href = '/registration-confirmed';
});
});
</script>🔧 Integration Patterns
These are simple drop-in snippets for static / no-build environments. For React or Next.js, use the Production Pattern — React Hook above (it handles SPA navigation, module-level pre-warm, and the forwardRef + sync pattern correctly). Don't use the React snippet below in production.
Static HTML SitesAdd to any HTML page - perfect for GitHub Pages, Netlify, or simple hosting
Add to any HTML page - perfect for GitHub Pages, Netlify, or simple hosting
<!-- Add to your HTML file -->
<div id="payment" data-widget="payment" data-tenant="YOUR_ID" data-container="payment"></div>
<script type="module" src="https://cdn.widgetfied.com/portal.js"></script>WordPressUse Custom HTML block or add to theme template
Use Custom HTML block or add to theme template
<!-- Add via Custom HTML Block in WordPress Editor -->
<div id="payment" data-widget="payment" data-tenant="YOUR_ID" data-container="payment"></div>
<script type="module" src="https://cdn.widgetfied.com/portal.js"></script>React/Next.jsAdd as a component or use useEffect for dynamic loading
Add as a component or use useEffect for dynamic loading
// React Component
function PaymentWidget({ amount, reference }) {
useEffect(() => {
const script = document.createElement('script');
script.src = 'https://cdn.widgetfied.com/portal.js';
script.type = 'module';
document.body.appendChild(script);
}, []);
return (
<div
id="payment"
data-widget="payment"
data-tenant="YOUR_ID"
data-container="payment"
data-amount={amount}
data-reference={reference}
/>
);
}Dynamic with JavaScriptGenerate payment widgets dynamically
Generate payment widgets dynamically
// Create payment widget dynamically
function createPaymentWidget(amount, reference) {
const container = document.createElement('div');
container.id = 'dynamic-payment';
container.setAttribute('data-widget', 'payment');
container.setAttribute('data-tenant', 'YOUR_ID');
container.setAttribute('data-container', 'dynamic-payment');
container.setAttribute('data-amount', amount);
container.setAttribute('data-reference', reference);
document.getElementById('payment-area').appendChild(container);
// Load script if not already loaded
if (!document.querySelector('script[src*="portal.js"]')) {
const script = document.createElement('script');
script.src = 'https://cdn.widgetfied.com/portal.js';
script.type = 'module';
document.body.appendChild(script);
}
}💳 Payment Flow
Widget Loads
The widget initializes and checks Stripe Connect status
Customer Clicks Pay
Payment modal opens with pre-filled or editable details
Amount Confirmation
Customer reviews amount and can add optional tip
Secure Checkout
Stripe handles payment details securely (PCI compliant)
Processing
Payment processes directly to merchant's Stripe account
Confirmation
Success message shown and emails sent to both parties
🤝 Division of Responsibility
Knowing exactly which side owns which step keeps integration scoped and avoids duplicate work.
Widgetfied handles
- Stripe session creation + Connect routing to the tenant's account
- Popup window open + window.open() blocked detection + retry UI
- Stripe-hosted success / cancel pages
- postMessage from Stripe → Widgetfied success page → consumer tab
- Re-emitting on the bus as payment:complete once the postMessage arrives
- payment:closed on cancel or success
- Tip selection, processing fees, discount codes, Venmo (per dashboard config)
You handle
- Loading portal.js (pre-warm at module-import time recommended for React apps)
- Picking the right trigger contract: data-* sync for inline/button mode, payment:open emit for modal mode
- Generating + persisting the reference (your purchase id) BEFORE the payment is triggered so you have something to reconcile against
- Listening for payment:complete (use onWidgetfiedPaymentEvent — it polls for the bus internally, no ready gate needed in consumer code), matching data.reference against your pending set, and crediting the customer in your DB
- Hiding modal containers with visibility:hidden + opacity:0 + pointer-events:none — never display:none (it breaks the placeholder scan)
The lossy edge case
If the user pays but closes the consumer tab before payment:complete arrives, the credit is missed. Mitigations:
- •Recommended: a periodic "reconcile pending purchases" cron that polls Stripe (or a future Widgetfied API) for completed sessions matching pending rows
- •Or: a manual support flow keyed off your purchase id (data.reference)
✅ Pre-flight Checklist Before Going Live
- 1
Sign up at widgetfied.com
Grab your tenant id from the dashboard.
- 2
Connect Stripe
Settings → Payments → Connect Stripe Account, then complete Stripe onboarding. Without this the API call to create a checkout session fails.
- 3
Configure widget settings
Set preset amounts, tip percentages, processing fees, and minimum amount in the Widgetfied dashboard.
- 4
Set environment variable
NEXT_PUBLIC_WIDGETFIED_TENANT_ID (or equivalent) in your app.
- 5
Smoke-test
Use Stripe test card 4242 4242 4242 4242 → verify payment:complete fires on your listener with the right reference.
🔒 Security & Compliance
PCI Compliance
All payment data is handled by Stripe's PCI-compliant infrastructure. Your servers never touch sensitive card data.
Stripe Connect
Each merchant has their own isolated Stripe account. Payments go directly to them with no intermediary.
HTTPS Required
The widget only works on secure HTTPS connections to protect customer data.
No Data Storage
The widget doesn't store any payment information. Everything is processed in real-time through Stripe.
✅ Requirements
🧪 Testing Your Integration
- 1Use Stripe test mode (enabled by default in development)
- 2Test card number: 4242 4242 4242 4242
- 3Any future expiry date and any 3-digit CVC
- 4Test different amounts and scenarios
- 5Verify email confirmations are sent
- 6Check that payments appear in your Stripe dashboard
🔧 Troubleshooting
Widget not appearing
- • Verify the container ID matches the data-container attribute
- • Check browser console for JavaScript errors
- • Ensure the script tag is loading correctly
- • Confirm your tenant ID is valid
Payment button disabled
- • Complete Stripe Connect onboarding in dashboard
- • Verify your Stripe account is active
- • Check that your subscription is active
- • Ensure you're using HTTPS
Payments failing
- • Check Stripe dashboard for detailed error messages
- • Verify customer card details are correct
- • Ensure amount is above minimum ($0.50 USD)
- • Check for any Stripe account restrictions
Custom styling needed
- • The widget adapts to your site's font family
- • Use CSS to style the container div
- • Contact support for advanced customization options
✨ Best Practices
Always pre-fill known data
If you have customer information, use data attributes to pre-fill fields for better UX
Use meaningful references
Include invoice numbers or job IDs in data-reference for easy tracking
Set specific amounts when possible
Pre-set amounts reduce friction and errors in the payment process
Test thoroughly
Always test the full payment flow before going live
Provide clear context
Add descriptive text around the widget explaining what the payment is for
❓ Frequently Asked Questions
Q: Do I need a backend server?
A: No! The widget is completely self-contained and handles everything through our secure API.
Q: What are the fees?
A: Standard Stripe fees apply (2.9% + $0.30). We don't charge any additional platform fees.
Q: Can I customize the appearance?
A: The widget adapts to your site's styling. Advanced customization is available through white-label settings.
Q: Is it mobile-friendly?
A: Yes! The widget is fully responsive and works great on all devices.
Q: Can I use it on multiple sites?
A: Yes, you can embed the widget on unlimited sites using the same tenant ID.
Q: How quickly do I receive payments?
A: Payments are deposited according to your Stripe payout schedule (typically 2-7 days).