Building CCAvenue Right, Then Open-Sourcing python-pay-ccavenue

CCAvenue is India's oldest, fiddliest payment gateway. Here is how I built the AES redirect handshake cleanly, then open-sourced it as python-pay-ccavenue.

Building CCAvenue Right, Then Open-Sourcing python-pay-ccavenue
Contents

When Django took over the payments slice from the FastAPI proxy (chapter 4), I made myself a promise: this time CCAvenue would be built properly, not smeared across a request handler with encryption copied from a vendor sample and a prayer. I had already integrated it once — deliberately badly — back in the proxy era, just to prove people would pay for courses at all. They did: roughly one in ten of six thousand users. But that first version was a pile of hashlib calls and string concatenation I never wanted to write again. So on the rebuild I wrote it one more time, carefully, and then pulled the gnarly core out into a package so nobody would ever have to. That package is python-pay-ccavenue, and extracting it was the highest-leverage hour in the whole payments effort.

The problem: the gateway everyone in India uses and nobody enjoys

If you take money online in India, you will meet CCAvenue. It’s one of the country’s oldest gateways, it supports the long tail of Indian payment methods — UPI, netbanking from banks you’ve never heard of, wallets, cards — and for a non-profit like Tejgyan Foundation with an existing merchant relationship, it was the pragmatic choice long before I arrived. Nobody was going to re-KYC a charity onto a shinier gateway to save me a weekend.

The trouble is the developer experience. Where a newer gateway hands you a clean SDK and a five-line snippet, CCAvenue hands you a downloadable “integration kit”: a zip of PHP (or JSP, or ASP) sample files, a working key, and a lot of implied knowledge. The encryption is bespoke enough that most teams copy the sample, mangle it until a payment succeeds once, and never touch it again. That’s exactly the code that fails silently at 11pm on a festival day when donations spike. I wanted to understand every byte before it sat in front of real money.

What a gateway integration actually is

Strip away the branding and every hosted payment gateway is the same three-act play. Get the shape first and CCAvenue’s specifics stop being intimidating.

Act one — the redirect. You never want card numbers touching your server; that’s a compliance nightmare you don’t need. So instead of collecting payment details yourself, you hand the user off to the gateway’s own page. You build a description of the order — amount, a reference number, where to send the user back — and you redirect the browser to the gateway carrying that description.

Act two — the encrypted handshake. But that order description travels through the user’s own browser to reach the gateway. As plain text, anyone could open dev-tools and change amount=1499 to amount=1. So the gateway gives you a shared secret — CCAvenue calls it the working key — and you encrypt the whole order blob with it before the browser ever sees it. The browser carries opaque ciphertext it cannot read or alter; only the gateway, holding the same key, can decrypt it. This is symmetric encryption: the same secret both locks and unlocks, so the secret must never leave your server.

Act three — verifying the response. When the payment finishes, the gateway sends the result back through the browser to a callback URL on your server — again encrypted with the same working key. You decrypt it, and here is the discipline most integrations skip: you do not trust it. You re-check, on your server, that the status really says success, that the amount matches the order you created, and that you haven’t already fulfilled it. The browser is hostile territory; the only thing you believe is what you can verify with your own key against your own database.

That’s the whole game. Redirect out, encrypted; result back, encrypted and verified.

CCAvenue’s specific handshake

CCAvenue gives you three credentials, and the distinction between them is the thing to internalise:

  • merchant_id — who you are. Not secret.
  • access_code — identifies this particular API integration. Semi-public; it rides along in the browser.
  • working_key — the AES secret. Server-only, forever. This is the one that must never appear in a template, a log line, or a frontend bundle.

The encryption itself is AES-128 in CBC mode, and two details trip everyone up. First, the 16-byte AES key is not the working key directly — it’s the MD5 digest of the working key. AES-128 wants exactly 16 bytes of key material; an MD5 digest is exactly 16 bytes, which is almost certainly why CCAvenue picked it a decade-and-a-half ago. Second, the initialisation vector is a fixed, published sequence of bytes 0x00 through 0x0f, identical on every request. (In a sane design the IV is random per message and shipped alongside the ciphertext; a constant IV means identical plaintexts encrypt identically, which is a real weakness — but it’s their protocol.) Get either wrong and the gateway rejects your ciphertext with an error that tells you nothing.

Here is the actual primitive, both directions, on pycryptodome — no vendored PHP, no mystery helper functions:

import hashlib
from binascii import hexlify, unhexlify

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad

_BLOCK_SIZE = 16
# CCAvenue's fixed IV: the 16 bytes 0x00, 0x01, ... 0x0f. Identical every request.
_IV = bytes(range(_BLOCK_SIZE))


def _derive_key(working_key: str) -> bytes:
    # The AES-128 key is the raw 16-byte MD5 digest of the working key,
    # NOT the working key itself. MD5 and a constant IV are weak by any
    # modern standard, but CCAvenue's server expects exactly this — so on
    # this one interface, compatibility beats cryptographic taste.
    return hashlib.md5(working_key.encode("utf-8")).digest()


def encrypt(plain_text: str, working_key: str) -> str:
    # Fresh cipher per call: a pycryptodome AES object is stateful and
    # single-use — you cannot reuse one instance across encrypt and decrypt,
    # or even across two encrypts.
    cipher = AES.new(_derive_key(working_key), AES.MODE_CBC, _IV)
    padded = pad(plain_text.encode("utf-8"), _BLOCK_SIZE, style="pkcs7")
    return hexlify(cipher.encrypt(padded)).decode("ascii")


def decrypt(cipher_text: str, working_key: str) -> str:
    cipher = AES.new(_derive_key(working_key), AES.MODE_CBC, _IV)
    decrypted = cipher.decrypt(unhexlify(cipher_text))
    return unpad(decrypted, _BLOCK_SIZE, style="pkcs7").decode("utf-8")

MD5. A hardcoded IV. If a junior engineer shipped this in a greenfield service, I’d block the PR. But this isn’t greenfield — it’s an integration against a counterparty whose server I cannot change. The correct call here is compatibility over cryptographic purity, and the important thing is to know you’re making that trade and fence it behind a comment a future reader can see. Note the two decisions that keep this from becoming the usual sample-kit landmine: the working key is a parameter, never a module-level literal (in Django it arrives as settings.CCAVENUE_WORKING_KEY, which itself reads from os.environ), and each call builds a fresh AES.new(...) because the library’s cipher objects carry state and silently corrupt output if reused. Decrypting the response is the exact mirror — same derived key, same IV, decrypt instead of encrypt, unpad instead of pad. The whole flow is one symmetric primitive applied twice.

CCAvenue doesn’t want a JSON body; it wants a URL-encoded query string as the plaintext, and the response comes back the same way. So the request side is really “build a param dict, flatten it, encrypt it”:

from urllib.parse import urlencode

def build_enc_request(order_id: str, amount: str, *, merchant_id: str,
                      redirect_url: str, cancel_url: str,
                      working_key: str) -> str:
    params = {
        "merchant_id": merchant_id,
        "order_id": order_id,
        "amount": amount,          # a string like "1499.00" — never a float
        "currency": "INR",
        "redirect_url": redirect_url,
        "cancel_url": cancel_url,
    }
    return encrypt(urlencode(params), working_key)

That encRequest hex string, plus the plaintext access_code, is what the browser POSTs to CCAvenue’s hosted page.

Here is the complete checkout as a sequence — the mental model I wish the sample kit had led with:

The options I weighed

Copy the sample kit and move on. The path of least resistance, rejected fast: an unreadable blob of ported PHP in front of donations is a liability I’d re-learn under pressure every time it broke. The proxy era already taught me what un-tested payment code costs.

Adopt an existing Python wrapper. I looked. What existed was thin, mostly unmaintained, often pinned to a Python 2-era crypto library, and none of it verified the callback the way I wanted. Taking on an abandoned dependency for the single most important transaction in the system isn’t a saving; it’s a slower way to write it yourself.

Switch to a gateway with a nicer SDK. Tempting on developer experience alone. Rejected on business grounds — the foundation’s merchant account, its settlement history, and its finance team’s familiarity all lived with CCAvenue. Migrating a charity’s payment rails to save engineering hours is optimising the wrong variable. The right move was to make CCAvenue feel like a clean SDK, not to run from it.

So: build it once, build it small, build it tested — and shape it as a standalone module from the first commit rather than tangling it into Django views.

Verifying the response is where the money is

The return trip is where I’d been sloppiest the first time, and it’s the part that actually protects revenue.

The subtle danger is upstream of the crypto. Because CCAvenue’s response arrives as a browser POST to your redirect URL, the naive flow is: user pays, browser redirects, you decrypt, you mark the order paid. But the browser is an unreliable narrator. Users close the tab on the bank’s OTP screen. Mobile data drops mid-redirect — and remember, this whole platform lived on users burning through data packs on Indian mobile networks. When that happens, the payment succeeded at CCAvenue and your redirect never fired. If the redirect is your only source of truth, you’ve taken someone’s money and shown them nothing.

