Payments

Accept payments

Create a payment, send the user to the HTP cashier and confirm it on your server.

User / HTP AppYour serverHTP Open API1. Checkout on your app2. POST /open/v1/payments3. checkout_url4. Open checkout_url in HTP App5. Confirm with payment PIN6. Callback payment.paid (signed)7. Verify, mark paid, return 2xx8. Show success

1. Create the payment

Create the payment on your server when the user checks out.

http
POST /open/v1/payments
Idempotency-Key: 5c1e0f8a2b9d4e7f
json
{
  "out_order_no": "A20261107-0001",
  "amount": "40.00",
  "currency": "USDT",
  "description": "Premium membership, 1 month",
  "payer_sub": "2f6c0d1e8a9b4c7d",
  "expire_minutes": 30
}
Field Required Notes
out_order_no ✓ Your order number, [A-Za-z0-9_-]{1,64}, unique per environment
amount ✓ USDT, at most 2 decimals. Must be more than the fee.
currency ✓ USDT
description Shown to the user in the cashier (max 255)
payer_sub Lock the payment to this user. Recommended when the user is signed in.
expire_minutes 1–1440. Default 30.

The response is the order with status: "CREATED" and a checkout_url:

json
{
  "id": "0b7a9c62-3f1e-4a2b-9d6c-5e4f3a2b1c0d",
  "out_order_no": "A20261107-0001",
  "order_type": "PAYMENT",
  "environment": "PRODUCTION",
  "status": "CREATED",
  "amount": "40.00",
  "refunded_amount": "0.00",
  "fee_amount": "0.40",
  "net_amount": "39.60",
  "currency": "USDT",
  "user_sub": "2f6c0d1e8a9b4c7d",
  "checkout_url": "https://app.htp.example/open/checkout?token=…",
  "expires_at": "2026-11-07T09:30:00Z",
  "created_at": "2026-11-07T09:00:00Z"
}

Save id with your order. Checks at this point return errors such as LIMIT_EXCEEDED, USER_NOT_AUTHORIZED (the payer_sub never signed in or revoked access), SELF_PAYMENT_FORBIDDEN or SANDBOX_USER_ONLY.

2. Send the user to the cashier

checkout_url is the HTP cashier, a page that runs inside the HTP App and uses the user's HTP session. Opened in an ordinary browser it cannot sign the user in and asks them to open it in the HTP App. Typical patterns:

  • Desktop web — show checkout_url as a QR code for the user to scan with the HTP App.
  • Your page already runs in the HTP App (an H5 app opened from HTP) — navigate to checkout_url.
  • Mobile web or native app — hand the link to the HTP App (QR code, share, or the app link your HTP contact provides).

The cashier shows your app name, logo, developer name, amount and description. The user pays the full amount (the payer never pays the fee) and confirms with their payment PIN. There is no redirect back: the user closes the page and returns to your app.

checkout_url is a bearer link

Anyone who opens it can pay (unless you set payer_sub). Show it only to the buyer, and don't log it.

3. Confirm on your server

Mark the order paid only after one of:

While the user is on the cashier, your page can poll your own backend, which in turn checks its database (updated by the callback) and, if needed, queries HTP. Never trust the client to tell you a payment succeeded.

Payment statuses

Status Meaning Next
CREATED Waiting for the user Pay, close, or expire
PAID Paid; money is in your wallet (net_amount) Fulfil; may be refunded
CLOSED Closed by you or expired unpaid Final — create a new payment to retry

An unpaid payment past expires_at is reported as CLOSED and has no checkout_url.

Close an unpaid payment

When the user cancels or you change the cart, close the payment so the old link cannot be paid:

http
POST /open/v1/payments/0b7a9c62-3f1e-4a2b-9d6c-5e4f3a2b1c0d/close

No body and no Idempotency-Key. Closing a closed payment is a no-op; closing a paid one returns INVALID_STATE.

Money movement

Payer wallet Your wallet
Payment − amount + net_amount (amount − fee_amount)
Refund + refund amount − refund amount (fee not returned)
Payout + amount − amount

fee_amount = round_half_up(amount × fee_rate) + fee_fixed, in cents. If the fee is not less than the amount, creation fails with INVALID_AMOUNT.

Limits (single, daily, monthly — UTC days and months) are checked when the payment is created and again when the user confirms, so a payment can fail at checkout with LIMIT_EXCEEDED if other payments completed in between.

Full example

Node.js
import crypto from 'node:crypto';
import { htp } from './htp.js';

export async function startCheckout(order, user) {
  // Persist the key with the order so retries reuse it
  order.idempotencyKey ??= crypto.randomBytes(16).toString('hex');
  const payment = await htp(
    'POST',
    '/open/v1/payments',
    {
      out_order_no: order.number,
      amount: order.total, // "40.00" — a string
      currency: 'USDT',
      description: order.title,
      payer_sub: user.htpSub,
    },
    order.idempotencyKey,
  );
  await db.orders.update(order.id, { htpPaymentId: payment.id, status: 'AWAITING_PAYMENT' });
  return payment.checkout_url;
}
Python
import secrets
from htp import htp

def start_checkout(order, user):
    order.idempotency_key = order.idempotency_key or secrets.token_hex(16)
    payment = htp("POST", "/open/v1/payments", {
        "out_order_no": order.number,
        "amount": order.total,          # "40.00" — a string
        "currency": "USDT",
        "description": order.title,
        "payer_sub": user.htp_sub,
    }, order.idempotency_key)
    order.htp_payment_id = payment["id"]
    order.status = "AWAITING_PAYMENT"
    order.save()
    return payment["checkout_url"]
Go
type Order struct {
	ID          string `json:"id"`
	Status      string `json:"status"`
	CheckoutURL string `json:"checkout_url"`
}

func StartCheckout(ctx context.Context, c *htp.Client, o *MyOrder, payerSub string) (string, error) {
	var p Order
	err := c.Do(ctx, "POST", "/open/v1/payments", map[string]any{
		"out_order_no": o.Number,
		"amount":       o.Total, // "40.00"
		"currency":     "USDT",
		"description":  o.Title,
		"payer_sub":    payerSub,
	}, o.IdempotencyKey, &p)
	if err != nil {
		return "", err
	}
	o.HTPPaymentID = p.ID
	return p.CheckoutURL, saveOrder(ctx, o)
}
Java
// HtpClient from /docs/authentication; JSON with Jackson
private final ObjectMapper mapper = new ObjectMapper();

public String startCheckout(MyOrder order, String payerSub) throws Exception {
    if (order.idempotencyKey == null) {
        // Persist the key with the order so retries reuse it
        order.idempotencyKey = UUID.randomUUID().toString().replace("-", "");
    }
    String body = mapper.writeValueAsString(Map.of(
        "out_order_no", order.number,
        "amount", order.total,          // "40.00" — a string
        "currency", "USDT",
        "description", order.title,
        "payer_sub", payerSub));

    HttpResponse<String> res = htp.send("POST", "/open/v1/payments", body, order.idempotencyKey);
    JsonNode json = mapper.readTree(res.body());
    if (res.statusCode() != 200) {
        throw new IllegalStateException(json.path("error_code").asText() + " (trace " + json.path("trace_id").asText() + ")");
    }
    JsonNode payment = json.get("data");
    order.htpPaymentId = payment.get("id").asText();
    order.status = "AWAITING_PAYMENT";
    orders.save(order);
    return payment.get("checkout_url").asText();
}