Best practices
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 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 it needs. Most services only need
collect. - Use a separate key for the service that sends payouts or refunds, so a leaked
collectkey 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_referenceunique 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
500or429, retry with the same reference. You get the original transaction back, and the customer is never charged twice. - After a
failedresult, 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.
Collecting payments
- Fulfil orders only when you see
successful. A200fromPOST /payments/collectmeans the request was accepted, not that the customer paid. - Read
data.statuson every response. A retry returns the transaction as it is now, which may no longer bepending. - Don’t treat a slow payment as failed. Customers have about 5 minutes to approve, and a transaction can stay
pendingfor up to 24 hours before RazPay closes it. - Before fulfilling, check that the
amountandcurrencymatch 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. - Tell the customer to check their phone, and show
failure_reasonif the payment fails. It’s written to be shown to a user.
Handling webhooks
- Verify the signature on the raw request body before doing anything else, and reject a
tmore than 5 minutes from your clock. Keep your server’s clock synced. - Answer with a
2xxwithin 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_referenceof 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_atwith 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.*andrefund.*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}. - 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. Rows can repeat across pages during a sync, so de-duplicate by reference.
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. They limit the damage from a bug or a leaked key.
- Confirm the recipient with a wallet check using
?account=disbursement, and show the owner’s name before paying. - Check the disbursement balance before a large batch, and keep it funded. Refunds are paid from this account too, not the collection account.
- Check for a
failed to send payout:orfailed 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
pendingrefund as money that’s already committed. Leave outamountonly when you mean to refund everything that’s left.
Recurring payments and invoices
- Update your records on
preapproval.cancelledandpreapproval.expired, and stop charging under them. Customers can cancel from their phone, so this can happen at any time. See Recurring payments. - Charge under a pre-approval with a new
external_referencefor each billing period, such asgym-2026-11-sipho. - Treat a paid invoice 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 forRetry-Afterbefore retrying, and watchX-RateLimit-Remainingto slow down first. See Rate limits. - Check
successbefore readingdata. On failure,messagesays what went wrong in words you can show a user. See Errors. - Send amounts in major units (
30.00, not3000), in the currency of your MTN environment. - Store
provider_refandfinancial_tx_id. You’ll need them when you contact MTN about a transaction. - Use
metadatafor your own ids rather than encoding them indescription, 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
payoutandrefundonly 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.
