widgetfied

© 2026 Widgetfied

Standalone Payment Widget

Zero Dependencies

A 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.

No backend required
Works on any HTML page
Stripe Connect powered
Mobile-responsive
PCI compliant

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

data-* sync

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-amount
  • data-reference
  • data-service
  • data-customer-name
  • data-customer-email
  • data-customer-phone

displayMode = modal

payment:open emit

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()

critical

portal.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

critical

For 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

high

portal.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

medium

Loading 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

low

onWidgetfiedPaymentEvent 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=qtPortalPayment

GLOBAL_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-123

Service Payment Portals

Accept payments for completed services with optional tipping

Lawn care, cleaning, repairs

Donation Forms

Quick donation pages for non-profits or fundraising

One-time or custom amounts

Event Registration

Collect payments for workshops, classes, or events

Registration with confirmation

Digital Product Sales

Simple checkout for digital downloads or services

E-books, courses, consultations

🛠️ Configuration Options

Customize the widget behavior with these HTML data attributes:

AttributeRequiredTypeDescriptionExample
data-widgetRequiredstringMust be "payment" for payment widget"payment"
data-tenantRequiredstringYour unique tenant/merchant ID"TENANT_ABC123"
data-containerOptionalstringID of the container element. Strongly recommended in custom HTML — the default only matches one element."payment-widget"
data-display-modeOptionalstringTrigger 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-amountOptionalnumberPre-set payment amount in dollars"150.00"
data-serviceOptionalstringService or product description"Premium Package"
data-referenceOptionalstringInvoice or reference number"INV-2024-001"
data-customer-nameOptionalstringPre-fill customer name"John Doe"
data-customer-emailOptionalstringPre-fill customer email"john@example.com"
data-customer-phoneOptionalstringPre-fill customer phone"555-0100"
data-global-nameOptionalstringWindow 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 Sites

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>
WordPress

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.js

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 JavaScript

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

1

Widget Loads

The widget initializes and checks Stripe Connect status

2

Customer Clicks Pay

Payment modal opens with pre-filled or editable details

3

Amount Confirmation

Customer reviews amount and can add optional tip

4

Secure Checkout

Stripe handles payment details securely (PCI compliant)

5

Processing

Payment processes directly to merchant's Stripe account

6

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. 1

    Sign up at widgetfied.com

    Grab your tenant id from the dashboard.

  2. 2

    Connect Stripe

    Settings → Payments → Connect Stripe Account, then complete Stripe onboarding. Without this the API call to create a checkout session fails.

  3. 3

    Configure widget settings

    Set preset amounts, tip percentages, processing fees, and minimum amount in the Widgetfied dashboard.

  4. 4

    Set environment variable

    NEXT_PUBLIC_WIDGETFIED_TENANT_ID (or equivalent) in your app.

  5. 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

Stripe Connect Setup- Complete Stripe onboarding in your dashboard
HTTPS Website- Your site must use SSL/TLS (https://)
Valid Tenant ID- Get your tenant ID from the dashboard
Modern Browser- Works in all modern browsers (Chrome, Firefox, Safari, Edge)

🧪 Testing Your Integration

  1. 1Use Stripe test mode (enabled by default in development)
  2. 2Test card number: 4242 4242 4242 4242
  3. 3Any future expiry date and any 3-digit CVC
  4. 4Test different amounts and scenarios
  5. 5Verify email confirmations are sent
  6. 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).

⚡ Quick setup
🚀 Get Started
DOCS