Skip to content

feat: add subscription events#1632

Merged
ArushKapoorJuspay merged 10 commits into
mainfrom
subscription-based-events-2
Jun 24, 2026
Merged

feat: add subscription events#1632
ArushKapoorJuspay merged 10 commits into
mainfrom
subscription-based-events-2

Conversation

@aritro2002

@aritro2002 aritro2002 commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Type of Change

  • Bugfix
  • New feature
  • Enhancement
  • Refactoring
  • Dependency updates
  • Documentation
  • CI/CD

Description

  • Introduces a subscription events system that allows merchants to opt into specific events via subscriptionEvents config option, replacing the old unconditional event emission pattern
  • Adds a new surchargeInfo subscription event that emits the full eligibility surcharge details object when surcharge data changes
  • Adds multi-instance element support in LoaderPaymentElement so multiple payment elements on the same page get correct per-instance event routing
  • Migrates all payment components (card, wallets, bank transfers, saved methods) to the new hook-based event emission pattern
  • SharedCode Pr: feat: add surcharge event hyperswitch-sdk-utils#75
  • ##Note: only surchregeInfo is supported

Subscription Events

Merchants configure which events they want to receive via the subscriptionEvents option:

// react integration
export const paymentElementOptions = (layout, extraOptions) => ({
  displayDefaultSavedPaymentIcon: false,
  ...(layout && { layout }),
  subscriptionEvents: [
     "PAYMENT_METHOD_INFO_CARD",
     "PAYMENT_METHOD_STATUS",
     "FORM_STATUS",
     "PAYMENT_METHOD_INFO_BILLING_ADDRESS",
     "CVC_STATUS",
   ],
  wallets: {
    walletReturnUrl: window.location.origin,
    applePay: "auto",
    googlePay: "auto",
    style: {
      theme: "dark",
      type: "default",
      height: 55,
    },
  },
  ...(extraOptions && extraOptions),
});

...


<PaymentElement
                    id="payment-element"
                    onChange={(event) => {
                      console.log("CardElement changed:", event);
                    }}
                    onFocus={(ev) => console.log("CardElement focused", ev)}
                    options={paymentElementOptions(
                      parseLayoutParam(layoutQueryParam),
                      parseOptionsParam(optionsQueryParam),
                    )}
                  />
                  
        
// Js integration

widgets = hyper.widgets({
    appearance,
    clientSecret: "xyz",
  });

  const unifiedCheckoutOptions = {
    // layout: {
    //   savedMethodCustomization: {
    //     groupingBehavior: "groupByPaymentMethods",
    //   },
    // },
    subscriptionEvents: ["CVC_STATUS"],
    wallets: {
      walletReturnUrl: "https://example.com/complete",
      //Mandatory parameter for Wallet Flows such as Googlepay, Paypal and Applepay
    },
  };


  const unifiedCheckout = widgets.create("cardCvc", unifiedCheckoutOptions);
  globalWidgets1 = unifiedCheckout;
  unifiedCheckout.mount("#unified-checkout");



  unifiedCheckout.on("CVC_STATUS", (event) => {
    // if (event.complete !== undefined) {
    console.log("change event is", event);
    // console.log("is complete", event.complete);
    // }
  });
  • If subscriptionEvents is not set (or empty), all events are emitted (backward compatible)
  • If set to a specific list, only listed events are emitted

Events Reference

Existing Events (migrated to subscription system)

Event Name elementType Payload Props Emitted When
PAYMENT_METHOD_INFO_CARD payment bin (string|null), last4 (string|null), brand (string|null), expiryMonth (string|null), expiryYear (string|null), formattedExpiry (string|null), isCardNumberComplete (bool), isCvcComplete (bool), isExpiryComplete (bool), isCardNumberValid (bool), isExpiryValid (bool) Card input changes (number, expiry, CVC, brand)
PAYMENT_METHOD_STATUS payment paymentMethod (string), paymentMethodType (string), isSavedPaymentMethod (bool), isOneClickWallet (bool) Selected payment method changes or wallet button clicked
FORM_STATUS payment status (string: "EMPTY" | "FILLING" | "COMPLETE") Form completeness changes (empty/filling/complete)
PAYMENT_METHOD_INFO_BILLING_ADDRESS payment country (string), state (string), postalCode (string) Billing address fields change
CVC_STATUS cardCvc iframeId (string), isCvcEmpty (bool), isCvcComplete (bool) CVC field state changes

New Event

