REST API · v1 · Signed webhooks

Integration Guide & API Reference

Accept UPI payments on your website, Telegram bot, Discord bot, WhatsApp bot or mobile app. Create an order, show the QR / pay link, get a signed webhook when it is paid.

Create your first order → Get API key
BASE https://hexupi.xyz

Overview

Everything is plain HTTPS + JSON. You need two things from your side: a server that calls the API (never the browser or the app itself) and, optionally, a public URL that receives the payment webhook.

Base URL: https://hexupi.xyz

Set up once (2 minutes)

  1. Sign up / log in, open My Gateway and save your UPI ID (@fam / @yesfam), alert Gmail and the 16-character Google App Password. Press Test connection.
  2. Open Keys & URL and copy your API key.
  3. Use any code below. That is the whole setup.

How a payment works

  1. Your server calls POST /api/v1/create-order and gets pay_url, upi and qr.
  2. You show the customer the QR code or send them the pay_url link.
  3. The customer pays the exact payable amount from any UPI app.
  4. The gateway reads your bank / FamPay alert mail, matches it to the order and marks it PAID.
  5. The gateway sends a signed webhook to your server. You verify the signature and deliver the product / credit the wallet.

Where you can integrate

  • Websites - plain PHP, Node.js, Python, Ruby, Go, .NET, Java, any framework.
  • Bots - Telegram, Discord, WhatsApp, Slack, Messenger.
  • Mobile apps - Android (Kotlin/Java), iOS (Swift), Flutter, React Native.
  • E-commerce - WooCommerce, Shopify (via custom app), custom carts.
  • Scripts - cURL, Postman, shell automation, cron jobs.

Authentication

Send your API key in the x-api-key header on every /api/v1/ call.

HTTP Headers
x-api-key: YOUR_API_KEY
Content-Type: application/json
Keep it secret. Use the key only in server code. Never put it in a website's JavaScript, an Android/iOS app or a public GitHub repo. If it leaks, press Regenerate on the Keys & URL page (the old key stops working immediately; remember to update your server).

Payment lifecycle

StatusMeaning
PENDINGOrder created, waiting for the customer to pay.
PAIDPayment alert matched. Safe to deliver the product.
EXPIREDNot paid within 10 minutes. Create a new order.
  • Order window: 10 minutes after creation. A payment that lands a little after expiry (up to about 20 minutes) is still matched and marked PAID.
  • Exact amount: the customer must pay payable, not amount. If two orders for the same amount are open at the same time, the gateway adds a few paise (for example 199.00 to 199.01) so each payment matches only one order. Always show payable to the customer.
  • Speed: a payment is normally confirmed within seconds to a minute after the bank / FamPay alert mail reaches your inbox.

Create order POST

/api/v1/create-order

FieldTypeRequiredDescription
amountnumberYesAmount in INR, from 1 to 100000. Example 199 or 499.50.
order_idstringRecommendedYour own reference (user id, cart id, wallet top-up id). Make it unique per order. It is returned in the webhook and shown in the UPI note.
webhookstringNoPublic https URL that receives the payment webhook. Private / local addresses (localhost, 192.168.x.x) are rejected. If omitted, your account's default webhook (if you set one) is used.
redirectstringNoOptional. A page on your site (https) for this one order. If you leave it out, the customer is sent back to the same website they started the payment from automatically, so one API key can run on any number of websites without a redirect URL for each. After paying they see "Payment Successful", then a 5 second countdown, then your page with the order info and a signature appended. Use redirect only when you want an exact page (for example /thanks) or when the order is created from a bot (bots have no website to return to).
PHP (cURL)
<?php
$apiKey = "YOUR_API_KEY";                       // server-side only

$ch = curl_init("https://hexupi.xyz/api/v1/create-order");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => ["x-api-key: $apiKey", "Content-Type: application/json"],
    CURLOPT_POSTFIELDS     => json_encode([
        "amount"   => 199,
        "order_id" => "ORD-" . time(),          // unique per order
        "webhook"  => "https://yoursite.com/hexpay-webhook.php",
        "redirect" => "https://yoursite.com/thanks",
    ]),
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);

if (empty($res["ok"])) die($res["error"] ?? "Gateway error");

// Save $res["id"] with your order, then redirect to checkout
header("Location: " . $res["pay_url"]);
Node.js (fetch, Node 18+)
const r = await fetch("https://hexupi.xyz/api/v1/create-order", {
  method: "POST",
  headers: {
    "x-api-key": process.env.HEX_API_KEY,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    amount: 199,
    order_id: "ORD-" + Date.now(),
    webhook: "https://yoursite.com/hexpay-webhook",
    redirect: "https://yoursite.com/thanks"
  })
});
const o = await r.json();
if (!r.ok || !o.ok) throw new Error(o.error || "Gateway error");
console.log(o.pay_url, o.payable);
Python (requests)
import os, time, requests

r = requests.post(
    "https://hexupi.xyz/api/v1/create-order",
    headers={"x-api-key": os.environ["HEX_API_KEY"]},
    json={
        "amount": 199,
        "order_id": f"ORD-{int(time.time())}",
        "webhook": "https://yoursite.com/hexpay-webhook",
        "redirect": "https://yoursite.com/thanks",
    },
    timeout=20,
)
o = r.json()
if not r.ok or not o.get("ok"):
    raise Exception(o.get("error", "Gateway error"))
print(o["pay_url"], o["payable"])
cURL
curl -X POST https://hexupi.xyz/api/v1/create-order \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount":199,"order_id":"ORD-1001","webhook":"https://yoursite.com/hexpay-webhook"}'
Go
package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
)

