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
- Create an account. During signup you give Milan your UPI ID and connect the mailbox your bank sends credit alerts to.
- 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.
- In Webhooks, set your endpoint URL. It must be public
https(not localhost or a private address). - 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"}'
- Show the customer
payable_amountand a QR code or link built fromupi_intent. - When the payment is confirmed, your endpoint receives a signed
order.paidevent. Verify the signature, then fulfil the order.
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
| Field | Type | Notes |
|---|---|---|
amount | string | Required. Base amount in rupees, at most 2 decimals, e.g. "349.50". Send it as a string. |
order_id | string | Optional. 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
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
| Status | Meaning |
|---|---|
pending | Waiting for a payment of exactly payable_amount. |
paid | A matching bank credit was confirmed. Final. |
expired | The 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. |
cancelled | Cancelled; will not be settled automatically. |
Webhook events & payload
Milan sends a POST with a JSON body to your webhook URL. Headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Webhook-Id | The event ID, e.g. evt_3f9a1c2b7d4e5f60. Same on every retry — use it to de-duplicate. |
X-Webhook-Timestamp | Unix time in seconds when this attempt was signed. |
X-Webhook-Signature | Lowercase 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" }
}
| Event | When |
|---|---|
order.paid | An order was settled — automatically from a bank alert, or by you matching a credit from the Review queue. Both look identical. |
credit.unmatched | A credit arrived that matched no open order (for example the payer changed the amount). It is waiting in Review. |
order.expired | The 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.
- Read the raw request body as bytes/string. Do not parse and re-serialise the JSON first — any change breaks the signature.
- Compute
HMAC-SHA256(secret, timestamp + "." + rawBody)as lowercase hex. - Compare with
X-Webhook-Signaturein constant time, and check the timestamp is within 300 seconds. - Return a
2xxquickly, then process. De-duplicate onX-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
2xxresponse counts as delivered. - On a network error, a
5xxor a429, Milan retries — up to 5 attempts in total, waiting about 2, 4, 8 and 16 seconds between them. - Any other
4xx(for example400for a bad signature) is treated as a deliberate rejection and not retried. - Each retry is signed again with a fresh timestamp; the
X-Webhook-Idstays 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
| Limit | Value |
|---|---|
| Concurrent open orders per base amount | Up to 99 per time window (one per paise value). Paid and expired orders free their slot. Different base amounts have separate slots. |
| Order window | 12 minutes by default; 1–120 minutes configurable |
| Amount format | Up to 9 whole-rupee digits and at most 2 decimals |
| Public payment page | 10 order attempts per minute per visitor per page |
| Webhook and return URLs | Must be public https |
| Banks | HDFC 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"}.
| Status | Error | What to do |
|---|---|---|
400 | Amount must have at most 2 decimals | Send amount as a string like "349.50". |
401 | Invalid API key | Check the X-API-Key header; create a new key if lost. |
404 | Not found | No order with that ID in your account. |
404 | Unknown endpoint | Check the path and method. |
409 | Order already exists | That order_id is taken. Fetch it instead, or use a new ID. |
412 | code: "mailbox_not_ready" + reason | Your 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. |
429 | Too 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. |
500 | Something went wrong + ref | Retry shortly. Quote the ref if you contact support. |