nestjs

A Simple Guide to Integrating Xendit Payment Gateway with NestJS

A practical guide to integrating Xendit Payment Gateway with NestJS using ngrok for local testing, while following the same invoice and webhook flow used in production payment systems.

Muhammad Nurfatkhur Rahman

Muhammad Nurfatkhur Rahman

June 12, 2026

0 views
5 min read0 likes

Introduction

As I became more familiar with building APIs and connecting applications to external services, I started wondering how digital payments actually work behind the scenes. Features like wallet top-ups look simple from the user’s perspective: choose an amount, make a payment, and the balance increases automatically. But from the backend side, I was curious about how the system knows when a payment is completed and when it is safe to update the user’s balance.

In this article, I will share a simple implementation of a wallet top-up flow using Xendit as the payment gateway, NestJS as the backend framework, and ngrok for local webhook testing without deploying the backend to a public server

Prerequisites

Before we start, there are a few things you should prepare. This article does not require a fully deployed backend server because we will use ngrok to expose our local NestJS application for webhook testing.

You should have a basic understanding of:

You will also need:

1. Project Setup

If you already have an existing NestJS project, you can continue using it. But if you haven’t created one, you can use the Nest CLI to generate a new project.

nest new nestjs-payment-api

1.1 Install Dependencies

Next, install the required dependencies for this project.

npm install @prisma/client @nestjs/jwt @nestjs/config class-validator class-transformer bcrypt @prisma/adapter-pg
npm install -D prisma @types/bcrypt

Here is a short explanation of what these packages are used for:

  • @prisma/client is used to communicate with the database through Prisma.

  • prisma is used for database schema management and migrations.

  • @nestjs/config is used to load environment variables from the .env file.

  • @nestjs/jwt is used to handle JWT-based authentication.

In this tutorial, JWT authentication is used to protect the wallet top-up endpoint, so only authenticated users can create a payment invoice.

1.2 Environment Variables

Create a .env file in the root of your project:

# Database
DATABASE_URL=postgresql://user:password@localhost:5432/dbname

# JWT
JWT_ACCESS_SECRET=your-secret-key

# Xendit
XENDIT_SECRET_KEY=xnd_development_...
XENDIT_WEBHOOK_TOKEN=your-webhook-token

Then add the environment variables based on your own project configuration.

The DATABASE_URL will be used by Prisma to connect to PostgreSQL. Make sure to adjust the username, password, host, port, and database name according to your local or production database setup.

The JWT_ACCESS_SECRET will be used to sign and verify access tokens. In a real project, you should use a strong and secure secret value.

The XENDIT_SECRET_KEY is used by the backend to create invoices through the Xendit API. You can get this key from your Xendit dashboard.

The XENDIT_WEBHOOK_TOKEN is used to validate incoming webhook requests from Xendit. This is important because the webhook endpoint will be publicly accessible, especially when we expose our local server using ngrok.

1.3 Prisma Setup

After configuring the environment variables, initialize Prisma:

npx prisma init

This command will create a prisma folder and a schema.prisma file. The schema.prisma file is where we will define our database models, such as users, wallets, wallet top-ups, and wallet transactions.

After the schema is created, run the migration:

npx prisma migrate dev --name init

Then generate the Prisma Client:

npx prisma generate

The migration command will apply our database schema to PostgreSQL, while the generate command will create the Prisma Client that we can use inside our NestJS services.

We will define the actual Prisma schema in the next section.

1.4 Module Registration

Before diving into the implementation, let's set up the required modules. We will create four modules: PrismaModule, AuthModule, WalletsModule, and PaymentsModule.


Prisma Module

The PrismaModule is responsible for providing the database client across the application. Create the files manually:

Create src/prisma.module.ts:

import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

Create src/prisma.service.ts:

import { Injectable } from '@nestjs/common';
import { PrismaClient } from './generated/prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';

@Injectable()
export class PrismaService extends PrismaClient {
  constructor() {
    const connectionString = process.env.DATABASE_URL;
    if (!connectionString) {
      throw new Error('DATABASE_URL environment variable is not set');
    }
    const adapter = new PrismaPg(connectionString);
    super({ adapter });
  }
}

A few things worth noting:

  • By marking PrismaModule with @Global(), PrismaService is available in every module without needing to import PrismaModule repeatedly.

  • This project uses Prisma 7 with @prisma/adapter-pg as the database adapter. In Prisma 7, PrismaClient is no longer imported from @prisma/client — instead it comes from the generated client at ./generated/prisma/client. Connection handling is done through the adapter, so $connect() is no longer needed.


Auth Module

The AuthModule handles JWT validation. Generate the module, service, and controller:

nest g module auth
nest g service auth
nest g controller auth

Then create the JWT guard at src/auth/guards/jwt-access.guard.ts:

import {
  CanActivate,
  ExecutionContext,
  Injectable,
  InternalServerErrorException,
  UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';

type AccessTokenPayload = {
  sub: string;
  username: string;
  role: string;
  iat: number;
  exp: number;
};

@Injectable()
export class JwtAccessGuard implements CanActivate {
  constructor(private readonly jwtService: JwtService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context
      .switchToHttp()
      .getRequest<Request & { user?: AccessTokenPayload }>();

    const token = this.extractBearerToken(request);

    if (!token) {
      throw new UnauthorizedException('Missing or invalid authorization header');
    }

    const secret = process.env.JWT_ACCESS_SECRET?.trim();
    if (!secret) {
      throw new InternalServerErrorException('JWT_ACCESS_SECRET is not configured');
    }

    try {
      const payload = await this.jwtService.verifyAsync<AccessTokenPayload>(
        token,
        { secret },
      );
      request.user = payload;
      return true;
    } catch {
      throw new UnauthorizedException('Invalid access token');
    }
  }

  private extractBearerToken(request: Request): string | null {
    const auth = request.get('authorization');
    if (!auth) return null;
    const [type, token] = auth.split(' ');
    if (type !== 'Bearer' || !token) return null;
    return token;
  }
}

Update src/auth/auth.module.ts:

import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtModule } from '@nestjs/jwt';
import { JwtAccessGuard } from './guards/jwt-access.guard';

