Skip to content

Repository files navigation

upi-402

Open HTTP 402 payment standard for UPI.

x402 brought HTTP 402 to crypto. L402 brought it to Lightning. This brings it to UPI — the world's largest real-time payment network. 23 billion transactions/month. Open standard. PA-agnostic.

Install

npm install upi-402

From git (builds dist/ via prepare):

npm install github:ram0verflow/upi-402

Quick start

Server (5 lines):

import express from 'express';
import { upi402 } from 'upi-402';

const app = express();
app.get('/api/data', upi402({ vpa: 'merchant@ybl', amount: 100 }), (req, res) => {
  res.json({ secret: 'paid content', receipt: req.upi402.receipt });
});
app.listen(3000);

Client (3 lines):

import { upi402Fetch } from 'upi-402/client';

const res = await upi402Fetch('http://localhost:3000/api/data', { mandateRef: 'TEST-001' });
console.log(await res.json()); // { secret: 'paid content', receipt: {...} }

Run both. The client gets a 402, retries with the mandate header, gets 200 + receipt. Zero configuration — mock mode is the default.

The protocol

1. Server returns 402

HTTP/1.1 402 Payment Required
X-UPI-402-Version: 1

{
  "upi402": 1,
  "payee": { "vpa": "merchant@ybl", "name": "Example API" },
  "payment": { "amount": 500, "currency": "INR", "description": "API access" },
  "mandate": { "required": true, "maxAmount": 5000, "frequency": "DAILY" }
}

2. Client retries with mandate

GET /api/data HTTP/1.1
Authorization: UPI-Mandate umn=ABCD1234567890&txnRef=TXN789

3. Server verifies and responds

HTTP/1.1 200 OK
X-UPI-402-Receipt: {"txnId":"UPI123","amount":500,"timestamp":"2026-06-13T14:32:18Z","umn":"ABCD1234567890"}

{ "data": "..." }

See SPEC.md for the full protocol specification.

Server middleware API

import { upi402 } from 'upi-402';

app.get('/api/data', upi402(options), handler);

Options

Option Type Required Default Description
vpa string yes Merchant UPI VPA
name string no same as vpa Merchant display name
amount number yes Amount in INR
currency string no "INR" Currency code
description string no Payment description
mandate object no { required: true } Mandate configuration
requireSignature boolean no false Reject unsigned requests
verify function no mock verifier Verification function

With a real verifier

import { razorpayVerifier } from 'upi-402/verifiers/razorpay';

app.get('/api/data', upi402({
  vpa: 'merchant@ybl',
  amount: 500,
  verify: razorpayVerifier({
    keyId: process.env.RAZORPAY_KEY_ID,
    keySecret: process.env.RAZORPAY_KEY_SECRET
  })
}), handler);

With a custom verifier

app.get('/api/data', upi402({
  vpa: 'merchant@ybl',
  amount: 500,
  verify: async (mandateRef, amount, txnRef) => {
    const result = await yourPA.executeDebit(mandateRef, amount);
    return { success: result.ok, txnId: result.txnId };
  }
}), handler);

Verify function signature

type VerifyFunction = (
  mandateRef: string,
  amount: number,
  txnRef: string,
  metadata?: Record<string, any>
) => Promise<{ success: boolean; txnId?: string; error?: string }>;

Client API

import { upi402Fetch } from 'upi-402/client';

const res = await upi402Fetch(url, options);

Options

Option Type Required Default Description
mandateRef string yes UPI Unique Mandate Number
txnRef string no auto-generated Transaction reference for idempotency
privateKey string no auto-generated Ed25519 private key (base64). Auto-generates a keypair per session if omitted.
maxRetries number no 1 Max retry attempts after 402
onPaymentRequired function no Called when 402 is received
onPaymentComplete function no Called after successful payment

The client wraps fetch with 402 retry logic. When the server returns a paymentId in the 402 response, the client automatically signs the payment agreement with Ed25519 before retrying. The response has a .upi402Receipt property with the payment receipt.

