Skip to content
upınow

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

json
{
  "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": "[email protected]",
  "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.

  1. 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.
  2. Split X-Signature on the comma into t=... and v1=....
  3. Recompute HMAC-SHA256("<t>.<raw body>") with your webhook secret and compare it to v1 with a constant-time comparison (hash_equals in PHP, timingSafeEqual in Node, hmac.compare_digest in Python).
  4. Reject the request if t is more than 5 minutes away from the current time, so an old, captured request cannot be replayed later.
upinow-webhook.php (UPINOW will POST to this URL)
<?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';
upinow-webhook.mjs (listens on port 8000)
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);
upinow_webhook.py (listens on port 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 200 for 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.