Words that inspire, ideas that spark, stories that stay

Implementing Digital Identity Verification in Next.js with Cashfree DigiLocker and Directus

Arbaz Khan

Arbaz Khan

Tuesday, September 15th, 20266 min read

Implementing Digital Identity Verification in Next.js with Cashfree DigiLocker and Directus

JamboCard’s user-facing application is built with Next.js. From this point onward, I’ll refer to that application simply as the Frontend.

What JamboCard is and Why Identity Verification Matters

JamboCard is a digital business card platform. A card can bring together a person’s name, photograph, professional role, company, and contact information in one profile that is easy to share. Instead of handing someone a paper card, a JamboCard user can share a link or let another person scan the card and open the profile immediately.

That convenience also creates a trust problem. A digital profile can display a name, photograph, and job title, but displaying those details does not prove that they belong to the person presenting the card. The recipient may be meeting the card owner online or for the first time, so there is not always an existing relationship on which to rely.

This is why the verified badge matters to JamboCard. It is not intended to be a decorative icon or a setting that users can enable for themselves. It is a trust signal: JamboCard has completed an identity-verification process before activating the badge. If an unverified person could claim it, the badge would make a misleading profile appear more credible and weaken trust in the entire product.

We therefore needed a verification journey that was strict enough to protect the meaning of the badge but simple enough for people to complete. Our existing provider remains available for users outside India. For Indian users, a flow based on passports or photographs of physical documents introduced unnecessary friction. Cashfree’s DigiLocker integration gave us a consent-based Aadhaar journey without asking the user to photograph, crop, and upload an identity document.

At the implementation level, the task still sounded small: send the user to DigiLocker, wait for the result, and show the badge. The redirect was the easy part. The harder work was deciding where the integration should live, binding an external verification attempt to the correct JamboCard user, supporting two providers, and handling cases where the user returned before the provider had finished processing the document.

The design I settled on uses the Frontend for the user journey and a custom Directus endpoint extension for the actual orchestration. Directus chooses the provider, talks to Cashfree, tracks the attempt, validates the webhook, normalizes the result, and updates the user’s verification state.

This post walks through that implementation, including the parts that were less obvious than the API documentation made them look.

The Complete Journey in One Picture

The flow below separates what the user sees from the work happening securely in Directus. The user starts verification and eventually sees a badge or a clear next step. In between, Directus chooses the correct provider, links the attempt to the signed-in account, validates the result, and stores only the information JamboCard needs.

JamboCard identity-verification architecture flow

Figure 1. JamboCard identity-verification flow: the Frontend handles the experience, Directus controls trust and state, and the provider confirms identity.

For a non-technical summary: Frontend manages the screens, Directus manages trust and data, and the verification provider confirms the identity.

The Split Between Frontend and Directus

The responsibilities are deliberately uneven.

Frontend owns what the user sees: the country selector, the redirect, the callback screen, and the verified badge. Directus owns the decisions and the sensitive work: authentication, country-based routing, provider credentials, session tracking, data persistence, and webhook processing.

The provider rule itself is only one line:

const providerByCountry = (countryCode) =>
  countryCode?.trim().toUpperCase() === 'IN'
    ? 'cashfree_digilocker'
    : 'didit';

 

For IN, the backend starts Cashfree DigiLocker and requests Aadhaar. For another country, it starts with Didit.

The location of that line matters more than its complexity. If provider selection existed only in the browser, a modified request could choose a flow that the product did not intend. By resolving it again in Directus, the Frontend becomes a caller rather than the authority.

Why I Put The Integration in Directus

Directus is usually described as a headless CMS. In JamboCard, we already use it as the data layer behind the product, so adding a custom endpoint extension was a better fit than building a second, disconnected identity-verification service.

The extension registers routes under /document-verification. Inside those routes, it can call Cashfree with server-side credentials and use Directus services to update users and related collections. The verification result therefore lands next to the rest of the user’s application data instead of living in a provider-specific island.

