Payments
Accept payments
Create a payment, send the user to the UC Card 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 UC Card cashier, a page that runs inside the UC Card App and uses the user's UC Card session. Opened in an ordinary browser it cannot sign the user in and asks them to open it in the UC Card App. Typical patterns:
- Desktop web — show
checkout_urlas a QR code for the user to scan with the UC Card App. - Your page already runs in the UC Card App (an H5 app opened from UC Card) — navigate to
checkout_url. - Mobile web or native app — hand the link to the UC Card App (QR code, share, or the app link your UC Card 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 UC Card. 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();
}