Quickstart: Integrate payments in your website

An example on how to charge your users with sphereone

Web Project

This document will show you how to use SphereOne to charge your users for your products. For more context, we'll build out functionality to purchase NFTs with Next.js and Firebase.

Step 1 - Create a Next.js project with Firebase functions

In our case we'll be using Next.js with Typescript, Tailwind CSS, and Firebase functions. We created a repo and added a folder www with all the Next.js code. Then in the same root, initiated a firebase project with firebase init and selected functions.

Step 2 - How does it work?

In this case, we'll build NFT purchasing functionality. We want to display a collection of NFTs and allow users to pay for them. We'll be using Firestore as a database with a collection for NFTs following this schema:

collection nfts key=nftUid:
- chain: string; // Chain supported by sphereone: "ETHEREUM" | "SOLANA" | "POLYGON" | ...
- image: string; // Nft image
- name: string; // Nft name
- ownerUid: string; // Uid of the user that owns this nft
- price: number; // Price of the nft in the symbol and tokenAddress expressed
- symbol: string; // symbol of the token in wich payment is expected
- tokenAddress: string; // address of the token in which payment is expected
- usdcAmount: number; // amount of usdc equivalent to the token expected

As well as one for transactions using the above schema

collection transactions key=chargeId:
- nftUid: string; // Nft that is on sale
- status: "PENDING" | "SUCCESS" | "PROCESSING" | "FAILED" | undefined; // status received from webhook.
- userUid: string; // User making the purchase
- error: null | undefined | string; // Error message in case of failure

So the flow would be as follows:

  • The user clicks on buy.
  • We create a payment intent and associate the NFT to the user that is buying it by creating a document in the transactions collection.
  • With the chargeId we can either redirect the user to the SphereOne checkout workflow or execute the payment ourselves for a more custom experience. (More on this later)
  • Receive webhooks notifications and update the transaction collection assigning NFTs to users if the transaction is successful.

Step 3 - Setup firebase functions

First, let's setup some environment variables for our project. Create this .env file in functions/.env and fill it with the proper data

FB_PROJECT_ID=""
FB_CLIENT_EMAIL=""
FB_PRIVATE_KEY=""
SPHEREONE_APIKEY=""
SPHEREONE_CHECKOUT_URL=""
SPHEREONE_BASE_URL=""
SPHEREONE_LOGIN_URL=""

Install dotenv with npm install dotenv and add the following so you can start using Firebase functions with the admin SDK.

import "dotenv/config";
import * as admin from "firebase-admin";
import serviceAccount from "./utils/serviceAccountKey";

if (!admin.apps.length) {
  admin.initializeApp({
    credential: admin.credential.cert(serviceAccount),
  });
}

where functions/src/utils/serviceAccountKey.ts file is:

import {ServiceAccount} from "firebase-admin";

const serviceAccount: ServiceAccount = {
  projectId: process.env.FB_PROJECT_ID,
  clientEmail: process.env.FB_CLIENT_EMAIL,
  privateKey: process.env.FB_PRIVATE_KEY,
};

export default serviceAccount;

Step 4 - Create charge endpoint

This endpoint will take nftUid create a charge for it and associate it with the userUid in the transactions collection.

import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import axios from "axios";
import cors from "cors";

const db = admin.firestore();

export const createCharge = functions.https.onRequest(
    async (req: any, res: any) => {
      cors({origin: true})(req, res, async () => {
        try {
          if (req.method !== "POST") throw new Error("Method not allowed");

          const {nftUid, userUid} = req.body;
          if (!nftUid || !userUid) throw new Error("Missing information");

          // Get nft metadata
          const nft = (await db.doc(`nfts/${nftUid}`).get()).data();
          if (nft === undefined) throw new Error("NFT not found");

          const chargeRes = (await axios.request({
            url: "https://api-olgsdff53q-uc.a.run.app/createCharge",
            method: "post",
            headers: {"Content-Type": "application/json", "x-api-key": process.env.SPHEREONE_APIKEY},
            data: JSON.stringify({
              chargeData: {
                symbol: nft.symbol,
                chain: nft.chain,
                successUrl: "https://dogdaysgame.vercel.app/success",
                cancelUrl: "https://dogdaysgame.vercel.app/cancel",
                tokenAddress: nft.tokenAddress,
                items: [
                  {
                    amount: nft.price,
                    image: nft.image,
                    name: nft.name,
                    quantity: 1,
                  },
                ],
              },
            }),
          })).data;
          if (chargeRes.error) return res.status(400).json({error: chargeRes.error});

          // Link chargeId with user and nft
          const chargeId = chargeRes.data.chargeId;
          await db.doc(`transactions/${chargeId}`).set({
            nftUid,
            userUid,
            timestamp: Date.now(),
          });

          return res.status(200).json({
            chargeId,
            paymentUrl: `${process.env.SPHEREONE_CHECKOUT_URL}/${chargeId}`,
          });
        } catch (error: any) {
          return res.status(500).json({error: error.message});
        }
      });
    });

