GSMOnePay Home Your API keys
Server-to-server API · v2

Integration guide

Add GSMOnePay checkout to any site. You create a signed checkout session from your server, send the customer to the hosted payment page, and we notify your server when the money actually arrives. Examples below are in PHP and Python.

How it works

  1. Your server calls /api/v2/checkout/sessions with the amount, the customer's email and an HMAC signature.
  2. We return a checkout_url. You redirect the customer there (or open it in a popup or iframe).
  3. The customer pays with crypto, mobile money or card on our hosted page.
  4. When the payment is confirmed on-chain or by the provider, we POST a signed webhook to your server. That is your signal to credit the customer.
Credit on the webhook, never on the redirect.

The customer returning to your return_url only means their browser came back — it is not proof of payment and it can be forged by anyone. Grant value when you receive the signed webhook, or after calling the status endpoint yourself.

Credentials

From your merchant dashboard under API keys you need two values:

ValueUsed for
merchant_idIdentifies your account. Not secret.
webhook_secretSigns your API calls and verifies our webhooks. Keep it server-side only.
Never put the secret in front-end code.

Anything in JavaScript, a mobile app bundle or an HTML page is public. Sessions must be created from your backend.

Signing requests

Every API call carries two headers:

HeaderValue
X-Checkout-TimestampCurrent Unix time in seconds.
X-Checkout-SignatureHMAC-SHA256 of the string to sign, hex-encoded, keyed with your webhook_secret.

The string to sign is the parts joined with dots, in this exact order:

{timestamp}.{merchant_id}.{email}.{amount}
Format the amount the way the server does, or the signature will not match.

The server casts the amount to a float and concatenates it, so 25.00 becomes 25 and 25.50 becomes 25.5 — trailing zeros are dropped. Sending "25.00" in the signed string returns 401 Invalid signature. The Python helper below reproduces this exactly.

A request is rejected if the timestamp is more than 300 seconds away from server time, so make sure your clock is synchronised (NTP).

Create a checkout session

POSThttps://gsmonepay.com/api/v2/checkout/sessions

Body

FieldRequiredNotes
merchant_idyesYour merchant id.
emailyesCustomer email. Must be a valid address.
amountyesGreater than 0.
currencynoDefaults to USD.
idempotency_keynoReusing a key returns the same still-open session instead of creating a second one. Recommended on retries.
return_urlnoWhere to send the customer after paying. Must start with http:// or https://.
cancel_urlnoWhere to send the customer if they cancel.
allowed_originnoRequired for popup/iframe mode — the exact origin allowed to receive our postMessage events.
metadatanoObject echoed back to you. Put your own order id here.

Response

{
  "success": true,
  "session_id": "9f2c…64 hex chars",
  "checkout_url": "https://gsmonepay.com/pay/9f2c…",
  "expires_at": "2026-08-05 09:12:44"
}

Sessions are valid for 30 minutes. expires_at is UTC.

Example

<?php
const GSMONEPAY_BASE   = 'https://gsmonepay.com';
const GSMONEPAY_ID     = 'your-merchant-id';
const GSMONEPAY_SECRET = 'your-webhook-secret';   // server-side only

function gsmonepay_create_checkout(string $email, float $amount, array $opts = []): array {
    $timestamp = (string) time();

    // (float) then string concatenation — matches the server byte for byte.
    $stringToSign = $timestamp . '.' . GSMONEPAY_ID . '.' . $email . '.' . $amount;
    $signature    = hash_hmac('sha256', $stringToSign, GSMONEPAY_SECRET);

    $body = array_merge([
        'merchant_id' => GSMONEPAY_ID,
        'email'       => $email,
        'amount'      => $amount,
        'currency'    => 'USD',
    ], $opts);

    $ch = curl_init(GSMONEPAY_BASE . '/api/v2/checkout/sessions');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 20,
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'X-Checkout-Timestamp: ' . $timestamp,
            'X-Checkout-Signature: ' . $signature,
        ],
        CURLOPT_POSTFIELDS     => json_encode($body),
    ]);
    $raw    = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    $json = json_decode($raw, true);
    if ($status !== 200 || empty($json['success'])) {
        throw new RuntimeException('GSMOnePay: ' . ($json['error'] ?? "HTTP $status"));
    }
    return $json;
}

// Usage — store session_id against your order before redirecting.
$session = gsmonepay_create_checkout('client@example.com', 25.00, [
    'idempotency_key' => 'order-1042',
    'return_url'      => 'https://your-site.com/payment/done',
    'cancel_url'      => 'https://your-site.com/cart',
    'metadata'        => ['order_id' => 1042],
]);

header('Location: ' . $session['checkout_url']);

Send the customer to the checkout

Three ways to use checkout_url:

In popup and iframe mode the checkout page posts progress events to your origin:

window.addEventListener('message', function (e) {
  // Always check the origin before trusting a message.
  if (e.origin !== 'https://gsmonepay.com') return;
  var msg = e.data;
  if (!msg || msg.source !== 'gsmonepay-checkout') return;

  if (msg.event === 'payment.created')   { /* session started */ }
  if (msg.event === 'payment.confirmed') { /* show a spinner — wait for your webhook */ }
});
These events are UI hints, not proof of payment.

Use them to update the interface. Credit the account from the webhook or the status endpoint.

Check a session's status

POSThttps://gsmonepay.com/api/v2/checkout/sessions/status

Useful as a fallback if a webhook was missed. The signature uses a different string to sign:

{timestamp}.{merchant_id}.{session_token}
function gsmonepay_session_status(string $sessionToken): array {
    $timestamp = (string) time();
    $signature = hash_hmac(
        'sha256',
        $timestamp . '.' . GSMONEPAY_ID . '.' . $sessionToken,
        GSMONEPAY_SECRET
    );

    $ch = curl_init(GSMONEPAY_BASE . '/api/v2/checkout/sessions/status');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 15,
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'X-Checkout-Timestamp: ' . $timestamp,
            'X-Checkout-Signature: ' . $signature,
        ],
        CURLOPT_POSTFIELDS     => json_encode([
            'merchant_id'   => GSMONEPAY_ID,
            'session_token' => $sessionToken,
        ]),
    ]);
    $out = json_decode(curl_exec($ch), true);
    curl_close($ch);
    return $out ?: [];
}

$s = gsmonepay_session_status($sessionToken);
if (($s['payment']['status'] ?? '') === 'confirmed') {
    // Compare the paid amount with what you asked for before crediting.
}

The response carries session_status, the session amount, and a payment object with status, amount, tx_hash and confirmed_at once a payment exists.

Webhooks

Set your webhook URL in the merchant dashboard. When a payment confirms we POST JSON to it:

{
  "event": "payment.confirmed",
  "payment_id": 4821,
  "session_token": "9f2c…",
  "email": "client@example.com",
  "amount": 25,
  "tx_hash": "0x…",
  "confirmed_at": "2026-08-05 08:44:10",
  "timestamp": 1770281050
}

The request carries an X-Webhook-Signature header: HMAC-SHA256 of the raw request body, keyed with your webhook_secret.

Verifying a webhook

Three checks before you credit anyone.

1. The signature is valid. 2. The paid amount matches what you charged. 3. You have not already processed this payment_id. Skipping check 2 lets someone pay 1 and be credited 500; skipping check 3 lets a replayed delivery credit twice.

<?php
// Read the RAW body — re-encoding $_POST would change the bytes and break the signature.
$raw       = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$expected  = hash_hmac('sha256', $raw, GSMONEPAY_SECRET);

// hash_equals is constant-time; == would leak the secret through timing.
if ($signature === '' || !hash_equals($expected, $signature)) {
    http_response_code(401);
    exit(json_encode(['error' => 'Invalid signature']));
}

$payload = json_decode($raw, true) ?: [];
if (($payload['event'] ?? '') !== 'payment.confirmed') {
    http_response_code(200);
    exit(json_encode(['ok' => true, 'ignored' => true]));
}

$order = find_order_by_session($payload['session_token'] ?? '');
if (!$order) { http_response_code(404); exit(json_encode(['error' => 'Unknown session'])); }

// 2 — never credit more than what was actually paid (1% tolerance for FX drift).
$expectedAmount = (float) $order['total'];
$paidAmount     = (float) ($payload['amount'] ?? 0);
if (abs($paidAmount - $expectedAmount) > max(0.01, $expectedAmount * 0.01)) {
    error_log("GSMOnePay amount mismatch: expected $expectedAmount got $paidAmount");
    http_response_code(400);
    exit(json_encode(['error' => 'Amount mismatch']));
}

// 3 — idempotency: only the first delivery may credit.
if (!mark_order_paid_once($order['id'], $payload['payment_id'] ?? null)) {
    http_response_code(200);
    exit(json_encode(['ok' => true, 'duplicate' => true]));
}

credit_customer($order);
http_response_code(200);
echo json_encode(['ok' => true]);

Reply 200 quickly. Any other status is treated as a failure and the delivery is retried, so do slow work (emails, provisioning) after you have answered.

Errors

StatusMeaningWhat to do
401Invalid signature, or timestamp more than 300 s off.Check the string to sign and the amount formatting; sync your clock.
403Merchant inactive, or no webhook_secret configured.Check your account status and dashboard settings.
404Merchant or session not found.Verify merchant_id / session_token.
422Missing or invalid field.Read error in the body; check email format, amount > 0, URL scheme.

Errors always come back as JSON: {"success": false, "error": "…"}.

Go-live checklist