func main() {
    body, _ := json.Marshal(map[string]any{
        "amount":   199,
        "order_id": "ORD-1001",
        "webhook":  "https://yoursite.com/hexpay-webhook",
    })
    req, _ := http.NewRequest("POST",
        "https://hexupi.xyz/api/v1/create-order",
        bytes.NewBuffer(body))
    req.Header.Set("x-api-key", os.Getenv("HEX_API_KEY"))
    req.Header.Set("Content-Type", "application/json")

    res, _ := http.DefaultClient.Do(req)
    defer res.Body.Close()
    raw, _ := io.ReadAll(res.Body)
    fmt.Println(string(raw))
}
Java (HttpClient 11+)
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

var json = """
    {"amount":199,"order_id":"ORD-1001","webhook":"https://yoursite.com/hexpay-webhook"}
    """;
var req = HttpRequest.newBuilder()
    .uri(URI.create("https://hexupi.xyz/api/v1/create-order"))
    .header("x-api-key", System.getenv("HEX_API_KEY"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(json))
    .build();
var res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
C# / .NET
using System.Net.Http.Json;

var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key",
    Environment.GetEnvironmentVariable("HEX_API_KEY"));

var res = await http.PostAsJsonAsync(
    "https://hexupi.xyz/api/v1/create-order",
    new {
        amount = 199,
        order_id = "ORD-1001",
        webhook = "https://yoursite.com/hexpay-webhook"
    });

var json = await res.Content.ReadAsStringAsync();
Console.WriteLine(json);
Ruby (net/http)
require "net/http"
require "json"

uri = URI("https://hexupi.xyz/api/v1/create-order")
req = Net::HTTP::Post.new(uri)
req["x-api-key"]    = ENV["HEX_API_KEY"]
req["Content-Type"] = "application/json"
req.body = {
    amount: 199,
    order_id: "ORD-1001",
    webhook: "https://yoursite.com/hexpay-webhook"
}.to_json

res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |h|
    h.request(req)
end
puts res.body

Response

JSON (HTTP 200)
{
  "ok": true,
  "id": "ord_8f1b90c2a7e4",
  "order_id": "ORD-1001",
  "amount": 199,
  "payable": 199.01,
  "pay_url": "https://hexupi.xyz/pay/ord_8f1b90c2a7e4",
  "upi": "upi://pay?pa=yourname@fam&pn=HexStore&am=199.01&cu=INR&tn=ORD-1001",
  "qr": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0i...",
  "expires_at": 1728189400000,
  "status": "PENDING"
}
FieldUse it for
idThe gateway order id (ord_...). Store it with your order. Used for status checks and it is the idempotency key in the webhook.
payableThe exact amount the customer must pay. Show this one.
pay_urlHosted checkout page with QR, UPI buttons and live status. Easiest option: just send this link.
upiThe upi://pay?... deep link. Open it on mobile, or turn it into your own QR image.
qrReady QR code as an SVG data-URI (works in an <img> tag; not accepted by Telegram, see QR codes).
expires_atUnix time in milliseconds when the 10-minute window ends.

Check status GET

/api/v1/status/{id}

{id} is either the gateway id (ord_..., no key needed) or your own order_id (needs your x-api-key, and only your orders are searched). If you reused the same order_id twice, the newest order is returned, so keep them unique.

Instant verify. Every call on a still-pending order makes the gateway read your payment mail right then, so a bot's Verify payment button works 3-4 seconds after the customer pays. The customer does not need to open the checkout page again.
cURL
curl https://hexupi.xyz/api/v1/status/ord_8f1b90c2a7e4

# or with your own order_id (needs your key):
curl -H "x-api-key: YOUR_API_KEY" https://hexupi.xyz/api/v1/status/ORD-1001
JSON (HTTP 200)
{
  "ok": true,
  "id": "ord_8f1b90c2a7e4",
  "order_id": "ORD-1001",
  "status": "PAID",
  "paid": true,
  "amount": 199,
  "payable": 199.01,
  "paid_at": 1728189400000,
  "expires_at": 1728189400000
}
Webhook or polling? Use the webhook as your main signal and status polling (every 3-5 seconds, only while the order is PENDING) as a backup. The Telegram bot example below uses both.

Webhooks

When an order is paid, the gateway sends POST to the webhook URL you gave in create-order.

Request body (JSON)
{
  "event": "payment.success",
  "id": "ord_8f1b90c2a7e4",
  "order_id": "ORD-1001",
  "status": "PAID",
  "amount": 199,
  "payable": 199.01,
  "paid_at": 1728189400000
}
ItemDetails
Headersx-signature (same value also in x-paygate-signature), Content-Type: application/json
SignatureLower-case hex of HMAC-SHA256(raw request body, your API key)
Query stringFor simple PHP/GET scripts the URL also gets ?order_id=&id=&status=PAID&amount= added. Do not trust these; trust only the signed body.
SuccessReply with any HTTP 2xx status. Anything else counts as a failure.
RetriesAfter a failure the gateway retries after 1 min, 5 min, 15 min, 1 hour and 6 hours, then marks it failed. You can also resend from Webhook logs.
Two rules that save you. First, verify the signature against the raw body. Parsing the JSON and encoding it again changes spaces, key order and escaping, and the signature will not match. Second, the same event can arrive more than once (retries, manual resend). Mark the order paid only once per id.
PHP receiver (hexpay-webhook.php)
<?php
$apiKey = "YOUR_API_KEY";
$raw    = file_get_contents("php://input");   // RAW body, do not re-encode
$sig    = $_SERVER["HTTP_X_SIGNATURE"] ?? "";