So the rule the Django side is built around: the redirect is a hint, never the truth. Decrypt it for a fast, optimistic UX, but treat CCAvenue’s server-to-server notification (the same encResp shape) as authoritative, and reconcile anything still Pending against their status API on a Celery beat. And whatever the source, every field gets checked against our own record before a course unlocks.

The security note underneath all of this is worth saying plainly, because it’s the one thing the sample kits bury: order_status is not authoritative until you decrypt it with your own key and cross-check it against your own database. An attacker who pays ₹1 for a ₹1499 course, or who replays a stranger’s success callback at their own order id, is attacking exactly the gap between “the browser told me it succeeded” and “my server verified it succeeded.” The working key never leaves the server precisely so that this decrypt-and-verify step is something only you can perform — that’s the entire point of putting the secret server-side. Trusting the browser’s redirect params is functionally the same as letting the customer type their own receipt.

Here’s the callback handler as it actually runs in Django — decrypt, then verify three fields against our own order, under a row lock so duplicate deliveries can’t race:

from decimal import Decimal

from django.db import transaction
from django.http import HttpResponse

from pay_ccavenue import CCAvenue

client = CCAvenue(
    working_key=settings.CCAVENUE_WORKING_KEY,
    access_code=settings.CCAVENUE_ACCESS_CODE,
    merchant_id=settings.CCAVENUE_MERCHANT_ID,
    redirect_url=settings.CCAVENUE_REDIRECT_URL,
    cancel_url=settings.CCAVENUE_CANCEL_URL,
)


@transaction.atomic
def ccavenue_callback(request):
    resp = client.decrypt_response(request.POST["encResp"])   # typed object

    # select_for_update() only holds a lock inside an atomic block. Under the
    # default READ COMMITTED isolation we must re-read the row *after* taking
    # the lock, so two concurrent deliveries (browser redirect AND the
    # server-to-server notify) can't both observe the order as still Pending.
    order = (
        Order.objects
        .select_for_update()
        .get(reference=resp.order_id)
    )
    if order.status == Order.PAID:
        return HttpResponse(status=200)          # already fulfilled — idempotent no-op

    if resp.order_status != "Success":
        order.mark_failed(reason=resp.order_status)
        return HttpResponse(status=200)
    if Decimal(resp.amount) != order.amount:     # underpay / tamper guard
        order.flag_for_review(reason="amount_mismatch")
        return HttpResponse(status=200)

    order.mark_paid(tracking_id=resp.tracking_id, bank_ref_no=resp.bank_ref_no)
    enroll_user_in_course.delay(order.id)        # dispatched to the payments queue
    return HttpResponse(status=200)

Several things earn their keep. The lock makes fulfilment idempotent under concurrency — CCAvenue can deliver the same result more than once, and unlocking a course twice is harmless but crediting a wallet twice is not, so the row is locked, re-read, and the already PAID branch turns any duplicate into a no-op. The follow-up work is a Celery task (.delay) rather than inline: enrolment can touch email, S3, and the ERP, none of which belong on CCAvenue’s callback timeout, and it lands on a dedicated payments queue so it never waits behind a 40-minute video transcode:

# settings.py — payments never share a worker with media
CELERY_TASK_ROUTES = {
    "billing.tasks.enroll_user_in_course": {"queue": "payments"},
    "media.tasks.transcode_upload":        {"queue": "transcode"},
}
CELERY_TASK_ACKS_LATE = True          # re-deliver if a worker dies mid-task
CELERY_WORKER_PREFETCH_MULTIPLIER = 1 # don't let one worker hoard payment jobs
# The payments queue gets its own workers, sized independently of transcode
celery -A happy_thoughts worker -Q payments -c 4 --hostname=pay@%h

And notice resp is a typed object, not a raw dict. When you’re branching on whether to give someone a paid course, you do not want a stringly-typed data["order_status"] where a renamed field fails silently. decrypt_response returns a dataclass with order_id, tracking_id, bank_ref_no, amount, and order_status, so “did this pay?” becomes an autocompleted, typo-proof attribute access.

Then we made it a package

The moment that told me to extract it was noticing I’d now written this code twice — once in the proxy, once in Django. The Rule of Three says extract on the third repetition; for a security-sensitive handshake against a fixed external contract, two is plenty. The cost of a bug is a mispriced or lost payment, and the surface never changes across projects. That’s the exact profile of code that belongs behind a stable, tested boundary.

