Onboarding

Learn how to onboard new users to Lens.


The process of onboarding a new user involves minting a new Profile NFT. This can be accomplished either by the user themselves in a self-funded manner, or by a third party on behalf of the user.

Choosing a Handle

Typically, users will choose their Handle during the onboarding process. They can only choose the local-name portion of the Handle, such as wagmi in lens/wagmi. The namespace is currently fixed to lens.

Here are the rules to follow when choosing an Handle:

  • Length: The local-name should be between 5-26 characters. For example, lens/wagmi is correct, but lens/wagm is too short.

  • Uniqueness: The Handle must be unique. If you try to create a Handle that already exists, the minting will fail.

  • Character Types: Handles must consist of alphanumeric characters (a-z0-9) and underscores (_), but they must not start with _ or a digit (0-9).

  • Case Insensitivity: Handles are case-insensitive. So lens/wagmi and lens/Wagmi are considered the same Handle.


Crypto Onboarding

In self-funded onboarding, the user is guided through the process of creating a Lens Profile with their desired Handle for a fee of 8 MATIC. As this process uses the user's wallet to send the transaction, it cannot be sponsored and requires the user's signature.

The fee may vary based on the market value of MATIC, with the aim of maintaining a consistent onboarding cost.

The examples provided use @lens-protocol/react-web for creating an onboarding webpage. This process can also be applied in React Native using the @lens-protocol/react-native package.

Start by connecting the user's wallet. Although the example uses a basic Wagmi setup, it can be adapted to suit different requirements.

OnboardingPage.tsx
import { useAccount, useConnect } from "wagmi";import { injected } from "wagmi/connectors";
import { CreateProfileForm } from "./CreateProfileForm";
export function OnboardingPage() {  const { address, isDisconnected, isConnecting } = useAccount();  const { connect } = useConnect();
  if (isDisconnected && !address) {    return (      <button        disabled={isConnecting}        onClick={() => connect({ connector: injected() })}      >        Connect Wallet      </button>    );  }
  return <CreateProfileForm address={address} />;}

Next, we'll focus on the <CreateProfileForm> component, which allows the user to choose their desired Handle.

Keep in mind that users can only choose the local-name portion of the Handle. The namespace is reserved for future use and currently fixed to lens.

You can use the useValidateHandle hook to check if the desired handle is available.

