feat: added support for separate card fields - #1742
Conversation
🚫 Missing Linked IssueHi 👋 This pull request does not appear to be linked to any open issue yet. Linking your PR to an issue helps keep the project tidy and ensures the issue is closed automatically. ✔️ How to fix this
Once linked, this check will pass automatically on your next push or when you re-run the workflow. Thanks for helping maintainers! 🙌 |
🚫 Missing Linked IssueHi 👋 This pull request does not appear to be linked to any open issue yet. Linking your PR to an issue helps keep the project tidy and ensures the issue is closed automatically. ✔️ How to fix this
Once linked, this check will pass automatically on your next push or when you re-run the workflow. Thanks for helping maintainers! 🙌 |
🚫 Missing Linked IssueHi 👋 This pull request does not appear to be linked to any open issue yet. Linking your PR to an issue helps keep the project tidy and ensures the issue is closed automatically. ✔️ How to fix this
Once linked, this check will pass automatically on your next push or when you re-run the workflow. Thanks for helping maintainers! 🙌 |
| // `vgsScriptIntegrity` is imported from VGSConstants (see src/Utilities/VGSConstants.res) | ||
| // — co-located with `vgsScriptURL` so the two stay in lockstep when the pinned | ||
| // VGS version is bumped. |
There was a problem hiding this comment.
Also, there are extra comments in many files. Please remove those as well.
| ~latency=renderLatency, | ||
| ~value="", | ||
| ) | ||
| applyShowCardIconOption(dict->getDictFromObj("options")) |
There was a problem hiding this comment.
Instead of creating a new function, applyShowCardIconOption, can we use only updateOptions since it can handle the combined logic?
| // `OtherFamily` is the loud-fail path (raises `InvalidSurfaceFamilyParams` upstream). | ||
| type surfaceFamily = | ||
| | VaultFamily // `componentName=paymentMethodsSDK&surfaceFamily=vault` | ||
| | PaymentsFamilyV2 // `componentName=paymentMethodsSDK&surfaceFamily=payments` |
There was a problem hiding this comment.
Rename it to something proper instead of V2
🚫 Missing Linked IssueHi 👋 This pull request does not appear to be linked to any open issue yet. Linking your PR to an issue helps keep the project tidy and ensures the issue is closed automatically. ✔️ How to fix this
Once linked, this check will pass automatically on your next push or when you re-run the workflow. Thanks for helping maintainers! 🙌 |
🚫 Missing Linked IssueHi 👋 This pull request does not appear to be linked to any open issue yet. Linking your PR to an issue helps keep the project tidy and ensures the issue is closed automatically. ✔️ How to fix this
Once linked, this check will pass automatically on your next push or when you re-run the workflow. Thanks for helping maintainers! 🙌 |
| } | ||
| | None => { | ||
| let endpoint = ApiEndpoint.getApiEndPoint(~publishableKey) | ||
| PaymentHelpersV2.fetchPaymentManagementList( |
There was a problem hiding this comment.
We don't have vault details in this API
…removed dead code
Type of Change
Description
Adds separate card fields to the Web SDK: merchants can mount
cardNumber,cardExpiryandcardCvcas three independently positioned elements instead of one bundled card form, and lay them out freely in their own checkout.Today the SDK only offers a single combined card element, so any merchant wanting card number and expiry on different rows — or a saved-card CVC box on its own — has to either accept our layout or leave Hyperswitch's iframes entirely and take on PCI scope themselves. This PR closes that gap without moving card data into the merchant page.
Two surfaces are supported, sharing one implementation:
hyper.widgets({ clientSecret }).cardForm()confirmPayment()PaymentHelpers.usePaymentIntenthyper.paymentMethodsSession({ sdkAuthorization }).cardForm()tokenize()PaymentHelpersV2.savePaymentMethod/updatePaymentMethodTypes.cardForm(payments) andTypes.vaultCardForm(vault) are distinct types: the vault path stores a payment method and resolves the vault response, so it exposestokenize()rather thanconfirmPayment().Merchant integration
Payments — collect a new card
Get a card form off the widgets instance, create the three fields, mount each one into your own container, and confirm. Options passed to
create()are per field; anything you don't pass falls back to the group'sappearanceandlocale.Only the three field types above are accepted; any other string logs
invalid_field_typeand hands back an inert handle. EachfieldHandlesupportsmount·unmount·destroy·update·focus·blur·clear·on. Focus auto-advances number → expiry → CVC as each field completes, decided inside the field's own iframe.cardForm.update(options)forwards new options to every mounted field. It deliberately refusesclientSecretandconfirmParams(they are immutable after mount) with a console warning — create a new group to switch intents.cardForm.deinit()destroys the fields, tears down the coordinator, and settles any in-flightconfirmPayment()with agroup_deinitializederror.Vault — tokenize into a payment method session
Same shape, different entry point and a
tokenize()instead ofconfirmPayment(). The SDK resolves the vault configuration itself fromGET /v1/payment-method-sessions/{id}, reusing thesdkAuthorizationyou already passed, so there is nothing extra to wire up.A session is single-use: a successful
tokenize()marks it consumed, and further calls resolve{ error: { code: "session_consumed", ... } }. An expired session (expires_atfrom the retrieve call) resolvessession_expired, and a second concurrent call resolvestokenization_in_progress.session.update()is a no-op that warns — session options are fixed at creation.If the session's
external_vault_detailsname VGS, the samecardForm()code mounts VGS Collect.js fields into your DOM instead of Hyperswitch iframes. Nothing in the snippet above changes.Saved-card CVC recollect
Mount only
cardCvcand pass the stored card's token and brand. The brand sizes and validates the CVC box (3 vs 4 digits); the token identifies the saved method at confirm time.You can also supply it later with
cvc.update({ savedCard: { token, brand } }). On the payments surfacetokenis required — confirming without one resolves avalidation_errornaming the fix. On the vault surface, mounting onlycardCvcruns the CVC-update flow and just thebrandis read.Field events
Subscribe per field with
field.on(event, cb), or at the group withcardForm.on(event, cb). One callback per event name per target — a secondon()for the same name replaces the first.ready·focus·blur·changefire on every hosted field, and on VGS fields too.field.on("error", ...)carries{ elementType, iframeId, message }and is vault-only — on the payments surface, confirm-time validation failures arrive on the group'serror({ elementType: "paymentsCoordinator", iframeId, code, message }) rather than per field. The payments group additionally emitsconfirmDispatchedonce the confirm has left the coordinator. The vault group'serrorcarries the same{ error: { type, message, code } }envelope thattokenize()resolves with.Per-field status —
cardFieldStatusInfoA registered subscription event that reports a single field's form status. It is opt-in: list it in
subscriptionEventsin that field'screate()options, then subscribe with the same name.A field that does not list it in
subscriptionEventsemits nothing, whatever you subscribe to. This event is hosted-field only — the VGS path does not emit it.Styling
Every field inherits the group
appearanceandlocalepassed towidgets()/paymentMethodsSession(). A field can also carry its ownappearanceincreate(), and that override is wholesale, not a merge: if the field'sappearanceobject has any keys at all, the group'sappearanceis dropped entirely for that field. A field supplying onlyvariablestherefore loses the group'srulesas well, so a per-field override has to restate everything it still wants.appearance.variables.cardFieldHeight(default48px) sets the height of the element the field occupies — the field's iframe on the hosted path, the VGS-owned container on the VGS path — and is the one appearance variable that only means anything for separate card fields.Per-field options, hosted fields. These are read out of each field's
create()options and apply to both the payments and vault hosted paths.placeholder""renders no placeholdercardBrandIconcardNumberstandard·hidden·animated·hideGenericcvcIconcardCvchidden·defaultappearancesavedCardcardCvc{ token, brand }— see saved-card CVC recollectsubscriptionEvents["cardFieldStatusInfo"]— see per-field statusBoth enums are validated by value: an unrecognised string warns (
Unknown Value: 'foo' value in options.cardBrandIcon, Expected …) and is then ignored, leaving the icon style as it was. Every key is presence-gated, oncreate()and onfield.update()alike — a key you leave out is not reset to its default, it is simply left alone, soupdate()behaves as a patch rather than a replace.Per-field options, VGS only. When the session's
external_vault_detailsname VGS, these extra keys are merged over the SDK's per-field defaults and handed toform.field(...)verbatim. They are not read on the hosted-iframe path, which parses only the table above.placeholder1234 1234 1234 1234·MM / YY·123successColor/errorColorariaLabelautoCompleteinputModedefaultValueshowCardIcontrueoncardNumberandcardCvc,falseoncardExpiryand on the saved-cardcardCvcdisabled/readOnly/hideValueyearLengthcardExpiryonly; SDK default2cssfield.update()on the VGS path honours a narrower set —placeholder,ariaLabel,autoComplete,css,hideValue,disabled,readOnly,showCardIcon— and every other key is dropped before the call reaches VGS;successColor,inputMode,yearLengthand friends are therefore create-time only.One trap worth flagging:
yearLength: 4makes VGS require a four-digit year, so a shopper typing the perfectly valid-looking12 / 28is rejected. Pair it with a matching placeholder (MM / YYYY) or leave it at the default2.Errors
Both
confirmPayment()andtokenize()resolve rather than reject, matchinghyper.confirmPayment. A failure is any resolved object carryingerror:The two surfaces raise different codes — they share the envelope, not the vocabulary.
confirmPayment()(payments)codevalidation_errortokenconfirm_in_progressconfirmPayment()while one is in flightgroup_deinitializeddeinit()was called mid-confirmtokenize()(vault)codevalidation_errorincomplete_field_setcardCvcis mountedsession_expired·session_consumedexpires_atpassed, or it was already tokenizedtokenization_in_progresstokenize()while one is in flighttokenization_failedvgs_form_not_readyOn the VGS path, mount- and field-level failures do not resolve the promise; they arrive on
cardForm.on("error")asvgs_mount_failed,vgs_field_event_binding_failed,vgs_field_update_failedorvgs_field_unmount_failed, since they happen outside atokenize()call.A payments failure relayed from the confirm call itself passes through the same envelope
hyper.confirmPaymentresolves (submitSuccessful: falsepluserror: { type, message }) and carries nocode, so branch onresult.error, not onresult.error.code.There are no timeouts on either promise — again matching
hyper.confirmPayment, which has none. Commands issued before the coordinator reports ready are queued and flushed, so a confirm racing the coordinator's boot is never dropped.Architecture
Each field renders in its own cross-origin iframe. The centrepiece is the coordinator — one hidden
0×0iframe per card form, and the single place every card-data API call is made from.Both surfaces reach it the same way: an entry point returns an object exposing
cardForm(), and that card form creates and mounts the individual fields.Flow 1 — Payments (
hyper.widgets(...).cardForm())Flow 2 — Vault (
hyper.paymentMethodsSession(...).cardForm())When the session's provider is VGS, this flow swaps the field iframes and the coordinator for VGS Collect.js fields injected into the merchant DOM; the
cardForm()API is unchanged.Why a coordinator
Three iframes each hold one third of a card. Something has to assemble them before an API call can be made — and the obvious candidate, the merchant page, is exactly where card data must not go.
The coordinator is that assembly point, moved out of the merchant page into an SDK-controlled iframe. Every field's raw value reaches it directly over a
MessageChannelport, bypassing the merchant window entirely.The group's role deserves precision, because it looks like it handles more than it does. The group creates each
MessageChanneland wires it up —port2travels to the field with its mount config,port1is forwarded into the coordinator — but it never reads a port. It is the switchboard operator who connects the call and cannot hear it. Everything the group itself consumes is masked: validity flags, brand, masked card info, and afocusReadyflag used to auto-advance focus.PaymentHelpers.usePaymentIntentpayments,savedCardCvcPaymentHelpersV2.savePaymentMethodsavePaymentHelpersV2.updatePaymentMethodupdatePaymentHelpers.fetchClientListPaymentHelpersV2.retrievePaymentMethodSessionThe field iframes make no network calls at all — they are pure emitters.
What the coordinator returns to the group is the API response itself: the payments arm posts the SDK's standard
{submitSuccessful, data, url}message, the vault arm posts{confirmResult, confirmId}carrying the vault response — never the card values it assembled. The merchant page sees a payment outcome without ever having seen a card number.Each coordinator mount also carries its own 3DS fullscreen slot, which is why two card forms on one page can each run a challenge without clobbering the other.
Two planes
The core of the design. Field state is split across two transports so raw card data never touches the merchant window.
Both halves are produced from one encoder —
CardFormPortProtocol.encodeFieldStateUpdate— in a single effect, so the two planes can never disagree about a field's state. Port frames are versioned:{cardFormPortV, kind, payload}.A
MessageChannelis created per field per epoch.port2rides with the field's mount-config message viapostMessagetransfer;port1is retained and forwarded into the coordinator once it reports mounted. Both documents absorbev.ports[0]intoSadPortRegistry, keyed"<groupId>:<fieldName>". Re-installing under the same key with a new epoch closes the superseded port, so a remount can never leave a live listener on a dead channel.Confirm
The confirm command itself carries no card data — it is just
{cardFormCoordinatorCommand: "initiateConfirm", flow, confirmId}(pluspaymentTokenfor saved-card CVC,savedCardBrand/localeon the vault side). The coordinator already has everything it needs from the port plane. The merchant page never handles a card value at any point in this sequence.flowis one ofpayments·savedCardCvc·save·update, and the coordinator refuses a command whose flow does not match its ownsurfaceFamilyURL parameter, so a vault command can never be serviced by a payments coordinator or vice versa.VGS direct injection
When the session's
external_vault_detailsname VGS,VGSVaultBroker.resloads VGS Collect.js and mounts VGS's own fields directly into the merchant DOM, bypassing Hyperswitch field iframes and the coordinator entirely;tokenize()then settles throughform.submit. The merchant-facing API is identical; only the transport differs.Flows supported
paymentssavedCardCvcpayment_tokensaveupdateSecurity notes
ev.sourceand origin, and only absorbs a port when the same message carries a handshake key (paymentElementCreateorcardFieldPort) and a non-emptyportKey.focusReadyflag computed inside each field's own iframe, so no keystroke timing leaves the iframe.Breaking changes
All three affect anyone integrated against an earlier revision of this branch; none of them touches the existing bundled card element.
cardForm.confirm()is nowcardForm.confirmPayment(). The payments card form's settle method was renamed for parity with the SDK's top-levelhyper.confirmPayment(), whose contract it already mirrors — resolves the confirm-intent response verbatim on success,{ error: { type, message, code } }on failure, never rejects. Nothing but the name changed. The vault surface is unaffected and keepstokenize().formStatusChangeis nowcardFieldStatusInfo. The ad-hoc per-field status event was replaced by a registered subscription event. Rename the listener; the payload is now the{elementType, iframeId, eventName, payload: {status, message?, cardBrand?}}envelope every other subscription event uses.cardFieldStatusInfois opt-in. It only fires for fields whosecreate()options list it insubscriptionEvents. A listener alone is no longer enough.onFieldEvent— a group-level fan-out that never reachedTypes.cardForm— was removed. Usefield.on(...)andcardForm.on(...).Files added
CardFormCoordinator·CardFormPortProtocol·SadPortRegistry·MessageChannelBinding·CardCollectorBridge·CommonCardFieldHooks·SecureCardNumberField·SecureCardExpiryField·SecureCardCvcField·PaymentSurfaceFamily·CardFormShared·CardFormGroupShared·CoordinatorMount·PaymentsGroup·PaymentMethodsSession·VGSVaultBrokerHow did you test it?
Checklist
npm run re:build