if (!hash_equals(hash_hmac("sha256", $raw, $apiKey), $sig)) {
    http_response_code(401);
    exit("bad signature");
}

$ev = json_decode($raw, true);
if (($ev["event"] ?? "") === "payment.success") {
    // $ev["id"]       = gateway order id
    // $ev["order_id"] = YOUR reference
    // $ev["amount"]   = amount
    // Retries / manual resends can deliver the same event again
    // -> mark paid ONCE per $ev["id"]
}
http_response_code(200);
echo "ok";
Node.js (Express)
const express = require("express");
const crypto = require("crypto");
const app = express();
const API_KEY = process.env.HEX_API_KEY;

// IMPORTANT: express.raw gives the untouched body.
// JSON.stringify(req.body) will NOT match the signature.
app.post("/hexpay-webhook", express.raw({ type: "*/*" }), (req, res) => {
    const sig  = String(req.headers["x-signature"] || "");
    const calc = crypto.createHmac("sha256", API_KEY)
                       .update(req.body)
                       .digest("hex");
    const ok = sig.length === calc.length
        && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(calc));
    if (!ok) return res.status(401).send("bad signature");

    const ev = JSON.parse(req.body.toString());
    if (ev.event === "payment.success") {
        // ev.id = gateway order id, ev.order_id = YOUR reference
        // The same event can arrive more than once
        // -> mark paid ONCE per ev.id
    }
    res.send("ok");
});

app.listen(3000);
Python (Flask)
import os, hmac, hashlib, json
from flask import Flask, request, abort

app = Flask(__name__)
API_KEY = os.environ["HEX_API_KEY"]

@app.post("/hexpay-webhook")
def hexpay_webhook():
    raw  = request.get_data()                 # RAW body
    calc = hmac.new(API_KEY.encode(), raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(calc, request.headers.get("x-signature", "")):
        abort(401)

    ev = json.loads(raw)
    if ev.get("event") == "payment.success":
        # ev["id"] = gateway order id, ev["order_id"] = YOUR reference
        # The same event can arrive more than once
        # -> mark paid ONCE per ev["id"]
        pass
    return "ok"
Go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "log"
    "net/http"
    "os"
)

func webhook(w http.ResponseWriter, r *http.Request) {
    raw, _ := io.ReadAll(r.Body)
    mac := hmac.New(sha256.New, []byte(os.Getenv("HEX_API_KEY")))
    mac.Write(raw)
    if !hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))),
        []byte(r.Header.Get("x-signature"))) {
        http.Error(w, "bad signature", 401)
        return
    }
    log.Println("payment ok:", string(raw))
    w.WriteHeader(200)
    w.Write([]byte("ok"))
}

func main() {
    http.HandleFunc("/hexpay-webhook", webhook)
    http.ListenAndServe(":3000", nil)
}
C# / .NET (ASP.NET Core)
using System.Security.Cryptography;
using System.Text;

app.MapPost("/hexpay-webhook", async (HttpRequest req) =>
{
    using var reader = new StreamReader(req.Body);
    var raw = await reader.ReadToEndAsync();
    var sig = req.Headers["x-signature"].ToString();

    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(
        Environment.GetEnvironmentVariable("HEX_API_KEY")));
    var calc = Convert.ToHexString(
        hmac.ComputeHash(Encoding.UTF8.GetBytes(raw))).ToLower();

    if (calc != sig) return Results.Unauthorized();

    // process raw JSON here...
    return Results.Text("ok");
});

Test your receiver without paying

Create a signed fake event from your terminal:

bash
BODY='{"event":"payment.success","id":"ord_test1","order_id":"ORD-1","status":"PAID","amount":199,"payable":199.01,"paid_at":1728189400000}'
SIG=$(printf %s "$BODY" | openssl dgst -sha256 -hmac "YOUR_API_KEY" | sed 's/^.* //')

curl -X POST https://yoursite.com/hexpay-webhook \
  -H "Content-Type: application/json" \
  -H "x-signature: $SIG" \
  -d "$BODY"

Errors & limits

Errors return JSON like {"error": "message"} with an HTTP status.

HTTPMessage / causeFix
400amount must be 1-100000Send a number between 1 and 100000.
400webhook must point to a public serverUse a public https URL, not localhost / a private IP. For local testing use ngrok or cloudflared.
400Gateway not configuredFinish My Gateway (UPI ID, Gmail, App Password) first.
401Invalid API keyWrong or regenerated key, or the x-api-key header is missing.
403Account / network blockedContact support.
404Not found (status)Wrong id, or an order_id looked up without your key.
429Too many requestsSlow down and wait for the Retry-After seconds.
503Maintenance / storage busyRetry after a few seconds.

Plan for retries: if create-order fails with 429 / 503 or a network error, retry with a short delay. Do not retry a 4xx other than 429, fix the request.

Guide: website checkout

  1. On "Pay" click, your server calls create-order and saves id with your order.
  2. Redirect the customer to pay_url (or render the qr yourself).
  3. Your webhook marks the order paid and delivers the product.
  4. The customer sees "Payment Successful", then "Redirecting to your-site.com in 5s", then lands on your redirect page (never on the HexPay site). Show a "thank you" / current wallet balance there, but never deliver the product based on that page alone (anyone can open the URL). Deliver only from the webhook or a server-side status check.

