Skip to content

feat(checkout): add identityVerificationHandling so a merchant can own the verification step - #2005

Open
AngelPaella wants to merge 7 commits into
mainfrom
angel/kyc-external-handling
Open

feat(checkout): add identityVerificationHandling so a merchant can own the verification step#2005
AngelPaella wants to merge 7 commits into
mainfrom
angel/kyc-external-handling

Conversation

@AngelPaella

@AngelPaella AngelPaella commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

The SDK half of the merchant escape hatch for KYC, §4.5 of the Identity SDK PRD. ENG4-367.

The checkout page half is Paella-Labs/crossbit-main#28380, which has to reach production before this ships. Unknown query params are stripped, so a newer SDK against an older page loses the flag and both checkout and the merchant render a widget for the same inquiry.

What this adds

identityVerificationHandling?: "external" on CrossmintEmbeddedCheckoutV3WebProps, which only the react-ui component takes. It rides to the checkout page as a query param through appendObjectToQueryParams, which appends strings bare, so the page receives identityVerificationHandling=external and its zod enum accepts it. It intersects the shared props type, so both the existing-order and new-order shapes keep it.

Web only, deliberately. It started on the props type react-native shares with web, which meant an RN merchant could set the flag, typecheck, reach the page, and have checkout suppress the step with nothing able to replace it: CrossmintIdentityVerification renders a DOM iframe, and RN's barrel exports neither it nor the credentials reader. The order would sit at requires-kyc behind a collapsed WebView with no message. RN now rejects the flag at compile time, and gets it back when there is an RN component to mount.

getIdentityVerificationCredentials(order), taking Order | undefined and returning IdentityVerificationCredentials | undefined. It has to accept undefined: useCrossmintCheckout() returns order?: Order, so a narrower parameter would have forced every merchant into order!, discarding exactly the defensiveness the reader exists to provide. A merchant taking the step over reads the order from the hook and passes the result to CrossmintIdentityVerification:

const { order } = useCrossmintCheckout();
const credentials = getIdentityVerificationCredentials(order);

<CrossmintEmbeddedCheckout orderId={orderId} identityVerificationHandling="external" payment={...} />
{credentials && <CrossmintIdentityVerification credentials={credentials} onComplete={...} />}

Re-exported from @crossmint/client-sdk-react-ui so merchants do not have to depend on client-sdk-base directly.

Why one value and not two

The prop started as kycHandling?: "checkout" | "external". Two things changed.

The name is now identityVerificationHandling, matching the vocabulary the rest of this surface already uses (CrossmintIdentityVerification, getIdentityVerificationCredentials). KYC is the provider's term for one step of identity verification, and it was the odd one out. This diverges from §4.5 of the PRD, which writes kycHandling.

The union is a single value. Nothing branches on the non-external value on the checkout page, where the only read is === "external". Widening an optional union later is backward compatible and free, so a second value buys nothing today and commits us to a name before the third mode's real semantics are known. If the in-house UI never ships, we never paid for it. "embedded" was the leading candidate and is actively misleading besides, since the merchant's replacement is an iframe too: ownership is the axis, not where it renders.

Consequence for the page: identityVerificationHandling="embedded" is now rejected rather than ignored, so it hits checkout's validation error screen like any unknown value. That is the existing loud-rejection behaviour, and it means a second mode has to reach production on the page before it can ship here.

A transport fix rides along

Running this flow in a browser turned up a bug in WindowTransport, and the fix ships here because identityVerificationHandling="external" is what exposes it.

addMessageListener subscribes to the global message event and filtered on event.origin alone. One Crossmint iframe per page kept that harmless, since no other frame could send from that origin. This flag puts two on the page: the checkout iframe, and the CrossmintIdentityVerification iframe the merchant mounts. Both serve from the same origin, and ui:height.changed is the one event name their maps share, so each client received the other's heights.