Event Name elementType Payload Props Emitted When
SURCHARGE payment surcharge (object: { type: string, value: number }), taxOnSurcharge (number|null), displaySurchargeAmount (number), displayTaxOnSurchargeAmount (number), displayTotalSurchargeAmount (number) Eligibility surcharge details change (card payment, saved card selection)

Lifecycle Events (unchanged, always emitted)

Event Props Emitted When
ready elementType (string), iframeId (string) Element iframe finishes loading
focus elementType (string), iframeId (string) Input field gains focus
blur elementType (string), iframeId (string) Input field loses focus
change elementType (string), iframeId (string), complete (bool), empty (bool) Form state changes (legacy, gated by isLegacy)

Multi-Instance Support

LoaderPaymentElement now supports multiple instances of the same element type on one page:

  • Each instance gets a unique elementInstanceId to scope event listener activity names
  • matchesInstance filters events by elementType + iframeId so events route to the correct instance
  • Sibling adoption: handler-only instances (e.g. React wrapper) that never call .mount() get their iframeId resolved by a sibling's mount

Backward Compatibility

  • When subscriptionEvents is not configured, all events fire as before (legacy mode)
  • useLegacyEvents hook gates old-style unconditional events behind isLegacy flag
  • All existing .on() event types (ready, focus, blur, change, click, confirmPayment, etc.) continue to work

Files Changed

Area Files Purpose
Event types & payloads PaymentEventTypes.res, PaymentEventData.res, SubscriptionEventTypes.res New Surcharge event type, surcharge event builder, JSON encoder, payload creator
Event hooks SubscriptionEventHooks.res useSubscriptionEventEmitter, useEmitFormStatus, useEmitBillingAddress, useEmitPaymentMethodStatus, useEmitSurcharge, useLegacyEvents
Loader LoaderPaymentElement.res, Types.res, Elements.res Per-instance event routing, subscription event listeners, subscriptionEvents passthrough
Payment components CardPayment.res, ApplePay.res, GPay.res, PayPal.res, KlarnaSDK.res, SamsungPayComponent.res, PazeButton.res, PaypalSDKHelpers.res, bank transfers, SavedMethods.res, etc. Migrated to new hooks, added useEmitFormStatus/useEmitSurcharge calls
Utils Utils.res, PaymentUtils.res, UtilityHooks.res sanitizeEventData, iframeId in focus/blur/postMessage events, isLegacy gating

How did you test it?

case 1: if subscriptionEvents: is not passed,
expectation: all events should emit

Screen.Recording.2026-06-23.at.9.44.50.pm.mov

case 2: PAYMENT_METHOD_INFO_CARD event

Screen.Recording.2026-06-23.at.9.50.30.pm.mov

case 3: PAYMENT_METHOD_STATUS event

Screen.Recording.2026-06-23.at.9.52.29.pm.mov

case 4: FORM_STATUS event

Screen.Recording.2026-06-23.at.9.54.23.pm.mov

case 5: PAYMENT_METHOD_INFO_BILLING_ADDRESS event

Screen.Recording.2026-06-23.at.10.11.59.pm.mov

case 6a: CVC_STATUS event (only for cvc widget) -> multiple cvc widget

Screen.Recording.2026-06-23.at.10.18.43.pm.mov

case 6b: CVC_STATUS event (only for cvc widget) -> single cvc widget

Screen.Recording.2026-06-23.at.10.21.57.pm.mov

case 7: SURCHARGE event

Screen.Recording.2026-06-23.at.10.33.11.pm.mov

case 8: payment widget + 2 cvc widget (subscribed events are FORM_STATUS, CVC_STATUS)

Screen.Recording.2026-06-23.at.10.35.58.pm.mov

case 9: using .on("change", ev=> ...), no subscription event passed

Screen.Recording.2026-06-23.at.10.41.46.pm.mov
Screen.Recording.2026-06-23.at.10.42.54.pm.mov

case 10: using .on("change", ev=> ...), PAYMENT_METHOD_INFO_CARD event

Screen.Recording.2026-06-23.at.10.44.48.pm.mov

case 11: using .on("change", ev=> ...), PAYMENT_METHOD_STATUS event

Screen.Recording.2026-06-23.at.10.46.49.pm.mov

case 12: using .on("change", ev=> ...), FORM_STATUS event

Screen.Recording.2026-06-23.at.10.48.27.pm.mov

case 13: using .on("change", ev=> ...), PAYMENT_METHOD_INFO_BILLING_ADDRESS event

Screen.Recording.2026-06-23.at.10.50.09.pm.mov

case 14: using .on("change", ev=> ...), SURCHARGE event

Screen.Recording.2026-06-23.at.10.52.00.pm.mov

