Webhooks
When webhooks are sent, how to verify their signature, and the retry schedule.
On this page
UPINOW sends a signed webhook to your webhook_url whenever an order you created with one reaches a final state: paid, expired or failed.
Headers
| Header | Meaning |
|---|---|
Content-Type |
Always application/json. |
X-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, signed with your webhook secret. |
X-Webhook-Id |
<order_id>-<attempt>. Unique per delivery attempt; never repeats across the events of one order. |
X-Event |
The legacy event name: payment.success, payment.expired or payment.failed. |
Body
{
"event": "payment.success",
"type": "payment.paid",
"order_id": "UPN-0123456789",
"merchant_order_id": "order_1024",
"amount": 499,
"payable_amount": 499.07,
"paid_at": "2026-09-08T17:26:01.000Z",
"failed_at": null,
"payer_email": "no-reply@paytm.com",
"attempt": 1
}| Field | Meaning |
|---|---|
event |
The legacy event name. Kept for integrations built before type existed. |
type |
The name to switch on: payment.paid, payment.expired or payment.failed. |
order_id |
The UPINOW order id. |
merchant_order_id |
Your own order id, or null. |
amount |
The amount you requested when you created the order, in rupees. |
payable_amount |
The exact amount the customer paid. |
paid_at, failed_at |
ISO 8601 timestamps; whichever does not apply is null. |
payer_email |
The From address of the bank's alert email. |
attempt |
This order's delivery attempt number; see Attempt numbers below. |
Both event and type are always sent, so you can switch on either name.
Signature verification
Compute the HMAC yourself and compare it in constant time; never compare the strings directly, and never trust the payload before you have verified it.
- Read the raw request body before you parse it as JSON; the signature covers the exact bytes UPINOW sent, and parsing first can change whitespace.
- Split
X-Signatureon the comma intot=...andv1=.... - Recompute
HMAC-SHA256("<t>.<raw body>")with your webhook secret and compare it tov1with a constant-time comparison (hash_equalsin PHP,timingSafeEqualin Node,hmac.compare_digestin Python). - Reject the request if
tis more than 5 minutes away from the current time, so an old, captured request cannot be replayed later.
<?php
$secret = 'YOUR_WEBHOOK_SECRET';
$raw = file_get_contents('php://input');
$parts = [];
foreach (explode(',', $_SERVER['HTTP_X_SIGNATURE'] ?? '') as $pair) {
[$k, $v] = array_pad(explode('=', $pair, 2), 2, '');
$parts[trim($k)] = trim($v);
}
$t = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
if ($t === '' || $v1 === '' || !hash_equals($expected, $v1)) {
http_response_code(401);
exit('invalid signature');
}
if (abs(time() - (int) $t) > 300) {
http_response_code(400);
exit('stale');
}
$event = json_decode($raw, true);
// "type" is the new name (payment.paid). "event" keeps the old one (payment.success).
if (($event['type'] ?? '') === 'payment.paid') {
// Mark $event['merchant_order_id'] as paid in your database, only once.
}
// Log to the server's error log, never to a file inside the public web folder.
error_log('UPINOW webhook verified: ' . ($event['order_id'] ?? ''));
echo 'ok';import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = "YOUR_WEBHOOK_SECRET";
createServer((req, res) => {
let raw = "";
req.on("data", (chunk) => (raw += chunk));
req.on("end", () => {
const parts = Object.fromEntries(
String(req.headers["x-signature"] ?? "").split(",").map((p) => p.trim().split("=")),
);
const t = parts.t ?? "";
const v1 = parts.v1 ?? "";
const expected = createHmac("sha256", SECRET).update(t + "." + raw).digest("hex");
// Check the shape first: timingSafeEqual throws (and would stop this server) on unequal lengths.
const ok = t !== "" && /^[0-9a-f]{64}$/.test(v1) && timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(expected, "hex"));
if (!ok) return res.writeHead(401).end("invalid signature");
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.writeHead(400).end("stale");
const event = JSON.parse(raw);
// "type" is the new name (payment.paid). "event" keeps the old one (payment.success).
if (event.type === "payment.paid") {
// Mark event.merchant_order_id as paid in your database, only once.
}
res.writeHead(200).end("ok");
});
}).listen(8000);import hashlib, hmac, json, time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = "YOUR_WEBHOOK_SECRET"
class Hook(BaseHTTPRequestHandler):
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("Content-Length", 0)))
parts = dict(p.strip().split("=", 1) for p in self.headers.get("X-Signature", "").split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
expected = hmac.new(SECRET.encode(), t.encode() + b"." + raw, hashlib.sha256).hexdigest()
if not t or not hmac.compare_digest(expected.encode(), v1.encode()):
self.send_response(401); self.end_headers(); return
if abs(time.time() - int(t)) > 300:
self.send_response(400); self.end_headers(); return
event = json.loads(raw)
# "type" is the new name (payment.paid). "event" keeps the old one (payment.success).
if event.get("type") == "payment.paid":
pass # Mark event["merchant_order_id"] as paid in your database, only once.
self.send_response(200); self.end_headers(); self.wfile.write(b"ok")
HTTPServer(("", 8000), Hook).serve_forever()Delivery and retries
A delivery counts as successful when your server answers with any 2xx status within 10 seconds of the whole exchange, connecting, sending and reading your reply included. A redirect (3xx) is not followed and counts as a failed attempt, the same as a timeout, a connection error, or any other status.
A failed attempt is retried with this backoff, up to 8 attempts in total:
| Attempt | Delay before it |
|---|---|
| 1 | (first attempt, no delay) |
| 2 | 10 seconds |
| 3 | 30 seconds |
| 4 | 2 minutes |
| 5 | 10 minutes |
| 6 | 30 minutes |
| 7 | 1 hour |
| 8 | 3 hours |
That is about 4 hours 43 minutes from the first attempt to the last. After the eighth attempt fails, UPINOW stops trying and marks the webhook failed. A late payment.success that follows a payment.expired is a separate event and gets its own fresh set of 8 retries.
UPINOW checks the webhook URL again before every send; if it now resolves to a private or reserved address, delivery is refused for good and not retried.
Attempt numbers
attempt and X-Webhook-Id count every delivery of an order, across every event it sends, not just the current event. If a payment.expired webhook used attempts 1 to 3 before a late payment marked the order paid, the payment.success retries continue from attempt 5 (attempt 4 is skipped, reserved for a delivery that may still be in flight when the order flips to paid). De-duplicate on order_id plus the event (type) rather than on the attempt number alone: payment.success is final, and if you ever see two events for one order, the higher attempt number is the newer one. If UPINOW's own process is interrupted mid-delivery, the same attempt can be re-sent later with the same X-Webhook-Id, so de-duplicating on it is safe too.
Grace period and late matches
An order stays pending for up to 5 minutes after expires_at before UPINOW marks it expired and sends payment.expired. A bank alert for a payment made at or before expires_at that reaches the mailbox up to 2 minutes late can still move the order to paid, so a payment.expired webhook can be followed by a late payment.success for the same order.
Best practices
- Answer fast, then do the real work afterwards. Verify the signature, write the event to a queue or a database row, and return 200; fulfil the order in a background job.
- Return
200for a webhook you have already processed. UPINOW retries on anything other than a 2xx, so answering 200 for a duplicate stops the retries without double-crediting anything. - Store the raw request body somewhere if you need an audit trail; you cannot re-verify a signature against a body you only kept after re-serialising the JSON.