To integrate the PayU payment gateway with Django, you build a payment request on the server (a unique txnid, amount, productinfo, firstname, email), compute a SHA-512 request hash from a pipe-delimited string of those fields plus your merchant key and salt, then POST an auto-submitting form to PayU's hosted checkout. The customer pays on PayU's page and is redirected back to your success or failure URL — at which point you MUST recompute and verify the response hash server-side, in reverse field order, before marking the order as paid. Never trust the browser redirect alone: confirm every payment with PayU's Verify Payment API or a server-to-server webhook.
Key takeaways
- Hosted checkout = least PCI burden. PayU captures the card on its own page, so your Django app never touches raw card data.
- Two hashes, two directions. Sign the outgoing request with
sha512(key|txnid|amount|productinfo|firstname|email|udf1...udf10|salt); verify the incoming response with the reverse sequencesha512(salt|status|udf10...udf1|email|firstname|productinfo|amount|txnid|key). - Verify server-side, always. A matching response hash is the only cryptographic proof the redirect was not tampered with — then reconcile with the Verify Payment API or a webhook.
- Secrets in env vars. Keep
PAYU_MERCHANT_KEYandPAYU_MERCHANT_SALTout of code and version control, and serve every PayU URL over HTTPS. - Pick the right region/API. PayU India, PayU Global (EMEA/Poland/CEE), and PayU LATAM are different products with versioned APIs — integrate against the one that matches your settlement country.
What is PayU and how does the Django payment flow work?
PayU is a payment service provider that lets businesses accept cards, UPI, net-banking, wallets, and EMI through a single integration. It is one of the most widely used gateways in India, Poland and the wider CEE region, and Latin America, which makes it a common choice for Django teams shipping to those markets.
The standard integration uses hosted (redirect) checkout, which keeps card entry on PayU's PCI-DSS-compliant page. The end-to-end flow is:
- The customer clicks Pay in your Django app.
- Your server builds the transaction parameters and computes the request hash.
- Django returns an auto-submitting HTML form that POSTs to PayU (
https://test.payu.in/_paymentfor test,https://secure.payu.in/_paymentfor live). - PayU creates a transaction (returning a unique MihPayID) and shows its payment page.
- After payment, PayU POSTs the result back to your
surl(success) orfurl(failure). - Your server verifies the response hash, then reconciles the payment before fulfilling the order.
Which PayU product and API version are you integrating?
"PayU" is a brand spanning several regional platforms, and the API contract differs by region:
- PayU India — the
_paymenthosted-checkout flow plus the Verify Payment /postserviceAPIs described here; also powers UPI and EMI. - PayU Global (EMEA, including Poland and CEE) — uses the OAuth-based REST flow at
/api/v2_1/orderswith anOpenPayU-Signatureheader rather than the India-style pipe hash. - PayU LATAM — its own REST API and credentials for Colombia, Brazil, Mexico, Argentina, Peru, and Chile.
Always read the docs for your settlement country and pin the API version. This guide focuses on the India _payment hash flow, which is the most common Django use case.
PayU vs PayPal vs Razorpay vs Stripe: which gateway fits?
All four offer hosted checkout and REST APIs; the real differences are geographic reach and local payment-method coverage. (Every gateway charges a per-transaction fee and settles in the local currency — confirm current rates and supported currencies with each provider.)
| Gateway | Strongest regions | Local methods | Integration style |
|---|---|---|---|
| PayU | India, Poland/CEE, LATAM | UPI, net-banking, EMI, wallets, local cards | Hosted redirect + SHA-512 hash (India); OAuth REST (Global) |
| Razorpay | India | UPI, net-banking, wallets, EMI | REST API + Orders, JS Checkout |
| PayPal | US, EU, global | PayPal balance, cards, Pay Later | JS SDK Smart Buttons + Orders API v2 |
| Stripe | US, EU, global | Cards, wallets, many local methods | Payment Intents + Stripe.js / Checkout |
If your buyers are mostly in India or CEE/LATAM, PayU (or Razorpay for India-only) gives the best local-method coverage; for global card acceptance, Stripe and PayPal are the usual picks. See our companion guides on PayPal integration with Django and 2Checkout integration with Django.
Hosted checkout vs direct API: which should you build?
| Hosted (redirect) checkout | Direct / server-to-server API | |
|---|---|---|
| Card data touches your server? | No — PayU's page captures it | Yes — full card data in transit |
| PCI-DSS scope | Minimal (SAQ-A) | Heavy (SAQ-D); audited infrastructure |
| UX | Customer leaves to PayU, then returns | Fully in-app / native |
| Best for | Most Django apps, MVPs, marketplaces | Large merchants with compliance teams |
For almost every Django project, hosted checkout is the right default — you avoid storing or transmitting raw card numbers entirely, which is the single biggest reduction in your PCI compliance obligations.
Step 1: Store your PayU key and salt in environment variables
When you sign up, PayU issues a merchant key and a salt. These credentials sign every hash, so treat the salt like a password: keep both in environment variables (or a secrets manager), never in settings.py or version control. Use a separate test key/salt for the sandbox and a live key/salt for production, switched by a PAYU_MODE flag.
# settings.py
import os
PAYU_MERCHANT_KEY = os.environ["PAYU_MERCHANT_KEY"]
PAYU_MERCHANT_SALT = os.environ["PAYU_MERCHANT_SALT"]
# "TEST" -> https://test.payu.in ; "LIVE" -> https://secure.payu.in
PAYU_MODE = os.environ.get("PAYU_MODE", "TEST")
PAYU_PAYMENT_URL = {
"TEST": "https://test.payu.in/_payment",
"LIVE": "https://secure.payu.in/_payment",
}[PAYU_MODE]Step 2: How do you build the payment request and request hash?
PayU authenticates your request with a SHA-512 checksum of a pipe-delimited string. The field order is fixed and must match exactly — a single missing or extra | produces a different hash and PayU rejects the transaction. The request sequence is:
sha512(key|txnid|amount|productinfo|firstname|email|udf1|udf2|udf3|udf4|udf5|udf6|udf7|udf8|udf9|udf10|salt)
Key rules:
txnidmust be unique per transaction (max 25 chars) — a UUID works well.amountis a string with two decimals (e.g."499.00"); the value inside the hash must equal the value you POST.- Unused
udf1–udf10(user-defined fields) are sent as empty strings but still occupy their pipe positions. surl/furlare your absolute success/failure URLs;curlis the optional cancel URL.
# payments/views.py
import hashlib
from uuid import uuid4
from django.conf import settings
from django.shortcuts import render
def initiate_payment(request):
key = settings.PAYU_MERCHANT_KEY
salt = settings.PAYU_MERCHANT_SALT
txnid = uuid4().hex[:20] # unique, <= 25 chars
amount = "499.00" # string, 2 decimals
productinfo = "Pro plan (annual)"
firstname = "Asha"
email = "asha@example.com"
udfs = ["", "", "", "", "", "", "", "", "", ""] # udf1..udf10
seq = [key, txnid, amount, productinfo, firstname, email, *udfs, salt]
request_hash = hashlib.sha512("|".join(seq).encode("utf-8")).hexdigest().lower()
params = {
"key": key,
"txnid": txnid,
"amount": amount,
"productinfo": productinfo,
"firstname": firstname,
"email": email,
"phone": "9999999999",
"surl": "https://yourapp.com/payu/success/",
"furl": "https://yourapp.com/payu/failure/",
"hash": request_hash,
}
return render(
request,
"payments/payu_redirect.html",
{"action": settings.PAYU_PAYMENT_URL, "params": params},
)Step 3: Redirect to PayU with an auto-submitting form
You cannot call PayU's _payment endpoint with a background AJAX request — the customer's browser has to land on PayU's hosted page. The simplest approach is a hidden form that submits itself on load, with a no-JavaScript fallback button:
<!-- templates/payments/payu_redirect.html -->
<form id="payu-form" method="post" action="{{ action }}">
{% for name, value in params.items %}
<input type="hidden" name="{{ name }}" value="{{ value }}">
{% endfor %}
<noscript><button type="submit">Continue to PayU</button></noscript>
</form>
<script>
document.getElementById("payu-form").submit();
</script>Step 4: How do you verify the PayU response hash? (the step you must not skip)
After payment, PayU POSTs the result to your surl or furl, including status, txnid, amount, mihpayid, and a hash. Because that POST travels through the customer's browser, it can be forged or replayed — so the response hash is your only cryptographic proof the data is genuine.
PayU computes the response hash with the request sequence reversed, prefixed by the salt and with status inserted right after it:
sha512(salt|status|udf10|udf9|udf8|udf7|udf6|udf5|udf4|udf3|udf2|udf1|email|firstname|productinfo|amount|txnid|key)
Recompute it on your server and compare with constant-time hmac.compare_digest. Treat the payment as real only when the hashes match and status == "success". If PayU included an additionalCharges field, it is prepended to the sequence (additionalCharges|salt|status|...).
# payments/views.py
import hashlib
import hmac
from django.conf import settings
from django.http import HttpResponseForbidden
from django.shortcuts import render
from django.views.decorators.csrf import csrf_exempt
def _is_valid_response(posted):
key = settings.PAYU_MERCHANT_KEY
salt = settings.PAYU_MERCHANT_SALT
status = posted.get("status", "")
udfs = [posted.get(f"udf{i}", "") for i in range(10, 0, -1)] # udf10..udf1
seq = [salt, status, *udfs,
posted.get("email", ""), posted.get("firstname", ""),
posted.get("productinfo", ""), posted.get("amount", ""),
posted.get("txnid", ""), key]
if posted.get("additionalCharges"): # rare; prepend when present
seq = [posted["additionalCharges"], *seq]
expected = hashlib.sha512("|".join(seq).encode("utf-8")).hexdigest().lower()
return hmac.compare_digest(expected, posted.get("hash", ""))
@csrf_exempt # PayU POSTs cross-site, so Django's CSRF token is not present
def payu_success(request):
posted = request.POST.dict()
if not _is_valid_response(posted):
return HttpResponseForbidden("Hash mismatch - response was tampered with")
if posted.get("status") == "success":
# Idempotently mark the order paid using posted["txnid"] / posted["mihpayid"],
# then reconcile with the Verify Payment API before fulfilling.
pass
return render(request, "payments/success.html", {"txn": posted})Why the browser redirect is not enough: webhooks and the Verify Payment API
The redirect to your surl can fail even on a successful payment — the customer may close the tab, lose connectivity, or hit a timeout before the POST reaches you. Relying on it alone leaves you with paid-but-unfulfilled orders. Build two server-side safety nets:
- Server-to-server webhook (S2S callback) — configure a callback URL in your PayU dashboard so PayU notifies your backend directly, independent of the browser. Verify its hash exactly as you verify the redirect.
- Verify Payment API — a pull-based reconciliation call you make from a cron job (or after each redirect) to confirm the true status straight from PayU.
# payments/reconcile.py
import hashlib
import requests
from django.conf import settings
def verify_payment(txnid):
key = settings.PAYU_MERCHANT_KEY
salt = settings.PAYU_MERCHANT_SALT
command = "verify_payment"
# Command-API hash sequence: sha512(key|command|var1|salt)
seq = f"{key}|{command}|{txnid}|{salt}"
h = hashlib.sha512(seq.encode("utf-8")).hexdigest().lower()
endpoint = ("https://info.payu.in/merchant/postservice?form=2"
if settings.PAYU_MODE == "LIVE"
else "https://test.payu.in/merchant/postservice?form=2")
resp = requests.post(endpoint, data={
"key": key, "command": command, "var1": txnid, "hash": h,
}, timeout=15)
return resp.json() # transaction_details -> status, mode, amt, ...Security and PCI-DSS checklist
- Never store raw card data. With hosted checkout, PayU captures and tokenizes the card, keeping you in the lightest PCI-DSS scope. Don't log or persist card numbers or CVVs.
- Keep key and salt in env vars / a secrets manager — never in
settings.py, the repo, or client-side code. Rotate the salt if it leaks. - Verify both hashes — the request you sign and the response you check — and compare in constant time with
hmac.compare_digest. - Serve every PayU URL over HTTPS, including
surl,furl, and your webhook endpoint. - Make fulfilment idempotent, keyed on
txnid/mihpayid, so a redirect and a webhook for the same payment don't double-ship. - Validate the amount server-side against the order — never trust an amount echoed back through the browser.
Frequently Asked Questions
How do I integrate PayU with Django?
Build the transaction parameters (txnid, amount, productinfo, firstname, email) on the server, compute a SHA-512 request hash from key|txnid|amount|productinfo|firstname|email|udf1...udf10|salt, and render an auto-submitting HTML form that POSTs to PayU's _payment endpoint. After the customer pays, PayU redirects to your success/failure URL, where you recompute and verify the response hash before marking the order paid.
What hash does PayU use, and why does verification matter?
PayU uses SHA-512 over a pipe-delimited string of your transaction fields plus the merchant salt. The request hash proves the call came from you; the response hash (the same fields reversed, prefixed with the salt and status) proves PayU's reply was not altered in the browser. Skipping response verification lets an attacker forge a "success" POST to your surl and receive goods for free.
Where do I get the PayU merchant key and salt?
They are issued in your PayU merchant dashboard — a separate test key/salt for the sandbox (test.payu.in) and a live key/salt for production (secure.payu.in). Store each in environment variables and switch with a PAYU_MODE setting; never commit them to your repository.
Do I need webhooks if I already verify the redirect?
Yes. The browser redirect can be lost if the customer closes the tab or the network drops, leaving a paid order unconfirmed. Configure a server-to-server webhook and/or poll the Verify Payment API so your backend learns the true status directly from PayU, independent of the browser.
Is my Django app PCI-DSS compliant if I use PayU?
With hosted checkout, PayU captures the card on its own page, so your server never handles raw card data and you fall under the lightest PCI scope (SAQ-A). You still must use HTTPS, protect your key and salt, and avoid logging any card details. A direct server-to-server card API would put you in full SAQ-D scope.
PayU India vs PayU Global vs PayU LATAM — which API do I use?
Pick the platform that matches your settlement country. PayU India uses the _payment hosted flow and SHA-512 hash shown here; PayU Global (EMEA/Poland/CEE) uses an OAuth REST API (/api/v2_1/orders) with signature headers; PayU LATAM has its own REST API. They are not interchangeable, so read the docs for your region and pin the API version.
Build a secure PayU checkout with confidence
A correct PayU integration lives or dies on one detail: verifying the response hash server-side and reconciling every payment before you fulfil an order. Get that right and hosted checkout gives you broad local-payment coverage with minimal PCI exposure.
If you want a payment flow that's audited, idempotent, and production-ready, our team can help — explore our Django development services. For other gateways and storefronts, see our guides on PayPal integration with Django and building an e-commerce store with Django Oscar.