case 15: using .on("change", ev=> ...), CVC_STATUS event
https://github.com/user-attachments/assets/263cd4ef-8ae2-4dcf-b080-1058b100a87d

case 16: using .on("PAYMENT_METHOD_INFO_CARD", ev=> ...)

Screen.Recording.2026-06-23.at.10.56.42.pm.mov

case 17: using .on("PAYMENT_METHOD_STATUS", ev=> ...)

Screen.Recording.2026-06-23.at.10.58.00.pm.mov

case 18: using .on("FORM_STATUS", ev=> ...)

Screen.Recording.2026-06-23.at.11.01.26.pm.mov

case 19: using .on("PAYMENT_METHOD_INFO_BILLING_ADDRESS", ev=> ...)

Screen.Recording.2026-06-23.at.11.03.11.pm.mov

case 20: using .on("SURCHARGE", ev=> ...)

Screen.Recording.2026-06-23.at.11.04.46.pm.mov

case 21 using .on("CVC_STATUS", ev=> ...)

Screen.Recording.2026-06-23.at.11.10.23.pm.mov

case 22 using .on("ready", ev=> ...) and onReady(ev=>...)
image
image

case 23a subscriptionEvents: ["CVC_STATUS"], .on("FORM_STATUS", (event)=> ...)

Screen.Recording.2026-06-24.at.9.09.02.am.mov
Screen.Recording.2026-06-24.at.9.09.51.am.mov

case 23b: react integration, event "FORM_STATUS" (which is only for payment element)

Screen.Recording.2026-06-24.at.9.13.10.am.mov