And export the function from functions/src/index.ts so Firebase can use them:

export {createCharge} from "./onRequest/createCharge";

Step 5 - Create webhook endpoint

We need to receive notifications on payments the user makes. If the payment go through successfully we will assign the NFT to the user.

import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import cors from "cors";

const db = admin.firestore();

export const webhooks = functions.https.onRequest(async (req: any, res: any) => {
  cors({origin: true})(req, res, async () => {
    try {
      if (req.method !== "POST") throw new Error("Method not allowed");

      const {paymentStatus, transactionId, error} = req.body;
      if (!paymentStatus || !transactionId) res.status(400).json({msg: "Missing information"});

      if (paymentStatus === "SUCCESS") {
      // Get transaction details from database
        const transaction = (await db.doc(`transactions/${transactionId}`).get()).data();
        if (transaction === undefined) throw new Error("Transaction not found");
        const nftUid = transaction.nftUid;
        const userUid = transaction.userUid;

        // Update transaction status
        await db.doc(`transactions/${transactionId}`).set({
          status: "SUCCESS",
        }, {merge: true});

        // Assign nft to user
        await db.doc(`nfts/${nftUid}`).set({
          ownerUid: userUid,
        }, {merge: true});
      } else if (paymentStatus === "FAILURE") {
        // Handle error
        await db.doc(`transactions/${transactionId}`).set({
          status: "FAILURE",
          error: error ?? null,
        }, {merge: true});
      } else {
        // Just update status
        await db.doc(`transactions/${transactionId}`).set({
          status: paymentStatus,
          error: error?? null,
        }, {merge: true});
      }

      return res.status(200).send("OK");
    } catch (error: any) {
      return res.status(200).send(error?.message);
    }
  });
});

And export the function from functions/src/index.ts so firebase can use them:

export {webhooks} from "./onRequest/webhooks";

Only thing left here is deploy this function and inform SphereOne team of the webhook url.

Step 6 - Let's initiate those payments!

To facilitate things for you we've created a hook with all you need to manage payments with SphereOne. Just copy paste it in your utils/sphereone folder under useSphereone.ts

import firebase from "firebase";
import { useEffect, useState } from "react";
import { setUserCookie } from "../auth/userCookie";
import { mapUserData, useUser } from "../auth/useUser";
import axios from "axios";