@Module({
  imports: [JwtModule.register({})],
  controllers: [AuthController],
  providers: [AuthService, JwtAccessGuard],
})
export class AuthModule {}

JwtModule.register({}) is registered with an empty config because the JWT secret is read directly from process.env at runtime inside the guard, not at module initialization time.


Wallets Module

The WalletsModule manages wallet balance and transaction history. We will use it in the testing section to verify that the wallet balance was credited after a successful payment. Generate the module, service, and controller:

nest g module wallets
nest g service wallets
nest g controller wallets

Update src/wallets/wallets.module.ts:

import { Module } from '@nestjs/common';
import { WalletsService } from './wallets.service';
import { WalletsController } from './wallets.controller';
import { JwtModule } from '@nestjs/jwt';

@Module({
  imports: [JwtModule.register({})],
  providers: [WalletsService],
  controllers: [WalletsController],
})
export class WalletsModule {}

We will implement the service and controller for this module in the testing section.


Payments Module

The PaymentsModule handles wallet top-up creation and Xendit webhook processing. Generate the module, service, and controller:

nest g module payments
nest g service payments
nest g controller payments

Update src/payments/payments.module.ts:

import { Module } from '@nestjs/common';
import { PaymentsService } from './payments.service';
import { PaymentsController } from './payments.controller';
import { XenditService } from './xendit/xendit.service';
import { JwtModule } from '@nestjs/jwt';

@Module({
  imports: [JwtModule.register({})],
  providers: [PaymentsService, XenditService],
  controllers: [PaymentsController],
})
export class PaymentsModule {}

Wiring Everything in AppModule

Finally, register all modules in src/app.module.ts:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma.module';
import { AuthModule } from './auth/auth.module';
import { WalletsModule } from './wallets/wallets.module';
import { PaymentsModule } from './payments/payments.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    PrismaModule,
    AuthModule,
    WalletsModule,
    PaymentsModule,
  ],
})
export class AppModule {}

1.5 Application Setup

Before running the application, we need to configure the global prefix and validation pipe. Open src/main.ts and update it:

import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.setGlobalPrefix('api/v1');

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );

  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

A few things worth noting:

  • setGlobalPrefix('api/v1') adds /api/v1 prefix to all endpoints, so for example POST /auth/register becomes POST /api/v1/auth/register

  • ValidationPipe with whitelist: true strips any properties that are not in the DTO, forbidNonWhitelisted: true throws an error if unknown properties are sent, and transform: true automatically transforms incoming request data into the correct DTO class instances — this is required for class-validator decorators to work

  • PORT env variable can be used to change the port, with 3000 as the fallback

2. Database Schema

In this section, we will define the database models needed for the payment flow. Open prisma/schema.prisma and define the following models.

2.1 Generator & Datasource

This project uses Prisma 7, which introduces a change in how database connections are configured. Starting from Prisma 7, the url property is no longer supported inside schema.prisma. Instead, the connection URL is moved to a separate prisma.config.ts file.

First, install the dotenv package so prisma.config.ts can load environment variables:

npm install dotenv

Create prisma.config.ts in the root of your project:

import 'dotenv/config';
import { defineConfig } from 'prisma/config';

export default defineConfig({
  schema: 'prisma/schema.prisma',
  migrations: {
    path: 'prisma/migrations',
  },
  datasource: {
    url: process.env['DATABASE_URL'],
  },
});

Then open prisma/schema.prisma and define the generator and datasource like this:

generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
}

A few things worth noting:

  • provider = "prisma-client" is the new provider name in Prisma 7, replacing the old prisma-client-js

  • output = "../src/generated/prisma" tells Prisma to generate the client into src/generated/prisma instead of node_modules, so the generated types are part of your source code and easier to inspect

  • The datasource block no longer contains url — that's now handled by prisma.config.ts

2.2 User

The User model stores the basic user data. Each user will have one wallet.

model User {
  id       String @id @default(cuid())
  email    String @unique
  password String
  username String @unique
  name     String?
  role     String   @default("USER")
  isActive Boolean  @default(true)

  wallet       Wallet?
  walletTopUps WalletTopUp[]

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("users")
}

2.3 Wallet

Each user has one wallet. The balance field uses Decimal(18, 2) to avoid floating point precision issues when dealing with monetary values.

model Wallet {
  id       String  @id @default(cuid())
  userId   String  @unique
  balance  Decimal @db.Decimal(18, 2)
  currency String  @default("IDR")

  user               User                @relation(fields: [userId], references: [id], onDelete: Cascade)
  walletTransactions WalletTransaction[]

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("wallets")
}

2.4 WalletTransaction

Every balance change is recorded as a transaction. This gives us a full audit trail of what happened to the wallet balance.

enum WalletTransactionType {
  TOP_UP
  TRANSFER_IN
  TRANSFER_OUT
  ADJUSTMENT
}

model WalletTransaction {
  id           String                @id @default(cuid())
  walletId     String
  type         WalletTransactionType
  amount       Decimal               @db.Decimal(18, 2)
  balanceAfter Decimal               @db.Decimal(18, 2)
  description  String?
  reference    String?

  wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade)

  createdAt DateTime @default(now())

  @@index([walletId, createdAt])
  @@map("wallet_transactions")
}

2.5 WalletTopUp

This is the core model for the payment flow. It tracks each top-up request from creation through to payment confirmation from Xendit.

enum PaymentStatus {
  PENDING
  PAID
  EXPIRED
  FAILED
}

model WalletTopUp {
  id       String        @id @default(cuid())
  userId   String
  amount   Decimal       @db.Decimal(18, 2)
  currency String        @default("IDR")
  status   PaymentStatus @default(PENDING)

  reference        String  @unique
  idempotencyKey   String?
  xenditInvoiceId  String? @unique
  xenditInvoiceUrl String?

  paidAt    DateTime?
  expiredAt DateTime?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@unique([userId, idempotencyKey])
  @@index([userId, status])
  @@map("wallet_top_ups")
}

