Developer docs

Milan API

Two endpoints and one signed webhook. You create an order, show your customer the exact amount and UPI link, and Milan tells your server when your bank confirms the money arrived.

Quickstart

  1. Create an account. During signup you give Milan your UPI ID and connect the mailbox your bank sends credit alerts to.
  2. Copy your API key from the final signup screen, or create a new one in the dashboard under API & SDK → New key. Keys are shown once; Milan stores only a hash.
  3. In Webhooks, set your endpoint URL. It must be public https (not localhost or a private address).
  4. Create an order from your server:
curl -X POST https://milanpayments.in/v1/orders \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"order_id":"RT-10021","amount":"349.50"}'
  1. Show the customer payable_amount and a QR code or link built from upi_intent.
  2. When the payment is confirmed, your endpoint receives a signed order.paid event. Verify the signature, then fulfil the order.
Replace the host. Examples use https://milanpayments.in. Use the address of your Milan instance.

How matching works

A bank credit alert carries an amount and a reference number, and nothing you chose. So Milan makes the amount identify the order: each new order reserves a payable amount of your base amount plus 1 to 99 paise that no other open order for that base amount is using. ₹349.50 becomes, say, ₹349.53.

When your bank's alert arrives, Milan parses it (HDFC Bank, ICICI Bank and SBI alert formats today), finds the one open order expecting exactly that amount, marks it paid and records the UPI reference (UTR). Rules that follow from this:

  • The customer must pay the exact payable_amount. A rounded payment (₹349 for a ₹349.53 order) will not match. It goes to the dashboard's Review queue, where you can attach it to an order by hand.
  • A debit alert is never read as a credit, and an alert Milan can't parse confidently is never guessed at.
  • The same credit delivered twice settles once.

Authentication

Every /v1/ request needs your API key in the X-API-Key header. Keep it on your server; never ship it in a browser or mobile app. A missing or wrong key returns 401 with {"error":"Invalid API key"}.

Create an order

POST/v1/orders
FieldTypeNotes
amountstringRequired. Base amount in rupees, at most 2 decimals, e.g. "349.50". Send it as a string.
order_idstringOptional. Your own ID. Must be unique in your account. If omitted, Milan generates one like ord_1a2b3c4d5e6f.

Response · 201 Created

{
  "order_id": "RT-10021",
  "base_amount": "349.50",
  "payable_amount": "349.53",
  "upi_intent": "upi://pay?pa=shop%40okhdfcbank&pn=Your%20Shop&am=349.53&cu=INR&tn=RT-10021&tr=RT-10021",
  "expires_at": "2026-09-23T10:42:00.000Z",
  "status": "pending"
}

Amounts are always strings with two decimals. upi_intent pays your UPI ID with the payable amount and your order ID as the note; render it as a QR code, or use it as a link on mobile. expires_at is when the amount slot is released — 12 minutes by default, adjustable from 1 to 120 minutes in dashboard settings (QR validity).

Fetch an order

GET/v1/orders/{order_id}
curl https://milanpayments.in/v1/orders/RT-10021 \
  -H "X-API-Key: YOUR_API_KEY"

Response · 200 OK

{
  "id": "RT-10021",
  "status": "paid",
  "utr": "526412345678",
  "base": "349.50",
  "payable": "349.53",
  "source": "api",
  "created_at": "2026-09-23T10:30:00.000Z",
  "paid_at": "2026-09-23T10:31:12.000Z",
  "expires_at": "2026-09-23T10:42:00.000Z"
}

Note the field names differ from the create response: id, base and payable. source is api for API orders and page for payment-page orders. utr and paid_at are null until paid. An unknown ID returns 404.

Use this as a fallback or for a “check status” button. Webhooks are the primary signal — you don't need to poll.

Order statuses