export default function useSphereOne() {
  const { user, logout } = useUser();
  const [callback, setCallback] = useState<Function | null>(null);

  const signInWithSphereOne = async (idToken) => {
    try {
      const provider = new firebase.auth.OAuthProvider("oidc.sphereone");
      const credential = provider.credential({ idToken });
      await firebase.auth().signInWithCredential(credential);
    } catch (error) {
      console.log(error.message);
      return error;
    }
  };

  const openSignInPopUp = (c) => {
    setCallback(() => c);
    window.open(
      process.env.SPHEREONE_LOGIN_URL,
      "Popup",
      `width=${800}, height=${850}, top=${(screen.height - 850) / 2}, left=${
        (screen.width - 800) / 2
      }`
    );
  };

  const getIdToken = () => localStorage.getItem("idToken");

  const getUserBalances = async () => {
    const idToken = getIdToken();
    if (!idToken)
      throw new Error("Sign in with sphereone to request user balances");
    const userBalances = (
      await axios.request({
        method: "post",
        maxBodyLength: Infinity,
        url: "https://api-olgsdff53q-uc.a.run.app/getFundsAvailable",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${idToken}`,
        },
        data: JSON.stringify({ data: { refreshCache: true } }),
      })
    ).data;
    if (userBalances?.result?.error)
      throw new Error("Error getting user balances");
    return userBalances.result;
  };

  const getUserDek = async () => {
    const idToken = getIdToken();
    if (!idToken)
      throw new Error("Sign in with sphereone to request user balances");
    const dekRes = (
      await axios.request({
        method: "post",
        maxBodyLength: Infinity,
        url: "https://api-olgsdff53q-uc.a.run.app/createOrRecoverAccount",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${idToken}`,
        },
        data: JSON.stringify({ data: {} }),
      })
    ).data;
    return dekRes.result.data;
  };

  const openOnrampPopUp = async (chargeId: string) => {
    window.open(
      `https://wallet.sphereone.xyz/?txId=${chargeId}`,
      "Popup",
      `width=800, height=850`
    );
  };

  const payWithRedirect = (chargeId: string) => {
    window.open(`${process.env.SPHEREONE_CHECKOUT_URL}/${chargeId}`, "_blank");
  };

  // returns true if payment begin. False if onramp required
  const payWithSphereOneApi = async (
    chargeId: string,
    minimumUsdcBalance: number
  ) => {
    const idToken = getIdToken();
    if (!idToken)
      throw new Error("Sign in with sphereone to request user balances");
    const balances = await getUserBalances();
    if (balances.total < minimumUsdcBalance) {
      alert("Not enough funds to pay with sphereone");
      openOnrampPopUp(chargeId);
      return false;
    }

    const dek = await getUserDek();
    axios.request({
      method: "post",
      maxBodyLength: Infinity,
      url: "https://pay-g2eggt3ika-uc.a.run.app",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${idToken}`,
      },
      data: JSON.stringify({
        data: {
          transactionId: chargeId,
          wrappedDek: dek,
        },
      }),
    });
    return true;
  };

  useEffect(() => {
    const receiveMessage = async (event) => {
      if (event.origin === process.env.SPHEREONE_LOGIN_URL) {
        localStorage.setItem("idToken", event.data);
        const token = event.data;
        if (token !== "") await signInWithSphereOne(token);
      }
    };
    window.addEventListener("message", receiveMessage);

    return () => window.removeEventListener("message", receiveMessage);
  }, []);

  useEffect(() => {
    if (user && callback) {
      callback(user.uid);
      setCallback(null);
    }
  }, [user]);

  useEffect(() => {
    firebase.auth().onAuthStateChanged((user) => {
      if (user) {
        setUserCookie(
          mapUserData({
            uid: user.uid,
            email: user.email,
          })
        );
      } else setUserCookie(null);
    });
  }, []);

  return {
    openSignInPopUp,
    user,
    logout,
    getIdToken,
    getUserBalances,
    getUserDek,
    payWithSphereOneApi,
    payWithRedirect
  };
}

import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import {
  removeUserCookie,
  setUserCookie,
  getUserFromCookie
} from './userCookie';
import { auth } from '../firebase';

export const mapUserData = user => {
  const { uid, email } = user;
  return {
    uid,
    email,
  };
};
  
const useUser = () => {
  const [user, setUser] = useState<any>();
  const router = useRouter();

  const logout = async () => {
    return auth
      .signOut()
      .then(() => {
        router.push('/');
      })
      .catch(e => {
        console.error(e);
      });
  };

  useEffect(() => {
    const cancelAuthListener = auth
      .onIdTokenChanged(async userToken => {
        if (userToken) {
          const userData = await mapUserData(userToken);
          setUserCookie(userData);
          setUser(userData);
        } else {
          removeUserCookie();
          setUser(null);
        }
      });

    const userFromCookie = getUserFromCookie();
    if (!userFromCookie) {
      return;
    }
    setUser(userFromCookie);
    return () => cancelAuthListener();
  }, []);

  return { user, logout };
};

export { useUser };

import cookies from 'js-cookie';

export const getUserFromCookie = () => {
  const cookie = cookies.get('auth');
  if (!cookie) return;
  return JSON.parse(cookie);
};

export const setUserCookie = user => {
  cookies.set('auth', JSON.stringify(user), {
    expires: 1 / 24
  });
};

export const removeUserCookie = () => cookies.remove('auth');

We're showing you useUser and userCookies files as examples. Feel free to change them to work with your own system requirements.

From here, we just need to hit our createCharge endpoint and make buttons to either pay with a redirect or with the SphereOne API.

  const router = useRouter();
  const { user, payWithRedirect } = useSphereOne();

  const buyWithRedirect = async (nftUid) => {
    try {
      if (!user?.uid)
        return alert("Please login with to marketplace to pay here");
      const res = await axios.request({
        method: "post",
        maxBodyLength: Infinity,
        url: "https://api-olgsdff53q-uc.a.run.app/createCharge",
        headers: { "Content-Type": "application/json" },
        data: JSON.stringify({ nftUid, userUid: user?.uid }),
      });
      payWithRedirect(res.data.chargeId)
    } catch (error) {
      alert("Something went wrong. Please try again");
    }
  };
  const router = useRouter();
  const { user, payWithSphereOneApi } = useSphereOne();


  const buyWithApi = async (uid) => {
    try {
      // Create charge
      const res = await axios.request({
        method: "post",
        maxBodyLength: Infinity,
        url: `${process.env.NEXT_PUBLIC_SPHEREONE_BASE_URL}/createCharge`,
        headers: { "Content-Type": "application/json" },
        data: JSON.stringify({ nftUid, userUid: user?.uid }),
      });

      // Call pay with sphereone
      const isProcessing = await payWithSphereOneApi(res.data.chargeId, nft.usdcAmount);
      if (isProcessing) router.push(`/processing?chargeId=${chargeId}`);
    } 
  };
<button type="button" onClick={() => buyWithRedirect(nftUid)>
    Buy with SphereOne Checkout
</button>
<button 
    type="button" 
    onClick={() => {
  		// Sphereone Api currently requires to be logged in with sphereone. Doesn't apply to redirect.
      if (!user?.uid) openSignInPopUp(buyWithApi);
      else buyWithApi(user.uid);
    }}
>
  Buy with SphereOne API
</button>

That's all of it! Our webhook handler will take care of the rest. For more information, please reach out to us directly.

Web SDK

Another option to make a payment transaction within SphereOne is through the SphereOne WebSDK.

In a previous section - Quickstart: Charges - Web SDK - showed how to properly create a charge object, containing the transaction information, in order to properly proceed forward.

In this section, we will show how to call the SphereOne WebSDK's pay functionality.

NOTE: If you haven't read the Quickstart: Login - Web SDK, please do so. The follow section assumes that you have successfully installed the SphereOne WebSDK and signed into SphereOne.

Below is a code example of how to call pay to SphereOne:

// importing the initialized WebSDK
import { sphereoneSdk } from "your-imported-file";

// ...
// these are from previous section from: https://docs.sphereone.xyz/docs/create-charge#web-sdk
const [chargeId, setChargeId] = useState("");
const [paymentLink, setPaymentLink] = useState("");

const pay = async () => {
  try {
    await sphereoneSdk.payCharge(chargeId);
  } catch (e: any) {
    console.error(e);
  }
};

// ...

From a previous section, we created a charge object within SphereOne and then, if successful, returned a basic JSON response:

{
  chargeId: "a-charge-id",
  paymentLink: "https://a-payment-link/a-charge-id"
}

The frontend-client would store the chargeId or paymentLink in a local state, redux, or local storage. The chargeId can then be used within the WebSDK's payCharge function to trigger the payment process immediately.

The paymentLink offers a redirect into SphereOne's hosted checkout website, where consumers can then trigger the payment process.

Regardless, both options will leverage SphereOne's pay functionality between multiple chains and tokens.

In the above code example, we can modify the code to handle the specific case if a consumer don't have or enough funds in their Sphere Wallets. For this case, pay will fail.

So, we can modify the existing code from above example into this:

// importing the initialized WebSDK
import { sphereoneSdk } from "your-imported-file";

// ...
const pay = async () => {
  try {
    // trigger `pay` and listen for `result`
    const result = await sphereoneSdk.payCharge(chargeId);
    alert("Payment successful");
    // you can also show the route created
    console.log("route created =>", result.route)
    
  } catch (e: any) {
    // the error could be related to the user not having sufficients funds
    // in that case the error contains a link to our PWA to easily onramp money
 		if(e.onrampLink){
      alert(
        "Not enough funds to pay with SphereOne. We will redirect you to the onramp page."
      );
      window.open(e.onrampLink, "Popup", 'width=800, height=850'); // open a pop-up
      }
  	// the error is not solved with an onrampLink
  	else alert(`${e.message}`);
  }
};
// ...

If payCharge fails because consumer doesn't have enough funds in Sphere Wallets, then payCharge will return an error, which frontend-client can consume to determine what to do.

As shown in the code example above for payCharge, frontend-client can check for the error.onrampLink in the JSON response and utilize it in the JSON response to redirect or show a popup for consumer to proceed.

The onrampLink will direct consumer to the SphereOne Wallet site. Here, consumer can utilize SphereOne's many on-ramp providers to add funds into their Sphere Wallets. The estimated time for this onramp operation can vary depending on the provider from a few minutes to a few hours.

Once consumer has successfully added funds to their Sphere Wallets, then consumer can resume their payment.

That's all, for integrating payment with SphereOne through SphereOne's WebSDK.

Unity SDK

TBD

Unreal SDK

TBD