NestJSBlockchainWeb3BullMQ

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.

R
Roni Sarkar
May 20268 min read

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.

💡 Key principle: Every financial transaction goes through a queue. Never process payments synchronously in the request cycle.

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.

typescript
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.

typescript
@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
💡 The most valuable decision: treating every external side-effect (payment, blockchain write, email) as an async job. Our system can go down and come back up — no transaction is lost.

Enjoyed this post?

Let's connect and talk engineering.

Get in touch