That gave me a few practical benefits:

  • Cashfree’s client secret never reaches the frontend browser bundle.

  • The authenticated Directus user is available through the request accountability context.

  • Verification records, provider records, and user state can be updated through one backend.

  • Directus policies can restrict who is allowed to inspect verification collections.

  • A future provider can be added without changing every screen that displays verification status.

I would not say that using Directus makes an integration secure by itself. The custom endpoint still has to check authentication, verify ownership, validate webhooks, and return only the fields the caller needs. Directus gives us a useful place to enforce those rules.

One Status Model, Two Providers

Cashfree and Didit do not use the same status names or return the same document shape. Passing either response directly to the Frontend would make the UI provider-aware and difficult to maintain.

I reduced both providers to a small internal contract:

type KycStatus = 'none' | 'pending' | 'completed' | 'failed';
type KycProvider = 'didit' | 'cashfree_digilocker' | null;

 

The Directus schema keeps the common state separate from provider details:

  • kyc is the user-level record.

  • kyc_verification_details stores the provider and normalized status.

  • kyc_verification_sessions links Cashfree identifiers to a JamboCard user and attempt.

  • cashfree_digilocker_verification_details stores the Cashfree-specific result.

  • didit_verification_details does the same for the other provider.

  • directus_users.kyc_verified is the boolean used by the card UI.

This is slightly more schema work than putting everything in one JSON field. It pays off when reading the data. The banner, settings page, callback, and badge all understand the same four states. They do not need a switch statement for every status a provider might add.

Starting the Flow in the Frontend

The verification page first loads the current user and the normalized verification summary. A user who is already verified is sent back instead of creating another attempt. If country_code is missing, the page asks the user to select a country.

Step 1 — Start verification

JamboCard User Settings showing KYC Unverified status and Start button

The journey starts from User Settings. The account remains unverified until the backend confirms the identity check.

Step 2 — Select a country

JamboCard country selection screen

If no country is saved, the user selects one so Directus can route the request to the correct provider.

 

Once it has a country, the Frontend makes one authenticated request:

const response = await makeRequest(
  'POST',
  '/document-verification/start',
  { country_code: countryCode, entrypoint },
  {},
  true,
);

 

The response contains a provider and a verification URL. For Cashfree, it also contains verificationId and referenceId. I keep those two values in sessionStorage before leaving JamboCard:

if (data.verificationId) {
  sessionStorage.setItem('cashfree_verification_id', data.verificationId);
}
if (data.referenceId) {
  sessionStorage.setItem('cashfree_reference_id', data.referenceId);
}
window.location.assign(data.verificationUrl);

 

They are there to help the callback recover if a query parameter is missing. They are not treated as proof of identity. The backend still requires an authenticated user and checks that the stored session belongs to that user.

Letting Directus Identify the User

The /start endpoint does not accept a user ID. It derives the user from Directus:

const userId = req.accountability?.user;
if (!userId) {
  return res.status(401).json({ error: 'Unauthorized' });
}

 

The route then loads that user, normalizes the selected ISO country code, saves it when necessary, and makes the provider decision. This prevents the obvious mistake of trusting a userId supplied by the browser.

The same idea appears again in the Cashfree sync route. A callback may contain a valid verification_id, but Directus only accepts the session when its user_id matches the authenticated caller. Knowing an external reference is not enough to claim another person’s result.

Creating the DigiLocker URL

For an Indian user, Directus generates a verification ID and prepares the JamboCard callback URL. If a phone number is available, the extension first calls Cashfree’s account-check endpoint. That response helps it choose between Cashfree’s sign-in and sign-up flows.

Step 3 — Sign in through DigiLocker

DigiLocker sign-in screen

After choosing India, the user leaves JamboCard temporarily and signs in through the government-managed DigiLocker journey.

Trust boundary

JamboCard does not collect the user’s DigiLocker credentials. The user authenticates and gives consent on the provider-managed screen before returning to JamboCard.

 

The next request creates the DigiLocker URL and asks for Aadhaar only:

const createResponse = await cashfreeCall({
  method: 'POST',
  path: '/digilocker',
  data: {
    verification_id: verificationId,
    redirect_url: `${frontendBase}/kyc/callback`,
    user_flow: userFlow,
    document_requested: ['AADHAAR'],
  },
});

 

Both Cashfree credentials remain in Directus environment variables. The browser receives the temporary verification URL, not the client secret used to create it.

Before returning the URL, the extension writes a pending verification record and a local session. That session contains the JamboCard user, Cashfree IDs, provider status, entry point, and expiry time. I found this local mapping to be the key record in the whole flow. Without it, the callback would have an external ID but no trustworthy way to know which local user it belongs to.

The Callback Race I Had to Account For

A successful redirect does not guarantee that Cashfree’s final document response is ready at that exact moment. Treating the callback as a synchronous success-or-failure request would create false failures whenever the provider was simply a little slow.

The callback page therefore does two things:

  1. It asks Directus to synchronize the Cashfree attempt.

  2. It polls /document-verification/me-details every two seconds, for up to 45 seconds.

On the backend, the sync has a 2.5-second response budget. If Cashfree responds within that window, the route returns the normalized result. If not, Directus returns HTTP 202 with a processing state while the sync continues.

This changed an important bit of UI behavior: a timeout is not shown as “verification failed.” The page says that verification is still being finalized and keeps checking the state stored in Directus.

Confirmed completion

This screen appears only after the Frontend receives the completed state from Directus. Returning from DigiLocker by itself is not enough to display success.

Step 4 — Confirmation

JamboCard Identity Verified confirmation screen

The callback screen confirms that the identity check completed and the verified badge is active.

 

The callback distinguishes between completion, consent denial, session expiry, provider failure, delayed processing, and a genuinely invalid callback. These states are not cosmetic. “The provider is still processing” and “the user denied consent” require different next steps.

After completion, the page invalidates the React Query caches for the current user, verification summary, and card data. That is what makes the badge appear without asking the user to sign out and back in.

Why There is a Callback and a Webhook

The callback exists for the user experience. The webhook exists for backend reliability. I use both because either one can arrive first, and a user may close the browser before the callback completes.

The webhook cannot be trusted just because it reaches the correct URL. Directus reads Cashfree’s timestamp and signature headers, concatenates the timestamp with the raw body, computes an HMAC-SHA256 digest, Base64-encodes it, and compares it with a timing-safe function:

const expected = createHmac('sha256', clientSecret)
  .update(`${timestamp}${rawBody}`)
  .digest('base64');

const valid =
  expected.length === signature.length &&
  timingSafeEqual(Buffer.from(expected), Buffer.from(signature));

 

Using the raw request body matters. Parsing and serializing JSON again can change whitespace or key order and produce a different signature.

The handler also checks the local session state. If the callback or an earlier webhook already completed the attempt, it acknowledges the event and does not create another result. In other words, duplicate delivery is expected and handled rather than treated as an exceptional case.

Returning Less Data on Purpose

Aadhaar data should not become general profile data. The normalized record keeps only the last four characters of the document number:

const last4 = (value) =>
  value === null || value === undefined || value === ''
    ? null
    : String(value).slice(-4);

 

The read endpoint also has two views. The signed-in user can receive their normalized verification details. A public lookup returns only the verified name and document type. It leaves out the address, date of birth, gender, postal code, and number suffix.

There is another small but important condition: document data is returned only when the status is completed. A pending or failed result has doc: null.

This response shaping is separate from Directus permissions. Both are needed. Permissions limit which roles can access the underlying collections; the endpoint limits what a valid caller receives from this particular API.

Updating the Verified Badge

When Cashfree reports AUTHENTICATED, Directus maps it to completed, stores the normalized document data, and sets directus_users.kyc_verified to true. Failure and in-progress statuses leave that flag false.

Step 5 — Verified state

JamboCard User Settings showing KYC Verified status

The final state is visible in User Settings: the badge changes to KYC Verified and the verification status is completed.

