GemiGuard Pay

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 pathPurpose
GET /v1/meYour merchant account
GET /v1/ratesCurrent hryvnia and other fiat rates per USDT, with the source and its time
POST /v1/invoicesCreate 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/:idOne invoice
POST /v1/invoices/:id/cancelCancel 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.

FieldRequiredMeaning
amountyesDecimal string in currency, such as "1595.00"
currencynoUAH, USD, EUR, GBP, PLN or USDT; the merchant default otherwise
order_idyesYour order reference, at most 200 characters; echoed in every webhook
descriptionnoShown on the payment page, at most 500 characters; the product list reads best
localenouk, ru or en for the payment page
expires_innoSeconds, 600 to 86400; the merchant default is 3600
success_url, cancel_urlnoWhere the payment page sends the customer back
callback_urlnoWebhook target for this invoice; the merchant default otherwise
customer_emailnoStored and echoed
metadatanoAny 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.