A few things worth noting:

  • reference is a unique identifier we generate internally and pass to Xendit as external_id. This is how we match incoming webhooks back to the correct top-up record.

  • idempotencyKey with @@unique([userId, idempotencyKey]) prevents duplicate top-up requests from the same user.

  • xenditInvoiceId and xenditInvoiceUrl are populated after the Xendit invoice is successfully created.

Once all models are defined, run the migration to apply the schema to your database:

npx prisma migrate dev --name add-wallet-payment-models
npx prisma generate

3. Authentication

We will keep the authentication part simple and only cover what is essential for the payment flow: user registration, login to generate an access token, and a JWT guard to protect the top-up endpoint.

3.1 DTOs

Create src/auth/dto/register.dto.ts:

import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';

export class RegisterDto {
  @IsEmail()
  email!: string;

  @IsString()
  @MinLength(8)
  password!: string;

  @IsOptional()
  @IsString()
  name?: string;

  @IsString()
  @MinLength(5)
  username!: string;
}

Create src/auth/dto/login.dto.ts:

import { IsString, MinLength } from 'class-validator';

export class LoginDto {
  @IsString()
  username!: string;

  @IsString()
  @MinLength(8)
  password!: string;
}

export class LoginResponseDto {
  accessToken!: string;
  refreshToken!: string;
  tokenType!: 'Bearer';
  expiresIn!: number;
  user!: {
    id: string;
    email: string;
    name: string | null;
    username: string;
    role: string;
  };
}

3.2 Auth Service

The AuthService handles user registration and login. On register, a wallet is automatically created inside the same database transaction. On login, it signs a JWT access token with the user's id, username, and role as the payload.

Create src/auth/auth.service.ts:

import {
  ConflictException,
  Injectable,
  InternalServerErrorException,
  UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { Prisma } from 'src/generated/prisma/client';
import { PrismaService } from 'src/prisma.service';
import { LoginDto, LoginResponseDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';

@Injectable()
export class AuthService {
  private readonly accessSecret: string;
  private readonly accessExpiresIn: number;

  constructor(
    private readonly prisma: PrismaService,
    private readonly jwtService: JwtService,
  ) {
    const secret = process.env.JWT_ACCESS_SECRET?.trim();
    if (!secret) {
      throw new InternalServerErrorException('JWT_ACCESS_SECRET is not configured');
    }
    this.accessSecret = secret;
    this.accessExpiresIn = 900;
  }

  async register(dto: RegisterDto) {
    const email = dto.email.trim().toLowerCase();
    const username = dto.username.trim().toLowerCase();

    const existing = await this.prisma.user.findFirst({
      where: { OR: [{ email }, { username }] },
    });

    if (existing) {
      throw new ConflictException('Email or username already registered');
    }

    const hashedPassword = await bcrypt.hash(dto.password, 10);

    return this.prisma.$transaction(async (tx) => {
      const user = await tx.user.create({
        data: {
          email,
          password: hashedPassword,
          name: dto.name?.trim() || null,
          username,
          isActive: true,
        },
        select: { id: true, email: true, username: true, name: true, role: true, createdAt: true },
      });

      await tx.wallet.create({
        data: {
          userId: user.id,
          balance: new Prisma.Decimal(0),
          currency: 'IDR',
        },
      });

      return user;
    });
  }

  async login(dto: LoginDto): Promise<LoginResponseDto> {
    const username = dto.username.trim().toLowerCase();

    const user = await this.prisma.user.findUnique({ where: { username } });

    if (!user) {
      throw new UnauthorizedException('Invalid credentials');
    }

    const valid = await bcrypt.compare(dto.password, user.password);
    if (!valid) {
      throw new UnauthorizedException('Invalid credentials');
    }

    const accessToken = await this.jwtService.signAsync(
      { sub: user.id, username: user.username, role: user.role },
      { secret: this.accessSecret, expiresIn: this.accessExpiresIn },
    );

    return {
      accessToken,
      refreshToken: '',
      tokenType: 'Bearer',
      expiresIn: this.accessExpiresIn,
      user: {
        id: user.id,
        email: user.email,
        username: user.username,
        name: user.name,
        role: user.role,
      },
    };
  }
}

Notice that on register, we create the user and wallet inside a single $transaction. This guarantees that if wallet creation fails, the user is also rolled back — no orphaned users without a wallet.

3.3 Auth Controller

Create src/auth/auth.controller.ts with only the two endpoints we need:

import { Body, Controller, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto, LoginResponseDto } from './dto/login.dto';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post('register')
  async register(@Body() dto: RegisterDto) {
    return this.authService.register(dto);
  }

  @Post('login')
  async login(@Body() dto: LoginDto): Promise<LoginResponseDto> {
    return this.authService.login(dto);
  }
}

3.4 JWT Guard

The JwtAccessGuard was already created in section 1.4 when we set up the AuthModule. It reads the Authorization: Bearer <token> header, verifies the JWT using JWT_ACCESS_SECRET, and attaches the decoded payload to req.user.

To protect any endpoint with this guard, simply add @UseGuards(JwtAccessGuard) to the controller method:

@Post('wallet-top-ups')
@UseGuards(JwtAccessGuard)
async createWalletTopUp(
  @Req() req: Request & { user?: { sub: string } },
  ...
) {
  const userId = req.user.sub;
  ...
}

We will use this guard in the next section when building the top-up endpoint.

4. Xendit Service

The XenditService is a thin wrapper around the Xendit REST API. It has two responsibilities: creating an invoice and verifying the webhook callback token.

Create src/payments/xendit/xendit.types.ts:

export type CreateXenditInvoiceInput = {
  externalId: string;
  amount: number;
  payerEmail: string;
  description: string;
  currency?: 'IDR';
  successRedirectUrl?: string;
  failureRedirectUrl?: string;
};

export type CreateXenditInvoiceResult = {
  id: string;
  externalId: string;
  status: string;
  invoiceUrl: string;
  amount: number;
  currency: string;
  expiryDate?: string;
};

export type XenditInvoiceResponse = {
  id: string;
  external_id: string;
  status: string;
  invoice_url: string;
  amount: number;
  currency: string;
  expiry_date?: string;
};

Create src/payments/xendit/xendit.service.ts:

import {
  Injectable,
  InternalServerErrorException,
  UnauthorizedException,
} from '@nestjs/common';
import {
  CreateXenditInvoiceInput,
  CreateXenditInvoiceResult,
  XenditInvoiceResponse,
} from './xendit.types';

@Injectable()
export class XenditService {
  private readonly baseUrl = 'https://api.xendit.co';

  private get secretKey(): string {
    const secretKey = process.env.XENDIT_SECRET_KEY;
    if (!secretKey) {
      throw new InternalServerErrorException('XENDIT_SECRET_KEY is not set');
    }
    return secretKey;
  }

  async createInvoice(
    input: CreateXenditInvoiceInput,
  ): Promise<CreateXenditInvoiceResult> {
    const response = await fetch(`${this.baseUrl}/v2/invoices`, {
      method: 'POST',
      headers: {
        Authorization: this.createAuthorizationHeader(),
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        external_id: input.externalId,
        amount: input.amount,
        payer_email: input.payerEmail,
        description: input.description,
        currency: input.currency ?? 'IDR',
        success_redirect_url: input.successRedirectUrl,
        failure_redirect_url: input.failureRedirectUrl,
      }),
    });

    const data = (await response.json()) as XenditInvoiceResponse;

    if (!response.ok) {
      throw new InternalServerErrorException({
        message: 'Failed to create Xendit invoice',
        statusCode: response.status,
        xenditResponse: data,
      });
    }

    return {
      id: data.id,
      externalId: data.external_id,
      status: data.status,
      invoiceUrl: data.invoice_url,
      amount: data.amount,
      currency: data.currency,
      expiryDate: data.expiry_date,
    };
  }

  verifyCallbackToken(callbackToken: string | undefined): void {
    const expectedToken = process.env.XENDIT_WEBHOOK_TOKEN;

    if (!expectedToken) {
      throw new InternalServerErrorException('XENDIT_WEBHOOK_TOKEN is not set');
    }

    if (!callbackToken || callbackToken !== expectedToken) {
      throw new UnauthorizedException('Invalid Xendit callback token');
    }
  }

  private createAuthorizationHeader(): string {
    const token = Buffer.from(`${this.secretKey}:`).toString('base64');
    return `Basic ${token}`;
  }
}

