DEV Community

Cover image for Paysafe.js Payment Integration in React.js with 3DS (Step-by-Step Guide)
Taron Vardanyan
Taron Vardanyan

Posted on

Paysafe.js Payment Integration in React.js with 3DS (Step-by-Step Guide)

Integrating a payment gateway should be straightforward, but working with Paysafe.js is anything but. If you've ever tried to implement it in your project, you probably know the frustration: minimal online resources, scattered documentation, and vague implementation details.

Unlike Stripe or PayPal, where you can find countless tutorials, forum discussions, and Stack Overflow answers, Paysafe.js feels like a desert of information. Their official docs provide only the bare essentials, leaving many edge cases unexplored. Even simple tasks—like handling errors, customizing the UI, or debugging payment failures—require trial, error, and deep dives into support tickets.

This post will highlight the challenges developers face when integrating Paysafe.js, what to watch out for, and some workarounds that might save you time. If you're currently battling this integration, you're not alone!

Image description

Step 1.

Create custom hook which will deal with paysafe.js.

'use client';

import { useState, useEffect } from 'react';
import { PaysafeError, PaysafeOptions } from '@/types/paySafe/type';

export const usePaysafe = (apiKey: string, options: PaysafeOptions) => {
  const [instance, setInstance] = useState<any | null>(null);
  const [error, setError] = useState<PaysafeError | null>(null);

  useEffect(() => {
    const loadScript = async () => {
      if (typeof window === 'undefined') return;

      if (!document.getElementById('paysafe-sdk')) {
        const script = document.createElement('script');
        script.src = 'https://hosted.paysafe.com/js/v1/latest/paysafe.min.js';
        script.id = 'paysafe-sdk';
        script.async = true;
        script.onload = () => {
          console.log('Paysafe SDK loaded');
          initializePaysafe();
        };
        script.onerror = () => {
          console.error('Failed to load Paysafe SDK');
          setError({
            code: 'SDK_LOAD_FAILED',
            detailedMessage: 'Failed to load Paysafe SDK',
          });
        };
        document.body.appendChild(script);
      } else {
        console.log('Paysafe SDK already loaded');
        initializePaysafe();
      }
    };

    const initializePaysafe = () => {
      if (window.paysafe) {
        window.paysafe.fields.setup(apiKey, options, (paysafeInstance: any, setupError: any) => {
          if (setupError) {
            console.error('Paysafe Setup Error:', setupError.detailedMessage);
            setError(setupError);
          } else {
            console.log('Paysafe setup successful');
            setInstance(paysafeInstance);
          }
        });
      } else {
        console.error('Paysafe SDK is not available after loading');
        setError({ code: 'SDK_NOT_LOADED', detailedMessage: 'Paysafe SDK not loaded' });
      }
    };

    loadScript();
  }, [apiKey, options]);

  // Additional Methods
  const checkFieldIsEmpty = (field: string) => {
    if (!instance) return false;
    return instance.fields[field]?.isEmpty() ?? false;
  };

  const checkFieldIsValid = (field: string) => {
    if (!instance) return false;
    return instance.fields[field]?.isValid() ?? false;
  };

  const checkAllFieldsValid = () => {
    if (!instance) return false;
    return instance.areAllFieldsValid();
  };

  return { instance, error, checkFieldIsEmpty, checkFieldIsValid, checkAllFieldsValid };
};

Enter fullscreen mode Exit fullscreen mode

You think it's over? We're just getting started!

Image description

Step 2.

Now it's time to use our hook and add options to it.

import { useState, useEffect } from 'react';
import { useMutation } from '@tanstack/react-query';
import { PaysafeError, PaysafeOptions } from '@/types/paySafe/type';
import { usePaysafe } from '@/hooks/usePaySafe';
import Image from 'next/image';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import useCustomToast from '@/hooks/useCustomToast';
import { PaymentContactType } from '@/types/flight/type';
import { get3DSOptions } from '@/utils/get3DSOptions';

