Get started

Quickstart

Take your first sandbox payment in about 15 minutes.

Code samples are in Node.js, Python, Go, Java and PHP. Pick your language on any code tab and every step switches with it.

Get sandbox credentials

In the Developer Console:

  1. Create an app and apply for payments (sandbox works before your app is published).
  2. Once payments are Enabled, open Payments → API keys and create a Sandbox key. Copy the key_id (ok_test_…) and the secret — the secret is shown only once.
  3. Set your callback URL and copy the callback signing secret (also shown once).

Store them as environment variables:

bash
export HTP_API_BASE=https://htp-open-api.vai247.pro
export HTP_KEY_ID=ok_test_xxxxxxxx
export HTP_API_SECRET=xxxxxxxxxxxxxxxx
export HTP_CALLBACK_SECRET=xxxxxxxxxxxxxxxx

Add a signing helper

Every request is signed with HMAC-SHA256. Save this helper in your project (the file name is in the first line):

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

const { HTP_API_BASE, HTP_KEY_ID, HTP_API_SECRET } = process.env;

export async function htp(method, path, body, idempotencyKey = '') {
  const raw = body === undefined ? '' : JSON.stringify(body);
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const nonce = crypto.randomBytes(18).toString('base64url'); // 24 URL-safe chars
  const bodyHash = crypto.createHash('sha256').update(raw).digest('hex');
  const stringToSign = [method, path, timestamp, nonce, idempotencyKey, bodyHash].join('\n');
  const signature = crypto.createHmac('sha256', HTP_API_SECRET).update(stringToSign).digest('base64');

  const headers = {
    'Content-Type': 'application/json',
    'X-Key-Id': HTP_KEY_ID,
    'X-Timestamp': timestamp,
    'X-Nonce': nonce,
    'X-Signature': signature,
  };
  if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;

  const res = await fetch(HTP_API_BASE + path, { method, headers, body: raw || undefined });
  const json = await res.json();
  if (!res.ok) {
    throw Object.assign(new Error(json.message), { code: json.error_code, status: res.status, traceId: json.trace_id });
  }
  return json.data;
}
Python
# htp.py  (pip install requests)
import base64, hashlib, hmac, json, os, secrets, time
import requests

API_BASE = os.environ["HTP_API_BASE"]
KEY_ID = os.environ["HTP_KEY_ID"]
SECRET = os.environ["HTP_API_SECRET"].encode()


def htp(method, path, body=None, idempotency_key=""):
    raw = b"" if body is None else json.dumps(body, separators=(",", ":")).encode()
    timestamp = str(int(time.time()))
    nonce = secrets.token_urlsafe(18)  # 24 URL-safe chars
    body_hash = hashlib.sha256(raw).hexdigest()
    string_to_sign = "\n".join([method, path, timestamp, nonce, idempotency_key, body_hash])
    signature = base64.b64encode(hmac.new(SECRET, string_to_sign.encode(), hashlib.sha256).digest()).decode()

    headers = {
        "Content-Type": "application/json",
        "X-Key-Id": KEY_ID,
        "X-Timestamp": timestamp,
        "X-Nonce": nonce,
        "X-Signature": signature,
    }
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key

    res = requests.request(method, API_BASE + path, headers=headers, data=raw or None, timeout=15)
    payload = res.json()
    if not res.ok:
        raise RuntimeError(f"{payload['error_code']}: {payload['message']} (trace {payload['trace_id']})")
    return payload["data"]
Go
// htp.go
package main