A few things worth noting:

  • Xendit uses HTTP Basic Auth where the secret key is the username and the password is left empty. That's why we append : before encoding to base64.

  • verifyCallbackToken compares the x-callback-token header from incoming webhooks against XENDIT_WEBHOOK_TOKEN in our env. This prevents anyone from spoofing webhook calls to our endpoint.

  • The secretKey getter throws at call time rather than at startup, so if XENDIT_SECRET_KEY is missing it will surface immediately when the first invoice is created — not silently ignored.

5. Create Wallet Top-Up

This is the core of the payment flow. When a user wants to top up their wallet, they call this endpoint. The backend will create a WalletTopUp record in the database, then call the Xendit API to create an invoice. The response includes an invoiceUrl that the user visits to complete the payment.

5.1 Reference Utility

We generate a unique internal reference for each top-up. This reference is passed to Xendit as external_id, and later used to match incoming webhooks back to the correct top-up record.

Create src/common/utils/reference.util.ts:

import { randomBytes } from 'crypto';

export function generateReference(prefix: string): string {
  const random = randomBytes(8).toString('hex').toUpperCase();
  const date = new Date().toISOString().slice(0, 10).replaceAll('-', '');

  return `${prefix}-${date}-${random}`;
}

For example, calling generateReference('WTU') produces something like WTU-20260612-A3F9C2B1D4E7F8A2.

5.2 DTO

Create src/payments/dto/create-wallet-top-up.dto.ts:

import { Type } from 'class-transformer';
import { IsNumber, Min } from 'class-validator';

export class CreateWalletTopUpDto {
  @Type(() => Number)
  @IsNumber({ maxDecimalPlaces: 2 })
  @Min(1000)
  amount!: number;
}

export class CreateWalletTopUpResponseDto {
  id!: string;
  reference!: string;
  status!: string;
  amount!: string;
  currency!: string;
  invoiceUrl!: string | null;
}

The amount has a minimum of 1000 (IDR 1.000) and allows up to 2 decimal places. Notice that amount in the response DTO is a string — this is intentional because we store monetary values as Prisma.Decimal and serialize them as strings to avoid floating point precision issues.

5.3 Payments Service

Create src/payments/payments.service.ts:

import {
  BadRequestException,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { Prisma } from 'src/generated/prisma/client';
import { generateReference } from 'src/common/utils/reference.util';
import { PrismaService } from 'src/prisma.service';
import { XenditService } from './xendit/xendit.service';
import {
  CreateWalletTopUpDto,
  CreateWalletTopUpResponseDto,
} from './dto/create-wallet-top-up.dto';

@Injectable()
export class PaymentsService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly xenditService: XenditService,
  ) {}

  async createWalletTopUp(
    userId: string,
    dto: CreateWalletTopUpDto,
    idempotencyKey?: string,
  ): Promise<CreateWalletTopUpResponseDto> {
    const normalizedIdempotencyKey = idempotencyKey?.trim() || null;
    const amount = new Prisma.Decimal(dto.amount);

    if (normalizedIdempotencyKey) {
      const existing = await this.findWalletTopUpByIdempotencyKey(
        userId,
        normalizedIdempotencyKey,
      );
      if (existing) return this.mapCreateWalletTopUpResponse(existing);
    }

    const user = await this.prisma.user.findUnique({
      where: { id: userId },
      select: { email: true },
    });

    if (!user) throw new NotFoundException('User not found');

    const reference = generateReference('WTU');

    let topUp: {
      id: string;
      reference: string;
      amount: Prisma.Decimal;
      currency: string;
      status: string;
    };

    try {
      topUp = await this.prisma.walletTopUp.create({
        data: {
          userId,
          amount,
          currency: 'IDR',
          reference,
          status: 'PENDING',
          idempotencyKey: normalizedIdempotencyKey,
        },
        select: {
          id: true,
          reference: true,
          amount: true,
          currency: true,
          status: true,
        },
      });
    } catch (error) {
      const isIdempotencyConflict =
        normalizedIdempotencyKey &&
        error instanceof Prisma.PrismaClientKnownRequestError &&
        error.code === 'P2002';

      if (!isIdempotencyConflict) throw error;

      const existing = await this.findWalletTopUpByIdempotencyKey(
        userId,
        normalizedIdempotencyKey,
      );

      if (!existing) throw error;
      return this.mapCreateWalletTopUpResponse(existing);
    }

    try {
      const invoice = await this.xenditService.createInvoice({
        externalId: topUp.reference,
        amount: dto.amount,
        payerEmail: user.email,
        description: `Wallet top up ${topUp.reference}`,
        currency: 'IDR',
      });

      const updatedTopUp = await this.prisma.walletTopUp.update({
        where: { id: topUp.id },
        data: {
          xenditInvoiceId: invoice.id,
          xenditInvoiceUrl: invoice.invoiceUrl,
          expiredAt: invoice.expiryDate ? new Date(invoice.expiryDate) : null,
        },
        select: {
          id: true,
          reference: true,
          amount: true,
          currency: true,
          status: true,
          xenditInvoiceUrl: true,
        },
      });

      return this.mapCreateWalletTopUpResponse(updatedTopUp);
    } catch (error) {
      await this.prisma.walletTopUp.update({
        where: { id: topUp.id },
        data: { status: 'FAILED' },
      });
      throw error;
    }
  }

  private async findWalletTopUpByIdempotencyKey(
    userId: string,
    idempotencyKey: string,
  ) {
    return this.prisma.walletTopUp.findUnique({
      where: {
        userId_idempotencyKey: { userId, idempotencyKey },
      },
      select: {
        id: true,
        reference: true,
        amount: true,
        currency: true,
        status: true,
        xenditInvoiceUrl: true,
      },
    });
  }

  private mapCreateWalletTopUpResponse(topUp: {
    id: string;
    reference: string;
    amount: Prisma.Decimal;
    currency: string;
    status: string;
    xenditInvoiceUrl: string | null;
  }): CreateWalletTopUpResponseDto {
    return {
      id: topUp.id,
      reference: topUp.reference,
      status: topUp.status,
      amount: topUp.amount.toString(),
      currency: topUp.currency,
      invoiceUrl: topUp.xenditInvoiceUrl,
    };
  }
}

5.4 Payments Controller

Create src/payments/payments.controller.ts:

import {
  Body,
  Controller,
  Headers,
  Post,
  Req,
  UnauthorizedException,
  UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard';
import { PaymentsService } from './payments.service';
import {
  CreateWalletTopUpDto,
  CreateWalletTopUpResponseDto,
} from './dto/create-wallet-top-up.dto';

@Controller('payments')
export class PaymentsController {
  constructor(private readonly paymentsService: PaymentsService) {}

  @Post('wallet-top-ups')
  @UseGuards(JwtAccessGuard)
  async createWalletTopUp(
    @Req() req: Request & { user?: { sub: string } },
    @Headers('idempotency-key') idempotencyKey: string | undefined,
    @Body() dto: CreateWalletTopUpDto,
  ): Promise<CreateWalletTopUpResponseDto> {
    if (!req.user?.sub) {
      throw new UnauthorizedException('Invalid access token payload');
    }

    return this.paymentsService.createWalletTopUp(
      req.user.sub,
      dto,
      idempotencyKey,
    );
  }
}

Here's the full flow when this endpoint is called:

  1. JwtAccessGuard validates the Bearer token and attaches req.user

  2. We check for an existing top-up by idempotencyKey — if found, return it immediately

  3. We create a WalletTopUp record in DB with status PENDING

  4. We call Xendit to create an invoice using the reference as external_id

  5. We update the record with xenditInvoiceId, xenditInvoiceUrl, and expiredAt

  6. We return the invoiceUrl to the client so the user can proceed to pay

If the Xendit call fails, the top-up is marked as FAILED so it doesn't remain stuck in PENDING forever.

6. Idempotency Key

When a user taps "Top Up" and the network drops before they get a response, what happens if they tap again? Without idempotency, two invoices get created for the same intent. With idempotency, the second request detects the duplicate and returns the original response instead.

How it works

The client sends an optional Idempotency-Key header with each top-up request. The value can be anything unique — typically a UUID generated on the client side.

POST /api/v1/payments/wallet-top-ups
Authorization: Bearer <token>
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

{
  "amount": 50000
}

On the backend, we store the idempotencyKey in the WalletTopUp table with a composite unique constraint:

@@unique([userId, idempotencyKey])

The userId is part of the constraint intentionally — the same key from two different users should not be treated as a duplicate.

The lookup flow

Before creating anything, we check if a top-up with the same userId + idempotencyKey already exists:

if (normalizedIdempotencyKey) {
  const existing = await this.findWalletTopUpByIdempotencyKey(
    userId,
    normalizedIdempotencyKey,
  );
  if (existing) return this.mapCreateWalletTopUpResponse(existing);
}

If found, we return the existing record immediately — no new invoice is created, no duplicate charge.

Race condition handling

There's a subtle edge case: two requests with the same key arrive at exactly the same time. Both pass the initial lookup (nothing exists yet), and both attempt to insert. The database unique constraint will reject one of them with a P2002 error. We catch that and fall back to fetching the existing record:

} catch (error) {
  const isIdempotencyConflict =
    normalizedIdempotencyKey &&
    error instanceof Prisma.PrismaClientKnownRequestError &&
    error.code === 'P2002';

  if (!isIdempotencyConflict) throw error;

  const existing = await this.findWalletTopUpByIdempotencyKey(
    userId,
    normalizedIdempotencyKey,
  );

  if (!existing) throw error;
  return this.mapCreateWalletTopUpResponse(existing);
}