Error handling

import { upi402Fetch, UPI402PaymentError } from 'upi-402/client';

try {
  const res = await upi402Fetch(url, { mandateRef: 'MANDATE-001' });
} catch (err) {
  if (err instanceof UPI402PaymentError) {
    console.log(err.details.error);    // "mandate_expired", "debit_failed", etc.
    console.log(err.details.payee.vpa); // merchant VPA
  }
}

Verifiers

Verifier Status Notes
Mock Tested Full 402 -> sign -> 202 -> 200 flow, all tests passing
Razorpay Implemented Targets POST /v1/payments/create/recurring (instant charge). Requires S2S enablement on Razorpay account

See PA_RESEARCH.md for why other PAs were evaluated and excluded.

The verify interface is simple — implement one async function returning { success, pending, txnId }. PRs adding verifiers for PAs that support instant charge (no scheduling) are welcome.

Agent-side code (upi-402/client) has zero dependencies. Verifier code is server-side only and tree-shakeable.

How UPI mandates work

UPI is a single real-time payment network operated by NPCI (National Payments Corporation of India). PhonePe, Google Pay, Paytm, Razorpay, bank apps, and even *99# USSD are all interfaces into the same system.

A mandate (also called autopay/recurring authorization) is a pre-approved debit permission. The user sets it up through any UPI app — the standard doesn't care which. The mandate gets a Unique Mandate Number (UMN) at the NPCI level. Any Payment Aggregator (PA) can then execute debits against that UMN within the approved limits.

This protocol uses mandates because:

  • The agent doesn't need to be present for each payment
  • The user approves once, the agent can pay within limits
  • It works exactly like how subscription services use UPI autopay today

Payment scoping (overcharge protection)

A malicious merchant could debit more than the agreed amount from a UPI mandate. NPCI enforces mandate-level limits but not per-transaction agreements. upi-402 solves this with decentralized payment scoping — no central authority required.

How it works

  1. Server returns 402 with a unique paymentId
  2. Client signs paymentId:amount:merchantVpa:timestamp with Ed25519
  3. Client sends signature + public key in the Authorization header
  4. Middleware verifies the signature and passes the signed amount to the verifier
  5. If the server tries to debit a different amount, the middleware blocks it
Client signs: "pay-uuid:500:merchant@ybl:1718300000"
Server receives: signature proves client agreed to exactly ₹500
Middleware: passes ₹500 to verifier, regardless of server config

What this prevents

  • Overcharge: merchant debits ₹500 when client agreed to ₹100 — middleware blocks
  • Replay: merchant reuses a paymentId — middleware blocks (single-use)
  • Tampering: man-in-middle changes the amount — signature verification fails

What this doesn't prevent

  • Merchant modifies the middleware source code — but client holds signed evidence for dispute
  • Same trust model as HTTPS: you trust the software, disputes handle bad actors

Backward compatibility

Signing is opt-in. Unsigned requests still work by default. Set requireSignature: true on the middleware to enforce signed requests.

Future: Agent Identity

The Authorization header is designed to be extensible. v2 may add:

Authorization: UPI-Mandate umn=ABCD1234&txnRef=TXN789&agent=did:web:myagent.dev&grant=eyJhbG...

v1 ships without identity fields. The header parser ignores unknown fields, so v1 servers work with v2 clients.

Use with PayRouter

import { upi402Fetch } from 'upi-402/client';
// Plug into PayRouter's UPI adapter — one import, handles the 402 handshake.

Contributing

Especially wanted:

  • Testing PhonePe, Cashfree, and Stripe verifiers with real credentials
  • Additional PA verifiers (Paytm, PayU, etc.)
  • Framework adapters beyond Express (Hono, Fastify, etc.)

License

Apache 2.0

About

Open HTTP 402 payment standard for UPI — AI agents pay for API endpoints using UPI mandates. PA-agnostic. No intermediary.

Topics

Resources

Code of conduct

Contributing

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages