Node.js · Express · TypeScript

Build a Node.js UPI checkout with a verifiable payment boundary

Create payment orders from trusted server data, preserve exact webhook bytes, commit one idempotent state transition, and recover safely from network uncertainty.

Request lifecycle

Four boundaries, one source of truth

01

Price

Calculate the cart total on your server and store it as integer paise.

02

Create

Call create_order with a stable client_txn_id and a server-only API key.

03

Verify

Authenticate exact raw webhook bytes before parsing or changing state.

04

Commit

Match immutable fields, deduplicate, update, and enqueue fulfilment atomically.

Step 1 · API client

Convert money once and create the order server-side

Use Node 20 or newer for built-in fetch and AbortSignal.timeout. Store VG_API_KEY and VG_WEBHOOK_SECRET in your secret manager; neither belongs in a public environment variable or client bundle.

import crypto from "node:crypto";

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

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

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

async function createGatewayOrder(input: {
  clientTxnId: string;
  amountPaise: bigint;
  description: string;
  customer: {
    name?: string;
    email?: string;
    mobile?: string;
  };
}): Promise<GatewayOrder> {
  const response = await fetch(
    `${requiredEnv("VG_BASE_URL")}/api/v1/create_order`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-API-Key": requiredEnv("VG_API_KEY"),
      },
      body: JSON.stringify({
        client_txn_id: input.clientTxnId,
        amount: paiseToRupees(input.amountPaise),
        p_info: input.description,
        customer_name: input.customer.name,
        customer_email: input.customer.email,
        customer_mobile: input.customer.mobile,
        redirect_url:
          `${requiredEnv("PUBLIC_STORE_URL")}/orders/${encodeURIComponent(
            input.clientTxnId,
          )}`,
        callback_url:
          `${requiredEnv("PUBLIC_STORE_URL")}/webhooks/vyapargateway`,
      }),
      signal: AbortSignal.timeout(10_000),
    },
  );

  const payload = await response.json() as {
    status?: boolean;
    msg?: string;
    detail?: string;
    data?: GatewayOrder;
  };

  if (!response.ok || payload.status !== true || !payload.data) {
    throw new Error(
      payload.detail ?? payload.msg ?? "Gateway rejected the order",
    );
  }
  if (
    payload.data.client_txn_id !== input.clientTxnId ||
    payload.data.currency !== "INR"
  ) {
    throw new Error("Gateway response does not match the local order");
  }

  return payload.data;
}

Step 2 · checkout endpoint

Make the local order idempotent before the network call

The browser submits a cart identifier, never a trusted amount. Your repository methods should enforce ownership and uniqueness; the pricing and persistence functions below are explicit application-layer boundaries around the complete payment flow.

import express from "express";

const app = express();

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

app.post("/api/checkout", async (req, res) => {
  const customer = await requireAuthenticatedCustomer(req);
  const cart = await loadOwnedCart(req.body.cartId, customer.id);
  const pricedCart = await priceCartFromCatalogue(cart);

  // The repository enforces UNIQUE(customer_id, cart_id) and
  // UNIQUE(client_txn_id). A retry loads the same pending order.
  const order = await orders.insertOrLoadPending({
    customerId: customer.id,
    cartId: cart.id,
    amountPaise: pricedCart.totalPaise,
  });

  try {
    const gateway = await createGatewayOrder({
      clientTxnId: order.clientTxnId,
      amountPaise: order.amountPaise,
      description: `Order ${order.publicNumber}`,
      customer: {
        name: customer.name,
        email: customer.email,
        mobile: customer.mobile,
      },
    });

    await orders.attachGatewayOrder(order.id, {
      gatewayOrderId: gateway.order_id,
      paymentUrl: gateway.payment_url,
      expiresAt: gateway.expires_at,
    });

    return res.status(201).json({
      orderId: order.publicNumber,
      paymentUrl: gateway.payment_url,
      expiresAt: gateway.expires_at,
    });
  } catch (error) {
    req.log?.error({ error, orderId: order.id }, "checkout failed");
    return res.status(502).json({
      error: "Payment checkout is temporarily unavailable",
    });
  }
});
A timeout is not a failed payment. Keep the same client_txn_id, then retry or reconcile. Creating a new ID can present the customer with two valid payment requests for one cart.

Step 3 · webhook

Verify timestamp.rawBody before JSON parsing

VyaparGateway sends a lowercase hexadecimal HMAC-SHA256 signature. Register this route before global JSON middleware. The transaction records the event and state change together; an outbox worker performs fulfilment only after commit.

type GatewayEvent = {
  event: string;
  order_id: string;
  client_txn_id: string;
  status: "success" | "failed" | "expired";
  amount: number;
  currency: string;
  upi_txn_id: string;
  timestamp: number;
};

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

    if (
      !/^\d{10}$/.test(timestamp) ||
      !Number.isSafeInteger(timestampNumber) ||
      Math.abs(Math.floor(Date.now() / 1000) - timestampNumber) > 300
    ) {
      return res.status(401).send("Invalid webhook timestamp");
    }

    const expected = crypto
      .createHmac("sha256", requiredEnv("VG_WEBHOOK_SECRET"))
      .update(timestamp)
      .update(".")
      .update(rawBody)
      .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 webhook signature");
    }

    let event: GatewayEvent;
    try {
      event = JSON.parse(rawBody.toString("utf8")) as GatewayEvent;
    } catch {
      return res.status(400).send("Invalid JSON");
    }

    await database.transaction(async (tx) => {
      const order = await tx.orders.lockByClientTxnId(
        event.client_txn_id,
      );
      if (!order) throw new Error("Unknown payment order");

      const eventKey = [
        event.event,
        event.order_id,
        event.upi_txn_id || event.status,
      ].join(":");
      const inserted = await tx.paymentEvents.insertOnce({
        eventKey,
        rawBody: rawBody.toString("utf8"),
      });
      if (!inserted) return;

      if (
        event.order_id !== order.gatewayOrderId ||
        event.currency !== "INR" ||
        rupeesToPaise(String(event.amount)) !== order.amountPaise
      ) {
        throw new Error("Webhook does not match local order");
      }

      if (event.status === "success") {
        await tx.orders.markPaidIfPending(order.id, event.upi_txn_id);
        await tx.outbox.enqueueOnce(
          `fulfil-order:${order.id}`,
          { orderId: order.id },
        );
      } else {
        await tx.orders.recordGatewayStatus(order.id, event.status);
      }
    });

    return res.status(204).send();
  },
);

// Register JSON parsing only after the raw-body webhook route.
app.use(express.json({ limit: "32kb" }));
Node.js constant-time comparison reference →

Step 4 · recovery

Reconcile pending and ambiguous orders

Call status from a backend job after an ambiguous create response, a webhook outage, or an overdue pending state. Apply the same amount, currency, mapping, idempotency, and transition checks used for a webhook.

async function checkGatewayOrder(input: {
  orderId?: string;
  clientTxnId?: string;
}): Promise<GatewayOrder> {
  if (!input.orderId && !input.clientTxnId) {
    throw new Error("An order identifier is required");
  }

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

  const payload = await response.json() as {
    status?: boolean;
    msg?: string;
    data?: GatewayOrder;
  };
  if (!response.ok || payload.status !== true || !payload.data) {
    throw new Error(payload.msg ?? "Unable to reconcile payment");
  }
  return payload.data;
}

Release checklist

Test failure paths, not only the happy path

Secret isolation

API key and webhook secret are server-only, scoped, rotated, and redacted from logs.

Money integrity

Cart is repriced on the server; expected and received amounts compare as integer paise.

Replay defence

Five-minute timestamp window, constant-time HMAC check, and a unique event key are enforced.

Order mapping

Gateway order ID and client transaction ID both resolve to the same tenant-owned order.

State machine

Only an allowed final success can move pending to paid; paid is never silently reversed.

Recovery

Timeout, duplicate, delayed, out-of-order, malformed, and mismatched events have automated tests.

Questions

Node.js integration FAQ

Can I call the create-order API from browser JavaScript?
No. The X-API-Key is a server credential. Your browser should call an authenticated application endpoint; that backend prices the cart, creates the gateway order, and returns only customer-safe payment fields.
Why must Express preserve the raw webhook body?
The signature covers the timestamp, a period, and the exact JSON bytes sent by VyaparGateway. Parsing and re-serialising JSON can change whitespace or key order, producing a different HMAC.
How should amounts be represented in Node.js?
Keep money as integer paise in your database and application logic. Convert to a two-decimal rupee string only at the gateway boundary, avoiding binary floating-point arithmetic.
What should happen when create_order times out?
Treat the result as unknown. Reuse the same client_txn_id and reconcile with check_order_status; do not generate a new payment attempt merely because the HTTP response was lost.
Is a redirect back to my site proof of payment?
No. A redirect is customer navigation. Fulfil only after a verified success webhook or an authenticated status response matches the stored order ID, client transaction ID, INR amount, and allowed state transition.

Keep payment truth on the server.

Reuse stable identifiers, verify exact bytes, and fulfil after a durable commit.

View all integrations