The merchant flow creates a signed NFC invoice and waits for a customer to tap their phone to receive it.
The merchant device must be Android due to iOS restrictions on Host Card Emulation (HCE).
High-Level: PaymentFlowManager
The PaymentFlowManager handles the complete merchant UI flow with zero boilerplate. This is the recommended approach for most integrations.
import { PaymentFlowManager } from '@taprails/tap-to-pay';
import { useState } from 'react';
import { TouchableOpacity, Text } from 'react-native';
export function MerchantScreen() {
const [showFlow, setShowFlow] = useState(false);
return (
<>
<TouchableOpacity onPress={() => setShowFlow(true)}>
<Text>Accept Payment</Text>
</TouchableOpacity>
{showFlow && (
<PaymentFlowManager
config={{
type: 'merchant',
onComplete: (data) => {
const { paymentRequest, txHash } = data as MerchantFlowData;
console.log('Payment received!', paymentRequest?.paymentId, txHash);
setShowFlow(false);
},
onCancel: () => setShowFlow(false),
onError: (error) => {
console.error('Payment error:', error.message);
setShowFlow(false);
},
}}
onMerchantCancel={() => setShowFlow(false)}
/>
)}
</>
);
}
Merchant flow states
idle → creating → waiting → (customer taps) → success
↓
error (timeout, NFC error, API error)
| State | UI shown |
|---|
creating | Loading — creating payment on backend |
waiting | Animated NFC icon + payment details + countdown timer |
success | Transaction receipt with amount and ID |
error | Contextual error message + retry / cancel buttons |
Low-Level: useNFCMerchant + writeSignedInvoice
Use the low-level API when you want to build your own payment UI.
Step 1 — Create the payment request
import { useNFCMerchant } from '@taprails/tap-to-pay';
const { createPaymentRequest, isCreating, paymentRequest, error, reset } = useNFCMerchant();
const handleCreatePayment = async () => {
const request = await createPaymentRequest({
amount: '25.00', // USDC amount as decimal string
merchantId: 'merchant_123', // Optional if set in SDK config
});
if (request) {
console.log('Payment ID:', request.paymentId);
console.log('Merchant wallet:', request.merchantWallet);
console.log('Expires at:', request.expiresAt);
}
};
Step 2 — Emit the invoice via NFC (HCE)
Once you have a PaymentRequest, write the signed invoice to the HCE layer:
import { writeSignedInvoice } from '@taprails/tap-to-pay';
import { useState } from 'react';
const [hasExchangedData, setHasExchangedData] = useState(false);
const startNFCEmulation = async () => {
if (!paymentRequest) return;
await writeSignedInvoice(
{
id: paymentRequest.paymentId,
merchantAddress: paymentRequest.merchantWallet,
amount: Math.round(parseFloat(paymentRequest.amount) * 1_000_000), // Convert USDC → µUSDC
expiresAt: paymentRequest.expiresAt!,
},
(exchanged) => {
if (exchanged) {
console.log('Customer successfully read the invoice!');
// Customer's app will now process the payment
setHasExchangedData(true);
}
}
);
};
writeSignedInvoice starts HCE emulation and returns immediately. The setHasExchangedData callback fires when the customer’s device reads the NFC tag. At this point, the customer’s app is processing the payment — you can show a “processing” state on the merchant screen.
Step 3 — Poll for confirmation (optional)
import { usePaymentStatus } from '@taprails/tap-to-pay';
const { startPolling, isConfirmed, status } = usePaymentStatus();
useEffect(() => {
if (hasExchangedData && paymentRequest) {
startPolling(paymentRequest.paymentId);
}
}, [hasExchangedData, paymentRequest]);
useEffect(() => {
if (isConfirmed) {
console.log('Payment confirmed on-chain!', status?.txHash);
}
}, [isConfirmed]);
Complete Low-Level Example
import { useState } from 'react';
import { View, Text, TextInput, Button, ActivityIndicator } from 'react-native';
import { useNFCMerchant, writeSignedInvoice, usePaymentStatus } from '@taprails/tap-to-pay';
export function CustomMerchantFlow() {
const [amount, setAmount] = useState('');
const [isEmulating, setIsEmulating] = useState(false);
const [exchanged, setExchanged] = useState(false);
const { createPaymentRequest, isCreating, paymentRequest, error, reset } = useNFCMerchant();
const { startPolling, isConfirmed } = usePaymentStatus();
const handleStart = async () => {
const request = await createPaymentRequest({ amount });
if (!request) return;
setIsEmulating(true);
await writeSignedInvoice(
{
id: request.paymentId,
merchantAddress: request.merchantWallet,
amount: Math.round(parseFloat(amount) * 1_000_000),
expiresAt: request.expiresAt!,
},
(didExchange) => {
setExchanged(didExchange);
setIsEmulating(false);
startPolling(request.paymentId);
}
);
};
if (isConfirmed) return <Text>✅ Payment confirmed!</Text>;
if (exchanged) return <Text>⏳ Processing payment…</Text>;
if (isEmulating) return <Text>📡 Waiting for customer to tap…</Text>;
if (isCreating) return <ActivityIndicator />;
return (
<View>
{error && <Text style={{ color: 'red' }}>{error.message}</Text>}
<TextInput
value={amount}
onChangeText={setAmount}
placeholder="0.00"
keyboardType="decimal-pad"
/>
<Button title="Create Payment" onPress={handleStart} />
</View>
);
}