What your redirect page receives

HexPay appends these to your redirect URL (an existing query string and #fragment are kept):

Example redirect
https://shop.example.com/thanks?order_id=INV-1001&id=ord_a1b2c3&status=PAID&amount=100.00&sig=9f2c...

sig = HMAC-SHA256 of id|order_id|amount|PAID with your API key. Check it (or call the status API with id) before showing "payment received":

PHP
$id  = $_GET['id'] ?? '';
$oid = $_GET['order_id'] ?? '';
$amt = $_GET['amount'] ?? '';
$ok  = hash_equals(hash_hmac('sha256', "$id|$oid|$amt|PAID", $apiKey), $_GET['sig'] ?? '');
Node.js
const crypto = require('crypto');
const { id, order_id, amount, sig } = req.query;
const exp = crypto.createHmac('sha256', API_KEY)
  .update(`${id}|${order_id}|${amount}|PAID`).digest('hex');
const ok = sig && sig.length === exp.length &&
  crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(exp));

The webhook is sent the moment the payment is detected, before the customer is redirected, so the product / wallet credit is normally already done when they land on your page. Payments made through a payment link never redirect: the customer just stays on the animated "Payment Successful" page.

The code for step 1 is in Create order and for step 3 in Webhooks.

Guide: Telegram bot wallet

Complete flow for a bot where users add money to a wallet and then buy with the balance:

  1. User sends /add 100. The bot calls create-order with order_id = "W<telegram id>-<time>" and saves id -> {user, amount}.
  2. The bot sends a QR photo plus buttons Open payment page (pay_url) and I have paid.
  3. The user pays payable. The gateway calls your /hexpay-webhook.
  4. The bot verifies the signature and credits the wallet with amount (not payable) once, then messages the user.
  5. Backup: the bot also polls the status every 5 seconds for 11 minutes and when the user taps I have paid.
Bot needs a public URL. The webhook must reach your bot server over public https. On a VPS / hosting, point a domain to it. On your own PC use ngrok http 3000 or cloudflared tunnel.
bot-node.js (npm i telegraf express qrcode)
const { Telegraf, Markup } = require('telegraf');
const express = require('express');
const QRCode  = require('qrcode');
const crypto  = require('crypto');

const GW         = 'https://hexupi.xyz';
const API_KEY    = process.env.HEX_API_KEY;
const PUBLIC_URL = process.env.PUBLIC_URL;
const bot        = new Telegraf(process.env.BOT_TOKEN);

const wallet = {};   // DEMO - use a real database
const orders = {};   // gateway id -> { tgId, amount, credited }

async function gw(path, body) {
    const r = await fetch(GW + path, {
        method: body ? 'POST' : 'GET',
        headers: { 'x-api-key': API_KEY, 'Content-Type': 'application/json' },
        body: body ? JSON.stringify(body) : undefined,
    });
    const d = await r.json();
    if (!r.ok || d.error) throw new Error(d.error || 'Gateway error ' + r.status);
    return d;
}

function creditOnce(orderId) {
    const o = orders[orderId];
    if (!o || o.credited) return false;
    o.credited = true;
    wallet[o.tgId] = (wallet[o.tgId] || 0) + o.amount;
    bot.telegram
       .sendMessage(o.tgId, `Added Rs.${o.amount}.\nBalance: Rs.${wallet[o.tgId]}`)
       .catch(() => {});
    return true;
}

bot.command('balance', ctx =>
    ctx.reply(`Wallet: Rs.${wallet[ctx.from.id] || 0}`)
);

// /add 100
bot.command('add', async ctx => {
    const amount = Number((ctx.message.text.split(/\s+/)[1] || '').trim());
    if (!(amount >= 1 && amount <= 100000)) {
        return ctx.reply('Usage: /add 100');
    }
    try {
        const o = await gw('/api/v1/create-order', {
            amount,
            order_id: `W${ctx.from.id}-${Date.now()}`,
            webhook:  PUBLIC_URL + '/hexpay-webhook',
        });
        orders[o.id] = { tgId: ctx.from.id, amount, credited: false };

        // o.qr is SVG; Telegram needs PNG -> build PNG from upi link
        const png = await QRCode.toBuffer(o.upi, { width: 512, margin: 2 });
        await ctx.replyWithPhoto({ source: png }, {
            caption:
                `Pay exactly Rs.${o.payable} (valid 10 min)\n` +
                `Scan this QR with any UPI app.`,
            ...Markup.inlineKeyboard([
                [Markup.button.url('Open payment page', o.pay_url)],
                [Markup.button.callback('I have paid', 'chk:' + o.id)],
            ]),
        });

        // backup poll every 5s for 11 min
        let n = 0;
        const t = setInterval(async () => {
            if (orders[o.id].credited || ++n > 132) return clearInterval(t);
            try {
                const s = await gw('/api/v1/status/' + o.id);
                if (s.paid) { creditOnce(o.id); clearInterval(t); }
                else if (s.status === 'EXPIRED') clearInterval(t);
            } catch (e) {}
        }, 5000);
    } catch (e) {
        ctx.reply('Error: ' + e.message);
    }
});

bot.action(/^chk:(.+)$/, async ctx => {
    try {
        const s = await gw('/api/v1/status/' + ctx.match[1]);
        if (s.paid) {
            creditOnce(ctx.match[1]);
            return ctx.answerCbQuery('Paid');
        }
        ctx.answerCbQuery('Status: ' + s.status);
    } catch (e) {
        ctx.answerCbQuery('Try again');
    }
});

