Skip to content
Fungies.io logoFungies.io Help Center

Developers

Example React checkout

A React pricing page that opens a Fungies overlay checkout with the user's e-mail and ID, then upgrades the account from the payment_success webhook.

This example is a pricing page with four subscription plans. When a signed-in user clicks a plan, an Overlay Checkout opens with their e-mail prefilled and their app user ID passed as the custom field user_id. Your webhook then reads user_id back and upgrades the right account.

It uses React 18 or 19 function components with TypeScript and the @fungies/fungies-js SDK. It works in any React setup (Vite, Remix, and others). For Next.js, see the Next.js 15 integration guide.

Before you start

  1. Create one Checkout Element per plan. Do this under Checkout Elements in the Dashboard, then copy each URL. A URL looks like https://your-store.fungies.io/checkout-element/YOUR_CHECKOUT_ID.
  2. Define a custom field with the Key user_id on the products (see Using CustomFields). Custom field values only reach your webhook when the key you pass matches a defined Key exactly.
  3. Authorize your domains. Go to Settings โ†’ Store โ†’ Checkout tab โ†’ Authorized domains and add every origin that opens the checkout, for example http://localhost:5173 and https://app.example.com. If an origin is missing, the overlay stays blank.

1. Install the SDK

bash
npm install @fungies/fungies-js

2. Initialize once

Fungies.Initialize() must run in the browser before the first Checkout.open(). It listens for messages from the checkout, so the overlay can close and checkout events fire. Wrap it so it runs only once, even under React Strict Mode:

ts
// src/lib/fungies.ts
import { Fungies } from '@fungies/fungies-js';

let initialized = false;

export function getFungies() {
  if (!initialized) {
    // This page opens checkouts from code, so the data-attribute scanner is turned off.
    Fungies.Initialize({ enableDataAttributes: false });
    initialized = true;
  }
  return Fungies;
}

3. The plan card

tsx
// src/components/PlanCard.tsx
import { Check } from 'lucide-react';

export type Plan = {
  id: 'basic' | 'standard' | 'premium' | 'pro';
  name: string;
  price: string;
  description: string;
  features: string[];
  checkoutUrl: string;
  popular?: boolean;
};

type PlanCardProps = {
  plan: Plan;
  isCurrentPlan: boolean;
  disabled: boolean;
  onSelect: (plan: Plan) => void;
};

export function PlanCard({ plan, isCurrentPlan, disabled, onSelect }: PlanCardProps) {
  return (
    <div
      className={`relative flex h-full flex-col rounded-lg border p-6 ${
        plan.popular ? 'border-green-500 shadow-md' : 'border-gray-200'
      }`}
    >
      {plan.popular && (
        <span className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-green-500 px-3 py-1 text-xs font-semibold text-white">
          Popular
        </span>
      )}
      <h3 className="text-lg font-semibold">{plan.name}</h3>
      <p className="mb-4 mt-2 whitespace-nowrap text-2xl font-bold">{plan.price}</p>
      <p className="mb-4 text-sm text-gray-500">{plan.description}</p>

      <ul className="mb-4 mt-auto space-y-2">
        {plan.features.map((feature) => (
          <li key={feature} className="flex items-center text-sm text-gray-600">
            <Check className="mr-2 h-4 w-4 flex-shrink-0 text-green-500" aria-hidden />
            {feature}
          </li>
        ))}
      </ul>

      <button
        type="button"
        onClick={() => onSelect(plan)}
        disabled={disabled || isCurrentPlan}
        className="mt-4 w-full rounded-md bg-green-600 px-4 py-2 text-white transition hover:bg-green-700 active:scale-95 disabled:cursor-not-allowed disabled:bg-gray-400"
      >
        {isCurrentPlan ? 'Current plan' : 'Select plan'}
      </button>
    </div>
  );
}

lucide-react only provides the check-mark icon. Drop it if you don't use it.

4. The pricing grid

The parent opens the checkout with the signed-in user's data. It also listens for the SDK's fungies:checkout:complete event to refresh the UI.

tsx
// src/components/PricingPlans.tsx
import { useEffect, useRef } from 'react';
import { DOM_CHECKOUT_EVENTS } from '@fungies/fungies-js';
import { getFungies } from '../lib/fungies';
import { PlanCard, type Plan } from './PlanCard';

