Skip to content
Pay
Esc
↑↓navigate↵open⌘Jpreview
On this page

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:

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:

  1. Join t, a . and the raw request body, and compute the HMAC-SHA256 of the result with your signing secret.
  2. Hex-encode the HMAC and compare it to v1 in constant time.
  3. Reject the delivery if t is 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)) <= 300

Replays

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.

Was this page helpful?