// Webhook
const app = express();
app.post('/hexpay-webhook', express.raw({ type: '*/*' }), (req, res) => {
    const sig  = String(req.headers['x-signature'] || '');
    const calc = crypto.createHmac('sha256', API_KEY).update(req.body).digest('hex');
    const ok = sig.length === calc.length
        && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(calc));
    if (!ok) return res.status(401).send('bad signature');
    const ev = JSON.parse(req.body.toString());
    if (ev.event === 'payment.success') creditOnce(ev.id);
    res.send('ok');
});

app.listen(process.env.PORT || 3000);
bot.launch();
bot-python.py (pip install pyTelegramBotAPI flask qrcode[pil] requests)
import os, io, time, hmac, hashlib, json, threading
import requests, qrcode, telebot
from telebot import types
from flask import Flask, request, abort

GW         = "https://hexupi.xyz"
API_KEY    = os.environ["HEX_API_KEY"]
PUBLIC_URL = os.environ["PUBLIC_URL"]
bot        = telebot.TeleBot(os.environ["BOT_TOKEN"])
app        = Flask(__name__)

wallet, orders, lock = {}, {}, threading.Lock()


def gw(path, body=None):
    r = requests.request("POST" if body else "GET", GW + path, json=body,
                         headers={"x-api-key": API_KEY}, timeout=20)
    d = r.json()
    if not r.ok or d.get("error"):
        raise Exception(d.get("error") or f"Gateway error {r.status_code}")
    return d


def credit_once(order_id):
    with lock:
        o = orders.get(order_id)
        if not o or o["credited"]:
            return
        o["credited"] = True
        wallet[o["tg"]] = wallet.get(o["tg"], 0) + o["amount"]
        bal = wallet[o["tg"]]
    bot.send_message(o["tg"], f"Added Rs.{o['amount']}.\nBalance: Rs.{bal}")


@bot.message_handler(commands=["balance"])
def balance(m):
    bot.reply_to(m, f"Wallet: Rs.{wallet.get(m.from_user.id, 0)}")


@bot.message_handler(commands=["add"])
def add(m):
    try:
        amount = float(m.text.split()[1])
        assert 1 <= amount <= 100000
    except Exception:
        return bot.reply_to(m, "Usage: /add 100")
    try:
        o = gw("/api/v1/create-order", {
            "amount": amount,
            "order_id": f"W{m.from_user.id}-{int(time.time())}",
            "webhook": PUBLIC_URL + "/hexpay-webhook",
        })
        orders[o["id"]] = {"tg": m.from_user.id, "amount": amount, "credited": False}

        buf = io.BytesIO()
        qrcode.make(o["upi"]).save(buf, "PNG")
        buf.seek(0)

        kb = types.InlineKeyboardMarkup()
        kb.add(types.InlineKeyboardButton("Open payment page", url=o["pay_url"]))
        kb.add(types.InlineKeyboardButton("I have paid", callback_data="chk:" + o["id"]))

        bot.send_photo(m.chat.id, buf, reply_markup=kb,
                       caption=f"Pay exactly Rs.{o['payable']} (valid 10 min)")
        threading.Thread(target=poll, args=(o["id"],), daemon=True).start()
    except Exception as e:
        bot.reply_to(m, "Error: " + str(e))


def poll(oid):
    for _ in range(132):
        time.sleep(5)
        if orders[oid]["credited"]:
            return
        try:
            s = gw("/api/v1/status/" + oid)
            if s["paid"]:
                return credit_once(oid)
            if s["status"] == "EXPIRED":
                return
        except Exception:
            pass


@bot.callback_query_handler(func=lambda c: c.data.startswith("chk:"))
def check(c):
    oid = c.data[4:]
    try:
        s = gw("/api/v1/status/" + oid)
        if s["paid"]:
            credit_once(oid)
        bot.answer_callback_query(c.id, "Paid" if s["paid"] else "Status: " + s["status"])
    except Exception:
        bot.answer_callback_query(c.id, "Try again")


@app.post("/hexpay-webhook")
def hook():
    raw  = request.get_data()
    calc = hmac.new(API_KEY.encode(), raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(calc, request.headers.get("x-signature", "")):
        abort(401)
    ev = json.loads(raw)
    if ev.get("event") == "payment.success":
        credit_once(ev["id"])
    return "ok"


if __name__ == "__main__":
    threading.Thread(target=bot.infinity_polling, daemon=True).start()
    app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000)))
hexpay.php (helpers for your PHP bot)
<?php
const HEX_GW  = 'https://hexupi.xyz';
const HEX_KEY = 'hex_live_xxxxxxxx';


function hex_call($path, $body = null) {
    $ch = curl_init(HEX_GW . $path);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 20,
        CURLOPT_HTTPHEADER     => [
            'x-api-key: ' . HEX_KEY,
            'Content-Type: application/json',
        ],
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    $d = json_decode(curl_exec($ch), true);
    curl_close($ch);
    if (!$d || !empty($d['error'])) {
        throw new Exception($d['error'] ?? 'Gateway error');
    }
    return $d;
}


function hex_create($tgId, $amount, $webhookUrl) {
    return hex_call('/api/v1/create-order', [
        'amount'   => $amount,
        'order_id' => "W{$tgId}-" . time(),
        'webhook'  => $webhookUrl,
    ]);
}


function hex_qr_png_url($upiLink) {
    return 'https://api.qrserver.com/v1/create-qr-code/?size=512x512&data='
        . rawurlencode($upiLink);
}


