Mobile SDKs
Native payments on iOS, Android, and React Native
Drop-in modules that handle Apple Pay (iOS), Google Pay (Android), and a Secure Web View fallback to hosted checkout. Wallet payments follow the same /wallet-init → native sheet → /wallet-confirm flow across every platform.
React Native install
npm install @paymenthooks/react-native
cd ios && pod installReact Native usage
/**
* PaymentHooks React Native SDK
*
* Add to your app:
* npm install @paymenthooks/react-native
* cd ios && pod install
*
* Usage:
* import PaymentHooks from '@paymenthooks/react-native';
* await PaymentHooks.init({ publishableKey: 'pk_test_replace_with_your_key' });
* const result = await PaymentHooks.checkout({ sessionId: 'cs_abc123' });
*/
import { NativeModules, Platform, Alert, Linking } from 'react-native';
const PaymentHooksNative = NativeModules.PaymentHooksNative;
const PAYMENTHOOKS_API_BASE = 'https://api.paymenthooks.com/v1';
const PaymentHooks = {
publishableKey: null,
/**
* Initialise the SDK with your publishable key. Idempotent — safe to call
* from multiple screens.
*/
async init({ publishableKey }) {
this.publishableKey = publishableKey;
if (PaymentHooksNative?.configure) {
await PaymentHooksNative.configure({ publishableKey });
}
return this;
},
/**
* Present the checkout for a session. On iOS and Android, the native
* Payment Request sheet is shown for Apple Pay / Google Pay if available;
* otherwise the hosted checkout opens in a Secure Web View.
*
* @param {{ sessionId: string, customerEmail?: string }} options
* @returns {Promise<{ status: 'succeeded' | 'cancelled' | 'failed', paymentId?: string, error?: string }>}
*/
async checkout({ sessionId, customerEmail }) {
if (!sessionId) {
throw new Error('checkout() requires a sessionId');
}
if (!this.publishableKey) {
throw new Error('PaymentHooks.init() must be called before checkout()');
}
// 1. Detect native wallet availability
let wallet = null;
if (PaymentHooksNative?.canMakePayments) {
const caps = await PaymentHooksNative.canMakePayments();
if (caps.applePay && Platform.OS === 'ios') wallet = 'apple_pay';
else if (caps.googlePay && Platform.OS === 'android') wallet = 'google_pay';
}
if (wallet) {
try {
// 2a. Initialize wallet session on server
const initRes = await fetch(`${PAYMENTHOOKS_API_BASE}/api/v1/checkout-sessions/${sessionId}/wallet-init`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.publishableKey}`,
},
body: JSON.stringify({ wallet }),
});
const initData = await initRes.json();
if (!initRes.ok) {
throw new Error(initData.error?.message || 'wallet init failed');
}
// 2b. Show native wallet sheet
const walletResult = await PaymentHooksNative.presentWalletPayment({
publishableKey: this.publishableKey,
clientSecret: initData.data.clientSecret,
merchantName: initData.data.merchantName || 'PaymentHooks',
});
// 2c. Confirm on server
const confirmRes = await fetch(`${PAYMENTHOOKS_API_BASE}/api/v1/checkout-sessions/${sessionId}/wallet-confirm`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.publishableKey}`,
},
body: JSON.stringify({
wallet,
confirmed: walletResult,
customerEmail,
}),
});
const confirmData = await confirmRes.json();
if (!confirmRes.ok) {
throw new Error(confirmData.error?.message || 'wallet confirm failed');
}
return { status: 'succeeded', paymentId: confirmData.data.paymentId };
} catch (err) {
if (err.message?.includes('cancel')) {
return { status: 'cancelled' };
}
// Fall through to hosted checkout on wallet failure
console.warn('Wallet payment failed, falling back to hosted checkout:', err.message);
}
}
// 3. Hosted checkout fallback
const url = `https://checkout.paymenthooks.com/${sessionId}${customerEmail ? `?prefilled_email=${encodeURIComponent(customerEmail)}` : ''}`;
try {
const result = await PaymentHooksNative?.openSecureWebView
? PaymentHooksNative.openSecureWebView({ url, publishableKey: this.publishableKey })
: Linking.openURL(url);
return { status: 'succeeded', paymentId: result?.paymentId };
} catch (err) {
return { status: 'failed', error: err.message };
}
},
/**
* Verify a webhook payload server-side. (Client apps usually don't verify
* webhooks — that's the server's job. This is included for completeness.)
*/
verifyWebhookSignature(rawBody, signature, secret) {
// eslint-disable-next-line no-undef
const subtle = require('crypto').webcrypto?.subtle ?? globalThis.crypto.subtle;
return subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']).then((key) =>
subtle.sign('HMAC', key, new TextEncoder().encode(rawBody))
).then((sig) => {
const expected = Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, '0')).join('');
return expected === signature;
});
},
};
export default PaymentHooks;
iOS install
Drag PaymentHooks.swift into your Xcode project. Set the deployment target to iOS 15+ and add the merchant identifier for Apple Pay.
# In your project's Info.plist:
# - Set com.apple.developer.in-app-payments.merchant-identifier
# - Enable Apple Pay capability in Signing & Capabilities
# Then call:
PaymentHooks.shared.configure(publishableKey: "pk_test_replace_with_your_key")iOS usage
//
// PaymentHooks.swift
// PaymentHooks iOS SDK
//
// Add to your project: drag into Xcode, set the deployment target to iOS 15+.
// In Info.plist add the merchant identifier for Apple Pay.
//
import Foundation
import PassKit
import WebKit
public final class PaymentHooks: NSObject {
public static let shared = PaymentHooks()
private var publishableKey: String?
private var webView: WKWebView?
public struct CheckoutOptions {
public let sessionId: String
public let customerEmail: String?
public let onResult: (Result<CheckoutResult, Error>) -> Void
public init(sessionId: String, customerEmail: String? = nil, onResult: @escaping (Result<CheckoutResult, Error>) -> Void) {
self.sessionId = sessionId
self.customerEmail = customerEmail
self.onResult = onResult
}
}
public struct CheckoutResult {
public let status: String // "succeeded" | "cancelled" | "failed"
public let paymentId: String?
}
/// Initialise the SDK with your publishable key.
@discardableResult
public func configure(publishableKey: String) -> PaymentHooks {
self.publishableKey = publishableKey
return self
}
/// Present the checkout for a session. Prefers Apple Pay when supported.
public func checkout(options: CheckoutOptions) {
guard let publishableKey else {
options.onResult(.failure(NSError(domain: "PaymentHooks", code: 401, userInfo: [NSLocalizedDescriptionKey: "configure() must be called before checkout()"])))
return
}
if PKPaymentAuthorizationController.canMakePayments(usingNetworks: [.visa, .masterCard, .amex]) {
presentApplePay(options: options, publishableKey: publishableKey)
} else {
presentHostedCheckout(options: options)
}
}
// MARK: - Apple Pay
private func presentApplePay(options: CheckoutOptions, publishableKey: String) {
// Step 1: Initialize wallet session on server
let url = URL(string: "https://api.paymenthooks.com/v1/api/v1/checkout-sessions/\(options.sessionId)/wallet-init")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(publishableKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: ["wallet": "apple_pay"])
URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
guard let self else { return }
if let error {
options.onResult(.failure(error))
return
}
guard let data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let payload = json["data"] as? [String: Any],
let clientSecret = payload["clientSecret"] as? String else {
options.onResult(.failure(NSError(domain: "PaymentHooks", code: 500, userInfo: [NSLocalizedDescriptionKey: "Invalid wallet-init response"])))
return
}
// Step 2: Show Apple Pay sheet
let request = PKPaymentRequest()
request.merchantIdentifier = "merchant.com.paymenthooks" // set in Capabilities
request.supportedNetworks = [.visa, .masterCard, .amex]
request.merchantCapabilities = .threeDSecure
request.countryCode = "US"
request.currencyCode = "USD"
request.paymentSummaryItems = [
PKPaymentSummaryItem(label: "Total", amount: NSDecimalNumber(string: "0.00"))
]
let controller = PKPaymentAuthorizationController(paymentRequest: request)
controller.delegate = ApplePayDelegate.shared.configure(options: options, clientSecret: clientSecret)
controller.present { presented in
if !presented {
options.onResult(.failure(NSError(domain: "PaymentHooks", code: 500, userInfo: [NSLocalizedDescriptionKey: "Apple Pay sheet failed to present"])))
}
}
}.resume()
}
// MARK: - Hosted checkout fallback
private func presentHostedCheckout(options: CheckoutOptions) {
var components = URLComponents(string: "https://checkout.paymenthooks.com/\(options.sessionId)")!
if let email = options.customerEmail {
components.queryItems = [URLQueryItem(name: "prefilled_email", value: email)]
}
guard let url = components.url else { return }
DispatchQueue.main.async {
UIApplication.shared.open(url, options: [:]) { success in
if success {
options.onResult(.success(CheckoutResult(status: "succeeded", paymentId: nil)))
} else {
options.onResult(.failure(NSError(domain: "PaymentHooks", code: 500, userInfo: [NSLocalizedDescriptionKey: "Could not open checkout URL"])))
}
}
}
}
}
// MARK: - Apple Pay delegate
private final class ApplePayDelegate: NSObject, PKPaymentAuthorizationControllerDelegate {
static let shared = ApplePayDelegate()
private var options: PaymentHooks.CheckoutOptions?
private var clientSecret: String?
func configure(options: PaymentHooks.CheckoutOptions, clientSecret: String) -> ApplePayDelegate {
self.options = options
self.clientSecret = clientSecret
return self
}
func paymentAuthorizationController(_ controller: PKPaymentAuthorizationController,
didAuthorizePayment payment: PKPayment,
handler completion: @escaping (PKPaymentAuthorizationResult) -> Void) {
completion(PKPaymentAuthorizationResult(status: .success, errors: nil))
}
func paymentAuthorizationControllerDidFinish(_ controller: PKPaymentAuthorizationController) {
controller.dismiss {}
// Confirm on server
guard let options, let clientSecret else { return }
let url = URL(string: "https://api.paymenthooks.com/v1/api/v1/checkout-sessions/\(options.sessionId)/wallet-confirm")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: ["wallet": "apple_pay", "clientSecret": clientSecret])
URLSession.shared.dataTask(with: request) { data, _, _ in
let paymentId = (try? JSONSerialization.jsonObject(with: data ?? Data()) as? [String: Any])??["data"] as? [String: Any]??["paymentId"] as? String
options.onResult(.success(PaymentHooks.CheckoutResult(status: "succeeded", paymentId: paymentId)))
}.resume()
}
}
import UIKit
Android install
Add the SDK dependency and the Google Pay environment configuration. Requires Android API 24+ and Google Play Services 20+.
dependencies {
implementation 'com.paymenthooks:android:1.0.0'
implementation 'com.google.android.gms:play-services-wallet:20.0.0'
}
// Enable Java 8+ for the SDK's desugaring
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}<meta-data
android:name="com.google.android.gms.wallet.api.enabled"
android:value="true" />
<uses-permission android:name="android.permission.INTERNET" /><?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Set to PRODUCTION for live builds -->
<string name="google_pay_environment">TEST</string>
<string name="merchant_id">YOUR_GOOGLE_PAY_MERCHANT_ID</string>
</resources>Android usage
import com.paymenthooks.android.PaymentHooks
import com.paymenthooks.android.CheckoutOptions
class CheckoutActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 1. Initialise the SDK once at app start (idempotent).
PaymentHooks.configure(this, publishableKey = "pk_test_replace_with_your_key")
// 2. Trigger checkout for a server-created session.
val result = PaymentHooks.checkout(
this,
CheckoutOptions(sessionId = "cs_abc123", customerEmail = "[email protected]")
)
// result.paymentId is set on success; null on cancel.
}
}On Android, PaymentHooks.checkout() prefers Google Pay when the device supports it; otherwise it falls back to the same Secure Web View used on iOS for hosted checkout. Both paths resolve to a payment.succeeded webhook on the server.
Wallet flow (wallet-init → confirm)
Wallet payments (Apple Pay / Google Pay) run a three-step flow that is identical across iOS, Android, and React Native. The SDK handles all of it; the sequence is documented here so server-side integrations can mirror the protocol.
- wallet-init — the SDK POSTs
{ wallet: "apple_pay" | "google_pay" }to/api/v1/checkout-sessions/:id/wallet-initwith the publishable key. The server returns aclientSecretscoped to that session. - Native sheet — the SDK presents the Apple Pay sheet or the Google Pay sheet using the clientSecret. The customer authenticates locally; the SDK never sees the card number.
- wallet-confirm — after the sheet closes, the SDK POSTs the wallet result + clientSecret back to
/api/v1/checkout-sessions/:id/wallet-confirm. The server finalizes the charge and fires the standardpayment.succeededwebhook.
# 1. Init
curl -X POST https://api.paymenthooks.com/v1/checkout-sessions/cs_abc123/wallet-init \
-H "Authorization: Bearer pk_test_..." \
-H "Content-Type: application/json" \
-d '{"wallet":"google_pay"}'
# → { "data": { "clientSecret": "wcs_...", "merchantName": "Acme Inc" } }
# 2. (customer authenticates via Google Pay sheet)
# 3. Confirm
curl -X POST https://api.paymenthooks.com/v1/checkout-sessions/cs_abc123/wallet-confirm \
-H "Authorization: Bearer pk_test_..." \
-H "Content-Type: application/json" \
-d '{"wallet":"google_pay","clientSecret":"wcs_...","confirmed":{"paymentMethodData":{"tokenizationData":{"token":"..."}}}}'Mobile webhooks
Wallet payments are confirmed asynchronously — your server is the source of truth, not the SDK. Set up a webhook endpoint in the dashboard and deliver the same payment.succeeded event with the standard envelope shape. Signature verification is identical to hosted checkout:
import crypto from 'crypto';
function verifyWebhook(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map(p => p.split('='))
);
const expected = crypto
.createHmac('sha256', secret)
.update(parts.t + '.' + rawBody)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Mobile apps don't verify webhook signatures — that's a server responsibility. If your backend is a serverless function (e.g. AWS Lambda, Vercel), make sure to read the raw request body before JSON parsing so the signature matches byte-for-byte.