The result contradicted what this PR and #28380 both claim. Checkout suppressed its step and reported height: 0, the verification iframe reported 660, and the checkout iframe took that, so the merchant got 660px of blank space where the box should have collapsed. The failure flips with message order: checkout's 0 arriving last sets the merchant's Persona form to 0, and the buyer cannot verify at all. An early harness run recorded that version.

Captured in the parent with a listener that tags each message with its sending frame:

height= 146  from=crossmint-embedded-checkout.iframe
height=   0  from=crossmint-embedded-checkout.iframe      <- checkout collapses
height= 660  from=crossmint-identity-verification.iframe  <- and this overwrites it

WindowTransport now compares event.source to the peer window it already holds for sending. SignersWindowTransport does this today. IFrameWindow, ChildWindow, PopupWindow and NewTabWindow all construct the transport with their real peer, so the check holds for every caller. A message whose sending window has closed carries a null source and drops.

Worth a reviewer's attention: this narrows a trust boundary that has been loose since the transport was written, and it affects every product using these iframes, not only checkout. A caller that relied on receiving from a window other than its peer would stop working. I found none.

Why a reader instead of typing the Order

packages/client/base/src/lib/hosted-checkout/Order.ts says at the top that it is kept in sync with @crossmint/products-payments-headless-checkout by hand, and it has drifted: the backend models the kyc preparation variant, this mirror has no kyc key anywhere in preparation. Editing a union inside 5000 lines of declared types is a bigger change than this feature and is not KYC-specific, so the reader holds the one cast inside the SDK instead of leaving it in every integration. Typing the mirror properly is filed separately, and it will simplify the reader's body without touching its signature.

The reader also optional-chains order.payment, because an order reaches the merchant over postMessage where order is typed z.any() on both ends. A shape the type forbids should return undefined rather than throw in the merchant's app.

Test plan

  • get-identity-verification-credentials.test.ts, new, 5 cases: returns the credentials, and returns undefined for a non-verification preparation, for no preparation, for an order with no payment at all, and for no order yet. All pass.
  • window-transport.test.ts, new, 4 cases: a same-origin non-peer frame gets dropped, a null source gets dropped, a foreign origin still gets dropped, and removeMessageListener unsubscribes. Take the source check back out and the first two fail. This package had no tests before, so it also gains a test:vitest script and a vitest.config.ts copied from client-sdk-base.
  • pnpm turbo build on client-sdk-base, client-sdk-react-ui and client-sdk-react-native-ui passes, which is what typechecks this repo. In the built output I checked the three things that matter: base's declarations export getIdentityVerificationCredentials and CrossmintEmbeddedCheckoutV3WebProps carrying identityVerificationHandling?: "external", react-ui's two embed components declare the web props type, and react-native's dist/index.d.ts contains no mention of the flag.
  • pnpm lint is clean on the touched files. The three warnings biome reports on the props file are pre-existing Record<string, any> line items.

Run in a browser, against ~/dev/kyc-e2e-harness §F: the quickstart on :3006 with identityVerificationHandling="external", crossbit-main #28380 on :3000, driven by Playwright. Checkout's iframe measures 0 and its body is empty, the merchant's slot mounts a real Persona sandbox form at 660 as a sibling rather than a nested frame, kyc:ready fires, checkout polls GET /orders on its 3s cadence, and the verification iframe's src stays byte-identical across those ticks so the fresh-per-poll credentials object does not remount Persona.

getUrl() on the built dist emits ?identityVerificationHandling=external, omits the param when the prop is unset, and never emits the old kycHandling name. The receiving end has a schema test in #28380 that parses a bare identityVerificationHandling=external, plus a browser check that the page rejects any other value with its validation screen.

Not in this PR: docs for the takeover flow, and react-native support, which needs an identity verification component that is not an iframe before the flag can mean anything there.

The changeset states the deploy-order requirement too, since the changelog is where a merchant reads it rather than this description.

…n step

The SDK half of the escape hatch. Paella-Labs/crossbit-main#28380 carries the
checkout page half and has to deploy first, otherwise the flag is stripped and
both checkout and the merchant render a widget for the same inquiry.