CreateProfileForm.tsx
import { useState } from "react";import { useValidateHandle } from "@lens-protocol/react-web";import { Address } from "viem";
type CreateProfileFormProps = {  address: Address;};
export function CreateProfileForm({ address }: CreateProfileFormProps) {  const [localName, setLocalName] = useState("");  const { execute: validateHandle, loading: verifying } = useValidateHandle();
  const submit = async () => {    const result = await validateHandle({ localName });
    if (result.isFailure()) {      console.error();      return;    }
    // TODO: mint profile  };
  return (    <form onSubmit={submit}>      <input        type="text"        disabled={verifying}        value={localName}        onChange={(e) => setLocalName(e.target.value)}      />
      <button type="submit" disabled={verifying}>        Create      </button>    </form>  );}

Finally, use the useCreateProfile hook to mint the new Profile.

CreateProfileForm.tsx
import { useState } from "react";import { useCreateProfile, useValidateHandle } from "@lens-protocol/react-web";import { Address } from "viem";
type CreateProfileFormProps = {  address: Address;};
export function CreateProfileForm({ address }: CreateProfileFormProps) {  const [localName, setLocalName] = useState("");  const { execute: validateHandle, loading: verifying } = useValidateHandle();  const { execute: createProfile, loading: creating } = useCreateProfile();
  const submit = async () => {    const validity = await validateHandle({ localName });
    if (validity.isFailure()) {      window.alert(validity.error.message);      return;    }
    const result = await createProfile({ localName, to: address });
    if (result.isFailure()) {      window.alert(result.error.message);      return;    }
    const profile = result.value;    window.alert(      `Congratulations! You now own: ${profile.handle?.fullHandle}!`    );  };
  return (    <form onSubmit={submit}>      <input        type="text"        disabled={verifying || creating}        value={localName}        onChange={(e) => setLocalName(e.target.value)}      />
      <button type="submit" disabled={verifying || creating}>        Create      </button>    </form>  );}

That's it—you can now log in the user using their new Lens Profile.


Credited Onboarding

For applications with unique onboarding requirements, a credit system for onboarding is available to cater to these needs.

The Lens Protocol team may grant an initial number of credits to particular app builders. These credits can be used to mint new Profile in behalf of the app new users. Each credit used reduces the total number of credits available to the app.

If you choose to charge users for onboarding with this integration, you must align with the Lens Protocol fees, which are 8 MATIC for crypto payments or 10 USD for fiat payments.

If you're interested in obtaining credits for your app, please reach out to the Lens Protocol team.

Below are the PermissionlessCreator contract features that can be utilized to mint new Profiles using credits.

Profile Minting

The createProfileWithHandleUsingCredits function, available on the PermissionlessCreator smart contract, allows the minting of a new Profile with Handle using credits. This function operates similarly to createProfileWithHandle, but it doesn't require the a fee. Instead, the app builder can utilize their credits to mint the new profile for the user.

This function must be invoked from the address that holds the credits.

function createProfileWithHandleUsingCredits(  Types.CreateProfileParams calldata createProfileParams,  string calldata handle,  address[] calldata delegatedExecutors) external returns (uint256 profileId, uint256 handleId);
struct CreateProfileParams {  address to;  address followModule;  bytes followModuleInitData;}

The app builder can use the delegatedExecutors parameter to set up Profile Manager addresses on the newly created Profile. If the Lens API relayer address is included in the delegatedExecutors array, this effectively enables the Signless Experience for the new Profile.

Lazy Onboarding

We've made it possible to mint Profile and Handle independently using credits. Consider minting a batch of Profiles without Handles in advance. These can be held in reserve and transferred to new users during their onboarding process. The Handle can then be minted lazily, according to the user's request.

This method provides a seamless experience, enabling users to authenticate and start using the app immediately. The Handle is minted in the background, and once it's successfully created, it can be transferred and linked to the user's Profile smoothly.

Use the createProfileUsingCredits function to mint a new Profile without a Handle using credits.

function createProfileUsingCredits(  Types.CreateProfileParams calldata createProfileParams,  address[] calldata delegatedExecutors) external returns (uint256);
struct CreateProfileParams {  address to;  address followModule;  bytes followModuleInitData;}

Use the createHandleWithCredits function to mint a new Handle using credits.

function createHandleWithCredits(  address to,  string calldata handle) external returns (uint256);

Use the standard ERC-721 transferFrom method on the Handle NFT contract (refer to LensHandles in smart contracts) to transfer the Handle to the user.

Transfer Profile

Once the Profile is minted, the app builder can transfer it to the user using the standard ERC-721 transferFrom method on the Profile NFT contract (refer to LensHub in smart contracts).

If the Profile was created with delegatedExecutors (i.e., Profile Manager addresses), the transferFromKeepingDelegates function should be used to maintain this configuration after transferring the Profile to the user. This function is available on the PermissionlessCreator smart contract.

function transferFromKeepingDelegates(  address from,  address to,  uint256 tokenId) external;

This function must be invoked by the address that initially minted the profile.

Creative Approaches

It's worth noting that the credit address you provide to the Lens Protocol team can be a smart contract. This allows you to incorporate flexible rules into your onboarding process. For instance, you could set conditions such as requiring users to hold a specific NFT, or you could charge your own fee for onboarding. All these conditions can be composed on-chain.

We recommend making your contracts upgradable proxies to easily adapt their criteria as needs evolve.

Below, we provide two examples to illustrate the simplicity of creating a wrapper contract. However, the possibilities are endless, and you can customize your contract to suit your specific needs.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Ownable} from '@openzeppelin/contracts/access/Ownable.sol';
struct CreateProfileParams {    address to;    address followModule;    bytes followModuleInitData;}
interface IPermissonlessCreator {    function createProfileUsingCredits(        CreateProfileParams calldata createProfileParams,        address[] calldata delegatedExecutors    ) external returns (uint256);
    function createProfileWithHandleCredits(        CreateProfileParams calldata createProfileParams,        string calldata handle,        address[] calldata delegatedExecutors    ) external returns (uint256, uint256);
    function createHandleWithCredits(        address to,        string calldata handle    ) external returns (uint256);}