function hex_status($gatewayOrderId) {
    return hex_call('/api/v1/status/' . rawurlencode($gatewayOrderId));
}


function hex_handle_webhook(callable $creditOnce) {
    $raw = file_get_contents('php://input');
    $sig = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
    if (!hash_equals(hash_hmac('sha256', $raw, HEX_KEY), $sig)) {
        http_response_code(401);
        exit('bad signature');
    }
    $ev = json_decode($raw, true);
    if (($ev['event'] ?? '') === 'payment.success') {
        $creditOnce($ev['id'], (float)$ev['amount']);
    }
    echo 'ok';
}

Buying with the wallet

Deduct and check in one atomic step so two quick taps can never spend the same balance twice.

Node.js
function buy(tgId, price) {
    if ((wallet[tgId] || 0) < price) return false;
    wallet[tgId] -= price;
    return true;
}
Python
def buy(tg_id, price):
    with lock:
        if wallet.get(tg_id, 0) < price:
            return False
        wallet[tg_id] -= price
    return True
PHP (MySQL)
<?php
$st = $pdo->prepare(
    "UPDATE users SET bal = bal - ? WHERE tg_id = ? AND bal >= ?"
);
$st->execute([$price, $tgId, $price]);
if ($st->rowCount() === 1) {
    // deliver product
}
Before real users: Replace the in-memory wallet / orders objects with a real database. Otherwise balances disappear when the bot restarts. Store the gateway order id as a unique column and add the wallet credit in the same transaction that marks the order credited.

Guide: Discord bot

Discord bots work exactly like Telegram bots. Use slash commands. Below is a minimal discord.js v14 example.

Discord bot (discord.js v14)
// npm i discord.js express qrcode
const { Client, GatewayIntentBits, SlashCommandBuilder,
        REST, Routes, AttachmentBuilder } = require('discord.js');
const express = require('express');
const QRCode  = require('qrcode');
const crypto  = require('crypto');

const GW         = 'https://hexupi.xyz';
const API_KEY    = process.env.HEX_API_KEY;
const PUBLIC_URL = process.env.PUBLIC_URL;
const wallet     = {};   // demo

const client = new Client({ intents: [GatewayIntentBits.Guilds] });

async function gw(path, body) {
    const r = await fetch(GW + path, {
        method: body ? 'POST' : 'GET',
        headers: { 'x-api-key': API_KEY, 'Content-Type': 'application/json' },
        body: body ? JSON.stringify(body) : undefined,
    });
    const d = await r.json();
    if (!r.ok || d.error) throw new Error(d.error || 'Gateway error');
    return d;
}

client.once('ready', async () => {
    const cmds = [
        new SlashCommandBuilder()
            .setName('balance')
            .setDescription('Show wallet balance'),
        new SlashCommandBuilder()
            .setName('add')
            .setDescription('Add money to wallet')
            .addIntegerOption(o =>
                o.setName('amount').setDescription('INR').setRequired(true)),
    ].map(c => c.toJSON());
    const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN);
    await rest.put(Routes.applicationCommands(client.user.id), { body: cmds });
    console.log('Ready');
});

client.on('interactionCreate', async it => {
    if (!it.isChatInputCommand()) return;
    const uid = it.user.id;

    if (it.commandName === 'balance')
        return it.reply(`Wallet: Rs.${wallet[uid] || 0}`);

    if (it.commandName === 'add') {
        const amount = it.options.getInteger('amount');
        if (amount < 1 || amount > 100000) return it.reply('Invalid amount');
        await it.deferReply();
        try {
            const o = await gw('/api/v1/create-order', {
                amount,
                order_id: `D${uid}-${Date.now()}`,
                webhook:  PUBLIC_URL + '/hexpay-webhook',
            });
            const png  = await QRCode.toBuffer(o.upi, { width: 512 });
            const file = new AttachmentBuilder(png, { name: 'qr.png' });
            await it.editReply({
                content: `Pay Rs.${o.payable} within 10 min\n${o.pay_url}`,
                files: [file],
            });
        } catch (e) {
            it.editReply('Error: ' + e.message);
        }
    }
});

// Webhook
const app = express();
app.post('/hexpay-webhook', express.raw({ type: '*/*' }), (req, res) => {
    const sig  = String(req.headers['x-signature'] || '');
    const calc = crypto.createHmac('sha256', API_KEY).update(req.body).digest('hex');
    if (sig.length !== calc.length ||
        !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(calc))) {
        return res.status(401).send('bad');
    }
    const ev = JSON.parse(req.body.toString());
    if (ev.event === 'payment.success') {
        const uid = ev.order_id.split('-')[0].slice(1);
        wallet[uid] = (wallet[uid] || 0) + ev.amount;
    }
    res.send('ok');
});
app.listen(3000);
client.login(process.env.DISCORD_TOKEN);

Guide: WhatsApp bot (Twilio)

WhatsApp bots receive a message, call the API, and reply with the QR (as an image URL) plus the payment link.

WhatsApp + Twilio (Node.js)
// npm i express twilio axios
const express = require('express');
const twilio  = require('twilio');
const axios   = require('axios');

const GW         = 'https://hexupi.xyz';
const API_KEY    = process.env.HEX_API_KEY;
const PUBLIC_URL = process.env.PUBLIC_URL;
const { MessagingResponse } = twilio.twiml;
const app = express();
app.use(express.urlencoded({ extended: false }));

