stripe/add-native-feature
Step-by-step guide for adding features requiring JS-to-native bridge communication in the Stripe React Native SDK. Covers TypeScript types, Android Kotlin, iOS Swift, event emitters, bidirectional callbacks, and native module specs.
npx skills add https://github.com/stripe/stripe-react-native --skill add-native-feature
This guide explains how to add new features that require communication between React Native (JavaScript) and native code (iOS/Android). Use this when adding new native functionality, payment methods, or extending existing components with platform-specific capabilities.
The SDK uses a bidirectional communication pattern:
Use this when adding new configuration parameters that flow one-way from JavaScript to native code.
Add your new parameter to the relevant type definition in src/types/.
Example: Adding onBehalfOf to PaymentSheet.IntentConfiguration
File: src/types/PaymentSheet.ts
export type IntentConfiguration = {
mode: Mode;
paymentMethodTypes?: PaymentMethod.Type[];
onBehalfOf?: string; // New parameter
};
Extract the parameter from the bridge arguments and pass it to the native SDK.
File: android/src/main/java/com/reactnativestripesdk/PaymentSheetManager.kt (or similar)
override fun onCreate() {
// Parse the parameter from arguments
val onBehalfOf = arguments?.getString("onBehalfOf")
// Pass it to the native SDK
val intentConfiguration = PaymentSheet.IntentConfiguration(
mode = mode,
// ... other existing parameters ...
onBehalfOf = onBehalfOf
)
}
File: ios/StripeSdkImpl+PaymentSheet.swift
guard let intentConfiguration = params["intentConfiguration"] as? NSDictionary else {
// handle error
return
}
// Extract the parameter
let onBehalfOf = intentConfiguration["onBehalfOf"] as? String
// Build the configuration
let intentConfig = PaymentSheet.IntentConfiguration(
mode: mode,
// ... other existing parameters ...
onBehalfOf: onBehalfOf
)
Use this when native code needs to request data from JavaScript (e.g., fetching client secrets, custom validation).
React Native (JS) -> Registers Event Listener
|
Native Code (iOS/Android) -> Emits Event -> JS Listener Triggered
|
JS Executes Logic (API call, user input, etc.)
|
JS Invokes Native Callback -> Native Code Receives Result
|
Native Code Continues Execution
Create the native code that will request data from JavaScript.
File: android/src/main/java/com/reactnativestripesdk/ReactNativeCustomerSessionProvider.kt (or similar)
internal var provideSetupIntentClientSecretCallback: CompletableDeferred<String>? = null
override suspend fun provideSetupIntentClientSecret(customerId: String): Result<String> {
return suspendCancellableCoroutine { continuation ->
// Store the continuation to resume later
provideSetupIntentClientSecretCallback = continuation
// Emit the event to JavaScript
stripeSdkModule?.eventEmitter?.emitOnCustomerSessionProviderSetupIntentClientSecret()
}
}
File: ios/StripeSdkImpl.swift
// Store the continuation as a property
var clientSecretProviderSetupIntentClientSecretCallback: ((String) -> Void)? = nil
File: ios/StripeSdkImpl+CustomerSheet.swift
let intentConfiguration = CustomerSheet.IntentConfiguration(
// ... other parameters ...
setupIntentClientSecretProvider: {
return try await withCheckedThrowingContinuation { continuation in
// Store the continuation to be resumed later
self.clientSecretProviderSetupIntentClientSecretCallback = { clientSecret in
continuation.resume(returning: clientSecret)
}
// Emit the event to JavaScript
self.emitter?.emitOnCustomerSessionProviderSetupIntentClientSecret()
}
}
)
File: src/events.ts
Add your event to the Events type:
type Events = {
// ... existing events ...
onCustomerSessionProviderSetupIntentClientSecret: EventEmitter<void>; // No parameters
// OR if you need to pass data:
onCustomerSessionProviderSetupIntentClientSecret: EventEmitter<{
customerId: string;
}>;
};
Guidelines:
EventEmitter<void> if no data is passed from native to JSEventEmitter<{ param: type }> for simple parametersEventEmitter<UnsafeObject<any>> for complex objects (use sparingly)File: android/src/main/java/com/reactnativestripesdk/EventEmitterCompat.kt
fun emitOnCustomerSessionProviderSetupIntentClientSecret(value: ReadableMap? = null) {
invoke("onCustomerSessionProviderSetupIntentClientSecret", value)
}
// For events with no parameters:
fun emitOnCustomerSessionProviderSetupIntentClientSecret() {
invoke("onCustomerSessionProviderSetupIntentClientSecret")
}
File: ios/StripeSdkEmitter.swift
@objc public protocol StripeSdkEmitter {
// ... existing methods ...
// For events with parameters:
func emitOnCustomerSessionProviderSetupIntentClientSecret(_ value: [String: Any])
// For events without parameters:
func emitOnCustomerSessionProviderSetupIntentClientSecret()
}
These are the methods JavaScript will call to return data to native code.
File: src/specs/NativeStripeSdkModule.ts
export interface Spec extends TurboModule {
// ... existing methods ...
clientSecretProviderSetupIntentClientSecretCallback(
setupIntentClientSecret: string
): Promise<void>;
}
File: android/src/oldarch/java/com/reactnativestripesdk/NativeStripeSdkModuleSpec.java
@ReactMethod
@DoNotStrip
public abstract void clientSecretProviderSetupIntentClientSecretCallback(
String setupIntentClientSecret,
Promise promise
);
File: ios/StripeSdk.mm
RCT_EXPORT_METHOD(clientSecretProviderSetupIntentClientSecretCallback:(nonnull NSString *)setupIntentClientSecret
resolve:(nonnull RCTPromiseResolveBlock)resolve
reject:(nonnull RCTPromiseRejectBlock)reject)
{
[StripeSdkImpl.shared clientSecretProviderSetupIntentClientSecretCallback:setupIntentClientSecret
resolver:resolve
rejecter:reject];
}
Listen for the native event and invoke the callback with the result.
File: src/components/CustomerSheet.tsx (or relevant component)
// Declare the EventSubscription at the top of the file
let setupIntentClientSecretProviderCallback: EventSubscription | null = null;
const configureClientSecretProviderEventListeners = (
clientSecretProvider: ClientSecretProvider
): void => {
// Remove existing listener to prevent duplicates
setupIntentClientSecretProviderCallback?.remove();
// Register the event listener
setupIntentClientSecretProviderCallback = addListener(
'onCustomerSessionProviderSetupIntentClientSecret',
async () => {
try {
// Execute the user-provided function (e.g., API call)
const setupIntentClientSecret =
await clientSecretProvider.provideSetupIntentClientSecret();
// Return the result to native code
await NativeStripeSdk.clientSecretProviderSetupIntentClientSecretCallback(
setupIntentClientSecret
);
} catch (error) {
// Handle errors appropriately
console.error('Failed to provide setup intent client secret:', error);
}
}
);
};
If the event includes parameters from native:
setupIntentClientSecretProviderCallback = addListener(
'onCustomerSessionProviderSetupIntentClientSecret',
async ({ customerId }) => { // Destructure parameters
const setupIntentClientSecret =
await clientSecretProvider.provideSetupIntentClientSecret(customerId);
await NativeStripeSdk.clientSecretProviderSetupIntentClientSecretCallback(
setupIntentClientSecret
);
}
);
Important: Don't forget to clean up listeners when the component unmounts or is reconfigured.
Resume the async operation started in Step 1 with the data from JavaScript.
File: android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt
override fun clientSecretProviderSetupIntentClientSecretCallback(
setupIntentClientSecret: String,
promise: Promise
) {
customerSheetFragment?.let {
// Resume the coroutine with the result from JavaScript
it.customerSessionProvider?.provideSetupIntentClientSecretCallback?.resume(
Result.success(setupIntentClientSecret)
)
promise.resolve(null)
} ?: run {
promise.reject(
"CustomerSheetNotInitialized",
"Customer Sheet must be initialized before calling this callback"
)
}
}
File: ios/StripeSdkImpl+CustomerSheet.swift
@objc(clientSecretProviderSetupIntentClientSecretCallback:resolver:rejecter:)
public func clientSecretProviderSetupIntentClientSecretCallback(
setupIntentClientSecret: String,
resolver resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock
) -> Void {
// Resume the continuation with the result from JavaScript
self.clientSecretProviderSetupIntentClientSecretCallback?(setupIntentClientSecret)
// Clear the callback
self.clientSecretProviderSetupIntentClientSecretCallback = nil
resolve([])
}
src/types/src/events.tsEventEmitterCompat.ktStripeSdkEmitter.swiftNativeStripeSdkModule.tsNativeStripeSdkModuleSpec.javaStripeSdk.mmStripeSdkModule.ktyarn lint)yarn typescript)Problem: Forgetting to remove event listeners.
Solution: Always call .remove() on subscriptions before creating new ones or when unmounting.
useEffect(() => {
// Setup listener
const subscription = addListener('myEvent', handler);
return () => {
// Cleanup on unmount
subscription?.remove();
};
}, []);
Problem: Not handling errors in async callbacks.
Solution: Wrap callback logic in try-catch blocks and handle failures gracefully.
async () => {
try {
const result = await userProvidedFunction();
await NativeStripeSdk.callback(result);
} catch (error) {
console.error('Error:', error);
// Consider how to communicate errors back to native
}
}
Problem: Updating UI from background threads.
Solution: Ensure UI updates happen on the main thread:
DispatchQueue.main.async {
// UI updates here
}
Problem: Not calling promise.resolve() or promise.reject() in native code.
Solution: Always resolve or reject promises, even in error cases.
Problem: TypeScript types don't match native expectations.
Solution: Use UnsafeObject<T> for complex types and validate in native code.
withCheckedThrowingContinuation)((String) -> Void)?)[weak self] when neededsuspendCancellableCoroutineCancellableContinuation or CompletableDeferredTake stripe/add-native-feature from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.