contract AppChargeLensOnboarding is Ownable {    // load the permissonless creator contract    IPermissonlessCreator public immutable PERMISSONLESS_CREATOR;
    // the cost to buy a profile with handle    uint256 public profileWithHandleCreationCost = 1 ether;
    error InvalidFunds();
    constructor(address owner, address permissonlessCreator) {        _transferOwnership(owner);        PERMISSONLESS_CREATOR = IPermissonlessCreator(permissonlessCreator);    }
    // charge funds send to your beneficiary and then create profile for the user    function createProfileWithHandle(        CreateProfileParams calldata createProfileParams,        string calldata handle,        address[] calldata delegatedExecutors    ) external payable returns (uint256 profileId, uint256 handleId) {        if (msg.value != profileWithHandleCreationCost) {            revert InvalidFunds();        }
        // delegatedExecutors are only allowed if to == msg.sender        if (delegatedExecutors.length > 0 && createProfileParams.to != msg.sender) {            revert NotAllowed();        }
        return PERMISSONLESS_CREATOR.createProfileWithHandleCredits(createProfileParams, handle, delegatedExecutors);    }
    function withdrawFunds() external onlyOwner {        payable(owner()).transfer(address(this).balance);    }}

Alternatively, you could use an Externally Owned Account (EOA) and manage the gating on the backend. This flexibility makes it a highly adaptable onboarding system.


Card Onboarding

In the card onboarding, the user is guided through the process of creating a Lens Profile with their desired Handle for a fee of 10 USD.

This feature is enabled through an integration with Stripe, a popular payment gateway. The Custom payment flow from Stripe is used, offering flexibility to customize the payment experience to suit your needs.

Please note that this guide is more complex and requires your application to have a server-side component to manage server-to-server requests.

Integration Overview

The following sequence diagrams shows in broad strokes how the integration works.

The process unfolds as follows:

  1. Upon user initiation, the UI creates a Payment Intent via a bespoke server-side endpoint and initializes the Stripe Checkout form using the obtained Client Secret.

  2. The user enters their card details and follows the payment instructions provided by their card provider.

  3. Once the payment is successful, Stripe communicates with the Lens API, triggering the on-chain Profile creation process. Stripe also redirects the UI to the specified return_url.

  4. The UI verifies the payment outcome using the Client Secret from step 1.

  5. After verifying the successful payment, the UI begins checking for Blockchain Transaction Info associated with the Payment Intent ID from step 1.

  6. Once the UI receives confirmation from the Lens API, it waits for the completion of the Create Profile Transaction. This process uses the Transaction ID obtained earlier, as explained in the Transaction Monitoring guide.

Finally, the UI can fetch the newly created Profile and continue with the onboarding process.

Shared Secret

The Lens API offers specific endpoints for this integration, available at the designated paths on the Lens API URL for each environment.

Two of these endpoints, Create Payment Intent and Get Blockchain Info, require a shared secret for server-to-server communication.

To use these endpoints, contact the Lens Protocol team to obtain a shared secret for your application. This shared secret should be included in the x-shared-secret HTTP headers when making requests.

This shared secret is a sensitive piece of information intended for server-to-server communication. It should never be exposed on the client-side.

Create Payment Intent

Use the POST /payments/create endpoint to create a Payment Intent. This endpoint requires the user's wallet address and the desired Handle local-name. The Lens API will respond with the Payment Intent's client secret, which you can use to initiate the Stripe Checkout form.

Request

POST /payments/createHost: <api-url>Accept: application/jsonContent-Type: application/jsonContent-Length: 103x-shared-secret: <shared-secret>{  "address": "0x1234567890abcdef1234567890abcdef12345678",  "handle": "wagmi",  "currency": "usd"}
Body ParameterDescription
addressThe user's wallet address. If the payment is successful, Profile and Handle NFTs will be minted to this address.
handleThe local-name portion of the desired Handle. For example, for lens/wagmi, the handle is wagmi.
currency (optional)The currency for the payment. Currently, only usd is supported. If not specified, it defaults to usd.

Success

HTTP/1.1 200 OKContent-Type: application/jsonContent-Length: 137{  "id": "pi_1J3xjz2eZvKYlo2C5z3z",  "for": "0x1234567890abcdef1234567890abcdef12345678",  "clientSecret": "pi_1J3xjz2eZvKYlo2C5z3z"}
PropertyDescription
idThe Payment ID. This will be used to retrieve the Transaction ID associated with the payment.
forThis is the ethereum address the profile and handle will be minted to if the payment succeeds
clientSecretThe Payment Intent's client secret to be used with Stripe SDK.

Failure

HTTP/1.1 400 Bad RequestContent-Type: text/plainContent-Length: 21HANDLE_ALREADY_EXISTS
Status CodeBody
400INVALID_CURRENCY
400INVALID_ETHEREUM_ADDRESS
400INVALID_HANDLE
400HANDLE_ALREADY_EXISTS
400FAILED_TO_CREATE_PAYMENT

Get Blockchain Info

Use the GET /payments/<paymentIntentId>/blockchain-tx-info endpoint to retrieve the Blockchain Transaction Info associated with a specific Payment Intent ID that was previously created.

