1. Overview
GatewayRoot verifies Fonepay payments for your store. You create a payment, show the customer the Fonepay QR code, and GatewayRoot tells you (by API and by signed webhook) when the payment is verified.
- Base URL:
https://gatewayroot.com - All endpoints live under
/api/v1. - Requests and responses are JSON (
Content-Type: application/json). - Every response carries an
X-Request-Idheader. Quote it when you contact support.
Getting started: merchant setup
A panel team can integrate without contacting us. Follow these steps in the dashboard:
- Register at /signup and verify your email address.
- Connect a merchant. Add your Fonepay or NepalPay merchant account under Connections and wait until it shows as connected.
- Bind a mode. Choose QR Mode (Checkout) or Verify Mode (QR Payment Verification) for the project. Under Modes & checkout each project can use its own connected account per mode; otherwise the account default is used.
- Issue an API key under API keys: one key per service and per environment (test or live). See Services and API keys.
- Register a webhook endpoint (see Webhooks) and test with a test key (see Test Mode and going live).
- Activate Live Mode in the dashboard (after one verified test payment), then issue your live keys.
Live payments are subject to per-plan daily and monthly caps (Free: NPR 3,000 per day and NPR 25,000 per month; higher tiers raise or remove them). Once a cap is reached, POST /api/v1/payments returns PLAN_LIMIT_EXCEEDED (HTTP 402) until the period resets or the plan is upgraded; it is also returned when the plan does not include the requested feature (Verify Mode / live transaction_claim payments need Premium or Enterprise). test payments are never capped.
Services and API keys
GatewayRoot has two services: QR Mode (Checkout: the customer scans a Fonepay QR with the exact amount) and Verify Mode (QR Payment Verification: the customer pays and enters the RRN). One API key = one project + one environment + one mode (qr or verify). To offer both services, issue two keys and add GatewayRoot twice in your panel. See QR Mode and Verify Mode keys for the differences.
2. Test and live mode
Each API key belongs to one project and one environment, test or live. The key decides the mode: test keys only see test payments, live keys only see live payments. Test and live webhook endpoints are separate too.
QR Mode and Verify Mode keys
GatewayRoot offers two ways to confirm a Fonepay payment. Each one has its own API key, so you "add GatewayRoot twice" to your panel, once per mode:
| QR Mode | Verify Mode | |
|---|---|---|
| Customer experience | Hosted page shows a Fonepay QR with the exact amount and a unique reference. The customer scans and pays. | Customer pays Fonepay any way they like, then enters the RRN (or transaction id) on the hosted page. |
| How it is confirmed | The payment remark carries the reference and GatewayRoot matches the transaction automatically. | GatewayRoot looks up the submitted RRN in your Fonepay transactions and checks amount, merchant and status. |
| Payment window | Configurable (QR expiry minutes) | 48 hours by default (configurable) |
| Hosted-page form | Configurable checkout fields: name, phone, email, note and up to two custom fields; each can be on/off and required/optional. Submissions are visible to you in the dashboard. | RRN / transaction id input |
Create the keys in the dashboard under API keys: pick the mode, then the environment. Use the QR Mode key in one integration and the Verify Mode key in a second one. The payload you send is identical for both; only the key differs.
Amount alone never verifies a payment. A short payment (partial_amount) or an RRN that cannot be found (rrn_not_found) goes to manual review and you receive a payment.needs_review webhook.
3. Authentication
Send your secret key as a bearer token:
Authorization: Bearer gr_test_sk_...
Keys look like gr_test_sk_… or gr_live_sk_…. Create and revoke them in the dashboard. A key is shown once at creation; store it server-side only and never ship it to a browser or mobile app.
Keys carry scopes: payments:read, payments:write, webhooks:read, webhooks:write. A missing scope returns 403 FORBIDDEN; an unknown or revoked key returns 401 UNAUTHORIZED.
Quick check that your key works:
curl https://gatewayroot.com/api/v1/ping -H "Authorization: Bearer $GATEWAYROOT_SECRET_KEY"
# {"ok":true,"environment":"test"}
Rate limit: 120 requests per minute per key. Beyond that you get 429 RATE_LIMITED.
4. Money
Amounts are integers in minor units (paisa): amount_minor: 125050 is NPR 1,250.50. Only currency: "NPR" is supported.
5. Idempotency
POST /api/v1/payments requires an Idempotency-Key header (1-255 characters, for example a UUID). Retrying the same request with the same key returns the original response (with header Idempotent-Replayed: true) instead of creating a second payment. Reusing a key with a different body returns 409 IDEMPOTENCY_CONFLICT; a retry while the first request is still running returns 409 REQUEST_IN_PROGRESS. Create one key per order attempt, and keep it when you retry after a timeout.
The claim (/verify) and cancel (/cancel) endpoints also require an Idempotency-Key.
6. Create a payment
curl https://gatewayroot.com/api/v1/payments \
-H "Authorization: Bearer $GATEWAYROOT_SECRET_KEY" \
-H "Idempotency-Key: 6f1c2a52-8b3e-4a55-9d0e-2c6a1d7e9b10" \
-H "Content-Type: application/json" \
-d '{
"external_order_id": "order-1001",
"customer_ref": "customer-42",
"amount_minor": 125050,
"currency": "NPR",
"return_url": "https://shop.example.com/orders/1001"
}'
| Field | Required | Notes |
|---|---|---|
external_order_id | yes | Your order id, 1-100 chars. Unique per project and environment. |
customer_ref | yes | Your customer id, 1-100 chars |
amount_minor | yes | Integer paisa, at least 1 |
currency | yes | NPR |
return_url | no | http(s) URL to send the customer back to (must be https in production) |
Do not send method or connection_id. Both come from the API key: a QR Mode key creates a QR payment, a Verify Mode key creates a payment the customer proves by submitting their Fonepay RRN. Sending a method that disagrees with the key returns MODE_MISMATCH.
The response (201) is a payment object. For method: "qr", qr_payload is the EMV QR string to render as a QR code (it already contains the amount and the payment reference), and checkout_url points to the hosted payment page.
The payment object contains: id, object, status, credit_status, provider, method, connection_id, project_id, environment, external_order_id, customer_ref, amount_minor, currency, reference, qr_payload, checkout_url, provider_transaction_id, review_reason, expires_at, verified_at, created_at, updated_at and claim.
- Expiry:
expires_atis set (QR expiry is configurable, default 60 minutes; Verify Mode uses a 48-hour window). A background sweeper moves unpaid payments toexpiredonce the Fonepay connection has been synced past the deadline, so a customer who paid just beforeexpires_atis still matched. Rely on thepayment.expiredwebhook orGET /payments/{id}. - Verify Mode payments (
method: "transaction_claim"): the customer pays by any means and the RRN is submitted on the hosted pay page, or by you withPOST /payments/{id}/verify(see below). - A new payment for the same
external_order_idis allowed once every earlier attempt isexpired,canceledorfailed. While an earlier attempt is still active (orverified), creating another returns409 DUPLICATE_EXTERNAL_ORDER.
The payment object includes a claim field: null if nobody has claimed the payment, otherwise the latest claim:
{ "id": "clm_...", "identifier_kind": "rrn", "outcome": "pending", "error_code": null, "status": "open", "created_at": "2026-10-06T10:00:00.000Z" }
identifier_kind is rrn or provider_transaction_id; outcome is verified, pending, review or rejected; status is open while the matcher is still retrying the claim and closed once resolved. claim is returned by GET /payments/{id}, /verify and /cancel.
Always confirm payment through the payment.verified webhook or by fetching the payment from your server. Never trust the browser redirect to return_url.
7. Retrieve and list payments
GET /api/v1/payments/{id}: fetch one payment.GET /api/v1/payments?limit=20&cursor=...&status=verified&external_order_id=order-1001: list newest first (optionally filtered bystatusandexternal_order_id); passnext_cursorback ascursorto page.limitis 1-100.
| Parameter | Notes |
|---|---|
q | Search by reference, external_order_id or customer_ref (exact or prefix) or by Fonepay transaction ID (prefix); 1-100 characters. |
from | Only payments created on or after this Nepal-time (NPT) date, YYYY-MM-DD. |
to | Only payments created on or before this Nepal-time (NPT) date, YYYY-MM-DD. |
8. Claim a payment
curl https://gatewayroot.com/api/v1/payments/pay_123/verify \
-H "Authorization: Bearer $GATEWAYROOT_SECRET_KEY" \
-H "Idempotency-Key: 0b9d6f0e-47b1-4f0a-8a6e-5d4d2f0c1a11" \
-H "Content-Type: application/json" \
-d '{ "rrn": "412345678901", "amount_minor": 125050 }'
Send exactly one of rrn (the bank reference number, up to 64 chars) or provider_transaction_id (the merchant-side Fonepay Transaction ID shown in the merchant dashboard; wallet-side transaction IDs are never used and will not match), plus amount_minor. Idempotency-Key is required. RRN matching ignores case; surrounding whitespace is trimmed from both fields; provider_transaction_id must match exactly.
| Situation | Response | What happens |
|---|---|---|
Payment is not pending | 409 INVALID_TRANSITION | Nothing changes |
amount_minor differs from the payment amount | 422 AMOUNT_MISMATCH | Attempt recorded |
| Transaction found, unused, amount equal, inside the payment window | 200, payment verified | Transaction allocated to this payment |
| Transaction already allocated to another payment | 409 TRANSACTION_ALREADY_USED | Attempt recorded |
| Transaction happened before this payment was created | 409 TRANSACTION_OUTSIDE_WINDOW | Attempt recorded |
Transaction found but amount differs, it arrived after expires_at, or its remark belongs to another payment | 202, payment needs_review | A human reviews it; you get a payment.needs_review webhook |
| Transaction not found yet, Fonepay connection healthy | 202 with top-level "code": "VERIFICATION_PENDING" | Claim stored as open; the matcher retries it every cycle until resolved or the payment expires |
| Transaction not found, connection needs attention | 503 CONNECTOR_ACTION_REQUIRED or PROVIDER_PAGE_CHANGED | details.retryable is false; the merchant must fix the connection. The claim is kept open and GatewayRoot keeps retrying it, so it may still verify later; do not fail the order on a 503 |
| Transaction not found, Fonepay unreachable | 503 CONNECTOR_UNAVAILABLE | details.retryable is true. The claim is kept open and GatewayRoot keeps retrying it, so it may still verify later; do not fail the order on a 503 |
| Too many attempts | 429 RATE_LIMITED | Max 5 claims per payment and 30 claims per API key per minute. The per-payment cap does not recover: use a new payment attempt or contact support instead of retrying |
Amount alone never verifies a payment: a claim without a matching transaction is never accepted.
Retry guidance
- On
202 VERIFICATION_PENDING, do not re-submit. PollGET /payments/{id}or wait for thepayment.verified/payment.needs_review/payment.expiredwebhook. - Never submit more than 5 claims for one payment, even with new
Idempotency-Keyvalues; the 6th is refused with429. - Idempotent replays of a stored response return it with
Idempotent-Replayed: trueand do not count as new attempts.429and503responses are not stored under theIdempotency-Key: retrying with the same key re-evaluates the claim from scratch and counts as a new attempt. Every other response is stored and replayed.
9. Cancel a payment
curl -X POST https://gatewayroot.com/api/v1/payments/pay_123/cancel \
-H "Authorization: Bearer $GATEWAYROOT_SECRET_KEY" \
-H "Idempotency-Key: 3c1f5e5a-0c5e-4f43-9a52-7e0a9f6b2d44"
Idempotency-Key is required. Returns 200 with the canceled payment (status canceled). A payment that is not pending returns 409 INVALID_TRANSITION. Canceled payments free their external_order_id for a new attempt.
10. Customer pay page
Every payment has a checkout_url. It opens a hosted GatewayRoot page that shows the QR code, the payment reference, a countdown and the live status. Send your customer there (redirect, link, or an <iframe>).
- Customers can submit their Fonepay RRN (or Transaction ID) on the page if the payment is not detected automatically.
- The page polls GatewayRoot every few seconds, so keep it open until it shows a final status.
- After a successful payment the page returns the customer to the payment's
return_url(set when the payment is created; must behttpsin production). - Embedding in an
<iframe>is allowed. If your panel sends a strict Content Security Policy, allow framing from the GatewayRoot origin (frame-src/child-src). - The redirect after payment navigates the iframe itself, not your top window. When embedding, either omit
return_urlor let the parent page navigate after the webhook /GET /payments/{id}confirms payment. - For the Copy button to work in a cross-origin iframe, add
allow="clipboard-write"to the<iframe>(copy fails safely otherwise). - Never treat the redirect as proof of payment. Rely on the signed webhook or
GET /payments/{id}.
Panel credit callback
After your panel credits the customer's balance for a verified payment, tell GatewayRoot. This sets the payment's credit_status to credited and sends a credit.updated webhook. Two forms are available:
POST /api/v1/payments/{id}/panel-credit: by GatewayRoot payment id.POST /api/v1/payments/panel-credit: by yourexternal_order_id(send it in the body).
Auth: Authorization: Bearer with a key that has the payments:write scope. Only payments of the key's own project and environment are visible.
| Body field | Required | Notes |
|---|---|---|
panel_payment_id | yes | Your own payment/transaction id in the panel (1-128 characters) |
panel_domain | yes | Domain of your panel (1-255 characters) |
panel_url | no | Absolute https link to the payment in your panel |
external_order_id | variant only | The external_order_id you used when creating the payment |
Unknown fields are rejected with 400 INVALID_REQUEST.
curl -X POST https://gatewayroot.com/api/v1/payments/pay_123/panel-credit \
-H "Authorization: Bearer $GATEWAYROOT_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"panel_payment_id":"inv_9001","panel_domain":"panel.example.com","panel_url":"https://panel.example.com/payments/inv_9001"}'
# by your own order id
curl -X POST https://gatewayroot.com/api/v1/payments/panel-credit \
-H "Authorization: Bearer $GATEWAYROOT_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"external_order_id":"order-1001","panel_payment_id":"inv_9001","panel_domain":"panel.example.com"}'
// Node.js
const res = await fetch('https://gatewayroot.com/api/v1/payments/pay_123/panel-credit', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.GATEWAYROOT_SECRET_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ panel_payment_id: 'inv_9001', panel_domain: 'panel.example.com', panel_url: 'https://panel.example.com/payments/inv_9001' }),
});
const out = await res.json();
Response 200:
{
"id": "pay_123",
"credit_status": "credited",
"panel_payment_id": "inv_9001",
"panel_domain": "panel.example.com",
"panel_url": "https://panel.example.com/payments/inv_9001"
}
The call is idempotent: repeating it updates the stored panel fields and returns the same shape, and only the first transition to credited emits a credit.updated webhook. Errors: 400 INVALID_REQUEST (missing or malformed field, or panel_url not absolute https), 401 UNAUTHORIZED (missing, invalid or revoked key), 403 FORBIDDEN (key lacks payments:write), 404 NOT_FOUND (no such payment for this key).
11. Payment statuses
| Status | Meaning |
|---|---|
created | Payment created, waiting for the customer |
pending | Customer has been shown or has submitted payment details; verification in progress |
verified | Payment confirmed against Fonepay. Fulfil the order. |
failed | Verification failed |
expired | Not paid before expires_at |
canceled | Canceled before completion |
needs_review | Could not be matched automatically; a human will review it |
refunded | Refunded after verification |
reversed | Reversed after verification |
credit_status tracks whether the payment has been credited on your side (not_requested, pending, credited, failed, unknown, manually_credited).
12. Errors
Errors use one format:
{ "error": { "code": "AMOUNT_MISMATCH", "message": "...", "request_id": "req_...", "details": [] } }
details is optional. For validation errors it is a list of { path, message }; for some codes it is an object, for example retryable on 503 errors and payment_id on DUPLICATE_EXTERNAL_ORDER. Branch on code, not on message.
| Code | HTTP status | Meaning |
|---|---|---|
INVALID_REQUEST | 400 | Malformed or invalid request (including a missing Idempotency-Key) |
UNAUTHORIZED | 401 | Missing, invalid or revoked API key |
FORBIDDEN | 403 | The key lacks the scope, or the action is not allowed (for example simulating with a live key) |
LIVE_NOT_ACTIVATED | 403 | Live Mode is not activated yet: connect an account, verify one test payment and activate Live Mode in the dashboard (live keys and live payments only) |
EMAIL_NOT_VERIFIED | 403 | Account email is not verified (dashboard only) |
MERCHANT_SUSPENDED | 403 | The merchant account is suspended; every API key request is refused until GatewayRoot reactivates it. |
INVALID_CREDENTIALS | 401 | The current password is wrong (dashboard account settings only) |
NOT_FOUND | 404 | Resource does not exist for this key |
UNSUPPORTED_MEDIA_TYPE | 415 | Dashboard mutations must send application/json |
CONFLICT | 409 | The resource is in a state that does not allow the action |
EMAIL_TAKEN | 409 | The email address belongs to another account (dashboard account settings only) |
IDEMPOTENCY_CONFLICT | 409 | Idempotency-Key reused with a different request |
REQUEST_IN_PROGRESS | 409 | A request with this Idempotency-Key is still running; retry shortly |
DUPLICATE_EXTERNAL_ORDER | 409 | external_order_id already used in this project and environment. error.details.payment_id is the existing payment. |
TRANSACTION_ALREADY_USED | 409 | That Fonepay transaction was already allocated to another payment |
TRANSACTION_OUTSIDE_WINDOW | 409 | The transaction happened before this payment was created |
REVIEW_ITEM_CLOSED | 409 | The review item was already resolved (dashboard) |
CONNECTION_NOT_READY | 409 | The Fonepay connection is missing, disconnected or has no QR yet |
PLAN_LIMIT_EXCEEDED | 402 | The merchant's plan daily or monthly cap for live payments has been reached, or the plan does not include the requested feature (Verify Mode / live transaction_claim payments need Premium or Enterprise); retry after the period resets or upgrade the plan |
INVALID_TRANSITION | 409 | The payment cannot move to the requested status |
AMOUNT_MISMATCH | 422 | The amount does not match |
CURRENCY_NOT_SUPPORTED | 422 | Only NPR is supported |
RATE_LIMITED | 429 | Too many requests; back off and retry. The per-payment cap of 5 claims does not recover: use a new payment attempt or contact support instead of retrying |
INTERNAL_ERROR | 500 | Our fault; safe to retry with the same Idempotency-Key |
CONNECTOR_UNAVAILABLE | 503 | Fonepay could not be reached; retry later (error.details.retryable is true). The claim is kept open and GatewayRoot keeps retrying it, so it may still verify later; do not fail the order on a 503 |
CONNECTOR_ACTION_REQUIRED | 503 | The Fonepay connection needs attention; error.details.retryable is false. The claim is kept open and GatewayRoot keeps retrying it, so it may still verify later; do not fail the order on a 503 |
PROVIDER_PAGE_CHANGED | 503 | The Fonepay connection needs attention (provider page changed); error.details.retryable is false. The claim is kept open and GatewayRoot keeps retrying it, so it may still verify later; do not fail the order on a 503 |
The panel-credit endpoints use INVALID_REQUEST, UNAUTHORIZED, FORBIDDEN and NOT_FOUND from the table above.
13. Webhooks
Register an endpoint (HTTPS is required; private and loopback addresses are rejected):
curl https://gatewayroot.com/api/v1/webhook-endpoints \
-H "Authorization: Bearer $GATEWAYROOT_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://shop.example.com/webhooks/gatewayroot", "events": ["payment.verified", "payment.failed", "payment.expired", "payment.needs_review"] }'
The response contains the signing secret (whsec_...) exactly once. Store it as a server-side secret. GET /api/v1/webhook-endpoints lists endpoints and DELETE /api/v1/webhook-endpoints/{id} disables one.
Event types: payment.verified, payment.failed, payment.expired, payment.needs_review, payment.refunded, payment.reversed, and credit.updated. credit.updated is sent when you report a panel credit through the panel-credit callback (first transition to credited only).
Each delivery is a POST with a JSON body such as:
{
"event_id": "evt_...", "type": "payment.verified", "occurred_at": "2026-10-06T08:15:00.000Z",
"payment_id": "pay_...", "external_order_id": "order-1001", "amount_minor": 125050, "currency": "NPR",
"provider": "fonepay", "method": "qr", "reference": "GR...", "status": "verified", "credit_status": "not_requested",
"review_reason": null
}
review_reason is null unless the payment is needs_review. Then it says why: partial_amount (the customer paid less than the order amount), rrn_not_found (the submitted RRN was not found in time), amount_mismatch and similar. Treat any non-null value as "do not credit yet; a human is reviewing".
and three headers:
| Header | Value |
|---|---|
X-GatewayRoot-Event-Id | Unique event id. The same event keeps the same id on every retry. |
X-GatewayRoot-Timestamp | Unix seconds when this attempt was signed |
X-GatewayRoot-Signature | v1= followed by the hex HMAC-SHA256, keyed with your signing secret, of <timestamp>.<raw request body> |
Respond with any 2xx within 10 seconds. Anything else (or a timeout) is retried with growing delays (1 min, 5 min, 15 min, 1 h, 3 h, 6 h, 12 h), then the delivery is marked dead. Inspect deliveries with GET /api/v1/webhook-deliveries and re-queue one with POST /api/v1/webhook-deliveries/{id}/retry. Delivery is at-least-once, so deduplicate by event id.
14. Verify the signature (Node.js)
Verify against the raw bytes, not re-serialized JSON. With Express use express.raw({ type: 'application/json' }) for this route.
import { createHmac, timingSafeEqual } from 'node:crypto';
export async function handleWebhook(req, res) { // req.body is the raw Buffer
const raw = req.body.toString('utf8');
const ts = req.get('x-gatewayroot-timestamp') ?? '';
const sig = req.get('x-gatewayroot-signature') ?? '';
const eventId = req.get('x-gatewayroot-event-id') ?? '';
if (!/^\d{1,12}$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400); // too old
const expected = createHmac('sha256', process.env.GATEWAYROOT_WEBHOOK_SECRET).update(`${ts}.${raw}`).digest('hex');
const given = Buffer.from(sig.replace(/^v1=/, ''), 'utf8'), want = Buffer.from(expected, 'utf8');
if (!sig.startsWith('v1=') || given.length !== want.length || !timingSafeEqual(given, want)) return res.sendStatus(401);
const event = JSON.parse(raw);
if (event.type !== 'payment.verified') return res.sendStatus(200);
// Never trust the webhook body alone: re-fetch the payment server-side and compare with your own order.
const pay = await fetch(`https://gatewayroot.com/api/v1/payments/${event.payment_id}`, {
headers: { Authorization: `Bearer ${process.env.GATEWAYROOT_SECRET_KEY}` },
}).then((r) => r.json());
const order = await db.orders.findByExternalId(pay.external_order_id); // pseudo-DB
if (pay.status !== 'verified' || !order || order.customerRef !== pay.customer_ref
|| order.amountMinor !== pay.amount_minor || order.currency !== pay.currency) {
return res.sendStatus(422); // mismatch: do not credit, investigate
}
// Record the event id and apply the credit in ONE database transaction.
// If anything throws, nothing is recorded, we return 5xx and GatewayRoot retries.
try {
await db.transaction(async (tx) => {
await tx.insert('processed_events', { event_id: eventId }); // UNIQUE(event_id)
await tx.credits.add(order.customerRef, pay.amount_minor, { orderId: order.id });
});
} catch (err) {
if (err.code === 'UNIQUE_VIOLATION') return res.sendStatus(200); // duplicate delivery: already credited
throw err; // framework answers 500, retry follows
}
res.sendStatus(200); // only after the transaction committed
}
Reject requests whose timestamp is more than 300 seconds from your clock, compare signatures in constant time, and process each X-GatewayRoot-Event-Id once: store the event id and apply the credit in the same database transaction, and only return 2xx after it commits.
Integration guides
Pick your stack. Every example creates a payment without method or connection_id, sends the customer to /pay/{id}, and verifies the webhook signature. Keep keys in environment variables; gr_live_sk_... and whsec_... below are placeholders.
Laravel (recommended for panels)
1. Configure, then add the routes. Webhook routes belong in routes/api.php (no CSRF, no session). Add the QR Mode key to one GatewayRoot gateway in your panel and the Verify Mode key to a second one.
// config/services.php
'gatewayroot' => [
'base_url' => env('GATEWAYROOT_BASE_URL', 'https://gatewayroot.com'),
'key' => env('GATEWAYROOT_SECRET_KEY'), // gr_live_sk_... (server-side only)
'webhook_secret' => env('GATEWAYROOT_WEBHOOK_SECRET'), // whsec_...
],
// routes/web.php
Route::post('/checkout', [GatewayRootController::class, 'checkout'])->middleware('auth');
// routes/api.php (webhooks have no CSRF token and no session)
Route::post('/webhooks/gatewayroot', [GatewayRootWebhookController::class, 'handle']);
2. Controller that creates the payment and redirects to the hosted page:
<?php
// app/Http/Controllers/GatewayRootController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class GatewayRootController extends Controller
{
// POST /checkout -> creates the payment and sends the customer to the hosted page
public function checkout(Request $request)
{
$order = $request->user()->orders()->findOrFail($request->input('order_id'));
$response = Http::withToken(config('services.gatewayroot.key')) // gr_live_sk_...
->withHeaders(['Idempotency-Key' => (string) Str::uuid()])
->acceptJson()
->post(config('services.gatewayroot.base_url') . '/api/v1/payments', [
'external_order_id' => (string) $order->id,
'customer_ref' => (string) $order->user_id,
'amount_minor' => (int) round($order->total * 100), // paisa
'currency' => 'NPR',
'return_url' => route('orders.show', $order),
]);
if ($response->failed()) {
report(new \RuntimeException('GatewayRoot: ' . $response->body()));
return back()->withErrors('Payment could not be started. Please try again.');
}
$order->update(['gateway_payment_id' => $response->json('id')]);
return redirect()->away(config('services.gatewayroot.base_url') . '/pay/' . $response->json('id'));
}
}
3. Webhook handler that verifies the HMAC signature, handles review_reason, and credits once per event:
<?php
// app/Http/Controllers/GatewayRootWebhookController.php
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
class GatewayRootWebhookController extends Controller
{
public function handle(Request $request)
{
$raw = $request->getContent(); // raw body, not re-encoded JSON
$timestamp = $request->header('X-GatewayRoot-Timestamp', '');
$signature = $request->header('X-GatewayRoot-Signature', '');
$eventId = $request->header('X-GatewayRoot-Event-Id', '');
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
return response('stale', 400);
}
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $raw, config('services.gatewayroot.webhook_secret'));
if (!hash_equals($expected, $signature)) {
return response('bad signature', 401);
}
$event = json_decode($raw, true);
if ($event['type'] === 'payment.needs_review') {
// $event['review_reason'] is e.g. partial_amount or rrn_not_found. Do not credit; notify support.
logger()->warning('GatewayRoot review', $event);
return response('ok', 200);
}
if ($event['type'] !== 'payment.verified') {
return response('ok', 200);
}
// Never trust the body alone: re-fetch the payment and compare with your own order.
$pay = Http::withToken(config('services.gatewayroot.key'))->acceptJson()
->get(config('services.gatewayroot.base_url') . '/api/v1/payments/' . $event['payment_id'])->json();
$order = Order::find($pay['external_order_id']);
if (!$order || $pay['status'] !== 'verified'
|| (int) $pay['amount_minor'] !== (int) round($order->total * 100) || $pay['currency'] !== 'NPR') {
return response('mismatch', 422);
}
// Record the event id and credit the order in ONE transaction. Duplicate deliveries are ignored.
try {
DB::transaction(function () use ($eventId, $order) {
DB::table('processed_events')->insert(['event_id' => $eventId]); // UNIQUE(event_id)
$order->markPaid();
});
} catch (\Illuminate\Database\UniqueConstraintViolationException $e) {
return response('duplicate', 200);
}
return response('ok', 200);
}
}
Create a processed_events table with a unique event_id column. The Verify Mode flow needs no extra code: the customer enters the RRN on the hosted page and you receive the same webhooks.
Plain PHP
<?php
// Create a payment (plain PHP, cURL extension)
$body = json_encode([
'external_order_id' => 'order-1001', 'customer_ref' => 'customer-42',
'amount_minor' => 125050, 'currency' => 'NPR', 'return_url' => 'https://shop.example.com/orders/1001',
]);
$ch = curl_init('https://gatewayroot.com/api/v1/payments');
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('GATEWAYROOT_SECRET_KEY'),
'Idempotency-Key: ' . bin2hex(random_bytes(16)),
'Content-Type: application/json',
],
]);
$payment = json_decode(curl_exec($ch), true);
header('Location: https://gatewayroot.com/pay/' . $payment['id']);
// Verify a webhook
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_GATEWAYROOT_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_GATEWAYROOT_SIGNATURE'] ?? '';
$ok = ctype_digit($ts) && abs(time() - (int) $ts) <= 300
&& hash_equals('v1=' . hash_hmac('sha256', "$ts.$raw", getenv('GATEWAYROOT_WEBHOOK_SECRET')), $sig);
if (!$ok) { http_response_code(401); exit; }
Node.js
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
// Create a payment
const res = await fetch('https://gatewayroot.com/api/v1/payments', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.GATEWAYROOT_SECRET_KEY}`,
'Idempotency-Key': randomUUID(),
'Content-Type': 'application/json',
},
body: JSON.stringify({
external_order_id: 'order-1001', customer_ref: 'customer-42',
amount_minor: 125050, currency: 'NPR', return_url: 'https://shop.example.com/orders/1001',
}),
});
const payment = await res.json();
// redirect the customer to `https://gatewayroot.com/pay/${payment.id}`
// Verify a webhook (Express: app.post(path, express.raw({ type: 'application/json' }), handler))
export function verify(req) {
const raw = req.body.toString('utf8');
const ts = req.get('x-gatewayroot-timestamp') ?? '';
const sig = req.get('x-gatewayroot-signature') ?? '';
if (!/^\d{1,12}$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const want = 'v1=' + createHmac('sha256', process.env.GATEWAYROOT_WEBHOOK_SECRET).update(`${ts}.${raw}`).digest('hex');
return sig.length === want.length && timingSafeEqual(Buffer.from(sig), Buffer.from(want));
}
Python
import hashlib, hmac, os, time, uuid
import requests
# Create a payment
r = requests.post(
"https://gatewayroot.com/api/v1/payments",
headers={
"Authorization": "Bearer " + os.environ["GATEWAYROOT_SECRET_KEY"],
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"external_order_id": "order-1001", "customer_ref": "customer-42",
"amount_minor": 125050, "currency": "NPR",
"return_url": "https://shop.example.com/orders/1001",
},
timeout=15,
)
payment = r.json()
pay_url = "https://gatewayroot.com/pay/" + payment["id"] # redirect the customer here
# Verify a webhook (Flask: raw = request.get_data(); headers from request.headers)
def verify(raw: bytes, ts: str, sig: str) -> bool:
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
secret = os.environ["GATEWAYROOT_WEBHOOK_SECRET"].encode()
want = "v1=" + hmac.new(secret, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, sig)
cURL
# Create a payment (no method / connection_id: the key decides the mode)
curl https://gatewayroot.com/api/v1/payments \
-H "Authorization: Bearer $GATEWAYROOT_SECRET_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"external_order_id":"order-1001","customer_ref":"customer-42","amount_minor":125050,"currency":"NPR"}'
# Open the hosted page: https://gatewayroot.com/pay/<payment id>
# Verify Mode: the customer submits the RRN there. You can also submit it yourself:
curl https://gatewayroot.com/api/v1/payments/pay_123/verify \
-H "Authorization: Bearer $GATEWAYROOT_SECRET_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"rrn":"412345678901","amount_minor":125050}'
15. Test Mode and going live
Test keys (gr_test_sk_...) work immediately with no payment connection: GatewayRoot uses a built-in sandbox. QR payments show a sample QR that cannot be paid; test payments stay open for 24 hours. Test and live data, keys and webhook endpoints are fully separate.
Finish a test payment with the simulator:
curl https://gatewayroot.com/api/v1/test/payments/pay_.../simulate \
-H "Authorization: Bearer $GATEWAYROOT_TEST_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "outcome": "verified" }'
outcome is one of verified, failed, expired, needs_review. The payment changes status and the matching webhook is sent exactly as in live mode. Live keys get 403 FORBIDDEN on this endpoint. You can do the same from the dashboard without an API key: open a Test-mode payment under Payments and use Simulate.
In Verify Mode, send the identifier TEST-SUCCESS (verified) or TEST-FAIL (failed). Webhooks for test payments go to your test webhook endpoints. Verify every webhook signature as shown in Verify the signature, and call panel-credit after crediting to see credit.updated arrive.
Going live: connect your Fonepay/NepalPay account, verify one test payment, then click Activate Live Mode in the dashboard. Until then live keys and live payments return 403 LIVE_NOT_ACTIVATED.

