developer

How to Build a Custom UPI Payment Gateway with Node.js and Python

Build a secure UPI checkout orchestration layer in Node.js or Python with dynamic payment orders, signed webhooks, idempotency, and reconciliation.

VE VyaparGateway Engineering Payments Platform Engineering 6 min read

Fact-checked and updated

How to Build a Custom UPI Payment Gateway with Node.js and Python guide
UPI payment gateway Node.js payments Python payments payment API tutorial

“Build a custom UPI payment gateway” can mean two very different projects.

The first is a merchant integration layer: your software creates orders through an approved payment provider, presents UPI checkout, verifies server events, and updates your business records. A small engineering team can build this safely.

The second is a regulated payment network or acquiring platform: your company directly participates in payment processing, merchant onboarding, settlement, disputes, risk, and compliance. That requires institutional partnerships, approvals, operations, and controls far beyond an application tutorial.

This guide builds the first: a provider-backed UPI checkout orchestration service. It uses VyaparGateway’s REST order flow as the concrete API, with complete Node.js and Python examples. If you use another approved provider, keep the architecture but replace request fields, signatures, status values, credentials, and commercial assumptions with that provider’s current documentation.

Define what you are building

An NPCI-compatible UPI payment URI uses the upi://pay scheme and parameters such as payee address, payee name, transaction reference, note, amount, and currency. A simplified example is:

upi://pay?pa=merchant%40bank&pn=Example%20Store&tr=ord_123&tn=Order%20123&am=499.00&cu=INR

The UPI linking specification documents the URI structure. NPCI’s UPI product page and UPI circulars are the right starting points for current network context.

Generating the string is not the hard part. A production commerce flow also needs:

  • a merchant identity and approved acceptance arrangement;
  • unique, expiring payment orders;
  • a trustworthy success signal;
  • amount and order-reference matching;
  • duplicate-event handling;
  • customer redirect handling;
  • retries and status reconciliation;
  • refunds and support procedures;
  • settlement reconciliation;
  • secret rotation, access control, logs, and alerts.

A QR image cannot observe the customer’s bank account. A browser redirect can be forged. A customer screenshot can be altered or refer to a different transaction. Payment success must come from authenticated server evidence.

Browser or mobile client
  └── untrusted presentation layer


Merchant checkout API
  ├── calculates price from server-side catalogue
  ├── owns API key and webhook secret
  ├── creates payment order
  └── owns final business state


Approved payment orchestration/provider API
  ├── returns hosted URL, QR or UPI intent
  ├── observes payment through supported provider channels
  └── sends signed event / exposes authenticated status

Never accept a client-supplied final amount without recalculating it from server-side cart data. Coupon validity, tax, shipping, inventory, and rounding belong to the backend.

Design the order data model

Start with an explicit state machine:

created ──► pending ──► paid
   │           ├─────► expired
   │           └─────► failed
   └─────────────────► cancelled

paid ──► partially_refunded ──► refunded

Do not let a late pending event move a paid order backwards. Do not let expired overwrite a verified success received near the expiry boundary.

A PostgreSQL foundation:

CREATE TABLE checkout_orders (
  id UUID PRIMARY KEY,
  merchant_order_id VARCHAR(64) NOT NULL UNIQUE,
  provider_order_id VARCHAR(128) UNIQUE,
  amount_paise BIGINT NOT NULL CHECK (amount_paise > 0),
  currency CHAR(3) NOT NULL CHECK (currency = 'INR'),
  status VARCHAR(24) NOT NULL,
  payment_url TEXT,
  expires_at TIMESTAMPTZ,
  paid_at TIMESTAMPTZ,
  version INTEGER NOT NULL DEFAULT 0,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE payment_events (
  id BIGSERIAL PRIMARY KEY,
  dedupe_key VARCHAR(160) NOT NULL UNIQUE,
  provider_order_id VARCHAR(128) NOT NULL,
  event_type VARCHAR(64) NOT NULL,
  payload_sha256 CHAR(64) NOT NULL,
  received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Store amounts as integer paise. For an API that accepts decimal rupees, convert at the boundary:

function formatRupees(paise: bigint): string {
  if (paise <= 0n) throw new Error("Amount must be positive");
  return `${paise / 100n}.${(paise % 100n)
    .toString()
    .padStart(2, "0")}`;
}
from decimal import Decimal

def format_rupees(paise: int) -> str:
    if paise <= 0:
        raise ValueError("Amount must be positive")
    return str((Decimal(paise) / Decimal(100)).quantize(Decimal("0.01")))

Use one immutable merchant_order_id as the provider’s client_txn_id. A retry for the same cart uses the same identifier. A genuinely new checkout attempt gets a new order.

Implement the Node.js service

This example uses Express and Node’s built-in fetch. The API key is read only by the server.

import crypto from "node:crypto";
import express from "express";

const app = express();

function env(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing environment variable: ${name}`);
  return value;
}

type GatewayResponse = {
  status: boolean;
  msg: string;
  data: {
    order_id: string;
    client_txn_id: string;
    amount: number;
    currency: "INR";
    status: string;
    payment_url: string;
    expires_at: string;
    expires_in: string;
    qr_code: string;
    upi_string: string;
    upi_intent: Record<string, string>;
  };
};

async function createGatewayOrder(input: {
  merchantOrderId: string;
  amountPaise: bigint;
  customerName?: string;
  customerEmail?: string;
  customerMobile?: string;
}): Promise<GatewayResponse["data"]> {
  const response = await fetch(
    `${env("VG_BASE_URL")}/api/v1/create_order`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-API-Key": env("VG_API_KEY"),
      },
      body: JSON.stringify({
        client_txn_id: input.merchantOrderId,
        amount: formatRupees(input.amountPaise),
        p_info: `Order ${input.merchantOrderId}`,
        customer_name: input.customerName,
        customer_email: input.customerEmail,
        customer_mobile: input.customerMobile,
        redirect_url:
          `${env("PUBLIC_STORE_URL")}/orders/${encodeURIComponent(
            input.merchantOrderId,
          )}`,
        callback_url:
          `${env("PUBLIC_STORE_URL")}/api/webhooks/vyapargateway`,
      }),
      signal: AbortSignal.timeout(10_000),
    },
  );

  const payload = (await response.json()) as
    | GatewayResponse
    | { detail?: string };

  if (!response.ok || !("status" in payload) || !payload.status) {
    const detail =
      "detail" in payload ? payload.detail : "Gateway rejected the order";
    throw new Error(String(detail));
  }

  if (
    payload.data.client_txn_id !== input.merchantOrderId ||
    payload.data.currency !== "INR"
  ) {
    throw new Error("Gateway response does not match the local order");
  }

  return payload.data;
}

Expose a narrow endpoint to your frontend:

app.use("/api/checkout", express.json({ limit: "32kb" }));

app.post("/api/checkout", async (req, res) => {
  const cart = await loadCartForAuthenticatedCustomer(req);
  const priced = await priceCartOnServer(cart);

  const order = await insertOrLoadPendingOrder({
    cartId: cart.id,
    customerId: cart.customerId,
    amountPaise: priced.totalPaise,
  });

  try {
    const gateway = await createGatewayOrder({
      merchantOrderId: order.merchantOrderId,
      amountPaise: order.amountPaise,
      customerName: cart.customerName,
      customerEmail: cart.customerEmail,
      customerMobile: cart.customerMobile,
    });

    await saveGatewayOrder(order.id, {
      providerOrderId: gateway.order_id,
      paymentUrl: gateway.payment_url,
      expiresAt: gateway.expires_at,
    });

    return res.status(201).json({
      orderId: order.merchantOrderId,
      paymentUrl: gateway.payment_url,
      expiresAt: gateway.expires_at,
    });
  } catch (error) {
    req.log?.error({ error, orderId: order.id }, "checkout creation failed");
    return res.status(502).json({
      error: "Payment checkout is temporarily unavailable",
    });
  }
});

The response deliberately excludes API credentials and internal provider payloads. If the UI needs an inline QR or an app intent, return only those customer-safe fields.

Handle ambiguous timeouts

If the create request times out, do not immediately issue a new merchantOrderId. Retry with the same ID or call /api/v1/check_order_status using client_txn_id. The first request may have succeeded.

Use bounded retries with jitter only for transport errors and retryable HTTP statuses. Do not blindly retry validation failures, missing merchant configuration, authentication errors, or a rejected amount.

Implement the Python service

The same flow in FastAPI with httpx:

import os
from decimal import Decimal
from typing import Any

import httpx
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel, Field

app = FastAPI()

def required_env(name: str) -> str:
    value = os.environ.get(name)
    if not value:
        raise RuntimeError(f"Missing environment variable: {name}")
    return value

class CheckoutRequest(BaseModel):
    cart_id: str = Field(min_length=1, max_length=64)

async def create_gateway_order(
    *,
    merchant_order_id: str,
    amount_paise: int,
    customer: dict[str, str | None],
) -> dict[str, Any]:
    body = {
        "client_txn_id": merchant_order_id,
        "amount": format_rupees(amount_paise),
        "p_info": f"Order {merchant_order_id}",
        "customer_name": customer.get("name"),
        "customer_email": customer.get("email"),
        "customer_mobile": customer.get("mobile"),
        "redirect_url": (
            f"{required_env('PUBLIC_STORE_URL')}/orders/{merchant_order_id}"
        ),
        "callback_url": (
            f"{required_env('PUBLIC_STORE_URL')}"
            "/api/webhooks/vyapargateway"
        ),
    }

    timeout = httpx.Timeout(10.0, connect=3.0)
    async with httpx.AsyncClient(timeout=timeout) as client:
        response = await client.post(
            f"{required_env('VG_BASE_URL')}/api/v1/create_order",
            headers={
                "Content-Type": "application/json",
                "X-API-Key": required_env("VG_API_KEY"),
            },
            json=body,
        )

    try:
        payload = response.json()
    except ValueError as exc:
        raise RuntimeError("Gateway returned invalid JSON") from exc

    if response.is_error or payload.get("status") is not True:
        reason = payload.get("detail") or payload.get("msg")
        raise RuntimeError(str(reason or "Gateway rejected the order"))

    data = payload["data"]
    if (
        data.get("client_txn_id") != merchant_order_id
        or data.get("currency") != "INR"
    ):
        raise RuntimeError("Gateway response does not match local order")

    return data

@app.post("/api/checkout", status_code=201)
async def checkout(payload: CheckoutRequest, request: Request):
    customer = await require_customer(request)
    cart = await load_cart(payload.cart_id, customer.id)
    amount_paise = await calculate_total_paise(cart)

    order = await insert_or_load_pending_order(
        cart_id=cart.id,
        customer_id=customer.id,
        amount_paise=amount_paise,
    )

    try:
        gateway = await create_gateway_order(
            merchant_order_id=order.merchant_order_id,
            amount_paise=order.amount_paise,
            customer={
                "name": customer.name,
                "email": customer.email,
                "mobile": customer.mobile,
            },
        )
    except (httpx.HTTPError, RuntimeError) as exc:
        request.app.state.logger.exception(
            "checkout creation failed",
            extra={"order_id": str(order.id)},
        )
        raise HTTPException(
            status_code=502,
            detail="Payment checkout is temporarily unavailable",
        ) from exc

    await save_gateway_order(
        order.id,
        provider_order_id=gateway["order_id"],
        payment_url=gateway["payment_url"],
        expires_at=gateway["expires_at"],
    )

    return {
        "order_id": order.merchant_order_id,
        "payment_url": gateway["payment_url"],
        "expires_at": gateway["expires_at"],
    }

require_customer, cart pricing, and database functions represent your application layer, not omitted payment logic. They must enforce tenant ownership and calculate the price from trusted catalogue data.

Verify webhooks safely

VyaparGateway signs:

timestamp + "." + exact raw JSON request body

with HMAC-SHA256. Compare the hexadecimal signature in constant time and reject stale timestamps.

Node.js verification

Register the webhook route with a raw parser before any global JSON parser:

app.post(
  "/api/webhooks/vyapargateway",
  express.raw({ type: "application/json", limit: "256kb" }),
  async (req, res) => {
    const raw = req.body as Buffer;
    const timestamp =
      req.header("x-vyapargateway-timestamp") ?? "";
    const signature =
      req.header("x-vyapargateway-signature") ?? "";

    const timestampNumber = Number(timestamp);
    const age = Math.abs(Math.floor(Date.now() / 1000) - timestampNumber);
    if (!Number.isSafeInteger(timestampNumber) || age > 300) {
      return res.status(401).send("Invalid timestamp");
    }

    const expected = crypto
      .createHmac("sha256", env("VG_WEBHOOK_SECRET"))
      .update(timestamp)
      .update(".")
      .update(raw)
      .digest();

    const received = /^[0-9a-f]{64}$/i.test(signature)
      ? Buffer.from(signature, "hex")
      : Buffer.alloc(0);

    if (
      received.length !== expected.length ||
      !crypto.timingSafeEqual(received, expected)
    ) {
      return res.status(401).send("Invalid signature");
    }

    const event = JSON.parse(raw.toString("utf8"));
    await consumeVerifiedEvent(event, raw);
    return res.status(204).send();
  },
);

Python verification

FastAPI exposes the raw request body:

import hashlib
import hmac
import json
import time

from fastapi import Header, Response

@app.post("/api/webhooks/vyapargateway")
async def vyapargateway_webhook(
    request: Request,
    x_vyapargateway_signature: str = Header(default=""),
    x_vyapargateway_timestamp: str = Header(default=""),
):
    raw_body = await request.body()

    try:
        timestamp = int(x_vyapargateway_timestamp)
    except ValueError:
        raise HTTPException(status_code=401, detail="Invalid timestamp")

    if abs(int(time.time()) - timestamp) > 300:
        raise HTTPException(status_code=401, detail="Stale webhook")

    signed = x_vyapargateway_timestamp.encode() + b"." + raw_body
    expected = hmac.new(
        required_env("VG_WEBHOOK_SECRET").encode(),
        signed,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(
        expected,
        x_vyapargateway_signature.lower(),
    ):
        raise HTTPException(status_code=401, detail="Invalid signature")

    try:
        event = json.loads(raw_body)
    except json.JSONDecodeError as exc:
        raise HTTPException(status_code=400, detail="Invalid JSON") from exc

    await consume_verified_event(event, raw_body)
    return Response(status_code=204)

Webhook verification authenticates the message. Your business validation must still compare:

  • client_txn_id and gateway order_id;
  • currency === "INR";
  • amount converted safely to expected paise;
  • an accepted final status;
  • merchant or tenant context where present;
  • whether the current state allows the transition.

Insert a deduplication record and update the order in the same database transaction. Return a successful HTTP response only after the event is durably recorded. Send email, allocate inventory, and call fulfilment systems through an outbox or queue after commit.

Reconcile ambiguous payments

Webhooks can be delayed, duplicated, delivered out of order, or temporarily blocked by your infrastructure. Add an authenticated status client:

async function checkOrderStatus(input: {
  orderId?: string;
  clientTxnId?: string;
}): Promise<GatewayResponse["data"]> {
  if (!input.orderId && !input.clientTxnId) {
    throw new Error("Order identifier required");
  }

  const response = await fetch(
    `${env("VG_BASE_URL")}/api/v1/check_order_status`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-API-Key": env("VG_API_KEY"),
      },
      body: JSON.stringify({
        order_id: input.orderId,
        client_txn_id: input.clientTxnId,
      }),
      signal: AbortSignal.timeout(10_000),
    },
  );

  const result = (await response.json()) as GatewayResponse;
  if (!response.ok || !result.status) {
    throw new Error("Unable to reconcile payment order");
  }
  return result.data;
}

Run reconciliation for:

  • create-order calls with an ambiguous timeout;
  • pending orders beyond the expected checkout window;
  • a customer support query with a transaction reference;
  • webhook outages;
  • daily transaction-versus-order checks.

Do not poll aggressively from the customer’s browser. Use a controlled backend schedule, exponential backoff, and provider limits. The customer order page can query your own database.

Separate payment confirmation from settlement

“Payment successful” and “settled to the bank” are different facts. Store provider transaction references and reconcile them against provider or bank reports. Your daily process should flag:

  • successful payments missing from the merchant report;
  • report entries with no local order;
  • amount or currency mismatches;
  • refunds not reflected in your ledger;
  • duplicated references;
  • settlement totals that differ after documented adjustments.

Escalate discrepancies through the approved provider or bank support path. Do not silently edit ledger records to make totals match.

Use the production-readiness checklist

Before real customer traffic, verify:

Merchant and provider controls

  • The business is approved for the acceptance product it uses.
  • Merchant name, VPA, settlement account, limits, fees, and support ownership are confirmed.
  • Test and live accounts are separate.
  • Direct provider access uses documented APIs, not reverse-engineered app traffic.

Application security

  • API keys and webhook secrets are in a secret manager.
  • Keys never enter browser bundles, mobile packages, URLs, analytics, or logs.
  • TLS is enforced.
  • Webhooks use raw-body HMAC verification and replay limits.
  • Secret rotation supports a short dual-secret transition.
  • Privileged dashboard actions use least privilege and audit logs.
  • Personally identifiable data is minimised and retained under a defined policy.

Payment correctness

  • Prices are calculated on the server.
  • Money uses integer paise or fixed decimals.
  • client_txn_id is unique and stable across retries.
  • Amount, currency, local ID, and provider ID are compared.
  • State transitions are monotonic and transactional.
  • Duplicate and out-of-order events are tested.
  • Fulfilment runs once through a durable queue or outbox.
  • Redirects display status but do not establish payment truth.

Reliability and operations

  • Timeouts are bounded.
  • Retryable and terminal failures are distinguished.
  • Webhook handling responds quickly after durable storage.
  • Status reconciliation recovers missed events.
  • Metrics cover creation latency, conversion, webhook delay, signature failures, duplicate events, and stuck orders.
  • Support can search by local order, provider order, and UPI reference without viewing secrets.
  • Restore procedures and incident ownership are documented.

Test matrix

Run at least:

  1. successful UPI app intent;
  2. successful desktop QR;
  3. customer cancellation;
  4. expired order;
  5. create-order network timeout;
  6. same idempotency key retried;
  7. webhook delivered twice;
  8. events delivered out of order;
  9. invalid signature;
  10. valid signature with stale timestamp;
  11. correct signature but wrong amount;
  12. webhook endpoint outage and retry;
  13. status reconciliation after missed delivery;
  14. provider account disabled;
  15. concurrent attempts for the same cart.

A custom integration should reduce business-specific friction, not recreate the regulated payment network. Keep the provider boundary explicit, make the backend the source of order truth, and design the unhappy paths before optimising the checkout animation.

Direct answers

Frequently asked questions

Can a developer create a UPI payment link without a payment gateway?
A developer can format a UPI deep link or QR for a valid merchant VPA, but that alone does not provide reliable server-side payment confirmation, signed webhooks, refunds, disputes, or reconciliation. Production commerce needs an approved provider or bank relationship for those capabilities.
Does generating a UPI QR prove that a payment was received?
No. A QR only encodes payment instructions. Mark an order paid only after verified provider evidence, such as a signed webhook or authenticated status response matched against the expected order, amount, currency, and merchant.
Should an API key be included in a React or mobile application?
No. A privileged payment API key belongs on your backend. The client should call a narrow endpoint on your server and receive only customer-safe checkout information such as a hosted payment URL, short-lived token, QR, or UPI intent.
Why is an idempotency key required when creating a payment order?
A network timeout does not reveal whether the provider created the order. Retrying with the same stable key lets the server return the original result and prevents duplicate payable orders for one cart.
Which is better for payment integrations, Node.js or Python?
Both are suitable. Choose the runtime your team can operate reliably. Correct decimal handling, secret storage, raw-body signature verification, transactional idempotency, observability, and reconciliation matter more than the language.

Build your payment flow

Explore the API and browser-only merchant tools.

Create UPI checkout orders, verify signed events, or test the free calculators and generators without exposing credentials.