Bill payment API integration with Python
Connect a Python backend to RizPay with a sandbox client, explicit timeouts, safe error handling and a durable purchase-status workflow.
Already registered? Open API settings and follow the business-access steps. Sandbox purchases do not deliver real services.
Reviewed
Save this module in your backend application. It uses the Python standard library and refuses redirects so a response cannot redirect your bearer credential to another host. Use a worker or synchronous server context appropriate to your framework.
Start with an approved order
In an async application, do not run this blocking urllib client directly on the event loop. Execute it in a worker or adopt an async HTTP client while preserving the same timeout, redirect and order-recovery rules.
Create sandbox access in API settings after completing the account's business-access steps. For this airtime walkthrough, the key needs view_products, purchase_airtime and read_transactions. Keep the secret in RIZPAY_API_KEY on the server. The client below deliberately accepts only sandbox keys and uses a fixed sandbox URL.
The server client
# Python 3.11+. Use from your server, not from a browser.
import json
import os
import re
import urllib.error
import urllib.parse
import urllib.request
BASE_URL = "https://my.rizpay.app/api/partners/sandbox/v1"
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
class RizPayError(Exception):
def __init__(self, code, status=None):
self.code = code
self.status = status
super().__init__("Request not confirmed. Reconcile the stored order before retrying.")
class RizPaySandbox:
def __init__(self, key=None):
self.key = key or os.environ.get("RIZPAY_API_KEY", "")
if not self.key.startswith("sk_test_"):
raise ValueError("A sandbox key is required.")
self.http = urllib.request.build_opener(NoRedirect)
def request(self, path, body=None):
request = urllib.request.Request(
BASE_URL + path,
data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": "Bearer " + self.key, "Content-Type": "application/json"},
method="POST" if body is not None else "GET",
)
try:
with self.http.open(request, timeout=20) as response:
return json.load(response)
except urllib.error.HTTPError as error:
try:
code = json.load(error).get("error", {}).get("code", "API_ERROR")
if not isinstance(code, str) or not re.fullmatch(r"[A-Z_]{3,60}", code):
code = "API_ERROR"
except (ValueError, AttributeError):
code = "API_ERROR"
raise RizPayError(code, error.code) from None
except (urllib.error.URLError, TimeoutError, ValueError, OSError):
raise RizPayError("UNCONFIRMED_RESPONSE") from None
def list_airtime(self, network="MTN", page=1):
if network not in ("MTN", "AIRTEL", "GLO", "9MOBILE"):
raise ValueError("Choose a supported network.")
if type(page) is not int or page < 1:
raise ValueError("Invalid page.")
return self.request("/products/airtimes?" + urllib.parse.urlencode({"network": network, "page": page}))
def purchase_airtime(self, *, product_id, network, phone_number, amount, external_reference):
if not re.fullmatch(r"\d{10}[A-Za-z0-9]{6}", external_reference):
raise ValueError("Use the stored order reference.")
if not re.fullmatch(r"prd_\d+", product_id):
raise ValueError("Select a catalogue product.")
if network not in ("MTN", "AIRTEL", "GLO", "9MOBILE"):
raise ValueError("Confirm the network.")
if not re.fullmatch(r"0[789]\d{9}", phone_number):
raise ValueError("Check the number.")
if not isinstance(amount, str) or not re.fullmatch(r"\d+\.\d{2}", amount):
raise ValueError("Use a decimal amount string.")
return self.request("/purchases", {
"product_id": product_id, "network": network, "phone_number": phone_number,
"amount": amount, "external_reference": external_reference,
})
def find_order(self, reference):
if not re.fullmatch(r"\d{10}[A-Za-z0-9]{6}", reference):
raise ValueError("Invalid reference.")
return self.request("/account/transactions/" + reference)
def purchase_status(self, transaction_id):
if not re.fullmatch(r"txn_[A-Za-z0-9-]+", transaction_id):
raise ValueError("Invalid transaction.")
return self.request("/purchases/" + transaction_id)
Use the catalogue before purchasing
Call the catalogue method and read the product array in data. Use an ID from your own result, confirm its network and read its price limits. Keep product IDs as strings and use decimal strings for money. The client checks the input format; your application must still enforce the selected product's minimum and maximum amounts and its own customer authorization rules.
Use phone number 08011111111 for a configured successful sandbox airtime scenario. For pending scenarios, use 08022222222 or 08033333333. These are simulator inputs, not recipients for real top-ups. An arbitrary phone number does not necessarily produce a successful sandbox result.
Persist before sending
Generate the external reference once when you save the order. Follow RizPay's documented 16-character format: ten timestamp digits followed by six alphanumeric characters. Add a unique constraint for that reference in your own database. Pass the same stored value to the purchase method.
These methods do not automatically retry a purchase. If the response is lost, use the reference lookup method and inspect the existing transaction. A DUPLICATE_REFERENCE error is a recovery signal, not proof that the customer received airtime. Protect a local order from concurrent submissions with your application's transaction or job-locking mechanism.
Read the result correctly
An accepted purchase returns its identifier at data.id and its status at data.attributes.status. Persist the identifier with the order. Use the status method to retrieve it again, or reconcile a verified webhook. Pending must remain pending in your customer interface. Do not mark an order paid-and-delivered merely because the HTTP response was successful.
If a result is unrecognized, keep the order unresolved and investigate it. Never show exception details, keys or a raw provider response to the customer. A safe message explains that the result is being checked and provides a route to view the existing order or contact support.
Moving this into a live application
Add customer authorization, durable persistence, payment collection, webhook verification and ledger reconciliation in your own application. The client is the HTTP boundary, not a complete checkout or wallet. Your customer's payment to you and your partner purchase from RizPay are distinct records.
Review the production checklist before introducing a production client and live credentials. Keep test and live clients, keys and order records separate. The airtime reference describes the request; other product categories require their own fields and, for electricity and cable TV, verification.