Product outcome

The badge is rendered from the trusted user state stored by Directus after provider confirmation. It is never activated directly by the browser or redirect URL.

 

The badge component does not call Cashfree. It reads the user state already attached to the card owner and renders only when kyc_verified === true. Keeping provider communication out of presentation components makes the badge cheap to render and consistent across card templates.

There is a trade-off here: denormalizing the final state onto the user means the write path must keep the verification record and user flag in sync. For this product, the simpler read path is worth it.

What Directus Made Easier and What It Did Not

Directus made the data model, relations, admin visibility, and extension boundary easier. It also gave the endpoint an authentication context and one set of services for updating application data.

It did not remove the need for ordinary backend security. The implementation still has to:

  • keep Cashfree credentials in server environment variables;

  • derive identity from req.accountability.user;

  • check that a verification session belongs to that user;

  • verify webhook signatures before touching data;

  • make duplicate events safe;

  • mask the document number;

  • separate private and public response shapes;

  • restrict verification collections with least-privilege Directus policies.

Raw provider payloads can contain more personal information than the application needs long term. They should be protected with restricted operational access, a defined retention period, and an automated deletion schedule. Those controls are as important as the code that receives the data.

Rate limits on /start and /cashfree/sync, freshness checks for webhook timestamps, database-level uniqueness on provider identifiers, and audit logging are sensible hardening steps as traffic grows.

What Adding Another Provider Would Involve

The Frontend contract does not need to change. A new provider would require a new backend adapter, a provider-specific details collection, and a mapping from its statuses to the four internal states. The country resolver could then route another region to that provider.

The existing Frontend screens could continue calling /document-verification/start and /document-verification/me-details. That was the main reason for normalizing at the Directus boundary instead of in the UI.

Conclusion: What I Took Away From The Implementation

The Cashfree API calls were not the part that needed the most thought. The difficult decisions were around ownership and timing: which system is allowed to select a provider, how an external attempt is bound to a local user, what happens when the callback wins the race with the webhook, and how much identity data should cross back into the product.

The Frontend works well for the interactive side of the flow. Directus works well as the boundary where provider-specific events become JamboCard state. Cashfree DigiLocker handles the consent-based Aadhaar exchange for Indian users, but the rest of the application only sees a provider-neutral verification result.

That separation is what makes the implementation useful beyond this one integration. We can change providers or add another region without teaching every card, banner, and settings screen a new verification language.

 

Booking form image

Struggling to choose the right Headless CMS & Headless Commerce tech stack?
 We’ll help you pick the best solution for your business! Exclusive Offer: 20 Hours of Free Development & Consultation


Book a Meeting

Frequently Asked Questions

The callback drives the user-facing experience, redirecting the person back to JamboCard. The webhook exists for backend reliability, since a user can close the browser before the callback finishes, or the webhook can arrive before the callback does. Either can win the race, so Directus handles both instead of relying on one.

 

It reads Cashfree's timestamp and signature headers, concatenates the timestamp with the raw request body, computes an HMAC-SHA256 digest, Base64-encodes it, and compares it against the received signature using a timing-safe function. Using the raw body matters — reserializing the JSON can shift whitespace or key order and break the signature match.

 

Only the last four characters of the document number, plus the verified name and document type for public lookups. Address, date of birth, gender, and postal code are excluded from what the app persists long-term, and document data is only returned at all when the verification status is completed.

 

Because a browser-based decision can be tampered with — a modified request could pick a verification flow the product didn't intend. Directus re-resolves the provider (Cashfree DigiLocker for India, Didit elsewhere) server-side using the authenticated user's country code, so the frontend acts as a caller rather than the authority on which provider runs.

 

Directus gives the sync request a 2.5-second budget; if Cashfree hasn't responded by then, it returns HTTP 202 with a processing state instead of a failure. The frontend polls /document-verification/me-details every two seconds for up to 45 seconds, so a slow provider response shows as "still finalizing," not a false rejection.