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.
Dual-Path Flow (Web Fallback)
When the merchant taps “Generate Tap Payment” , the SDK automatically activates two independent HCE services behind the scenes. No extra integration is required — this is built-in.
Path 1 — Primary HCE (Integrated Partner App)
The merchant device responds to your unique partner AID (e.g., F0xxxxxxxx). When a customer with a TapRails-integrated app taps their phone, Android routes the AID selection to your app’s registered HCE service. The customer’s app reads the signed invoice directly over APDU — no internet connection required for the data exchange itself.
Path 2 — NDEF Fallback (Web-based)
A second HCE service responds to the standard NDEF AID (D2760000850101). When a customer taps with any device that does not have a partner app — including all iOS devices — their phone reads a standard NFC URL tag. The browser opens automatically and the customer completes payment via the TapRails web pay page.
The URL served by the NDEF service is signed with the same Ed25519 device key used for the native path. The backend verifies this signature before displaying any payment details — the customer cannot see or act on the payment without a valid signature.
https://pay.taprails.xyz/p?id={paymentId}&sig={hexSignature}&t={unixTimestamp}
Both services run in parallel. Android’s NFC stack routes to the correct one based on which AID the customer’s device selects. The partner AID always wins if the customer has the integrated app installed, because a more specific AID match takes priority over the generic NDEF AID.
The NDEF fallback URL is valid for 35 minutes — matching the 30-minute payment window plus 5 minutes of clock skew tolerance. Customers opening the URL after this window will see an “expired” error.
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>
);
}