app.post('/whatsapp', async (req, res) => {
    const text = (req.body.Body || '').trim();
    const from = req.body.From;
    const tw   = new MessagingResponse();

    // user sends: "add 100"
    const m = text.match(/add\s+(\d+(\.\d+)?)/i);
    if (m) {
        const amount = parseFloat(m[1]);
        try {
            const r = await axios.post(
                GW + '/api/v1/create-order',
                {
                    amount,
                    order_id: `WA${from}-${Date.now()}`,
                    webhook:  PUBLIC_URL + '/hexpay-webhook',
                },
                { headers: { 'x-api-key': API_KEY } }
            );
            const o = r.data;

            // WhatsApp shows images by URL
            const qrUrl =
                'https://api.qrserver.com/v1/create-qr-code/?size=512x512&data='
                + encodeURIComponent(o.upi);

            const msg = tw.message();
            msg.body(`Pay Rs.${o.payable} within 10 min:\n${o.pay_url}`);
            msg.media(qrUrl);
        } catch (e) {
            tw.message('Error: ' + (e.response?.data?.error || e.message));
        }
    } else {
        tw.message('Send: add 100');
    }
    res.type('text/xml').send(tw.toString());
});

app.listen(3000);

Guide: mobile app

Never call the gateway directly from the app. Your API key would be extractable from the APK / IPA. Always route the request through your own backend, then have the app open the pay_url in a webview or browser.
Android (Kotlin)
// Your backend: /api/create-order (calls HexPay with the secret key)
// Android just gets back the pay_url and opens it.

val url  = URL("https://yoursite.com/api/create-order")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json")
conn.outputStream.write("""{"amount":199}""".toByteArray())

val payUrl = JSONObject(
    conn.inputStream.bufferedReader().readText()
).getString("pay_url")

startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(payUrl)))
iOS (Swift)
var req = URLRequest(
    url: URL(string: "https://yoursite.com/api/create-order")!
)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = #"{"amount":199}"#.data(using: .utf8)

URLSession.shared.dataTask(with: req) { data, _, _ in
    guard let data = data,
          let json = try? JSONSerialization
              .jsonObject(with: data) as? [String: Any],
          let payUrl = json["pay_url"] as? String,
          let url = URL(string: payUrl) else { return }
    DispatchQueue.main.async { UIApplication.shared.open(url) }
}.resume()
Flutter
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
import 'dart:convert';

Future<void> pay() async {
    final r = await http.post(
        Uri.parse('https://yoursite.com/api/create-order'),
        headers: {'Content-Type': 'application/json'},
        body: jsonEncode({'amount': 199}),
    );
    final o = jsonDecode(r.body);
    await launchUrl(Uri.parse(o['pay_url']));
}
React Native
import { Linking } from 'react-native';

const res = await fetch('https://yoursite.com/api/create-order', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ amount: 199 }),
});
const o = await res.json();
Linking.openURL(o.pay_url);

Your backend /api/create-order does the real gateway call:

Backend (Node.js)
app.post('/api/create-order', async (req, res) => {
    const r = await fetch(
        'https://hexupi.xyz/api/v1/create-order',
        {
            method: 'POST',
            headers: {
                'x-api-key': process.env.HEX_API_KEY,
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                amount:   req.body.amount,
                order_id: 'APP-' + Date.now(),
                webhook:  'https://yoursite.com/hexpay-webhook',
            }),
        }
    );
    const o = await r.json();
    res.json({ pay_url: o.pay_url, id: o.id });
});

Guide: WordPress / WooCommerce

Two ways: a tiny custom plugin, or paste the PHP snippet into a page template.

Simple: custom plugin

wp-content/plugins/hexpay/hexpay.php
<?php
/*
Plugin Name: HexPay Gateway
Description: Adds a "Pay with UPI" button via shortcode
             [hexpay amount="199"]
*/
add_shortcode('hexpay', function ($atts) {
    $a = shortcode_atts(
        ['amount' => 1, 'label' => 'Pay with UPI'],
        $atts
    );
    $nonce = wp_create_nonce('hexpay');
    return "<form method='post'>
        <input type='hidden' name='hexpay_nonce' value='$nonce'>
        <input type='hidden' name='hexpay_amount' value='{$a['amount']}'>
        <button type='submit'>{$a['label']}</button>
    </form>";
});

add_action('init', function () {
    if (empty($_POST['hexpay_nonce'])
        || !wp_verify_nonce($_POST['hexpay_nonce'], 'hexpay')) return;

    $amount = (float)$_POST['hexpay_amount'];
    $r = wp_remote_post(
        'https://hexupi.xyz/api/v1/create-order',
        [
            'headers' => [
                'x-api-key'    => get_option('hexpay_key'),
                'Content-Type' => 'application/json',
            ],
            'body' => wp_json_encode([
                'amount'   => $amount,
                'order_id' => 'WP-' . time(),
                'webhook'  => home_url('/?hexpay_webhook=1'),
                'redirect' => home_url('/thank-you'),
            ]),
        ]
    );
    $o = json_decode(wp_remote_retrieve_body($r), true);
    if (!empty($o['pay_url'])) {
        wp_redirect($o['pay_url']);
        exit;
    }
});

// Webhook
add_action('init', function () {
    if (empty($_GET['hexpay_webhook'])) return;
    $raw = file_get_contents('php://input');
    $key = get_option('hexpay_key');
    $sig = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
    if (!hash_equals(hash_hmac('sha256', $raw, $key), $sig)) {
        status_header(401);
        exit('bad');
    }
    $ev = json_decode($raw, true);
    if (($ev['event'] ?? '') === 'payment.success') {
        // mark order paid, deliver product
    }
    status_header(200);
    echo 'ok';
    exit;
});

