Webhooks
RazPay pushes a signed event to your server whenever a transaction, pre-approval or invoice changes state.
Set your project’s webhook URL from its page in the dashboard. Saving it shows a signing secret once, the same as an API key. Saving a new URL rotates the secret.
Events
| Event | Sent when |
|---|---|
transaction.successful |
A collection succeeded. |
transaction.failed |
A collection failed or expired. |
payout.successful |
A payout reached the recipient. |
payout.failed |
A payout failed. |
refund.successful |
A refund reached the payer. |
refund.failed |
A refund failed. |
preapproval.approved |
A payer approved a pre-approval. |
preapproval.failed |
A payer rejected a pre-approval, or it failed. |
preapproval.cancelled |
A pre-approval was cancelled, by you or by the payer. |
preapproval.expired |
A pre-approval ran out, or the payer never answered. |
invoice.paid |
An invoice was paid. |
invoice.failed |
An invoice failed. |
invoice.cancelled |
You cancelled an invoice. |
invoice.expired |
An invoice passed its validity without being paid. |
Collections keep the transaction.* names. Payouts and refunds have their own events, so a handler written for payments never mistakes money going out for money coming in. Ignore events you don’t handle.
Payload
{
"event": "transaction.successful",
"transaction": {
"external_reference": "order-1001",
"type": "collection",
"direction": "in",
"provider_id": "momo",
"provider_ref": "6f0a2e1c-8b1d-4a8e-9a55-0c7d0e3b1f42",
"amount": 30,
"currency": "SZL",
"status": "successful",
"financial_tx_id": "6543210987",
"payer_phone": "26876123456",
"description": "Order 1001",
"metadata": { "order_id": "1001" },
"created_at": 1760774400,
"updated_at": 1760774460
},
"sent_at": 1760774461
}
A failure has the same shape, with failure_reason explaining what went wrong and no financial_tx_id:
{
"event": "transaction.failed",
"transaction": {
"external_reference": "order-1002",
"type": "collection",
"direction": "in",
"provider_id": "momo",
"provider_ref": "0b7c9d24-3e5f-4c1a-8f60-2a9e4d7b6c13",
"amount": 30,
"currency": "SZL",
"status": "failed",
"failure_reason": "payment was rejected by the payer",
"payer_phone": "26876123456",
"description": "Order 1002",
"metadata": { "order_id": "1002" },
"created_at": 1760774400,
"updated_at": 1760774460
},
"sent_at": 1760774461
}
The object matches the API:
transaction: the transaction object. Sent ontransaction.*,payout.*andrefund.*, and oninvoice.paid(the collection the invoice created).preapproval: the pre-approval object. Sent onpreapproval.*.invoice: the invoice object. Sent oninvoice.*.
sent_at is when RazPay first tried to deliver the event. It stays the same on every retry.
Verifying the signature
Every delivery has an X-RazPay-Signature header shaped like t={unix_time},v1={hex_hmac}, where t is the time of that delivery attempt. To verify it:
- Join
t, a.and the raw request body, and compute the HMAC-SHA256 of the result with your signing secret. - Hex-encode the HMAC and compare it to
v1in constant time. - Reject the delivery if
tis more than 5 minutes from your server’s clock.
Use the raw bytes you received, not parsed and re-encoded JSON. A retry carries the same event, but its JSON can be formatted differently.
import crypto from "node:crypto";
export function verify(rawBody, header, secret) {
const parts = header.split(",");
const t = parts.find((p) => p.startsWith("t="))?.slice(2) ?? "";
const v1 = parts.find((p) => p.startsWith("v1="))?.slice(3) ?? "";
const expected = crypto.createHmac("sha256", secret)
.update(`${t}.`).update(rawBody).digest("hex");
const valid = v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
return valid && Math.abs(Date.now() / 1000 - Number(t)) <= 300;
}func verify(rawBody []byte, header, secret string) bool {
var t, v1 string
for _, part := range strings.Split(header, ",") {
if strings.HasPrefix(part, "t=") {
t = strings.TrimPrefix(part, "t=")
}
if strings.HasPrefix(part, "v1=") {
v1 = strings.TrimPrefix(part, "v1=")
}
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(rawBody)
if !hmac.Equal([]byte(v1), []byte(hex.EncodeToString(mac.Sum(nil)))) {
return false
}
sent, err := strconv.ParseInt(t, 10, 64)
if err != nil {
return false
}
age := time.Now().Unix() - sent
return age >= -300 && age <= 300
}import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = header.split(",")
t = next((p[2:] for p in parts if p.startswith("t=")), "")
v1 = next((p[3:] for p in parts if p.startswith("v1=")), "")
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(v1, expected) or not t.isdigit():
return False
return abs(time.time() - int(t)) <= 300Replays
The signature covers t, so a captured delivery can’t be given a new timestamp, and the 5-minute check stops it being replayed after that. Each retry is signed with its own time, so the check never rejects a real retry.
A delivery can still be replayed within those 5 minutes, and real retries also arrive as duplicates, so you still need to ignore events you’ve already processed.
Retries
Answer with any 2xx status to acknowledge a delivery. Anything else, or no answer within 10 seconds, is retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 2 hours, then every 6 hours, up to 8 attempts in total. Each retry goes to your current webhook URL, signed with your current secret. If the endpoint has been disabled or removed, RazPay stops retrying.
The same event can arrive more than once. Make your handler idempotent: key it on the event name plus the external_reference of the object the event is about, and ignore a pair you’ve already processed. The reference alone isn’t enough, because one pre-approval can send preapproval.approved and later preapproval.cancelled.
Each delivery retries on its own, so an older event can arrive after a newer one. Before changing state, compare the object’s updated_at with the value you stored, and skip the event if it’s older.
If your endpoint is down longer than the retries last, look transactions up with GET /payments/{reference} or list them.