case 24: subscriptionEvents: ["PAYMENT_METHOD_INFO_CARD", "FORM_STATUS"], .on("PAYMENT_METHOD_INFO_CARD", (event) => ..., .on("FORM_STATUS", (event)=> ...

Screen.Recording.2026-06-24.at.9.26.24.am.mov

case 25: multiple widget, js integration

Screen.Recording.2026-06-24.at.9.23.04.am.mov

Checklist

  • I ran npm run re:build
  • I reviewed submitted code
  • I added unit tests for my changes where possible

@semanticdiff-com

semanticdiff-com Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review changes with  SemanticDiff

Changed Files
File Status
  shared-code  0% smaller
  src/CardCVCElement.res Unsupported file format
  src/Components/AccordionContainer.res Unsupported file format
  src/Components/DropdownField.res Unsupported file format
  src/Components/Input.res Unsupported file format
  src/Components/InputField.res Unsupported file format
  src/Components/LoaderPaymentShimmer.res Unsupported file format
  src/Components/PaymentDropDownField.res Unsupported file format
  src/Components/PaymentField.res Unsupported file format
  src/Components/PaymentInputField.res Unsupported file format
  src/Components/SavedCardItem.res Unsupported file format
  src/Components/SavedMethods.res Unsupported file format
  src/Components/SavedMethodsV2.res Unsupported file format
  src/Hooks/CommonCardProps.res Unsupported file format
  src/Hooks/SubscriptionEventHooks.res Unsupported file format
  src/Hooks/UtilityHooks.res Unsupported file format
  src/LoaderController.res Unsupported file format
  src/PaymentElement.res Unsupported file format
  src/PaymentOptions.res Unsupported file format
  src/Payments/ACHBankDebit.res Unsupported file format
  src/Payments/ACHBankTransfer.res Unsupported file format
  src/Payments/ApplePay.res Unsupported file format
  src/Payments/BacsBankDebit.res Unsupported file format
  src/Payments/BacsBankTransfer.res Unsupported file format
  src/Payments/BecsBankDebit.res Unsupported file format
  src/Payments/Boleto.res Unsupported file format
  src/Payments/CardPayment.res Unsupported file format
  src/Payments/GPay.res Unsupported file format
  src/Payments/InstantBankTransfer.res Unsupported file format
  src/Payments/InstantBankTransferFinland.res Unsupported file format
  src/Payments/InstantBankTransferPoland.res Unsupported file format
  src/Payments/KlarnaSDK.res Unsupported file format
  src/Payments/PayPal.res Unsupported file format
  src/Payments/PaymentMethodsWrapper.res Unsupported file format
  src/Payments/PaypalSDK.res Unsupported file format
  src/Payments/PaypalSDKHelpers.res Unsupported file format
  src/Payments/PazeButton.res Unsupported file format
  src/Payments/SamsungPayComponent.res Unsupported file format
  src/Types/PaymentType.res Unsupported file format
  src/Types/SubscriptionEventTypes.res Unsupported file format
  src/Utilities/Utils.res Unsupported file format
  src/hyper-loader/Elements.res Unsupported file format
  src/hyper-loader/LoaderPaymentElement.res Unsupported file format
  src/hyper-loader/Types.res Unsupported file format

@github-actions

Copy link
Copy Markdown
Contributor

🚫 Missing Linked Issue

Hi 👋 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

  • Add a keyword like Fixes #123 or Closes #456 to your PR description or a commit message.
  • Or link it manually using the "Linked issues" panel in the PR sidebar.

Tip: You can link multiple issues.
🚫 Note: If only one issue is linked, it must be open for this check to pass.

Once linked, this check will pass automatically on your next push or when you re-run the workflow.

Thanks for helping maintainers! 🙌

@aritro2002 aritro2002 linked an issue Jun 23, 2026 that may be closed by this pull request
@XyneSpaces

Copy link
Copy Markdown

Code Review: Subscription Events Implementation

[should-fix] Effect dependency array incomplete

Location: CardCVCElement.res

The effect that emits CVC completion status likely depends on cvcNumber changes, but verify the dependency array includes all necessary values:

React.useEffect(() => {
  emitCvcInfo(~isCvcEmpty)
  emitter.emitCvcStatus({complete: isCvcComplete, empty: isCvcEmpty})
  None
}, [cvcNumber, isCvcEmpty, isCvcComplete]) // Verify all dependencies listed

Risk: Stale closure capturing old state if dependencies are incomplete.


[nit] Magic number for CVC length validation

Location: CardCVCElement.res, line ~25

let isCvcComplete = cvcNumber->String.length >= 3

Suggestion: This assumes all cards have 3-digit CVCs. American Express uses 4 digits. Consider using a card-type aware validator:

let isCvcComplete = CardUtils.isCvcValid(cvcNumber, cardBrand)

✓ Hook composition pattern

Good separation between useLegacyEvents() for backward compatibility and useSubscriptionEventEmitter() for the new subscription model. This allows gradual migration.


General observation

The PR migrates 40+ files to the new event system. Ensure:

  1. All event emitters are properly cleaned up on unmount
  2. No double-emission from both legacy and new paths simultaneously
  3. Feature flagging for gradual rollout if needed

@XyneSpaces

Copy link
Copy Markdown

Review Summary: Subscription Events Feature

This PR introduces a subscription events system that allows merchants to opt into specific events via a subscriptionEvents config option.

Overall Assessment

The implementation follows the new hook-based event emission pattern consistently across payment components. However, I've identified several areas for improvement:

[should-fix] Missing Input Validation for subscriptionEvents

The subscriptionEvents configuration accepts an array of strings but lacks validation to ensure only recognized event types are included. Consider adding runtime validation to catch typos or unsupported events early.

[should-fix] Potential Memory Leak in Hook Subscriptions

The useSubscriptionEventEmitter and related hooks should ensure proper cleanup of event listeners when components unmount to prevent memory leaks in single-page applications.

[nit] Documentation Needed

The new event types (SURCHARGE, PAYMENT_METHOD_INFO_CARD, etc.) would benefit from inline documentation explaining when each event fires and the expected payload structure.


Note: This is a high-level summary. Once detailed diff access is available, more specific line-by-line feedback can be provided.

@XyneSpaces

Copy link
Copy Markdown

[should-fix] Event type validation accepts unknown events silently

In SubscriptionEventTypes.res, the stringToEvent function logs a warning via unknownPropValueWarning but still returns UnknownEvent, which is then filtered out. This means:

  1. Merchants won't see their typo'd event names in the subscription list
  2. No hard error is thrown to indicate misconfiguration
  3. Debugging requires checking console warnings

Consider failing fast with a clear error when invalid subscription events are configured, rather than silently filtering.

Comment thread src/Hooks/UtilityHooks.res Outdated
Comment thread src/hyper-loader/Elements.res Outdated
Comment thread src/Utilities/Utils.res
@sakksham7

Copy link
Copy Markdown
Contributor

What will happen if merchant provides the subscription events list, and also add onChange callback? He will get all the subscribed events inside onChange as well?

Comment thread src/hyper-loader/LoaderPaymentElement.res
Comment thread src/Payments/ApplePay.res Outdated
Comment thread src/LoaderController.res
Comment thread src/PaymentElement.res
Comment thread src/Types/SubscriptionEventTypes.res
@sakksham7

Copy link
Copy Markdown
Contributor

Refactor and other discussion will be picked up later

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

Labels

Closed Label will be automatically added when the PR will get merged to main

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add subscription events

5 participants