NFCPaymentError for NFC-layer failures and APIError for backend communication failures. Both extend Error with a typed code for programmatic handling.
NFCPaymentError
Thrown bywriteSignedInvoice, readSignedInvoice, and the useNFCMerchant / useNFCCustomer hooks.
class NFCPaymentError extends Error {
code: NFCErrorCode;
}
enum NFCErrorCode {
NOT_SUPPORTED = 'NFC_NOT_SUPPORTED', // Device has no NFC hardware
NOT_ENABLED = 'NFC_NOT_ENABLED', // NFC is off in device settings
READ_FAILED = 'READ_FAILED', // Tag read error (hardware or timing)
WRITE_FAILED = 'WRITE_FAILED', // HCE emulation failed to start
INVALID_DATA = 'INVALID_DATA', // Bad payload, expired, or invalid signature
USER_CANCELLED = 'USER_CANCELLED', // User dismissed the NFC prompt
API_ERROR = 'API_ERROR', // Backend error during NFC flow
NETWORK_ERROR = 'NETWORK_ERROR', // No connection during NFC flow
TIMEOUT = 'TIMEOUT', // 30s read timeout exceeded
UNAUTHORIZED = 'UNAUTHORIZED', // Invalid API key
}
Handling NFC errors
import { useNFCCustomer, NFCErrorCode } from '@taprails/tap-to-pay';
const { scanPaymentRequest, error } = useNFCCustomer();
const handleScan = async () => {
try {
await scanPaymentRequest();
} catch (err) {
// Error is also available via the `error` state
}
};
useEffect(() => {
if (!error) return;
switch (error.code) {
case NFCErrorCode.NOT_SUPPORTED:
Alert.alert('NFC Not Supported', 'This device does not have NFC hardware.');
break;
case NFCErrorCode.NOT_ENABLED:
Alert.alert(
'NFC Disabled',
'Please enable NFC in your device settings to use tap-to-pay.',
);
break;
case NFCErrorCode.TIMEOUT:
Alert.alert('Timed Out', 'Hold your phone closer to the payment terminal and try again.');
break;
case NFCErrorCode.INVALID_DATA:
Alert.alert('Invalid Payment', 'This payment request is invalid or has expired.');
break;
case NFCErrorCode.USER_CANCELLED:
// Silent — user chose to cancel, no alert needed
break;
case NFCErrorCode.UNAUTHORIZED:
Alert.alert('Auth Error', 'Invalid API key. Please check your SDK configuration.');
break;
case NFCErrorCode.NETWORK_ERROR:
Alert.alert('No Connection', 'Check your internet connection and try again.');
break;
default:
Alert.alert('Error', error.message);
}
}, [error]);
APIError
Thrown by the API client layer (createPayment, processPayment, etc.) and surfaced via usePayment, usePaymentStatus, and related hooks.
class APIError extends Error {
code: APIErrorCode;
statusCode?: number; // HTTP status code
details?: Record<string, any>; // Additional error context from backend
}
enum APIErrorCode {
NETWORK_ERROR = 'NETWORK_ERROR', // No connection / DNS failure
TIMEOUT = 'TIMEOUT', // Request exceeded 30s
UNAUTHORIZED = 'UNAUTHORIZED', // 401 — invalid API key
BAD_REQUEST = 'BAD_REQUEST', // 400 — validation error
NOT_FOUND = 'NOT_FOUND', // 404 — resource not found
SERVER_ERROR = 'SERVER_ERROR', // 5xx — backend error
UNKNOWN_ERROR = 'UNKNOWN_ERROR', // Unexpected error
SDK_NOT_INITIALIZED = 'SDK_NOT_INITIALIZED', // initialize() not called yet
}
Handling API errors
import { usePayment, APIErrorCode } from '@taprails/tap-to-pay';
const { createPaymentRequest, error } = usePayment();
useEffect(() => {
if (!error) return;
switch (error.code) {
case APIErrorCode.UNAUTHORIZED:
// Likely a bad API key or expired session
redirectToLogin();
break;
case APIErrorCode.BAD_REQUEST:
Alert.alert('Invalid Request', error.details?.message || error.message);
break;
case APIErrorCode.NOT_FOUND:
Alert.alert('Not Found', 'Payment ID not found. It may have expired.');
break;
case APIErrorCode.SERVER_ERROR:
Alert.alert('Server Error', 'Something went wrong on our end. Please try again.');
break;
case APIErrorCode.NETWORK_ERROR:
case APIErrorCode.TIMEOUT:
Alert.alert('Connection Error', 'Check your internet connection and retry.');
break;
case APIErrorCode.SDK_NOT_INITIALIZED:
// Should not happen in production — initialize() called before rendering
console.error('SDK not initialized!');
break;
default:
Alert.alert('Error', error.message);
}
}, [error]);
Retry Behaviour
The SDK’s API client automatically retries server errors (5xx) and network timeouts with exponential backoff:| Attempt | Delay |
|---|---|
| 1st retry | 1 second |
| 2nd retry | 2 seconds |
| 3rd retry (final) | — throws error |
Error States in Hooks
All hooks expose anerror property and a clearError (or reset) method. Prefer handling errors via state rather than try/catch at the call site:
const { scanPaymentRequest, error, clearError, isReading } = useNFCCustomer();
// In JSX:
{error && (
<View>
<Text>{error.message}</Text>
<Button title="Try Again" onPress={() => { clearError(); scanPaymentRequest(); }} />
</View>
)}
Common Issues
| Symptom | Likely cause | Fix |
|---|---|---|
NFCErrorCode.NOT_SUPPORTED | Simulator or non-NFC device | Test on a physical device with NFC |
NFCErrorCode.TIMEOUT | Device too far from terminal | Hold devices back-to-back and ensure HCE is active |
NFCErrorCode.INVALID_DATA + “expired” | Invoice older than 5 minutes | Have merchant create a new payment request |
NFCErrorCode.INVALID_DATA + “signature” | Merchant device not registered | Call device registration before emitting HCE |
APIErrorCode.UNAUTHORIZED (401) | Wrong or missing API key | Verify apiKey in initialize() |
APIErrorCode.SDK_NOT_INITIALIZED | Hook used before initialize() | Move initialize() to app entry, before rendering |
| Web fallback shows “Invalid signature” | Signature corrupted in URL | Ensure SDK version is up to date — earlier builds used Base64 instead of Hex |
| Web fallback shows “Payment URL has expired” | URL opened more than 35 minutes after signing | Customer must tap again to get a fresh URL; payment expiry is 30 minutes |
| iOS customer’s browser doesn’t open | NFC background tag reading not enabled | Requires iOS 14+ with background NFC reading; user may need to tap from the Control Centre NFC icon on older iOS |
| NDEF tap does nothing on Android | Google Pay conflict | Ensure the SDK’s NDEF service is declared with category="other" in AndroidManifest.xml — this prevents the disambiguation dialog |