Request

GET /payments/<paymentIntentId>/blockchain-tx-infoHost: <api-url>Accept: application/jsonx-shared-secret: <shared-secret>
URL ParameterDescription
paymentIntentIdThe Payment Indent ID from the POST /payments/create response.

Response

HTTP/1.1 200 OKContent-Type: application/jsonContent-Length: 160{  "status": "FULL_SUCCESS",  "txId": "a8b3a0f9-87d7-42e5-b214-31ea3b76247b",  "handle": "wagmi",  "address": "0x1234567890abcdef1234567890abcdef12345678"}
PropertyValueDescription
statusThe status of the Card Onboarding.
CREATED_PAYMENTThe Payment Intent has been created.
PROCESSING_PAYMENTThe payment is currently being processed.
FAILED_PAYMENTThe payment has failed.
SUCCESS_PAYMENTThe payment was successful, but the Create Profile transaction has not been sent yet.
FULL_SUCCESSThe payment was successful and the Create Profile transaction has been sent.
SUCESSS_PAYMENT_BLOCKCHAIN_FAILEDThe payment was successful, but the Create Profile transaction failed. This is a rare case that requires manual intervention. Contact the Lens Protocol team if this occurs.
txIdstringWhen the status is FULL_SUCCESS, this is the Transaction ID of the Create Profile transaction. It is null otherwise.
handlestringThe desired Handle local-name
addressstringThe user's wallet address.

Get Onboarding Cost

Use the GET /payments/cost endpoint to retrieve the fiat cost of onboarding a user. This endpoint is useful for displaying the cost to the user before initiating the payment process.

Request

GET /payments/costHost: <api-url>Accept: application/json

Response

HTTP/1.1 200 OKContent-Type: application/jsonContent-Length: 16{  "cost": 10}
PropertyValueDescription
costnumberThe cost in USD.

Examples

Here are Next.js examples demonstrating how to perform server-to-server requests. These examples use the App Router, but they can be adapted to use the Pages Router.

src/app/api/payments/route.ts
export const dynamic = "force-dynamic";
export async function POST(request: Request) {  const response = await fetch(`${process.env.LENS_API}/payments/create`, {    method: "POST",    headers: {      "Content-Type": "application/json",      "x-shared-secret": process.env.SHARED_SECRET,    },    body: await request.text(),  });
  if (response.ok) {    const data = await response.json();    return Response.json({ data, success: true }, { status: 200 });  }
  const error = await response.text();  return Response.json(    { error, success: false },    { status: response.status }  );}
src/app/api/payments/[paymentIntentId]/route.ts
export const dynamic = "force-dynamic";
type Params = { paymentIntentId: string };
export async function GET(request: Request, { params }: { params: Params }) {  const response = await fetch(    `${process.env.LENS_API}/payments/${params.paymentIntentId}/blockchain-tx-info`,    {      method: "GET",      headers: {        "Content-Type": "application/json",        "x-shared-secret": process.env.SHARED_SECRET,      },    }  );
  if (response.ok) {    const data = await response.json();    return Response.json({ data, success: true }, { status: 200 });  }
  const error = await response.text();  return Response.json(    { error, success: false },    { status: response.status }  );}

Test Card Details

For testing your integration against the Amoy Testnet deployment, you can utilize the following test card details.

Test Card
Card number4242 4242 4242 4242
Expiry Dateany future date, e.g. 01/42
CVCany 3 digits, e.g. 123
Postal codeany valid postal code, e.g. KT4 7DD for a UK postcode

Additional Options

Testnet Profiles

The standard onboarding process can be challenging when developing new applications that require multiple test profiles. To alleviate this, a feature is available exclusively on the Testnet, enabling the programmatic creation of Testnet Profiles.

The client.wallet.createProfileWithHandle method enables you to create a Lens Profile with a given Handle.

import { LensClient, development, isRelaySuccess } from "@lens-protocol/client";
const client = new LensClient({  environment: development, // wont't work with `production`});
const result = await client.wallet.createProfileWithHandle({  // e.g. 'alice' which will be '@lens/alice' in full-handle notation  handle: "<local name>",  to: "<your address>",});
// handle relay errorsif (!isRelaySuccess(profileCreateResult)) {  console.error(`Something went wrong`, result);  process.exit(1);}
// wait for the transaction to be mined and indexedconst outcome = await client.transaction.waitUntilComplete({  forTxId: result.txId,});
// handle transaction not foundif (outcome === null) {  console.error("The transaction was not found");  process.exit(1);}
console.log("Profile created");