const PLANS: Plan[] = [
  {
    id: 'basic',
    name: 'Basic',
    price: '$9 / month',
    description: 'For getting started.',
    features: ['1 project', 'E-mail support'],
    checkoutUrl: 'https://your-store.fungies.io/checkout-element/BASIC_CHECKOUT_ID',
  },
  {
    id: 'standard',
    name: 'Standard',
    price: '$19 / month',
    description: 'For small teams.',
    features: ['5 projects', 'E-mail support'],
    checkoutUrl: 'https://your-store.fungies.io/checkout-element/STANDARD_CHECKOUT_ID',
  },
  {
    id: 'premium',
    name: 'Premium',
    price: '$39 / month',
    description: 'For growing teams.',
    features: ['Unlimited projects', 'Priority support'],
    checkoutUrl: 'https://your-store.fungies.io/checkout-element/PREMIUM_CHECKOUT_ID',
    popular: true,
  },
  {
    id: 'pro',
    name: 'Pro',
    price: '$79 / month',
    description: 'For power users.',
    features: ['Everything in Premium', 'Dedicated support'],
    checkoutUrl: 'https://your-store.fungies.io/checkout-element/PRO_CHECKOUT_ID',
  },
];

type PricingPlansProps = {
  /** The signed-in user from your auth provider, or null while it's loading or signed out. */
  user: { id: string; email: string } | null;
  currentPlanId?: Plan['id'];
  /** Runs when the customer finishes checkout, for example to refetch the user's plan. */
  onPurchaseComplete?: () => void;
};

export function PricingPlans({ user, currentPlanId, onPurchaseComplete }: PricingPlansProps) {
  const onCompleteRef = useRef(onPurchaseComplete);
  useEffect(() => {
    onCompleteRef.current = onPurchaseComplete;
  });

  useEffect(() => {
    getFungies();
    const handleComplete = () => onCompleteRef.current?.();
    document.addEventListener(DOM_CHECKOUT_EVENTS.COMPLETE, handleComplete);
    return () => document.removeEventListener(DOM_CHECKOUT_EVENTS.COMPLETE, handleComplete);
  }, []);

  function openCheckout(plan: Plan) {
    if (!user) return;
    getFungies().Checkout.open({
      checkoutUrl: plan.checkoutUrl,
      settings: { mode: 'overlay' },
      billingData: { email: user.email },
      customFields: { user_id: user.id },
    });
  }

  return (
    <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
      {PLANS.map((plan) => (
        <PlanCard
          key={plan.id}
          plan={plan}
          isCurrentPlan={plan.id === currentPlanId}
          disabled={!user}
          onSelect={openCheckout}
        />
      ))}
    </div>
  );
}

Use it anywhere in your app:

tsx
<PricingPlans
  user={user ? { id: user.id, email: user.email } : null}
  currentPlanId={user?.planId}
  onPurchaseComplete={() => refetchUser()}
/>

Here user, planId and refetchUser come from your own auth and data layer.

The user's details are read when the button is clicked, so the checkout always gets the current user.id, even if your auth loaded after the page rendered.

Why not data-fungies-* attributes here? Data attributes are made for static HTML. The SDK reads them once, when it first finds the button, so later React updates to the attribute values (for example once your user loads) are ignored. In React, call Checkout.open() from the click handler as shown above.

5. Upgrade the account from the webhook

The fungies:checkout:complete event only tells the browser that checkout finished. It carries no order data, so don't unlock the plan because of it. Upgrade the account when your server receives the payment_success webhook. The user_id you passed is in data.items[].customFields:

json
{
  "id": "770e8400-e29b-41d4-a716-446655440010",
  "type": "payment_success",
  "testMode": false,
  "data": {
    "items": [
      {
        "object": "item",
        "name": "Premium - Monthly",
        "offer": { "object": "offer", "id": "aa0e8400-e29b-41d4-a716-446655440005", "internalId": null },
        "customFields": { "user_id": "usr_123" }
      }
    ],
    "user": { "object": "user", "id": "123e4567-e89b-12d3-a456-426614174000", "email": "customer@example.com" },
    "subscription": { "object": "subscription", "id": "L8VQK3N2M7KpQ9nR", "status": "active" }
  },
  "idempotencyKey": "770e8400-e29b-41d4-a716-446655440010"
}

The payload is shortened here. Your handler must:

  1. Verify the x-fngs-signature header before parsing the body. The header is sha256_ plus the hex HMAC-SHA256 of the raw request body, made with your webhook's secret. Compare in constant time.
  2. Skip event IDs you have already processed, because delivery is at-least-once.
  3. Reply with a 2xx quickly.

A complete, copy-paste handler with types (Next.js Route Handler, plain Node.js crypto) is in the Next.js 15 integration guide. See Using Webhooks to create the webhook and Types of Webhooks for every event.

Testing

  • Use Test Mode to try the purchase and webhook flow without real money. Create a test webhook in Test Mode and click Test to send sample events. You can also buy through your test store (Go to Store in Test Mode) with the card 4242 4242 4242 4242.
  • Checkout links and embed snippets aren't available in Test Mode, so the Checkout Element URLs on this page are live ones. You can check that the overlay opens with the right e-mail on your authorized local origin, but a payment you complete there is real.

Last updated

Was this helpful?