1. Integration overview
| Information | Value |
|---|---|
| Primary endpoint | https://toctoico.com/chargingws/v2 |
| Fee schedule API | https://toctoico.com/chargingws/v2/getfee?partner_id=YOUR_PARTNER_ID |
| Method | POST |
| Data type | application/json or application/x-www-form-urlencoded |
| Supported commands | charging, check |
2. Requirements before calling the API
Nappay protocol compatibility: change the URL to this website and use the local Merchant's Partner ID/Key and wallet code. Catalogs, prices and processing conditions come from this system.
Integrate directly with this system using your account's Merchant. All amounts are in Vietnamese dong (VND); send integers without separators.
- Sign in → API Connections / Merchant, create a Merchant for the correct service and enable it.
- Get the Partner ID, Partner Key and wallet code from that Merchant. If an IP allowlist is configured, specify the public IP of the server sending requests.
- Configure the receiving system's HTTPS callback URL; select POST JSON or GET according to the Merchant.
- Retrieve catalog/fees and check the balance first; then integrate order creation, lookup and reconciliation.
The Partner Key is for your server only. Do not place it in browser JavaScript, public applications or URLs. YOUR_PARTNER_ID, YOUR_PARTNER_KEY and YOUR_WALLET below are placeholders for your own Merchant details on this website.
Endpoints for card exchange, top-ups and card code purchases
| Service | Endpoint | Format |
|---|---|---|
| Exchange prepaid cards | https://toctoico.com/chargingws/v2 | POST JSON or form-urlencoded |
| Service top-up | https://toctoico.com/api/rechargews | POST JSON or form-urlencoded |
| Buy card codes | https://toctoico.com/api/cardws | POST JSON or form-urlencoded |
| Card exchange fees | https://toctoico.com/chargingws/v2/getfee?partner_id=YOUR_PARTNER_ID | GET |
| Card code catalog | https://toctoico.com/api/cardws/products?partner_id=YOUR_PARTNER_ID | GET |
3. Authentication signatures
Sign the UTF-8 string with MD5 by concatenating fields in the exact order below, without dots, spaces or separators. Send the hexadecimal MD5 string; command must use lowercase exactly as documented.
| Command group | String to hash with MD5 |
|---|---|
| Card exchange: charging | partner_key + code + command + partner_id + request_id + serial + telco |
| Card exchange: check | partner_key + command + partner_id + request_id |
| Card exchange: legacy signature still supported | partner_key + code + serial |
| Topup: topup, getstatus | partner_key + partner_id + command + request_id |
| Topup: productlist, getbalance, feetopup, canceltopup | partner_key + partner_id + command |
| Card codes: buycard, redownload | partner_key + partner_id + command + request_id |
| Card codes: getbalance, checkavailable | partner_key + partner_id + command |
| Card code catalog, getfee | Merchant/IP authentication through partner_id; this endpoint currently does not require sign |
| Serial lookup /api/checkcard | partner_key + serial |
hashlib.md5((partner_key + partner_id + command + request_id).encode("utf-8")).hexdigest()Each Merchant may use only its registered API type (Charging, Recharge, Softcard or All). Authentication/IP errors may appear in JSON even when HTTP is 200.
4. Card exchange parameters and responses
POST /chargingws/v2
Submit cards with command=charging. Check using command=check with partner_id, request_id and sign as shown above; card details do not need to be resubmitted. The legacy MD5(partner_key + code + serial) signature is still supported when code and serial are included.
| Parameters | Request | Meaning / value |
|---|---|---|
| partner_id, sign | Required | Merchant and signature for card exchange |
| command | Required | charging or check |
| request_id | Required | Unique request reference per Merchant; reuse the original reference for check |
| telco | Khi charging | Uppercase network code; retrieve it from getfee |
| amount | Khi charging | Declared denomination; optional for check |
| code, serial | Khi charging | Card code/serial strings; preserve leading zeros and resend exactly the original string for check |
{
"partner_id": "YOUR_PARTNER_ID",
"command": "charging",
"request_id": "CHARGE_DEMO_001",
"telco": "VIETTEL",
"amount": 100000,
"code": "YOUR_CARD_CODE",
"serial": "YOUR_CARD_SERIAL",
"sign": "MD5_THEO_CONG_THUC_CHARGING"
}Recheck transaction
{
"partner_id": "YOUR_PARTNER_ID",
"command": "check",
"request_id": "CHARGE_DEMO_001",
"sign": "MD5_PARTNER_KEY_CHECK_PARTNER_ID_REQUEST_ID"
}Transaction response
{
"trans_id": 123,
"request_id": "CHARGE_DEMO_001",
"status": 99,
"message": "PENDING",
"declared_value": 100000,
"value": 0,
"amount": 0,
"telco": "VIETTEL",
"code": "YOUR_CARD_CODE",
"serial": "YOUR_CARD_SERIAL",
"callback_sign": "MD5_PARTNER_KEY_CODE_SERIAL"
}| Parameters | Request | Meaning / value |
|---|---|---|
| trans_id, request_id | Receive | System transaction ID and your request reference |
| declared_value | Receive | Declared denomination |
| value | Receive | Confirmed actual denomination |
| amount | Receive | Net amount credited to the wallet after processing |
| status, message | Receive | Card status or error; always check both |
| status | Meaning |
|---|---|
| 99 | Pending; not yet credited as a successful order |
| 1 | Valid card, successful |
| 2 | Valid card with incorrect denomination |
| 3 | Failed / invalid card |
| 4 | Skip / maintenance based on processing result |
| 100 | Request error |
| 101 | Transaction not found |
| 102 | Invalid authentication, IP or data |
| 104 | Invalid command |
Merchant-specific fees
GET /chargingws/v2/getfee?partner_id=YOUR_PARTNER_ID. The response is a flat list containing telco, value, fees and penalty, compatible with Nappay. Existing integrations that need grouped objects can add format=grouped. fees is the customer fee for the account group; only enabled card types and denominations are returned. Do not hardcode the catalog or fees from the documentation examples.
[
{
"telco": "VIETTEL",
"value": 100000,
"fees": "18.5",
"penalty": "50"
}
]The group fee is finalized per order when the card is accepted. For an incorrect denomination (status=2), a 50% penalty applies to the net amount after the group fee: credited amount = provider-confirmed denomination × (100 − group fee)% × 50%, rounded down to VND. For example, 10.000 VND with a 26,8% group fee credits 3.660 VND. Use amount from the final result to record the credit; do not recalculate it from the declared denomination. Orders routed to manual processing may remain at status 99 until an operator confirms them; lookup/callback results are the basis for the final update.
4. Top-up parameters and responses
POST /api/rechargews
| command | Additional parameters | Result |
|---|---|---|
| productlist | partner_id, sign | Account-specific catalog and prices |
| getbalance | partner_id, sign | VND wallet balance |
| topup | request_id, service_code, amount, qty=1, account_info | Accepts the order and returns order_code; this does not mean the top-up succeeded |
| getstatus | request_id and order_code of the previous order | Look up the Merchant's matching order |
| feetopup | partner_id, sign | Account-specific list of key, value, price and discount |
| canceltopup | request_id, order_code, sign | Only automatic orders not yet handed over for processing may be canceled; submitted orders or orders with uncertain results are rejected |
| Parameters | Request | Meaning / value |
|---|---|---|
| partner_id, command, sign | All commands | Signature as specified in the authentication table |
| request_id | topup, getstatus | Your request reference, up to 100 letters/numbers and _ . : - for the configured catalog |
| service_code | topup | Use the exact value from productlist, not the display name |
| amount | topup | Denomination or requested top-up amount before discount |
| qty | topup | Always 1 |
| account_info | topup | Object using the exact keys from the product's options; field values are strings |
| order_code | getstatus | System-generated code returned when an order is created; do not substitute the Nappay code or request_id |
1. Retrieve the catalog and required customer fields
{
"partner_id": "YOUR_PARTNER_ID",
"command": "productlist",
"sign": "MD5_PARTNER_KEY_PARTNER_ID_PRODUCTLIST"
}Read status=success and data as a product list. Each product has service_code, name, options and items. options describes key, name and type; select fields include choices. For password/otp fields, keep values on the server and send them only over HTTPS.
{
"status": "success",
"data": [
{
"service_code": "DEMO_GAME",
"name": "Sản phẩm minh họa",
"options": [
{
"key": "game_id",
"name": "ID nhân vật",
"type": "text"
}
],
"amount_mode": "fixed",
"amount_limits": null,
"items": [
{
"value": 100000,
"name": "Gói 100.000đ",
"price": "92500",
"discount": 7.5
}
]
}
]
}price is the payment amount and value is the denomination. discount represents the account discount, calculated as (value − price) / value × 100. Do not add profit or deduct the discount again from price.
2. Fixed packages or customer-entered amounts
amount_mode=fixed: select a value from items. amount_mode=amount: read amount_limits.minimum, maximum and multiple; submit an amount within the allowed range and divisible by multiple. items may contain a technical package with value=1, price=1; this is not a 1 VND package for sale. Use amount_mode and amount_limits to build the form. Refresh the catalog when settings/prices change; the accepted order response contains the finalized price.
3. Submit an order
{
"partner_id": "YOUR_PARTNER_ID",
"command": "topup",
"request_id": "TOPUP_DEMO_001",
"service_code": "DEMO_GAME",
"amount": 100000,
"qty": 1,
"account_info": {
"game_id": "PLAYER_ID"
},
"sign": "MD5_PARTNER_KEY_PARTNER_ID_TOPUP_REQUEST_ID"
}In JSON, send account_info as an object. With form-urlencoded, send account_info as a JSON string or account_info[game_id]=PLAYER_ID; do not send conflicting formats. This game example is illustrative only: retrieve actual fields from productlist rather than assuming every game uses game_id.
{
"status": "success",
"data": {
"time": "2026-10-18 10:00:00",
"request_id": "TOPUP_DEMO_001",
"order_code": "R_DEMO_ORDER",
"pay_amount": 92500,
"discount": 7.5,
"service_code": "DEMO_GAME",
"amount": 100000,
"status": "pending"
}
}4. Lookup and balance
{
"partner_id": "YOUR_PARTNER_ID",
"command": "getstatus",
"request_id": "TOPUP_DEMO_001",
"order_code": "R_DEMO_ORDER",
"sign": "MD5_PARTNER_KEY_PARTNER_ID_GETSTATUS_REQUEST_ID"
}data.status: pending = processing; failed = unsuccessful. On success, order creation responses for the internal catalog may use success, while getstatus and callbacks use completed. Outer status=success only indicates that the API accepted/looked up the request. Check data.status to determine the order result. getstatus returns request_value as the denomination; amount for orders with internal quotes is the payment amount.
{
"status": "success",
"data": {
"account": {
"game_id": "PLAYER_ID"
},
"request_value": 100000,
"amount": 92500,
"request_id": "TOPUP_DEMO_001",
"order_code": "R_DEMO_ORDER",
"status": "completed"
}
}{
"partner_id": "YOUR_PARTNER_ID",
"command": "getbalance",
"sign": "MD5_PARTNER_KEY_PARTNER_ID_GETBALANCE"
}{
"status": "success",
"data": {
"balance": 1000000,
"currency": "VND"
}
}4. Card purchase parameters and responses
POST /api/cardws
| Command / URL | Additional parameters | Result |
|---|---|---|
| GET /api/cardws/products | partner_id | Product array; cardvalue contains value and price |
| checkavailable | service_code, value, qty | stock_available: true/false |
| getbalance | sign; if supplied, wallet_number must match the Merchant | balance and currency_code |
| buycard | request_id, service_code, value, qty, sign | Order code and cards, or a pending status |
| redownload | request_id, sign; order_code is optional | Retrieve a previous order's result |
Catalog and stock
curl "https://toctoico.com/api/cardws/products?partner_id=YOUR_PARTNER_ID"[
{
"name": "Thẻ minh họa",
"service_code": "DEMO_CARD",
"cardvalue": [
{
"service_code": "DEMO_CARD",
"value": 100000,
"id": "DEMO_SKU",
"price": 97000
}
]
}
]A stock check is only a snapshot at the time of the request and does not reserve stock; purchase orders still require processing and balance checks.
{
"partner_id": "YOUR_PARTNER_ID",
"command": "checkavailable",
"service_code": "DEMO_CARD",
"value": 100000,
"qty": 1
}{
"stock_available": true,
"message": "Còn hàng"
}Purchase and retrieve
{
"partner_id": "YOUR_PARTNER_ID",
"command": "buycard",
"request_id": "CARD_DEMO_001",
"wallet_number": "YOUR_WALLET",
"service_code": "DEMO_CARD",
"value": 100000,
"qty": 1,
"sign": "MD5_PARTNER_KEY_PARTNER_ID_BUYCARD_REQUEST_ID"
}qty from 1 to 100, subject to stock and balance. value is the denomination, not the discounted price. service_code and prices come from the Merchant-specific catalog.
{
"status": 1,
"message": "Mua thẻ thành công",
"data": {
"request_id": "CARD_DEMO_001",
"order_code": "S_DEMO_ORDER",
"cards": [
{
"serial": "DEMO_SERIAL",
"code": "DEMO_PIN",
"expired": "2027-12-31"
}
]
}
}| status | Meaning |
|---|---|
| 1 | Completed; retrieve cards from data |
| 2 | The provider order is processing; keep request_id and order_code for lookup |
| 3 | The provider order was canceled / refunded |
| Other codes or error | Request error; read message and HTTP status |
{
"partner_id": "YOUR_PARTNER_ID",
"command": "redownload",
"request_id": "CARD_DEMO_001",
"sign": "MD5_PARTNER_KEY_PARTNER_ID_REDOWNLOAD_REQUEST_ID"
}While pending or disconnected, prefer redownload using the received order code. Do not change request_id and buy again. Some processing paths return duplicate-reference errors when buycard is retried, so redownload is the main reconciliation step. Store codes/PINs securely on your server.
{
"partner_id": "YOUR_PARTNER_ID",
"command": "getbalance",
"wallet_number": "YOUR_WALLET",
"sign": "MD5_PARTNER_KEY_PARTNER_ID_GETBALANCE"
}{
"balance": 1000000,
"currency_code": "VND"
}5. Callbacks and reconciliation
Register your callback URL in the Merchant on this website. The system sends card exchange/top-up results using POST JSON or GET according to the Merchant settings. Callbacks may be retried; handle repeats idempotently by order code and status.
| Service | Verify callback_sign | Main fields |
|---|---|---|
| Exchange cards | MD5(partner_key + code + serial) | status, message, request_id, trans_id, declared_value, value, amount, telco, code, serial |
| Topup | MD5(partner_key + order_code) | status, request_id, order_code, request_value, amount, account |
Top-up callbacks use status=completed for successful orders; request_value is the requested top-up amount and amount is the payment amount if a finalized quote exists. Order lookups may return data.status=success for the same outcome. GET card exchange callbacks also include card_value.
{
"status": "completed",
"request_id": "TOPUP_DEMO_001",
"order_code": "R_DEMO_ORDER",
"request_value": 100000,
"amount": 92500,
"account": {
"game_id": "PLAYER_ID"
},
"callback_sign": "MD5_PARTNER_KEY_ORDER_CODE"
}- Verify the signature using the corresponding Merchant key with a timing-safe comparison; verify that the order belongs to your system.
- Call check/getstatus to confirm the status and verify the denomination/amount before recording a result. The compatible signature does not sign all status fields.
- Record within a database transaction, prevent duplicate records/credits, then return HTTP 2xx promptly.
- If callbacks are delayed or lost, look up the order periodically; do not treat disconnection as failure or create a new order.
The callback queue retries up to 12 times with increasing delays. Top-up callbacks also depend on the product's callback settings. Card purchase results are obtained through buycard/redownload.
import hashlib, hmac
expected = hashlib.md5((partner_key + payload["order_code"]).encode()).hexdigest()
valid = hmac.compare_digest(expected, str(payload.get("callback_sign", "")).lower())6. Error codes and troubleshooting
Read the HTTP code, status, message and transaction status within data. Do not use one status table for all services: top-ups use strings, while card codes use numbers.
| Common error codes | Reason |
|---|---|
| 100 | Product not found or card exchange request error; read message |
| 101 | E-wallet not found |
| 102 | Insufficient wallet balance |
| 103 | Missing data |
| 104 | Incorrect submitted data |
| 108 | Merchant nonexistent, disabled or invalid |
| 109 | request_id already exists |
| 110 | Invalid request, incorrect Merchant type or order not found |
| 111 | wallet_number mismatch in the card code API |
| 116 | Invalid signature |
| 117 | Missing/invalid command |
| 118 | The product is out of stock or unavailable |
| 114 | IP not allowed |
| 129 | Card code order not found |
| 130 | Provider error |
| 125 | Invalid quantity |
| 126 | Quantity must be a positive integer |
| Scenario | How to handle |
|---|---|
| HTTP 400 | Check data, customer fields, amount limits and product selection |
| HTTP 409 / duplicate request_id | Look up the original order; do not send a different payload with an existing reference |
| HTTP 503 / timeout | Keep the request reference and reconcile before retrying; provider submission status may be uncertain |
| API success received but still pending | Continue monitoring status/callbacks; the transaction is not complete |
| Sai sign | Check the correct service key, concatenation order, request_id and UTF-8 |
| The price differs from the previous quote | Refresh the Merchant catalog; prices are finalized when an order is accepted |
Space lookups at least a few seconds apart, increasing the delay while results are pending. Your server must generate and save request_id before sending. Do not use a real purchase to test connectivity; productlist/getbalance/getfee are read-only checks.
7. Code examples
Python example calling getbalance safely to test the connection (install requests using pip install requests). Use environment variables for private information; do not put keys in source code.
import os, hashlib, requests
BASE = "https://toctoico.com"
partner_id = os.environ["GACHTHE_PARTNER_ID"]
partner_key = os.environ["GACHTHE_PARTNER_KEY"]
def recharge(command, **fields):
request_id = str(fields.get("request_id", "")) if command in ("topup", "getstatus") else ""
sign = hashlib.md5((partner_key + partner_id + command + request_id).encode()).hexdigest()
payload = {**fields, "partner_id": partner_id, "command": command, "sign": sign}
response = requests.post(BASE + "/api/rechargews", json=payload, timeout=20)
result = response.json()
response.raise_for_status()
return result
print(recharge("getbalance"))
# Lưu request_id trước khi gọi topup. Sau đó lưu order_code trả về.
# Khi timeout: tra cứu/đối soát, không tạo request_id mới để mua lại.PHP: signature and JSON submission
<?php
$partnerId = getenv('GACHTHE_PARTNER_ID');
$partnerKey = getenv('GACHTHE_PARTNER_KEY');
$command = 'getbalance';
$payload = ['partner_id' => $partnerId, 'command' => $command,
'sign' => md5($partnerKey . $partnerId . $command)];
$ch = curl_init('https://toctoico.com/api/rechargews');
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_TIMEOUT => 20]);
$body = curl_exec($ch);
if ($body === false) { throw new RuntimeException('Chưa xác nhận được phản hồi API'); }
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$result = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
// Kiểm tra $status, $result['status'] và trạng thái đơn trong data nếu có.cURL
curl --request POST 'https://toctoico.com/api/rechargews' \
--header 'Content-Type: application/json' \
--data '{"partner_id":"YOUR_PARTNER_ID","command":"getbalance","sign":"YOUR_COMPUTED_MD5"}' Card exchange uses the separate charging/check formulas in the signature table; the legacy MD5(partner_key + code + serial) formula remains supported. For card code purchases, use the concatenation rule in the signature table and the /api/cardws endpoint.
8. Serial lookup
POST /api/checkcard. Only use this when an administrator has enabled the feature and granted access to the Merchant. Lookup availability, limits and returned information depend on the approved configuration. Do not infer that a card is unused from an incomplete response.
| Parameters | Request | Meaning / value |
|---|---|---|
| partner_id | Required | Authorized Merchant |
| telco | Khi charging | Card type code |
| serial | Required | Serial string |
| sign | Required | MD5(partner_key + serial) |
{
"partner_id": "YOUR_PARTNER_ID",
"telco": "VIETTEL",
"serial": "YOUR_CARD_SERIAL",
"sign": "MD5_PARTNER_KEY_SERIAL"
}Check status, message and the actual returned data. This is not a card top-up or PIN-check API; do not enable permissions yourself or run bulk lookups.
Tool Rejoin · Reseller API
1. Integration flow
This API sells, renews and resets Tool Rejoin VIP keys using your VND wallet on TOCTOICO. It is a separate REST protocol for Rejoin; do not send command or MD5 signatures from the three card/top-up services to this endpoint.
- Sign in to website → API connections, create a Merchant of type Tool Rejoin, enable it and store the Partner ID/Partner Key on your backend.
- Call GET /plans to retrieve plans on sale, prices and service_ready status. Use GET /balance to check your wallet balance.
- Save your order reference, plan_id and quantity before sending POST /keys/purchase. Use that reference as the Idempotency-Key.
- Read keys from a successful response or look up GET /orders/{request_id} after a lost response. Display keys securely to the purchaser.
- Renew or reset using the UUID of a key owned by the Merchant account, with a separate Idempotency-Key for each operation.
VIP key validity starts upon the first valid activation on a device. Prices come from the database when a new transaction is processed, not from a price you submit. Prices and days in examples are illustrative; use GET /plans for actual plans. Free keys are issued through Discord and cannot be purchased or reset through this reseller API.
2. Authentication, permissions and format
Authorization: Bearer YOUR_PARTNER_KEY
X-Partner-ID: YOUR_PARTNER_ID
Accept: application/json
Content-Type: application/json
Idempotency-Key: shop_order_20261030_000001| Parameters | Request | Meaning / value |
|---|---|---|
| Authorization | Every request | Bearer + the Merchant Partner Key from this website |
| X-Partner-ID | Every request | Corresponding Partner ID; do not send credentials in the URL |
| Content-Type | Every POST | application/json; resets still send the JSON object {} |
| Idempotency-Key | POST purchase, renew, reset | 8–100 characters: A–Z, a–z, 0–9, _ . : -; a stable identifier for one operation |
| Accept | Recommended | application/json |
The Merchant must be active, have type Tool Rejoin or All, and belong to an unlocked account. If an IP allowlist is configured, requests must come from an allowed server public IP; do not add X-Forwarded-For or IP headers to bypass restrictions. Keep the Partner Key on the backend only, not in frontend JavaScript, URLs or logs.
Keys are scoped to the wallet owner, even when that account is an administrator on the website; only VIP keys purchased by that wallet owner through the website or API can be managed. API order lists belong only to the calling Merchant. Idempotency-Key is scoped per Merchant and shared across all operation types for that Merchant; do not reuse a purchase reference for renewal/reset. Different Merchants have separate request reference scopes, even if they share a wallet owner.
Response uses success boolean type, data is the result; errors contain success=false, message, code and data=null. Do not interpret status using the card API's numeric status table. Amounts are integer VND values; timestamps are returned in ISO 8601 UTC. The admin page may display UTC+7.
{
"success": true,
"data": {
"balance": 500000,
"currency": "VND",
"discount_percent": 0,
"discount_bps": 0
}
}3. Endpoint list
| Method / path | Purpose | Input |
|---|---|---|
| GET /api/rejoin/v1/balance | The Merchant account's shared wallet balance | No body |
| GET /api/rejoin/v1/plans | Plans on sale, current prices and service_ready | No body |
| GET /api/rejoin/v1/keys | VIP keys owned by the wallet owner, with keys masked | page, per_page, status, q |
| GET /api/rejoin/v1/keys/{uuid} | VIP key details for the wallet owner, including the complete key | UUID from license_ids or keys.id |
| POST /api/rejoin/v1/keys/purchase | Deduct wallet funds and issue keys in the same transaction | JSON: plan_id, quantity |
| POST /api/rejoin/v1/keys/{uuid}/renew | Deduct wallet funds and renew an existing key | JSON: plan_id |
| POST /api/rejoin/v1/keys/{uuid}/reset | Reset a VIP key's device binding | JSON: {} |
| GET /api/rejoin/v1/orders | Current Merchant operation history, with keys masked | page, per_page, request_id, operation |
| GET /api/rejoin/v1/orders/{request_id} | Look up the exact operation using the submitted Idempotency-Key | Original request reference, including full keys where applicable |
| GET /api/rejoin/v1/users/{user_id}/discount | Admin only: view an account's key purchase discount | Website account ID, not Partner ID |
| POST /api/rejoin/v1/users/{user_id}/discount | Admin only: set the Rejoin key purchase discount | JSON: discount_percent; does not deduct funds or issue keys |
4. Balance and available plans
GET /balance returns balance, currency=VND and the Merchant account owner's discount_percent and discount_bps. This is the same wallet used for website transactions; do not deduct from an additional wallet for the same order without a reconciliation design.
{
"success": true,
"data": {
"balance": 500000,
"currency": "VND",
"discount_percent": 0,
"discount_bps": 0
}
}GET /plans returns data.items, currency, service_ready and the account's own key purchase discount. In each plan: base_price is the list price, price is the discounted unit price for a new purchase, and renew_price is the renewal price with the same account Rejoin discount applied. discount_bps is the percentage multiplied by 100 (2%=200 bps). Send only the plan_id of a plan currently on sale. service_ready=false means the VIP service is not ready; do not treat the listing as confirmation that keys can be sold.
{
"success": true,
"data": {
"items": [
{
"id": 1,
"name": "VIP 7 ngày",
"days": 7,
"base_price": 50000,
"price": 50000,
"renew_price": 50000,
"discount_percent": 0,
"discount_bps": 0,
"reset_limit": 1,
"featured": 1
}
],
"currency": "VND",
"service_ready": true,
"discount_percent": 0,
"discount_bps": 0
}
}The discount applies to new purchases and renewals of Tool Rejoin VIP keys, through both the website and API of the same account owner. It does not apply to card exchange, card code purchases or service top-ups. For example, a list price of 50.000 VND with a 2% discount gives price=49.000 VND; buying 3 keys deducts 147.000 VND. Unit prices are rounded HALF_UP to the nearest VND before multiplying by quantity (a fractional amount of 0,5 VND or more rounds up). Do not apply the discount again to price. Prices and discounts are finalized at purchase or renewal; subsequent changes do not modify previous orders.
Admin: configure account discounts
Use a Tool Rejoin or All Merchant created by account active admin owns. The API rechecks admin permissions from the database within the transaction; ordinary resellers cannot view or modify this endpoint, even for themselves. Do not submit role, owner_id or admin permissions in the body.
| Parameters | Request | Meaning / value |
|---|---|---|
| user_id | In the URL | Integer ID of the website account to configure |
| discount_percent | POST required | A value from 0–100 with at most 2 decimal places, using a decimal point; set 0 to remove the discount |
{
"discount_percent": 2
}{
"success": true,
"data": {
"user_id": 123,
"discount_percent": 2,
"discount_bps": 200
}
}GET and POST use the same path /api/rejoin/v1/users/123/discount. POST accepts only discount_percent, sets an absolute value and writes an admin audit entry. It does not accumulate discounts, change previous orders, create an order in /orders or require an Idempotency-Key. Sending the same value again keeps the same discount; if an admin subsequently changed the rate, a new POST still sets the submitted value. Errors: 403 permission_denied for non-admin users, 404 not_found if the account does not exist, and 400 invalid_request for invalid values or fields. Authentication, Merchant, IP and request-limit rules still apply.
curl --silent --show-error --include --request POST 'https://toctoico.com/api/rejoin/v1/users/123/discount' \
--header 'Authorization: Bearer YOUR_ADMIN_PARTNER_KEY' \
--header 'X-Partner-ID: YOUR_ADMIN_PARTNER_ID' \
--header 'Content-Type: application/json' \
--data '{"discount_percent":2}' 5. Buy VIP keys
POST /api/rejoin/v1/keys/purchase
| Parameters | Request | Meaning / value |
|---|---|---|
| plan_id | Required | Integer ID of a plan on sale from GET /plans |
| quantity | Optional, defaults to 1 | An integer from 1 to 50; total payment = discounted new-purchase unit price × quantity |
{
"plan_id": 1,
"quantity": 1
}Do not submit price, discount_percent, discount_bps, charged, balance, duration_days or VIP entitlements to determine your own price/permissions. On success, wallet debit and key issuance occur in one transaction; on failure, neither partial key issuance nor partial wallet debit occurs. Save request_id, order_code, license_ids and charged for reconciliation.
{
"success": true,
"data": {
"request_id": "shop_order_20261030_000001",
"operation": "purchase",
"status": "completed",
"order_code": "RJ_EXAMPLE_ORDER",
"license_ids": [
"11111111-2222-4333-8444-555555555555"
],
"charged": 50000,
"balance": 450000,
"keys": [
{
"id": "11111111-2222-4333-8444-555555555555",
"owner_id": 123,
"plan_id": 1,
"kind": "vip",
"key": "RJ-DEMO0001-DEMO0002-DEMO0003-DEMO0004",
"status": "inactive",
"device": "",
"expires_at": null,
"duration_days": 7,
"resets_remaining": 1,
"created_at": "2026-10-30T02:00:00+00:00",
"max_tabs": 4,
"activated_at": null,
"last_seen": null,
"device_id": "",
"generation": 1
}
],
"created_at": "2026-10-30T02:00:00+00:00",
"pricing": {
"base_unit_price": 50000,
"unit_price": 50000,
"quantity": 1,
"discount_percent": 0,
"discount_bps": 0
}
}
}| Parameters | Request | Meaning / value |
|---|---|---|
| request_id | Receive | The Idempotency-Key you submitted |
| operation, status | Receive | purchase and completed when the operation has finished |
| order_code | Receive | System wallet transaction ID; use for admin reconciliation |
| license_ids | Receive | List of key UUIDs; not RJ-... key strings |
| charged, balance | Receive | Total charged and balance after the operation, integer VND |
| pricing | Receive | Finalized price and discount for the purchase/renewal; null for resets or legacy orders without snapshot evidence |
| keys | Receive | Full key and issued entitlements; store securely and do not log |
| expires_at | Trong key | null before activation; validity starts at the first valid activation |
6. Key list and details
GET /api/rejoin/v1/keys?page=1&per_page=20. page starts at 1; set per_page between 1–100. status may be inactive, active, expired or revoked; q is used for search. Pagination returns page, per_page, total and pages. Keys are masked in the list; do not enter a masked value into the tool.
{
"success": true,
"data": {
"items": [
{
"id": "11111111-2222-4333-8444-555555555555",
"owner_id": 123,
"plan_id": 1,
"kind": "vip",
"key": "RJ-DEMO••••••••00004",
"status": "inactive",
"device": "",
"expires_at": null,
"duration_days": 7,
"resets_remaining": 1,
"created_at": "2026-10-30T02:00:00+00:00",
"max_tabs": 4,
"activated_at": null,
"last_seen": null,
"device_id": "",
"generation": 1
}
],
"page": 1,
"per_page": 20,
"total": 1,
"pages": 1
}
}Call GET /api/rejoin/v1/keys/11111111-2222-4333-8444-555555555555 to retrieve data containing complete key details. Only VIP keys owned by the account are returned; invalid UUIDs or unauthorized keys are rejected. status=inactive means unactivated; active means currently valid; expired means validity ended; revoked means revoked.
{
"success": true,
"data": {
"id": "11111111-2222-4333-8444-555555555555",
"owner_id": 123,
"plan_id": 1,
"kind": "vip",
"key": "RJ-DEMO0001-DEMO0002-DEMO0003-DEMO0004",
"status": "inactive",
"device": "",
"expires_at": null,
"duration_days": 7,
"resets_remaining": 1,
"created_at": "2026-10-30T02:00:00+00:00",
"max_tabs": 4,
"activated_at": null,
"last_seen": null,
"device_id": "",
"generation": 1
}
}| Parameters | Request | Meaning / value |
|---|---|---|
| id, owner_id, plan_id | Trong key | Key UUID, owner account ID and plan ID |
| kind, max_tabs | Trong key | VIP, up to 4 tabs |
| key | Trong key | Masked in lists; shown in full in details, purchases and specific order lookups |
| device, device_id | Trong key | Device name and device ID hash; empty when unbound or reset |
| generation | Trong key | Binding generation; incremented on reset to invalidate the previous session-issuance context |
| activated_at, expires_at, last_seen | Trong key | ISO 8601 UTC or null; first activation, expiry and most recent authentication |
| duration_days, resets_remaining | Trong key | Days granted and resets remaining |
| q | Filter key list | Up to 100 characters; search by UUID, masked key or device name, not the complete key |
7. Renewal and device-transfer resets
POST /api/rejoin/v1/keys/{uuid}/renew with body {"plan_id":1}, with a new Idempotency-Key. Renewal uses renew_price, which already includes the account owner's Rejoin discount at the time of renewal. Do not apply the discount again. The pricing field records the price and rate finalized for the renewal order. For an activated key: new expiry = max(previous expiry, current time) + plan days. For an unactivated key: add pending days without starting the validity period. Add the plan's reset allowance while preserving the key string and device binding.
POST /api/rejoin/v1/keys/{uuid}/reset with body {}, with a new Idempotency-Key. A reset uses one allowance, removes the server-side device binding, increases generation and preserves the expiry date. A reset returns charged=0, order_code=null; an exhausted allowance returns an error. Force reset is not supported for resellers, Free keys or revoked keys.
A reset does not delete files on the phone, push an immediate disconnect or extend validity automatically. Stop the tool on the old device before resetting so it cannot reclaim the key; open the tool on the new device and enter the same key. Revocation of the old session depends on the tool's authentication cycle.
{
"success": true,
"data": {
"request_id": "shop_reset_20261030_000001",
"operation": "reset",
"status": "completed",
"order_code": null,
"license_ids": [
"11111111-2222-4333-8444-555555555555"
],
"charged": 0,
"balance": 450000,
"keys": [
{
"id": "11111111-2222-4333-8444-555555555555",
"owner_id": 123,
"plan_id": 1,
"kind": "vip",
"key": "RJ-DEMO0001-DEMO0002-DEMO0003-DEMO0004",
"status": "active",
"device": "",
"expires_at": "2026-11-06T02:00:00+00:00",
"duration_days": 7,
"resets_remaining": 0,
"created_at": "2026-10-30T02:00:00+00:00",
"max_tabs": 4,
"activated_at": "2026-10-30T02:00:00+00:00",
"last_seen": "2026-10-30T02:00:00+00:00",
"device_id": "",
"generation": 2
}
],
"created_at": "2026-10-30T02:00:00+00:00",
"pricing": null
}
}The tool requests authentication every 120 seconds, and leases last at most 300 seconds. This does not guarantee immediate disconnection of the old device or allow a full 300 seconds offline after every network failure; stop the old session before changing devices.
8. Order lookup and safe retries
GET /api/rejoin/v1/orders/{request_id} use the exact Idempotency-Key from the purchase/renewal/reset; do not substitute order_code or the key string into this path. Returns data in the same structure as the successful operation, including authorized complete keys. References not recorded by this Merchant return 404.
GET /api/rejoin/v1/orders?page=1&per_page=20&operation=purchase returns data.items, page, per_page, total and pages. operation accepts purchase, renew or reset; request_id can filter an exact reference. Keys in order lists are masked. The Rejoin API processes transactions immediately and does not use card/top-up callbacks.
- Save the request reference + payload before calling the API, including calls from a queue.
- Timeout or disconnection: do not conclude failure, refund or submit a new order with another ID. Look up GET /orders/{request_id}.
- If a completed order is found, reuse its result; do not buy again. For 404, you may resend the same POST with the same Idempotency-Key and payload.
- Retrying with the same reference/content returns the recorded result without another charge or reset. Reusing the same reference with a different payload or operation type returns 409.
- 429/503: follow Retry-After if provided; look up the existing order and retry within limits using the same reference and payload. If the status is still uncertain, keep the order pending reconciliation.
Do not treat a browser timeout or HTTP error as proof that the wallet was not debited. Changing plan prices or account discounts after an order completes does not change its finalized charged and pricing values.
Order lookups/retries preserve request_id, order_code, charged, pricing, license_ids and the balance after that operation. pricing includes base_unit_price (list price), unit_price (discounted unit price), quantity, discount_percent and discount_bps from the order snapshot, not the current discount. Legacy orders without snapshots return pricing=null rather than an inferred discount. The keys field reflects the keys' current state: they may have been renewed, reset or bound to a device after the original transaction. Use GET /balance for the current balance. Retrying a reset after the key has been bound to a new device still returns the completed original operation and does not reset it again.
{
"success": true,
"data": {
"items": [
{
"request_id": "shop_order_20261030_000001",
"operation": "purchase",
"status": "completed",
"order_code": "RJ_EXAMPLE_ORDER",
"license_ids": [
"11111111-2222-4333-8444-555555555555"
],
"charged": 50000,
"balance": 450000,
"keys": [
{
"id": "11111111-2222-4333-8444-555555555555",
"owner_id": 123,
"plan_id": 1,
"kind": "vip",
"key": "RJ-DEMO••••••••00004",
"status": "inactive",
"device": "",
"expires_at": null,
"duration_days": 7,
"resets_remaining": 1,
"created_at": "2026-10-30T02:00:00+00:00",
"max_tabs": 4,
"activated_at": null,
"last_seen": null,
"device_id": "",
"generation": 1
}
],
"created_at": "2026-10-30T02:00:00+00:00",
"pricing": {
"base_unit_price": 50000,
"unit_price": 50000,
"quantity": 1,
"discount_percent": 0,
"discount_bps": 0
}
}
],
"page": 1,
"per_page": 20,
"total": 1,
"pages": 1
}
}9. HTTP and errors
| HTTP | Meaning / resolution |
|---|---|
| 400 | Missing/invalid JSON, parameters or Idempotency-Key; correct the data before creating a new operation |
| 401 | Missing/invalid Partner ID or Bearer Partner Key, or disabled Merchant |
| 402 | Insufficient wallet funds; add funds and check the balance again |
| 403 | Incorrect Merchant type, IP not allowed, locked account or unauthorized action |
| 404 | No authorized plan/key/order; check code and message to distinguish the reason |
| 405 | Incorrect HTTP method; use GET/POST as specified in the endpoint table |
| 409 | Idempotency-Key conflict or reset allowance exhausted; do not change the reference to bypass the error |
| 429 | Request limit exceeded; slow down, wait and look up the existing order |
| 503 | VIP or the service is not ready; keep the request reference for reconciliation |
| common codes | HTTP |
|---|---|
| authentication_required, authentication_failed, merchant_disabled | 401 |
| module_forbidden, account_denied, ip_forbidden, permission_denied, purchase_limit | 403 |
| not_found | 404 |
| method_not_allowed | 405 |
| invalid_request | 400 |
| idempotency_conflict, reset_limit | 409 |
| insufficient_balance | 402 |
| rate_limited | 429 |
| vip_unavailable, service_unavailable, discount_unavailable | 503 |
{
"success": false,
"data": null,
"code": "insufficient_balance",
"message": "Số dư không đủ. Vui lòng nạp thêm tiền."
}Current limits: 100 requests/minute per Merchant and 120 requests/minute per IP on the Rejoin API; every request counts toward both. HTTP 429 requires waiting at least until the next minute window. Read both HTTP and success/code/message. Do not apply chargingws/cardws numeric error codes to Rejoin. Do not log Authorization, Partner Key or full VIP keys in support logs.
Account/group purchase limits also apply to the API: per-transaction value, daily purchase total and limits for accounts without two-factor authentication. Exceeding a limit returns HTTP 403, code=purchase_limit. This does not require submitting a 2FA code to the API; check account settings/limits before retrying. Unauthorized actions, such as resetting a revoked key, may return permission_denied (403).
Success returns HTTP 200. Responses containing complete keys use Cache-Control: no-store; do not cache them publicly or store raw keys in localStorage or logs. Keep key issuance data on the backend and display it only to the authenticated purchaser.
10. Complete code examples
Run this from the reseller's backend with HTTPS verification enabled by default. Set environment variables using a Merchant from this website. Do not use the tool's client API challenge/activate endpoints instead of the sales API. The purchase/renewal POST examples deduct wallet funds when real credentials are used; resets consume the reset allowance.
cURL: catalog, purchase and lookup after timeout
export REJOIN_PARTNER_ID='YOUR_PARTNER_ID'
export REJOIN_PARTNER_KEY='YOUR_PARTNER_KEY'
API='https://toctoico.com/api/rejoin/v1'
REQUEST_ID='shop_order_20261030_000001' # Persist before submitting.
curl --silent --show-error --include "$API/plans" \
--header "Authorization: Bearer $REJOIN_PARTNER_KEY" \
--header "X-Partner-ID: $REJOIN_PARTNER_ID"
curl --silent --show-error --include --request POST "$API/keys/purchase" \
--header "Authorization: Bearer $REJOIN_PARTNER_KEY" \
--header "X-Partner-ID: $REJOIN_PARTNER_ID" \
--header "Content-Type: application/json" \
--header "Idempotency-Key: $REQUEST_ID" \
--data '{"plan_id":1,"quantity":1}'
# On timeout, look up the original request ID. Never create a replacement ID.
curl --silent --show-error --include "$API/orders/$REQUEST_ID" \
--header "Authorization: Bearer $REJOIN_PARTNER_KEY" \
--header "X-Partner-ID: $REJOIN_PARTNER_ID"cURL: key details, history, renewal and reset
Reuse the API/Partner variables from the example above. Replace KEY_ID with the UUID returned in license_ids; use a new operation reference for each POST below and save that reference before sending the request.
KEY_ID='UUID_FROM_LICENSE_IDS'
curl --silent --show-error --include "$API/balance" \
--header "Authorization: Bearer $REJOIN_PARTNER_KEY" \
--header "X-Partner-ID: $REJOIN_PARTNER_ID"
curl --silent --show-error --include "$API/keys?page=1&per_page=20&status=inactive" \
--header "Authorization: Bearer $REJOIN_PARTNER_KEY" \
--header "X-Partner-ID: $REJOIN_PARTNER_ID"
curl --silent --show-error --include "$API/keys/$KEY_ID" \
--header "Authorization: Bearer $REJOIN_PARTNER_KEY" \
--header "X-Partner-ID: $REJOIN_PARTNER_ID"
curl --silent --show-error --include "$API/orders?page=1&per_page=20&operation=purchase" \
--header "Authorization: Bearer $REJOIN_PARTNER_KEY" \
--header "X-Partner-ID: $REJOIN_PARTNER_ID"
# Renewal debits the wallet using the selected plan price.
curl --silent --show-error --include --request POST "$API/keys/$KEY_ID/renew" \
--header "Authorization: Bearer $REJOIN_PARTNER_KEY" \
--header "X-Partner-ID: $REJOIN_PARTNER_ID" \
--header "Content-Type: application/json" \
--header "Idempotency-Key: shop_renew_20261030_000001" \
--data '{"plan_id":1}'
# Stop the tool on the old device first. Reset consumes one allowance.
curl --silent --show-error --include --request POST "$API/keys/$KEY_ID/reset" \
--header "Authorization: Bearer $REJOIN_PARTNER_KEY" \
--header "X-Partner-ID: $REJOIN_PARTNER_ID" \
--header "Content-Type: application/json" \
--header "Idempotency-Key: shop_reset_20261030_000001" \
--data '{}' Python 3: purchase, order lookup and retry with the same reference
Uses the standard library; requests does not need to be installed. Save REJOIN_ORDER_ID linked to your order before sending the request.
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
BASE = os.environ.get("REJOIN_API_BASE", "https://toctoico.com/api/rejoin/v1").rstrip("/")
PARTNER_ID = os.environ["REJOIN_PARTNER_ID"]
PARTNER_KEY = os.environ["REJOIN_PARTNER_KEY"]
class APIError(Exception):
def __init__(self, status, result):
if not isinstance(result, dict):
result = {"message": "HTTP error without a valid API object"}
super().__init__(result.get("message", "API request failed"))
self.status, self.result = status, result
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None # Never forward API credentials to a redirected host.
OPENER = urllib.request.build_opener(NoRedirect())
def api(method, path, payload=None, request_id=None):
headers = {"Authorization": "Bearer " + PARTNER_KEY,
"X-Partner-ID": PARTNER_ID, "Accept": "application/json"}
if request_id is not None:
headers["Idempotency-Key"] = request_id
data = None
if payload is not None:
headers["Content-Type"] = "application/json"
data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
try:
with OPENER.open(request, timeout=20) as response:
try:
result = json.load(response)
except (ValueError, UnicodeError):
raise urllib.error.URLError("Invalid API response; outcome not confirmed") from None
except urllib.error.HTTPError as error:
try:
result = json.load(error)
except (ValueError, UnicodeError):
result = {"message": "HTTP error without a valid JSON body"}
raise APIError(error.code, result) from None
if not isinstance(result, dict):
raise urllib.error.URLError("Invalid API response; outcome not confirmed")
if result.get("success") is not True:
raise APIError(200, result)
if not isinstance(result.get("data"), dict):
raise urllib.error.URLError("Invalid API response; outcome not confirmed")
return result["data"]
def order(request_id):
return api("GET", "/orders/" + urllib.parse.quote(request_id, safe=""))
def purchase(plan_id, quantity, request_id):
# Save request_id + payload in your database BEFORE calling this function.
payload = {"plan_id": plan_id, "quantity": quantity}
try:
return api("POST", "/keys/purchase", payload, request_id)
except APIError as error:
if error.status not in (429, 503):
raise # Never replace a rejected 409 payload with a new request ID.
time.sleep(60 if error.status == 429 else 2)
except (urllib.error.URLError, TimeoutError):
pass
# A lost response does not mean the purchase failed. Look up the SAME ID.
try:
return order(request_id)
except APIError as error:
if error.status != 404:
raise
# Only one bounded retry, with exactly the same payload and request ID.
# If this also times out, persist "needs reconciliation" and poll order().
return api("POST", "/keys/purchase", payload, request_id)
if __name__ == "__main__":
# Use a plan_id returned by GET /plans, never infer it from a display name.
result = purchase(int(os.environ["REJOIN_PLAN_ID"]), 1, os.environ["REJOIN_ORDER_ID"])
# Store/show result["keys"] only through a protected customer flow, not logs.
print(json.dumps({name: result[name] for name in
("request_id", "order_code", "charged", "license_ids")}, ensure_ascii=False))PHP 8 + cURL: the same contract and retry reference
<?php
$base = rtrim(getenv('REJOIN_API_BASE') ?: 'https://toctoico.com/api/rejoin/v1', '/');
$partnerId = getenv('REJOIN_PARTNER_ID');
$partnerKey = getenv('REJOIN_PARTNER_KEY');
class RejoinApiError extends RuntimeException {
public int $httpStatus;
public function __construct(int $status, array $result) {
parent::__construct($result['message'] ?? 'API request failed');
$this->httpStatus = $status;
}
}
class RejoinTransportError extends RuntimeException {}
function rejoinApi(string $method, string $path, ?array $payload = null,
?string $requestId = null): array {
global $base, $partnerId, $partnerKey;
$headers = ['Authorization: Bearer ' . $partnerKey,
'X-Partner-ID: ' . $partnerId, 'Accept: application/json'];
if ($requestId !== null) $headers[] = 'Idempotency-Key: ' . $requestId;
$ch = curl_init($base . $path);
$options = [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 20,
CURLOPT_FOLLOWLOCATION => false];
if ($payload !== null) {
$headers[] = 'Content-Type: application/json';
// Empty reset payload must be a JSON object {}, never an array [].
$options[CURLOPT_POSTFIELDS] = json_encode($payload === [] ? new stdClass() : $payload,
JSON_THROW_ON_ERROR);
}
$options[CURLOPT_HTTPHEADER] = $headers;
curl_setopt_array($ch, $options);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false) throw new RejoinTransportError('Response not confirmed');
try {
$result = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $error) {
if ($status < 200 || $status >= 300) {
throw new RejoinApiError($status, ['message' => 'HTTP error without valid JSON']);
}
throw new RejoinTransportError('Invalid JSON; outcome not confirmed');
}
if (!is_array($result)) {
if ($status < 200 || $status >= 300) {
throw new RejoinApiError($status, ['message' => 'HTTP error without a valid API object']);
}
throw new RejoinTransportError('Invalid API object; outcome not confirmed');
}
if ($status < 200 || $status >= 300 || ($result['success'] ?? false) !== true) {
throw new RejoinApiError($status, $result);
}
if (!is_array($result['data'] ?? null)) {
throw new RejoinTransportError('Invalid API data; outcome not confirmed');
}
return $result['data'];
}
function rejoinOrder(string $requestId): array {
return rejoinApi('GET', '/orders/' . rawurlencode($requestId));
}
function rejoinPurchase(int $planId, int $quantity, string $requestId): array {
// Persist the request ID and payload in your database before sending.
$payload = ['plan_id' => $planId, 'quantity' => $quantity];
try {
return rejoinApi('POST', '/keys/purchase', $payload, $requestId);
} catch (RejoinApiError $error) {
if (!in_array($error->httpStatus, [429, 503], true)) throw $error;
sleep($error->httpStatus === 429 ? 60 : 2);
} catch (RejoinTransportError $error) {
// Continue with order lookup; never generate a replacement ID here.
}
try {
return rejoinOrder($requestId);
} catch (RejoinApiError $error) {
if ($error->httpStatus !== 404) throw $error;
}
// One retry with the identical payload and request ID.
// If still uncertain, keep the order for reconciliation using rejoinOrder().
return rejoinApi('POST', '/keys/purchase', $payload, $requestId);
}
$requestId = getenv('REJOIN_ORDER_ID'); // Stable business order ID, saved before I/O.
$planId = (int) getenv('REJOIN_PLAN_ID'); // Read from GET /plans.
$result = rejoinPurchase($planId, 1, $requestId);
// Reset example: stop the old tool first, then persist a NEW reset request ID.
// $uuid = $result['license_ids'][0];
// $reset = rejoinApi('POST', '/keys/' . $uuid . '/reset', [], 'shop_reset_20261030_000001');
// Store/show $result['keys'] only through a protected customer flow, not logs.
$summary = array_intersect_key($result, array_flip(['request_id', 'order_code', 'charged', 'license_ids']));
echo json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);