Payments
Accept payments
Create a payment, send the user to the HTP cashier and confirm it on your server.
1. Create the payment
Create the payment on your server when the user checks out.
POST /open/v1/payments
Idempotency-Key: 5c1e0f8a2b9d4e7f{
"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:
{
"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_urlas 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:
- a verified
payment.paidcallback, or GET /open/v1/orders/{id}returningstatus: "PAID".
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:
POST /open/v1/payments/0b7a9c62-3f1e-4a2b-9d6c-5e4f3a2b1c0d/closeNo 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
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;
}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"]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)
}// 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();
}