---
title: Best practices
description: How to build a RazPay integration that never charges twice, never misses a result and keeps money movements under control.
---

Each section covers one part of an integration, with links to the detail. Use the [go-live checklist](#go-live-checklist) before you launch.

## Keys and secrets

- Keep API keys and the webhook signing secret on your server, in environment variables or a secrets manager. Never put them in a browser, a mobile app or a repository.
- Give each key only the [permissions](/docs/concepts/authentication#permissions) it needs. Most services only need `collect`.
- Use a separate key for the service that sends payouts or refunds, so a leaked `collect` key can't move money out.
- Revoke a key as soon as you suspect it has leaked, then create a new one. Revoking takes effect immediately.
- Saving a webhook URL replaces the signing secret, and the new one is shown once. Update your server straight away, or it will reject deliveries.

## References

- Make `external_reference` unique per intended payment, not per attempt: `order-1001`, not a random value generated on each try.
- Save the reference in your database **before** calling RazPay, so you can always retry or look up the payment after a crash.
- If a request times out or returns a `500` or `429`, retry with the **same** reference. You get the original transaction back, and the customer is never charged twice.
- After a `failed` result, use a **new** reference for the next attempt. A transaction never changes once it has failed.
- References are unique across collections, payouts and refunds in a project, so prefix them by purpose if your ids could overlap.

See [References and retries](/docs/concepts/transactions#references-and-retries).

## Collecting payments

- Fulfil orders only when you see `successful`. A `200` from [`POST /payments/collect`](/docs/api/collect) means the request was accepted, not that the customer paid.
- Read `data.status` on every response. A retry returns the transaction as it is now, which may no longer be `pending`.
- Don't treat a slow payment as failed. Customers have about 5 minutes to approve, and a transaction can stay `pending` for up to 24 hours before RazPay closes it.
- Before fulfilling, check that the `amount` and `currency` match the order.
- Check for a `failed to initiate payment:` message. It means the provider refused the request, the reference is used up, and [no webhook will follow](/docs/api/collect#response).
- Tell the customer to check their phone, and show `failure_reason` if the payment fails. It's written to be shown to a user.

## Handling webhooks

- [Verify the signature](/docs/concepts/webhooks#verifying-the-signature) on the raw request body before doing anything else, and reject a `t` more than 5 minutes from your clock. Keep your server's clock synced.
- Answer with a `2xx` within 10 seconds. Record the event and do slow work afterwards, or RazPay will treat the delivery as failed and retry.
- Deduplicate on the event name plus the `external_reference` of the object the event is about. Use a database unique constraint with no expiry, not an in-memory cache.
- Record the event in the same database transaction as the business change, so two copies arriving together can't both be applied.
- Compare `updated_at` with the value you stored and skip older events, because a retried event can arrive after a newer one.
- Ignore event types you don't handle, but still answer them with a `2xx`.
- Keep payouts and refunds separate from payments in your code. They have their own `payout.*` and `refund.*` events, so never treat them as money coming in.

## Never missing a result

Treat webhooks as the main signal and polling as the backup:

- Run a periodic job that looks up anything you still have as pending with [`GET /payments/{reference}`](/docs/api/get-transaction).
- If a page is waiting on the customer, poll every few seconds for a couple of minutes at most.
- After an outage longer than the retry window of about 10 hours, catch up with [`GET /transactions`](/docs/api/list-transactions). Rows can repeat across pages during a sync, so [de-duplicate by reference](/docs/concepts/pagination#reading-every-page).

## Sending money out

Payouts can't be reversed once MTN accepts them, so check before you send:

- Set a **Maximum single payout** and a **Daily payout limit** on every project that sends [payouts](/docs/guides/payouts). They limit the damage from a bug or a leaked key.
- Confirm the recipient with a [wallet check](/docs/guides/wallet-checks) using `?account=disbursement`, and show the owner's name before paying.
- Check the disbursement balance before a large batch, and keep it funded. [Refunds](/docs/guides/refunds) are paid from this account too, not the collection account.
- Check for a `failed to send payout:` or `failed to send refund:` message. As with collections, the provider refused the request, the reference is used up and no webhook will follow.
- Give each refund its own new reference, and treat a `pending` refund as money that's already committed. Leave out `amount` only when you mean to refund everything that's left.

## Recurring payments and invoices

- Update your records on `preapproval.cancelled` and `preapproval.expired`, and stop charging under them. Customers can cancel from their phone, so this can happen at any time. See [Recurring payments](/docs/guides/recurring-payments).
- Charge under a pre-approval with a new `external_reference` for each billing period, such as `gym-2026-11-sipho`.
- Treat a paid [invoice](/docs/guides/invoices) as income when you get `invoice.paid`. It carries both the invoice and the collection written to the ledger.

## Requests and errors

- Stay under one request per second sustained. On a `429`, wait for `Retry-After` before retrying, and watch `X-RateLimit-Remaining` to slow down first. See [Rate limits](/docs/concepts/rate-limits).
- Check `success` before reading `data`. On failure, `message` says what went wrong in words you can show a user. See [Errors](/docs/concepts/errors).
- Send amounts in major units (`30.00`, not `3000`), in the currency of your MTN environment.
- Store `provider_ref` and `financial_tx_id`. You'll need them when you contact MTN about a transaction.
- Use `metadata` for your own ids rather than encoding them in `description`, which customers can see.
- Keep API keys, signing secrets and signature headers out of your logs.

## Go-live checklist

- [ ] Keys are stored server-side, with `payout` and `refund` only on the services that need them.
- [ ] References are saved before each call and reused on retries.
- [ ] Orders are fulfilled only on `successful`, after checking the amount and currency.
- [ ] A provider refusal at collect is handled without waiting for a webhook.
- [ ] The webhook handler verifies the raw body and the 5-minute limit, and answers within 10 seconds.
- [ ] Duplicate and out-of-order events have been tested.
- [ ] A periodic job resolves anything still pending.
- [ ] Payout limits are set, and recipients are confirmed before paying.
- [ ] Every flow you use has been tested against production with small amounts. See [Sandbox](/docs/providers/mtn-momo#sandbox).