`kycHandling="external"` rides to the checkout page as a query param through the
existing prop serializer, and stops checkout from rendering the step.

`getIdentityVerificationCredentials(order)` reads `payment.preparation.kyc` off
an order from useCrossmintCheckout(), so a merchant gets the credentials for
CrossmintIdentityVerification without casting. The Order type in this package
mirrors the backend schema by hand and does not model the kyc preparation
variant, so the reader keeps that one cast inside the SDK rather than in every
integration. Typing the mirror properly is filed separately.
@changeset-bot

changeset-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 63bc9fa

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 18 packages
Name Type
@crossmint/client-sdk-base Minor
@crossmint/client-sdk-react-ui Minor
@crossmint/client-sdk-window Patch
@crossmint/client-sdk-nextjs-starter Patch
@crossmint/client-sdk-auth Patch
@crossmint/client-sdk-react-base Patch
@crossmint/client-sdk-react-native-ui Patch
@crossmint/client-sdk-verifiable-credentials Patch
@crossmint/client-sdk-smart-wallet Patch
@crossmint/common-sdk-auth Patch
@crossmint/auth-ssr-nextjs-demo Patch
@crossmint/wallets-quickstart-devkit Patch
@crossmint/wallets-playground-react Patch
@crossmint/client-sdk-rn-window Patch
@crossmint/wallets-sdk Patch
@crossmint/wallets-playground-expo Patch
@crossmint/server-sdk Patch
crossmint-auth-node Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
### Issue 1
packages/client/base/src/services/identity-verification/get-identity-verification-credentials.test.ts:12-13
**`environmentId` in test fixture but absent from `IdentityVerificationCredentials` type**

The test fixture includes `environmentId: null` as part of the credentials, but `IdentityVerificationCredentials` is typed as `{ provider: "persona"; inquiryId: string; sessionToken?: string }` — no `environmentId` field. Because the function casts `preparation as { kyc?: IdentityVerificationCredentials }`, the returned object's runtime shape (with `environmentId`) silently diverges from its TypeScript type. If `CrossmintIdentityVerification` (or a merchant using the SDK's own Persona integration) needs to read `credentials.environmentId`, it would have to use an unchecked cast; otherwise, the component would receive the field at runtime but TypeScript would reject access to it statically.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "feat(checkout): add kycHandling so a mer..." | Re-trigger Greptile

Review follow-ups.

The flag was on the props type react-native shares with web, so an RN merchant
could set `kycHandling="external"`, typecheck, reach the page, and have checkout
suppress the step with nothing able to replace it: CrossmintIdentityVerification
renders a DOM iframe and RN's barrel exports neither it nor the credentials
reader. The order would sit at requires-kyc behind a collapsed WebView. It now
lives on CrossmintEmbeddedCheckoutV3WebProps, which only the react-ui component
takes, so RN rejects it at compile time until RN has something to mount.

`getIdentityVerificationCredentials` takes `Order | undefined`, because
useCrossmintCheckout() returns `order?: Order` and the documented call site did
not typecheck without a non-null assertion, which would have discarded the
defensiveness the reader exists to provide.

The changeset now states the deploy-order requirement, since the changelog is
where a merchant reads it.
@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Reviews (2): Last reviewed commit: "refactor(checkout): keep kycHandling off..." | Re-trigger Greptile

…external only

Renames `kycHandling` to `identityVerificationHandling` and drops the
non-external value from the union.

The name now matches the vocabulary the rest of the identity surface uses
(`CrossmintIdentityVerification`, `getIdentityVerificationCredentials`) rather
than KYC, which is the provider's term for one step of it.

`"checkout"` is gone rather than renamed to `"embedded"`. Nothing branches on
it on the checkout page: the only read is `=== "external"`. Widening an
optional union later is backward compatible and free, so naming a second value
now buys nothing and commits us to a name before the third mode's semantics are
known. "embedded" would also have been the wrong name, since the merchant's
replacement is an iframe too, so it does not distinguish the two paths.

