# RizPay Documentation (Full) > Complete RizPay developer API and storefront partner documentation in a single file, for LLMs and AI tools. A per-page index is available at https://www.rizpay.app/llms.txt and each page is individually available as markdown by appending .md to its URL. OpenAPI 3.0 specification for the RizPay API: https://www.rizpay.app/api/partners/openapi Interactive API explorer: https://www.rizpay.app/developers/docs/api --- # RizPay Developer API --- ## Introduction Source: https://www.rizpay.app/developers/docs/overview/introduction.md The RizPay API enables businesses and developers to integrate bill payment capabilities directly into their applications. Provide your users with seamless access to airtime, data plans, electricity, and cable TV payments. ## Who is this for? The RizPay API is designed for: - **Fintech applications** adding bill payment features - **E-commerce platforms** offering value-added services - **Mobile wallets** expanding payment options - **Business applications** automating recurring payments ## What can you do? | Feature | Description | | --------------- | -------------------------------------------------- | | **Airtime** | Purchase airtime for MTN, Airtel, Glo, and 9mobile | | **Data Plans** | Buy daily, weekly, or monthly data bundles | | **Electricity** | Pay prepaid and postpaid electricity bills | | **Cable TV** | Subscribe to DStv, GOtv, and Startimes | | **Webhooks** | Receive real-time transaction notifications | ## Key Features ### Simple Integration RESTful API with predictable resource-oriented URLs. JSON request and response bodies. ```bash curl -X GET \ -H "Authorization: Bearer sk_live_your_secret_key" \ https://my.rizpay.app/api/partners/v1/account/balance ``` ### Business Pricing Developers enjoy discounted rates compared to retail pricing. Volume-based bonuses available for high-transaction businesses. ### Real-time Webhooks Stay informed about transaction status changes. Automatic retries ensure delivery. Detailed logs for debugging. ### Sandbox Environment Test your integration without affecting real accounts or making real purchases. The sandbox mirrors production behavior exactly. ### Granular Permissions Scoped API keys let you grant only the permissions needed. Create separate keys for different use cases. ## Base URLs | Environment | URL | | ----------- | ----------------------------------------------- | | Production | `https://my.rizpay.app/api/partners/v1` | | Sandbox | `https://my.rizpay.app/api/partners/sandbox/v1` | ## Getting Started 1. **Sign in or Create Account** - Already have an account? [Sign in](https://my.rizpay.app/login). New to RizPay? [Create an account](https://my.rizpay.app/signup) 2. **Upgrade to Business** at [Settings > API Keys](https://my.rizpay.app/settings/api) 3. **Generate API Keys** from the same page after upgrading 4. **Start Building** using this documentation ## Next Steps - [Quick Start](/developers/docs/getting-started/quickstart) - Make your first API call - [Authentication](/developers/docs/getting-started/authentication) - Understand API keys and scopes - [Error Handling](/developers/docs/getting-started/errors) - Handle errors gracefully --- ## Quick Start Source: https://www.rizpay.app/developers/docs/getting-started/quickstart.md Get up and running with the RizPay API in 5 minutes. ## Prerequisites - A RizPay business account - API access enabled in your settings - Your API key from [Settings > API Keys](https://my.rizpay.app/settings/api) > **Using Postman?** Download our [Postman Collection](/downloads/rizpay-partners-api.postman_collection.json) to get started quickly with pre-configured requests. ## Step 1: Get Your API Key 1. Log in at [my.rizpay.app](https://my.rizpay.app) 2. Go to **Settings > API Keys** 3. Click **Create New Key** 4. Select scopes and environment 5. Copy your secret key (shown only once!) Your key looks like: `sk_live_xxxxxxxxxxxx` (production) or `sk_test_xxxxxxxxxxxx` (sandbox) > **Warning:** Never share your secret key or commit it to version control. ## Step 2: Check Your Balance Verify your API key works: ```bash curl -X GET \ -H "Authorization: Bearer sk_live_your_secret_key" \ https://my.rizpay.app/api/partners/v1/account/balance ``` Response: ```json { "status": { "code": 200, "message": "Balance retrieved" }, "data": { "balance": "50000.00", "currency": "NGN", "updated_at": "2026-05-18T10:30:00+01:00" } } ``` ## Step 3: List Products See available airtime products: ```bash curl -X GET \ -H "Authorization: Bearer sk_live_your_secret_key" \ "https://my.rizpay.app/api/partners/v1/products/airtimes?network=MTN" ``` Response: ```json { "status": { "code": 200, "message": "Airtime products retrieved" }, "data": [ { "id": "prd_42", "type": "airtime", "attributes": { "display_name": "MTN Airtime", "network": "MTN", "min_amount": "50.0", "max_amount": "50000.0", "price": { "currency": "NGN", "basis": "face_value", "min_amount": "50.00", "max_amount": "50000.00" } } } ], "pagination": { "page": 1, "per_page": 20, "total_pages": 1, "total_count": 1 } } ``` Product ids are numeric with a `prd_` prefix (e.g. `prd_42`). Use the `id` from your own response in the next step. ## Step 4: Make a Purchase Purchase airtime: ```bash curl -X POST \ -H "Authorization: Bearer sk_live_your_secret_key" \ -H "Content-Type: application/json" \ -d '{ "product_id": "prd_42", "phone_number": "08012345678", "amount": "100.00", "external_reference": "1736234400A1B2C3" }' \ https://my.rizpay.app/api/partners/v1/purchases ``` Response: ```json { "status": { "code": 201, "message": "Purchase created successfully" }, "data": { "id": "txn_abc123", "type": "transaction", "attributes": { "amount": "100.0", "currency": "NGN", "status": "pending", "category": "purchase", "description": "Purchase of MTN Airtime", "reference": "a1b2c3d4e5f607181736234400", "external_reference": "1736234400A1B2C3", "product_type": "airtime", "phone_number": "08012345678", "meter_number": null, "price": { "product_amount": "100.00", "fee_amount": "0.00", "total_debit": "100.00", "currency": "NGN", "basis": "face_value" }, "created_at": "2026-05-18T10:35:00+01:00", "updated_at": "2026-05-18T10:35:00+01:00" } } } ``` > **Tip:** The `external_reference` must be exactly 16 characters: 10-digit Unix timestamp + 6 alphanumeric. See [Duplicate Prevention](/developers/docs/core-concepts/duplicate-prevention) for code examples. ## Step 5: Check Status Poll the transaction status: ```bash curl -X GET \ -H "Authorization: Bearer sk_live_your_secret_key" \ https://my.rizpay.app/api/partners/v1/purchases/txn_abc123 ``` Response (when complete): ```json { "status": { "code": 200, "message": "Purchase details retrieved" }, "data": { "id": "txn_abc123", "type": "transaction", "attributes": { "amount": "100.0", "currency": "NGN", "status": "successful", "category": "purchase", "description": "Purchase of MTN Airtime", "reference": "a1b2c3d4e5f607181736234400", "external_reference": "1736234400A1B2C3", "product_type": "airtime", "phone_number": "08012345678", "meter_number": null, "price": { "product_amount": "100.00", "fee_amount": "0.00", "total_debit": "100.00", "currency": "NGN", "basis": "face_value" }, "created_at": "2026-05-18T10:35:00+01:00", "updated_at": "2026-05-18T10:35:02+01:00" } } } ``` The transaction is done when `status` reaches `successful`, `failed`, or `reversed`. There is no separate completion timestamp: use `updated_at`. ## Simple Node.js Example ```javascript const API_KEY = "sk_live_your_secret_key"; const BASE_URL = "https://my.rizpay.app/api/partners/v1"; // Generate external reference: 10-digit timestamp + 6 alphanumeric function generateReference() { const timestamp = Math.floor(Date.now() / 1000); const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let suffix = ""; for (let i = 0; i < 6; i++) { suffix += chars.charAt(Math.floor(Math.random() * chars.length)); } return `${timestamp}${suffix}`; } async function purchaseAirtime(phoneNumber, amount) { const response = await fetch(`${BASE_URL}/purchases`, { method: "POST", headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ product_id: "prd_42", // use a product id from GET /products/airtimes phone_number: phoneNumber, amount: amount, external_reference: generateReference(), }), }); return response.json(); } // Usage purchaseAirtime("08012345678", "500.00") .then((result) => console.log(result)) .catch((error) => console.error(error)); ``` ## Using the Sandbox For testing, use sandbox credentials: 1. Create a sandbox API key (`sk_test_*`) 2. Use the sandbox endpoint: `/api/partners/sandbox/v1` ```bash curl -X GET \ -H "Authorization: Bearer sk_test_your_test_key" \ https://my.rizpay.app/api/partners/sandbox/v1/account/balance ``` Sandbox purchases don't charge your account or deliver real airtime. The sandbox only recognizes specific test phone numbers (e.g. `08011111111` for an instant success); any other number fails with `PURCHASE_FAILED`. The [First Purchase guide](/developers/docs/getting-started/first-purchase) lists them all and walks the full flow. ## Next Steps - [First Purchase](/developers/docs/getting-started/first-purchase) - Complete a sandbox purchase end to end - [Authentication](/developers/docs/getting-started/authentication) - Understand scopes and environments - [Error Handling](/developers/docs/getting-started/errors) - Handle errors gracefully - [Airtime Guide](/developers/docs/products/airtime) - Complete airtime integration - [Webhooks](/developers/docs/webhooks/overview) - Get real-time notifications --- ## First Purchase in 5 Minutes Source: https://www.rizpay.app/developers/docs/getting-started/first-purchase.md Make your first purchase against the sandbox: authenticate, check your balance, buy airtime, and verify it completed. Nothing is charged and no real airtime is delivered. All requests in this guide use the sandbox base URL: ``` https://my.rizpay.app/api/partners/sandbox/v1 ``` ## Prerequisites - A RizPay business account - API access enabled in your settings That's it. No funding required: the sandbox returns a mock balance. ## Step 1: Create a Sandbox API Key 1. Log in at [my.rizpay.app](https://my.rizpay.app) 2. Go to **Settings > API Keys** 3. Click **Create New Key** 4. Select the **Sandbox** environment 5. Select these scopes: `read_balance`, `view_products`, `purchase_airtime`, `read_transactions` 6. Copy your secret key (shown only once!) Your key looks like `sk_test_xxxxxxxxxxxx`. Sandbox keys only work on sandbox endpoints, so there is no risk of touching production. > **Warning:** Never share your secret key or commit it to version control. Store it in an environment variable: > > ```bash > export RIZPAY_API_KEY="sk_test_your_test_key" > ``` ## Step 2: Confirm the API Is Up The health endpoint needs no authentication: ```bash curl -X GET \ https://my.rizpay.app/api/partners/sandbox/v1/health ``` Response: ```json { "status": { "code": 200, "message": "OK" }, "data": { "service": "partners-api-sandbox", "version": "1.0.0", "environment": "sandbox", "timestamp": "2026-05-18T11:39:50+01:00" } } ``` ## Step 3: Check Your Balance Verify your key works. The sandbox returns a mock balance: ```bash curl -X GET \ -H "Authorization: Bearer $RIZPAY_API_KEY" \ https://my.rizpay.app/api/partners/sandbox/v1/account/balance ``` Response: ```json { "status": { "code": 200, "message": "Balance retrieved" }, "data": { "balance": "100000.00", "currency": "NGN", "updated_at": "2026-05-18T11:40:02+01:00" } } ``` If you get an `ENVIRONMENT_MISMATCH` error, you are using a `sk_live_` key. Create a sandbox key (`sk_test_`) and try again. ## Step 4: Pick a Product List airtime products. The sandbox returns real product data: ```bash curl -X GET \ -H "Authorization: Bearer $RIZPAY_API_KEY" \ "https://my.rizpay.app/api/partners/sandbox/v1/products/airtimes?network=MTN" ``` Response: ```json { "status": { "code": 200, "message": "Airtime products retrieved" }, "data": [ { "id": "prd_42", "type": "airtime", "attributes": { "display_name": "MTN Airtime", "network": "MTN", "min_amount": "50.0", "max_amount": "50000.0", "price": { "currency": "NGN", "basis": "face_value", "min_amount": "50.00", "max_amount": "50000.00" } } } ], "pagination": { "page": 1, "per_page": 20, "total_pages": 1, "total_count": 1 } } ``` Note the product `id`: you need it for the purchase. Product ids are numeric with a `prd_` prefix (e.g. `prd_42`) and differ per environment, so always use the `id` from your own response, not the one in this example. ## Step 5: Create the Purchase The sandbox decides the outcome of a purchase from the phone number you send. Only these test numbers are recognized for airtime: | Phone number | Outcome | | ---------------- | ------------------------------------------------------------- | | `08011111111` | `successful` immediately | | `08022222222` | `pending`, auto-transitions to `successful` after ~30 seconds | | `08033333333` | `pending`, auto-transitions to `failed` after ~30 seconds | | `08044444444` | `failed` with `INSUFFICIENT_BALANCE` | | `08055555555` | `failed` with `VALIDATION_ERROR` | | `08066666666` | `reversed` | | Any other number | `failed` with `PURCHASE_FAILED` | Use `08011111111` for an instant success. Replace `prd_42` with the product `id` from Step 4: ```bash curl -X POST \ -H "Authorization: Bearer $RIZPAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "product_id": "prd_42", "phone_number": "08011111111", "amount": "100.00", "external_reference": "1736234400T3S4T5" }' \ https://my.rizpay.app/api/partners/sandbox/v1/purchases ``` Response: ```json { "status": { "code": 201, "message": "Purchase created successfully" }, "data": { "id": "txn_5f1c3a9e-2b4d-4c6f-8a1e-9d7b5c3a2f4e", "type": "transaction", "attributes": { "amount": "100.0", "currency": "NGN", "status": "successful", "category": "purchase", "description": "Successful airtime purchase", "reference": "sandbox_1a2b3c4d5e6f7a8b9c0d", "external_reference": "1736234400T3S4T5", "product_type": "airtime", "phone_number": "08011111111", "meter_number": null, "price": { "product_amount": "100.00", "fee_amount": "0.00", "total_debit": "100.00", "currency": "NGN", "basis": "face_value" }, "created_at": "2026-05-18T11:41:10+01:00", "updated_at": "2026-05-18T11:41:10+01:00" } } } ``` > **Tip:** The `external_reference` must be exactly 16 characters: a 10-digit Unix timestamp plus 6 alphanumeric characters, and unique per transaction. See [Duplicate Prevention](/developers/docs/core-concepts/duplicate-prevention) for code that generates one. If you send a phone number that is not in the table above, the sandbox rejects the purchase: ```json { "status": { "code": 400, "message": "Bad Request" }, "error": { "code": "PURCHASE_FAILED", "message": "Purchase failed. Please try again." } } ``` ## Step 6: Walk the Pending Lifecycle Production purchases usually start as `pending` and complete on the provider side. To rehearse that flow, create a second purchase with the pending test number `08022222222` (and a fresh `external_reference`): ```bash curl -X POST \ -H "Authorization: Bearer $RIZPAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "product_id": "prd_42", "phone_number": "08022222222", "amount": "100.00", "external_reference": "1736234460P3N6D1" }' \ https://my.rizpay.app/api/partners/sandbox/v1/purchases ``` The response has `"status": "pending"`. It transitions to `successful` on its own after about 30 seconds, or you can query it to complete it immediately (replace the id with the `id` from your response): ```bash curl -X POST \ -H "Authorization: Bearer $RIZPAY_API_KEY" \ https://my.rizpay.app/api/partners/sandbox/v1/purchases/txn_7e2d4b1c-9a3f-4e5d-b6c8-1f0a9e8d7c6b/query ``` Response: ```json { "status": { "code": 200, "message": "Purchase details retrieved" }, "data": { "id": "txn_7e2d4b1c-9a3f-4e5d-b6c8-1f0a9e8d7c6b", "type": "transaction", "attributes": { "amount": "100.0", "currency": "NGN", "status": "successful", "category": "purchase", "description": "Pending transaction - transitions to successful after delay", "reference": "sandbox_0d9c8b7a6f5e4d3c2b1a", "external_reference": "1736234460P3N6D1", "product_type": "airtime", "phone_number": "08022222222", "meter_number": null, "price": { "product_amount": "100.00", "fee_amount": "0.00", "total_debit": "100.00", "currency": "NGN", "basis": "face_value" }, "created_at": "2026-05-18T11:42:30+01:00", "updated_at": "2026-05-18T11:42:35+01:00" } } } ``` You can fetch the transaction at any time with `GET /purchases/:id` and get the same shape back. > **Note:** `POST /purchases/:id/query` only works while a transaction is `pending`. Querying a transaction that already completed returns an `INVALID_STATUS` error, so there is nothing to query on the Step 5 purchase: it was `successful` from the start. That's the complete purchase lifecycle: created, pending, successful. In production the transition happens on the provider side and you can receive it as a [webhook event](/developers/docs/webhooks/overview) instead of polling. ## What You Just Did | Step | Endpoint | What it proved | | ------------- | ----------------------------------------------------- | ------------------------------- | | Health check | `GET /health` | The API is reachable | | Balance | `GET /account/balance` | Your key authenticates | | List products | `GET /products/airtimes` | You can discover products | | Purchase | `POST /purchases` | You can create transactions | | Lifecycle | `POST /purchases/:id/query` then `GET /purchases/:id` | You can handle pending statuses | ## Going Live When your integration works end to end in the sandbox: 1. Create a production API key (`sk_live_`) 2. Change the base URL to `https://my.rizpay.app/api/partners/v1` 3. Fund your account and test with a small amount first The endpoints and payloads are identical between sandbox and production. The test phone numbers are sandbox-only: in production, every valid number is a real purchase. ## Next Steps - [Sandbox Testing](/developers/docs/core-concepts/sandbox) - Full sandbox behavior and testing checklist - [Authentication](/developers/docs/getting-started/authentication) - Scopes and environment separation - [Error Handling](/developers/docs/getting-started/errors) - Handle every error code gracefully - [Webhooks](/developers/docs/webhooks/overview) - Get notified instead of polling --- ## Authentication Source: https://www.rizpay.app/developers/docs/getting-started/authentication.md Learn how to authenticate API requests using API keys with scoped permissions. ## API Keys Every API request requires a valid API key in the Authorization header: ```bash Authorization: Bearer sk_live_your_secret_key ``` ### Key Types | Environment | Prefix | Use Case | | ----------- | ---------- | ----------------------- | | Production | `sk_live_` | Real transactions | | Sandbox | `sk_test_` | Testing and development | ### Getting Your Keys 1. Log in at [my.rizpay.app](https://my.rizpay.app) 2. Go to **Settings > API Keys** 3. Click **Create New Key** 4. Select environment and scopes 5. Copy the key immediately (shown only once!) ### Key Security - **Never expose keys in client-side code** - **Never commit keys to version control** - **Use environment variables** to store keys - **Regenerate keys** if compromised - **Use separate keys** for different applications ## Scopes Scopes control what actions an API key can perform. Select only the scopes you need. ### Available Scopes | Scope | Description | | ---------------------- | ------------------------ | | `read_balance` | View account balance | | `read_transactions` | View transaction history | | `view_products` | List and view products | | `purchase_airtime` | Purchase airtime | | `purchase_data` | Purchase data plans | | `purchase_electricity` | Purchase electricity | | `purchase_cable_tv` | Purchase cable TV | | `manage_webhooks` | Manage webhook endpoints | ### Example: Minimal Scope for Airtime If you only need to purchase airtime: ``` view_products - To list airtime products purchase_airtime - To make purchases read_transactions - To check purchase status ``` ### Insufficient Scope Error If you call an endpoint without the required scope: ```json { "status": { "code": 403, "message": "Forbidden" }, "error": { "code": "INSUFFICIENT_SCOPE", "message": "This action requires the 'purchase_data' scope" } } ``` ## Environments Production and sandbox are completely isolated. ### Production ``` Base URL: https://my.rizpay.app/api/partners/v1 API Keys: sk_live_* ``` - Real transactions - Real charges - Affects customer accounts ### Sandbox ``` Base URL: https://my.rizpay.app/api/partners/sandbox/v1 API Keys: sk_test_* ``` - Test transactions only - No real charges - Mock responses ### Environment Mismatch Using the wrong key type returns an error: ```json { "status": { "code": 403, "message": "Forbidden" }, "error": { "code": "ENVIRONMENT_MISMATCH", "message": "Sandbox API keys can only access sandbox endpoints" } } ``` ## IP Whitelisting Optionally restrict API access to specific IP addresses. ### Setup When creating or editing an API key: 1. Enable IP whitelisting 2. Enter allowed IPs (comma-separated) 3. Save the key ### Format ``` 192.168.1.1, 10.0.0.0/8, 2001:db8::1 ``` Supports: - Single IPv4 addresses - IPv4 CIDR ranges - IPv6 addresses ### Blocked Request Requests from non-whitelisted IPs return: ```json { "status": { "code": 403, "message": "Forbidden" }, "error": { "code": "IP_NOT_ALLOWED", "message": "Request IP is not in the allowed list" } } ``` ## Key Management ### Regenerate Secret If your key is compromised: 1. Go to **Settings > API Keys** 2. Click the key 3. Click **Regenerate Secret** 4. Update your application immediately The old secret is invalidated instantly. ### Disable vs Revoke | Action | Effect | Reversible | | ------- | ------------------------------- | ---------- | | Disable | Temporarily blocks the key | Yes | | Revoke | Permanently invalidates the key | No | ### Multiple Keys Create separate keys for: - Different environments (dev, staging, production) - Different applications - Different team members - Different permission levels ## Authentication Errors | Code | Description | Solution | | ------------------------- | --------------- | ------------------------ | | `AUTHENTICATION_REQUIRED` | No key provided | Add Authorization header | | `INVALID_CREDENTIALS` | Key is invalid | Check key is correct | | `TOKEN_REVOKED` | Key was revoked | Generate new key | | `TOKEN_EXPIRED` | Key has expired | Generate new key | ## Code Examples ### Node.js ```javascript const API_KEY = process.env.RIZPAY_API_KEY; const response = await fetch( "https://my.rizpay.app/api/partners/v1/account/balance", { headers: { Authorization: `Bearer ${API_KEY}`, }, } ); ``` ### Python ```python import os import requests API_KEY = os.environ['RIZPAY_API_KEY'] response = requests.get( 'https://my.rizpay.app/api/partners/v1/account/balance', headers={'Authorization': f'Bearer {API_KEY}'} ) ``` ### cURL ```bash curl -X GET \ -H "Authorization: Bearer $RIZPAY_API_KEY" \ https://my.rizpay.app/api/partners/v1/account/balance ``` ## Next Steps - [Error Handling](/developers/docs/getting-started/errors) - Handle authentication errors - [Sandbox Testing](/developers/docs/core-concepts/sandbox) - Test your integration - [Rate Limiting](/developers/docs/core-concepts/rate-limiting) - Understand rate limits --- ## Error Handling Source: https://www.rizpay.app/developers/docs/getting-started/errors.md Learn how to handle API responses and errors gracefully in your application. ## Response Format All API responses follow a consistent format: ### Success Response ```json { "status": { "code": 200, "message": "Success" }, "data": { // Response data here } } ``` ### Error Response Errors carry a separate `error` object. `status.code` is the numeric HTTP status; the machine-readable code is `error.code`. Match on `error.code`, never on the message text. ```json { "status": { "code": 400, "message": "Bad Request" }, "error": { "code": "VALIDATION_ERROR", "message": "Please check your input and try again.", "details": {} } } ``` The `details` object is included only when there are field-level details. ## HTTP Status Codes | Code | Meaning | | ----- | ---------------------------------------------- | | `200` | Success | | `201` | Created (for new resources) | | `400` | Bad Request - Invalid parameters | | `401` | Unauthorized - Invalid or missing API key | | `403` | Forbidden - Insufficient permissions | | `404` | Not Found - Resource doesn't exist | | `429` | Too Many Requests - Rate limit exceeded | | `500` | Server Error - Something went wrong on our end | ## Error Codes Reference ### Authentication Errors (401) | Code | Description | Solution | | ------------------------- | ------------------------ | -------------------------------------- | | `AUTHENTICATION_REQUIRED` | No API key provided | Include `Authorization: Bearer` header | | `INVALID_CREDENTIALS` | API key is invalid | Check your API key is correct | | `TOKEN_REVOKED` | API key has been revoked | Generate a new API key | | `TOKEN_EXPIRED` | API key has expired | Generate a new API key | ### Authorization Errors (403) | Code | Description | Solution | | ---------------------- | -------------------------- | ------------------------------------------------------- | | `INSUFFICIENT_SCOPE` | Missing required scope | Add the required scope to your API key | | `API_DISABLED` | API access is disabled | Enable API access in account settings | | `IP_NOT_ALLOWED` | IP address not whitelisted | Add your IP to the whitelist or remove restrictions | | `ENVIRONMENT_MISMATCH` | Wrong environment | Use `sk_test_*` for sandbox, `sk_live_*` for production | ### Validation Errors (400) | Code | Description | Solution | | ---------------------- | --------------------------------- | -------------------------------------------- | | `VALIDATION_ERROR` | General validation failure | Check the error message for details | | `MISSING_PARAMETER` | Required parameter missing | Include all required parameters | | `INVALID_PARAMETER` | Parameter format invalid | Check parameter format requirements | | `INVALID_PHONE_NUMBER` | Phone number format wrong | Use Nigerian format: 080, 081, 070, 090, 091 | | `INVALID_AMOUNT` | Amount outside allowed range | Check product min/max amounts | | `DUPLICATE_REFERENCE` | `external_reference` already used | Use a unique reference for each transaction | | `INSUFFICIENT_BALANCE` | Account balance too low | Fund your account before purchasing | | `PRODUCT_UNAVAILABLE` | Product or provider unavailable | Try a different product or retry later | Meter and decoder verification failures return `VALIDATION_ERROR` with a message describing the problem: there is no dedicated verification error code. A purchase submitted before verifying, or with details that do not match, also returns `VALIDATION_ERROR` (or `PURCHASE_FAILED` when the purchase itself is rejected downstream). ### Provider Errors (400) | Code | Description | Solution | | ----------------- | ----------------------- | ------------------------------------ | | `PROVIDER_ERROR` | Upstream provider error | Retry the request or contact support | | `PURCHASE_FAILED` | Purchase was rejected | Check the message, then retry | ### Not Found Errors (404) | Code | Description | Solution | | ----------------------- | ------------------------- | --------------------------------- | | `PRODUCT_NOT_FOUND` | Product doesn't exist | Check the product ID | | `TRANSACTION_NOT_FOUND` | Transaction doesn't exist | Check the transaction ID | | `PURCHASE_NOT_FOUND` | Purchase doesn't exist | Check the purchase/transaction ID | ### Status Errors (400) | Code | Description | Solution | | ---------------- | -------------------------------------------- | ------------------------------------------ | | `INVALID_STATUS` | Transaction status doesn't allow this action | Only pending transactions can be requeried | ### Rate Limit Errors (429) | Code | Description | Solution | | --------------------- | ----------------- | ------------------------------------------ | | `RATE_LIMIT_EXCEEDED` | Too many requests | Wait and retry, check `Retry-After` header | ## Handling Errors in Code ```javascript // Generate external reference: 10-digit timestamp + 6 alphanumeric function generateReference() { const timestamp = Math.floor(Date.now() / 1000); const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let suffix = ""; for (let i = 0; i < 6; i++) { suffix += chars.charAt(Math.floor(Math.random() * chars.length)); } return `${timestamp}${suffix}`; } async function makePurchase(productId, phoneNumber, amount) { try { const response = await fetch( "https://my.rizpay.app/api/partners/v1/purchases", { method: "POST", headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ product_id: productId, phone_number: phoneNumber, amount: amount, external_reference: generateReference(), }), } ); const data = await response.json(); if (!response.ok) { // Match on error.code (status.code is the numeric HTTP status) switch (data.error?.code) { case "INSUFFICIENT_BALANCE": throw new Error("Please fund your account"); case "INVALID_PHONE_NUMBER": throw new Error("Please enter a valid phone number"); case "DUPLICATE_REFERENCE": throw new Error("This transaction was already processed"); case "RATE_LIMIT_EXCEEDED": const retryAfter = response.headers.get("Retry-After"); throw new Error( `Too many requests. Retry after ${retryAfter} seconds` ); default: throw new Error(data.error?.message || "Request failed"); } } return data.data; } catch (error) { console.error("Purchase failed:", error.message); throw error; } } ``` ## Best Practices 1. **Always check the status code** - Don't assume success 2. **Log error responses** - Include the full response for debugging 3. **Handle rate limits gracefully** - Use the `Retry-After` header 4. **Use unique references** - Prevent duplicate transaction errors 5. **Verify before purchase** - For electricity and cable TV, always verify first ## Next Steps - [Rate Limiting](/developers/docs/core-concepts/rate-limiting) - Understand rate limits - [Duplicate Prevention](/developers/docs/core-concepts/duplicate-prevention) - Prevent duplicate transactions --- ## Build with AI Source: https://www.rizpay.app/developers/docs/build-with-ai.md RizPay's documentation is AI-native. You can point your AI coding tools straight at these docs and have them read, search, and reason over the RizPay API while you build - no copy-pasting pages into a chat window. The fastest way is the RizPay Docs MCP server. It lets tools like Claude, ChatGPT, and Cursor search these docs and pull full pages on demand, so your assistant answers with real RizPay reference material instead of guessing. ## The RizPay Docs MCP [MCP](https://modelcontextprotocol.io) (the Model Context Protocol) is an open standard that lets AI tools connect to external data sources. RizPay runs a small, read-only MCP server over these docs. | | | | ------------- | ------------------------------------------------------ | | **Endpoint** | `https://www.rizpay.app/api/mcp` | | **Transport** | Streamable HTTP | | **Auth** | None - the docs are public | | **Scope** | Read-only. It only searches and returns documentation. | It exposes two tools: - **`search_docs(query, source?, limit?)`** - Search the developer and storefront docs. Returns ranked results, each with a title, source, URL, markdown URL, and snippet. - `query` (required) - your search terms. - `source` (optional) - limit to one docs source: `developers` or `storefront`. - `limit` (optional) - max results, default 8, max 25. - **`get_doc(source, slug)`** - Fetch the full markdown of one page. - `source` (required) - `developers` or `storefront`. - `slug` (required) - the path within that source, e.g. `getting-started/quickstart` or `reference/error-codes`. A typical flow: your assistant calls `search_docs` to find the right page, then `get_doc` to read it in full before writing code against the API. ## Set it up in your AI tool Pick your client below. The endpoint is the same everywhere: `https://www.rizpay.app/api/mcp`. There is no API key to configure - the docs are public. ### Claude Code Add the server with a single command: ```bash claude mcp add --transport http rizpay-docs https://www.rizpay.app/api/mcp ``` Then check it registered: ```bash claude mcp list ``` Prefer to configure it by hand? Add this to your `.mcp.json` (project scope) or your user config: ```json { "mcpServers": { "rizpay-docs": { "type": "http", "url": "https://www.rizpay.app/api/mcp" } } } ``` ### Claude Desktop and claude.ai Add RizPay as a custom connector by URL: 1. Open **Settings > Connectors**. 2. Click **Add custom connector**. 3. Name it `RizPay Docs` and paste the URL `https://www.rizpay.app/api/mcp`. 4. Save. No authentication is required. Once connected, ask Claude to search the RizPay docs and it will use the `search_docs` and `get_doc` tools automatically. ### Cursor Create `.cursor/mcp.json` in your project (or add to the existing file): ```json { "mcpServers": { "rizpay-docs": { "url": "https://www.rizpay.app/api/mcp" } } } ``` Cursor picks up the server on the next reload. You can then reference the RizPay docs from Cursor's chat and agent. ### ChatGPT In ChatGPT, add RizPay as a custom connector by URL: 1. Open **Settings > Connectors** (custom connectors require a plan that supports them; some clients gate this behind developer mode). 2. Add a connector and paste `https://www.rizpay.app/api/mcp`. 3. No authentication is needed. ### Generic mcp.json Most MCP clients read a config block in this shape. Adapt the key names to your client if they differ: ```json { "mcpServers": { "rizpay-docs": { "type": "http", "url": "https://www.rizpay.app/api/mcp" } } } ``` ## Other AI-forward options If you cannot use MCP, the docs are still easy to feed to an AI tool: - **Copy page as Markdown** - every docs page has a "Copy page" button with a dropdown. It copies the page as clean Markdown, or opens it directly in Claude.ai, ChatGPT, or Cursor. - **Markdown version of any page** - append `.md` to any docs URL to get raw Markdown. For example, `https://www.rizpay.app/developers/docs/getting-started/quickstart.md`. - **[llms.txt](https://www.rizpay.app/llms.txt)** - a concise, linked index of every docs page, for AI tools that follow the [llms.txt convention](https://llmstxt.org). - **[llms-full.txt](https://www.rizpay.app/llms-full.txt)** - the entire docs corpus concatenated into one file, ready to paste into a large-context model. - **[OpenAPI specification](https://www.rizpay.app/api/partners/openapi)** - the machine-readable API spec, useful for generating clients or grounding an assistant in exact request and response shapes. ## Build faster: a quick example With the MCP connected, you can let your assistant do the doc lookup for you. Try a prompt like: > Using the RizPay docs MCP, show me how to check my account balance and then buy MTN airtime. Include the exact endpoints, headers, and request body. Your assistant will call `search_docs` to find the relevant pages, `get_doc` to read them in full, and then write the integration against the real RizPay API - for example the [Quick Start](/developers/docs/getting-started/quickstart) flow, complete with the `external_reference` rules from [Duplicate Prevention](/developers/docs/core-concepts/duplicate-prevention). Because the assistant is reading the live docs, it stays current with the API instead of relying on stale training data. ## Next Steps - [Quick Start](/developers/docs/getting-started/quickstart) - Make your first API call in 5 minutes - [Authentication](/developers/docs/getting-started/authentication) - API keys, scopes, and environments - [Error Handling](/developers/docs/getting-started/errors) - Response format and error codes - [API Explorer](/developers/docs/api) - Interactive API documentation --- ## Rate Limiting Source: https://www.rizpay.app/developers/docs/core-concepts/rate-limiting.md The RizPay API uses rate limiting to ensure fair usage and system stability. Understanding rate limits helps you build reliable integrations. ## How It Works Rate limits are enforced per API key on a per-minute window. Each API key has a fixed maximum number of requests per minute. Read your exact limit from the `X-RateLimit-Limit` header on any response rather than hardcoding a value. If you need a higher limit, contact support. ## Response Headers Every API response includes rate limit headers, so you can track your usage without guessing: | Header | Description | | ----------------------- | ------------------------------------ | | `X-RateLimit-Limit` | Maximum requests allowed per minute | | `X-RateLimit-Remaining` | Requests remaining in current window | | `X-RateLimit-Reset` | Unix timestamp when the limit resets | Example response headers: ``` X-RateLimit-Limit: 300 X-RateLimit-Remaining: 291 X-RateLimit-Reset: 1705312860 ``` ## When You Hit the Limit If you exceed the rate limit, you'll receive a `429 Too Many Requests` response: ```json { "status": { "code": 429, "message": "Too Many Requests" }, "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Rate limit exceeded. Please retry after 45 seconds.", "retry_after": 45 } } ``` The response also includes a `Retry-After` header indicating how many seconds to wait: ``` Retry-After: 45 ``` ## Handling Rate Limits ### Basic Retry Logic ```javascript async function makeRequestWithRetry(url, options, maxRetries = 3) { for (let attempt = 0; attempt < maxRetries; attempt++) { const response = await fetch(url, options); if (response.status === 429) { const retryAfter = parseInt(response.headers.get("Retry-After") || "60"); console.log(`Rate limited. Waiting ${retryAfter} seconds...`); await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000)); continue; } return response; } throw new Error("Max retries exceeded"); } ``` ### Proactive Rate Limit Checking ```javascript async function makeRequest(url, options) { const response = await fetch(url, options); const remaining = parseInt(response.headers.get("X-RateLimit-Remaining")); const resetTime = parseInt(response.headers.get("X-RateLimit-Reset")); if (remaining < 5) { const waitTime = resetTime * 1000 - Date.now(); console.log( `Low on requests. ${remaining} remaining. Resets in ${waitTime}ms` ); } return response; } ``` ## Best Practices 1. **Monitor rate limit headers** - Track remaining requests proactively 2. **Implement exponential backoff** - Don't hammer the API when rate limited 3. **Cache responses** - Reduce unnecessary API calls for data that doesn't change often 4. **Batch operations** - Combine multiple operations where possible 5. **Use webhooks** - Instead of polling for status updates, use webhooks ## Sandbox Rate Limits The sandbox environment applies the same per-key rate limiting as production, returning the same `X-RateLimit-*` headers. Read your current limit from `X-RateLimit-Limit` while testing. ## Next Steps - [Pagination](/developers/docs/core-concepts/pagination) - Navigate large result sets - [Error Handling](/developers/docs/getting-started/errors) - Handle all error types --- ## Pagination Source: https://www.rizpay.app/developers/docs/core-concepts/pagination.md List endpoints return paginated results to keep responses fast and manageable. Learn how to navigate through large datasets. ## How Pagination Works Paginated endpoints accept `page` and `per_page` query parameters and return a `pagination` object with pagination info. ## Request Parameters | Parameter | Type | Default | Max | Description | | ---------- | ------- | ------- | --- | ----------------------- | | `page` | integer | 1 | - | Page number (1-indexed) | | `per_page` | integer | 20 | 100 | Items per page | ## Example Request ```bash curl -X GET \ -H "Authorization: Bearer sk_live_your_secret_key" \ "https://my.rizpay.app/api/partners/v1/account/transactions?page=2&per_page=50" ``` ## Response Format Paginated responses include a `pagination` object: ```json { "status": { "code": 200, "message": "Success" }, "data": [ // Array of items ], "pagination": { "page": 2, "per_page": 50, "total_pages": 10, "total_count": 487 } } ``` ## Pagination Object Fields | Field | Description | | ------------- | --------------------- | | `page` | Current page number | | `per_page` | Items per page | | `total_pages` | Total number of pages | | `total_count` | Total number of items | ## Paginated Endpoints | Endpoint | Description | | --------------------------- | ------------------------- | | `GET /account/transactions` | List transactions | | `GET /products/airtimes` | List airtime products | | `GET /products/dataplans` | List data plans | | `GET /products/electricity` | List electricity products | | `GET /products/cabletv` | List cable TV products | ## Iterating Through Pages ```javascript async function getAllTransactions(apiKey) { const transactions = []; let page = 1; let hasMore = true; while (hasMore) { const response = await fetch( `https://my.rizpay.app/api/partners/v1/account/transactions?page=${page}&per_page=100`, { headers: { Authorization: `Bearer ${apiKey}`, }, } ); const data = await response.json(); transactions.push(...data.data); hasMore = page < data.pagination.total_pages; page++; } return transactions; } ``` ## Best Practices 1. **Use reasonable page sizes** - Larger pages mean fewer requests but slower responses 2. **Don't fetch all pages at once** - Paginate through as needed 3. **Cache results** - Avoid re-fetching data that hasn't changed 4. **Use filters** - Narrow results before paginating when possible ## Filtering Most paginated endpoints support filters to narrow results: ### Transactions ```bash # Filter by status GET /account/transactions?status=successful # Filter by date range GET /account/transactions?from=2024-01-01T00:00:00Z&to=2024-01-31T23:59:59Z # Filter by category (transaction type, not product type) GET /account/transactions?category=purchase # Combine filters GET /account/transactions?status=successful&category=purchase&page=1&per_page=50 ``` ### Products ```bash # Filter by network GET /products/airtimes?network=MTN GET /products/dataplans?network=MTN&bundle_type=monthly GET /products/electricity?distributor=IKEDC ``` ## Next Steps - [Duplicate Prevention](/developers/docs/core-concepts/duplicate-prevention) - Prevent duplicate transactions - [Balance & Transactions](/developers/docs/account/balance) - Query your transaction history --- ## Duplicate Prevention Source: https://www.rizpay.app/developers/docs/core-concepts/duplicate-prevention.md Network issues and retries can lead to duplicate transactions. Learn how to prevent them using external references. ## The Problem Consider this scenario: 1. You send a purchase request 2. The request times out (but the server received it) 3. You retry the request 4. Now there are two purchases for the same order This can result in: - Double charges to customers - Inventory discrepancies - Financial reconciliation issues ## The Solution: External References Every purchase request accepts an `external_reference` parameter. This is your unique identifier for the transaction. ```json { "product_id": "prd_42", "phone_number": "08012345678", "amount": "500.00", "external_reference": "1736234400A1B2C3" } ``` If you send the same `external_reference` twice, the second request returns a `DUPLICATE_REFERENCE` error instead of creating a duplicate transaction. ## Reference Format The `external_reference` must follow a specific format: | Component | Length | Format | Description | | --------- | ----------------- | -------------- | --------------------------------- | | Timestamp | 10 digits | Unix timestamp | Seconds since Jan 1, 1970 UTC | | Suffix | 6 characters | Alphanumeric | Unique identifier (A-Z, a-z, 0-9) | | **Total** | **16 characters** | | | **Regex**: `/^\d{10}[a-zA-Z0-9]{6}$/` **Example**: `1736234400A1B2C3` - `1736234400` → Jan 7, 2025 07:00:00 UTC - `A1B2C3` → Unique suffix ### Why This Format? - **Timestamp prefix**: Natural ordering, easy debugging, audit trail - **Unix format**: Timezone-agnostic, universal standard, compact - **Alphanumeric suffix**: Prevents collisions for multiple transactions per second - **Fixed length**: Consistent, easy to validate ## Code Examples ### JavaScript / Node.js ```javascript function generateExternalReference() { const timestamp = Math.floor(Date.now() / 1000); const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let suffix = ""; for (let i = 0; i < 6; i++) { suffix += chars.charAt(Math.floor(Math.random() * chars.length)); } return `${timestamp}${suffix}`; } // => "1736234400A1B2C3" ``` ### Python ```python import time import random import string def generate_external_reference(): timestamp = str(int(time.time())) suffix = ''.join(random.choices(string.ascii_letters + string.digits, k=6)) return f"{timestamp}{suffix}" # => "1736234400A1B2C3" ``` ### PHP ```php function generateExternalReference(): string { $timestamp = time(); $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; $suffix = ''; for ($i = 0; $i < 6; $i++) { $suffix .= $chars[random_int(0, strlen($chars) - 1)]; } return $timestamp . $suffix; } // => "1736234400A1B2C3" ``` ### Ruby ```ruby def generate_external_reference timestamp = Time.now.to_i.to_s suffix = SecureRandom.alphanumeric(6) "#{timestamp}#{suffix}" end # => "1736234400A1B2C3" ``` ## Example Implementation ```javascript async function purchaseAirtime(order) { // Generate a unique reference const reference = generateExternalReference(); try { const response = await fetch( "https://my.rizpay.app/api/partners/v1/purchases", { method: "POST", headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ product_id: order.productId, phone_number: order.phoneNumber, amount: order.amount, external_reference: reference, }), } ); const data = await response.json(); if (data.error?.code === "DUPLICATE_REFERENCE") { // This reference was already used // Fetch the existing transaction instead return await getTransactionByReference(reference); } return data.data; } catch (error) { // Safe to retry with the SAME reference - prevents duplicates throw error; } } ``` ## Safe Retry Pattern With external references, retries are safe: ```javascript async function purchaseWithRetry(order, maxRetries = 3) { // Generate reference ONCE, reuse for retries const reference = generateExternalReference(); for (let attempt = 0; attempt < maxRetries; attempt++) { try { const response = await makePurchase(order, reference); if (response.error?.code === "DUPLICATE_REFERENCE") { // Already processed - this is success, not an error console.log("Order already processed"); return { success: true, alreadyProcessed: true }; } return { success: true, data: response.data }; } catch (error) { if (attempt === maxRetries - 1) { throw error; } // Wait before retry await new Promise((r) => setTimeout(r, 1000 * (attempt + 1))); } } } ``` ## Validation Rules | Rule | Description | | ---------- | ---------------------------------------------- | | Length | Exactly 16 characters | | Format | First 10 digits (timestamp) + 6 alphanumeric | | Timestamp | Must be within the same day as the transaction | | Uniqueness | Must be unique per account | | Reuse | Cannot reuse, even for failed transactions | ## Error Responses | Error Code | Message | Cause | | --------------------- | -------------------------------------------------- | --------------------- | | `VALIDATION_ERROR` | "External reference must be exactly 16 characters" | Wrong length | | `VALIDATION_ERROR` | "External reference format invalid" | Doesn't match pattern | | `VALIDATION_ERROR` | "External reference timestamp must be same day" | Timestamp too old | | `DUPLICATE_REFERENCE` | "External reference already used" | Reference reused | ## Converting Timestamp Back to Date The Unix timestamp can be converted back for debugging: ```javascript // JavaScript new Date(1736234400 * 1000).toISOString(); // => "2025-01-07T07:00:00.000Z" ``` ## Querying by Reference You can find a transaction by its external reference: ```bash curl -X GET \ -H "Authorization: Bearer sk_live_your_secret_key" \ "https://my.rizpay.app/api/partners/v1/account/transactions?reference=1736234400A1B2C3" ``` ## Best Practices 1. **Always use external_reference** - Even if you think you don't need it 2. **Store the reference** - Save it with your order for reconciliation 3. **Generate once, retry with same** - Use the same reference for retries 4. **Handle DUPLICATE_REFERENCE gracefully** - It means the order was processed 5. **Use current timestamp** - The timestamp must be from the same day ## Next Steps - [Error Handling](/developers/docs/getting-started/errors) - Handle duplicate reference errors - [Webhooks](/developers/docs/webhooks/overview) - Get notified of transaction status --- ## Sandbox Testing Source: https://www.rizpay.app/developers/docs/core-concepts/sandbox.md The sandbox environment lets you test your integration without making real purchases or affecting production data. ## Sandbox vs Production | Aspect | Sandbox | Production | | ----------------- | -------------------------- | ------------------ | | Base URL | `/api/partners/sandbox/v1` | `/api/partners/v1` | | API Key Prefix | `sk_test_` | `sk_live_` | | Real Transactions | No | Yes | | Real Charges | No | Yes | | Webhooks | Test payloads | Real events | | Rate Limits | 60/min (all tiers) | Tier-based | | Max Webhooks | 1 | 3 | ## Getting Sandbox Credentials 1. Go to [Settings > API Keys](https://my.rizpay.app/settings/api) 2. Click **Create New Key** 3. Select **Sandbox** environment 4. Copy your `sk_test_` key ## Making Sandbox Requests Use the sandbox base URL with your test key: ```bash curl -X GET \ -H "Authorization: Bearer sk_test_your_test_key" \ https://my.rizpay.app/api/partners/sandbox/v1/account/balance ``` ## Sandbox Behavior ### Account Balance Returns a mock balance for testing: ```json { "status": { "code": 200, "message": "Success" }, "data": { "balance": "100000.00", "currency": "NGN", "updated_at": "2024-01-15T10:30:00Z" } } ``` ### Products Returns real product data. Use these products in your test purchases. ### Purchases Creates mock transactions: 1. **Initial status**: `pending` 2. **After query**: Transitions to `successful` No real airtime or data is delivered. No balance is deducted. ```bash # Create a test purchase curl -X POST \ -H "Authorization: Bearer sk_test_your_test_key" \ -H "Content-Type: application/json" \ -d '{ "product_id": "prd_42", "phone_number": "08012345678", "amount": "100.00", "external_reference": "1736234400T3S4T5" }' \ https://my.rizpay.app/api/partners/sandbox/v1/purchases ``` ### Transaction Status Query a transaction to simulate completion: ```bash # Query to complete the transaction curl -X POST \ -H "Authorization: Bearer sk_test_your_test_key" \ https://my.rizpay.app/api/partners/sandbox/v1/purchases/txn_abc123/query ``` The transaction will immediately become `successful`. ### Webhooks Sandbox webhooks fire with test payloads. Use them to: - Test your webhook endpoint - Verify signature validation - Debug payload handling ## Test Phone Numbers Use any valid Nigerian phone number format: - `08012345678` - `08112345678` - `07012345678` - `09012345678` ## Test Meter/Decoder Numbers For electricity and cable TV testing, use these formats: - Meter numbers: Any 11-13 digit number - Decoder numbers: Any 10-11 digit number ## Environment Isolation Sandbox and production are completely isolated: | Aspect | Behavior | | ------------ | ----------------------------------------------- | | API Keys | Sandbox keys only work on sandbox endpoints | | Transactions | Sandbox transactions don't appear in production | | Webhooks | Configured separately for each environment | | Data | No data sharing between environments | Attempting to use a sandbox key on production (or vice versa) returns: ```json { "status": { "code": 403, "message": "Forbidden" }, "error": { "code": "ENVIRONMENT_MISMATCH", "message": "Sandbox API keys can only access sandbox endpoints" } } ``` ## Testing Checklist Before going live, verify: - [ ] **Authentication** - API key works correctly - [ ] **Products** - Can list and filter products - [ ] **Purchases** - Can create purchases - [ ] **Status Checks** - Can query transaction status - [ ] **Verification** - Meter/decoder verification works (electricity/cable) - [ ] **Error Handling** - Handles all error codes gracefully - [ ] **Webhooks** - Receives and verifies webhook payloads - [ ] **Rate Limits** - Handles 429 responses correctly - [ ] **Duplicate Prevention** - External reference prevents duplicates ## Going Live When you're ready for production: 1. Create a production API key (`sk_live_`) 2. Update your base URL to `/api/partners/v1` 3. Configure production webhooks 4. Test with small amounts first 5. Monitor your first few transactions ## Next Steps - [Quick Start](/developers/docs/getting-started/quickstart) - Make your first API call - [Webhooks](/developers/docs/webhooks/overview) - Set up webhook testing --- ## Airtime Source: https://www.rizpay.app/developers/docs/products/airtime.md Purchase airtime for MTN, Airtel, Glo, and 9mobile networks. ## Supported Networks | Network | Code | | -------------- | --------- | | MTN Nigeria | `MTN` | | Airtel Nigeria | `AIRTEL` | | Globacom | `GLO` | | 9mobile | `9MOBILE` | ## Purchase Flow Airtime purchases are straightforward - no verification required. 1. **List products** - Get available airtime products 2. **Make purchase** - Send the purchase request ## Step 1: List Products ```bash curl -X GET \ -H "Authorization: Bearer sk_live_your_secret_key" \ "https://my.rizpay.app/api/partners/v1/products/airtimes?network=MTN" ``` Response: ```json { "status": { "code": 200, "message": "Airtime products retrieved" }, "data": [ { "id": "prd_42", "type": "airtime", "attributes": { "display_name": "MTN Airtime", "network": "MTN", "min_amount": "50.0", "max_amount": "50000.0", "price": { "currency": "NGN", "basis": "face_value", "min_amount": "50.00", "max_amount": "50000.00" } } } ], "pagination": { "page": 1, "per_page": 20, "total_pages": 1, "total_count": 1 } } ``` Product ids are numeric with a `prd_` prefix (e.g. `prd_42`). Use the `id` from your own response in the purchase request. ### The `price` block Airtime is a variable-priced product - the partner picks the amount. RizPay returns the supported range as a `face_value` tuple. The partner will be billed exactly the `amount` they submit on the purchase request (no markup today). To quote your end-user, take their request amount, add your margin, and bill them; RizPay will bill you the underlying amount. | Field | Type | Notes | | ------------ | ------ | ----------------------------------------- | | `currency` | string | Always `NGN` | | `basis` | string | `face_value` (partner chooses the amount) | | `min_amount` | string | Smallest amount accepted by the network | | `max_amount` | string | Largest amount accepted by the network | ## Step 2: Make Purchase ```bash curl -X POST \ -H "Authorization: Bearer sk_live_your_secret_key" \ -H "Content-Type: application/json" \ -d '{ "product_id": "prd_42", "phone_number": "08012345678", "amount": "500.00", "external_reference": "1736234400B2C3D4" }' \ https://my.rizpay.app/api/partners/v1/purchases ``` Response: ```json { "status": { "code": 201, "message": "Purchase created successfully" }, "data": { "id": "txn_abc123", "type": "transaction", "attributes": { "amount": "500.0", "currency": "NGN", "status": "pending", "category": "purchase", "description": "Purchase of MTN Airtime", "reference": "a1b2c3d4e5f607181736234400", "external_reference": "1736234400B2C3D4", "product_type": "airtime", "phone_number": "08012345678", "meter_number": null, "price": { "product_amount": "500.00", "fee_amount": "0.00", "total_debit": "500.00", "currency": "NGN", "basis": "face_value" }, "created_at": "2026-05-18T10:30:00+01:00", "updated_at": "2026-05-18T10:30:00+01:00" } } } ``` ### The `price` breakdown Every purchase response carries a `price` object so you can reconcile the debit against your customer charge: | Field | Type | Notes | | ---------------- | ------ | --------------------------------------------------------------------------- | | `product_amount` | string | What the underlying product cost. For airtime equals your requested amount. | | `fee_amount` | string | Any partner-tier markup RizPay applied. Zero today. | | `total_debit` | string | What was actually taken from your wallet (`product_amount + fee_amount`). | | `currency` | string | Always `NGN`. | | `basis` | string | `face_value` for airtime/electricity, `fixed` for data/cable TV. | ## Check Transaction Status Poll the transaction or use webhooks to get the final status: ```bash curl -X GET \ -H "Authorization: Bearer sk_live_your_secret_key" \ "https://my.rizpay.app/api/partners/v1/purchases/txn_abc123" ``` Successful response: ```json { "status": { "code": 200, "message": "Purchase details retrieved" }, "data": { "id": "txn_abc123", "type": "transaction", "attributes": { "amount": "500.0", "currency": "NGN", "status": "successful", "category": "purchase", "description": "Purchase of MTN Airtime", "reference": "a1b2c3d4e5f607181736234400", "external_reference": "1736234400B2C3D4", "product_type": "airtime", "phone_number": "08012345678", "meter_number": null, "price": { "product_amount": "500.00", "fee_amount": "0.00", "total_debit": "500.00", "currency": "NGN", "basis": "face_value" }, "created_at": "2026-05-18T10:30:00+01:00", "updated_at": "2026-05-18T10:30:02+01:00" } } } ``` The transaction is done when `status` reaches `successful`, `failed`, or `reversed`. There is no separate completion timestamp: use `updated_at`. ## Complete Example ```javascript // Generate external reference: 10-digit timestamp + 6 alphanumeric function generateReference() { const timestamp = Math.floor(Date.now() / 1000); const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let suffix = ""; for (let i = 0; i < 6; i++) { suffix += chars.charAt(Math.floor(Math.random() * chars.length)); } return `${timestamp}${suffix}`; } async function purchaseAirtime(network, phoneNumber, amount) { const API_KEY = "sk_live_your_secret_key"; const BASE_URL = "https://my.rizpay.app/api/partners/v1"; // Step 1: Find the product const productsRes = await fetch( `${BASE_URL}/products/airtimes?network=${network}`, { headers: { Authorization: `Bearer ${API_KEY}` } } ); const products = await productsRes.json(); const product = products.data[0]; // Validate amount if (parseFloat(amount) < parseFloat(product.attributes.min_amount)) { throw new Error(`Minimum amount is ${product.attributes.min_amount}`); } if (parseFloat(amount) > parseFloat(product.attributes.max_amount)) { throw new Error(`Maximum amount is ${product.attributes.max_amount}`); } // Step 2: Make purchase const purchaseRes = await fetch(`${BASE_URL}/purchases`, { method: "POST", headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ product_id: product.id, phone_number: phoneNumber, amount: amount, external_reference: generateReference(), }), }); return await purchaseRes.json(); } // Usage purchaseAirtime("MTN", "08012345678", "1000.00") .then((result) => console.log(result)) .catch((error) => console.error(error)); ``` ## Phone Number Validation Nigerian phone numbers should: - Start with `080`, `081`, `070`, `090`, or `091` - Be 11 digits total - Match the network of the product (or be cross-network if supported) | Prefix | Network | | ---------------------------------------------------------- | ------- | | 0803, 0806, 0813, 0816, 0810, 0814, 0903, 0906, 0913, 0916 | MTN | | 0802, 0808, 0812, 0701, 0708, 0902, 0907, 0912 | Airtel | | 0805, 0807, 0811, 0815, 0705, 0905, 0915 | Glo | | 0809, 0817, 0818, 0908, 0909 | 9mobile | ## Amount Limits | Network | Min | Max | | ------- | --- | ------ | | MTN | 50 | 50,000 | | Airtel | 50 | 50,000 | | Glo | 50 | 50,000 | | 9mobile | 50 | 50,000 | _Limits may vary. Check the product `min_amount` and `max_amount` fields._ ## Transaction States | Status | Description | | ------------ | ---------------------------------- | | `pending` | Processing with provider | | `successful` | Airtime delivered | | `failed` | Purchase failed (balance refunded) | ## Required Scope Requires the `purchase_airtime` scope on your API key. ## Next Steps - [Data Plans](/developers/docs/products/data-plans) - Buy data bundles - [Webhooks](/developers/docs/webhooks/overview) - Get notified when purchase completes --- ## Data Plans Source: https://www.rizpay.app/developers/docs/products/data-plans.md Buy daily, weekly, and monthly data bundles for MTN, Airtel, Glo, and 9mobile. ## Supported Networks | Network | Code | | -------------- | --------- | | MTN Nigeria | `MTN` | | Airtel Nigeria | `AIRTEL` | | Globacom | `GLO` | | 9mobile | `9MOBILE` | ## Bundle Types Each plan carries a free-form `bundle_type` tag describing the plan family (for example `SME`, `NIGHT`, `SOCIAL`, `SG GIFTING`). It can be `null` when a plan is not categorised. Use the `duration` field for validity. Filter the products endpoint by `bundle_type` to narrow results. ## Purchase Flow Data plan purchases are straightforward - no verification required. 1. **List products** - Get available data plans 2. **Make purchase** - Send the purchase request ## Step 1: List Products Filter by network and/or bundle type: ```bash curl -X GET \ -H "Authorization: Bearer sk_live_your_secret_key" \ "https://my.rizpay.app/api/partners/v1/products/dataplans?network=MTN&bundle_type=monthly" ``` Response: ```json { "status": { "code": 200, "message": "Success" }, "data": [ { "id": "prd_2270", "type": "data_plan", "attributes": { "display_name": "MTN 75MB / 1 Day (SG GIFTING)", "network": "MTN", "bundle_size": "75MB", "duration": "1 Day", "bundle_type": "SG GIFTING", "price": { "amount": "77.60", "currency": "NGN", "basis": "fixed" } } }, { "id": "prd_2271", "type": "data_plan", "attributes": { "display_name": "MTN 2GB / 30 Days", "network": "MTN", "bundle_size": "2GB", "duration": "30 Days", "bundle_type": null, "price": { "amount": "1000.00", "currency": "NGN", "basis": "fixed" } } } ], "pagination": { "page": 1, "per_page": 20, "total_pages": 3, "total_count": 45 } } ``` Product ids are numeric with a `prd_` prefix (e.g. `prd_2270`). Use the `id` from your own response in the purchase request. `bundle_type` may be `null` when the plan is not categorised. ### The `price` block Data plans are catalog-priced - RizPay returns a single `fixed` amount per plan. This is the number you'll be billed; quote your end-user this amount plus your own margin. | Field | Type | Notes | | ---------- | ------ | ----------------------------------------------- | | `amount` | string | What RizPay will bill you per unit of this plan | | `currency` | string | Always `NGN` | | `basis` | string | `fixed` | ## Step 2: Make Purchase ```bash curl -X POST \ -H "Authorization: Bearer sk_live_your_secret_key" \ -H "Content-Type: application/json" \ -d '{ "product_id": "prd_2271", "phone_number": "08012345678", "network": "MTN", "external_reference": "1736234400C3D4E5" }' \ https://my.rizpay.app/api/partners/v1/purchases ``` **Note:** For data plans, the amount is fixed by the plan. You don't need to specify it. Include the `network` matching the plan you selected. Response: ```json { "status": { "code": 201, "message": "Purchase created successfully" }, "data": { "id": "txn_abc123", "type": "transaction", "attributes": { "amount": "1000.0", "currency": "NGN", "status": "pending", "category": "purchase", "description": "Purchase of MTN 2GB - 30 Days", "reference": "a26e6eb0cd196076ea2b", "external_reference": "1736234400C3D4E5", "product_type": "data_plan", "phone_number": "08012345678", "meter_number": null, "price": { "product_amount": "1000.00", "fee_amount": "0.00", "total_debit": "1000.00", "currency": "NGN", "basis": "fixed" }, "created_at": "2026-05-18T10:30:00+01:00", "updated_at": "2026-05-18T10:30:00+01:00" } } } ``` ### The `price` breakdown Same breakdown as airtime. For data plans the `basis` is `fixed` because the catalog price is the bill, with no partner-supplied amount. ## Check Transaction Status ```bash curl -X GET \ -H "Authorization: Bearer sk_live_your_secret_key" \ "https://my.rizpay.app/api/partners/v1/purchases/txn_abc123" ``` Successful response: ```json { "status": { "code": 200, "message": "Purchase details retrieved" }, "data": { "id": "txn_abc123", "type": "transaction", "attributes": { "amount": "1000.0", "currency": "NGN", "status": "successful", "category": "purchase", "description": "Purchase of MTN 2GB - 30 Days", "reference": "a26e6eb0cd196076ea2b", "external_reference": "1736234400C3D4E5", "product_type": "data_plan", "phone_number": "08012345678", "meter_number": null, "price": { "product_amount": "1000.00", "fee_amount": "0.00", "total_debit": "1000.00", "currency": "NGN", "basis": "fixed" }, "created_at": "2026-05-18T10:30:00+01:00", "updated_at": "2026-05-18T10:30:02+01:00" } } } ``` The transaction is done when `status` reaches `successful`, `failed`, or `reversed`. There is no separate completion timestamp: use `updated_at`. ## Complete Example ```javascript // Generate external reference: 10-digit timestamp + 6 alphanumeric function generateReference() { const timestamp = Math.floor(Date.now() / 1000); const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let suffix = ""; for (let i = 0; i < 6; i++) { suffix += chars.charAt(Math.floor(Math.random() * chars.length)); } return `${timestamp}${suffix}`; } async function purchaseDataPlan(network, bundleType, bundleSize, phoneNumber) { const API_KEY = "sk_live_your_secret_key"; const BASE_URL = "https://my.rizpay.app/api/partners/v1"; // Step 1: Find matching data plans const params = new URLSearchParams({ network: network, bundle_type: bundleType, }); const productsRes = await fetch(`${BASE_URL}/products/dataplans?${params}`, { headers: { Authorization: `Bearer ${API_KEY}` }, }); const products = await productsRes.json(); // Find the plan matching the requested bundle size const product = products.data.find( (p) => p.attributes.bundle_size === bundleSize ); if (!product) { throw new Error(`No ${bundleSize} ${bundleType} plan found for ${network}`); } // Step 2: Make purchase const purchaseRes = await fetch(`${BASE_URL}/purchases`, { method: "POST", headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ product_id: product.id, phone_number: phoneNumber, network: product.attributes.network, external_reference: generateReference(), }), }); return await purchaseRes.json(); } // Usage purchaseDataPlan("MTN", "monthly", "2GB", "08012345678") .then((result) => console.log(result)) .catch((error) => console.error(error)); ``` ## Listing All Plans for a User Build a plan selector by fetching all plans: ```javascript async function getDataPlans(network) { const API_KEY = "sk_live_your_secret_key"; const BASE_URL = "https://my.rizpay.app/api/partners/v1"; const response = await fetch( `${BASE_URL}/products/dataplans?network=${network}&per_page=100`, { headers: { Authorization: `Bearer ${API_KEY}` } } ); const data = await response.json(); // Group by bundle type. `bundle_type` may be null for uncategorised plans. const grouped = {}; data.data.forEach((plan) => { const attrs = plan.attributes; const key = attrs.bundle_type || "other"; (grouped[key] ||= []).push({ id: plan.id, name: `${attrs.bundle_size} - ₦${attrs.price.amount}`, size: attrs.bundle_size, price: attrs.price.amount, duration: attrs.duration, }); }); return grouped; } ``` ## Popular Plans ### MTN | Plan | Price | Validity | | ---- | ------ | -------- | | 1GB | ~500 | 30 days | | 2GB | ~1,000 | 30 days | | 5GB | ~2,000 | 30 days | | 10GB | ~3,500 | 30 days | ### Airtel | Plan | Price | Validity | | ---- | ------ | -------- | | 1GB | ~500 | 30 days | | 2GB | ~1,000 | 30 days | | 6GB | ~2,000 | 30 days | _Prices may vary. Always check the products endpoint for current pricing._ ## Transaction States | Status | Description | | ------------ | ---------------------------------- | | `pending` | Processing with provider | | `successful` | Data plan activated | | `failed` | Purchase failed (balance refunded) | ## Required Scope Requires the `purchase_data` scope on your API key. ## Next Steps - [Airtime](/developers/docs/products/airtime) - Purchase airtime - [Electricity](/developers/docs/products/electricity) - Pay electricity bills - [Webhooks](/developers/docs/webhooks/overview) - Get notified when purchase completes --- ## Electricity Source: https://www.rizpay.app/developers/docs/products/electricity.md Pay prepaid and postpaid electricity bills for all major Nigerian distribution companies. ## Supported Distributors | Code | Name | Region | | ------- | ---------------------- | ------------- | | `IKEDC` | Ikeja Electric | Lagos (Ikeja) | | `EKEDC` | Eko Electric | Lagos (Eko) | | `AEDC` | Abuja Electric | Abuja | | `KEDC` | Kaduna Electric | Kaduna | | `JEDC` | Jos Electric | Jos | | `IBEDC` | Ibadan Electric | Ibadan | | `KAEDC` | Kano Electric | Kano | | `EEDC` | Enugu Electric | Enugu | | `PhED` | Port Harcourt Electric | Port Harcourt | | `BEDC` | Benin Electric | Benin | | `ABA` | Aba Power | Aba | | `YEDC` | Yola Electric | Yola | ## Purchase Flow Electricity purchases require a **two-step process**: 1. **Verify** - Validate the meter number and get customer details 2. **Purchase** - Make the payment This prevents payments to invalid meters. ## Step 1: List Products ```bash curl -X GET \ -H "Authorization: Bearer sk_live_your_secret_key" \ "https://my.rizpay.app/api/partners/v1/products/electricity?distributor=IKEDC" ``` Response: ```json { "status": { "code": 200, "message": "Success" }, "data": [ { "id": "prd_8152", "type": "electricity", "attributes": { "display_name": "Ikeja Electric Payment - IKEDC", "distributor": "Ikeja Electric Payment - IKEDC", "meter_type": null, "min_amount": "100.0", "max_amount": "100000.0", "price": { "currency": "NGN", "basis": "face_value", "min_amount": "100.00", "max_amount": "100000.00" } } } ], "pagination": { "page": 1, "per_page": 20, "total_pages": 1, "total_count": 12 } } ``` Product ids are numeric with a `prd_` prefix (e.g. `prd_8152`). There is one product per distribution company. `meter_type` on the product is `null`: you supply the meter type (`prepaid` or `postpaid`) on the purchase request, and verify returns the meter type it detected. ### The `price` block Electricity is variable: the partner chooses the amount. RizPay returns the supported range as a `face_value` tuple. You'll be billed exactly the amount you submit on the purchase request. ## Step 2: Verify Meter **Required before purchase.** Validates the meter and returns customer details. ```bash curl -X POST \ -H "Authorization: Bearer sk_live_your_secret_key" \ -H "Content-Type: application/json" \ -d '{ "product_id": "prd_8152", "meter_number": "12345678901", "meter_type": "prepaid" }' \ https://my.rizpay.app/api/partners/v1/purchases/verify ``` Response: ```json { "status": { "code": 200, "message": "Verification successful" }, "data": { "verified": true, "product_id": "prd_8152", "product_type": "electricity", "price": { "currency": "NGN", "basis": "face_value", "min_amount": "100.00", "max_amount": "100000.00" }, "customer": { "meter_number": "12345678901", "meter_type": "prepaid", "customer_name": "JOHN DOE", "address": "123 Main Street, Ikeja, Lagos", "distributor": "Ikeja Electric Payment - IKEDC" } } } ``` The `price` block on verify gives you the supported amount range for the verified meter so you can prompt your end-user before submitting the purchase. ### Verification Errors If the meter number cannot be validated, verify returns a `400` with `error.code` set to `VALIDATION_ERROR` and a message describing the problem. If the distributor itself is unavailable, purchases for that product return `PRODUCT_UNAVAILABLE`. | Error | Meaning | | --------------------- | ---------------------------------- | | `VALIDATION_ERROR` | Meter number could not be verified | | `PRODUCT_UNAVAILABLE` | Distributor service is down | ## Step 3: Make Purchase After verification, make the purchase. Include the `meter_type` (`prepaid` or `postpaid`) you verified: ```bash curl -X POST \ -H "Authorization: Bearer sk_live_your_secret_key" \ -H "Content-Type: application/json" \ -d '{ "product_id": "prd_8152", "meter_number": "12345678901", "meter_type": "prepaid", "amount": "5000.00", "phone_number": "08012345678", "external_reference": "1736234400D4E5F6" }' \ https://my.rizpay.app/api/partners/v1/purchases ``` Response: ```json { "status": { "code": 201, "message": "Purchase created successfully" }, "data": { "id": "txn_abc123", "type": "transaction", "attributes": { "amount": "5000.0", "currency": "NGN", "status": "pending", "category": "purchase", "description": "Purchase of IKEDC Electricity", "reference": "elec0order0001abcd", "external_reference": "1736234400D4E5F6", "product_type": "electricity", "phone_number": "08012345678", "meter_number": "12345678901", "price": { "product_amount": "5000.00", "fee_amount": "0.00", "total_debit": "5000.00", "currency": "NGN", "basis": "face_value" }, "created_at": "2026-05-18T10:30:00+01:00", "updated_at": "2026-05-18T10:30:00+01:00" } } } ``` ### The `price` breakdown Same shape as airtime. `basis` is `face_value` because the partner picked the amount. ## Successful Purchase Poll the transaction (or use webhooks) until `status` reaches `successful`. The completed purchase uses the same nested shape as the create response. For a prepaid meter the recharge `token` appears inside `data.attributes.token`: ```json { "status": { "code": 200, "message": "Purchase details retrieved" }, "data": { "id": "txn_abc123", "type": "transaction", "attributes": { "amount": "5000.0", "currency": "NGN", "status": "successful", "category": "purchase", "description": "Purchase of IKEDC Electricity", "reference": "elec0order0001abcd", "external_reference": "1736234400D4E5F6", "product_type": "electricity", "phone_number": "08012345678", "meter_number": "12345678901", "token": "1234-5678-9012-3456-7890", "price": { "product_amount": "5000.00", "fee_amount": "0.00", "total_debit": "5000.00", "currency": "NGN", "basis": "face_value" }, "created_at": "2026-05-18T10:30:00+01:00", "updated_at": "2026-05-18T10:30:05+01:00" } } } ``` **Important:** For prepaid meters, provide the `token` to the customer. They'll enter it into their meter to load the units. The `token` field is present only once the purchase is successful and only for prepaid meters. There is no separate completion timestamp: use `updated_at`. ## Complete Example ```javascript // Generate external reference: 10-digit timestamp + 6 alphanumeric function generateReference() { const timestamp = Math.floor(Date.now() / 1000); const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let suffix = ""; for (let i = 0; i < 6; i++) { suffix += chars.charAt(Math.floor(Math.random() * chars.length)); } return `${timestamp}${suffix}`; } async function payElectricity(meterNumber, meterType, amount, phoneNumber) { const API_KEY = "sk_live_your_secret_key"; const BASE_URL = "https://my.rizpay.app/api/partners/v1"; // Step 1: Find the product (one per distributor) const productsRes = await fetch( `${BASE_URL}/products/electricity?distributor=IKEDC`, { headers: { Authorization: `Bearer ${API_KEY}` } } ); const products = await productsRes.json(); const product = products.data[0]; // Step 2: Verify meter const verifyRes = await fetch(`${BASE_URL}/purchases/verify`, { method: "POST", headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ product_id: product.id, meter_number: meterNumber, meter_type: meterType, }), }); const verification = await verifyRes.json(); if (!verification.data.verified) { throw new Error("Invalid meter number"); } // Step 3: Make purchase const purchaseRes = await fetch(`${BASE_URL}/purchases`, { method: "POST", headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ product_id: product.id, meter_number: meterNumber, meter_type: meterType, amount: amount, phone_number: phoneNumber, external_reference: generateReference(), }), }); return await purchaseRes.json(); } ``` ## Meter Types | Type | Description | Token | | ---------- | -------------- | ---------------------------- | | `prepaid` | Pay before use | Yes - customer enters token | | `postpaid` | Pay after use | No - payment credits account | ## Required Scope Requires the `purchase_electricity` scope on your API key. ## Next Steps - [Cable TV](/developers/docs/products/cable-tv) - Subscribe to cable TV - [Webhooks](/developers/docs/webhooks/overview) - Get notified when payment completes --- ## Cable TV Source: https://www.rizpay.app/developers/docs/products/cable-tv.md Renew cable TV subscriptions for DStv, GOtv, and Startimes. ## Supported Providers | Provider | Description | | ----------- | ---------------- | | `DSTV` | MultiChoice DStv | | `GOTV` | MultiChoice GOtv | | `STARTIMES` | StarTimes | ## Purchase Flow Cable TV purchases require a **two-step process**: 1. **Verify** - Validate the smart card/decoder number 2. **Purchase** - Make the subscription payment ## Step 1: List Products List available packages for a provider: ```bash curl -X GET \ -H "Authorization: Bearer sk_live_your_secret_key" \ "https://my.rizpay.app/api/partners/v1/products/cabletv?provider=GOTV" ``` Response: ```json { "status": { "code": 200, "message": "Success" }, "data": [ { "id": "prd_7640", "type": "cable_tv", "attributes": { "display_name": "GOtv Smallie (Monthly)", "service": "GOTV", "package": "GOtv Smallie (Monthly)", "price": { "amount": "1896.00", "currency": "NGN", "basis": "fixed" } } }, { "id": "prd_7806", "type": "cable_tv", "attributes": { "display_name": "GOtv Smallie - monthly N1900", "service": "GOTV", "package": "GOtv Smallie - monthly N1900", "price": { "amount": "1877.20", "currency": "NGN", "basis": "fixed" } } } ], "pagination": { "page": 1, "per_page": 20, "total_pages": 6, "total_count": 103 } } ``` Product ids are numeric with a `prd_` prefix (e.g. `prd_7640`). Use the `id` from your own response in the purchase request. ### The `price` block Cable TV packages are catalog-priced. The `fixed` price is what RizPay will bill you for that subscription. Add your margin on top before charging your end-user. ## Step 2: Verify Decoder **Required before purchase.** Validates the decoder and returns customer details. ```bash curl -X POST \ -H "Authorization: Bearer sk_live_your_secret_key" \ -H "Content-Type: application/json" \ -d '{ "product_id": "prd_7640", "smart_number": "1234567890" }' \ https://my.rizpay.app/api/partners/v1/purchases/verify ``` Response: ```json { "status": { "code": 200, "message": "Verification successful" }, "data": { "verified": true, "product_id": "prd_7640", "product_type": "cable_tv", "price": { "amount": "1896.00", "currency": "NGN", "basis": "fixed" }, "customer": { "smart_number": "1234567890", "customer_name": "JOHN DOE", "current_bouquet": "GOtv Jolli", "due_date": "2024-01-20" } } } ``` The `price` block on verify is the fixed subscription cost the partner will be billed if they go through with the purchase. ### Verification Errors If the smart card or decoder number cannot be validated, verify returns a `400` with `error.code` set to `VALIDATION_ERROR` and a message describing the problem. If the provider itself is unavailable, purchases for that product return `PRODUCT_UNAVAILABLE`. | Error | Meaning | | --------------------- | ----------------------------------------------- | | `VALIDATION_ERROR` | Smart card/decoder number could not be verified | | `PRODUCT_UNAVAILABLE` | Provider service is down | ## Step 3: Make Purchase After verification, make the subscription payment: ```bash curl -X POST \ -H "Authorization: Bearer sk_live_your_secret_key" \ -H "Content-Type: application/json" \ -d '{ "product_id": "prd_7640", "smart_number": "1234567890", "amount": "1896.00", "service": "GOTV", "phone_number": "08012345678", "external_reference": "1736234400E5F6G7" }' \ https://my.rizpay.app/api/partners/v1/purchases ``` Include the `service` matching the package you selected (for example `GOTV`, `DSTV`, or `STARTIMES`). Response: ```json { "status": { "code": 201, "message": "Purchase created successfully" }, "data": { "id": "txn_abc123", "type": "transaction", "attributes": { "amount": "1896.0", "currency": "NGN", "status": "pending", "category": "purchase", "description": "Purchase of GOtv Smallie (Monthly)", "reference": "cable0order0001abcd", "external_reference": "1736234400E5F6G7", "product_type": "cable_tv", "phone_number": "08012345678", "meter_number": null, "price": { "product_amount": "1896.00", "fee_amount": "0.00", "total_debit": "1896.00", "currency": "NGN", "basis": "fixed" }, "created_at": "2026-05-18T10:30:00+01:00", "updated_at": "2026-05-18T10:30:00+01:00" } } } ``` ### The `price` breakdown Same shape as the other product types. `basis` is `fixed` for cable TV. ## Successful Purchase Poll the transaction (or use webhooks) until `status` reaches `successful`. The completed purchase uses the same nested shape as the create response, with `status` updated: ```json { "status": { "code": 200, "message": "Purchase details retrieved" }, "data": { "id": "txn_abc123", "type": "transaction", "attributes": { "amount": "1896.0", "currency": "NGN", "status": "successful", "category": "purchase", "description": "Purchase of GOtv Smallie (Monthly)", "reference": "cable0order0001abcd", "external_reference": "1736234400E5F6G7", "product_type": "cable_tv", "phone_number": "08012345678", "meter_number": null, "price": { "product_amount": "1896.00", "fee_amount": "0.00", "total_debit": "1896.00", "currency": "NGN", "basis": "fixed" }, "created_at": "2026-05-18T10:30:00+01:00", "updated_at": "2026-05-18T10:30:05+01:00" } } } ``` The subscription is activated automatically. No token or manual action required. There is no separate completion timestamp: use `updated_at`. ## Complete Example ```javascript // Generate external reference: 10-digit timestamp + 6 alphanumeric function generateReference() { const timestamp = Math.floor(Date.now() / 1000); const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let suffix = ""; for (let i = 0; i < 6; i++) { suffix += chars.charAt(Math.floor(Math.random() * chars.length)); } return `${timestamp}${suffix}`; } async function subscribeCableTV(smartNumber, packageId, phoneNumber) { const API_KEY = "sk_live_your_secret_key"; const BASE_URL = "https://my.rizpay.app/api/partners/v1"; // Step 1: Find the package const productsRes = await fetch( `${BASE_URL}/products/cabletv?provider=GOTV`, { headers: { Authorization: `Bearer ${API_KEY}` } } ); const products = await productsRes.json(); const product = products.data.find((p) => p.id === packageId); if (!product) { throw new Error("Package not found"); } // Step 2: Verify decoder const verifyRes = await fetch(`${BASE_URL}/purchases/verify`, { method: "POST", headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ product_id: product.id, smart_number: smartNumber, }), }); const verification = await verifyRes.json(); if (!verification.data.verified) { throw new Error("Invalid decoder number"); } // Show customer details for confirmation console.log( `Subscribing ${verification.data.customer.customer_name} to ${product.attributes.package}` ); // Step 3: Make purchase const purchaseRes = await fetch(`${BASE_URL}/purchases`, { method: "POST", headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ product_id: product.id, smart_number: smartNumber, amount: product.attributes.price.amount, service: product.attributes.service, phone_number: phoneNumber, external_reference: generateReference(), }), }); return await purchaseRes.json(); } ``` ## Package Changes Customers can: - **Upgrade** - Subscribe to a higher package - **Downgrade** - Subscribe to a lower package (applies at renewal) - **Renew** - Extend their current package The verification response shows the current package, helping you guide customers. ## DStv Packages | Package | Typical Price | | ----------------- | ------------- | | DStv Premium | ~24,500 | | DStv Compact Plus | ~16,600 | | DStv Compact | ~10,500 | | DStv Confam | ~6,200 | | DStv Yanga | ~3,500 | | DStv Padi | ~2,500 | ## GOtv Packages | Package | Typical Price | | ------------ | ------------- | | GOtv Supa+ | ~9,600 | | GOtv Supa | ~6,400 | | GOtv Max | ~7,200 | | GOtv Jolli | ~4,850 | | GOtv Jinja | ~2,700 | | GOtv Smallie | ~1,575 | _Prices may vary. Always check the products endpoint for current pricing._ ## Required Scope Requires the `purchase_cable_tv` scope on your API key. ## Next Steps - [Electricity](/developers/docs/products/electricity) - Pay electricity bills - [Webhooks](/developers/docs/webhooks/overview) - Get notified when subscription activates --- ## Webhooks Overview Source: https://www.rizpay.app/developers/docs/webhooks/overview.md Webhooks let you receive real-time notifications when events happen in your account. Instead of polling the API for status updates, webhooks push data to your server automatically. ## Why Use Webhooks? | Without Webhooks | With Webhooks | | ---------------------- | ---------------------------- | | Poll API repeatedly | Receive instant updates | | Waste API requests | Only receive relevant events | | Delayed status updates | Real-time notifications | | Complex polling logic | Simple event handlers | ## How Webhooks Work 1. You create a webhook endpoint in your dashboard 2. You subscribe to specific events (or all events) 3. When an event occurs, we send an HTTP POST to your endpoint 4. Your server processes the payload and returns a 2xx response 5. If delivery fails, we retry automatically ## Quick Setup ### 1. Create an Endpoint Go to [Settings > Webhooks](https://my.rizpay.app/settings/webhooks) and create a new webhook: - **URL**: Your HTTPS endpoint (e.g., `https://yourapp.com/webhooks/rizpay`) - **Environment**: Production or Sandbox - **Events**: Select events or subscribe to all (`*`) ### 2. Handle the Webhook ```javascript const express = require("express"); const app = express(); app.post("/webhooks/rizpay", express.json(), (req, res) => { const event = req.body; console.log(`Received ${event.type} event`); switch (event.type) { case "transaction.successful": handleSuccessfulTransaction(event.data.object); break; case "transaction.failed": handleFailedTransaction(event.data.object); break; // Handle other events... } // Always return 200 to acknowledge receipt res.status(200).send("OK"); }); ``` ### 3. Verify the Signature See [Webhook Security](/developers/docs/webhooks/security) for signature verification. ## Webhook Payload Every webhook delivery includes: ```json { "id": "evt_abc123xyz", "type": "transaction.successful", "api_version": "1.0.0", "created_at": "2024-01-15T10:30:00Z", "data": { "object": { // Event-specific data } } } ``` | Field | Description | | ------------- | --------------------------------------------------------------------- | | `id` | Unique event identifier | | `type` | Event type (see [Events Reference](/developers/docs/webhooks/events)) | | `api_version` | API version that generated the event | | `created_at` | When the event occurred | | `data.object` | The object that triggered the event | ## Webhook Limits | Environment | Max Webhooks | | ----------- | ------------ | | Sandbox | 1 | | Production | 3 | ## Delivery Behavior ### Success We consider delivery successful when your endpoint returns a 2xx status code within 30 seconds. ### Retries If delivery fails (timeout or non-2xx response), we retry with increasing delays: | Attempt | Delay | | --------- | ---------- | | 1 | Immediate | | 2 | 1 minute | | 3 | 5 minutes | | 4 | 30 minutes | | 5 | 2 hours | | 6 (final) | 24 hours | ### Auto-Disable After 5 consecutive failures, the webhook is automatically disabled. You can re-enable it from the dashboard. ## Dashboard Features From [Settings > Webhooks](https://my.rizpay.app/settings/webhooks), you can: - **Create/Edit webhooks** - Configure URL and events - **Send test payloads** - Test your endpoint - **View delivery history** - See all deliveries with response details - **Enable/Disable** - Toggle webhooks without deleting ## Best Practices 1. **Return 200 quickly** - Process webhooks asynchronously if needed 2. **Verify signatures** - Always verify webhook authenticity 3. **Handle duplicates** - Use the event `id` for idempotency 4. **Log everything** - Keep records for debugging 5. **Use HTTPS** - Webhook URLs must be HTTPS ## Testing Webhooks ### Sandbox Webhooks Create a sandbox webhook to receive test events without affecting production. ### Test Payloads Use the "Send Test" button in the dashboard to send a sample payload to your endpoint. ### Local Development Use tools like [ngrok](https://ngrok.com) to expose your local server: ```bash ngrok http 3000 ``` ## Next Steps - [Events Reference](/developers/docs/webhooks/events) - All available event types - [Webhook Security](/developers/docs/webhooks/security) - Verify webhook signatures --- ## Events Reference Source: https://www.rizpay.app/developers/docs/webhooks/events.md Complete reference of all webhook events and their payloads. ## Event Types ### Transaction Events | Event | Description | | ------------------------ | -------------------------------------------- | | `transaction.pending` | Purchase initiated, processing with provider | | `transaction.successful` | Purchase completed successfully | | `transaction.failed` | Purchase failed | | `transaction.reversed` | Payment was reversed/refunded | ### Subscribe to All Use `*` to subscribe to all current and future event types. ## Event Envelope Every webhook body shares the same envelope: | Field | Description | | ------------- | ----------------------------------------------------- | | `id` | Unique event id (`evt_...`), also in `X-RizPay-Event-ID` | | `type` | The event type, e.g. `transaction.successful` | | `api_version` | Webhook payload version (currently `1.0.0`) | | `created_at` | ISO 8601 timestamp when the event was created | | `data.object` | The resource for this event (see payloads below) | The transaction object is a flat object (its fields are not nested under an `attributes` key). It carries two reference fields: `reference` is the value you supplied at purchase time (your external reference), and `rizpay_reference` is the RizPay-generated reference. Use `id` (or `X-RizPay-Event-ID`) for idempotency. ## Transaction Event Payloads ### transaction.pending Sent when a purchase is initiated: ```json { "id": "evt_abc123def456", "type": "transaction.pending", "api_version": "1.0.0", "created_at": "2024-01-15T10:30:00Z", "data": { "object": { "id": "txn_1042", "reference": "1736234400A1B2C3", "rizpay_reference": "9a8edccb078b0f91a713", "amount": "100.00", "currency": "NGN", "status": "pending", "category": "purchase", "description": "Purchase of MTN Airtime", "product_type": "airtime", "phone_number": "08012345678", "meter_number": null, "created_at": "2024-01-15T10:30:00Z", "updated_at": "2024-01-15T10:30:00Z" } } } ``` ### transaction.successful Sent when a purchase completes successfully: ```json { "id": "evt_def456ghi789", "type": "transaction.successful", "api_version": "1.0.0", "created_at": "2024-01-15T10:30:05Z", "data": { "object": { "id": "txn_1042", "reference": "1736234400A1B2C3", "rizpay_reference": "9a8edccb078b0f91a713", "amount": "100.00", "currency": "NGN", "status": "successful", "category": "purchase", "description": "Purchase of MTN Airtime", "product_type": "airtime", "phone_number": "08012345678", "meter_number": null, "created_at": "2024-01-15T10:30:00Z", "updated_at": "2024-01-15T10:30:05Z" } } } ``` For electricity purchases, the object also includes the vending `token` (present once the token is available): ```json { "data": { "object": { "id": "txn_1043", "reference": "1736234400D4E5F6", "rizpay_reference": "c74afbae9481256ab5b7", "amount": "5000.00", "currency": "NGN", "status": "successful", "category": "purchase", "description": "Purchase of Ikeja Electric (prepaid)", "product_type": "electricity", "phone_number": "08012345678", "meter_number": "12345678901", "token": "1234-5678-9012-3456-7890", "created_at": "2024-01-15T10:30:00Z", "updated_at": "2024-01-15T10:30:05Z" } } } ``` ### transaction.failed Sent when a purchase fails: A failed purchase carries the same transaction object with `status: "failed"`. The amount is refunded to your balance: ```json { "id": "evt_ghi789jkl012", "type": "transaction.failed", "api_version": "1.0.0", "created_at": "2024-01-15T10:30:10Z", "data": { "object": { "id": "txn_1042", "reference": "1736234400A1B2C3", "rizpay_reference": "9a8edccb078b0f91a713", "amount": "100.00", "currency": "NGN", "status": "failed", "category": "purchase", "description": "Purchase of MTN Airtime", "product_type": "airtime", "phone_number": "08012345678", "meter_number": null, "created_at": "2024-01-15T10:30:00Z", "updated_at": "2024-01-15T10:30:10Z" } } } ``` ### transaction.reversed Sent when a previously successful transaction is reversed. The object is the same transaction with `status: "reversed"`: ```json { "id": "evt_jkl012mno345", "type": "transaction.reversed", "api_version": "1.0.0", "created_at": "2024-01-15T12:00:00Z", "data": { "object": { "id": "txn_1042", "reference": "1736234400A1B2C3", "rizpay_reference": "9a8edccb078b0f91a713", "amount": "100.00", "currency": "NGN", "status": "reversed", "category": "purchase", "description": "Purchase of MTN Airtime", "product_type": "airtime", "phone_number": "08012345678", "meter_number": null, "created_at": "2024-01-15T10:30:00Z", "updated_at": "2024-01-15T12:00:00Z" } } } ``` ## Handling Events ```javascript app.post("/webhooks/rizpay", express.json(), async (req, res) => { const event = req.body; // Use event.id for idempotency if (await isEventProcessed(event.id)) { return res.status(200).send("Already processed"); } try { switch (event.type) { case "transaction.successful": await fulfillOrder(event.data.object); break; case "transaction.failed": await notifyCustomerOfFailure(event.data.object); break; case "transaction.reversed": await handleReversal(event.data.object); break; } await markEventProcessed(event.id); res.status(200).send("OK"); } catch (error) { console.error("Webhook processing error:", error); // Return 500 to trigger retry res.status(500).send("Processing failed"); } }); ``` ## Event Filtering When creating a webhook, you can subscribe to specific events: - Single event: `transaction.successful` - Multiple events: `transaction.successful`, `transaction.failed` - All events: `*` Use specific subscriptions to reduce noise and processing overhead. ## Next Steps - [Webhook Security](/developers/docs/webhooks/security) - Verify webhook signatures - [Webhooks Overview](/developers/docs/webhooks/overview) - Setup and configuration --- ## Webhook Security Source: https://www.rizpay.app/developers/docs/webhooks/security.md Verify webhook signatures to ensure requests are genuinely from RizPay and haven't been tampered with. ## Why Verify Signatures? Without verification, anyone who discovers your webhook URL could send fake events to your server. Signature verification ensures: - **Authenticity** - The request came from RizPay - **Integrity** - The payload hasn't been modified - **Protection** - Against replay attacks ## Signing Secret Each webhook has a unique signing secret (prefixed with `whsec_`). Find it in your webhook settings at [Settings > Webhooks](https://my.rizpay.app/settings/webhooks). **Keep this secret safe.** Never expose it in client-side code or public repositories. ## Webhook Headers Every webhook request includes these headers: | Header | Description | | -------------------- | ------------------------------------------------------- | | `X-RizPay-Signature` | HMAC-SHA256 signature, formatted `sha256=` | | `X-RizPay-Timestamp` | Unix timestamp (seconds) when the signature was created | | `X-RizPay-Event-ID` | Unique event id (also present as `id` in the body) | Example: ``` X-RizPay-Signature: sha256=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd X-RizPay-Timestamp: 1705312200 X-RizPay-Event-ID: evt_abc123def456 ``` The signature is an HMAC-SHA256 hex digest computed over the string `timestamp.rawBody` (the value of `X-RizPay-Timestamp`, a literal period, then the raw request body), keyed with your signing secret. The digest is prefixed with `sha256=` in the header. ## Verification Steps ### 1. Read the Timestamp and Signature The timestamp and signature arrive in separate headers. Strip the `sha256=` prefix from the signature to get the raw hex digest: ```javascript function readSignatureHeaders(headers) { const timestamp = headers["x-rizpay-timestamp"]; const signatureHeader = headers["x-rizpay-signature"]; // "sha256=" const signature = signatureHeader?.replace(/^sha256=/, ""); return { timestamp, signature }; } ``` ### 2. Prepare the Signed Payload Concatenate the timestamp and raw request body with a period: ```javascript const signedPayload = `${timestamp}.${rawBody}`; ``` ### 3. Compute Expected Signature ```javascript const crypto = require("crypto"); function computeSignature(signedPayload, secret) { return crypto .createHmac("sha256", secret) .update(signedPayload) .digest("hex"); } ``` ### 4. Compare Signatures Use constant-time comparison to prevent timing attacks: ```javascript const crypto = require("crypto"); function secureCompare(a, b) { return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); } ``` ### 5. Check Timestamp (Optional but Recommended) Reject requests older than 5 minutes to prevent replay attacks: ```javascript function isTimestampValid(timestamp, toleranceSeconds = 300) { const now = Math.floor(Date.now() / 1000); return Math.abs(now - parseInt(timestamp)) <= toleranceSeconds; } ``` ## Complete Verification Example ### Node.js / Express ```javascript const express = require("express"); const crypto = require("crypto"); const WEBHOOK_SECRET = "whsec_your_signing_secret"; function verifyWebhookSignature(req, secret) { const timestamp = req.headers["x-rizpay-timestamp"]; const signatureHeader = req.headers["x-rizpay-signature"]; if (!timestamp || !signatureHeader) { throw new Error("Missing signature headers"); } // Strip the "sha256=" prefix to get the raw hex digest const receivedSig = signatureHeader.replace(/^sha256=/, ""); // Check timestamp (5 minute tolerance) const now = Math.floor(Date.now() / 1000); if (Math.abs(now - parseInt(timestamp)) > 300) { throw new Error("Timestamp too old"); } // Compute expected signature const payload = `${timestamp}.${req.rawBody}`; const expectedSig = crypto .createHmac("sha256", secret) .update(payload) .digest("hex"); // Constant-time comparison if ( !crypto.timingSafeEqual(Buffer.from(receivedSig), Buffer.from(expectedSig)) ) { throw new Error("Signature mismatch"); } return true; } // Middleware to capture raw body app.use( "/webhooks/rizpay", express.json({ verify: (req, res, buf) => { req.rawBody = buf.toString(); }, }) ); app.post("/webhooks/rizpay", (req, res) => { try { verifyWebhookSignature(req, WEBHOOK_SECRET); } catch (error) { console.error("Signature verification failed:", error.message); return res.status(401).send("Invalid signature"); } // Process the verified webhook const event = req.body; console.log(`Verified event: ${event.type}`); res.status(200).send("OK"); }); ``` ### Python / Flask ```python import hmac import hashlib import time from flask import Flask, request, abort WEBHOOK_SECRET = 'whsec_your_signing_secret' def verify_signature(payload, signature_header, timestamp, secret): if not signature_header or not timestamp: raise ValueError('Missing signature headers') # Strip the "sha256=" prefix to get the raw hex digest received_sig = signature_header.removeprefix('sha256=') # Check timestamp (5 minute tolerance) if abs(time.time() - int(timestamp)) > 300: raise ValueError('Timestamp too old') # Compute expected signature signed_payload = f"{timestamp}.{payload}" expected_sig = hmac.new( secret.encode(), signed_payload.encode(), hashlib.sha256 ).hexdigest() # Constant-time comparison if not hmac.compare_digest(received_sig, expected_sig): raise ValueError('Signature mismatch') return True app = Flask(__name__) @app.route('/webhooks/rizpay', methods=['POST']) def webhook(): payload = request.get_data(as_text=True) signature = request.headers.get('X-RizPay-Signature') timestamp = request.headers.get('X-RizPay-Timestamp') try: verify_signature(payload, signature, timestamp, WEBHOOK_SECRET) except ValueError as e: print(f'Signature verification failed: {e}') abort(401) event = request.json print(f"Verified event: {event['type']}") return 'OK', 200 ``` ### Ruby / Rails ```ruby class WebhooksController < ApplicationController skip_before_action :verify_authenticity_token WEBHOOK_SECRET = 'whsec_your_signing_secret' def rizpay payload = request.raw_post signature = request.headers['X-RizPay-Signature'] timestamp = request.headers['X-RizPay-Timestamp'] unless verify_signature(payload, signature, timestamp, WEBHOOK_SECRET) head :unauthorized return end event = JSON.parse(payload) Rails.logger.info "Verified event: #{event['type']}" head :ok end private def verify_signature(payload, signature_header, timestamp, secret) return false unless signature_header && timestamp # Strip the "sha256=" prefix to get the raw hex digest received_sig = signature_header.delete_prefix('sha256=') # Check timestamp (5 minute tolerance) return false if (Time.now.to_i - timestamp.to_i).abs > 300 # Compute expected signature signed_payload = "#{timestamp}.#{payload}" expected_sig = OpenSSL::HMAC.hexdigest('sha256', secret, signed_payload) # Constant-time comparison ActiveSupport::SecurityUtils.secure_compare(received_sig, expected_sig) end end ``` ## Regenerating Secrets If your signing secret is compromised: 1. Go to [Settings > Webhooks](https://my.rizpay.app/settings/webhooks) 2. Click on your webhook 3. Click "Regenerate Secret" 4. Update your server with the new secret 5. The old secret is invalidated immediately ## Troubleshooting ### Signature Mismatch - Ensure you're using the raw request body, not parsed JSON - Check that you're using the correct signing secret - Confirm you sign `timestamp.rawBody` using the `X-RizPay-Timestamp` value - Remember to strip the `sha256=` prefix before comparing digests ### Timestamp Too Old - Check your server's clock synchronization - Increase timestamp tolerance if needed (but not too much) ### Missing Header - Ensure your server preserves both `X-RizPay-Signature` and `X-RizPay-Timestamp` - Check for proxy/load balancer stripping headers ## Next Steps - [Events Reference](/developers/docs/webhooks/events) - All webhook event types - [Webhooks Overview](/developers/docs/webhooks/overview) - Setup and configuration --- ## Balance & Transactions Source: https://www.rizpay.app/developers/docs/account/balance.md Monitor your account balance and review transaction history through the API. ## Check Balance Get your current account balance: ```bash curl -X GET \ -H "Authorization: Bearer sk_live_your_secret_key" \ https://my.rizpay.app/api/partners/v1/account/balance ``` Response: ```json { "status": { "code": 200, "message": "Balance retrieved" }, "data": { "balance": "50000.00", "currency": "NGN", "updated_at": "2026-05-18T10:30:00+01:00" } } ``` | Field | Description | | ------------ | ------------------------- | | `balance` | Current available balance | | `currency` | Currency code (NGN) | | `updated_at` | Last balance update time | **Required Scope:** `read_balance` ## List Transactions Get your transaction history with optional filters: ```bash curl -X GET \ -H "Authorization: Bearer sk_live_your_secret_key" \ "https://my.rizpay.app/api/partners/v1/account/transactions?status=successful&per_page=50" ``` Response: ```json { "status": { "code": 200, "message": "Transactions retrieved" }, "data": [ { "id": "txn_abc123", "type": "transaction", "attributes": { "amount": "500.0", "currency": "NGN", "status": "successful", "category": "purchase", "description": "Purchase of MTN Airtime", "reference": "a1b2c3d4e5f607181736", "external_reference": "1736234400A1B2C3", "product_type": "airtime", "phone_number": "08012345678", "meter_number": null, "price": { "product_amount": "500.00", "fee_amount": "0.00", "total_debit": "500.00", "currency": "NGN", "basis": "face_value" }, "created_at": "2026-05-18T10:30:00+01:00", "updated_at": "2026-05-18T10:30:02+01:00" } } ], "pagination": { "page": 1, "per_page": 50, "total_pages": 10, "total_count": 487 } } ``` Each transaction uses the same nested `data[].attributes` shape as a purchase. Funding, commission, and transfer rows omit `product_type`, `phone_number`, `meter_number`, and (for non-purchases) the `price` block. There is no separate completion timestamp: use `updated_at`. **Required Scope:** `read_transactions` ### Query Parameters | Parameter | Type | Description | | ----------- | ------- | ------------------------------------------------------------------------------------------ | | `page` | integer | Page number (default: 1) | | `per_page` | integer | Items per page (max: 100, default: 20) | | `status` | string | Filter by status: `pending`, `successful`, `failed`, `reversed` | | `category` | string | Filter by category: `funding`, `purchase`, `withdrawal`, `transfer`, `commission`, `promo` | | `from` | ISO8601 | Start date (e.g., `2024-01-15T00:00:00Z`) | | `to` | ISO8601 | End date (e.g., `2024-01-31T23:59:59Z`). Must be after `from` | | `reference` | string | Filter by external_reference | ### Filter by Status ```bash # Successful transactions only GET /account/transactions?status=successful GET /account/transactions?status=pending GET /account/transactions?status=failed ``` ### Filter by Category ```bash # Purchase transactions only GET /account/transactions?category=purchase # Funding transactions only GET /account/transactions?category=funding # Withdrawal transactions only GET /account/transactions?category=withdrawal ``` ### Filter by Date Range ```bash # Transactions from January 2024 GET /account/transactions?from=2024-01-01T00:00:00Z&to=2024-01-31T23:59:59Z ``` ### Find by Reference ```bash # Find transaction by your reference GET /account/transactions?reference=1736234400A1B2C3 ``` ### Validation Errors Invalid filter parameters return a `400 Bad Request` with details: ```json { "status": { "code": 400, "message": "Bad Request" }, "error": { "code": "VALIDATION_ERROR", "message": "Invalid status 'invalid'. Valid values: pending, successful, failed, reversed" } } ``` Common validation errors: | Error | Cause | | ------------------------------------ | --------------------------------------------------------------------------------------- | | Invalid status | Status not in: `pending`, `successful`, `failed`, `reversed` | | Invalid category | Category not in: `funding`, `purchase`, `withdrawal`, `transfer`, `commission`, `promo` | | Invalid 'from' date format | Date not in ISO8601 format | | Invalid 'to' date format | Date not in ISO8601 format | | 'from' date must be before 'to' date | `from` is after `to` | ## Get Transaction Details Get details for a specific transaction by ID or external_reference: ```bash # By transaction ID curl -X GET \ -H "Authorization: Bearer sk_live_your_secret_key" \ https://my.rizpay.app/api/partners/v1/account/transactions/txn_abc123 # By external_reference (16 chars: 10 digits + 6 alphanumeric) curl -X GET \ -H "Authorization: Bearer sk_live_your_secret_key" \ https://my.rizpay.app/api/partners/v1/account/transactions/1736234400A1B2C3 ``` Response: ```json { "status": { "code": 200, "message": "Transaction details retrieved" }, "data": { "id": "txn_abc123", "type": "transaction", "attributes": { "amount": "500.0", "currency": "NGN", "status": "successful", "category": "purchase", "description": "Purchase of MTN Airtime", "reference": "a1b2c3d4e5f607181736", "external_reference": "1736234400A1B2C3", "product_type": "airtime", "phone_number": "08012345678", "meter_number": null, "price": { "product_amount": "500.00", "fee_amount": "0.00", "total_debit": "500.00", "currency": "NGN", "basis": "face_value" }, "created_at": "2026-05-18T10:30:00+01:00", "updated_at": "2026-05-18T10:30:02+01:00" } } } ``` ## Transaction Statuses | Status | Description | | ------------ | ------------------------------- | | `pending` | Processing with provider | | `successful` | Completed successfully | | `failed` | Failed (balance refunded) | | `reversed` | Reversed/refunded after success | ## Requery Transaction For pending transactions, you can trigger a status requery from the provider: ```bash curl -X POST \ -H "Authorization: Bearer sk_live_your_secret_key" \ https://my.rizpay.app/api/partners/v1/purchases/txn_abc123/query ``` Response: ```json { "status": { "code": 200, "message": "Query initiated" }, "data": { "id": "txn_abc123", "status": "pending", "message": "Transaction status will be updated shortly" } } ``` **Note:** Requery is only available for pending transactions. The actual status update happens asynchronously - use webhooks to get notified. ## Low Balance Alerts Configure low balance alerts in your account settings: 1. Go to [Settings > API](https://my.rizpay.app/settings/api) 2. Enable "Low Balance Alerts" 3. Set your threshold amount When your balance drops below the threshold: - You'll receive an email notification - A `account.low_balance` webhook is triggered (if subscribed) ## Complete Example ```javascript async function getAccountSummary(apiKey) { const BASE_URL = "https://my.rizpay.app/api/partners/v1"; const headers = { Authorization: `Bearer ${apiKey}` }; // Get balance const balanceRes = await fetch(`${BASE_URL}/account/balance`, { headers }); const balance = await balanceRes.json(); // Get recent transactions const txnRes = await fetch(`${BASE_URL}/account/transactions?per_page=10`, { headers, }); const transactions = await txnRes.json(); // Get today's stats const today = new Date().toISOString().split("T")[0]; const statsRes = await fetch( `${BASE_URL}/account/transactions?from=${today}T00:00:00Z&status=successful`, { headers } ); const todayTxns = await statsRes.json(); return { currentBalance: balance.data.balance, recentTransactions: transactions.data, todayCount: todayTxns.pagination.total_count, todayVolume: todayTxns.data.reduce( (sum, txn) => sum + parseFloat(txn.amount), 0 ), }; } ``` ## Next Steps - [Pagination](/developers/docs/core-concepts/pagination) - Navigate large result sets - [Webhooks](/developers/docs/webhooks/overview) - Get real-time transaction updates --- ## Error Code Registry Source: https://www.rizpay.app/developers/docs/reference/error-codes.md {/* GENERATED FILE - DO NOT EDIT BY HAND. */} {/* Regenerate with: yarn docs:errors (from landing/). */} {/* Source: backend/app/controllers/concerns/error_codes.rb and backend/config/locales/en.yml */} {/* Scoped to the Partner-API-facing subset via PARTNER_FACING in the generator. */} These are the error codes the RizPay Partner API can return, grouped by the HTTP status they accompany. A few Partner API codes are returned as literal strings and are documented in [Error Handling](/developers/docs/getting-started/errors): `AUTHENTICATION_REQUIRED`, `ENVIRONMENT_MISMATCH`, `PURCHASE_NOT_FOUND`, and `INVALID_STATUS`. For the Partner API response format and the codes you are most likely to encounter when integrating, start with [Error Handling](/developers/docs/getting-started/errors). Each code is a stable, machine-readable identifier returned in `error.code`. Match on the code, never on the message text: messages can change, codes do not. ## How to read this page - **Code**: the machine-readable identifier returned in `error.code`. - **Description**: what triggers the error. - **User-facing message**: the message shown to end users where one is mapped. Codes without a mapped message return a context-specific message. - The HTTP status in each section heading is the `status.code` the API returns for codes in that section. ## Authentication Errors (HTTP 401) | Code | Description | User-facing message | | --------------------- | -------------------------------------------------- | ----------------------------------------------------------------- | | `AUTH_REQUIRED` | No authentication token provided | Please sign in to continue. | | `INVALID_CREDENTIALS` | Wrong email/password combination | The email or password you entered is incorrect. Please try again. | | `TOKEN_EXPIRED` | JWT token has expired | - | | `TOKEN_REVOKED` | Token has been revoked (user logged out elsewhere) | - | ## Authorization Errors (HTTP 403) | Code | Description | User-facing message | | -------------------- | ------------------------------- | ------------------------------------------------- | | `FORBIDDEN` | General authorization failure | You don't have permission to perform this action. | | `INSUFFICIENT_SCOPE` | API client lacks required scope | - | | `API_DISABLED` | Account API access turned off | - | | `IP_NOT_ALLOWED` | IP not in whitelist | - | ## Validation Errors (HTTP 400) | Code | Description | User-facing message | | ---------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | `VALIDATION_ERROR` | General validation failure | Please check your input and try again. | | `MISSING_PARAMETER` | Required field not provided | Some required information is missing. Please provide all required details and try again. | | `INVALID_PARAMETER` | Field value fails validation | - | | `INVALID_PHONE_NUMBER` | Initiation called with a missing or malformed phone number | That doesn't look like a valid phone number. Please check it and try again. | | `INVALID_AMOUNT` | Invalid amount value | - | | `DUPLICATE_REFERENCE` | Duplicate transaction reference | - | ## Resource Not Found Errors (HTTP 404) | Code | Description | User-facing message | | ----------------------- | --------------------------------- | ------------------------------------------------------------------- | | `NOT_FOUND` | Generic not found | - | | `TRANSACTION_NOT_FOUND` | Transaction doesn't exist | This transaction could not be found. | | `PRODUCT_NOT_FOUND` | Product doesn't exist or inactive | This product is no longer available. Please select a different one. | ## Rate Limiting Errors (HTTP 429) | Code | Description | User-facing message | | --------------------- | ---------------------- | --------------------------------------------------------------------------------- | | `RATE_LIMIT_EXCEEDED` | General rate limit hit | For your security, we've paused new attempts. Please wait a moment and try again. | ## Financial Transaction Errors (HTTP 400) | Code | Description | User-facing message | | ---------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `INSUFFICIENT_BALANCE` | Account balance too low | You don't have enough funds. Please top up your wallet and try again. | | `TRANSACTION_FAILED` | General transaction failure | We couldn't complete your transaction. Please try again, and contact support if you were charged. | | `PURCHASE_FAILED` | Purchase processing failed | - | | `PRODUCT_UNAVAILABLE` | Product or provider down. | This product is currently unavailable. Please try again later or choose a different option. | | `PROVIDER_PRICE_CHANGED` | Provider price has changed since product was last synced - user must retry | This product's price has been updated. Please go back and try again. | | `PROVIDER_ERROR` | External provider failure | - | | `TRANSACTION_LIMIT_EXCEEDED` | Amount over limit | - | | `INVALID_STATUS` | Wrong transaction status for action | - | ## Server Errors (HTTP 500) | Code | Description | User-facing message | | --------------------- | --------------------- | --------------------------------------------------------------------------------------------- | | `INTERNAL_ERROR` | Unhandled exception | Something went wrong on our end. Please try again. If the problem continues, contact support. | | `SERVICE_UNAVAILABLE` | External service down | This service is no longer available. Please choose a different option. | ## Next Steps - [Error Handling](/developers/docs/getting-started/errors) - Partner API response format and handling patterns - [Sandbox Testing](/developers/docs/core-concepts/sandbox) - Trigger errors safely while testing - [Rate Limiting](/developers/docs/core-concepts/rate-limiting) - Understand 429 responses --- # RizPay Storefront Partner Docs --- ## Introduction Source: https://www.rizpay.app/storefront/docs/overview/introduction.md # Storefront Partner Program The RizPay Storefront Program gives you a fully branded bill payment store at your own subdomain on `payfront.store`. Your customers create accounts, fund their wallets, and pay their bills through your store. You set the prices. We handle everything else. ## How it differs from the API program | | API Program | Storefront Program | | -------------------- | ------------------ | --------------------------------- | | User accounts | You manage | RizPay manages | | User funding | You arrange | RizPay handles (virtual accounts) | | Integration required | Yes (REST API) | No | | Price control | Via your app logic | Via pricing rules dashboard | | Your wallet required | Yes | No | | Go-live time | Days to weeks | After approval | ## What you get - A branded storefront at your own subdomain on `payfront.store` (e.g. `yourbrand.payfront.store`) - Configurable template, colors, logo, and hero text - Pricing rules: set markups globally, per network, per product type, or per product - Transaction dashboard: see every purchase your customers make and your earnings per transaction - SEO configuration: custom title, description, and OG image per storefront - Each storefront has its own `sitemap.xml` and `robots.txt` automatically ## Who manages what **You manage:** - Your storefront brand and configuration - Your pricing rules and markup strategy - Your relationship with your customers **RizPay manages:** - User account creation and verification - Wallet funding (virtual accounts, bank transfers) - Payment processing and settlement to providers - Calculating and crediting your margin per transaction - Uptime, security, and compliance --- ## Request Access Source: https://www.rizpay.app/storefront/docs/getting-started/request-access.md # Request Access Storefront access is by application. Here is how to apply: 1. Sign in to [my.rizpay.app](https://my.rizpay.app) 2. Navigate to **Storefront** in the sidebar 3. Click **New Storefront** and fill in your application: - **Subdomain**: your preferred subdomain handle. Must be 3-30 characters, lowercase letters, numbers, and hyphens only. Your storefront will be at `yourhandle.payfront.store`. - **Display name**: the name shown to your customers on the storefront - **Template**: choose from available storefront templates - **Contact email**: for communications from the RizPay team 4. Submit your application Applications are reviewed by the RizPay team. You will receive an email when your storefront is approved or if we need more information. ## What happens after approval Once approved, you can: - Configure your pricing rules - Customize your branding (colors, logo, hero text, SEO settings) - Share your storefront URL with your customers ## Subdomain rules - Minimum 3 characters, maximum 30 - Lowercase letters, numbers, and hyphens only - Cannot start or end with a hyphen - Must be unique across all RizPay storefronts - Some names are reserved and unavailable --- ## Your Storefront URL Source: https://www.rizpay.app/storefront/docs/getting-started/your-storefront.md # Your Storefront URL Once your application is approved, the RizPay team will confirm your storefront URL. It is based on the subdomain you chose during the application and will be on the `payfront.store` domain (e.g. `yourbrand.payfront.store`). ## What your customers see Your storefront is a full bill payment web app under your brand. Customers can: - Register and verify their email - Fund their wallet - Buy airtime, data plans, electricity, and cable TV - View their transaction history and wallet balance Your storefront reflects your configured template, colors, display name, and SEO settings. ## Going live Your storefront has a launch date. Until it is set by the RizPay team, your store is in setup mode and not publicly indexed. Configure your pricing rules and branding first, then let the team know you are ready to launch. ## Your sitemap and robots.txt Every storefront automatically gets a `sitemap.xml` and `robots.txt` at your storefront URL. Private pages (login, register, dashboard, account) are excluded from the sitemap and marked `noindex` in robots.txt automatically. Public category pages (airtime, data, electricity, cable TV) are indexed. --- ## Pricing Rules Source: https://www.rizpay.app/storefront/docs/configuration/pricing-rules.md # Pricing Rules Pricing rules let you set a markup above the RizPay base rate. Your customers pay the base rate plus your markup. You earn the markup on every transaction. Navigate to **Storefront > Pricing Rules** in [my.rizpay.app](https://my.rizpay.app) to manage your rules. You must have an approved storefront to access this section. ## Scope levels Rules apply at four scope levels. More specific rules take precedence over broader ones. | Scope | Example | When to use | | ---------------- | -------------------- | --------------------------------- | | **Global** | All products | One uniform markup for everything | | **Network** | MTN only | Different rate per mobile network | | **Product type** | All airtime | Different rate per category | | **Product** | A specific data plan | Per-product precision pricing | A product-level rule overrides a global rule for that product. A network-level rule overrides a global rule for all products on that network. ## Markup types **Percentage:** `markup_value` is a percentage added to the base price. - Example: `markup_value: 2` means the customer pays 2% more than the base rate. **Flat (Naira):** `markup_value` is a fixed Naira amount added to the base price. - Example: `markup_value: 50` means the customer pays 50 Naira more than the base rate, regardless of transaction size. ## Min and max markup For percentage rules, you can optionally set minimum and maximum Naira caps: - `min_markup`: the minimum Naira amount charged regardless of percentage result - `max_markup`: the maximum Naira amount charged regardless of percentage result **Example:** A global rule of 2% with a 200 Naira max: - A 1,000 Naira airtime purchase: customer pays 1,020 Naira (2% = 20 Naira, below the cap). You earn 20 Naira. - A 20,000 Naira electricity token: customer pays 20,200 Naira (2% would be 400 Naira, capped at 200). You earn 200 Naira. ## Managing rules - You can have one active rule per scope combination at a time - Deleting a rule deactivates it (soft delete - it is not permanently removed) - New rules take effect immediately on your live storefront --- ## Branding Source: https://www.rizpay.app/storefront/docs/configuration/branding.md # Branding Customize how your storefront looks and what it says. All configuration is in **Storefront > Settings** on [my.rizpay.app](https://my.rizpay.app). ## Template Choose from available storefront templates. Each template is a different layout and visual style. Templates share the same functionality - they differ in appearance only. ## Brand colors Set your primary and secondary colors as hex codes (e.g. `#1a73e8`). These are applied throughout your storefront. ## Hero text Customize what your customers see on the storefront home page: | Field | Default | Description | | ---------------- | ------------------------------------- | -------------------------------------- | | Hero title | "Pay your bills simply" | Main heading on your storefront home | | Hero subtitle | "Airtime, data, electricity and more" | Subheading | | Primary button | "Get started" | Primary CTA button text | | Secondary button | "Sign in" | Secondary CTA button text | | Description | (none) | Short description shown below the hero | ## SEO configuration | Field | Description | | -------------- | --------------------------------------------------------- | | Title | The `` tag for your storefront home page | | Description | Meta description for search engines | | Keywords | Comma-separated keywords | | OG title | Open Graph title for social sharing | | OG description | Open Graph description | | OG image URL | URL to your social sharing image (1200x630px recommended) | | Favicon URL | URL to your favicon | | Twitter handle | Your Twitter/X handle (e.g. `@yourbrand`) | ## Per-category SEO SEO title and description can also be set individually for each product category (airtime, data, electricity, cable TV). This lets each category page have its own relevant meta tags. --- ## How Payments Work Source: https://www.rizpay.app/storefront/docs/understanding/how-payments-work.md # How Payments Work Here is what happens when a customer makes a purchase on your storefront. ## Customer lifecycle 1. Customer visits your storefront URL 2. Customer registers with their email and verifies it with a one-time code 3. Customer funds their wallet via bank transfer to a virtual account assigned to them 4. Customer selects a product category (airtime, data, electricity, or cable TV), picks a product, and completes the purchase 5. RizPay debits their wallet, processes the purchase with the provider, and delivers the service instantly 6. Your markup is calculated and credited to your account ## Your role in this flow You do not touch any of this. You configured the storefront and set your pricing rules before going live. Every step above is handled entirely by RizPay. ## How the customer price is calculated When a customer buys a product, the price they pay is: ``` customer price = base rate + your markup (subject to min_markup and max_markup if set) ``` The most specific pricing rule that matches the product is used. A per-product rule takes precedence over a per-network rule, which takes precedence over a per-category rule, which takes precedence over a global rule. ## Failed transactions If a purchase fails after the customer's wallet is debited, RizPay initiates a reversal and the customer's wallet is refunded automatically. You are not charged and your margin is not credited for failed or reversed transactions. --- ## Transactions and Earnings Source: https://www.rizpay.app/storefront/docs/understanding/transactions-and-earnings.md # Transactions and Earnings ## Viewing transactions Navigate to **Storefront > Transactions** on [my.rizpay.app](https://my.rizpay.app) to see all purchases made through your storefront. Each transaction shows: - Product purchased and provider - Amount the customer paid - Base rate amount - Your margin earned on that transaction - Transaction status and timestamp ## How your earnings are calculated For each successful transaction: ``` your margin = customer price - base rate ``` Where `customer price = base rate + your markup rule` (subject to any min/max caps you set). ## Earnings are credited per transaction Your margin is credited to your account for each successful transaction. There is no batching or delay - each purchase that completes successfully contributes to your account balance. ## Failed and reversed transactions Your margin is credited only for successful transactions. If a transaction fails or is reversed after a credit was applied, the credited margin is reversed as well. ## Settlement Earnings accumulate in your account balance. Contact the RizPay team for information on payout schedules and methods.