All resources

Payment Webhooks: Integration & Signature Verification Guide

Rapid Gateway notifies your server of payment outcomes by POSTing a signed JSON webhook to your URL. Every callback is HMAC-SHA256 signed so you can prove it came from us, and failed deliveries are retried automatically. This guide shows you how to receive and verify them.

How webhooks work

The moment a payment completes, fails, or is refunded, we send a POST request to your webhook URL with a JSON body and a set of X-RapidGateway-* headers. Respond with any 2xx status to acknowledge it. Any non-2xx response — or a timeout beyond 15 seconds — triggers an automatic retry.

event_typeWhen it fires
transaction.completedPayment succeeded (customer paid)
transaction.failedPayment failed or was declined
refund.completed / refund.failedRefund outcome
reversal.completed / reversal.failedReversal / void outcome
webhook.testTest-fire from the portal (ignore in production logic)

The webhook payload

A typical delivery body looks like this. The eventId is stable across retries — use it to de-duplicate.

JSON
{
  "eventId": "b7f3c2a1-9e44-4d2a-8b10-2f0e5c9a1234",
  "eventType": "transaction.completed",
  "source": "ORCHESTRATOR",
  "merchantId": 375,
  "gatewayTxnRef": "20cccf38-a8f9-4666-abf1-be11f18e1819",
  "merchantTransactionId": "ORDER-1001",
  "status": "SUCCESS",
  "amount": 100.00,
  "currency": "PKR",
  "environment": "LIVE",
  "occurredAt": "2026-07-07T08:59:07Z"
}

Headers

During the transition we also send legacy X-RapidPay-* aliases — verify the X-RapidGateway-* headers.

  • X-RapidGateway-Signature — uppercase hex HMAC-SHA256 (see below).
  • X-RapidGateway-Timestamp — Unix epoch seconds when we signed the request.
  • X-RapidGateway-Event — the event_type.
  • X-RapidGateway-Delivery — the eventId.

Verify the signature (do this on every webhook)

The signature is HMAC-SHA256(secret = your webhook salt, message = timestamp + "." + rawBody), hex-encoded and uppercase. To verify: (1) reject if X-RapidGateway-Timestamp is more than 5 minutes from now; (2) recompute the HMAC over timestamp + "." + rawBody with your salt; (3) constant-time compare against X-RapidGateway-Signature. Always use the raw request body bytes — do not re-serialize the JSON. Your salt is in the portal under Developers → Webhooks; during a rotation, accept the current OR previous salt.

Java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;

boolean verify(String salt, String timestamp, String rawBody, String signature) {
    long ts = Long.parseLong(timestamp);
    if (Math.abs(System.currentTimeMillis() / 1000 - ts) > 300) return false; // 5-min window
    try {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(salt.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        byte[] raw = mac.doFinal((timestamp + "." + rawBody).getBytes(StandardCharsets.UTF_8));
        StringBuilder sb = new StringBuilder(raw.length * 2);
        for (byte b : raw) sb.append(String.format("%02X", b));
        return MessageDigest.isEqual(sb.toString().getBytes(StandardCharsets.UTF_8),
                                     signature.getBytes(StandardCharsets.UTF_8));
    } catch (Exception e) { return false; }
}
PHP
function rg_verify(string $salt, string $timestamp, string $rawBody, string $signature): bool {
    if (abs(time() - (int)$timestamp) > 300) return false;              // 5-min window
    $expected = strtoupper(hash_hmac('sha256', $timestamp . '.' . $rawBody, $salt));
    return hash_equals($expected, $signature);                          // constant-time
}
// $rawBody = file_get_contents('php://input');
// $ts  = $_SERVER['HTTP_X_RAPIDGATEWAY_TIMESTAMP'];
// $sig = $_SERVER['HTTP_X_RAPIDGATEWAY_SIGNATURE'];
Node.js (Express)
const crypto = require('crypto');
// Capture the RAW body, e.g. app.use(express.raw({ type: 'application/json' }))
function rgVerify(salt, timestamp, rawBody, signature) {
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300) return false;
  const expected = crypto.createHmac('sha256', salt)
    .update(timestamp + '.' + rawBody)
    .digest('hex').toUpperCase();
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// const ts  = req.header('X-RapidGateway-Timestamp');
// const sig = req.header('X-RapidGateway-Signature');
// rgVerify(salt, ts, req.body.toString('utf8'), sig)
Python (Flask)
import hmac, hashlib, time

def rg_verify(salt: str, timestamp: str, raw_body: bytes, signature: str) -> bool:
    if abs(int(time.time()) - int(timestamp)) > 300:      # 5-min window
        return False
    msg = timestamp.encode() + b"." + raw_body
    expected = hmac.new(salt.encode(), msg, hashlib.sha256).hexdigest().upper()
    return hmac.compare_digest(expected, signature)
# raw_body = request.get_data()   # raw bytes, not request.json
# ts  = request.headers["X-RapidGateway-Timestamp"]
# sig = request.headers["X-RapidGateway-Signature"]

Per-transaction webhook URL

By default we deliver to your registered default callback URL. You can override it per transaction by passing webhookUrl in the Process Transaction request (POST /api/v1/payments/process) or when creating a checkout session (POST /v1/checkout-sessions). If present in the payload it wins for that transaction; otherwise the registered default is used.

  • HTTPS only (in production).
  • No embedded credentials (user:pass@) or #fragments.
  • Must not resolve to a private, loopback, link-local or cloud-metadata IP (SSRF protection).
  • Maximum 512 characters.
  • Must match your allowed-domains whitelist, if you have set one.

Retries, idempotency & delivery

  • Retries: on any non-2xx or timeout, at approximately 30s, 2m, 10m, 1h and 6h (6 attempts total), then dead-lettered.
  • Idempotency: the same event may arrive more than once — de-duplicate on eventId and treat a repeat as a no-op.
  • History & resend: every attempt is recorded in the portal; you can resend any event and test-fire a signed sample to your endpoint.

Salt rotation & whitelist

  • Rotate your salt anytime — during the rotation window BOTH the new and previous salts verify, so migrate then switch fully to the new one.
  • Domain whitelist: restrict which hosts we will deliver to, so a leaked key cannot redirect your callbacks to an attacker.

Troubleshooting

SymptomCause / fix
Signature never matchesYou are hashing a re-serialized body — use the raw bytes; or wrong salt/case (hex is uppercase).
“timestamp too old”Your clock is off, or you are verifying an old/retried delivery — allow the 5-minute window.
No webhook receivedNon-HTTPS, private-IP/localhost URL, or host not in your whitelist — check the portal delivery history for a rejected row.
Duplicate processingDe-duplicate on eventId.

Ready to start accepting payments?

Set up Rapid Gateway in minutes. No setup fees, no monthly subscriptions.

Create Your Free Account