Skip to main content
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.
MerchantScreen.tsx
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)
StateUI shown
creatingLoading — creating payment on backend
waitingAnimated NFC icon + payment details + countdown timer
successTransaction receipt with amount and ID
errorContextual 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

CustomMerchantFlow.tsx
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>
  );
}