Merchant API
Base URL https://gemiguardpay.com/v1. JSON in, JSON out. Every request carries Authorization: Bearer ggp_live_…, the key issued with your merchant account. Errors answer { "error": { "code", "message" } }. Amounts are decimal strings; USDT amounts also come as integers in micro-USDT (*_micro, six decimals).
Endpoints
| Method and path | Purpose |
|---|---|
GET /v1/me | Your merchant account |
GET /v1/rates | Current hryvnia and other fiat rates per USDT, with the source and its time |
POST /v1/invoices | Create an invoice (201), or get the existing one back for the same Idempotency-Key (200) |
GET /v1/invoices?order_id=&status=&limit= | Your invoices, newest first, at most 200 |
GET /v1/invoices/:id | One invoice |
POST /v1/invoices/:id/cancel | Cancel a pending invoice that has received nothing |
Create an invoice
Send the Idempotency-Key header with your order number: a repeat returns the first invoice instead of creating a second address.
| Field | Required | Meaning |
|---|---|---|
amount | yes | Decimal string in currency, such as "1595.00" |
currency | no | UAH, USD, EUR, GBP, PLN or USDT; the merchant default otherwise |
order_id | yes | Your order reference, at most 200 characters; echoed in every webhook |
description | no | Shown on the payment page, at most 500 characters; the product list reads best |
locale | no | uk, ru or en for the payment page |
expires_in | no | Seconds, 600 to 86400; the merchant default is 3600 |
success_url, cancel_url | no | Where the payment page sends the customer back |
callback_url | no | Webhook target for this invoice; the merchant default otherwise |
customer_email | no | Stored and echoed |
metadata | no | Any JSON object under 4000 characters, echoed in every webhook |
curl -X POST https://gemiguardpay.com/v1/invoices \
-H "Authorization: Bearer ggp_live_…" \
-H "Idempotency-Key: 406" \
-H "Content-Type: application/json" \
-d '{"amount":"1595.00","currency":"UAH","order_id":"406","locale":"uk",
"success_url":"https://shop.example/order/406","callback_url":"https://shop.example/hooks/gemiguardpay"}' Send the customer to invoice.pay_url. The page shows the address, the exact USDT amount, two QR codes, the deadline in Kyiv time and the live status; it works without JavaScript.
The invoice object
{ "invoice": {
"id": "inv_…", "object": "invoice", "order_id": "406", "status": "pending",
"price": { "amount": "1595.00", "currency": "UAH" },
"rate": { "fiat_minor_per_usdt": 4498, "markup_bps": 100 },
"pay": { "asset": "USDT", "network": "TRON", "standard": "TRC-20",
"address": "T…", "amount": "35.82", "amount_exact": "35.820000",
"amount_micro": "35820000", "tolerance_micro": "358200" },
"received": { "amount": "0.00", "amount_exact": "0.000000", "amount_micro": "0" },
"pending_micro": "0", "remaining_micro": "35820000", "shortfall_micro": "0", "overpaid_micro": "0",
"fee_micro": "358200",
"pay_url": "https://gemiguardpay.com/pay/inv_…",
"created_at": "…Z", "expires_at": "…Z", "paid_at": null } } pay.amount is the quote: the price at the National Bank rate plus the markup, rounded up to a whole cent of USDT and locked until expires_at. received is the confirmed on-chain total; pending_micro is money seen on the network and still unconfirmed. A shortfall of at most pay.tolerance_micro settles the invoice as paid, with shortfall_micro saying how much was short. fee_micro is the platform fee on the invoice.
Statuses
pending → paid, expired or cancelled. An expired invoice becomes underpaid when some money arrived, or paid when a late transfer completes it; underpaid becomes paid the same way. Ship on paid only; the amount is the solidified on-chain total, and TRON has no reversal.
Webhooks
One POST per event to your callback_url, from GemiGuardPay-Webhook/1:
POST https://shop.example/hooks/gemiguardpay
Content-Type: application/json
X-GGPay-Event: invoice.paid
X-GGPay-Delivery: wh_…
X-GGPay-Signature: t=1760443200,v1=<64 hex characters>
{ "id": "wh_…", "event": "invoice.paid", "created_at": "…Z", "data": { …the invoice object… } } Events: invoice.payment_received (a transfer arrived, the total is still below due), invoice.paid, invoice.underpaid, invoice.expired. Answer any 2xx within 10 seconds; anything else is retried 1 minute, 5 minutes, 15 minutes, 1 hour, 4 hours, 12 hours and 24 hours after the previous attempt, then dropped. Deliveries can arrive twice or out of order: key your handling on X-GGPay-Delivery and on data.status.
Verifying a webhook
v1 is hex HMAC-SHA-256 under your webhook secret over the string <t>.<raw body>. Reject when |now − t| > 300 seconds or when the MAC differs, compared in constant time. Verify the raw bytes; never parse and re-serialise the body first.
// Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = createHmac('sha256', secret).update(parts.t + '.' + rawBody).digest('hex');
return expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
} <?php // PHP
function verify(string $secret, ?string $header, string $rawBody): bool {
if ($header === null) return false;
parse_str(str_replace(',', '&', $header), $parts);
if (!isset($parts['t'], $parts['v1']) || abs(time() - (int) $parts['t']) > 300) return false;
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, strtolower($parts['v1']));
} The same verifier ships in the PHP SDK inside every shop module (GemiGuardPay\Webhook::verify).
Errors
unauthenticated 401, merchant_suspended 403, invoice_not_found 404, invalid_json, invalid_field, invalid_amount, invalid_currency, invalid_locale, amount_too_small (under the minimum the platform computes from the network cost; the message names it, and GET /v1/me answers minimum_invoice_usdt) 400, not_pending, funds_received 409, rate_unavailable 503 (no fresh fiat rate: retry, or price in USDT), method_not_allowed 405, not_found 404, internal 500.