Building Cross-Border Remittance with NestJS & Blockchain
How we architected a production-grade remittance platform using NestJS, BullMQ for async jobs, and ERC-20 smart contracts on Ethereum & Polygon — lessons learned.
When our team at Nagorik Technologies set out to build Remit & Go — a cross-border finance and remittance platform — we knew the architecture had to be bulletproof. Money movement across borders, multiple currencies, and blockchain-backed wallets all in one system. Here's what we built and what we learned.
The Architecture at a Glance
The backend is a NestJS monorepo with clearly separated modules: payments, wallets, notifications, and blockchain. PostgreSQL handles the main transactional data, Redis powers caching and job queues, and BullMQ manages all async operations — webhook processing, wallet reconciliation, and email dispatch.
Payment Gateway Integration
We integrated three payment gateways — Stripe, Fin.com, and Transfi — each with different APIs, webhook schemas, and failure behaviors. The trick was building a unified PaymentService interface so the rest of the app never knew which gateway it was talking to.
interface PaymentProvider {
createIntent(amount: number, currency: string, metadata: Record<string, string>): Promise<PaymentIntent>;
refund(intentId: string, amount?: number): Promise<Refund>;
handleWebhook(payload: Buffer, signature: string): Promise<WebhookEvent>;
}
@Injectable()
export class StripeProvider implements PaymentProvider {
constructor(private readonly stripe: Stripe) {}
async createIntent(amount: number, currency: string, metadata: Record<string, string>) {
return this.stripe.paymentIntents.create({ amount, currency, metadata });
}
}Async Job Processing with BullMQ
Payment webhooks are the trickiest part. Stripe fires a webhook, your server must respond within 5 seconds, but processing a payment event can involve 10+ database writes and external API calls. The solution: accept the webhook, validate the signature, enqueue the event, return 200. Process everything in a BullMQ worker.
@Processor('payment-events')
export class PaymentEventProcessor extends WorkerHost {
async process(job: Job<PaymentWebhookEvent>): Promise<void> {
const { type, data } = job.data;
switch (type) {
case 'payment_intent.succeeded':
await this.handleSuccess(data);
break;
case 'payment_intent.payment_failed':
await this.handleFailure(data);
break;
}
}
private async handleSuccess(data: PaymentIntent) {
// 1. Update transaction status
// 2. Credit receiver wallet
// 3. Emit WebSocket notification
// 4. Send email receipt
}
}Blockchain: ERC-20 Wallets on Ethereum & Polygon
Each user gets a non-custodial wallet. We deploy a minimal ERC-20 contract on Polygon (for low gas fees) and Ethereum mainnet for larger transfers. Web3.js handles contract calls from NestJS.
- ▸Polygon for everyday remittance (fast, cheap gas ~$0.001)
- ▸Ethereum mainnet for high-value transfers above a threshold
- ▸Private keys stored encrypted in AWS KMS — never in plaintext
- ▸Event listeners watch for Transfer events to reconcile wallet balances
Real-time Notifications via WebSocket
When a transaction completes (whether from a payment gateway or a blockchain event), the user sees it instantly. NestJS Gateways with Socket.io push events to authenticated WebSocket connections. Each connection is tied to a userId room, so targeted push is straightforward.
Lessons Learned
- ▸Always idempotent webhook processing — Stripe can fire the same webhook multiple times
- ▸Use database-level locks or optimistic concurrency for wallet balance updates to prevent race conditions
- ▸Test blockchain interactions against a local Hardhat node before hitting testnet
- ▸BullMQ retry strategies are critical — use exponential backoff with a dead letter queue
- ▸Log everything with correlation IDs — tracing a payment across 5 services is hard without them
Enjoyed this post?
Let's connect and talk engineering.