import (
	"bytes"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

func htp(method, path string, body any, idempotencyKey string, out any) error {
	var raw []byte
	if body != nil {
		var err error
		if raw, err = json.Marshal(body); err != nil {
			return err
		}
	}
	timestamp := strconv.FormatInt(time.Now().Unix(), 10)
	nb := make([]byte, 18)
	rand.Read(nb)
	nonce := base64.RawURLEncoding.EncodeToString(nb) // 24 URL-safe chars
	sum := sha256.Sum256(raw)
	stringToSign := strings.Join([]string{method, path, timestamp, nonce, idempotencyKey, hex.EncodeToString(sum[:])}, "\n")
	mac := hmac.New(sha256.New, []byte(os.Getenv("HTP_API_SECRET")))
	mac.Write([]byte(stringToSign))
	signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))

	req, err := http.NewRequest(method, os.Getenv("HTP_API_BASE")+path, bytes.NewReader(raw))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-Key-Id", os.Getenv("HTP_KEY_ID"))
	req.Header.Set("X-Timestamp", timestamp)
	req.Header.Set("X-Nonce", nonce)
	req.Header.Set("X-Signature", signature)
	if idempotencyKey != "" {
		req.Header.Set("Idempotency-Key", idempotencyKey)
	}

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	data, _ := io.ReadAll(res.Body)
	var env struct {
		Data      json.RawMessage `json:"data"`
		ErrorCode string          `json:"error_code"`
		Message   string          `json:"message"`
		TraceID   string          `json:"trace_id"`
	}
	if err := json.Unmarshal(data, &env); err != nil {
		return err
	}
	if res.StatusCode >= 300 {
		return fmt.Errorf("%s: %s (trace %s)", env.ErrorCode, env.Message, env.TraceID)
	}
	return json.Unmarshal(env.Data, out)
}
Java
// Htp.java  (Java 17+)
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.time.Instant;
import java.util.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class Htp {
    private static final HttpClient HTTP = HttpClient.newHttpClient();
    private static final SecureRandom RANDOM = new SecureRandom();

    /** body: a JSON string, or null. Returns the response JSON. */
    public static String call(String method, String path, String body, String idempotencyKey) throws Exception {
        String raw = body == null ? "" : body;
        String idem = idempotencyKey == null ? "" : idempotencyKey;
        String timestamp = String.valueOf(Instant.now().getEpochSecond());
        byte[] nb = new byte[18];
        RANDOM.nextBytes(nb);
        String nonce = Base64.getUrlEncoder().withoutPadding().encodeToString(nb); // 24 URL-safe chars
        String bodyHash = HexFormat.of().formatHex(
            MessageDigest.getInstance("SHA-256").digest(raw.getBytes(StandardCharsets.UTF_8)));
        String stringToSign = String.join("\n", method, path, timestamp, nonce, idem, bodyHash);
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(System.getenv("HTP_API_SECRET").getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        String signature = Base64.getEncoder().encodeToString(mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)));

        HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(System.getenv("HTP_API_BASE") + path))
            .header("Content-Type", "application/json")
            .header("X-Key-Id", System.getenv("HTP_KEY_ID"))
            .header("X-Timestamp", timestamp)
            .header("X-Nonce", nonce)
            .header("X-Signature", signature)
            .method(method, raw.isEmpty() ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(raw));
        if (!idem.isEmpty()) req.header("Idempotency-Key", idem);

        HttpResponse<String> res = HTTP.send(req.build(), HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 300) throw new RuntimeException("HTTP " + res.statusCode() + ": " + res.body());
        return res.body(); // {"data": {...}} — parse with Jackson, Gson, etc.
    }
}
PHP
<?php
// htp.php
function htp(string $method, string $path, ?array $body = null, string $idempotencyKey = ''): array
{
    $raw = $body === null ? '' : json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    $timestamp = (string) time();
    $nonce = rtrim(strtr(base64_encode(random_bytes(18)), '+/', '-_'), '='); // 24 URL-safe chars
    $stringToSign = implode("\n", [$method, $path, $timestamp, $nonce, $idempotencyKey, hash('sha256', $raw)]);
    $signature = base64_encode(hash_hmac('sha256', $stringToSign, getenv('HTP_API_SECRET'), true));

    $headers = [
        'Content-Type: application/json',
        'X-Key-Id: ' . getenv('HTP_KEY_ID'),
        'X-Timestamp: ' . $timestamp,
        'X-Nonce: ' . $nonce,
        'X-Signature: ' . $signature,
    ];
    if ($idempotencyKey !== '') {
        $headers[] = 'Idempotency-Key: ' . $idempotencyKey;
    }

    $ch = curl_init(getenv('HTP_API_BASE') . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 15,
    ]);
    if ($raw !== '') {
        curl_setopt($ch, CURLOPT_POSTFIELDS, $raw);
    }
    $res = json_decode(curl_exec($ch), true);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    if ($status >= 300) {
        throw new RuntimeException("{$res['error_code']}: {$res['message']} (trace {$res['trace_id']})", $status);
    }
    return $res['data'];
}

Create a payment

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

const orderNo = 'A' + Date.now();
const payment = await htp(
  'POST',
  '/open/v1/payments',
  { out_order_no: orderNo, amount: '1.00', currency: 'USDT', description: 'Test order' },
  crypto.randomUUID().replaceAll('-', ''), // Idempotency-Key — reuse it if you retry
);