Packaging forced the design to get better, which is the underrated part. To publish it, I had to strip out every dependency on Django’s request, on FastAPI’s models, on our settings module. What’s left is a deliberately boring Client class: you construct it once with your credentials, then it’s dict in / hex string out on the way there, and hex string in / typed object out on the way back. The caller only ever passes what is semantically theirs — the order id and amount — and the class injects everything constant (merchant id, redirect and cancel URLs) so those can’t be fat-fingered per call:

from dataclasses import dataclass
from urllib.parse import parse_qsl, urlencode


@dataclass
class CCAvenueResponse:
    order_id: str
    tracking_id: str
    order_status: str
    amount: str
    bank_ref_no: str = ""


class CCAvenue:
    def __init__(self, working_key, access_code, merchant_id,
                 redirect_url, cancel_url):
        # Credentials are injected, never read from a global. A Django app
        # passes settings.*, a plain script passes os.environ[...], a test
        # passes fakes — the library stays framework-agnostic.
        self.working_key = working_key
        self.access_code = access_code
        self.merchant_id = merchant_id
        self.redirect_url = redirect_url
        self.cancel_url = cancel_url

    def encrypt_order(self, order_id: str, amount: str, currency: str = "INR") -> str:
        params = {
            "merchant_id": self.merchant_id,
            "order_id": order_id,
            "amount": amount,
            "currency": currency,
            "redirect_url": self.redirect_url,
            "cancel_url": self.cancel_url,
        }
        return encrypt(urlencode(params), self.working_key)

    def decrypt_response(self, enc_resp: str) -> CCAvenueResponse:
        fields = dict(parse_qsl(decrypt(enc_resp, self.working_key)))
        return CCAvenueResponse(
            order_id=fields["order_id"],
            tracking_id=fields.get("tracking_id", ""),
            order_status=fields.get("order_status", ""),
            amount=fields.get("amount", "0"),
            bank_ref_no=fields.get("bank_ref_no", ""),
        )

Every field the caller doesn’t have to repeat is a field they can’t get wrong. That same object worked unchanged in the proxy and in the Django rebuild, and it survived an entire platform migration without a rewrite.

Making it pip install-able was the last mile, and it’s smaller than people fear — one pyproject.toml with a real build backend and a single runtime dependency:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "python-pay-ccavenue"
version = "1.2.0"
description = "A clean, framework-agnostic CCAvenue AES handshake for Python."
readme = "README.md"
requires-python = ">=3.9"
license = { text = "MIT" }
authors = [{ name = "Kuldeep Pisda" }]
keywords = ["ccavenue", "payments", "aes", "india", "payment-gateway"]
dependencies = ["pycryptodome>=3.20"]

[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-cov"]

[project.urls]
Homepage = "https://github.com/kdpisda/python-pay-ccavenue"
Issues = "https://github.com/kdpisda/python-pay-ccavenue/issues"

# The distribution is named python-pay-ccavenue, but the import package is
# pay_ccavenue. Hatchling can't infer that mismatch, so point it at the real
# package explicitly — otherwise `python -m build` fails with
# "Unable to determine which files to ship inside the wheel".
[tool.hatch.build.targets.wheel]
packages = ["pay_ccavenue"]

One dependency (pycryptodome), no framework in the tree, python -m build produces a wheel, twine upload ships it. Wrapped in pytest from the first commit — encrypt/decrypt round-trips, tampered amounts, replayed callbacks, wrong-order callbacks — the scary code became boring code, which is the highest compliment a payment integration can earn.

The compounding you don’t see on the invoice

Here’s the transferable lesson, and it’s the one I actually care about. When you fight through a genuinely gnarly integration and come out the other side with something clean, you’re holding an asset most people leave on the floor. The instinct is to be relieved it’s done and move on. Resist that. The higher-leverage move is to package the thing you understood once so it compounds — name a clean boundary, write the tests you skipped the first time, and put it where anyone can reuse it.

python-pay-ccavenue cost an extra hour or two beyond fixing it in place. In return: our Django platform got a tested, framework-free payments core; a future project inherits it for nothing; and a handful of other Indian teams wiring up the same maddening gateway open a GitHub tab instead of a PHP sample kit. Open source is the clearest way I know to lead beyond the boundaries of your own repository — you don’t just unblock your own codebase, you unblock everyone who hits the same wall after you, and the payback returns as reputation, contributions, and a solution battle-tested by people you’ll never meet.

With payments finally solid and boring, the platform was free to grow — and almost immediately it started sprouting many faces, a fleet of Next.js frontends all needing to talk to the one Django backend. Wiring those together behind a single identity is the next chapter.