StatusMeaning
pendingWaiting for a payment of exactly payable_amount.
paidA matching bank credit was confirmed. Final.
expiredThe window passed without a match and the paise slot was released. A late payment for it lands in Review, where you can still attach it to this order.
cancelledCancelled; will not be settled automatically.

Webhook events & payload

Milan sends a POST with a JSON body to your webhook URL. Headers:

HeaderValue
Content-Typeapplication/json
X-Webhook-IdThe event ID, e.g. evt_3f9a1c2b7d4e5f60. Same on every retry — use it to de-duplicate.
X-Webhook-TimestampUnix time in seconds when this attempt was signed.
X-Webhook-SignatureLowercase hex HMAC-SHA256 of ${timestamp}.${body} using your webhook secret.

order.paid

{
  "id": "evt_3f9a1c2b7d4e5f60",
  "type": "order.paid",
  "createdAt": 1790159472000,
  "data": {
    "orderId": "RT-10021",
    "amountPaid": "349.53",
    "baseAmount": "349.50",
    "utr": "526412345678"
  }
}

credit.unmatched

{
  "id": "evt_9b1d7e0a4c3f2a18",
  "type": "credit.unmatched",
  "createdAt": 1790159610000,
  "data": { "amount": "349.00", "utr": "526412349999" }
}

order.expired

{
  "id": "evt_4e2a91c07b5d3f60",
  "type": "order.expired",
  "createdAt": 1790160192000,
  "data": { "orderId": "RT-10022", "amountExpected": "349.51", "baseAmount": "349.50" }
}
EventWhen
order.paidAn order was settled — automatically from a bank alert, or by you matching a credit from the Review queue. Both look identical.
credit.unmatchedA credit arrived that matched no open order (for example the payer changed the amount). It is waiting in Review.
order.expiredThe order's window passed without a matching payment. Sent within about 30 seconds of expires_at.

Notes: the body's createdAt is in milliseconds, unlike the timestamp header. utr can be an empty string if the bank's alert didn't include a reference. A payment that arrives after order.expired lands in Review; if you match it there, an order.paid follows.

Verify signatures

Always verify before trusting an event. The signature covers the timestamp as well as the body, so a captured request can't be replayed later. Milan's own check rejects timestamps more than 300 seconds from now; do the same.

  1. Read the raw request body as bytes/string. Do not parse and re-serialise the JSON first — any change breaks the signature.
  2. Compute HMAC-SHA256(secret, timestamp + "." + rawBody) as lowercase hex.
  3. Compare with X-Webhook-Signature in constant time, and check the timestamp is within 300 seconds.
  4. Return a 2xx quickly, then process. De-duplicate on X-Webhook-Id.

Node.js (Express)

import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.MILAN_WEBHOOK_SECRET;

function verify(secret, timestamp, body, signature, toleranceSec = 300) {
  if (!Number.isFinite(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSec) return false;
  const expected = Buffer.from(
    createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex'));
  const given = Buffer.from(signature);
  return expected.length === given.length && timingSafeEqual(expected, given);
}

const app = express();

// express.raw keeps the exact bytes Milan signed.
app.post('/milan/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const body = req.body.toString('utf8');
  const ts = Number(req.get('X-Webhook-Timestamp'));
  const sig = req.get('X-Webhook-Signature') ?? '';
  if (!verify(SECRET, ts, body, sig)) return res.status(400).send('bad signature');

  const event = JSON.parse(body);
  res.sendStatus(200);                        // acknowledge first

  if (event.type === 'order.paid') {
    // idempotent: skip if you've already handled req.get('X-Webhook-Id')
    markOrderPaid(event.data.orderId, event.data.amountPaid, event.data.utr);
  }
});

PHP

<?php
$secret    = getenv('MILAN_WEBHOOK_SECRET');
$body      = file_get_contents('php://input');          // raw body, unparsed
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
    http_response_code(400); exit('stale timestamp');
}
$expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);  // lowercase hex
if (!hash_equals($expected, $signature)) {
    http_response_code(400); exit('bad signature');
}

