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_type | When it fires |
|---|---|
| transaction.completed | Payment succeeded (customer paid) |
| transaction.failed | Payment failed or was declined |
| refund.completed / refund.failed | Refund outcome |
| reversal.completed / reversal.failed | Reversal / void outcome |
| webhook.test | Test-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.
{
"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.
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; }
}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'];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)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
eventIdand 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
| Symptom | Cause / fix |
|---|---|
| Signature never matches | You 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 received | Non-HTTPS, private-IP/localhost URL, or host not in your whitelist — check the portal delivery history for a rejected row. |
| Duplicate processing | De-duplicate on eventId. |