This makes idempotency safe even under concurrent requests.

Idempotency key is optional

If the client doesn't send the header, idempotencyKey is stored as null and the uniqueness constraint is not enforced — each request creates a new top-up. This is fine for clients that manage deduplication on their own side.

7. Webhook Handler

When a user completes payment on the Xendit invoice page, Xendit sends a POST request to our webhook endpoint with the invoice status. This is how we know when to credit the user's wallet.

7.1 DTO

Create src/payments/dto/xendit-invoice-webhook.dto.ts:

import { Type } from 'class-transformer';
import {
  IsBoolean,
  IsEmail,
  IsNumber,
  IsObject,
  IsOptional,
  IsString,
} from 'class-validator';

export class XenditInvoiceWebhookDto {
  @IsString()
  id!: string;

  @IsString()
  external_id!: string;

  @IsString()
  status!: string;

  @IsOptional()
  @Type(() => Number)
  @IsNumber()
  amount?: number;

  @IsOptional()
  @Type(() => Number)
  @IsNumber()
  paid_amount?: number;

  @IsOptional()
  @IsString()
  currency?: string;

  @IsOptional()
  @IsString()
  paid_at?: string;

  @IsOptional()
  @IsString()
  user_id?: string;

  @IsOptional()
  @IsBoolean()
  is_high?: boolean;

  @IsOptional()
  @IsString()
  payment_method?: string;

  @IsOptional()
  @IsString()
  merchant_name?: string;

  @IsOptional()
  @IsString()
  bank_code?: string;

  @IsOptional()
  @IsEmail()
  payer_email?: string;

  @IsOptional()
  @IsString()
  description?: string;

  @IsOptional()
  @IsNumber()
  adjusted_received_amount?: number;

  @IsOptional()
  @IsNumber()
  fees_paid_amount?: number;

  @IsOptional()
  @IsString()
  updated?: string;

  @IsOptional()
  @IsString()
  created?: string;

  @IsOptional()
  @IsString()
  payment_channel?: string;

  @IsOptional()
  @IsString()
  payment_destination?: string;

  @IsOptional()
  @IsString()
  payment_id?: string;

  @IsOptional()
  @IsString()
  payment_method_id?: string;

  @IsOptional()
  @IsObject()
  payment_details?: Record<string, unknown>;
}

export class XenditWebhookResponseDto {
  received!: boolean;
  credited!: boolean;
  status!: string;
}

XenditInvoiceWebhookDto maps all possible fields from the Xendit invoice webhook payload. Most fields are optional because Xendit only sends certain fields depending on the payment status and method. The three required fields — id, external_id, and status — are the minimum we need to process any webhook event.

Note that XenditInvoiceWebhookDto is not used directly as a @Body() parameter in the controller. Instead, the controller receives the raw body as Record<string, unknown> and the service manually parses and validates it using parseXenditInvoiceWebhook(). This gives us control over the error messages when required fields are missing.

XenditWebhookResponseDto is the response we send back to Xendit after processing the webhook.

7.2 Webhook Service

Before adding the methods, make sure to update the imports at the top of src/payments/payments.service.ts:

import {
  BadRequestException,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { Prisma } from 'src/generated/prisma/client';
import { generateReference } from 'src/common/utils/reference.util';
import { PrismaService } from 'src/prisma.service';
import { XenditService } from './xendit/xendit.service';
import {
  CreateWalletTopUpDto,
  CreateWalletTopUpResponseDto,
} from './dto/create-wallet-top-up.dto';
import {
  XenditInvoiceWebhookDto,
  XenditWebhookResponseDto,
} from './dto/xendit-invoice-webhook.dto';

Then add the following methods to the PaymentsService class:

async handleXenditInvoiceWebhook(
  callbackToken: string | undefined,
  body: Record<string, unknown>,
): Promise<XenditWebhookResponseDto> {
  this.xenditService.verifyCallbackToken(callbackToken);

  const dto = this.parseXenditInvoiceWebhook(body);
  const status = dto.status.toUpperCase();

  if (status === 'PAID' || status === 'SETTLED') {
    const credited = await this.creditPaidWalletTopUp(dto);
    return { received: true, credited, status: 'PAID' };
  }

  if (status === 'EXPIRED') {
    await this.markWalletTopUpStatus(dto.external_id, 'EXPIRED');
    return { received: true, credited: false, status: 'EXPIRED' };
  }

  if (status === 'FAILED') {
    await this.markWalletTopUpStatus(dto.external_id, 'FAILED');
    return { received: true, credited: false, status: 'FAILED' };
  }

  return { received: true, credited: false, status };
}

private async creditPaidWalletTopUp(
  dto: XenditInvoiceWebhookDto,
): Promise<boolean> {
  return this.prisma.$transaction(async (tx) => {
    const topUp = await tx.walletTopUp.findUnique({
      where: { reference: dto.external_id },
      select: {
        id: true,
        userId: true,
        amount: true,
        currency: true,
        status: true,
        reference: true,
      },
    });

    if (!topUp) throw new NotFoundException('Wallet top-up not found');

    if (topUp.status === 'PAID') return false;

    if (topUp.status !== 'PENDING') {
      throw new BadRequestException('Wallet top-up is not payable');
    }

    this.assertWebhookPaymentMatchesTopUp(dto, topUp);

    const paidTopUp = await tx.walletTopUp.updateMany({
      where: { id: topUp.id, status: 'PENDING' },
      data: {
        status: 'PAID',
        xenditInvoiceId: dto.id,
        paidAt: dto.paid_at ? new Date(dto.paid_at) : new Date(),
      },
    });

    if (paidTopUp.count === 0) return false;

    const updatedWallet = await tx.wallet.update({
      where: { userId: topUp.userId },
      data: { balance: { increment: topUp.amount } },
      select: { id: true, balance: true },
    });

    await tx.walletTransaction.create({
      data: {
        walletId: updatedWallet.id,
        type: 'TOP_UP',
        amount: topUp.amount,
        balanceAfter: updatedWallet.balance,
        description: `Xendit wallet top-up ${topUp.reference}`,
        reference: topUp.reference,
      },
    });

    return true;
  });
}

private async markWalletTopUpStatus(
  reference: string,
  status: 'EXPIRED' | 'FAILED',
): Promise<void> {
  await this.prisma.walletTopUp.updateMany({
    where: { reference, status: 'PENDING' },
    data: { status },
  });
}

private assertWebhookPaymentMatchesTopUp(
  dto: XenditInvoiceWebhookDto,
  topUp: { amount: Prisma.Decimal; currency: string },
): void {
  const paidAmount = dto.paid_amount ?? dto.amount;

  if (paidAmount === undefined) {
    throw new BadRequestException('Xendit paid amount is missing');
  }

  if (!new Prisma.Decimal(paidAmount).equals(topUp.amount)) {
    throw new BadRequestException('Xendit paid amount does not match top-up amount');
  }

  if (dto.currency && dto.currency !== topUp.currency) {
    throw new BadRequestException('Xendit currency does not match top-up currency');
  }
}

private parseXenditInvoiceWebhook(
  body: Record<string, unknown>,
): XenditInvoiceWebhookDto {
  if (typeof body.id !== 'string') {
    throw new BadRequestException('Xendit invoice id is required');
  }
  if (typeof body.external_id !== 'string') {
    throw new BadRequestException('Xendit external_id is required');
  }
  if (typeof body.status !== 'string') {
    throw new BadRequestException('Xendit status is required');
  }

  return {
    id: body.id,
    external_id: body.external_id,
    status: body.status,
    amount: typeof body.amount === 'number' ? body.amount : undefined,
    paid_amount: typeof body.paid_amount === 'number' ? body.paid_amount : undefined,
    currency: typeof body.currency === 'string' ? body.currency : undefined,
    paid_at: typeof body.paid_at === 'string' ? body.paid_at : undefined,
  };
}

7.3 Webhook Controller

Before adding the webhook endpoint, make sure to update the imports at the top of src/payments/payments.controller.ts:

import {
  Body,
  Controller,
  Headers,
  Post,
  Req,
  UnauthorizedException,
  UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard';
import { PaymentsService } from './payments.service';
import {
  CreateWalletTopUpDto,
  CreateWalletTopUpResponseDto,
} from './dto/create-wallet-top-up.dto';
import { XenditWebhookResponseDto } from './dto/xendit-invoice-webhook.dto';

Then add the webhook endpoint to the PaymentsController class:

@Post('webhooks/xendit')
async handleXenditWebhook(
  @Headers('x-callback-token') callbackToken: string | undefined,
  @Body() body: Record<string, unknown>,
): Promise<XenditWebhookResponseDto> {
  return this.paymentsService.handleXenditInvoiceWebhook(callbackToken, body);
}

Notice there is no @UseGuards(JwtAccessGuard) here — this endpoint is intentionally public because the caller is Xendit's server, not our users. Authentication is handled by verifying the x-callback-token header instead.

7.4 How the webhook flow works

Xendit → POST /payments/webhooks/xendit
         x-callback-token: <XENDIT_WEBHOOK_TOKEN>
         body: { id, external_id, status, paid_amount, ... }
              ↓
         verifyCallbackToken()      — reject if token mismatch
              ↓
         parseXenditInvoiceWebhook() — validate required fields
              ↓
         status === 'PAID'?
           → creditPaidWalletTopUp() — DB transaction:
               1. Find top-up by external_id (our reference)
               2. assertWebhookPaymentMatchesTopUp() — amount & currency check
               3. updateMany with status check (prevents double credit)
               4. Increment wallet balance
               5. Create WalletTransaction record
         status === 'EXPIRED' / 'FAILED'?
           → markWalletTopUpStatus() — update status only

The updateMany with where: { status: 'PENDING' } inside the transaction is the key safety mechanism — even if Xendit sends the same webhook twice, the second call will find count === 0 and return false without crediting the wallet again.

7.5 Wallet Endpoint

We need a GET /wallets/me endpoint to verify that the wallet balance was credited after a successful payment.

Create src/wallets/dto/me-wallet.dto.ts:

export class MeWalletResponseDto {
  id!: string;
  balance!: string;
  currency!: string;
  createdAt!: Date;
  updatedAt!: Date;
}

Update src/wallets/wallets.service.ts:

import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from 'src/prisma.service';
import { MeWalletResponseDto } from './dto/me-wallet.dto';

@Injectable()
export class WalletsService {
  constructor(private readonly prisma: PrismaService) {}

  async me(userId: string): Promise<MeWalletResponseDto> {
    const wallet = await this.prisma.wallet.findUnique({
      where: { userId },
      select: {
        id: true,
        balance: true,
        currency: true,
        createdAt: true,
        updatedAt: true,
      },
    });

    if (!wallet) {
      throw new NotFoundException('Wallet not found');
    }

    return {
      id: wallet.id,
      balance: wallet.balance.toString(),
      currency: wallet.currency,
      createdAt: wallet.createdAt,
      updatedAt: wallet.updatedAt,
    };
  }
}

Update src/wallets/wallets.controller.ts:

import {
  Controller,
  Get,
  Req,
  UnauthorizedException,
  UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard';
import { WalletsService } from './wallets.service';
import { MeWalletResponseDto } from './dto/me-wallet.dto';

type AccessTokenPayload = { sub: string };

@Controller('wallets')
export class WalletsController {
  constructor(private readonly walletsService: WalletsService) {}

  @UseGuards(JwtAccessGuard)
  @Get('me')
  async me(
    @Req() req: Request & { user?: AccessTokenPayload },
  ): Promise<MeWalletResponseDto> {
    if (!req.user?.sub) {
      throw new UnauthorizedException('Invalid access token payload');
    }

    return this.walletsService.me(req.user.sub);
  }
}

8. Testing the Flow

Before testing, make sure your NestJS server is running:

npm run start:dev

We also need to expose our local server to the internet so Xendit can send webhook callbacks. Install and run ngrok:

ngrok http 3000

Copy the forwarding URL (e.g. https://abc123.ngrok.io) and set it as your webhook URL in the Xendit Dashboard → Settings → Webhooks → Invoice. Set the callback token to match your XENDIT_WEBHOOK_TOKEN env variable.


8.1 Register a User

POST /api/v1/auth/register
Content-Type: application/json

{
  "email": "[email protected]",
  "username": "johndoe",
  "password": "password123"
}

Response:

{
  "id": "cm123abc",
  "email": "[email protected]",
  "username": "johndoe",
  "name": null,
  "role": "USER",
  "createdAt": "2026-06-14T00:00:00.000Z"
}

8.2 Login

POST /api/v1/auth/login
Content-Type: application/json

{
  "username": "johndoe",
  "password": "password123"
}

Response:

{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "tokenType": "Bearer",
  "expiresIn": 900,
  "user": {
    "id": "cm123abc",
    "email": "[email protected]",
    "username": "johndoe",
    "name": null,
    "role": "USER"
  }
}

Copy the accessToken — you'll need it for all subsequent requests.


8.3 Check Initial Wallet Balance

Before creating a top-up, let's check the current wallet balance. Since the wallet was created automatically during registration, the balance should be 0.00.

GET /api/v1/wallets/me
Authorization: Bearer <accessToken>

Response:

{
  "id": "cm789ghi",
  "balance": "0.00",
  "currency": "IDR",
  "createdAt": "2026-06-14T00:00:00.000Z",
  "updatedAt": "2026-06-14T00:00:00.000Z"
}


8.4 Create a Wallet Top-Up

The Idempotency-Key header is generated on the client side — typically a UUID. In a real frontend application, this would be generated automatically using crypto.randomUUID() before the request is sent. For testing purposes, you can generate one manually or use any UUID generator, then paste it into the Headers tab in Postman. For now, you can just use this value:

550e8400-e29b-41d4-a716-446655440000

Then set the Authorization header with the access token from the login response:

Now send the request:

POST /api/v1/payments/wallet-top-ups
Authorization: Bearer <accessToken>
Content-Type: application/json
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

{
  "amount": 50000
}

Response:

{
  "id": "cm456def",
  "reference": "WTU-20260614-A3F9C2B1D4E7F8A2",
  "status": "PENDING",
  "amount": "50000",
  "currency": "IDR",
  "invoiceUrl": "https://checkout.xendit.co/v2/..."
}

Notice the status is PENDING — this means the invoice has been created on Xendit's side, but the user has not paid yet.

Open the invoiceUrl in your browser to see the Xendit checkout page:


8.5 Check Wallet Balance Before Payment

Let's check the wallet balance again. Even though we just created a top-up request, the balance should still be 0.00 — because the wallet is only credited after Xendit confirms the payment via webhook, not when the invoice is created.

GET /api/v1/wallets/me
Authorization: Bearer <accessToken>

Response:

{
  "id": "cm789ghi",
  "balance": "0.00",
  "currency": "IDR",
  "createdAt": "2026-06-14T00:00:00.000Z",
  "updatedAt": "2026-06-14T00:00:00.000Z"
}

This is the expected behavior. The balance does not change until the payment is actually confirmed. This is important — it means we never credit a wallet speculatively. We only credit it when we have proof of payment from Xendit.


8.6 Simulate Payment

Open the invoiceUrl from the previous response in your browser. You'll see the Xendit invoice page with available payment methods.

Select any payment method. Since we are using Xendit's test mode, you will see a "Click here to simulate payment" button instead of an actual payment form.

Click the button to simulate a successful payment. Once confirmed, the invoice page will show a success screen.

Once paid, Xendit will automatically send a webhook to your ngrok URL.

You can confirm the webhook was received and processed in two ways:

1. ngrok Web Interface

Open http://localhost:4040 in your browser. You should see an incoming POST /api/v1/payments/webhooks/xendit request with a 200 response status.

2. Xendit Dashboard

Go to Xendit Dashboard → Developers → Webhook Logs. You should see the webhook event listed there with the invoice status and the response from your server.


8.7 Check Wallet Balance After Payment

Now let's check the wallet balance one more time:

GET /api/v1/wallets/me
Authorization: Bearer <accessToken>

Response:

{
  "id": "cm789ghi",
  "balance": "50000.00",
  "currency": "IDR",
  "createdAt": "2026-06-14T00:00:00.000Z",
  "updatedAt": "2026-06-14T00:00:00.000Z"
}

The balance is now 50000.00 — confirming that the wallet was credited automatically after Xendit sent the webhook callback.


This is the complete payment flow:

Register → Login → Check Balance (0.00)
→ Create Top-Up (PENDING, balance still 0.00)
→ Pay via Xendit Invoice
→ Xendit sends webhook → wallet credited automatically
→ Check Balance (50000.00) ✓

9. Conclusion

In this tutorial, we built a complete wallet top-up flow using NestJS and Xendit. Here's a quick recap of what we covered:

  • Project Setup — installing dependencies, configuring environment variables, and setting up Prisma

  • Database Schema — designing models for User, Wallet, WalletTopUp, and WalletTransaction with proper decimal handling for monetary values

  • Authentication — a minimal JWT-based auth with a guard that protects the top-up endpoint

  • Xendit Service — a thin wrapper around the Xendit REST API using native fetch, with Basic Auth header construction and callback token verification

  • Create Wallet Top-Up — creating a PENDING record in the database before calling Xendit, so we always have a record regardless of what happens next

  • Idempotency Key — preventing duplicate invoices from network retries using a composite unique constraint at the database level

  • Webhook Handler — verifying the callback token, parsing the payload, crediting the wallet inside a database transaction, and guarding against double credit with updateMany + status check

The pattern used here — create DB record first, then call external API — is worth carrying into any integration with third-party payment providers. It gives you a recovery point if the external call fails, and makes it easier to audit what happened to every payment request.

The full source code for this tutorial is available at: https://github.com/mhmdnurf/nestjs-payment-gateway