console.log(payment.id, payment.status); // …  CREATED
console.log(payment.checkout_url);
Python
# create_payment.py
import time, uuid
from htp import htp

order_no = f"A{int(time.time() * 1000)}"
payment = htp(
    "POST",
    "/open/v1/payments",
    {"out_order_no": order_no, "amount": "1.00", "currency": "USDT", "description": "Test order"},
    uuid.uuid4().hex,  # Idempotency-Key — reuse it if you retry
)

print(payment["id"], payment["status"])  # …  CREATED
print(payment["checkout_url"])
Go
// main.go
package main

import (
	"crypto/rand"
	"encoding/hex"
	"fmt"
	"log"
	"time"
)

func main() {
	idem := make([]byte, 16)
	rand.Read(idem) // Idempotency-Key — reuse it if you retry

	var payment struct {
		ID          string `json:"id"`
		Status      string `json:"status"`
		CheckoutURL string `json:"checkout_url"`
	}
	err := htp("POST", "/open/v1/payments", map[string]string{
		"out_order_no": fmt.Sprintf("A%d", time.Now().UnixMilli()),
		"amount":       "1.00",
		"currency":     "USDT",
		"description":  "Test order",
	}, hex.EncodeToString(idem), &payment)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(payment.ID, payment.Status) // …  CREATED
	fmt.Println(payment.CheckoutURL)
}
Java
// CreatePayment.java
import java.util.UUID;

public class CreatePayment {
    public static void main(String[] args) throws Exception {
        String orderNo = "A" + System.currentTimeMillis();
        String body = "{\"out_order_no\":\"" + orderNo + "\",\"amount\":\"1.00\",\"currency\":\"USDT\",\"description\":\"Test order\"}";
        String idem = UUID.randomUUID().toString().replace("-", ""); // Idempotency-Key — reuse it if you retry

        String json = Htp.call("POST", "/open/v1/payments", body, idem);
        System.out.println(json); // data.id, data.status = CREATED, data.checkout_url
    }
}
PHP
<?php
// create-payment.php
require __DIR__ . '/htp.php';

$payment = htp(
    'POST',
    '/open/v1/payments',
    ['out_order_no' => 'A' . floor(microtime(true) * 1000), 'amount' => '1.00', 'currency' => 'USDT', 'description' => 'Test order'],
    bin2hex(random_bytes(16)), // Idempotency-Key — reuse it if you retry
);

echo $payment['id'], ' ', $payment['status'], PHP_EOL; // …  CREATED
echo $payment['checkout_url'], PHP_EOL;

Pay with a test user

Open checkout_url in the UC Card App, signed in as one of the app's test users (add them in the console under Test users), and confirm with the payment PIN.

Use a test user, not your own account

A developer can never pay their own app (SELF_PAYMENT_FORBIDDEN), in sandbox or production. Sandbox payments can only be paid by the app's test users (SANDBOX_USER_ONLY for anyone else).

Receive the callback

UC Card POSTs payment.paid to your callback URL. Verify the signature before trusting it — copy the handler from Callbacks.

json
{
  "event_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "event": "payment.paid",
  "created_at": "2026-11-07T09:05:12Z",
  "data": { "id": "0b7a9c62-…", "out_order_no": "A1730970000000", "status": "PAID", "amount": "1.00", "net_amount": "0.99", "user_sub": "2f6c0d1e8a9b4c7d", "…": "…" }
}

No public URL yet? Query the order instead:

Node.js
const order = await htp('GET', `/open/v1/orders/${payment.id}`);
console.log(order.status); // PAID
Python
order = htp("GET", f"/open/v1/orders/{payment['id']}")
print(order["status"])  # PAID
Go
var order struct{ Status string `json:"status"` }
if err := htp("GET", "/open/v1/orders/"+payment.ID, nil, "", &order); err != nil {
	log.Fatal(err)
}
fmt.Println(order.Status) // PAID
Java
String order = Htp.call("GET", "/open/v1/orders/" + paymentId, null, null);
System.out.println(order); // data.status = PAID
PHP
$order = htp('GET', '/open/v1/orders/' . $payment['id']);
echo $order['status'], PHP_EOL; // PAID

You're done

Next: handle refunds, test the failure cases in Sandbox & testing, then work through the Go-live checklist.