DEV Community

Cover image for What Nobody Tells You About Integrating Monnify — A War Story
igbojionu
igbojionu

Posted on

What Nobody Tells You About Integrating Monnify — A War Story

What Nobody Tells You About Integrating Monnify (A War Story)

I've been building EstateTickets.ng — a Django platform where people buy tickets for a chance to win real property in Nigeria — and one of the last big pieces was wiring up real payments through Monnify. On paper, it looked simple: read the docs, drop in the SDK, verify the transaction server-side, done in an afternoon.

It was not done in an afternoon.

This is the honest version of what actually happened — every dead end, every cryptic error, and what I'd tell a past version of myself before starting. If you're about to integrate Monnify (or honestly, any Nigerian payment gateway) into your own app, I hope this saves you a few hours of confusion.

The setup, briefly

Django backend, a custom checkout flow, Monnify's Inline JS SDK on the frontend for the actual card/transfer/USSD popup, and a server-to-server verification step afterward — because you should never trust a client-side "payment successful" callback on its own. More on why that matters below.

Challenge #1: A 403 that made no sense

The very first server-to-server call I made — just authenticating with my API key and secret to get an access token — came back with a flat 403 Forbidden. No useful message. Credentials were definitely right. Copy-pasted straight from the dashboard.

I spent way too long assuming it was a typo before I actually looked at what was different between my request and a browser's request. Turned out: Python's urllib sends a dead-giveaway default header —

User-Agent: Python-urllib/3.x
Enter fullscreen mode Exit fullscreen mode

— and whatever's fronting Monnify's API (almost certainly Cloudflare) was quietly blocking it as a bot. The fix was almost insultingly simple:

headers = {
    "Content-Type": "application/json",
    "User-Agent": "Mozilla/5.0 (compatible; EstateTickets/1.0)",
}
Enter fullscreen mode Exit fullscreen mode

Lesson: if a payment API gives you a bare 403 with zero explanation, check your User-Agent before you check anything else. Automated HTTP clients get silently profiled and blocked more often than you'd expect.

Challenge #2: The SSL error that appeared and disappeared at random

This one nearly broke me. Out of nowhere, calls to Monnify's sandbox started failing with:

[SSL: SSLV3_ALERT_BAD_RECORD_MAC] sslv3 alert bad record mac
Enter fullscreen mode Exit fullscreen mode

Sometimes it worked. Sometimes it didn't. Same code, same credentials, same machine — different result minutes apart. I tried switching HTTP libraries (urllibrequests), no difference. I isolated it down to the exact request shape (POST with a JSON body specifically), thought I'd found the pattern, then it broke a "safe" combination too on the next run.

The real answer: this is Cloudflare's bot-protection doing TLS-level fingerprinting, and it can flag a client intermittently based on request volume and pattern — not something you can fully "fix" client-side. What actually helped:

  1. Retry logic with backoff. Most of the time, the exact same request succeeds on a second or third attempt seconds later.
  2. Realizing my own aggressive diagnostic testing was self-inflicted. Hammering an endpoint 20+ times while debugging is exactly the kind of pattern that trips rate-limiting/bot-detection. The fix, ironically, was to stop testing so aggressively.
for attempt in range(1, MAX_RETRIES + 1):
    try:
        return urllib.request.urlopen(request_obj, timeout=15)
    except (ssl.SSLError, ConnectionResetError, urllib.error.URLError):
        if attempt < MAX_RETRIES:
            time.sleep(RETRY_DELAY)
Enter fullscreen mode Exit fullscreen mode

Lesson: not every error is a bug in your code. Some errors are the infrastructure between you and the API having a bad moment. Build for resilience, not just correctness.

Challenge #3: "Could not find specified contract"

The scariest one, because it happened with live production credentials and the error the user actually sees in Monnify's widget is completely useless:

Transaction Failed! Unable to process your transaction request.

That's it. No code, no reason, nothing actionable. And because Monnify's checkout widget runs inside a cross-origin iframe, you can't even inspect the network request in your browser's dev tools to see what Monnify's server actually said.

The move that cracked it: bypass the widget entirely and call Monnify's transaction-init API directly from a script, outside the iframe sandbox. Same credentials, same payload, just a raw HTTP call I could actually read the response of:

{
  "requestSuccessful": false,
  "responseMessage": "Could not find specified contract",
  "responseCode": "99"
}
Enter fullscreen mode Exit fullscreen mode

The contract code in my .env didn't match my live merchant account. One value, copied from the wrong place at some point, and the widget's generic error message gave zero indication that was the problem.

Lesson: if a payment widget gives you an opaque, generic failure, don't trust it — replicate the exact same request server-side where you can actually see Monnify's real response. The iframe is a black box; a raw API call isn't.

Challenge #4: Webhooks aren't optional, and the docs are thin

Monnify requires you to configure at least one webhook (transaction completion or refund completion), and I initially treated it as a nice-to-have on top of my existing "redirect back and verify" flow. It's not optional in practice — if a user closes their browser tab right after paying but before the redirect completes, your server-redirect flow never fires, and without a webhook, that payment just vanishes into limbo.

The tricky part was signature verification — Monnify's own docs are genuinely sparse here. What I eventually confirmed:

  • Header: monnify-signature
  • Formula: SHA-512(your_secret_key + raw_request_body)
  • Sandbox notifications don't include this header at all — only production does, which makes testing awkward until you know that.
def verify_signature(request):
    signature = request.headers.get("monnify-signature", "")
    expected = hashlib.sha512(
        (SECRET_KEY + request.body.decode()).encode()
    ).hexdigest()
    return hmac.compare_digest(signature, expected)
Enter fullscreen mode Exit fullscreen mode

And critically: never trust the webhook body's claims about payment status directly. I built mine to use the webhook purely as a trigger — it extracts the reference, then re-verifies server-to-server with Monnify's own query API before doing anything. That way, even a forged webhook call can't fake a successful payment; it can only prompt a real re-check.

Lesson: treat every "payment succeeded" signal — client callback, webhook, whatever — as a hint to go verify, never as ground truth on its own.

Challenge #5: I almost leaked my own live keys

This isn't a Monnify-specific problem, but it happened because of Monnify integration work, so it belongs here. While debugging, I:

  • Wrote a standalone test HTML file with live API keys hardcoded, sitting in my project folder, not yet gitignored
  • Pasted a live API key and secret directly into a chat conversation while asking for help

Neither of those got committed to git or published anywhere — but both were one careless command away from it. I rotated the exposed credentials immediately and added the test file to .gitignore before it could ever be staged.

Lesson: the moment a real secret touches a file, a chat log, or a terminal history that isn't your .env, treat it as compromised and rotate it. Don't wait to find out if it actually leaked — the cost of rotating is minutes, the cost of not rotating in time could be your whole payment account.

What I'd tell someone starting this today

  1. Set your User-Agent header on every outbound request. Not just for Monnify — for any payment/banking API. Default HTTP client headers scream "bot."
  2. Build retry logic in from day one, not as an afterthought after you've panicked over a phantom SSL error.
  3. Don't trust widget error messages. If it's vague, replicate the call yourself where you can see the real response.
  4. Webhooks are load-bearing, not decorative. Build the redirect flow and the webhook, and make them share the same verification logic so you're not maintaining two sources of truth.
  5. Sandbox and live are genuinely different animals — different base URLs, different key prefixes, sometimes different behavior (like the missing signature header). Test the switch deliberately, don't assume it's a drop-in swap.
  6. Secrets discipline isn't paranoia, it's hygiene. .gitignore your test files before you write them, not after.

The encouragement part

If you're in the middle of your own version of this — staring at a 403 with no explanation, or an SSL error that makes no sense, or a payment widget that just says "failed" and nothing else — you are not bad at this. Payment gateway integration is genuinely one of the least forgiving parts of building software, because the failure modes are deliberately opaque (fraud prevention needs some obscurity) and the stakes (real money, real users) make you want to move slower and more carefully than usual.

Every single issue above felt like a dead end in the moment. Every one of them had a boring, findable answer once I stopped guessing and started actually looking at what was happening at the network level instead of trusting error messages at face value.

It works now. Real payments go through, tickets get issued, winners get picked. If I can get there through all of that, so can you.

Good luck out there. 🚀

Top comments (0)