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
- Your server calls
/api/v2/checkout/sessionswith the amount, the customer's email and an HMAC signature. - We return a
checkout_url. You redirect the customer there (or open it in a popup or iframe). - The customer pays with crypto, mobile money or card on our hosted page.
- 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.
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:
| Value | Used for |
|---|---|
merchant_id | Identifies your account. Not secret. |
webhook_secret | Signs your API calls and verifies our webhooks. Keep it server-side only. |
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:
| Header | Value |
|---|---|
X-Checkout-Timestamp | Current Unix time in seconds. |
X-Checkout-Signature | HMAC-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}
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
https://gsmonepay.com/api/v2/checkout/sessionsBody
| Field | Required | Notes |
|---|---|---|
merchant_id | yes | Your merchant id. |
email | yes | Customer email. Must be a valid address. |
amount | yes | Greater than 0. |
currency | no | Defaults to USD. |
idempotency_key | no | Reusing a key returns the same still-open session instead of creating a second one. Recommended on retries. |
return_url | no | Where to send the customer after paying. Must start with http:// or https://. |
cancel_url | no | Where to send the customer if they cancel. |
allowed_origin | no | Required for popup/iframe mode — the exact origin allowed to receive our postMessage events. |
metadata | no | Object 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']);
import hmac, hashlib, time, requests
BASE = "https://gsmonepay.com"
MID = "your-merchant-id"
SECRET = b"your-webhook-secret" # server-side only
def php_float_str(x) -> str:
"""Render a number the way PHP does when it concatenates a float.
PHP: (float) 25.00 -> "25", 25.50 -> "25.5".
Python's str(25.0) would give "25.0", which breaks the signature.
"""
s = repr(float(x))
return s[:-2] if s.endswith(".0") else s
def create_checkout(email: str, amount, **opts) -> dict:
timestamp = str(int(time.time()))
string_to_sign = f"{timestamp}.{MID}.{email}.{php_float_str(amount)}"
signature = hmac.new(SECRET, string_to_sign.encode(), hashlib.sha256).hexdigest()
body = {"merchant_id": MID, "email": email,
"amount": float(amount), "currency": "USD", **opts}
r = requests.post(
f"{BASE}/api/v2/checkout/sessions",
json=body,
headers={"X-Checkout-Timestamp": timestamp,
"X-Checkout-Signature": signature},
timeout=20,
)
data = r.json()
if r.status_code != 200 or not data.get("success"):
raise RuntimeError(f"GSMOnePay: {data.get('error', r.status_code)}")
return data
session = 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},
)
print(session["checkout_url"])
Send the customer to the checkout
Three ways to use checkout_url:
- Redirect — simplest and most reliable. Works everywhere, including mobile.
- Popup —
window.open(checkout_url). Setallowed_originso the page can message you back. - Iframe — also needs
allowed_origin.
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 */ }
});
Use them to update the interface. Credit the account from the webhook or the status endpoint.
Check a session's status
https://gsmonepay.com/api/v2/checkout/sessions/statusUseful 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.
}
def session_status(session_token: str) -> dict:
timestamp = str(int(time.time()))
string_to_sign = f"{timestamp}.{MID}.{session_token}"
signature = hmac.new(SECRET, string_to_sign.encode(), hashlib.sha256).hexdigest()
r = requests.post(
f"{BASE}/api/v2/checkout/sessions/status",
json={"merchant_id": MID, "session_token": session_token},
headers={"X-Checkout-Timestamp": timestamp,
"X-Checkout-Signature": signature},
timeout=15,
)
return r.json()
s = session_status(session_token)
if s.get("payment", {}).get("status") == "confirmed":
... # compare the paid amount 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
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]);
import hmac, hashlib
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.post("/webhooks/gsmonepay")
def gsmonepay_webhook():
# Read the RAW body — request.json would re-serialise and break the signature.
raw = request.get_data()
signature = request.headers.get("X-Webhook-Signature", "")
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
# compare_digest is constant-time; == would leak the secret through timing.
if not signature or not hmac.compare_digest(expected, signature):
return jsonify(error="Invalid signature"), 401
payload = request.get_json(silent=True) or {}
if payload.get("event") != "payment.confirmed":
return jsonify(ok=True, ignored=True), 200
order = find_order_by_session(payload.get("session_token"))
if not order:
return jsonify(error="Unknown session"), 404
# 2 — never credit more than what was actually paid (1% tolerance for FX drift).
expected_amount = float(order["total"])
paid_amount = float(payload.get("amount") or 0)
if abs(paid_amount - expected_amount) > max(0.01, expected_amount * 0.01):
app.logger.warning("amount mismatch: want %s got %s", expected_amount, paid_amount)
return jsonify(error="Amount mismatch"), 400
# 3 — idempotency: only the first delivery may credit.
if not mark_order_paid_once(order["id"], payload.get("payment_id")):
return jsonify(ok=True, duplicate=True), 200
credit_customer(order)
return jsonify(ok=True), 200
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
| Status | Meaning | What to do |
|---|---|---|
401 | Invalid signature, or timestamp more than 300 s off. | Check the string to sign and the amount formatting; sync your clock. |
403 | Merchant inactive, or no webhook_secret configured. | Check your account status and dashboard settings. |
404 | Merchant or session not found. | Verify merchant_id / session_token. |
422 | Missing 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
- The
webhook_secretexists only on your server — never in JS, apps or repos. - Your webhook verifies the signature with a constant-time comparison.
- Your webhook compares the paid amount with the amount you charged.
- Crediting is idempotent — a replayed delivery cannot credit twice.
- You credit on the webhook or the status endpoint, never on the browser redirect.
- You send an
idempotency_keyso a retried session creation cannot duplicate an order. - Your webhook endpoint is reachable over HTTPS and answers
200in a few seconds. - Server clock synchronised with NTP (the 300 s signature window).