The page-side rename is Paella-Labs/crossbit-main#28380, which still has to
reach production before this ships.
@AngelPaella AngelPaella changed the title feat(checkout): add kycHandling so a merchant can own the verification step feat(checkout): add identityVerificationHandling so a merchant can own the verification step Aug 5, 2026
@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Reviews (3): Last reviewed commit: "refactor(checkout): rename the prop to i..." | Re-trigger Greptile

WindowTransport subscribes to the global message event and accepted anything
from a matching origin. One Crossmint iframe per page kept that harmless: no
other frame could send from that origin. Two iframes on one page and each
client receives the other's events.

`identityVerificationHandling="external"` is the first configuration that puts
two there, the checkout iframe and the CrossmintIdentityVerification iframe the
merchant mounts. Both serve from the same origin, and `ui:height.changed` is the
one event name their maps share. Checkout suppressed its step and reported
height 0, the verification iframe reported 660, and the checkout iframe took it,
leaving 660px of empty space above the merchant's widget. The failure flips with
message order: checkout's 0 arriving last sets the merchant's Persona form to 0
and the buyer cannot verify at all.

Sending was already addressed to `otherWindow`; only the receive path threw that
away. SignersWindowTransport does this check today. IFrameWindow, ChildWindow,
PopupWindow and NewTabWindow all construct the transport with their real peer,
so it holds for every caller.

A message whose sending window has closed carries a null source and now drops.

Verified end to end against ~/dev/kyc-e2e-harness §F with a real Persona sandbox
form: checkout iframe 660px and empty before, 0px after, merchant's form at its
own 660 with kyc:ready firing.
@AngelPaella
AngelPaella force-pushed the angel/kyc-external-handling branch from 182e218 to ad57d20 Compare August 5, 2026 19:27
@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Reviews (4): Last reviewed commit: "fix(window): match the peer window on re..." | Re-trigger Greptile

environmentId arrives on the wire, is hardcoded to null by the backend, and
PAY-11803 removes it in the next API version. Nothing in the SDK reads it, so
putting it on IdentityVerificationCredentials would enshrine a field that is
always null and already scheduled for deletion. The fixture keeps it to pin that
the reader hands back the order's object unreshaped.
@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Reviews (5): Last reviewed commit: "test(identity): say why the fixture carr..." | Re-trigger Greptile

@vmc-crossmint vmc-crossmint left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved for me, but i prefer @maxwellfortney to review too as he has context on sdk details

Comment on lines +32 to +38
// Web only: taking over the verification step needs CrossmintIdentityVerification, which renders an
// iframe. React Native has no equivalent yet, so the flag stays off the shared props.
export type CrossmintEmbeddedCheckoutV3WebProps = CrossmintEmbeddedCheckoutV3Props & {
/** `"external"`: you mount `CrossmintIdentityVerification` from
* `getIdentityVerificationCredentials(order)`, or the buyer cannot finish. */
identityVerificationHandling?: "external";
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does it mean, we our sdk cannot work in react native?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RN checkout keeps working, and this PR leaves it alone. Verification still runs inside checkout's WebView there.

Only the opt-out is web. identityVerificationHandling="external" tells the checkout page to skip its verification step because the merchant renders it instead. The one component for that job, CrossmintIdentityVerification, is a DOM iframe, and RN has no counterpart to it yet.

Put the flag on the shared props and an RN merchant's code compiles clean all the way to the page. Checkout suppresses the step and nothing replaces it. The order sits at requires-kyc behind a collapsed WebView, no message shown. Keeping the flag off the shared type makes that a compile error instead. RN gets it back when there is a WebView verification component to mount.

@AngelPaella
AngelPaella requested a review from mPaella August 6, 2026 17:43
@greptile-apps

greptile-apps Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Reviews (6): Last reviewed commit: "docs: clarify why identityVerificationHa..." | Re-trigger Greptile

@greptile-apps

greptile-apps Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Reviews (7): Last reviewed commit: "docs: say the RN gap is an unbuilt compo..." | Re-trigger Greptile

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants