Price
Calculate the cart total on your server and store it as integer paise.
Node.js · Express · TypeScript
Create payment orders from trusted server data, preserve exact webhook bytes, commit one idempotent state transition, and recover safely from network uncertainty.
Request lifecycle
Calculate the cart total on your server and store it as integer paise.
Call create_order with a stable client_txn_id and a server-only API key.
Authenticate exact raw webhook bytes before parsing or changing state.
Match immutable fields, deduplicate, update, and enqueue fulfilment atomically.
Step 1 · API client
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
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",
});
}
}); 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
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
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
API key and webhook secret are server-only, scoped, rotated, and redacted from logs.
Cart is repriced on the server; expected and received amounts compare as integer paise.
Five-minute timestamp window, constant-time HMAC check, and a unique event key are enforced.
Gateway order ID and client transaction ID both resolve to the same tenant-owned order.
Only an allowed final success can move pending to paid; paid is never silently reversed.
Timeout, duplicate, delayed, out-of-order, malformed, and mismatched events have automated tests.
Questions
Reuse stable identifiers, verify exact bytes, and fulfil after a durable commit.