How to Build a React Payment Component with InvinciblePay
If you are building a Canadian fintech product or e-commerce platform in React, you can integrate Invincible Pay's REST API to accept Interac e-Transfers, process EFT payments, and generate payment links directly from a reusable React component. This tutorial walks you through building that component from scratch, covering state management, API integration, form validation, error handling, and responsive design.
Whether you are a solo developer launching your first SaaS product or a fintech team building payment infrastructure for the Canadian market, this guide gives you a production-ready starting point that connects to a real, FINTRAC-registered payment platform.
Why Build a Custom React Payment Component?
Most payment tutorials out there focus on credit card processing through providers like Stripe or Square. That is great for global checkout flows, but it overlooks a major opportunity for businesses operating in Canada.
Interac e-Transfer is the most widely used digital payment method in the country. According to Interac Corp, Canadians sent over 1.1 billion e-Transfer transactions in 2023 alone. For many businesses, offering e-Transfer at checkout can reduce processing costs by up to 80% compared to credit card fees, which typically sit between 2.4% and 2.9% per transaction.
Invincible Pay gives developers API access to this payment rail. Instead of redirecting customers to a third-party payment page or embedding a card form, you can build a native React component that initiates e-Transfers, triggers EFT payouts, calculates fees in real time, and monitors transaction status. All of this runs through a single REST API with a consistent JSON interface.
Building a custom component (rather than using a prebuilt drop-in) gives you full control over the user experience, the checkout flow, and how your brand appears during the most sensitive moment of a transaction.
What Does the Invincible Pay API Offer?
Before writing any code, it helps to understand the surface area of the API you are working with. The Invincible Pay API is an OpenAPI-first REST service hosted at https://api.invinciblepay.com. It uses standard bearer token authentication and returns structured JSON responses with predictable HTTP status codes.
Stop splitting transfers and funding middlemen.
Open a Business Wallet in minutes. No daily limits, no hidden fees.
The key endpoint groups relevant to a React payment component include:
Accounts and Wallet: The /v1/accounts/me endpoint returns your account context, while /v1/funding-sources/me lists your wallet(s). Each wallet has a UUID that you will reference in every transaction. The /v1/funding-sources/{id}/expand endpoint returns your current wallet balance.
Beneficiaries: Before sending any payment, you need to create a beneficiary (the person or business receiving funds). The /v1/beneficiaries endpoint supports full CRUD operations. You will use this when your component needs to send money to a new recipient.
Transactions: The core payment endpoints live under /v1/funding-sources/{funding_source_id}/. From here you can trigger an e-Transfer (/etransfer), an EFT bank payment (/eft), or an internal wallet transfer (/internal).
Fee Calculation: The /v1/funding-sources/calculate-fee endpoint lets you show users the exact cost of a transaction before they confirm it. This is critical for building trust in your checkout UI.
Input Validation Rules: The /v1/funding-sources/input-rules endpoint returns the server-side validation rules for transaction descriptions. Querying this on component mount ensures your frontend validations stay in sync with what the backend expects.
You can browse the full machine-readable spec at https://docs.invinciblepay.com/api-v1.json and generate typed clients for any language.
How Should You Structure the Project?
For this tutorial, we will use a feature-based folder structure. This keeps all payment-related logic (components, hooks, services, types) co-located so you can drop the entire feature folder into any React project.
src/
├── features/
│ └── payment/
│ ├── components/
│ │ ├── PaymentForm.tsx
│ │ ├── AmountInput.tsx
│ │ ├── RecipientField.tsx
│ │ ├── FeeBreakdown.tsx
│ │ └── TransactionStatus.tsx
│ ├── hooks/
│ │ ├── usePayment.ts
│ │ ├── useFeeCalculation.ts
│ │ └── useWalletBalance.ts
│ ├── services/
│ │ └── invinciblePayApi.ts
│ ├── types/
│ │ └── payment.types.ts
│ └── PaymentPage.tsx
├── shared/
│ ├── components/
│ │ └── LoadingSpinner.tsx
│ └── utils/
│ └── formatCurrency.tsThis structure follows a principle that has become standard in modern React development: keep state as close to the component that uses it as possible, and extract shared logic into custom hooks.
How Do You Set Up the API Service Layer?
The service layer is where all HTTP calls to the Invincible Pay API live. By isolating API logic here, your components never deal with fetch calls or auth headers directly.
// services/invinciblePayApi.ts
const API_BASE = 'https://api.invinciblepay.com';
interface ApiConfig {
token: string;
}
export function createInvinciblePayClient(config: ApiConfig) {
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.token}`,
};
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: { ...headers, ...options.headers },
});
if (!response.ok) {
const error = await response.json();
throw new PaymentApiError(
error.message || 'Request failed',
response.status
);
}
return response.json();
}
return {
getAccount: () =>
request<AccountResponse>('/v1/accounts/me'),
getWalletBalance: (fundingSourceId: string) =>
request<WalletBalanceResponse>(
`/v1/funding-sources/${fundingSourceId}/expand`
),
getFundingSources: () =>
request<FundingSource[]>('/v1/funding-sources/me'),
calculateFee: (payload: FeeCalculationRequest) =>
request<FeeCalculationResponse>(
'/v1/funding-sources/calculate-fee',
{ method: 'POST', body: JSON.stringify(payload) }
),
getInputRules: () =>
request<InputRules>('/v1/funding-sources/input-rules'),
sendETransfer: (
fundingSourceId: string,
payload: ETransferRequest
) =>
request<TransactionResponse>(
`/v1/funding-sources/${fundingSourceId}/etransfer`,
{ method: 'POST', body: JSON.stringify(payload) }
),
sendEft: (
fundingSourceId: string,
payload: EftRequest
) =>
request<TransactionResponse>(
`/v1/funding-sources/${fundingSourceId}/eft`,
{ method: 'POST', body: JSON.stringify(payload) }
),
createBeneficiary: (payload: CreateBeneficiaryRequest) =>
request<BeneficiaryResponse>(
'/v1/beneficiaries',
{ method: 'POST', body: JSON.stringify(payload) }
),
listBeneficiaries: () =>
request<BeneficiaryResponse[]>('/v1/beneficiaries'),
};
}
class PaymentApiError extends Error {
constructor(message: string, public statusCode: number) {
super(message);
this.name = 'PaymentApiError';
}
}A few things to note here. First, we are using a factory function (createInvinciblePayClient) rather than a static class. This makes testing easier because you can inject different tokens or mock the entire client. Second, we have a custom PaymentApiError class that captures the HTTP status code, which you will use later to display contextual error messages to users.
Important security note: Your API bearer token should never be hardcoded or exposed in client-side JavaScript. In a production environment, all API calls to Invincible Pay should be proxied through your own backend server. Your React component calls your backend, and your backend calls the Invincible Pay API with the real credentials. This keeps your token out of the browser entirely.
How Do You Build the Core Payment Hook?
The usePayment hook manages the entire transaction lifecycle: loading the wallet, calculating fees, validating inputs, submitting the payment, and tracking status.
// hooks/usePayment.ts
import { useState, useCallback, useEffect } from 'react';
import { createInvinciblePayClient } from '../services/invinciblePayApi';
type PaymentStatus =
| 'idle'
| 'calculating'
| 'ready'
| 'submitting'
| 'success'
| 'error';
interface PaymentState {
status: PaymentStatus;
walletBalance: number | null;
fundingSourceId: string | null;
fee: number | null;
totalAmount: number | null;
transactionId: string | null;
error: string | null;
}
export function usePayment(apiToken: string) {
const [state, setState] = useState<PaymentState>({
status: 'idle',
walletBalance: null,
fundingSourceId: null,
fee: null,
totalAmount: null,
transactionId: null,
error: null,
});
const client = createInvinciblePayClient({ token: apiToken });
// Load wallet on mount
useEffect(() => {
async function loadWallet() {
try {
const sources = await client.getFundingSources();
if (sources.length > 0) {
const walletId = sources[0].uuid;
const wallet = await client.getWalletBalance(walletId);
setState((prev) => ({
...prev,
fundingSourceId: walletId,
walletBalance: wallet.balance,
}));
}
} catch (err) {
setState((prev) => ({
...prev,
error: 'Failed to load wallet. Please try again.',
}));
}
}
loadWallet();
}, [apiToken]);
// Calculate fee when amount changes
const calculateFee = useCallback(
async (amount: number) => {
setState((prev) => ({ ...prev, status: 'calculating' }));
try {
const result = await client.calculateFee({
amount,
type: 'etransfer',
});
setState((prev) => ({
...prev,
status: 'ready',
fee: result.fee,
totalAmount: result.totalAmount,
}));
} catch {
setState((prev) => ({
...prev,
status: 'error',
error: 'Could not calculate fee.',
}));
}
},
[client]
);
// Submit the payment
const submitPayment = useCallback(
async (beneficiaryId: string, amount: number, description: string) => {
if (!state.fundingSourceId) return;
setState((prev) => ({ ...prev, status: 'submitting', error: null }));
try {
const transaction = await client.sendETransfer(
state.fundingSourceId,
{ beneficiaryId, amount, description }
);
setState((prev) => ({
...prev,
status: 'success',
transactionId: transaction.id,
}));
} catch (err) {
const message =
err instanceof Error
? err.message
: 'Payment failed. Please try again.';
setState((prev) => ({
...prev,
status: 'error',
error: message,
}));
}
},
[state.fundingSourceId, client]
);
const reset = useCallback(() => {
setState((prev) => ({
...prev,
status: 'idle',
fee: null,
totalAmount: null,
transactionId: null,
error: null,
}));
}, []);
return { ...state, calculateFee, submitPayment, reset };
}This hook gives your component a clean, declarative interface. The component renders differently based on status, and all side effects (API calls, state transitions) are handled inside the hook. No useEffect chains in your component code.
How Do You Build the Payment Form Component?
The PaymentForm component brings everything together into a UI that a user actually interacts with. It consumes the usePayment hook and renders different views based on the current payment status.
// components/PaymentForm.tsx
import { useState, useEffect } from 'react';
import { usePayment } from '../hooks/usePayment';
import { AmountInput } from './AmountInput';
import { RecipientField } from './RecipientField';
import { FeeBreakdown } from './FeeBreakdown';
import { TransactionStatus } from './TransactionStatus';
interface PaymentFormProps {
apiToken: string;
onComplete?: (transactionId: string) => void;
}
export function PaymentForm({ apiToken, onComplete }: PaymentFormProps) {
const {
status,
walletBalance,
fee,
totalAmount,
transactionId,
error,
calculateFee,
submitPayment,
reset,
} = usePayment(apiToken);
const [amount, setAmount] = useState('');
const [recipientEmail, setRecipientEmail] = useState('');
const [description, setDescription] = useState('');
const [beneficiaryId, setBeneficiaryId] = useState('');
// Debounced fee calculation
useEffect(() => {
const parsed = parseFloat(amount);
if (!isNaN(parsed) && parsed > 0) {
const timer = setTimeout(() => calculateFee(parsed), 500);
return () => clearTimeout(timer);
}
}, [amount, calculateFee]);
// Notify parent on success
useEffect(() => {
if (status === 'success' && transactionId && onComplete) {
onComplete(transactionId);
}
}, [status, transactionId, onComplete]);
if (status === 'success') {
return (
<TransactionStatus
transactionId={transactionId!}
onReset={reset}
/>
);
}
const isSubmitDisabled =
status === 'submitting' ||
status === 'calculating' ||
!amount ||
!beneficiaryId ||
!description ||
parseFloat(amount) <= 0;
return (
<div className="payment-form">
<h2>Send Payment</h2>
{walletBalance !== null && (
<p className="wallet-balance">
Available: {formatCAD(walletBalance)}
</p>
)}
<RecipientField
value={recipientEmail}
onChange={setRecipientEmail}
onBeneficiaryResolved={setBeneficiaryId}
/>
<AmountInput
value={amount}
onChange={setAmount}
max={walletBalance ?? undefined}
/>
<label htmlFor="payment-description">Description</label>
<input
id="payment-description"
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Invoice #1234"
maxLength={140}
/>
{fee !== null && totalAmount !== null && (
<FeeBreakdown
amount={parseFloat(amount)}
fee={fee}
total={totalAmount}
/>
)}
{error && (
<div className="error-message" role="alert">
{error}
</div>
)}
<button
type="button"
onClick={() =>
submitPayment(beneficiaryId, parseFloat(amount), description)
}
disabled={isSubmitDisabled}
aria-busy={status === 'submitting'}
>
{status === 'submitting' ? 'Processing...' : 'Send e-Transfer'}
</button>
</div>
);
}
function formatCAD(cents: number): string {
return new Intl.NumberFormat('en-CA', {
style: 'currency',
currency: 'CAD',
}).format(cents / 100);
}Notice a few deliberate choices here. The submit button is disabled during both the "calculating" and "submitting" states to prevent double-tap charges. The fee calculation is debounced (500ms delay) so we are not hitting the API on every keystroke. And the aria-busy attribute on the button tells screen readers that a process is underway.
How Do You Handle Fee Calculation in Real Time?
One of the strongest features of the Invincible Pay API is the ability to calculate fees before a transaction is confirmed. This transparency builds trust, especially for businesses processing large payments (remember, Invincible Pay supports e-Transfers up to $25,000 per transaction with no daily limits).
Here is the FeeBreakdown component that renders the calculated values:
// components/FeeBreakdown.tsx
interface FeeBreakdownProps {
amount: number;
fee: number;
total: number;
}
export function FeeBreakdown({ amount, fee, total }: FeeBreakdownProps) {
return (
<div className="fee-breakdown" aria-live="polite">
<div className="fee-row">
<span>Payment amount</span>
<span>{formatCAD(amount)}</span>
</div>
<div className="fee-row">
<span>Transaction fee</span>
<span>{formatCAD(fee)}</span>
</div>
<hr />
<div className="fee-row fee-total">
<span>Total</span>
<span>{formatCAD(total)}</span>
</div>
</div>
);
}The aria-live="polite" attribute is important. When the fee updates (because the user changed the amount), screen readers will announce the new values without interrupting whatever the user is currently doing. This is the kind of accessibility detail that separates a production-ready component from a tutorial demo.
What About Responsive Design?
A payment form needs to work flawlessly on every device. On mobile, users are often completing a payment from a text message or email link. If the form is awkward to use at 375px wide, you are going to lose conversions.
Here is a minimal CSS approach that handles the layout across breakpoints:
.payment-form {
max-width: 480px;
margin: 0 auto;
padding: 1.5rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.payment-form input,
.payment-form button {
width: 100%;
padding: 0.875rem 1rem;
font-size: 1rem;
border: 1px solid #d1d5db;
border-radius: 8px;
margin-bottom: 1rem;
box-sizing: border-box;
}
.payment-form input:focus {
outline: none;
border-color: #00C853;
box-shadow: 0 0 0 3px rgba(0, 200, 83, 0.15);
}
.payment-form button {
background-color: #1B2A4A;
color: #ffffff;
border: none;
cursor: pointer;
font-weight: 600;
transition: background-color 0.2s ease;
}
.payment-form button:hover:not(:disabled) {
background-color: #243658;
}
.payment-form button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.fee-breakdown {
background: #f9fafb;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.fee-row {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
}
.fee-total {
font-weight: 700;
}
.error-message {
background: #fef2f2;
color: #b91c1c;
padding: 0.75rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.wallet-balance {
color: #6b7280;
font-size: 0.875rem;
margin-bottom: 1rem;
}
@media (max-width: 480px) {
.payment-form {
padding: 1rem;
}
.payment-form input,
.payment-form button {
padding: 1rem;
font-size: 1.0625rem;
}
}A few details worth calling out: input tap targets are at least 44px tall (meeting WCAG touch target guidelines), the focus ring uses Invincible Pay's green accent color for brand consistency, and the mobile breakpoint slightly increases font sizes to compensate for the smaller viewport.
How Do You Handle Errors Gracefully?
Payment errors are not optional edge cases. They are a core part of the user experience. Your component needs to handle network failures, insufficient funds, invalid recipients, rate limits, and server errors, all without crashing or leaving the user confused.
Here is a utility that maps API error codes to user-friendly messages:
// utils/paymentErrors.ts
export function getErrorMessage(error: unknown): string {
if (error instanceof PaymentApiError) {
switch (error.statusCode) {
case 401:
return 'Your session has expired. Please log in again.';
case 409:
return 'This recipient already exists in your account.';
case 422:
return 'Please check the payment details and try again.';
case 429:
return 'Too many requests. Please wait a moment.';
case 500:
return 'Something went wrong on our end. Please try again shortly.';
default:
return error.message;
}
}
if (error instanceof TypeError && error.message === 'Failed to fetch') {
return 'Network error. Please check your connection.';
}
return 'An unexpected error occurred. Please try again.';
}In the component, you would replace the raw err.message in the catch block with getErrorMessage(err). This way, users never see a raw HTTP status code or a cryptic server message.
How Do You Test the Component?
Testing a payment component requires covering three layers: unit tests for individual functions, integration tests for the hook, and end-to-end tests for the full flow.
For unit testing the fee calculation display:
import { render, screen } from '@testing-library/react';
import { FeeBreakdown } from '../components/FeeBreakdown';
test('displays fee breakdown correctly', () => {
render(<FeeBreakdown amount={10000} fee={150} total={10150} />);
expect(screen.getByText('$100.00')).toBeInTheDocument();
expect(screen.getByText('$1.50')).toBeInTheDocument();
expect(screen.getByText('$101.50')).toBeInTheDocument();
});For integration testing the usePayment hook, mock the API client and verify that state transitions happen correctly:
import { renderHook, act } from '@testing-library/react';
import { usePayment } from '../hooks/usePayment';
// Mock the API client
jest.mock('../services/invinciblePayApi', () => ({
createInvinciblePayClient: () => ({
getFundingSources: jest.fn().mockResolvedValue([
{ uuid: 'wallet-123' },
]),
getWalletBalance: jest.fn().mockResolvedValue({
balance: 250000,
}),
calculateFee: jest.fn().mockResolvedValue({
fee: 150,
totalAmount: 10150,
}),
sendETransfer: jest.fn().mockResolvedValue({
id: 'txn-abc-123',
}),
}),
}));
test('transitions through payment states', async () => {
const { result } = renderHook(() =>
usePayment('test-token')
);
// Initially idle
expect(result.current.status).toBe('idle');
// Calculate fee
await act(async () => {
await result.current.calculateFee(10000);
});
expect(result.current.status).toBe('ready');
expect(result.current.fee).toBe(150);
// Submit payment
await act(async () => {
await result.current.submitPayment(
'ben-456', 10000, 'Test payment'
);
});
expect(result.current.status).toBe('success');
expect(result.current.transactionId).toBe('txn-abc-123');
});For end-to-end testing, use Cypress or Playwright against a staging environment. The Invincible Pay API documentation recommends confirming service availability via the /health endpoint before running automated test suites.
What Are the Security Best Practices?
Security is non-negotiable when handling payments. Here are the key practices to follow when building your React payment component:
Never expose API keys in client-side code. All requests to the Invincible Pay API should be proxied through your own backend. Your React component calls /api/payment/send, and your Node.js (or other) backend calls the actual Invincible Pay endpoint with the bearer token stored in environment variables.
Validate on both sides. The Invincible Pay API provides input validation rules via the /v1/funding-sources/input-rules endpoint. Fetch these rules on component mount and apply them to your form fields. But never trust client-side validation alone. The API performs its own validation and will reject malformed requests.
Use HTTPS everywhere. This should go without saying in 2026, but your React application and your backend proxy must both be served over HTTPS. The Invincible Pay API itself uses 256-bit encryption for all data in transit.
Implement rate limiting on your proxy. Even though the Invincible Pay API handles its own rate limits (returning 429 status codes), you should also throttle requests at your backend to prevent abuse.
Disable autocomplete on sensitive fields. Add autoComplete="off" to inputs that handle financial data. This prevents browsers from caching payment-related information.
Invincible Pay's platform already includes multi-factor authentication and AI-powered 24/7 fraud monitoring, so you are getting an additional layer of protection at the infrastructure level. Your funds are also safeguarded at Schedule 1 Canadian financial institutions, which provides a level of deposit security that is hard to match.
How Do You Deploy This to Production?
Once your component is tested and your backend proxy is in place, here is a quick deployment checklist:
Confirm API connectivity. Hit the
/healthendpoint from your backend to confirm the Invincible Pay API is reachable.Load your wallet ID on initialization. Call
/v1/funding-sources/meonce on app startup and cache the funding source UUID.Set up webhook listeners. For asynchronous payment methods like EFT, you will want to listen for transaction status updates rather than polling.
Monitor errors. Log all
PaymentApiErrorinstances with their status codes and timestamps. Set up alerts for spikes in 5xx errors.Test with real transactions. Before going live, send a small e-Transfer through the full flow to confirm everything works end to end.
Invincible Pay's onboarding process takes about five minutes, and the platform supports businesses across a wide range of industries, including those typically considered high-risk. If your application needs to process payments for cannabis, crypto, or other specialized verticals, Invincible Pay has a 98% approval rate for high-risk merchant accounts.
Wrapping Up
Building a React payment component with Invincible Pay gives you direct access to Canadian payment rails (Interac e-Transfer, EFT, wire transfers) without the overhead of traditional payment processors. The API is clean, well-documented with an OpenAPI spec, and designed for developers who want full control over their checkout experience.
The component we built in this tutorial covers the critical pieces: a service layer with typed API calls, a state management hook that tracks the full transaction lifecycle, a responsive form with real-time fee calculation, proper error handling, and accessibility built in from the start.
From here, you can extend the component to support EFT payouts, payment link generation, or even white-label checkout flows for your own platform's end users. The Invincible Pay API exposes all of these capabilities through the same consistent interface.
Ready to start building? Open your Invincible Wallet in minutes and grab your API credentials from the dashboard. If you need help with your integration, talk to the Invincible Pay team.
FAQ
Can I use the Invincible Pay API with Next.js or other React frameworks?
Yes. The API is framework-agnostic. Since it is a standard REST API, you can call it from Next.js API routes, Remix loaders, or any server-side environment. The React component patterns shown in this tutorial work in any React-based framework. For Next.js specifically, you can use Server Actions or Route Handlers as your backend proxy.
What are the transaction limits for e-Transfers through Invincible Pay?
Invincible Pay supports Interac e-Transfers up to $25,000 per transaction with no daily limits. This is significantly higher than the $3,000 limit most Canadian banks impose on personal e-Transfers. For businesses processing large invoices or vendor payments, this removes a common bottleneck.
Do I need PCI compliance to use the Invincible Pay API?
Since Invincible Pay processes e-Transfers and EFT payments (not credit card transactions), the PCI DSS requirements that apply to card processing do not apply here. However, you should still follow security best practices: proxy all API calls through your backend, use HTTPS, and never expose API credentials in client-side code.
Is there a sandbox or test environment for development?
Consult the Invincible Pay API documentation at docs.invinciblepay.com for the latest information on test environments and credentials. You can also contact their support team directly for developer onboarding assistance.
How does Invincible Pay compare to building with Stripe for Canadian payments?
Stripe excels at global credit card processing, but for Canadian-specific payment methods like Interac e-Transfer, Invincible Pay offers a more direct integration. The e-Transfer checkout option can save up to 80% on processing fees compared to credit card transactions. If your primary audience is Canadian and prefers e-Transfer, Invincible Pay gives you native access to that rail without the overhead of card network fees.