const API_KEY = 'your API key';
const PAYSAFE_ACCOUNT_ID = 'your account id';

type Props = {
  amount: number;
  currency?: string;
  serverPayload: any;
  validPaymentContacts: PaymentContactType | null;
};

const OPTIONS: PaysafeOptions = {
  environment: 'TEST',
  currency: 'GBP',
  accounts: {
    default: PAYSAFE_ACCOUNT_ID,
  },
  fields: {
    cardNumber: { selector: '#card-number', placeholder: 'Card number' },
    expiryDate: { selector: '#expiration-date', placeholder: 'Expiration date' },
    cvv: { selector: '#cvv', placeholder: 'CVV' },
  },
};

const PaySafeForm = ({ amount, validPaymentContacts, serverPayload, currency = 'GBP' }: Props) => {
  const [cardBrand, setCardBrand] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [cardHolderName, setCardHolderName] = useState('');

  const { showToast } = useCustomToast();

  const { instance, error: setupError, checkAllFieldsValid } = usePaysafe(API_KEY, OPTIONS);

  const tokenizeMutation = useMutation<string, PaysafeError>({
    mutationFn: () => {
      return new Promise((_, reject) => {
        if (!instance) {
          return reject({ code: 'NO_INSTANCE', detailedMessage: 'No Paysafe instance available' });
        }

        instance.tokenize(
          get3DSOptions(
            amount,
            currency,
            PAYSAFE_ACCOUNT_ID,
            cardHolderName,
            serverPayload.bookingUuid || '',
            validPaymentContacts,
          ),
          function (_: any, error: any, result: { token: string }) {
            if (result) {
              console.log(result.token);
            }

            if (error) {
              console.error('Tokenization error:', error);
              reject(error);
              setIsLoading(false);
            }
          },
        );
      });
    },

    onError: (error: any) => {
      setIsLoading(false);
      console.log(error.detailedMessage || 'Tokenization failed');
      alert(error.detailedMessage || 'Tokenization failed');
    },
  });

  const handlePay = async () => {
    setIsLoading(true);
    if (checkAllFieldsValid()) {
      tokenizeMutation.mutate();
    } else {
      setIsLoading(false);
      showToast({
        type: 'error',
        content:
          'Kindly ensure that the "Card Number," "Expiration Date," and "CVV" are provided and valid.',
      });
    }
  };

  useEffect(() => {
    if (instance) {
      instance.cardBrandRecognition(function (_, event) {
        const recognizedCardBrand: any = event.data.cardBrand;

        if (recognizedCardBrand) {
          setCardBrand(recognizedCardBrand);
        } else {
          setCardBrand(null);
        }
      });

      instance.unsupportedCardBrand(function (instance, event) {
        alert('The provided card brand is not supported. Please choose another card brand.');
        console.log(instance, event);
      });
    }
  }, [instance]);

  const handleCardHolderNameChange = (e: any) => {
    setCardHolderName(e.target.value);
  };

  if (setupError) {
    return (
      <div className="flex min-h-screen items-center justify-center">
        <p className="text-red-600">Error: {setupError.detailedMessage}</p>
      </div>
    );
  }

  return (
    <div className="w-full">
      <div className="flex w-full items-start justify-between">
        <div className="w-full max-w-[970px]">
          <label htmlFor="card-number" className="hidden text-sm text-gray-600">
            Card Number*
          </label>
          <div
            id="card-number"
            className="relative mb-6 h-14 w-full rounded-lg border border-lightBlue bg-white p-2 pl-4 pr-4"
          >
            {cardBrand && (
              <Image
                src={`/${cardBrand}.svg`}
                alt={`${cardBrand} logo`}
                width={30}
                height={30}
                className="absolute bottom-0 right-2 top-0 h-full"
              />
            )}
          </div>
          <Input
            onChange={handleCardHolderNameChange}
            placeholder="Card Holder Name"
            style={{ fontFamily: 'sans-serif', fontSize: 16 }}
            className="h-14 w-full rounded-lg border border-lightBlue bg-white p-2 pl-4 pr-4 text-base !text-black placeholder:font-sans placeholder:text-base placeholder:font-[500] placeholder:text-[#757575] hover:border-lightBlue"
          />
        </div>
        <div className="w-full max-w-[262px]">
          <label htmlFor="expiration-date" className="hidden text-sm text-gray-600">
            Expiration Date
          </label>
          <div
            id="expiration-date"
            className="mb-6 h-14 w-full rounded-lg border border-lightBlue bg-white p-2 pl-4 pr-4"
          />

          <label htmlFor="cvv" className="hidden text-sm text-gray-600">
            CVV
          </label>
          <div
            id="cvv"
            className="h-14 w-full rounded-lg border border-lightBlue bg-white p-2 pl-4 pr-4"
          />
        </div>
      </div>
      <Button
        className={`ml-auto mt-6 flex h-14 w-40 max-w-[200px] items-center justify-center text-sm font-medium ${isLoading ? 'bg-gray-400' : 'bg-gradient-to-r from-[#203478] to-[#D10E32]'}`}
        onClick={handlePay}
        disabled={isLoading || !validPaymentContacts}
      >
        {isLoading ? 'Processing...' : 'Pay'}
      </Button>
    </div>
  );
};