WooCommerce: hook into the built-in checkout

functions.php / plugin
<?php
add_action('woocommerce_thankyou', function ($order_id) {
    $order = wc_get_order($order_id);
    if ($order->get_payment_method() !== 'hexpay') return;

    $r = wp_remote_post(
        'https://hexupi.xyz/api/v1/create-order',
        [
            'headers' => [
                'x-api-key'    => get_option('hexpay_key'),
                'Content-Type' => 'application/json',
            ],
            'body' => wp_json_encode([
                'amount'   => (float)$order->get_total(),
                'order_id' => 'WC-' . $order_id,
                'webhook'  => home_url('/?hexpay_webhook=1'),
                'redirect' => $order->get_checkout_order_received_url(),
            ]),
        ]
    );
    $o = json_decode(wp_remote_retrieve_body($r), true);
    if (!empty($o['pay_url'])) wp_redirect($o['pay_url']);
});

Guide: Shopify / custom cart

Shopify does not allow arbitrary redirects to third-party gateways on checkout. Usual approach: build a Buy Button or Custom App that calls your server, or use the Draft Order flow. Simplest: on your own landing page before Shopify checkout, call the gateway, collect the payment, then create the Shopify order via Admin API.

Server-side flow (Node.js)
// 1. Customer clicks "Pay" on your page
app.post('/buy', async (req, res) => {
    const r = await fetch(
        'https://hexupi.xyz/api/v1/create-order',
        {
            method: 'POST',
            headers: {
                'x-api-key': process.env.HEX_API_KEY,
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                amount:   req.body.total,
                order_id: 'SHOP-' + Date.now(),
                webhook:  'https://yoursite.com/hexpay-webhook',
            }),
        }
    );
    const o = await r.json();
    res.json({ pay_url: o.pay_url });
});

// 2. In /hexpay-webhook, after payment.success:
//    create a Shopify order via Admin API and fulfil it.
async function createShopifyOrder(ev) {
    await fetch(
        `https://${process.env.SHOP}/admin/api/2024-10/orders.json`,
        {
            method: 'POST',
            headers: {
                'X-Shopify-Access-Token': process.env.SHOPIFY_TOKEN,
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                order: {
                    line_items: [],
                    financial_status: 'paid',
                    note: ev.order_id,
                },
            }),
        }
    );
}

QR codes

The easiest option is to send the customer the pay_url: that page already shows a QR code, UPI app buttons and live status. If you want to show the QR yourself:

  • Website / app screen: the qr field is an SVG data-URI. Put it straight into an <img src>.
  • Telegram, WhatsApp, Discord uploads: they need a PNG/JPG, not SVG. Make a PNG from the upi link (it contains the exact payable amount and order note).
HTML
<!-- the "qr" field is an SVG data-URI, it works directly in an <img> -->
<img src="DATA_URI_FROM_qr_FIELD" width="260" alt="Scan to pay">
Node.js
const QRCode = require("qrcode");               // npm i qrcode
const png = await QRCode.toBuffer(order.upi, { width: 512, margin: 2 });
// send png to Telegram / Discord / WhatsApp
Python
import io, qrcode                                 # pip install qrcode[pil]
buf = io.BytesIO()
qrcode.make(order["upi"]).save(buf, "PNG")
buf.seek(0)
PHP
<?php
// composer require endroid/qr-code
use Endroid\QrCode\Builder\Builder;

$png = Builder::create()
    ->data($order["upi"])
    ->size(512)
    ->margin(10)
    ->build()
    ->getString();
Always show the amount. The QR already contains the amount, but print payable in the message too. If a customer types the amount by hand, it must match exactly.

Testing & go-live checklist

  1. In My Gateway press Test connection (it must say Connected) and then Create test order (a real Rs.1 order). Pay it from a different UPI account and watch it turn PAID.
  2. Run the fake webhook command against your receiver: wrong signature must give 401, correct one 200.
  3. Create an order with your code, pay it, and check that the webhook fired once and your product / wallet credit happened once.
  4. Open Webhook logs to see every delivery attempt and its HTTP result.

Before real customers

  • API key only on the server, loaded from an environment variable.
  • Webhook signature verified on the raw body, product delivered only from the webhook / status API.
  • Idempotent: the same id never credits or delivers twice.
  • Unique order_id for every order.
  • Real database instead of in-memory data; logs for failed gateway calls.
  • Payment alert mails stay in the Gmail Inbox (not Spam) and IMAP stays enabled.

Troubleshooting

ProblemCheck
Customer paid but order stays PENDINGDid the customer pay the exact payable? Is the alert mail in the Gmail Inbox (not Spam)? Is IMAP on and the App Password still valid? Press Test connection in My Gateway.
Webhook never arrivesURL must be public https and answer 2xx. Open Webhook logs.
Signature does not matchUse the raw body (express.raw, request.get_data(), php://input). Use the same API key that created the order.
Wallet credited twiceMake the credit idempotent by gateway id (unique column / "already credited" flag).
Gateway not configuredSave UPI ID + Gmail + App Password in My Gateway.
QR does not show in TelegramTelegram does not accept SVG. Build a PNG from the upi link, see QR codes.
Order expired while payingOrders live 10 minutes. Create a new order; late payments within about 20 minutes are still matched.

Still stuck? Contact support on Telegram @FrenzyHex with your order id.

Copied to clipboard