---
title: Webhooks
description: 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

```json
{
  "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`:

```json
{
  "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](/docs/concepts/transactions#the-transaction-object). Sent on `transaction.*`, `payout.*` and `refund.*`, and on `invoice.paid` (the collection the invoice created).
- **`preapproval`:** [the pre-approval object](/docs/api/create-preapproval). Sent on `preapproval.*`.
- **`invoice`:** [the invoice object](/docs/api/create-invoice). Sent on `invoice.*`.

`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.

```js Node.js
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;
}
```

```go Go
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
}
```

```python Python
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).

## 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}`](/docs/api/get-transaction) or [list them](/docs/api/list-transactions).