export default PaySafeForm;

Enter fullscreen mode Exit fullscreen mode

Step 3.

You can detect the helper function we used, get3DSOptions

import { PaymentContactType } from '@/types/flight/type';

export const get3DSOptions = (
  amount: number,
  currency: string,
  accountId: number,
  cardHolderName: string,
  bookingsUUID: string,
  validPaymentContacts: PaymentContactType | null,
) => ({
  amount: Math.round(amount * 100),
  openAs: 'IFRAME', // Ensures the tokenization happens with 3D Secure in an iframe
  transactionType: 'PAYMENT',
  merchantRefNum: bookingsUUID,
  paymentType: 'CARD',
  customerDetails: {
    holderName: cardHolderName,
    profile: {
      firstName: validPaymentContacts?.fullName?.split(' ')[0],
      lastName: validPaymentContacts?.fullName?.split(' ')[1],
      email: validPaymentContacts?.email,
    },
  },
  threeDS: {
    amount: Math.round(amount * 100),
    currency,
    accountId,
    merchantRefNum: bookingsUUID,
    useThreeDSecureVersion2: true,
    authenticationPurpose: 'PAYMENT_TRANSACTION',
    challengeRequestIndicator: 'CHALLENGE_MANDATED',
    threeDsMethodCompletionIndicator: 'Y',
    deviceChannel: 'BROWSER',
    messageCategory: 'PAYMENT',
    electronicDelivery: {
      email: validPaymentContacts?.email,
      isElectronicDelivery: true,
    },
    priorAuthenticationData: {
      authenticationMethod: 'SMS_OTP',
    },
    requestorChallengePreference: 'NO_PREFERENCE',
    transactionIntent: 'GOODS_OR_SERVICE_PURCHASE',
    profile: {
      email: validPaymentContacts?.email,
      phone: validPaymentContacts?.phone,
      cellPhone: validPaymentContacts?.phone,
    },
    userAccountDetails: {
      priorThreeDSAuthentication: {
        data: 'Some up to 2048 bytes undefined data',
        method: 'ACS_CHALLENGE',
        time: '2014-01-26T10:32:28Z',
      },
    },
    browserDetails: {
      javaEnabled: true,
      language: 'en-US',
      colorDepth: 24,
      screenHeight: 1080,
      screenWidth: 1920,
      timeZoneOffset: -120,
      userAgent:
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
    },
  },
  vault: {
    holderName: cardHolderName,
  },
});

Enter fullscreen mode Exit fullscreen mode

Congratulations! We're done.

Image description

Below, I'll attach some useful links that helped me a lot.

P.S. In the test environment, try to use only test cards that have 3DS activation.

Wish you the best of luck!

Top comments (0)