Skip to content
API v1 · Fonepay · NPR

GatewayRoot API documentation

Create Fonepay payments, show the customer a QR code, and confirm verification by API or signed webhook. Base URL: https://gatewayroot.com

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-Id header. Quote it when you contact support.

Getting started: merchant setup

A panel team can integrate without contacting us. Follow these steps in the dashboard:

  1. Register at /signup and verify your email address.
  2. Connect a merchant. Add your Fonepay or NepalPay merchant account under Connections and wait until it shows as connected.
  3. 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.
  4. Issue an API key under API keys: one key per service and per environment (test or live). See Services and API keys.
  5. Register a webhook endpoint (see Webhooks) and test with a test key (see Test Mode and going live).
  6. 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 ModeVerify Mode
Customer experienceHosted 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 confirmedThe 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 windowConfigurable (QR expiry minutes)48 hours by default (configurable)
Hosted-page formConfigurable 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"
  }'
FieldRequiredNotes
external_order_idyesYour order id, 1-100 chars. Unique per project and environment.
customer_refyesYour customer id, 1-100 chars
amount_minoryesInteger paisa, at least 1
currencyyesNPR
return_urlnohttp(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_at is set (QR expiry is configurable, default 60 minutes; Verify Mode uses a 48-hour window). A background sweeper moves unpaid payments to expired once the Fonepay connection has been synced past the deadline, so a customer who paid just before expires_at is still matched. Rely on the payment.expired webhook or GET /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 with POST /payments/{id}/verify (see below).
  • A new payment for the same external_order_id is allowed once every earlier attempt is expired, canceled or failed. While an earlier attempt is still active (or verified), creating another returns 409 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 by status and external_order_id); pass next_cursor back as cursor to page. limit is 1-100.
ParameterNotes
qSearch by reference, external_order_id or customer_ref (exact or prefix) or by Fonepay transaction ID (prefix); 1-100 characters.
fromOnly payments created on or after this Nepal-time (NPT) date, YYYY-MM-DD.
toOnly 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.

SituationResponseWhat happens
Payment is not pending409 INVALID_TRANSITIONNothing changes
amount_minor differs from the payment amount422 AMOUNT_MISMATCHAttempt recorded
Transaction found, unused, amount equal, inside the payment window200, payment verifiedTransaction allocated to this payment
Transaction already allocated to another payment409 TRANSACTION_ALREADY_USEDAttempt recorded
Transaction happened before this payment was created409 TRANSACTION_OUTSIDE_WINDOWAttempt recorded
Transaction found but amount differs, it arrived after expires_at, or its remark belongs to another payment202, payment needs_reviewA human reviews it; you get a payment.needs_review webhook
Transaction not found yet, Fonepay connection healthy202 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 attention503 CONNECTOR_ACTION_REQUIRED or PROVIDER_PAGE_CHANGEDdetails.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 unreachable503 CONNECTOR_UNAVAILABLEdetails.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 attempts429 RATE_LIMITEDMax 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. Poll GET /payments/{id} or wait for the payment.verified / payment.needs_review / payment.expired webhook.
  • Never submit more than 5 claims for one payment, even with new Idempotency-Key values; the 6th is refused with 429.
  • Idempotent replays of a stored response return it with Idempotent-Replayed: true and do not count as new attempts. 429 and 503 responses are not stored under the Idempotency-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 be https in 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_url or 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 your external_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 fieldRequiredNotes
panel_payment_idyesYour own payment/transaction id in the panel (1-128 characters)
panel_domainyesDomain of your panel (1-255 characters)
panel_urlnoAbsolute https link to the payment in your panel
external_order_idvariant onlyThe 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

StatusMeaning
createdPayment created, waiting for the customer
pendingCustomer has been shown or has submitted payment details; verification in progress
verifiedPayment confirmed against Fonepay. Fulfil the order.
failedVerification failed
expiredNot paid before expires_at
canceledCanceled before completion
needs_reviewCould not be matched automatically; a human will review it
refundedRefunded after verification
reversedReversed 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.

CodeHTTP statusMeaning
INVALID_REQUEST400Malformed or invalid request (including a missing Idempotency-Key)
UNAUTHORIZED401Missing, invalid or revoked API key
FORBIDDEN403The key lacks the scope, or the action is not allowed (for example simulating with a live key)
LIVE_NOT_ACTIVATED403Live 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_VERIFIED403Account email is not verified (dashboard only)
MERCHANT_SUSPENDED403The merchant account is suspended; every API key request is refused until GatewayRoot reactivates it.
INVALID_CREDENTIALS401The current password is wrong (dashboard account settings only)
NOT_FOUND404Resource does not exist for this key
UNSUPPORTED_MEDIA_TYPE415Dashboard mutations must send application/json
CONFLICT409The resource is in a state that does not allow the action
EMAIL_TAKEN409The email address belongs to another account (dashboard account settings only)
IDEMPOTENCY_CONFLICT409Idempotency-Key reused with a different request
REQUEST_IN_PROGRESS409A request with this Idempotency-Key is still running; retry shortly
DUPLICATE_EXTERNAL_ORDER409external_order_id already used in this project and environment. error.details.payment_id is the existing payment.
TRANSACTION_ALREADY_USED409That Fonepay transaction was already allocated to another payment
TRANSACTION_OUTSIDE_WINDOW409The transaction happened before this payment was created
REVIEW_ITEM_CLOSED409The review item was already resolved (dashboard)
CONNECTION_NOT_READY409The Fonepay connection is missing, disconnected or has no QR yet
PLAN_LIMIT_EXCEEDED402The 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_TRANSITION409The payment cannot move to the requested status
AMOUNT_MISMATCH422The amount does not match
CURRENCY_NOT_SUPPORTED422Only NPR is supported
RATE_LIMITED429Too 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_ERROR500Our fault; safe to retry with the same Idempotency-Key
CONNECTOR_UNAVAILABLE503Fonepay 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_REQUIRED503The 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_CHANGED503The 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:

HeaderValue
X-GatewayRoot-Event-IdUnique event id. The same event keeps the same id on every retry.
X-GatewayRoot-TimestampUnix seconds when this attempt was signed
X-GatewayRoot-Signaturev1= 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.