$event = json_decode($body, true);
http_response_code(200);

if ($event['type'] === 'order.paid') {
    $eventId = $_SERVER['HTTP_X_WEBHOOK_ID'] ?? '';   // de-duplicate on this
    $orderId = $event['data']['orderId'];
    $utr     = $event['data']['utr'];
    // mark $orderId paid, once
}

Get your signing secret from the dashboard under Webhooks → Generate signing secret. It is shown once; generating a new one stops the old one working immediately. Keep it on your server alongside your API key.

Retries

  • Any 2xx response counts as delivered.
  • On a network error, a 5xx or a 429, Milan retries — up to 5 attempts in total, waiting about 2, 4, 8 and 16 seconds between them.
  • Any other 4xx (for example 400 for a bad signature) is treated as a deliberate rejection and not retried.
  • Each retry is signed again with a fresh timestamp; the X-Webhook-Id stays the same.
  • Every attempt is listed in the dashboard's Webhooks → Delivery log with its status code.

If all attempts fail, the order is still paid in Milan — fetch it with GET /v1/orders/{id} to catch up.

Payment pages & invoices

No code needed for either.

Payment pages — /p/<slug>

Create a page in the dashboard with a title, a description, and either a fixed amount or an open amount the payer types in. Share the link anywhere — Instagram bio, WhatsApp, email. The payer enters their name, email and optionally phone, gets their unique amount and a UPI QR/link, and the page updates itself when the payment is confirmed. Add an https return URL to send them back to your site after paying. Page orders appear in your dashboard and API with source: "page" and trigger the same webhooks.

Invoices — /i/<slug>

Create an invoice with line items for a client in the dashboard, and share it as a link or email it. The client sees the invoice and pays it by UPI.

WooCommerce plugin

For WooCommerce stores there is a Milan plugin, kept in the Milan repository at integrations/woocommerce. It adds UPI via Milan as a checkout method: it creates a Milan order for the cart total, shows the shopper the exact amount to pay, and marks the WooCommerce order paid when Milan's signed order.paid webhook arrives. You'll need your API key and your signing secret (Webhooks → Generate signing secret); the plugin's settings screen shows the webhook URL to paste into Milan's Webhooks setting. See the plugin's README for installation.

Limits

LimitValue
Concurrent open orders per base amountUp to 99 per time window (one per paise value). Paid and expired orders free their slot. Different base amounts have separate slots.
Order window12 minutes by default; 1–120 minutes configurable
Amount formatUp to 9 whole-rupee digits and at most 2 decimals
Public payment page10 order attempts per minute per visitor per page
Webhook and return URLsMust be public https
BanksHDFC Bank, ICICI Bank and SBI credit alert emails

If you sell one item at one price in very high volume, 99 open orders may be tight at peak. Shorter order windows free slots faster; varying the base amount (₹499.00 vs ₹499.10) gives each price its own 99 slots.

Errors

Errors return a JSON body of the form {"error": "message"}.

StatusErrorWhat to do
400Amount must have at most 2 decimalsSend amount as a string like "349.50".
401Invalid API keyCheck the X-API-Key header; create a new key if lost.
404Not foundNo order with that ID in your account.
404Unknown endpointCheck the path and method.
409Order already existsThat order_id is taken. Fetch it instead, or use a new ID.
412code: "mailbox_not_ready" + reasonYour bank-alert mailbox is not connected (not_connected), switched off (disabled), or has not been readable for 15 minutes (failing). Milan confirms every payment from your bank's alert, so it will not take an order it could never confirm. Reconnect in Mail Settings with a fresh app password; the same check applies to creating API keys and setting a webhook URL.
429Too many open orders for this amount…All 99 paise slots for that base amount are held by open orders. Retry after the Retry-After seconds, or vary the amount.
500Something went wrong + refRetry shortly. Quote the ref if you contact support.