This is the abridged developer documentation for Pesepay # Accept payments across Zimbabwe > EcoCash, InnBucks, Omari, Zimswitch, PayGo and cards — one encrypted API, one integration. Start building ## Everything you need to take your first payment Four places to start, depending on how you like to learn. ### [Quickstart](/getting-started/quickstart/) Zero to your first sandbox payment in about five minutes, with copy-paste code in five languages. Start the quickstart ### [Payment methods](/payment-methods/overview/) One page per channel, each with its required fields, currencies and test credentials. Browse methods ### [API reference](/api/introduction/) Every endpoint, every field, with request and response examples you can copy straight into your codebase. Read the reference ### [Webhooks](/webhooks/result-callback/) Get notified the moment a payment completes, instead of polling for status. Set up webhooks Integration styles ## Two ways to integrate Both return a reference number you can track, and both can notify your server automatically. ### [Redirect (checkout) flow](/payments/redirect-flow/) Initiate a transaction, send your customer to the Pesepay-hosted payment page, and get notified when they are done. The fastest way to start — and no PCI scope on your servers. 1. 1 Initiate 2. 2 Pesepay checkout 3. 3 Result callback Learn the redirect flow ### [Seamless (direct) flow](/payments/seamless-flow/) Collect the payment method and its required fields in your own UI and submit them straight to Pesepay. Full control over the customer experience, more UI to build. 1. 1 Collect details 2. 2 Make payment 3. 3 Result callback Learn the seamless flow Coverage ## Every major Zimbabwean channel Supported in US dollars and Zimbabwe dollars. One integration covers all of them. * [![](/_astro/ecocash.CYmcFJHh_1r1B2W.webp) EcoCash Mobile money](/payment-methods/ecocash/) * [![](/_astro/innbucks.BnD3fFA1_15Xpid.webp) InnBucks Mobile money](/payment-methods/innbucks/) * [![](/_astro/omari.CVmUQTlQ_2sYy8H.webp) Omari Mobile money](/payment-methods/omari/) * [![](/_astro/zimswitch.BLZ5PReE_ZfB57r.webp) Zimswitch Bank card](/payment-methods/zimswitch/) * [![](/_astro/visa.DdC_AMRl_2VUqw.webp) ![](/_astro/mastercard.1xTiBuk6_18Rrwh.webp) Visa / Mastercard Card](/payment-methods/card-payments/) * [![](/_astro/paygo.Cj9LHng7_trfkN.webp) PayGo QR wallet](/payment-methods/paygo/) Libraries ## Official SDKs Native clients that handle encryption, request signing and response decryption for you. * ![](/_astro/nodejs.DiEzk2oT_Z1z4nbv.svg) Node.js [npm](https://www.npmjs.com/package/pesepay) `npm install pesepay` * ![](/_astro/python.CHbrEgUk_Z1z4nbv.svg) Python [PyPI](https://pypi.org/project/pesepay/) `pip install pesepay` * ![](/_astro/php.C_tsUqeZ_Z1z4nbv.svg) PHP [Packagist](https://packagist.org/packages/codevirtus/pesepay) `composer require codevirtus/pesepay` * ![](/_astro/java.D14CAP56_Z1z4nbv.svg) Java [Maven Central](https://central.sonatype.com/artifact/com.pesepay/pesepay) `com.pesepay:pesepay` Tools ## Build faster, with or without an assistant Machine-readable docs for AI coding tools, and a spec you can generate clients from. ### [Building with AI tools](/sdks/ai-tools/) Point Cursor, Copilot or Claude at machine-readable versions of this site, and use a prompt that stops them inventing Pesepay endpoints and fields. Set up your assistant ### [OpenAPI spec & Postman](/api/openapi/) An OpenAPI 3.1 description of every endpoint, plus a ready-made Postman collection for poking at the API by hand. Get the spec ## Ready to take your first payment? Sandbox keys are free and take a couple of minutes to set up. Nothing to sign before you start building. [Create a sandbox account](/getting-started/onboarding/) [Run the quickstart](/getting-started/quickstart/) # Go-live checklist > What to verify before switching an application from sandbox to production credentials. Work through this before you point an application at production. ## Credentials and environment [Section titled “Credentials and environment”](#credentials-and-environment) * [ ] Your Pesepay merchant account has been approved (see [Onboarding](/getting-started/onboarding/)) * [ ] You’ve swapped sandbox base URLs (`api.test.sandbox.pesepay.com`) for production (`api.pesepay.com`) — see [API Introduction](/api/introduction/) * [ ] Your production **integration key** and **encryption key** are stored as server-side secrets, never in client code — see [API Keys & Credentials](/security/api-keys/) * [ ] Sandbox keys are gone from the production build entirely, not just unused * [ ] Your [payout account](/getting-started/onboarding/#3-payout-accounts) is configured and approved, for **every currency** you charge in ## Getting paid reliably [Section titled “Getting paid reliably”](#getting-paid-reliably) * [ ] `resultUrl` is a publicly reachable HTTPS endpoint on your server that handles the [result callback](/webhooks/result-callback/) * [ ] Your handler **verifies before fulfilling** — it calls [Check Payment Status](/api/check-payment-status/) rather than trusting the callback body ([why](/webhooks/verifying-callbacks/)) * [ ] Your handler is **idempotent**, keyed on `referenceNumber` * [ ] You run a **reconciliation job** for orders whose callback never arrived — [callbacks are never retried](/webhooks/retries/) * [ ] Nothing marks an order paid on `returnUrl` alone ## Payments and amounts [Section titled “Payments and amounts”](#payments-and-amounts) * [ ] You’ve tested at least one full payment per method you plan to support, using real small-value transactions * [ ] You’ve tested a **failed** payment per method, not just successes * [ ] You have made a small-value production payment for every method the [sandbox can’t exercise](/testing/sandbox-environment/) — InnBucks, Omari, Zimswitch, PayGo, and any Zimbabwe dollar checkout * [ ] Your checkout validates amounts against each method’s limits — they differ per method and currency, and [Get Payment Methods by Currency](/api/get-payment-methods-by-currency/) returns the live values * [ ] If you accept EcoCash, you handle [payments above $500](/payment-methods/ecocash/#payments-above-the-limit) — the customer sees several prompts, and a partial failure reverses the whole payment * [ ] Your error handling covers the statuses in the [error catalog](/api/errors/) and the non-success [transaction statuses](/resources/transaction-statuses/), not just the happy path ## If you take cards [Section titled “If you take cards”](#if-you-take-cards) * [ ] You are sending customers to the hosted page for card entry — card payments are [redirect-only](/payment-methods/card-payments/), so no card data should ever reach your servers # Onboarding > Create your Pesepay account, add an application, and find your integration and encryption keys. Set up your account once, then create an **application** per website or app you want to accept payments on — each application gets its own integration key and encryption key. ## 1. Create your account [Section titled “1. Create your account”](#1-create-your-account) 1. [Register for a Pesepay account](http://dashboard.pesepay.com/#/auth/register). 2. Check your email for your username and a system-generated password, then log in and change your password. 3. Open **My Business** in the left-hand menu and choose how you’re registering — **Personal** (individual or sole trader) or **Corporate** (registered company) — then click **Get Started**. 4. Fill in your **Business Profile** and click **Submit**. The documents are uploaded in the same form, alongside your business details, so have them ready before you start: | Registering as | Uploads the form requires | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Personal** | **Identification Document** (government-issued national ID or passport) and **Proof of Address** | | **Corporate** | **CR14 / Deed of Trust**, **Certificate of Incorporation**, and — for each business director — their email, phone number, full name and **Proof of Identification** | Bank account proof isn’t part of this form — it’s uploaded per application when you add a [payout account](#3-payout-accounts). 5. Sign the **Merchant Agreement**. Once your profile is saved, **My Business** shows a link to download the agreement: download it, sign it, upload it under **Upload Signed Merchant Agreement Contract**, and click **Submit**. This sends your account for review. ## 2. Add your first application [Section titled “2. Add your first application”](#2-add-your-first-application) 1. In the Merchant Control Panel, open **Applications** in the left-hand menu. 2. Click **Create** and fill in the application details — this can represent a website, a mobile app, or any product surface you’re integrating. 3. If you want a custom payment page instead of the default Pesepay-hosted one, opt in and provide its URL. 4. Submit. Your application now appears in the applications list. ### Find your keys [Section titled “Find your keys”](#find-your-keys) In the applications list, click **View Details** on the application. The **Application Details** tab shows both keys: | Key | Used for | | ------------------- | ---------------------------------------------------------------------------------------- | | **Integration key** | The `authorization` header on every API request | | **Encryption key** | Encrypting requests and decrypting responses ([Encryption Guide](/security/encryption/)) | The other tabs on that page are **Payout Accounts (Indirect Settlement)**, **Payout Accounts (Direct Settlement)**, **Transactional Fees**, **Invoices** and **Tracking Orders**. Caution Each application’s keys belong to **one environment**. Sandbox keys work only against the sandbox URLs and live keys only against the production URLs — see [Sandbox vs live keys](/security/api-keys/#sandbox-vs-live-keys) for what mixing them looks like when it goes wrong. Caution Treat both keys like passwords. Never call the Pesepay API from client-side/browser code — see [API Keys & Credentials](/security/api-keys/). ## 3. Payout accounts [Section titled “3. Payout accounts”](#3-payout-accounts) Settlement happens **T+2 days** from the day a customer completes a transaction, once the minimum amount defined in your Merchant Agreement is reached. Payout accounts are configured **per application**, and any change (adding or editing an account) requires Pesepay approval — while a request is pending, new sales continue to process but settlement to that account is on hold. To add one: **Applications → View Details → Payout Accounts (Indirect Settlement) → Create**, then provide the account name, account number, settlement notification email address, currency, bank, and an uploaded bank statement. ## 4. Give your team access [Section titled “4. Give your team access”](#4-give-your-team-access) Other people in your organisation get into the Merchant Control Panel through **groups**. A group is a set of users with the same role — Accounts Clerks, Supervisors, Managers — and permissions are granted to the group, not to individuals. A user takes on their group’s permissions the moment they’re added to it. 1. **Create the groups.** **User Manager → Groups → Create Group**, give it a name and description, and submit. 2. **Assign permissions.** On the group’s ⋮ menu choose **View Group Permissions**. The screen has two lists — **Unassigned Permissions** on the left, **Permissions to Assign** on the right. Select the permissions the role needs and use **Assign Selected** (or **Assign All**) to move them across; **Remove Selected** / **Remove All** takes them back. You can come back and change this at any time. 3. **Create the users.** **User Manager → Users**, then add each person — initials, first and last name, username, phone number and email — and put them in a group. They receive login credentials by email and inherit that group’s permissions automatically. The users list shows each person’s username, email, group and status (**Active**, **Inactive** or **Account Locked**), and a user can be moved to a different group later — their permissions change with them, immediately. Group the permissions by what the role actually does. A common starting set is one group per function — administration, development, finance, support — so that, for example, a developer can read transactions and manage applications without being able to change payout accounts. ## 5. Working in the dashboard [Section titled “5. Working in the dashboard”](#5-working-in-the-dashboard) Once you’re live, day-to-day operations live under **Payments** in the left-hand menu, per application (use the application dropdown to switch): * **Payments → Transactions** — every payment for the selected application, with its status, reference number, amount and date. This is where you reconcile against your own records — see [Checking payment status](/payments/checking-status/) for doing the same thing programmatically. * **Payments → Invoices** — generate an invoice for an application and email it to a customer; they pay it through the same Pesepay flow. * **Payments → Payouts** — track settlements and their status against each payout account (see [section 3](#3-payout-accounts)). **Sandbox → Payments / Transactions** is the same pair of screens against the [sandbox environment](/testing/sandbox-environment/). ## Next step [Section titled “Next step”](#next-step) You have your keys — send your [first sandbox payment](/getting-started/quickstart/). # Overview > What Pesepay is, and the two ways to integrate payments into your product. Pesepay is a payment gateway for Zimbabwe. One integration gives your application access to EcoCash, InnBucks, Omari, Zimswitch, PayGo and Visa/Mastercard, in US dollars and Zimbabwe dollars. Every request and response that carries payment details is encrypted with AES-256-CBC using a key unique to your application. See the [Encryption Guide](/security/encryption/) for the full spec. ## The two integration styles [Section titled “The two integration styles”](#the-two-integration-styles) ### [Redirect (checkout) flow](/payments/redirect-flow/) You **initiate** a transaction and redirect the customer to a hosted Pesepay payment page, where they choose a method and pay. Simplest to build — Pesepay’s page handles the payment method UI for you. 1. 1 Initiate 2. 2 Pesepay checkout 3. 3 Result callback Redirect flow guide ### [Seamless (direct) flow](/payments/seamless-flow/) You collect the payment method and its required fields (for example a phone number for EcoCash) in your own UI and call **make payment** directly. More control, more UI to build. 1. 1 Collect details 2. 2 Make payment 3. 3 Result callback Seamless flow guide Both flows return a `referenceNumber` you use to check status, and both can notify your server automatically via the [result callback](/webhooks/result-callback/) instead of you having to poll. ## Where to go next [Section titled “Where to go next”](#where-to-go-next) 1 ### [Create a sandbox account](/getting-started/onboarding/) Set up an account and locate your integration key and encryption key. Find your keys 2 ### [Send your first payment](/getting-started/quickstart/) Take a sandbox transaction end to end in about five minutes. Run the quickstart 3 ### [Pick your payment methods](/payment-methods/overview/) Decide which channels to support and what each one requires. Browse methods 4 ### [Move to production](/getting-started/go-live-checklist/) Work through the checklist before you switch to live keys. Go-live checklist Using an AI coding assistant? Read [Building with AI tools](/sdks/ai-tools/) first — it links machine-readable versions of these docs and gives you a prompt that keeps assistants from inventing Pesepay endpoints, fields and currency codes. # Quickstart: Your first payment > Send a real sandbox payment in about five minutes using an official Pesepay SDK. By the end of this page you’ll have redirected a test customer to the Pesepay sandbox payment page and gotten a transaction reference back — the core loop behind every Pesepay integration. ### Prerequisites [Section titled “Prerequisites”](#prerequisites) * A [Pesepay account](/getting-started/onboarding/) with a sandbox **integration key** and **encryption key** * Node.js, Python, PHP, or Java installed locally Caution The official SDKs currently call the **production** API only — they have no sandbox base URL. To run this against the sandbox, use the raw HTTP calls in the [API reference](/api/initiate-transaction/), which show the sandbox URLs, and switch to an SDK once you’re integrating for real. 1. ### Install the SDK [Section titled “Install the SDK”](#install-the-sdk) * Node.js ```bash npm install pesepay ``` * Python ```bash pip install pesepay ``` * PHP ```bash composer require codevirtus/pesepay ``` * Java ```xml com.pesepay pesepay 1.0.0 ``` 2. ### Initialize the client [Section titled “Initialize the client”](#initialize-the-client) Use your **sandbox** integration key and encryption key from [Onboarding](/getting-started/onboarding/). * Node.js pesepay.js ```js const { Pesepay } = require('pesepay'); const pesepay = new Pesepay('YOUR_INTEGRATION_KEY', 'YOUR_ENCRYPTION_KEY'); pesepay.resultUrl = 'https://example.com/payments/result'; pesepay.returnUrl = 'https://example.com/payments/return'; ``` * Python pesepay\_client.py ```python from pesepay import Pesepay pesepay = Pesepay("YOUR_INTEGRATION_KEY", "YOUR_ENCRYPTION_KEY") pesepay.result_url = "https://example.com/payments/result" pesepay.return_url = "https://example.com/payments/return" ``` * PHP pesepay.php ```php returnUrl = 'https://example.com/payments/return'; $pesepay->resultUrl = 'https://example.com/payments/result'; ``` * Java PesepayClient.java ```java Pesepay pesepay = new Pesepay(integrationKey, encryptionKey); pesepay.setResultUrl("https://example.com/payments/result"); pesepay.setReturnUrl("https://example.com/payments/return"); ``` 3. ### Create and initiate a transaction [Section titled “Create and initiate a transaction”](#create-and-initiate-a-transaction) This is the [redirect flow](/payments/redirect-flow/): create a transaction for an amount and currency, then initiate it to get a `redirectUrl`. * Node.js ```js const transaction = pesepay.createTransaction( 10.0, 'USD', 'Order #1042 — running shoes' ); pesepay .initiateTransaction(transaction) .then((response) => { console.log(response.redirectUrl); // send the customer here console.log(response.referenceNumber); // store this for later }) .catch((error) => console.error(error)); ``` * Python ```python transaction = pesepay.create_transaction( 10.0, "USD", "Order #1042 — running shoes" ) try: response = pesepay.initiate_transaction(transaction) print(response.redirect_url) # send the customer here print(response.reference_number) # store this for later except Exception as err: print(err) ``` * PHP ```php $transaction = $pesepay->createTransaction( 10.0, 'USD', 'Order #1042 — running shoes' ); $response = $pesepay->initiateTransaction($transaction); if ($response->success()) { $redirectUrl = $response->redirectUrl(); // send the customer here $referenceNumber = $response->referenceNumber(); // store this for later } else { echo $response->message(); } ``` * Java ```java Transaction transaction = pesepay.createTransaction( 10.0, "USD", "Order #1042 — running shoes" ); Response response = pesepay.initiateTransaction(transaction); if (response.isSuccess()) { String redirectUrl = response.getRedirectUrl(); // send the customer here String referenceNumber = response.getReferenceNumber(); // store this for later } else { System.out.println(response.getMessage()); } ``` 4. ### Redirect your customer [Section titled “Redirect your customer”](#redirect-your-customer) Send the customer’s browser to `redirectUrl`. In the sandbox, use the [test payment methods](/testing/test-credentials/) to complete or deliberately fail the payment. 5. ### Confirm the result [Section titled “Confirm the result”](#confirm-the-result) Pesepay calls your `resultUrl` with the final status as soon as the customer finishes — see [The Result Callback](/webhooks/result-callback/). As a fallback, you can also poll: [Check Payment Status](/api/check-payment-status/) using the `referenceNumber` you stored in step 3. ## What’s next [Section titled “What’s next”](#whats-next) * [Add the payment methods you want to support](/payment-methods/overview/) * [Handle the result callback on your server](/webhooks/result-callback/) * [Move to production](/getting-started/go-live-checklist/) # Checking payment status > When to rely on the result callback versus polling for a transaction's status. You have two ways to find out how a transaction ended up — use both. ## Push: the result callback [Section titled “Push: the result callback”](#push-the-result-callback) Pesepay posts the final status to the `resultUrl` you set when you created the transaction, as soon as the customer finishes (or the transaction times out). This is the primary way production integrations should reconcile orders. See [The Result Callback](/webhooks/result-callback/). ## Pull: check payment status [Section titled “Pull: check payment status”](#pull-check-payment-status) Call [Check Payment Status](/api/check-payment-status/) with a transaction’s `referenceNumber` any time you need the current state — useful for: * A safety net if a callback delivery is ever delayed or missed * Rendering a “checking your payment…” state on your `returnUrl` page * Reconciliation jobs that sweep pending transactions ## Reading the status [Section titled “Reading the status”](#reading-the-status) `transactionStatus` is one of a fixed set of values — see [Transaction Statuses](/resources/transaction-statuses/) for the full list and which ones are terminal (safe to stop checking) versus in-progress. # How payments work > The lifecycle of a Pesepay transaction, from initiation to settlement. Every Pesepay payment — regardless of method — follows the same shape: Customer Your server Pesepay Payment provider 1 Create the transaction encrypted payload 2 referenceNumber store it now 3 Customer pays hosted page, or your UI 4 Processes the payment 5 Outcome 6 Result callback 7 Confirm the status 8 Settles to your account 1. Your server → Pesepay Create the transaction encrypted payload 2. Pesepay → Your server referenceNumber store it now 3. Customer → Pesepay Customer pays hosted page, or your UI 4. Pesepay → Payment provider Processes the payment 5. Payment provider → Pesepay Outcome 6. Pesepay → Your server Result callback 7. Your server → Pesepay Confirm the status 8. Pesepay Settles to your account The lifecycle of a Pesepay transaction 1. **Your server creates a transaction** with an amount, currency, and reason for payment, encrypts it, and sends it to Pesepay. 2. **The customer pays** through one of two flows: they’re redirected to a Pesepay-hosted page ([redirect flow](/payments/redirect-flow/)), or you collect their payment method details and submit them directly ([seamless flow](/payments/seamless-flow/)). 3. **Pesepay processes the payment** with the relevant provider (EcoCash, a card network, a bank, etc). 4. **Your server finds out the result** — either pushed to you via the [result callback](/webhooks/result-callback/), or pulled by [checking status](/payments/checking-status/) with the `referenceNumber`. 5. **Funds settle** to your payout account roughly T+2 days after the transaction completes. ## Reference number: your source of truth [Section titled “Reference number: your source of truth”](#reference-number-your-source-of-truth) Every transaction gets a `referenceNumber` the moment it’s created. Store it against your order/invoice immediately — it’s what ties together the initiation request, the result callback, and any status checks you make later. See the [transaction result fields](/webhooks/result-callback/#payload). ## Picking a flow [Section titled “Picking a flow”](#picking-a-flow) | | Redirect flow | Seamless flow | | ----------------------------------------- | --------------------- | ------------------------------------------ | | Where the customer enters payment details | Pesepay’s hosted page | Your own UI | | PCI/compliance surface on your servers | Minimal | Larger — you handle method-specific fields | | Best for | Fastest to ship | Fully branded checkout | [Redirect flow →](/payments/redirect-flow/) · [Seamless flow →](/payments/seamless-flow/) Either flow can settle part of each payment to a second merchant automatically — see [Split payments](/payments/split-payments/). # Redirect (checkout) flow > Send customers to a Pesepay-hosted payment page and get notified when they finish. The redirect flow is the fastest way to accept payments: you create a transaction, redirect the customer to a page Pesepay hosts, and they pick a payment method and complete the payment there. Pesepay handles the method-specific UI for you — including [card entry](/payment-methods/card-payments/), which keeps card data out of your systems entirely. Customer Your server Pesepay Payment provider 1 Starts checkout 2 Create transaction POST /payments/initiate 3 redirectUrl + referenceNumber 4 Redirect to Pesepay 5 Picks a method and pays 6 Charges the customer 7 Result callback POST resultUrl 8 Confirm the status GET /payments/check-payment 1. Customer → Your server Starts checkout 2. Your server → Pesepay Create transaction POST /payments/initiate 3. Pesepay → Your server redirectUrl + referenceNumber 4. Your server → Customer Redirect to Pesepay 5. Customer → Pesepay Picks a method and pays 6. Pesepay → Payment provider Charges the customer 7. Pesepay → Your server Result callback POST resultUrl 8. Your server → Pesepay Confirm the status GET /payments/check-payment Redirect flow — the customer pays on Pesepay's hosted page ## Building it [Section titled “Building it”](#building-it) 1. **Build the request** — amount, currency, reason for payment, plus your `resultUrl` and `returnUrl`. 2. **Encrypt and send it** to [Initiate Transaction](/api/initiate-transaction/). 3. **Decrypt the response** to get a `redirectUrl` and `referenceNumber`. Store the `referenceNumber` against your order now. 4. **Redirect the customer’s browser** to `redirectUrl`. 5. **The customer chooses a method and pays** on Pesepay’s page. 6. **They land back on your `returnUrl`**, and separately, Pesepay posts the final status to your `resultUrl` — see [The Result Callback](/webhooks/result-callback/). 7. **Confirm before you fulfil** with [Check Payment Status](/api/check-payment-status/), and only act on `SUCCESS`. Caution `returnUrl` is where the *browser* goes — treat it as UI only. Never mark an order paid because the customer landed on `returnUrl`; only trust an explicit [status check](/payments/checking-status/). Steps 7 and 8 in the diagram are two halves of one thing: the callback tells you *something happened*, the status check tells you *what*. Full request/response fields, headers, and code samples in five languages: [Initiate Transaction reference →](/api/initiate-transaction/) # Seamless (direct) flow > Collect payment method details in your own UI and submit them directly to Pesepay. In the seamless flow, your own UI collects the payment method and any fields it requires (like a phone number for EcoCash), and you submit everything directly — no redirect to a Pesepay-hosted page. Customer Your server Pesepay Wallet provider 1 Submits checkout method + phone number 2 Make payment POST /v2/payments/make-payment 3 Requests the debit 4 PIN prompt on the phone 5 referenceNumber + pollUrl status not yet final 6 Enters PIN 7 Result callback POST resultUrl 8 Confirm the status GET /payments/check-payment 1. Customer → Your server Submits checkout method + phone number 2. Your server → Pesepay Make payment POST /v2/payments/make-payment 3. Pesepay → Wallet provider Requests the debit 4. Wallet provider → Customer PIN prompt on the phone 5. Pesepay → Your server referenceNumber + pollUrl status not yet final 6. Customer → Wallet provider Enters PIN 7. Pesepay → Your server Result callback POST resultUrl 8. Your server → Pesepay Confirm the status GET /payments/check-payment Seamless flow — an EcoCash payment approved on the customer's phone The gap between step 5 and step 7 is the part that catches people out: your API call has already returned while the customer is still holding their phone. Show a waiting state, and drive it from the callback or `pollUrl`. ## Building it [Section titled “Building it”](#building-it) 1. **Pick a payment method** and check its [required fields](/payment-methods/overview/) — e.g. EcoCash needs `customerPhoneNumber`, InnBucks needs no extra fields at all. 2. **Build the request** — amount, currency, `paymentMethodCode`, the method’s required fields, plus `resultUrl` and `returnUrl`. 3. **Encrypt and send it** to [Make Payment](/api/make-payment/). 4. **Decrypt the response** and store the `referenceNumber`. The customer still has to approve the payment on their side, so the status you get back is usually not final. 5. **Get the result** via the [result callback](/webhooks/result-callback/) or by [checking status](/payments/checking-status/) with the `referenceNumber`. Caution A `200` from Make Payment does not mean you were paid. Only a terminal [`transactionStatus`](/resources/transaction-statuses/) of `SUCCESS` means that, and it arrives via the callback or a status check — never fulfil an order on the Make Payment response alone. Full request/response fields, headers, and code samples in five languages: [Make Payment reference →](/api/make-payment/) # Split payments > Settle part of every payment to a second Pesepay merchant automatically, and what turning it on changes about your integration. A **split payment** lets one application collect a payment and have Pesepay settle part of it to a *different* Pesepay merchant, automatically, with no transfer between you afterwards. The application that collects is the **master merchant**; the merchant that receives a share is the **beneficiary**. Typical uses: a marketplace paying a vendor out of each sale, a booking platform paying a venue, a fees portal collecting on behalf of a school. Turning splits on changes the contract for every transaction From the moment your first beneficiary accepts, **every** transaction on that application must carry the beneficiary’s email in `paymentMetadata` — including ordinary sales that have nothing to do with the arrangement. Transactions without it are refused at initiation: > Transaction cannot be initiated without Beneficiary merchant email in paymentMetadata because this application has settlement split beneficiaries. A merchant who adds their first beneficiary will break their existing checkout on the next payment unless the code is deployed first. Plan the two together, or set the arrangement up on a **separate application**. ## How the money moves [Section titled “How the money moves”](#how-the-money-moves) Customer Your server Pesepay Beneficiary 1 Create the transaction beneficiary email in paymentMetadata 2 Pays once one reference number 3 Divides the amount on success 4 Result callback no split detail on it 5 Credits the master merchant share 6 Credits the remainder their application, their payout account 1. Your server → Pesepay Create the transaction beneficiary email in paymentMetadata 2. Customer → Pesepay Pays once one reference number 3. Pesepay Divides the amount on success 4. Pesepay → Your server Result callback no split detail on it 5. Pesepay → Your server Credits the master merchant share 6. Pesepay → Beneficiary Credits the remainder their application, their payout account One payment, two settlement credits 1. **The customer pays once.** One transaction, one reference number, one [result callback](/webhooks/result-callback/) — the customer never sees the arrangement. 2. **On success, Pesepay creates two settlement records** against the parent transaction: a credit to the master merchant for their share, and a credit to the beneficiary for the rest. Both are booked in the currency of the parent payment. 3. **Each side is paid out on its own.** The beneficiary’s credit lands against *their* Pesepay application and is settled to *their* payout account under the normal settlement schedule. You never hold or forward their money. The split arithmetic runs on the transaction amount. Pesepay’s own transaction service fee is applied as usual for your application’s fee charge type — it is not part of the calculation below. ## Before you start [Section titled “Before you start”](#before-you-start) Both sides need to be approved Pesepay merchants before an arrangement can exist: | Requirement | Applies to | Why | | -------------------------------------------------------------------------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | An approved Pesepay merchant account | Beneficiary | The invitation is matched to an existing business by email. An unapproved account is rejected with *Beneficiary merchant account has not yet been approved.* | | An approved [payout account](/getting-started/onboarding/#3-payout-accounts) **in the currency you charge in** | Beneficiary | Credits inherit the parent transaction’s currency and are **never converted**. A `ZWG` payment to a beneficiary who only holds a USD payout account produces a balance they cannot withdraw | | The email address the beneficiary’s business is registered under | Master merchant | It is both the invitation address and the value you send on every transaction | Caution The currency point is an onboarding prerequisite, not a runtime check. Nothing fails at payment time — the money simply sits in a currency the beneficiary has no approved account for. Confirm their payout accounts cover every currency you will charge in **before** you send the invitation. ## Setting up an arrangement [Section titled “Setting up an arrangement”](#setting-up-an-arrangement) Arrangements are configured per application, in the Merchant Control Panel under **Applications → View Details → Manage Splits**. 1. **The master merchant configures the split.** **Configure Split** opens a form asking for the beneficiary merchant email, effective date, split amount mode, allocation model, and the fixed amount or percentage — the [settings below](#configuration-reference). Submitting it sends the invitation. 2. **The beneficiary gets an emailed invitation** headed *“\ split settlement invitation”*, showing the agreement details and a link to review and accept it. 3. **The beneficiary accepts.** Pesepay creates a dedicated application on their account — named ` - Beneficiary` — and their share of each payment is credited to it. They add a payout account to that application if they have not already. 4. **The arrangement becomes active.** Deploy the `paymentMetadata` change described [below](#what-changes-in-your-requests) at this point, not after. An application can hold several arrangements — one per beneficiary you collect for. Each transaction names exactly one of them. ### Agreement status [Section titled “Agreement status”](#agreement-status) **Manage Splits** lists every arrangement on the application with its beneficiary, allocation model, split amount mode, share, effective date and status; expanding a row also shows the agreement reference and the beneficiary’s settlement account. | Status | Meaning | Payments | | ---------------------- | --------------------------------------------------------------- | ---------------------------------------------------- | | `INVITED` | Sent, not yet answered | Not yet required, and not yet accepted | | `ENABLED` / `ACCEPTED` | Live | Accepted for this beneficiary | | `DECLINED` | The beneficiary refused the invitation | Never active | | `DISABLED` | Suspended because the beneficiary’s application was deactivated | Refused, and the metadata requirement stays in force | | `REVOKED` | Ended by the master merchant | Refused | ## Configuration reference [Section titled “Configuration reference”](#configuration-reference) | Setting | Values | Notes | | ----------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------- | | `allocationModel` | `PERCENTAGE`, `FIXED_AMOUNT` | Required | | `percentage` | Greater than 0, less than 100 | Required for `PERCENTAGE`. **This is the master merchant’s share, not the beneficiary’s** | | `fixedAmount` | Greater than 0 | Required for `FIXED_AMOUNT`. Also the master merchant’s share | | `splitAmountMode` | `PRINCIPAL` (default), `ADD_ON` | Set on the arrangement, not per transaction — see [below](#principal-vs-add_on) | | `effectiveDate` | A date; defaults to the day the invitation is created | Shown on the invitation | percentage is the master merchant's share The field sits on a record called *split beneficiary*, which reads as though it describes what the beneficiary gets. It does not. `percentage: 30` on a 100.00 payment means the **master merchant keeps 30.00** and the beneficiary receives 70.00. Getting this backwards is the easiest misconfiguration to make — the invitation email calls it *Master Merchant Share* for exactly this reason. ### PRINCIPAL vs ADD\_ON [Section titled “PRINCIPAL vs ADD\_ON”](#principal-vs-add_on) `PRINCIPAL` takes the master merchant’s share **out of** the amount you submit. `ADD_ON` **adds it on top**, so the customer is charged more than the amount you sent. Submitting `amount: 100.00` against a 30% arrangement: | | `PRINCIPAL` | `ADD_ON` | | ---------------------------------- | ----------- | ---------- | | Amount you submit | 100.00 | 100.00 | | **Amount the customer is charged** | 100.00 | **130.00** | | Master merchant receives | 30.00 | 30.00 | | Beneficiary receives | 70.00 | 100.00 | The same payment with a fixed share of 5.00: | | `PRINCIPAL` | `ADD_ON` | | ---------------------------------- | ----------- | ---------- | | Amount you submit | 100.00 | 100.00 | | **Amount the customer is charged** | 100.00 | **105.00** | | Master merchant receives | 5.00 | 5.00 | | Beneficiary receives | 95.00 | 100.00 | ADD\_ON rewrites the amount before the payment is created Under `ADD_ON`, the amount on the transaction — and so on the hosted checkout, the callback and every report — is the amount you submitted **plus** the master merchant’s share. If your order totals, receipts or reconciliation are built against your own figure, they will disagree with Pesepay. Read the amount back from the transaction rather than assuming it is the one you sent. ## What changes in your requests [Section titled “What changes in your requests”](#what-changes-in-your-requests) Add the beneficiary’s email to `paymentMetadata` on every [Initiate Transaction](/api/initiate-transaction/) or [Make Payment](/api/make-payment/) request for the application: Plaintext request body (before encryption) ```diff { "amountDetails": { "amount": 100.00, "currencyCode": "USD" }, "reasonForPayment": "Order #1042 — running shoes", +"paymentMetadata": { +"beneficiaryMerchantEmail": "vendor@example.com" + }, "resultUrl": "https://example.com/payments/result", "returnUrl": "https://example.com/payments/return" } ``` `beneficiaryMerchantEmail` is the canonical key. These aliases are also accepted, case-insensitively, for integrations that already use them: `beneficiaryEmail`, `beneficiary_email`, `schoolEmail`, `school_email`. Prefer the canonical key in new code. ### Metadata Pesepay writes back [Section titled “Metadata Pesepay writes back”](#metadata-pesepay-writes-back) Pesepay adds two keys to the transaction’s metadata, and you get them back as `transactionMetadata` on the [callback](/webhooks/result-callback/) and from [check payment status](/api/check-payment-status/): | Key | Value | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `splitAmountMode` | `PRINCIPAL` or `ADD_ON`, taken from the arrangement | | `splitPrincipalAmount` | The amount before the master merchant’s share was added. Under `PRINCIPAL` this equals the amount charged; under `ADD_ON` it is the amount you submitted | Under `ADD_ON`, `splitPrincipalAmount` is the figure to reconcile your own order total against — `amountDetails.amount` is the larger, charged amount. ## Errors [Section titled “Errors”](#errors) Split payments add three failures, all raised when you create the transaction and all `400` responses in the standard [error shape](/api/errors/). The `message` text is reproduced exactly. ### No beneficiary email on the request [Section titled “No beneficiary email on the request”](#no-beneficiary-email-on-the-request) ```text Transaction cannot be initiated without Beneficiary merchant email in paymentMetadata because this application has settlement split beneficiaries. ``` The application has at least one beneficiary in `ENABLED`, `ACCEPTED` or `DISABLED`, and this request carried no beneficiary email. **Fix:** send `beneficiaryMerchantEmail` on *every* transaction for this application, not only the ones you think of as split. ### The email isn’t an accepted beneficiary [Section titled “The email isn’t an accepted beneficiary”](#the-email-isnt-an-accepted-beneficiary) ```text Transaction cannot be initiated because the supplied split beneficiary merchant email is not accepted. ``` There is no accepted arrangement on this application for that address — it was never invited, is still `INVITED`, was `DECLINED`, or has been `REVOKED`. **Fix:** check the spelling, and check the arrangement’s status in **Manage Splits**. This is also what you get in the gap while an arrangement is being replaced. ### The beneficiary is disabled [Section titled “The beneficiary is disabled”](#the-beneficiary-is-disabled) ```text PAYMENTS TO THE MERCHANT ARE CURRENTLY DISABLED, PLEASE CONTACT ADMINISTRATOR, OR TRY AGAIN LATER ``` The arrangement is `DISABLED` because the beneficiary’s own application was deactivated. **Fix:** nothing in your code — the beneficiary’s account needs attention. Note this message is customer-facing in tone but arrives as an API error; don’t show it verbatim at checkout. One failure happens after the customer has paid Allocation runs when the payment succeeds. If it cannot produce a valid division — most often because a **fixed master-merchant share is equal to or larger than the payment amount** — the payment still succeeds and the customer is still charged, but the split is not allocated and the transaction is flagged internally as a failed settlement split. Keep fixed shares comfortably below your smallest expected payment, and treat a beneficiary reporting a missing credit on an otherwise successful payment as a support case, with the `referenceNumber`. ## Current limits [Section titled “Current limits”](#current-limits) * **One beneficiary per transaction.** A payment divides between the master merchant and exactly one beneficiary. There is no n-way split, and no way to name more than one beneficiary on a request. * **One current arrangement per beneficiary, per application.** Inviting a business that already has one is rejected — revoke the existing arrangement first. * **No currency conversion.** Both credits inherit the parent transaction’s currency. * **Nothing about the split appears on the callback.** The [result callback](/webhooks/result-callback/) describes the customer’s payment. The two credits are settlement records, visible in each merchant’s own dashboard. ## Changing or ending an arrangement [Section titled “Changing or ending an arrangement”](#changing-or-ending-an-arrangement) Terms are fixed once accepted. **To change a share, mode or model, revoke the arrangement and send a fresh invitation**, which the beneficiary must accept again. Revoking is the ⋮ action on the arrangement’s row in **Manage Splits**; there is no edit, and no un-revoke. Schedule this — do not do it mid-trade Between the revocation and the new acceptance there is a gap in which transactions carrying that beneficiary’s email are refused with *the supplied split beneficiary merchant email is not accepted*. Revoking is also not reversible: a revoked arrangement cannot be switched back on, only replaced by a new invitation. Do it in a maintenance window, or outside trading hours. Revoking your *only* beneficiary also lifts the metadata requirement, so transactions that omit the email start succeeding again while any request still sending the revoked address keeps failing. During a change-over, old and new code therefore fail in different ways — deploy the request change and the new acceptance close together. # Check Payment Status > Look up the current status of a transaction by its reference number. GET Returns the current status of a transaction. Use this as a fallback or reconciliation check alongside the [result callback](/webhooks/result-callback/) — see [Checking Payment Status](/payments/checking-status/) for when to use each. | Environment | URL | | ----------- | -------------------------------------------------------------------------------- | | Production | `https://api.pesepay.com/api/payments-engine/v1/payments/check-payment` | | Sandbox | `https://api.test.sandbox.pesepay.com/payments-engine/v1/payments/check-payment` | ## Headers [Section titled “Headers”](#headers) | Parameter | Type | Required | Description | | --------------- | ------ | -------- | ---------------------------------- | | `authorization` | string | Yes | Your application’s integration key | | `content-type` | string | Yes | Must be `application/json` | ## Query parameters [Section titled “Query parameters”](#query-parameters) | Parameter | Type | Description | | ----------------- | ------ | ------------------------------------------------ | | `referenceNumber` | string | The reference number of the transaction to check | ## Code examples [Section titled “Code examples”](#code-examples) * cURL ```bash curl -G https://api.test.sandbox.pesepay.com/payments-engine/v1/payments/check-payment \ -H "authorization: YOUR_INTEGRATION_KEY" \ -H "content-type: application/json" \ --data-urlencode "referenceNumber=YOUR_REFERENCE_NUMBER" ``` * Node.js ```javascript const url = new URL( 'https://api.test.sandbox.pesepay.com/payments-engine/v1/payments/check-payment' ); url.searchParams.set('referenceNumber', referenceNumber); const response = await fetch(url, { headers: { authorization: 'YOUR_INTEGRATION_KEY', 'content-type': 'application/json', }, }); const { payload } = await response.json(); const transaction = decrypt(payload, ENCRYPTION_KEY); console.log(transaction.transactionStatus); ``` * Python ```python import requests response = requests.get( "https://api.test.sandbox.pesepay.com/payments-engine/v1/payments/check-payment", params={"referenceNumber": reference_number}, headers={ "authorization": "YOUR_INTEGRATION_KEY", "content-type": "application/json", }, ) transaction = decrypt(response.json()["payload"], ENCRYPTION_KEY) print(transaction["transactionStatus"]) ``` * PHP ```php response = client.send(request, HttpResponse.BodyHandlers.ofString()); String transactionJson = decrypt(extractPayload(response.body()), encryptionKey); ``` ## Response [Section titled “Response”](#response) The response body is `{ "payload": "..." }`. Decrypt the `payload` with your encryption key to get the transaction result — the **same object** the [result callback](/webhooks/result-callback/) delivers and [Make Payment](/api/make-payment/) returns. Its complete field list, including the `amountDetails` breakdown, is on the [result callback page](/webhooks/result-callback/#payload). The fields you check most often: | Field | Type | Description | | ------------------------------ | ------ | --------------------------------------------------------------------------------------- | | `referenceNumber` | string | The transaction’s reference — match it to your order | | `transactionStatus` | string | Where the payment stands — see [Transaction Statuses](/resources/transaction-statuses/) | | `transactionStatusCode` | number | Numeric equivalent of `transactionStatus` | | `transactionStatusDescription` | string | Human-readable status message | | `amountDetails` | object | Amounts and fees applied to the transaction | This is **not** the full [Transaction](/resources/transaction-model/) entity — it is the smaller result view. There is no `redirectUrl` on it. # Errors > The shape of a Pesepay error response, what each HTTP status means, and how to handle failed transactions. There are two different kinds of “error” to handle: the **API call itself** failing, and a **transaction** completing with a non-success status. They look nothing alike, and only the first one is a bug in your integration. ## The error response [Section titled “The error response”](#the-error-response) Failed requests return a JSON body with this shape: ```json { "timestamp": "2026-09-01T10:32:14.000+00:00", "message": "Currency code should be provided", "description": null, "status": "400" } ``` | Field | Type | Description | | ------------- | ------ | -------------------------------------------------------------------------------- | | `timestamp` | string | When the error occurred | | `message` | string | What went wrong. This is the field to log and show your team | | `description` | string | Extra detail. Usually `null`; carries the per-field list for validation failures | | `status` | string | The HTTP status code, as a string | Caution **Error bodies are plain JSON — don’t try to decrypt them.** Successful responses come back [encrypted](/security/encryption/); error responses don’t. If decryption throws, check the HTTP status first: you’re probably holding an error body, not a corrupted payload. ## HTTP statuses [Section titled “HTTP statuses”](#http-statuses) | Status | What it means | Example `message` | What to do | | ------ | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `400` | The request was rejected — bad field, failed validation, or a payload that decrypted into something that isn’t valid JSON | `Payload was decrypted but did not produced a valid json representation`; `Currency code should be provided`; `Invalid Ecocash phone number supplied` | Fix the request. These are integration bugs, not transient failures — don’t retry unchanged | | `401` | Authentication failed | `Invalid username or password` | Check the credentials you’re using | | `403` | Your integration key was rejected, or no payment method is available | `Integration key for the application is not valid (Not active or disabled)`; `No payment method that supports {currency} is available at the moment` | Confirm the key is for the right environment and still active — see [API keys](/security/api-keys/) | | `404` | The record you asked for doesn’t exist | Lookup misses, e.g. an unknown `referenceNumber` | Check the reference number you’re sending | | `406` | The operation isn’t allowed in the transaction’s current state | — | Re-check the transaction’s [status](/resources/transaction-statuses/) before retrying the operation | | `500` | Something failed server-side, including decryption failures and downstream service errors | `Failed to decrypt your data` | If it mentions decryption, check your encryption key and [IV rule](/security/encryption/). Otherwise retry once, and contact support with the `referenceNumber` if it persists | ## Validation errors [Section titled “Validation errors”](#validation-errors) Field validation failures come back as `400`, and **multiple problems are joined into a single `message`** separated by `;` — so parse the whole string rather than assuming one error per response. Real examples: ```plaintext Invalid Visa Card Number length, its either 13, 16, 19 numbers.; Invalid Credit card expiry date provided ``` Each payment method validates its own fields. See the failure-modes table on the relevant [payment method page](/payment-methods/overview/) for what each one rejects. ## Transaction-level outcomes [Section titled “Transaction-level outcomes”](#transaction-level-outcomes) A request can succeed at the API level — HTTP 200, valid encrypted response — while the payment itself fails. A declined card and an EcoCash timeout are both HTTP 200. Read `transactionStatus` in the decrypted response, not the HTTP status: * `SUCCESS` is the only status that means you were paid. * `DECLINED`, `INSUFFICIENT_FUNDS`, `AUTHORIZATION_FAILED`, `TIME_OUT`, and `CANCELLED` are normal, expected outcomes your UI should handle gracefully. * Non-terminal statuses (`PENDING`, `PROCESSING`, `PARTIALLY_PAID`) mean the payment is still in flight — keep checking. See [Transaction statuses](/resources/transaction-statuses/) for the full list with numeric codes. `transactionStatusDescription` on a failed transaction often carries text straight from the payment provider (EcoCash, the card issuer, and so on). It’s useful to log and sometimes useful to show the customer, but it isn’t a stable value to branch on. # Get Active Currencies > Retrieve the currencies currently active on your gateway. GET Returns the currencies currently active on the gateway. Unlike the integration endpoints, this one returns plain JSON — no encryption required. | Environment | URL | | ----------- | ------------------------------------------------------------------ | | Production | `https://api.pesepay.com/api/payments-engine/v1/currencies/active` | ## Headers [Section titled “Headers”](#headers) | Parameter | Type | Required | Description | | -------------- | ------ | -------- | -------------------------- | | `content-type` | string | Yes | Must be `application/json` | ## Code examples [Section titled “Code examples”](#code-examples) * cURL ```bash curl https://api.pesepay.com/api/payments-engine/v1/currencies/active \ -H "content-type: application/json" ``` * Node.js ```javascript const response = await fetch( 'https://api.pesepay.com/api/payments-engine/v1/currencies/active', { headers: { 'content-type': 'application/json' } } ); const currencies = await response.json(); ``` * Python ```python import requests response = requests.get( "https://api.pesepay.com/api/payments-engine/v1/currencies/active", headers={"content-type": "application/json"}, ) currencies = response.json() ``` * PHP ```php response = client.send(request, HttpResponse.BodyHandlers.ofString()); ``` ## Response [Section titled “Response”](#response) An array of [Currency](/resources/currency-model/) objects. Caution This endpoint currently returns `USD` and `ZiG` only. `ZWG` is **also chargeable** — it is the currency code for [Zimswitch](/payment-methods/zimswitch/) and [Omari](/payment-methods/omari/) in Zimbabwe dollars — but it does not appear here. If you drive a currency selector purely off this response, you will silently drop those methods; see [Payment method codes](/resources/payment-method-codes/). # Get Payment Methods by Currency > Retrieve the payment methods available for a given currency. GET Returns the payment methods available to complete a transaction for a specified currency. Plain JSON — no encryption required. Use this to build a dynamic payment method picker in your own UI for the [seamless flow](/payments/seamless-flow/), instead of hardcoding [payment method codes](/resources/payment-method-codes/). | Environment | URL | | ----------- | ----------------------------------------------------------------------------- | | Production | `https://api.pesepay.com/api/payments-engine/v1/payment-methods/for-currency` | ## Headers [Section titled “Headers”](#headers) | Parameter | Type | Required | Description | | -------------- | ------ | -------- | -------------------------- | | `content-type` | string | Yes | Must be `application/json` | ## Query parameters [Section titled “Query parameters”](#query-parameters) | Parameter | Type | Description | | -------------- | ------ | ------------------------------------------------------------- | | `currencyCode` | string | The currency to get available payment methods for, e.g. `USD` | ## Code examples [Section titled “Code examples”](#code-examples) * cURL ```bash curl -G https://api.pesepay.com/api/payments-engine/v1/payment-methods/for-currency \ -H "content-type: application/json" \ --data-urlencode "currencyCode=USD" ``` * Node.js ```javascript const url = new URL( 'https://api.pesepay.com/api/payments-engine/v1/payment-methods/for-currency' ); url.searchParams.set('currencyCode', 'USD'); const response = await fetch(url, { headers: { 'content-type': 'application/json' }, }); const paymentMethods = await response.json(); ``` * Python ```python import requests response = requests.get( "https://api.pesepay.com/api/payments-engine/v1/payment-methods/for-currency", params={"currencyCode": "USD"}, headers={"content-type": "application/json"}, ) payment_methods = response.json() ``` * PHP ```php response = client.send(request, HttpResponse.BodyHandlers.ofString()); ``` ## Response [Section titled “Response”](#response) An array of [Payment Method](/resources/payment-method-model/) objects. # Initiate Transaction > Create a transaction and get a redirect URL for the Pesepay-hosted payment page. POST Creates a transaction and returns a `redirectUrl` where your customer completes payment. This is the first call in the [redirect flow](/payments/redirect-flow/). | Environment | URL | | ----------- | --------------------------------------------------------------------------- | | Production | `https://api.pesepay.com/api/payments-engine/v1/payments/initiate` | | Sandbox | `https://api.test.sandbox.pesepay.com/payments-engine/v1/payments/initiate` | ## How it fits [Section titled “How it fits”](#how-it-fits) 1. Build your request body with the payment details, then [encrypt it](/security/encryption/) with your application’s encryption key. 2. POST the encrypted payload to the URL above, with your integration key in the `authorization` header. 3. Decrypt the response to get `referenceNumber`, `redirectUrl` and `pollUrl`. 4. Store `referenceNumber` for tracking, then redirect the customer to `redirectUrl` to complete payment. ## Headers [Section titled “Headers”](#headers) | Parameter | Type | Required | Description | | --------------- | ------ | -------- | ---------------------------------- | | `authorization` | string | Yes | Your application’s integration key | | `content-type` | string | Yes | Must be `application/json` | ## Request body [Section titled “Request body”](#request-body) The fields below are the **plaintext** shape — encrypt the entire object before sending (see [Encryption Guide](/security/encryption/)). | Field | Type | Required | Description | | ------------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `amountDetails` | object | Yes | `{ amount: number, currencyCode: string }` | | `reasonForPayment` | string | Yes | A short summary of the transaction | | `resultUrl` | string | Yes | Where Pesepay posts the final transaction result — see [The Result Callback](/webhooks/result-callback/) | | `returnUrl` | string | Yes | Where the customer’s browser is sent after completing or cancelling | | `merchantReference` | string | No | Your own order/invoice reference, echoed back on the transaction | | `paymentMethodCode` | string | No | Pre-selects a method so the hosted page opens straight into it — see [Payment Method Codes](/resources/payment-method-codes/) | | `paymentMetadata` | object | No | String key/value pairs carried on the transaction and returned as `transactionMetadata` on the result. **Required** on an application with [split payments](/payments/split-payments/), which must carry `beneficiaryMerchantEmail` | Plaintext request body (before encryption) ```json { "amountDetails": { "amount": 10.00, "currencyCode": "USD" }, "reasonForPayment": "Order #1042 — running shoes", "resultUrl": "https://example.com/payments/result", "returnUrl": "https://example.com/payments/return" } ``` ## Code examples [Section titled “Code examples”](#code-examples) Each sample builds the request, encrypts it, sends it, and decrypts the response — see the [Encryption Guide](/security/encryption/) for the `encrypt`/`decrypt` helpers used here. * cURL ```bash # Encrypt your JSON body first (see the Encryption Guide), then: curl -X POST https://api.test.sandbox.pesepay.com/payments-engine/v1/payments/initiate \ -H "authorization: YOUR_INTEGRATION_KEY" \ -H "content-type: application/json" \ -d '{"payload": "ENCRYPTED_BASE64_STRING"}' ``` * Node.js ```javascript const body = { amountDetails: { amount: 10.0, currencyCode: 'USD' }, reasonForPayment: 'Order #1042 — running shoes', resultUrl: 'https://example.com/payments/result', returnUrl: 'https://example.com/payments/return', }; const response = await fetch( 'https://api.test.sandbox.pesepay.com/payments-engine/v1/payments/initiate', { method: 'POST', headers: { authorization: 'YOUR_INTEGRATION_KEY', 'content-type': 'application/json', }, body: JSON.stringify({ payload: encrypt(body, ENCRYPTION_KEY) }), } ); const { payload } = await response.json(); const transaction = decrypt(payload, ENCRYPTION_KEY); console.log(transaction.redirectUrl); // send the customer here console.log(transaction.referenceNumber); // store this for tracking ``` * Python ```python import requests body = { "amountDetails": {"amount": 10.00, "currencyCode": "USD"}, "reasonForPayment": "Order #1042 — running shoes", "resultUrl": "https://example.com/payments/result", "returnUrl": "https://example.com/payments/return", } response = requests.post( "https://api.test.sandbox.pesepay.com/payments-engine/v1/payments/initiate", json={"payload": encrypt(body, ENCRYPTION_KEY)}, headers={ "authorization": "YOUR_INTEGRATION_KEY", "content-type": "application/json", }, ) transaction = decrypt(response.json()["payload"], ENCRYPTION_KEY) print(transaction["redirectUrl"]) # send the customer here print(transaction["referenceNumber"]) # store this for tracking ``` * PHP ```php ['amount' => 10.00, 'currencyCode' => 'USD'], 'reasonForPayment' => 'Order #1042 — running shoes', 'resultUrl' => 'https://example.com/payments/result', 'returnUrl' => 'https://example.com/payments/return', ]; $ch = curl_init('https://api.test.sandbox.pesepay.com/payments-engine/v1/payments/initiate'); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([ 'payload' => encrypt($body, $encryptionKey), ])); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'authorization: YOUR_INTEGRATION_KEY', 'content-type: application/json', ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $response = json_decode(curl_exec($ch), true); curl_close($ch); $transaction = decrypt($response['payload'], $encryptionKey); echo $transaction['redirectUrl']; // send the customer here echo $transaction['referenceNumber']; // store this for tracking ``` * Java ```java String bodyJson = """ {"amountDetails":{"amount":10.00,"currencyCode":"USD"}, "reasonForPayment":"Order #1042 — running shoes", "resultUrl":"https://example.com/payments/result", "returnUrl":"https://example.com/payments/return"}"""; String encryptedPayload = encrypt(bodyJson, encryptionKey); HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.test.sandbox.pesepay.com/payments-engine/v1/payments/initiate")) .header("authorization", "YOUR_INTEGRATION_KEY") .header("content-type", "application/json") .POST(HttpRequest.BodyPublishers.ofString( "{\"payload\":\"" + encryptedPayload + "\"}" )) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); String transactionJson = decrypt(extractPayload(response.body()), encryptionKey); ``` ## Response [Section titled “Response”](#response) The response is an encrypted `payload`. Decrypt it to get these three fields — and only these three. There is no status or amount detail here; call [Check Payment Status](/api/check-payment-status/) for that. | Field | Type | Description | | ----------------- | ------ | ---------------------------------------------------------------------------------------- | | `referenceNumber` | string | **Store this** — used to track status and match result callbacks | | `redirectUrl` | string | **Redirect your customer here** to complete payment | | `pollUrl` | string | A ready-made [Check Payment Status](/api/check-payment-status/) URL for this transaction | Caution After decrypting the response, store `referenceNumber` and redirect the customer to `redirectUrl`. Don’t consider the payment complete until you receive the [result callback](/webhooks/result-callback/) or check status and see a terminal [transaction status](/resources/transaction-statuses/). # Introduction > Base URLs, authentication, the encrypted payload envelope, and versioning for the Pesepay API. New to working with HTTP APIs? Start with [REST & JSON basics](/api/rest-basics/) for the vocabulary this reference uses. ## Base URLs [Section titled “Base URLs”](#base-urls) | Environment | Base URL | | ----------- | --------------------------------------------------------- | | Production | `https://api.pesepay.com/api/payments-engine/v1` | | Sandbox | `https://api.test.sandbox.pesepay.com/payments-engine/v1` | Every endpoint page in this reference shows both — use sandbox while building, and see the [go-live checklist](/getting-started/go-live-checklist/) before switching to production. ## Authentication [Section titled “Authentication”](#authentication) Send your application’s **integration key** in the `authorization` header on every request: ```plaintext authorization: YOUR_INTEGRATION_KEY content-type: application/json ``` Find your integration key under [Onboarding](/getting-started/onboarding/). Never send this header from browser or mobile app code — see [API Keys & Credentials](/security/api-keys/). ## The encrypted envelope [Section titled “The encrypted envelope”](#the-encrypted-envelope) Integration endpoints (initiate, make payment, check status) don’t accept or return plain JSON. Every request body and response body is an AES-256-CBC encrypted string, wrapped like this: ```json { "payload": "base64_encoded_encrypted_string" } ``` Encrypt your request JSON and decrypt every response using your application’s encryption key before reading or sending any fields. Full walkthrough with code in five languages: [Encryption Guide](/security/encryption/). ## Versioning [Section titled “Versioning”](#versioning) The current API version is `v1`, reflected in the base URL path. Breaking changes will ship under a new version path; additive changes (new optional fields, new payment methods) won’t require a version bump — so treat unrecognised response fields as something to ignore, not something to fail on. The exception already in the wild is [Make Payment](/api/make-payment/), which is `v2`. ## Rate limits [Section titled “Rate limits”](#rate-limits) Pesepay does not currently enforce rate limits on the API — there are no per-second or per-day request quotas, and no `429` responses to handle. That is not a licence to poll aggressively. When you are [checking payment status](/payments/checking-status/), poll on a sensible interval (a few seconds) and stop as soon as the transaction reaches a [terminal status](/resources/transaction-statuses/), so your integration keeps working if limits are introduced later. ## Machine-readable spec [Section titled “Machine-readable spec”](#machine-readable-spec) The API is also published as an [OpenAPI 3.1 document](/api/openapi/) with a generated Postman collection — use it to generate client types or drive an API console instead of transcribing fields from these pages. ## Using these docs with an AI assistant [Section titled “Using these docs with an AI assistant”](#using-these-docs-with-an-ai-assistant) Every page on this site is also published as plain Markdown, and the whole site is available as a single file. Paste a URL into your assistant, or fetch it. | File | What’s in it | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `https://developers.pesepay.com/llms.txt` | An index of the documentation, plus the handful of rules that are most often got wrong (encryption, the two currency codes, redirect-only cards) | | `https://developers.pesepay.com/llms-full.txt` | The complete documentation as one file | | `https://developers.pesepay.com/llms-small.txt` | The same, with asides and non-essential content stripped, for smaller context windows | | `https://developers.pesepay.com/_llms-txt/api-reference.txt` | Just this API reference and the data models | | `https://developers.pesepay.com/_llms-txt/payment-methods.txt` | Just the per-method codes, limits and required fields | | `https://developers.pesepay.com/_llms-txt/getting-started.txt` | Onboarding, the quickstart, both flows and the encryption scheme | For a single page, add `.md` to its path — this page is at `https://developers.pesepay.com/api/introduction.md`. Caution An assistant working from these files still can’t test against the sandbox for you, and it will happily invent field names. Check anything it generates against the endpoint pages here before you ship it. # Make Payment > Submit a payment directly for a specific payment method, without redirecting to a hosted page. POST Creates and processes a transaction for a specific payment method in one call. This is the core request in the [seamless flow](/payments/seamless-flow/) — your UI collects the method’s required fields and submits them directly, without redirecting to a Pesepay-hosted page. | Environment | URL | | ----------- | ------------------------------------------------------------------------------- | | Production | `https://api.pesepay.com/api/payments-engine/v2/payments/make-payment` | | Sandbox | `https://api.test.sandbox.pesepay.com/payments-engine/v2/payments/make-payment` | ## How it fits [Section titled “How it fits”](#how-it-fits) 1. Pick a [payment method](/payment-methods/overview/) and its required fields. 2. Build and [encrypt](/security/encryption/) the request body. 3. POST it with your integration key in the `authorization` header. 4. Decrypt the response, store the `referenceNumber`, and wait for the [result callback](/webhooks/result-callback/) or poll `pollUrl` — the customer still has to approve the payment on their side. ## Headers [Section titled “Headers”](#headers) | Parameter | Type | Required | Description | | --------------- | ------ | -------- | ---------------------------------- | | `authorization` | string | Yes | Your application’s integration key | | `content-type` | string | Yes | Must be `application/json` | ## Request body [Section titled “Request body”](#request-body) | Field | Type | Required | Description | | ----------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `amountDetails` | object | Yes | `{ amount: number, currencyCode: string }` | | `paymentMethodCode` | string | Yes | e.g. `PZW211` for EcoCash, `PZW212` for InnBucks — see [Payment Method Codes](/resources/payment-method-codes/) | | `paymentMethodRequiredFields` | object | Yes | The chosen method’s required fields as key/value pairs. Send `{}` for methods that need none (InnBucks, PayGo). Keys come from the method’s [Payment Methods](/payment-methods/overview/) page or from [Get Payment Methods by Currency](/api/get-payment-methods-by-currency/) | | `reasonForPayment` | string | Yes | A short summary of the transaction | | `resultUrl` | string | Yes | Where Pesepay posts the final result — see [The Result Callback](/webhooks/result-callback/) | | `returnUrl` | string | No | Where the customer is sent after the payment. Defaults to `resultUrl` if omitted | | `merchantReference` | string | No | Your own order/invoice reference, echoed back on the transaction | | `customer` | object | No | `{ email, phoneNumber, name }` | | `paymentMetadata` | object | No | String key/value pairs carried on the transaction and returned as `transactionMetadata` on the result. **Required** on an application with [split payments](/payments/split-payments/), which must carry `beneficiaryMerchantEmail` | Plaintext request body — EcoCash example (before encryption) ```json { "amountDetails": { "amount": 10.00, "currencyCode": "USD" }, "merchantReference": "ORDER-1042", "reasonForPayment": "Order #1042 — running shoes", "resultUrl": "https://example.com/payments/result", "returnUrl": "https://example.com/payments/return", "paymentMethodCode": "PZW211", "customer": { "email": "customer@example.com", "phoneNumber": "0777777777", "name": "Jane Customer" }, "paymentMethodRequiredFields": { "customerPhoneNumber": "0777777777" } } ``` ## Code examples [Section titled “Code examples”](#code-examples) * cURL ```bash # Encrypt your JSON body first (see the Encryption Guide), then: curl -X POST https://api.test.sandbox.pesepay.com/payments-engine/v2/payments/make-payment \ -H "authorization: YOUR_INTEGRATION_KEY" \ -H "content-type: application/json" \ -d '{"payload": "ENCRYPTED_BASE64_STRING"}' ``` * Node.js ```javascript const body = { amountDetails: { amount: 10.0, currencyCode: 'USD' }, merchantReference: 'ORDER-1042', reasonForPayment: 'Order #1042 — running shoes', resultUrl: 'https://example.com/payments/result', returnUrl: 'https://example.com/payments/return', paymentMethodCode: 'PZW211', customer: { email: 'customer@example.com', phoneNumber: '0777777777', name: 'Jane Customer', }, paymentMethodRequiredFields: { customerPhoneNumber: '0777777777' }, }; const response = await fetch( 'https://api.test.sandbox.pesepay.com/payments-engine/v2/payments/make-payment', { method: 'POST', headers: { authorization: 'YOUR_INTEGRATION_KEY', 'content-type': 'application/json', }, body: JSON.stringify({ payload: encrypt(body, ENCRYPTION_KEY) }), } ); const { payload } = await response.json(); const transaction = decrypt(payload, ENCRYPTION_KEY); ``` * Python ```python import requests body = { "amountDetails": {"amount": 10.00, "currencyCode": "USD"}, "merchantReference": "ORDER-1042", "reasonForPayment": "Order #1042 — running shoes", "resultUrl": "https://example.com/payments/result", "returnUrl": "https://example.com/payments/return", "paymentMethodCode": "PZW211", "customer": { "email": "customer@example.com", "phoneNumber": "0777777777", "name": "Jane Customer", }, "paymentMethodRequiredFields": {"customerPhoneNumber": "0777777777"}, } response = requests.post( "https://api.test.sandbox.pesepay.com/payments-engine/v2/payments/make-payment", json={"payload": encrypt(body, ENCRYPTION_KEY)}, headers={ "authorization": "YOUR_INTEGRATION_KEY", "content-type": "application/json", }, ) transaction = decrypt(response.json()["payload"], ENCRYPTION_KEY) ``` * PHP ```php ['amount' => 10.00, 'currencyCode' => 'USD'], 'merchantReference' => 'ORDER-1042', 'reasonForPayment' => 'Order #1042 — running shoes', 'resultUrl' => 'https://example.com/payments/result', 'returnUrl' => 'https://example.com/payments/return', 'paymentMethodCode' => 'PZW211', 'customer' => [ 'email' => 'customer@example.com', 'phoneNumber' => '0777777777', 'name' => 'Jane Customer', ], 'paymentMethodRequiredFields' => ['customerPhoneNumber' => '0777777777'], ]; $ch = curl_init('https://api.test.sandbox.pesepay.com/payments-engine/v2/payments/make-payment'); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([ 'payload' => encrypt($body, $encryptionKey), ])); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'authorization: YOUR_INTEGRATION_KEY', 'content-type: application/json', ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $response = json_decode(curl_exec($ch), true); curl_close($ch); $transaction = decrypt($response['payload'], $encryptionKey); ``` * Java ```java String bodyJson = """ {"amountDetails":{"amount":10.00,"currencyCode":"USD"}, "merchantReference":"ORDER-1042", "reasonForPayment":"Order #1042 — running shoes", "resultUrl":"https://example.com/payments/result", "returnUrl":"https://example.com/payments/return", "paymentMethodCode":"PZW211", "customer":{"email":"customer@example.com","phoneNumber":"0777777777","name":"Jane Customer"}, "paymentMethodRequiredFields":{"customerPhoneNumber":"0777777777"}}"""; String encryptedPayload = encrypt(bodyJson, encryptionKey); HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.test.sandbox.pesepay.com/payments-engine/v2/payments/make-payment")) .header("authorization", "YOUR_INTEGRATION_KEY") .header("content-type", "application/json") .POST(HttpRequest.BodyPublishers.ofString( "{\"payload\":\"" + encryptedPayload + "\"}" )) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); String transactionJson = decrypt(extractPayload(response.body()), encryptionKey); ``` ## Response [Section titled “Response”](#response) Decrypt the `payload` to get the transaction result. It is the **same object** you get from [Check Payment Status](/api/check-payment-status/) and in the [result callback](/webhooks/result-callback/) — the [result callback page](/webhooks/result-callback/#payload) has the complete field list and the `amountDetails` breakdown. There is no `redirectUrl` on it. | Field | Type | Description | | ------------------------------ | ------ | ---------------------------------------------------------------------------------------- | | `referenceNumber` | string | **Store this** — used to track status and match result callbacks | | `pollUrl` | string | A ready-made [Check Payment Status](/api/check-payment-status/) URL for this transaction | | `transactionStatus` | string | See [Transaction statuses](/resources/transaction-statuses/) | | `transactionStatusCode` | number | Numeric equivalent of `transactionStatus` | | `transactionStatusDescription` | string | Human-readable description of the status | | `amountDetails` | object | Amounts and fees applied to the transaction | | `transactionMetadata` | object | String key/value pairs carried on the transaction | Caution A `200` here does **not** mean you have been paid. Most methods are still awaiting the customer at this point — the status will be non-terminal. Wait for the [result callback](/webhooks/result-callback/) or poll `pollUrl` until the status is [terminal](/resources/transaction-statuses/), and only treat `SUCCESS` as paid. # OpenAPI spec & Postman > The machine-readable Pesepay API description, and a Postman collection generated from it. The Pesepay API is described as an [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) document. Use it to generate client types, build request tables, or drive an API console — instead of copying fields out of these pages by hand. | File | Use it for | | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | | `https://developers.pesepay.com/openapi.yaml` | The full spec — endpoints, schemas, examples, error shapes | | `https://developers.pesepay.com/postman/pesepay.postman_collection.json` | A Postman collection, generated from the spec — import it and fill in `apiKey` | It covers the endpoints a merchant integration uses: [Initiate Transaction](/api/initiate-transaction/), [Make Payment](/api/make-payment/), [Check Payment Status](/api/check-payment-status/), [Get Active Currencies](/api/get-active-currencies/) and [Get Payment Methods by Currency](/api/get-payment-methods-by-currency/), plus the [result callback](/webhooks/result-callback/) as an OpenAPI `webhooks` entry. ## The one thing the spec can’t model for you [Section titled “The one thing the spec can’t model for you”](#the-one-thing-the-spec-cant-model-for-you) The three authenticated endpoints don’t send or receive plain JSON. The real body, both ways, is the encrypted envelope: ```json { "payload": "" } ``` The spec shows the **decrypted** JSON as each operation’s request and response body, because that is what is useful for generating models and field tables — but a generated client, or a “try it” console, will still send the plaintext and get a `400` until you add the encryption step yourself. Operations that need it are marked `x-pesepay-transport: aes-256-cbc-envelope`. See the [Encryption Guide](/security/encryption/) for the cipher, and [Introduction](/api/introduction/#the-encrypted-envelope) for the envelope. [Get Active Currencies](/api/get-active-currencies/) and [Get Payment Methods by Currency](/api/get-payment-methods-by-currency/) are plain JSON with no auth — those work straight from a generated client. ## Generating a client [Section titled “Generating a client”](#generating-a-client) * TypeScript ```bash npx openapi-typescript https://developers.pesepay.com/openapi.yaml -o pesepay.d.ts ``` * Python ```bash pip install openapi-python-client openapi-python-client generate --url https://developers.pesepay.com/openapi.yaml ``` * Any (OpenAPI Generator) ```bash npx @openapitools/openapi-generator-cli generate \ -i https://developers.pesepay.com/openapi.yaml \ -g -o ./pesepay-client ``` Caution The spec is currently hand-maintained alongside these pages, not generated from the backend. Treat a mismatch between the two as a bug and [report it](https://developers.pesepay.com). If you need a guarantee, verify against a real sandbox call. # REST & JSON basics > The HTTP and JSON vocabulary this reference assumes — for developers new to working with web APIs. If you’ve worked with a REST API before, skip this — head to [Introduction](/api/introduction/). If not, here’s the vocabulary the rest of this reference uses. ## REST [Section titled “REST”](#rest) The Pesepay API is a **REST** API: you interact with it by sending HTTP requests to URLs (endpoints), and it follows HTTP conventions for methods, headers and status codes. Every Pesepay endpoint is one of two methods: | Method | Meaning | Pesepay endpoints | | ------ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | Read something, changing nothing | [Check Payment Status](/api/check-payment-status/), [Get Active Currencies](/api/get-active-currencies/), [Get Payment Methods by Currency](/api/get-payment-methods-by-currency/) | | `POST` | Create or submit something | [Initiate Transaction](/api/initiate-transaction/), [Make Payment](/api/make-payment/) | ## HTTP [Section titled “HTTP”](#http) Requests are made over **HTTPS** (HTTP encrypted with TLS) — plain `http://` is rejected. A request has four parts: the **method**, the **endpoint** URL, **headers** (Pesepay uses `authorization` and `content-type`), and a **body**. A response has three: a **status code**, **headers**, and a **body**. The **status code** tells you what happened: `2xx` succeeded, `4xx` means your request was wrong (bad field, missing auth), `5xx` means the server failed. The full table is on the [Errors](/api/errors/) page. ## JSON [Section titled “JSON”](#json) Request and response bodies are **JSON** — though with Pesepay the meaningful JSON is [encrypted inside an envelope](/api/introduction/#the-encrypted-envelope). JSON has a small set of types, and the field tables in this reference use these names: | Type | Example | | ------- | --------------------------------------------------------------------- | | string | `"Order #1042"` | | number | `10.50` | | boolean | `true` / `false` | | null | `null` — no value | | object | `{ "amount": 10, "currencyCode": "USD" }` — key/value pairs in braces | | array | `["USD", "ZiG"]` — an ordered list in brackets | # Card payments > Accept Visa and Mastercard payments in USD through the Pesepay-hosted payment page. Pesepay accepts Visa and Mastercard payments in USD. Visa and Mastercard are separate payment methods with their own codes and their own amount limits, but they behave identically from your side. | | Visa | Mastercard | | ----------------------- | ------------------ | ------------------ | | **Payment method code** | `PZW204` | `PZW205` | | **Currency** | USD | USD | | **Amount range** | $0.10 – $10,000.00 | $0.10 – $20,000.00 | Caution **Card payments use the [redirect flow](/payments/redirect-flow/).** There is no seamless card integration today — the customer enters their card details on the Pesepay-hosted payment page, not in your checkout. That keeps raw card data out of your systems and out of your PCI DSS scope entirely. ## Customer experience [Section titled “Customer experience”](#customer-experience) The customer picks Visa or Mastercard on the Pesepay-hosted payment page, enters their card details there, completes 3-D Secure with their bank if the card requires it, and is returned to your `returnUrl`. Pesepay’s own status wording while the payment runs is *“Your payment is being processed.”* ## How to accept cards [Section titled “How to accept cards”](#how-to-accept-cards) You don’t do anything card-specific. Create the transaction with [Initiate Transaction](/api/initiate-transaction/), redirect the customer to the `redirectUrl` you get back, and Pesepay’s page offers Visa and Mastercard alongside the other methods enabled for your application. Confirm the outcome the same way as any other method: the [result callback](/webhooks/result-callback/), backed by [Check Payment Status](/api/check-payment-status/). The customer landing back on your `returnUrl` is not proof of payment — 3-D Secure can be abandoned at the last step. ## Amount limits [Section titled “Amount limits”](#amount-limits) Visa and Mastercard have **different ceilings** — $10,000 and $20,000 respectively — so a payment your Mastercard customers can make may be rejected on Visa. Amounts above the ceiling are rejected, not collected in parts. If your checkout shows an “up to” figure or validates amounts before sending the customer to Pesepay, read `minimumAmount` and `maximumAmount` live from [Get Payment Methods by Currency](/api/get-payment-methods-by-currency/) rather than hardcoding these numbers. ## Card validation rules [Section titled “Card validation rules”](#card-validation-rules) The hosted page enforces these before a card is submitted. They’re worth knowing when you’re reading a rejected sandbox payment: | Field | Rule | | ----------- | ------------------------------------------------------------------------------------------------------------------------------ | | Card number | Digits only, spaces stripped. Visa: 13, 16, or 19 digits starting with `4`. Mastercard: 16 digits. Both must pass a Luhn check | | Expiry date | 4–7 characters, month first: `MM/YY`, `MM/YYYY`, `MM-YY`, `MM-YYYY`, `MMYY`, or `MMYYYY`. Must not already have passed | | CVV | The security number printed on the card | Failures are returned as `400`, with multiple problems joined into a single message — see [Errors](/api/errors/). ## Failure modes [Section titled “Failure modes”](#failure-modes) | What happened | What you see | | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | Card declined by the issuer | The transaction ends in a non-success [status](/resources/transaction-statuses/); the decline reason is passed through from the issuer as free text | | 3-D Secure abandoned or failed | The transaction never reaches a success status — treat it as unpaid | | Amount outside the method’s range | The payment is rejected | ## Locally-issued bank cards [Section titled “Locally-issued bank cards”](#locally-issued-bank-cards) Cards issued by Zimbabwean banks are handled through [Zimswitch](/payment-methods/zimswitch/), not through the Visa/Mastercard methods above. ## Testing [Section titled “Testing”](#testing) Sandbox card numbers for successful and failed payments are on [Test credentials](/testing/test-credentials/). # EcoCash > Accept EcoCash mobile money payments in US dollars and Zimbabwe dollars, including payments above the per-transaction limit. EcoCash is Zimbabwe’s largest mobile money network. Customers pay by entering their PIN on the phone registered to the number they’re paying with. It works in both the [redirect](/payments/redirect-flow/) and [seamless](/payments/seamless-flow/) flows, in both currencies. | | US dollars | Zimbabwe dollars | | ------------------------- | --------------------- | --------------------- | | **Payment method code** | `PZW211` | `PZW201` | | **Currency code to send** | `USD` | `ZiG` | | **Amount range** | $1.00 – $500.00 | 2.00 – 8,000.00 | | **Required field** | `customerPhoneNumber` | `customerPhoneNumber` | Amounts above the range aren’t rejected — they’re [collected in several parts](#payments-above-the-limit). No redirect is required in either currency; the payment completes on the customer’s phone. ## Customer experience [Section titled “Customer experience”](#customer-experience) * Redirect flow The customer selects EcoCash on the Pesepay-hosted payment page, enters their EcoCash-registered phone number, and enters their PIN on that phone to approve the payment. * Seamless flow You collect the customer’s phone number in your own UI and submit it as `customerPhoneNumber`. Pesepay pushes a prompt to that phone; the customer enters their EcoCash PIN to approve. Your UI should show a waiting state — Pesepay’s own wording for this step is *“Please enter PIN on the phone that is making the payment.”* ## Seamless flow: required fields [Section titled “Seamless flow: required fields”](#seamless-flow-required-fields) ```json { "paymentMethodCode": "PZW211", "paymentMethodRequiredFields": { "customerPhoneNumber": "0771234567" } } ``` | Field | Type | Required | Description | | --------------------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `customerPhoneNumber` | string | Yes | The customer’s EcoCash-registered phone number. Digits only — a leading `+` and any spaces are stripped. Must start with a recognised Econet prefix and be no more than 14 digits | Send `PZW201` instead of `PZW211` when you’re charging in Zimbabwe dollars — the field is the same. See [Make Payment](/api/make-payment/) for the full request and response shape with code samples. ## Payments above the limit [Section titled “Payments above the limit”](#payments-above-the-limit) A single EcoCash transaction can’t exceed **$500** (or **8,000** in Zimbabwe dollars). Pesepay doesn’t reject larger payments — it collects them in several parts automatically, and your integration doesn’t have to do anything different. 1. **Pesepay divides the amount into legs**, each within the ceiling. A $1,200 payment becomes three legs: $500, $500, and $200. 2. **The customer approves each leg on their phone.** They get one prompt per leg and enter their PIN each time, so a large payment takes longer and asks more of the customer than a small one. 3. **You still see one transaction.** The legs are internal — you get the same single `referenceNumber` you’d get for any other payment, one [result callback](/webhooks/result-callback/) when it’s done, and one status when you [check the payment](/api/check-payment-status/). The individual legs are never exposed to you. 4. **If any leg fails, the whole payment is reversed.** Every leg that already succeeded is refunded automatically, and the transaction ends `REVERSED`. The customer doesn’t have to ask for the money back, and you never have to handle a partly-paid order. Caution In the rare case where a reversal itself fails, the transaction ends `FAILED` rather than `REVERSED` and the customer may be temporarily out of pocket for one or more legs. Treat a `FAILED` status on a large EcoCash payment as something to reconcile, not just an unpaid order — contact Pesepay support with the `referenceNumber`. This applies to EcoCash only — every other method rejects amounts over its ceiling. Because a multi-part payment asks the customer for several PIN entries, tell them what to expect at checkout when the amount is over the limit: an unexplained second prompt is the most common reason customers abandon these payments. ## Amount limits [Section titled “Amount limits”](#amount-limits) The ranges above are configured per payment method and currency, and can change without a documentation update. Read `minimumAmount` and `maximumAmount` live from [Get Payment Methods by Currency](/api/get-payment-methods-by-currency/) if you want your checkout’s validation to stay in sync automatically. ## Failure modes [Section titled “Failure modes”](#failure-modes) | What happened | What you see | | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | Phone number isn’t a valid Econet number | `400` with `Invalid Ecocash phone number supplied` | | `customerPhoneNumber` missing | `400` with `Ecocash paying phone number should be provided` | | Customer enters the wrong PIN, cancels, or ignores the prompt | The transaction ends in a non-success [status](/resources/transaction-statuses/) | | Insufficient wallet balance | The transaction fails; the message comes from EcoCash and is passed through as free text | | One leg of a multi-part payment fails | The whole payment is reversed — see [above](#payments-above-the-limit) | Decline messages for mobile money come from EcoCash, not from Pesepay, so match on [transaction status](/resources/transaction-statuses/) rather than on message text. ## Testing [Section titled “Testing”](#testing) Sandbox numbers that force a successful or failed EcoCash payment are on [Test credentials](/testing/test-credentials/). # InnBucks > Accept InnBucks payments in USD — the customer scans a QR code or enters a short code. InnBucks is a Zimbabwean mobile wallet. Unlike EcoCash it needs no customer details from you — Pesepay generates a QR code and a short code, and the customer authorises the payment from their own InnBucks app or the InnBucks USSD menu. It works in both the [redirect](/payments/redirect-flow/) and [seamless](/payments/seamless-flow/) flows. | | | | ----------------------- | ----------------- | | **Payment method code** | `PZW212` | | **Currency** | USD | | **Amount range** | $1.00 – $1,000.00 | | **Required fields** | None | | **Redirect required** | No | ## Customer experience [Section titled “Customer experience”](#customer-experience) * Redirect flow The customer selects InnBucks on the Pesepay-hosted payment page and is shown a QR code with a short code beneath it. They either scan the QR with the InnBucks app or enter the short code through the InnBucks USSD menu, then approve the payment there. * Seamless flow You submit the payment with no customer fields. Pesepay’s own wording for what the customer does next is: *“Please scan the QR Code on the screen using your Innbucks mobile app or enter the code below the QR Code via the Innbucks USSD menu.”* Show a waiting state until the [result callback](/webhooks/result-callback/) arrives or [Check Payment Status](/api/check-payment-status/) returns a terminal status. ## Seamless flow: required fields [Section titled “Seamless flow: required fields”](#seamless-flow-required-fields) InnBucks needs no required fields — send an empty object: ```json { "paymentMethodCode": "PZW212", "paymentMethodRequiredFields": {} } ``` See [Make Payment](/api/make-payment/) for the full request and response shape with code samples. ## Amount limits [Section titled “Amount limits”](#amount-limits) InnBucks accepts **$1.00 to $1,000.00** per transaction. Unlike [EcoCash](/payment-methods/ecocash/#payments-above-the-limit), larger amounts are **not** collected in parts — they’re rejected, so validate against the ceiling in your checkout. Read `minimumAmount` and `maximumAmount` live from [Get Payment Methods by Currency](/api/get-payment-methods-by-currency/) to stay in sync if the limits change. ## Failure modes [Section titled “Failure modes”](#failure-modes) | What happened | What you see | | --------------------------------------------- | ----------------------------------------------------------------------------------------- | | Customer never scans the code, or abandons it | The transaction ends in a non-success [status](/resources/transaction-statuses/) | | Insufficient wallet balance | The transaction fails; the message comes from InnBucks and is passed through as free text | | Amount outside the $1–$1,000 range | `400` — see [Errors](/api/errors/) | ## Testing [Section titled “Testing”](#testing) Caution The sandbox currently offers EcoCash, Visa and Mastercard only, so InnBucks can’t be exercised there — see [Sandbox environment](/testing/sandbox-environment/). # Omari > Accept Omari mobile money payments in US dollars and Zimbabwe dollars. Omari is a two-step OTP payment — the customer is texted a code and you submit it to complete the charge. Omari is a Zimbabwean mobile money wallet. Unlike the other wallets, **Omari does not push an approval prompt to the phone.** Paying takes two steps: the customer is sent a one-time password by SMS, and that code has to be submitted back to Pesepay to actually move the money. Omari works in both the [redirect](/payments/redirect-flow/) and [seamless](/payments/seamless-flow/) flows, in both currencies. | | US dollars | Zimbabwe dollars | | ------------------------- | --------------------- | --------------------- | | **Payment method code** | `PZW216` | `PZW217` | | **Currency code to send** | `USD` | `ZWG` | | **Amount range** | $0.50 – $500.00 | 0.10 – 100,000.00 | | **Required field** | `customerPhoneNumber` | `customerPhoneNumber` | | **Redirect required** | No | No | Caution Charge `PZW217` with the currency code `ZWG`, not `ZiG`. Zimbabwe dollars are addressed by two different currency codes depending on the method — see [Payment method codes](/resources/payment-method-codes/) for which one goes with which. ## Customer experience [Section titled “Customer experience”](#customer-experience) * Redirect flow The customer selects Omari on the Pesepay-hosted payment page and enters the phone number registered to their Omari wallet. Omari sends them a six-digit code by SMS, and the hosted page then shows an **O’mari OTP** field for them to type it into. Nothing extra is required of you — the hosted page handles both steps. * Seamless flow You build both screens: one to collect the phone number, and one to collect the six-digit code Omari texts the customer. A waiting state alone is not enough — if you never submit the code, the payment never completes. The two calls are below. ## Seamless flow [Section titled “Seamless flow”](#seamless-flow) Omari takes **two calls**. The first sends the customer a one-time code and moves no money; the second spends that code and debits the wallet. ### Step 1: request the OTP [Section titled “Step 1: request the OTP”](#step-1-request-the-otp) Send the customer’s Omari phone number as `customerPhoneNumber` to [Make Payment](/api/make-payment/): ```json { "paymentMethodCode": "PZW216", "paymentMethodRequiredFields": { "customerPhoneNumber": "263771234567" } } ``` | Field | Type | Required | Description | | --------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------ | | `customerPhoneNumber` | string | Yes | The customer’s Omari-registered number, in full international form without a `+` — twelve digits matching `2637XXXXXXXX` | Send `PZW217` instead of `PZW216` when you’re charging in Zimbabwe dollars — the field is the same. Caution Omari is stricter about number format than the other wallets. A local form like `0771234567` is **not** accepted — send `263771234567`. Pesepay’s own checkout rejects anything that doesn’t match `2637XXXXXXXX` before it submits, and you should validate the same way so the customer sees your error rather than a failed transaction. This call does **not** debit the customer — it asks Omari to text them a code. The decrypted response comes back with a `transactionStatus` of `PENDING` and an `otpReference` in `transactionMetadata`: | Key | Description | | -------------- | ------------------------------------------------------------------------------------------------------------ | | `otpReference` | Omari’s identifier for the OTP it just sent. Store it against the transaction for support and reconciliation | Store the `referenceNumber` as usual — you need it for step 2. ### Step 2: submit the OTP [Section titled “Step 2: submit the OTP”](#step-2-submit-the-otp) Omari sends the customer a **six-digit numeric** code by SMS. Collect it in your own UI, then post it with the transaction’s `referenceNumber`. **This is the call that debits the wallet.** | Environment | URL | | ----------- | ------------------------------------------------------ | | Production | `https://api.pesepay.com/api/omari/v1/payments/charge` | ```json { "otp": "123456", "referenceNumber": "20260901103214123-A1B2C3D4" } ``` | Field | Type | Required | Description | | ----------------- | ------ | -------- | ----------------------------------------------- | | `otp` | string | Yes | The six-digit code the customer received by SMS | | `referenceNumber` | string | Yes | The `referenceNumber` from step 1 | The response carries the resulting status directly: | Field | Description | | -------------------------- | ------------------------------------------------------------------------------------------------ | | `referenceNumber` | The transaction’s reference number | | `transactionStatus` | The resulting [status](/resources/transaction-statuses/) — `SUCCESS` when the wallet was debited | | `paymentProviderMessage` | Human-readable description of that status | | `paymentProviderStatus` | Omari’s own numeric response code | | `paymentProviderReference` | Omari’s reference for the debit | ### Step 3: confirm the result [Section titled “Step 3: confirm the result”](#step-3-confirm-the-result) The charge response tells you what happened immediately, but reconcile against the [result callback](/webhooks/result-callback/) or [Check Payment Status](/api/check-payment-status/), exactly as with every other method. ## Status while you’re waiting [Section titled “Status while you’re waiting”](#status-while-youre-waiting) Between steps 1 and 2 the transaction sits at `PENDING`. That is the normal state for “the OTP has been sent but not yet submitted”, not a sign that something has gone wrong, and polling during this window keeps returning `PENDING`. If the customer abandons the flow and no OTP is ever submitted, the transaction stays non-terminal until Pesepay’s status reconciliation resolves it against Omari. It will not complete on its own. ## Amount limits [Section titled “Amount limits”](#amount-limits) Omari rejects amounts outside its configured range — **$0.50 to $500.00** for US dollars, and a much wider range in Zimbabwe dollars. Amounts above the ceiling are rejected, not collected in parts. Validate in your own checkout so the customer sees your error message rather than a failed transaction. Limits are configured per payment method **and** currency, and can change without a documentation update. Read them live from [Get Payment Methods by Currency](/api/get-payment-methods-by-currency/) (`minimumAmount` / `maximumAmount`) if you want your checkout to stay in sync automatically. ## Failure modes [Section titled “Failure modes”](#failure-modes) | What happened | What you see | | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | Phone number isn’t in `2637XXXXXXXX` form | Omari rejects it at step 1 and the transaction ends `FAILED` | | Omari won’t send the OTP | Step 1 comes back non-`PENDING` and the transaction ends `FAILED` | | Customer enters the wrong code | The transaction ends in a terminal non-success [status](/resources/transaction-statuses/), usually `DECLINED` or `AUTHORIZATION_FAILED` | | Customer never enters the code | The transaction stays `PENDING` until reconciliation resolves it | | Omari times out | The transaction ends `TIME_OUT` | | Insufficient wallet balance | The transaction ends `INSUFFICIENT_FUNDS`; the message comes from Omari and is passed through as free text | | Amount outside the range | The API rejects the request with `400` — see [Errors](/api/errors/) | Caution A rejected OTP is terminal. There is no second attempt on the same transaction — the customer has to start a new payment, which sends a new code. Build your UI around a single attempt rather than a retry loop. Failure messages for mobile money come from the wallet provider, not from Pesepay, so match on [transaction status](/resources/transaction-statuses/) rather than on message text. ## Testing [Section titled “Testing”](#testing) Caution The sandbox currently offers EcoCash, Visa and Mastercard only, so Omari can’t be exercised there — see [Sandbox environment](/testing/sandbox-environment/). # Payment methods overview > Every payment method Pesepay supports, in each currency, with codes, amount limits, and required fields. These are the payment methods live on Pesepay today. Codes and limits are configured per method **and** currency, so the same wallet has a different code — and a different amount range — in each currency it supports. ## US dollars [Section titled “US dollars”](#us-dollars) Send `USD` as the currency code. | Method | Code | Amount range | Required fields | Flows | | --------------------------------------------- | -------- | ------------------------------------------------------------------------------------------ | --------------------- | -------------------------------------------------------------------------- | | [EcoCash](/payment-methods/ecocash/) | `PZW211` | $1 – $500 ([collected in parts above](/payment-methods/ecocash/#payments-above-the-limit)) | `customerPhoneNumber` | Redirect, seamless | | [InnBucks](/payment-methods/innbucks/) | `PZW212` | $1 – $1,000 | none | Redirect, seamless | | [Omari](/payment-methods/omari/) | `PZW216` | $0.50 – $500 | `customerPhoneNumber` | Redirect, seamless ([two-step OTP](/payment-methods/omari/#seamless-flow)) | | [Zimswitch](/payment-methods/zimswitch/) | `PZW215` | $0.10 – $5,000 | none | Redirect only | | [Visa](/payment-methods/card-payments/) | `PZW204` | $0.10 – $10,000 | none | Redirect only | | [Mastercard](/payment-methods/card-payments/) | `PZW205` | $0.10 – $20,000 | none | Redirect only | ## Zimbabwe dollars [Section titled “Zimbabwe dollars”](#zimbabwe-dollars) | Method | Code | Currency code to send | Amount range | Required fields | Flows | | ---------------------------------------- | -------- | --------------------- | ------------------------------------------------------------------------------------------ | --------------------- | -------------------------------------------------------------------------- | | [EcoCash](/payment-methods/ecocash/) | `PZW201` | `ZiG` | 2 – 8,000 ([collected in parts above](/payment-methods/ecocash/#payments-above-the-limit)) | `customerPhoneNumber` | Redirect, seamless | | [PayGo](/payment-methods/paygo/) | `PZW210` | `ZiG` | 1 – 2,400 | none | Redirect, seamless | | [Zimswitch](/payment-methods/zimswitch/) | `PZW213` | `ZWG` | 1 – 100,000 | none | Redirect only | | [Omari](/payment-methods/omari/) | `PZW217` | `ZWG` | 0.10 – 100,000 | `customerPhoneNumber` | Redirect, seamless ([two-step OTP](/payment-methods/omari/#seamless-flow)) | Caution **Zimbabwe dollars use two different currency codes.** EcoCash and PayGo are charged with `ZiG`; Zimswitch and Omari are charged with `ZWG`. Both work — send the code listed on the method’s row, not whichever one you used last. [Get Active Currencies](/api/get-active-currencies/) returns only `ZiG`, so don’t use it to decide whether `ZWG` is available. Caution **Omari is the one method whose seamless flow takes two calls.** Make Payment only sends the customer an OTP by SMS; a second call submits that code to complete the charge, and the phone number must be in `2637XXXXXXXX` form. See [Omari](/payment-methods/omari/#seamless-flow) before you build against it. ## Choosing which to support [Section titled “Choosing which to support”](#choosing-which-to-support) Start with the mobile money methods your customers actually use day to day — EcoCash has the broadest reach in Zimbabwe — then add cards and Zimswitch for higher-value customers. You don’t need to support everything on day one. Watch the amount ranges when you decide. They differ sharply per method: Visa tops out at $10,000 where Mastercard allows $20,000, and only [EcoCash collects payments above its ceiling in several parts](/payment-methods/ecocash/#payments-above-the-limit) rather than rejecting them. ## Redirect vs. seamless, per method [Section titled “Redirect vs. seamless, per method”](#redirect-vs-seamless-per-method) In the [redirect flow](/payments/redirect-flow/), Pesepay’s hosted page presents whichever methods are enabled for your application — you build no method-specific UI at all, and card data never touches your systems. Every method in the tables above works this way. The [seamless flow](/payments/seamless-flow/) is where you collect each method’s required fields yourself and submit them with [Make Payment](/api/make-payment/). Card methods — [Visa, Mastercard](/payment-methods/card-payments/) and [Zimswitch](/payment-methods/zimswitch/) — are **not** available this way: card entry happens on the Pesepay-hosted page. # PayGo > Accept PayGo QR code payments in Zimbabwe dollars. PayGo is a QR-code wallet. The customer scans a code to authorise the payment — you collect nothing from them up front. It is currently offered in **Zimbabwe dollars only**. | | | | ------------------------- | --------------- | | **Payment method code** | `PZW210` | | **Currency code to send** | `ZiG` | | **Amount range** | 1.00 – 2,400.00 | | **Required fields** | None | | **Redirect required** | No | ## Customer experience [Section titled “Customer experience”](#customer-experience) The customer is shown a QR code and scans it with the PayGo app to approve the payment. On the [redirect flow](/payments/redirect-flow/) the Pesepay-hosted page renders the code for you, which is the simplest way to support PayGo. ## Seamless flow [Section titled “Seamless flow”](#seamless-flow) PayGo needs no required fields — send an empty object: ```json { "paymentMethodCode": "PZW210", "paymentMethodRequiredFields": {} } ``` Because you’re not redirecting the customer, you have to render the QR code yourself. [Make Payment](/api/make-payment/) returns it in `transactionMetadata`: | Key | Description | | ----------- | --------------------------------------------------- | | `qrCodeUrl` | URL of the QR code image to display to the customer | | `qrCode` | The PayGo transaction identifier the code encodes | Show the code, then wait for the [result callback](/webhooks/result-callback/) or poll `pollUrl` until the [status](/resources/transaction-statuses/) is terminal. ## Amount limits [Section titled “Amount limits”](#amount-limits) PayGo accepts **1.00 to 2,400.00** per transaction. Amounts above the ceiling are rejected, not collected in parts. Read `minimumAmount` and `maximumAmount` live from [Get Payment Methods by Currency](/api/get-payment-methods-by-currency/) to stay in sync if the limits change. ## Failure modes [Section titled “Failure modes”](#failure-modes) | What happened | What you see | | ------------------------------------------------- | -------------------------------------------------------------------------------------- | | The customer never scans the code, or abandons it | The transaction ends in a non-success [status](/resources/transaction-statuses/) | | Insufficient balance | The transaction fails; the message comes from PayGo and is passed through as free text | | Amount outside the range | `400` — see [Errors](/api/errors/) | ## Testing [Section titled “Testing”](#testing) Caution The sandbox currently offers US dollar methods only, so PayGo can’t be exercised there — see [Sandbox environment](/testing/sandbox-environment/). # Zimswitch > Accept payments from locally-issued bank cards through Zimswitch, in US dollars and Zimbabwe dollars. Zimswitch is Zimbabwe’s national payment switch. It lets customers pay with a debit card issued by a local bank — the domestic equivalent of paying by [Visa or Mastercard](/payment-methods/card-payments/), and the option most Zimbabwean bank customers already have in their wallet. | | US dollars | Zimbabwe dollars | | ------------------------- | ----------------- | ----------------- | | **Payment method code** | `PZW215` | `PZW213` | | **Currency code to send** | `USD` | `ZWG` | | **Amount range** | $0.10 – $5,000.00 | 1.00 – 100,000.00 | | **Required fields** | None | None | ## Customer experience [Section titled “Customer experience”](#customer-experience) The customer selects Zimswitch on the Pesepay-hosted payment page and enters their bank card details there, then completes any authentication their bank requires. Pesepay’s own status wording while this runs is *“Processing payment via Zimswitch USD.”* ## Which flow to use [Section titled “Which flow to use”](#which-flow-to-use) Caution **Zimswitch is a [redirect flow](/payments/redirect-flow/) method.** It exposes no `paymentMethodRequiredFields`, so there is nothing for you to collect and submit through [Make Payment](/api/make-payment/) — card entry happens on the Pesepay-hosted page. That also keeps card data out of your systems entirely. You don’t need to do anything Zimswitch-specific: initiate the transaction as usual and Pesepay’s hosted page offers Zimswitch alongside the other methods enabled for your application. ## Amount limits [Section titled “Amount limits”](#amount-limits) The US dollar ceiling is **$5,000** — well above [EcoCash](/payment-methods/ecocash/) and [InnBucks](/payment-methods/innbucks/), which makes Zimswitch a useful option to enable for higher-value checkouts. Unlike EcoCash, amounts above the ceiling are **rejected**, not collected in parts. Read `minimumAmount` and `maximumAmount` live from [Get Payment Methods by Currency](/api/get-payment-methods-by-currency/) if you want your checkout to stay in sync when the limits change. ## Failure modes [Section titled “Failure modes”](#failure-modes) | What happened | What you see | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | | The bank declines the card | The transaction ends in a non-success [status](/resources/transaction-statuses/); the reason is passed through from the bank as free text | | The customer abandons the page | The transaction never reaches a success status — treat it as unpaid | | Amount outside the range | `400` — see [Errors](/api/errors/) | ## Testing [Section titled “Testing”](#testing) Sandbox cards for the domestic card flow are listed as **CABS** cards on [Test credentials](/testing/test-credentials/). # Currency model > Fields on the Currency objects returned by Get Active Currencies. Currency ```json { "active": true, "code": "string", "defaultCurrency": true, "description": "string", "id": 0, "name": "string", "rateToDefault": 0 } ``` | Field | Type | Description | | ----------------- | ------- | ----------------------------------------------------- | | `active` | boolean | Whether the currency is currently active | | `code` | string | The unique code assigned to the currency (e.g. `USD`) | | `defaultCurrency` | boolean | Whether this is the system’s default currency | | `description` | string | Description of the currency | | `name` | string | The currency’s name | | `rateToDefault` | number | Exchange rate to the default currency | Returned by [Get Active Currencies](/api/get-active-currencies/). Caution Zimbabwe dollars are addressed by **two** currency codes — `ZiG` for [EcoCash](/payment-methods/ecocash/) and [PayGo](/payment-methods/paygo/), `ZWG` for [Zimswitch](/payment-methods/zimswitch/) and [Omari](/payment-methods/omari/). Both are chargeable, but [Get Active Currencies](/api/get-active-currencies/) lists only `ZiG`, so take the code from the method you are charging — see [Payment method codes](/resources/payment-method-codes/). # Payment method codes > The paymentMethodCode to send for each payment method, in each currency. Send one of these as `paymentMethodCode` in [Make Payment](/api/make-payment/). Codes are per payment method **and** currency — the same wallet has a different code in each currency it supports. | Method | US dollars (`USD`) | Zimbabwe dollars | Currency code | Seamless flow | | --------------------------------------------- | ------------------ | ---------------- | ------------- | --------------- | | [EcoCash](/payment-methods/ecocash/) | `PZW211` | `PZW201` | `ZiG` | ✅ | | [InnBucks](/payment-methods/innbucks/) | `PZW212` | — | — | ✅ | | [Omari](/payment-methods/omari/) | `PZW216` | `PZW217` | `ZWG` | ✅ | | [PayGo](/payment-methods/paygo/) | — | `PZW210` | `ZiG` | ✅ | | [Zimswitch](/payment-methods/zimswitch/) | `PZW215` | `PZW213` | `ZWG` | ❌ redirect only | | [Visa](/payment-methods/card-payments/) | `PZW204` | — | — | ❌ redirect only | | [Mastercard](/payment-methods/card-payments/) | `PZW205` | — | — | ❌ redirect only | The **currency code** column is the string to send as `currencyCode` alongside the Zimbabwe dollar code on that row. Caution Zimbabwe dollars use two different currency codes — `ZiG` for EcoCash and PayGo, `ZWG` for Omari and Zimswitch. Both are chargeable; send the one in the row for the method you’re using. [Get Active Currencies](/api/get-active-currencies/) returns only `ZiG`, so don’t use it to decide whether `ZWG` is available. Amount limits differ per code; see the [payment methods overview](/payment-methods/overview/) for the full matrix. # Payment method model > Fields on the Payment Method objects returned by Get Payment Methods by Currency. Payment Method ```json { "active": true, "code": "string", "currencies": ["string"], "description": "string", "id": 0, "maximumAmount": 0, "minimumAmount": 0, "name": "string", "processingPaymentMessage": "string", "redirectRequired": true, "redirectURL": "string", "requiredFields": [ { "displayName": "string", "fieldType": "DATE", "name": "string", "optional": true } ] } ``` | Field | Type | Description | | -------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `active` | boolean | Whether the payment method is currently active | | `code` | string | The unique code assigned to the payment method (e.g. `PZW211`) — see [Payment Method Codes](/resources/payment-method-codes/) | | `currencies` | array | Currencies this method accepts | | `description` | string | Description of the payment method | | `maximumAmount` | number | Maximum transactable amount for this method | | `minimumAmount` | number | Minimum transactable amount for this method | | `name` | string | The payment method’s name | | `processingPaymentMessage` | string | Message to display while the transaction is processing | | `redirectRequired` | boolean | Whether this method requires a redirect to complete | | `redirectURL` | string | The redirect URL, when `redirectRequired` is `true` | | `requiredFields` | array | Fields you must collect and send in `paymentMethodRequiredFields` for the [seamless flow](/payments/seamless-flow/). `fieldType` is one of `DATE`, `FILE`, `NUMBER`, or `TEXT` | Returned by [Get Payment Methods by Currency](/api/get-payment-methods-by-currency/). Build your seamless-flow forms from `requiredFields` dynamically instead of hardcoding fields per method. # Transaction model > Every field on the full Transaction entity, and how it relates to what the API actually returns. This is the **full transaction entity**. The endpoints you integrate against return smaller views of it: * [Initiate Transaction](/api/initiate-transaction/#response) returns only `referenceNumber`, `redirectUrl` and `pollUrl`. * [Make Payment](/api/make-payment/#response), [Check Payment Status](/api/check-payment-status/) and the [result callback](/webhooks/result-callback/#payload) return the transaction **result** — the subset listed on the result callback page. It has no `redirectUrl`, `redirectRequired`, `settlementMode`, `liquidationStatus` or `paymentMethodDetails`. Use the table below to understand a field’s meaning; use the pages above for what each call actually gives you. Transaction (full entity) ```json { "amountDetails": { "amount": 0, "currencyCode": "string", "customerPayableAmount": 0, "defaultCurrencyAmount": 0, "defaultCurrencyCode": "string", "formattedMerchantAmount": "string", "merchantAmount": 0, "totalTransactionAmount": 0, "transactionServiceFee": 0 }, "applicationCode": "string", "applicationName": "string", "chargeType": "NO_CHARGE", "customer": { "contactNumbers": ["string"], "email": "string", "name": "string" }, "customerAmountPaid": { "amountPaid": 0, "currencyCode": "string" }, "dateOfTransaction": "string", "id": 0, "internalReference": "string", "liquidationStatus": "COMPLETED", "liquidationTransactionReference": "string", "merchantReference": "string", "paymentMetadata": {}, "paymentMethodDetails": { "paymentMethodCode": "string", "paymentMethodId": 0, "paymentMethodMessage": "string", "paymentMethodName": "string", "paymentMethodReference": "string", "paymentMethodStatus": "string" }, "pollUrl": "string", "reasonForPayment": "string", "redirectRequired": true, "redirectUrl": "string", "referenceNumber": "string", "resultUrl": "string", "returnUrl": "string", "settlementMode": "DIRECTLY_SETTLED", "transactionStatus": "AUTHORIZATION_FAILED", "transactionType": "BASIC" } ``` | Field | Type | Description | | --------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------- | | `amountDetails` | object | Currency, amount charged to the customer, and fees for the transaction | | `applicationCode` | string | The unique code assigned to your application on creation | | `applicationName` | string | Your application’s name | | `chargeType` | string | `NO_CHARGE`, `SHARED_TRANSACTIONAL_CHARGE`, `TRANSACTIONAL_CHARGE_FOR_CUSTOMER`, or `TRANSACTIONAL_CHARGE_FOR_MERCHANT` | | `customer` | object | The customer’s details | | `customerAmountPaid` | object | The amount actually paid by the customer | | `dateOfTransaction` | string | Date and time the transaction was initiated | | `internalReference` | string | Pesepay’s internal transaction reference | | `liquidationStatus` | string | `COMPLETED`, `DUE_FOR_LIQUIDATION`, `IN_PROGRESS`, `NO_LIQUIDATION_REQUIRED`, `PENDING`, or `WAITING_FOR_DETERMINATION` | | `liquidationTransactionReference` | string | The liquidation transaction reference | | `merchantReference` | string | Your own reference for the transaction | | `paymentMetadata` | object | Additional payment information | | `paymentMethodDetails` | object | Details of the payment method used | | `pollUrl` | string | URL to poll for a change in transaction status | | `reasonForPayment` | string | The subject of the transaction | | `redirectRequired` | boolean | Whether the payment method requires a redirect | | `redirectUrl` | string | The redirect URL, when applicable | | `referenceNumber` | string | The transaction’s reference number — your primary tracking key | | `resultUrl` | string | The URL Pesepay posts the transaction result to | | `returnUrl` | string | The URL the customer is returned to after processing | | `settlementMode` | string | How the transaction settles | | `transactionStatus` | string | See [Transaction Statuses](/resources/transaction-statuses/) | | `transactionType` | string | `BASIC` or `INVOICE` | # Transaction statuses > Every value transactionStatus can hold, its numeric code, and whether it's terminal. `transactionStatus` tells you where a payment stands. It appears in [Check Payment Status](/api/check-payment-status/) responses and in the [result callback](/webhooks/result-callback/), alongside `transactionStatusCode` (the numeric equivalent) and `transactionStatusDescription` (a human-readable message). **Terminal** statuses are final — the payment will not change again, and a [result callback](/webhooks/result-callback/) fires when one is reached. Stop polling once you see one. ## In progress (keep checking) [Section titled “In progress (keep checking)”](#in-progress-keep-checking) | Status | Code | Meaning | | ---------------- | ----- | --------------------------------- | | `INITIATED` | `301` | Transaction has been initiated | | `PROCESSING` | `302` | Transaction is being processed | | `PENDING` | `303` | Transaction is pending processing | | `PARTIALLY_PAID` | `315` | Transaction is partially paid | Caution `PARTIALLY_PAID` is **not** terminal, and no callback fires for it — it’s a payment still in flight, not a final outcome. Don’t fulfil an order on it, and don’t stop polling. ## Terminal — paid [Section titled “Terminal — paid”](#terminal--paid) | Status | Code | Meaning | | --------- | ----- | -------------------------------------- | | `SUCCESS` | `304` | Transaction was successfully completed | `SUCCESS` is the only status that means you have been paid. Treat every other terminal status as unpaid. ## Terminal — not paid [Section titled “Terminal — not paid”](#terminal--not-paid) | Status | Code | Meaning | | ----------------------- | ----- | ------------------------------------------------------- | | `FAILED` | `300` | Transaction has failed | | `TERMINATED` | `305` | Transaction was terminated | | `TIME_OUT` | `306` | Transaction timed out | | `CLOSED` | `307` | Transaction is closed | | `CLOSED_PERIOD_ELAPSED` | `307` | Closed by Pesepay — the transaction’s period elapsed | | `INSUFFICIENT_FUNDS` | `308` | Transaction failed due to insufficient funds | | `CANCELLED` | `309` | Transaction was cancelled | | `ERROR` | `310` | An error occurred | | `DECLINED` | `311` | Declined by the service provider | | `AUTHORIZATION_FAILED` | `312` | Authorization failed at the customer’s service provider | | `SERVICE_UNAVAILABLE` | `313` | The payment provider was unavailable | | `REVERSED` | `314` | A previously successful payment was reversed | `REVERSED` is what you see when an [EcoCash payment above the $500 limit](/payment-methods/ecocash/#payments-above-the-limit) was collected in several legs and one of them failed: the legs that succeeded were refunded automatically. `DECLINED`, `INSUFFICIENT_FUNDS`, `AUTHORIZATION_FAILED`, `TIME_OUT`, and `CANCELLED` are all normal, expected outcomes — handle them gracefully in your UI rather than treating them as integration bugs. See [Checking Payment Status](/payments/checking-status/) for how to read this field via the [result callback](/webhooks/result-callback/) or [Check Payment Status](/api/check-payment-status/). # Building with AI tools > Machine-readable versions of these docs, and a prompt that stops AI coding tools inventing Pesepay endpoints and fields. AI coding assistants write plausible Pesepay integrations that don’t work. Pesepay is small enough that models have little real training data on it, so they fall back on what other gateways do: a `publishableKey`, a webhook signature header, plain JSON request bodies, a `ZWL` currency code. Every one of those is wrong here, and the code fails at runtime rather than at review. Two things fix most of it: give the model the real docs, and tell it not to fill gaps from memory. ## Machine-readable sources [Section titled “Machine-readable sources”](#machine-readable-sources) This site publishes itself in formats built for models. Paste a URL into your assistant, or attach the file to your project. | URL | What it is | | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | | [`/llms.txt`](https://developers.pesepay.com/llms.txt) | An index of every page, with the rules that are most often got wrong stated up front | | [`/llms-full.txt`](https://developers.pesepay.com/llms-full.txt) | The entire documentation site as one text file | | [`/llms-small.txt`](https://developers.pesepay.com/llms-small.txt) | A compact build for smaller context windows | | [`/_llms-txt/api-reference.txt`](https://developers.pesepay.com/_llms-txt/api-reference.txt) | Every endpoint, field, error and data model, without the guides | | [`/_llms-txt/payment-methods.txt`](https://developers.pesepay.com/_llms-txt/payment-methods.txt) | Per-method codes, currencies, amount limits, required fields and failure modes | | [`/_llms-txt/getting-started.txt`](https://developers.pesepay.com/_llms-txt/getting-started.txt) | Onboarding, the quickstart, both payment flows and the encryption scheme | | [`/openapi.yaml`](https://developers.pesepay.com/openapi.yaml) | The OpenAPI 3.1 spec — see [OpenAPI spec & Postman](/api/openapi/) | For an agent that can fetch as it works, `/llms.txt` plus permission to follow its links beats pasting one page. For a one-shot prompt, attach `/llms-full.txt`. ## A prompt to start from [Section titled “A prompt to start from”](#a-prompt-to-start-from) Paste this above your own request. The constraints matter more than the wording — the point is to make “I don’t know” a permitted answer. Pesepay integration prompt ```markdown You are helping me integrate Pesepay, a Zimbabwean payment gateway. Ground rules — follow these exactly: 1. Use ONLY the Pesepay documentation I have given you (https://developers.pesepay.com/llms-full.txt and https://developers.pesepay.com/openapi.yaml). Do not fill gaps from memory of Stripe, Paystack, Flutterwave or any other gateway. 2. Do NOT invent endpoints, request or response fields, currency codes, payment method codes, transaction statuses, headers, or security mechanisms. If something you need is not in the documentation, say so and stop — do not produce a plausible substitute. 3. Quote the source: for every endpoint and field you use, name the page or spec path it comes from. 4. Flag any assumption you make, in a list at the end. Facts about Pesepay that contradict most other gateways — apply them: - Request and response bodies are ENCRYPTED, not plain JSON. Every integration endpoint takes {"payload": ""}, where the payload is the JSON body encrypted with AES-256-CBC. The key is the 32-character application encryption key; the IV is the FIRST 16 CHARACTERS OF THAT SAME KEY. Responses are encrypted the same way. Error responses are NOT encrypted. - There are two different keys and they are not interchangeable: the integration key goes in the `authorization` header; the encryption key is used only for the cipher above. There is no publishable or client-side key — nothing may be called from frontend code. - Result callbacks are UNSIGNED, UNENCRYPTED, and NEVER RETRIED. There is no signature header to verify. Confirm every payment by calling check-payment-status with the reference number. - Zimbabwe dollars are addressed by two distinct currency codes that are NOT aliases: `ZiG` (EcoCash, PayGo) and `ZWG` (Zimswitch, Omari). `ZWL` is a legacy code that appears nowhere in these APIs — never emit it. - Card payments (Visa, Mastercard, Zimswitch) are redirect-only. There is no seamless/direct card integration. - Seamless make-payment is POST /v2/payments/make-payment in both production and sandbox. - Sandbox and production keys are environment-specific and fail confusingly when crossed. My task: ``` ## Reviewing what it produces [Section titled “Reviewing what it produces”](#reviewing-what-it-produces) Generated Pesepay code fails in a small number of recognisable ways. Check these before you run it: | Check | Why | | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | Is the body sent as `{"payload": "..."}`? | Plain JSON bodies are the most common generated mistake | | Is the IV the first 16 characters of the encryption key? | Models default to a random IV prefixed to the ciphertext, which decrypts to nothing | | Is it verifying a webhook signature? | There isn’t one. Any signature-verification code is invented | | Does it treat the callback as final? | It isn’t — the status must be re-fetched | | Are the keys reachable from the client? | Any key in frontend code is a live credential leak | | Are currency and payment method codes ones the docs list? | `ZWL` and invented `PZW` codes are not real — check them against [Payment method codes](/resources/payment-method-codes/) | Caution Assistants also invent *endpoints* — refunds, payouts, customer objects, subscription APIs. If generated code calls a path that isn’t in [the API reference](/api/introduction/) or `/openapi.yaml`, that endpoint does not exist, however reasonable it looks. # Community libraries > Pesepay libraries built by other developers for platforms the official SDKs and plugins don't cover yet. Developers in the Pesepay community have built libraries for platforms the [official SDKs](/sdks/overview/) and [plugins](/sdks/plugins/) don’t cover. These are community projects we know about that met our [listing criteria](#listing-criteria) when they were added. Not maintained, supported or guaranteed by Pesepay Each library is written and maintained by its author. Pesepay can’t vouch for its accuracy, completeness or security, and support can help with the API itself, not with a library’s code. Evaluate any library yourself before you use it with live keys, pin the version you tested, and read the changes before you upgrade. [.NET — Stelele.PesePay ](https://github.com/Stelele/pesepay)NuGet package with dependency injection and sandbox support [ERPNext / Frappe ](https://github.com/Stelele/frappe-pesepay)Payment gateway app for Frappe and ERPNext sites [Dart — pesepay ](https://pub.dev/packages/pesepay)Dart client for server-side use ## .NET [Section titled “.NET”](#net) [Stelele.PesePay](https://github.com/Stelele/pesepay) targets .NET 8, 9 and 10, and switches between the sandbox and production. ```bash dotnet add package Stelele.PesePay ``` It registers a single shared client through dependency injection, reading your keys from configuration rather than code: ```csharp builder.Services.AddPesePay(builder.Configuration.GetSection("PesePay")); ``` Keep the keys in user secrets or environment variables, not in a committed `appsettings.json`. The project ships unit tests and a sandbox integration suite. MIT licensed. ## ERPNext / Frappe [Section titled “ERPNext / Frappe”](#erpnext--frappe) [frappe-pesepay](https://github.com/Stelele/frappe-pesepay) adds Pesepay as a payment gateway to a Frappe site. With ERPNext installed it also creates the Mode of Payment and gateway accounts, and records a Payment Entry when a payment settles. It needs Frappe’s `payments` app. ```bash bench get-app https://github.com/Stelele/frappe-pesepay --branch version-16 bench --site install-app pesepay ``` It settles payments by [checking their status](/api/check-payment-status/) with Pesepay from a background job, not by acting on the [result callback](/webhooks/result-callback/) — the safe way round, but it means a payment shows as paid on the scheduler’s next run, typically within a few minutes, rather than the instant the customer finishes. MIT licensed. ## Dart [Section titled “Dart”](#dart) The [pesepay](https://pub.dev/packages/pesepay) package on pub.dev is a plain Dart client covering both the redirect and seamless flows. ```bash dart pub add pesepay ``` Server-side Dart only — not inside a Flutter app The package’s own README shows it running inside a Flutter widget. Don’t do that: constructing it in an app puts your integration key and encryption key in every copy of the app, where anyone can extract them. Run it in Dart server code or a [serverless function](/sdks/serverless/), and have your app fetch the `redirectUrl` from there. It calls production only — there’s no sandbox setting — so do your [sandbox testing](/testing/sandbox-environment/) over raw HTTP or another SDK before switching it on. MIT licensed. ## Listing criteria [Section titled “Listing criteria”](#listing-criteria) To be listed, a library must: * Encrypt and decrypt as the [encryption guide](/security/encryption/) specifies, and call the current endpoints * Talk to Pesepay over HTTPS with certificate verification left on * Keep both keys on the server — nothing that runs in a browser or a mobile app * Confirm payment outcomes with [Check Payment Status](/api/check-payment-status/) rather than trusting a callback body, if it handles callbacks at all ([why](/webhooks/verifying-callbacks/)) * Have public source, an open-source license, a release on its language’s package registry, and activity in the last 12 months Meeting these criteria when a library is added isn’t a guarantee about that version or any later one. A library may be removed if it stops meeting them or goes unmaintained. ## Getting your library listed [Section titled “Getting your library listed”](#getting-your-library-listed) Built something for Pesepay? Email the repository link and the published package name to , and say how it meets the criteria above. # SDKs > Official Pesepay libraries for Node.js, Python, PHP, and Java. Official SDKs wrap the [encryption](/security/encryption/) and HTTP calls for you — use one if your language is covered. Caution The SDKs call the **production** API — none of them takes a sandbox base URL. While you’re building against the [sandbox](/testing/sandbox-environment/), use the raw HTTP calls in the [API reference](/api/introduction/), which show both environments’ URLs. * [![](/_astro/nodejs.DiEzk2oT_Zm5LbI.svg) Node.js `npm install pesepay`](https://www.npmjs.com/package/pesepay) * [![](/_astro/python.CHbrEgUk_Zm5LbI.svg) Python `pip install pesepay`](https://pypi.org/project/pesepay) * [![](/_astro/php.C_tsUqeZ_Zm5LbI.svg) PHP `composer require codevirtus/pesepay`](https://github.com/codevirtus/pesepay-php) * [![](/_astro/java.D14CAP56_Zm5LbI.svg) Java `Maven Central — com.pesepay:pesepay`](https://github.com/codevirtus/pesepay-java) * [![](/_astro/github.D1dQpSsN_Zm5LbI.svg) Ruby `gem install pesepay`](https://github.com/codevirtus/pesepay-ruby) ## Install and initialize [Section titled “Install and initialize”](#install-and-initialize) * Node.js ```bash npm install pesepay ``` ```javascript const { Pesepay } = require('pesepay'); const pesepay = new Pesepay('INTEGRATION_KEY', 'ENCRYPTION_KEY'); pesepay.resultUrl = 'https://example.com/result'; pesepay.returnUrl = 'https://example.com/return'; ``` * Python ```bash pip install pesepay ``` ```python from pesepay import Pesepay pesepay = Pesepay("INTEGRATION_KEY", "ENCRYPTION_KEY") pesepay.result_url = "https://example.com/result" pesepay.return_url = "https://example.com/return" ``` * PHP ```bash composer require codevirtus/pesepay ``` ```php returnUrl = "https://example.com/return"; $pesepay->resultUrl = "https://example.com/result"; ``` * Java ```xml com.pesepay pesepay 1.0.0 ``` ```java Pesepay pesepay = new Pesepay(integrationKey, encryptionKey); pesepay.setResultUrl("https://example.com/result"); pesepay.setReturnUrl("https://example.com/return"); ``` ## Redirect flow [Section titled “Redirect flow”](#redirect-flow) * Node.js ```javascript const transaction = pesepay.createTransaction(amount, 'CURRENCY_CODE', 'PAYMENT_REASON'); pesepay .initiateTransaction(transaction) .then((response) => { const redirectUrl = response.redirectUrl; // send the customer here const referenceNumber = response.referenceNumber; // store for tracking }) .catch((error) => { /* handle error */ }); ``` * Python ```python transaction = pesepay.create_transaction(amount, "CURRENCY_CODE", "PAYMENT_REASON") try: response = pesepay.initiate_transaction(transaction) redirect_url = response.redirect_url # send the customer here reference_number = response.reference_number # store for tracking except Exception as err: pass # handle error ``` * PHP ```php $transaction = $pesepay->createTransaction($amount, 'CURRENCY_CODE', 'PAYMENT_REASON', 'MERCHANT_REFERENCE'); $response = $pesepay->initiateTransaction($transaction); if ($response->success()) { $redirectUrl = $response->redirectUrl(); $referenceNumber = $response->referenceNumber(); } else { $errorMessage = $response->message(); } ``` * Java ```java Transaction transaction = pesepay.createTransaction(amount, "CURRENCY_CODE", "PAYMENT_REASON"); Response response = pesepay.initiateTransaction(transaction); if (response.isSuccess()) { String redirectUrl = response.getRedirectUrl(); String referenceNumber = response.getReferenceNumber(); } else { String errorMessage = response.getMessage(); } ``` ## Seamless flow [Section titled “Seamless flow”](#seamless-flow) * Node.js ```javascript const payment = pesepay.createPayment( 'CURRENCY_CODE', 'PAYMENT_METHOD_CODE', 'CUSTOMER_EMAIL', 'CUSTOMER_PHONE_NUMBER', 'CUSTOMER_NAME' ); const requiredFields = { customerPhoneNumber: '0777777777' }; pesepay .makeSeamlessPayment(payment, 'PAYMENT_REASON', amount, requiredFields) .then((response) => { const pollUrl = response.pollUrl; const referenceNumber = response.referenceNumber; }) .catch((error) => { /* handle error */ }); ``` * Python ```python payment = pesepay.create_payment( "CURRENCY_CODE", "PAYMENT_METHOD_CODE", "CUSTOMER_EMAIL", "CUSTOMER_PHONE_NUMBER", "CUSTOMER_NAME" ) required_fields = {"customerPhoneNumber": "0777777777"} try: response = pesepay.make_seamless_payment(payment, "PAYMENT_REASON", amount, required_fields) poll_url = response.poll_url reference_number = response.reference_number except Exception as err: pass # handle error ``` * PHP ```php $payment = $pesepay->createPayment('CURRENCY_CODE', 'PAYMENT_METHOD_CODE', 'CUSTOMER_EMAIL', 'CUSTOMER_PHONE_NUMBER', 'CUSTOMER_NAME'); $requiredFields = ['customerPhoneNumber' => '0777777777']; $response = $pesepay->makeSeamlessPayment($payment, 'PAYMENT_REASON', $amount, $requiredFields, 'MERCHANT_REFERENCE'); if ($response->success()) { $referenceNumber = $response->referenceNumber(); $pollUrl = $response->pollUrl(); } else { $errorMessage = $response->message(); } ``` * Java ```java Payment payment = pesepay.createPayment("CURRENCY_CODE", "PAYMENT_METHOD_CODE", "CUSTOMER_EMAIL"); Map requiredFields = new HashMap<>(); requiredFields.put("customerPhoneNumber", "0777777777"); Response response = pesepay.makeSeamlessPayment(payment, "PAYMENT_REASON", amount, requiredFields); if (response.isSuccess()) { String pollUrl = response.getPollUrl(); String referenceNumber = response.getReferenceNumber(); } else { String errorMessage = response.getMessage(); } ``` ## Plugins [Section titled “Plugins”](#plugins) Running WooCommerce, PrestaShop, Moodle or WHMCS? Install the [plugin](/sdks/plugins/) instead — no integration code at all. # Plugins > Drop-in Pesepay payment gateways for WooCommerce, PrestaShop, Moodle, and WHMCS. If your store or platform is one of these, you don’t need to write an integration at all — install the plugin, paste your [integration key and encryption key](/getting-started/onboarding/), and start taking payments. [WooCommerce ](https://wordpress.org/plugins/pesepay/)WordPress plugin directory — install from your dashboard [PrestaShop ](https://github.com/codevirtus/pesepay-prestashop)Module, installed from a ZIP [Moodle ](https://github.com/codevirtus/pesepay-moodle)Payment gateway for Moodle's payments subsystem [WHMCS ](https://github.com/codevirtus/pesepay-whmcs)Payment gateway module for WHMCS billing ## WooCommerce [Section titled “WooCommerce”](#woocommerce) The [Pesepay plugin](https://wordpress.org/plugins/pesepay/) is published in the WordPress plugin directory, so it installs and updates like any other plugin. 1. In your WordPress dashboard, go to **Plugins → Add New**, search for **Pesepay**, and click **Install Now**, then **Activate**. 2. Open the Pesepay settings and paste your **integration key** and **encryption key** from the [Pesepay dashboard](/getting-started/onboarding/). 3. Turn on **test mode** and run a payment with the [sandbox test credentials](/testing/test-credentials/) before going live. | | | | ------------------- | ------------------------------------------------------------------------------------------------- | | **Requires** | WordPress 4.0+, PHP 7.1+, WooCommerce | | **Current version** | 1.3.0 | | **Source** | [codevirtus/pesepay-woocommerce-plugin](https://github.com/codevirtus/pesepay-woocommerce-plugin) | It supports WooCommerce checkout blocks, and checks payment status in the background if the customer closes the Pesepay page without clicking through — so orders still settle when a customer wanders off, the same problem the [retries guidance](/webhooks/retries/) covers for custom integrations. ## PrestaShop [Section titled “PrestaShop”](#prestashop) The [PrestaShop module](https://github.com/codevirtus/pesepay-prestashop) adds Pesepay as a payment option and moves the order through PrestaShop’s statuses as the transaction progresses. Download the repository as a ZIP, rename the folder to match the module name, and install it from **Modules → Upload a module**, then add your keys in its configuration. ## Moodle [Section titled “Moodle”](#moodle) The [Moodle plugin](https://github.com/codevirtus/pesepay-moodle) plugs into Moodle’s payments subsystem, so you can charge for course enrolment. Install it by uploading the ZIP through **Site administration → Plugins → Install plugins**, or by placing the files in `{moodle-root}/payment/gateway/pesepay` and running the upgrade. Licensed GPL v3. ## WHMCS [Section titled “WHMCS”](#whmcs) The [WHMCS module](https://github.com/codevirtus/pesepay-whmcs) lets hosting and services businesses take EcoCash, Zimswitch, Mastercard and Visa payments on invoices. Upload `pesepay.php` into your WHMCS `modules/gateways/` directory and the callback handler into `modules/gateways/callbacks/`, then activate and configure it in **Setup → Payments → Payment Gateways**. ## Building your own [Section titled “Building your own”](#building-your-own) Not on this list? Check the [community libraries](/sdks/community/), use an [SDK](/sdks/overview/) for your language, or work straight from the [API reference](/api/introduction/). The [redirect flow](/payments/redirect-flow/) is the shortest path — it’s what these plugins use. # Serverless & edge functions > Call Pesepay from a serverless function instead of your frontend, with a complete Supabase Edge Functions example. If your app has no traditional backend — a React or Flutter frontend on top of Supabase, Firebase, or a static host — you still need somewhere server-side to hold your keys. A single function is enough. Danger **Never call Pesepay from frontend code.** Your integration key and encryption key would ship to every browser and every phone that installs your app: anyone can read them out of the network tab or the bundle, and take payments as you. There is no client-safe subset of the Pesepay API and no publishable key — everything goes through your server. The shape is the same on every platform: the function is the only thing that ever holds a key, and the only thing that ever talks to Pesepay. Browser Your function Pesepay 1 Start checkout an order id — never an amount 2 Initiate the transaction keys never leave here 3 redirectUrl 4 redirectUrl browser goes to checkout 5 Result callback unsigned, never retried 6 Confirm the status the answer you trust 1. Browser → Your function Start checkout an order id — never an amount 2. Your function → Pesepay Initiate the transaction keys never leave here 3. Pesepay → Your function redirectUrl 4. Your function → Browser redirectUrl browser goes to checkout 5. Pesepay → Your function Result callback unsigned, never retried 6. Your function → Pesepay Confirm the status the answer you trust A payment through a serverless function The example below uses **Supabase Edge Functions**, but the same three functions map directly onto Vercel and Netlify functions, Cloudflare Workers, Firebase Cloud Functions, or AWS Lambda. ## What you need [Section titled “What you need”](#what-you-need) Keys go in Supabase secrets, never in the repo: ```bash supabase secrets set \ PESEPAY_INTEGRATION_KEY=your_integration_key \ PESEPAY_ENCRYPTION_KEY=your_32_character_encryption_key \ PESEPAY_BASE_URL=https://api.test.sandbox.pesepay.com/payments-engine ``` ## The shared helper [Section titled “The shared helper”](#the-shared-helper) Supabase Edge Functions run on Deno, which supports Node’s built-in `crypto` through the `node:` prefix — so the [standard encryption helpers](/security/encryption/) work unchanged. supabase/functions/\_shared/pesepay.ts ```typescript import { createCipheriv, createDecipheriv } from 'node:crypto'; import { Buffer } from 'node:buffer'; const KEY = Deno.env.get('PESEPAY_ENCRYPTION_KEY')!; const INTEGRATION_KEY = Deno.env.get('PESEPAY_INTEGRATION_KEY')!; const BASE_URL = Deno.env.get('PESEPAY_BASE_URL')!; // The IV is the first 16 characters of the encryption key itself. const iv = Buffer.from(KEY.substring(0, 16), 'utf8'); const key = Buffer.from(KEY, 'utf8'); export function encrypt(data: unknown): string { const cipher = createCipheriv('aes-256-cbc', key, iv); return cipher.update(JSON.stringify(data), 'utf8', 'base64') + cipher.final('base64'); } export function decrypt(payload: string): T { const decipher = createDecipheriv('aes-256-cbc', key, iv); const json = decipher.update(payload, 'base64', 'utf8') + decipher.final('utf8'); return JSON.parse(json) as T; } export async function pesepay(path: string, body?: unknown): Promise { const response = await fetch(`${BASE_URL}${path}`, { method: body ? 'POST' : 'GET', headers: { authorization: INTEGRATION_KEY, 'content-type': 'application/json', }, body: body ? JSON.stringify({ payload: encrypt(body) }) : undefined, }); // Error bodies are plain JSON, not encrypted — don't try to decrypt them. if (!response.ok) { const error = await response.json(); throw new Error(`Pesepay ${response.status}: ${error.message}`); } const { payload } = await response.json(); return decrypt(payload); } ``` ## 1. Initiating a payment [Section titled “1. Initiating a payment”](#1-initiating-a-payment) The frontend sends an order id — never an amount, and never a currency the user could tamper with. The function looks the order up, prices it itself, and returns only the redirect URL. supabase/functions/create-payment/index.ts ```typescript import { createClient } from 'jsr:@supabase/supabase-js@2'; import { pesepay } from '../_shared/pesepay.ts'; Deno.serve(async (req) => { const { orderId } = await req.json(); // Service-role client: server-side only, bypasses RLS. const db = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! ); const { data: order, error } = await db .from('orders') .select('id, amount, currency_code, description') .eq('id', orderId) .single(); if (error || !order) { return new Response(JSON.stringify({ error: 'Order not found' }), { status: 404 }); } const transaction = await pesepay<{ referenceNumber: string; redirectUrl: string }>( '/v1/payments/initiate', { amountDetails: { amount: order.amount, currencyCode: order.currency_code }, reasonForPayment: order.description, merchantReference: order.id, resultUrl: `${Deno.env.get('SUPABASE_URL')}/functions/v1/payment-callback`, returnUrl: `https://example.com/orders/${order.id}`, } ); await db .from('orders') .update({ reference_number: transaction.referenceNumber, status: 'PENDING' }) .eq('id', order.id); return new Response(JSON.stringify({ redirectUrl: transaction.redirectUrl }), { headers: { 'content-type': 'application/json' }, }); }); ``` ## 2. Checking status [Section titled “2. Checking status”](#2-checking-status) supabase/functions/payment-status/index.ts ```typescript import { pesepay } from '../_shared/pesepay.ts'; Deno.serve(async (req) => { const referenceNumber = new URL(req.url).searchParams.get('referenceNumber'); const result = await pesepay<{ transactionStatus: string }>( `/v1/payments/check-payment?referenceNumber=${referenceNumber}` ); return new Response(JSON.stringify({ status: result.transactionStatus }), { headers: { 'content-type': 'application/json' }, }); }); ``` Poll this from the frontend while the customer is paying — but treat the callback below, not the poll, as what settles the order. ## 3. Handling the callback [Section titled “3. Handling the callback”](#3-handling-the-callback) Two things make the callback function different from the other two: * **It must be publicly reachable.** Pesepay sends no Supabase JWT, so deploy it with `--no-verify-jwt` (or set `verify_jwt = false` in `supabase/config.toml`). * **Its body is plain JSON, not encrypted**, and carries no signature. Don’t decrypt it, and don’t trust it — use it only as a trigger to go and ask Pesepay what really happened. See [Verifying callbacks](/webhooks/verifying-callbacks/). supabase/functions/payment-callback/index.ts ```typescript import { createClient } from 'jsr:@supabase/supabase-js@2'; import { pesepay } from '../_shared/pesepay.ts'; Deno.serve(async (req) => { // Pesepay sends your own integration key in the Authorization header. // A cheap first filter, not proof — the confirmation below is what counts. // Genuine callbacks very occasionally arrive without the header, so the // reconciliation job is what catches anything dropped here. if (req.headers.get('authorization') !== Deno.env.get('PESEPAY_INTEGRATION_KEY')) { return new Response('OK', { status: 200 }); // ignore, don't advertise } const callback = await req.json(); // plain JSON, unsigned const reference = callback.referenceNumber; // Re-fetch the authoritative status; never trust the callback body. const result = await pesepay<{ transactionStatus: string; amountDetails: { amount: number } }>( `/v1/payments/check-payment?referenceNumber=${reference}` ); const db = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! ); // Idempotent: only move an order that is still pending, so a duplicate // callback can't fulfil the same order twice. await db .from('orders') .update({ status: result.transactionStatus, paid_amount: result.amountDetails.amount }) .eq('reference_number', reference) .eq('status', 'PENDING'); // Always 200. Pesepay does not retry, and a non-200 changes nothing. return new Response('OK', { status: 200 }); }); ``` Caution **There are no retries.** If your function is cold, erroring, or briefly down when Pesepay posts, that result is gone — nothing is redelivered. A scheduled job that re-checks orders left `PENDING` for more than a few minutes is not optional; see [Retries & reliability](/webhooks/retries/). Deploy: ```bash supabase functions deploy create-payment supabase functions deploy payment-status supabase functions deploy payment-callback --no-verify-jwt ``` ## Other platforms [Section titled “Other platforms”](#other-platforms) | Platform | Notes | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Vercel / Netlify functions | Node runtime — use `crypto` directly and `process.env`. No changes to the logic | | Firebase Cloud Functions | Same as above; set keys with `firebase functions:secrets:set` | | Cloudflare Workers | Add `compatibility_flags = ["nodejs_compat"]` to `wrangler.toml`, then `import { createCipheriv } from 'node:crypto'` as above. Without that flag there is no `node:crypto` | | AWS Lambda | Node runtime; keep keys in Secrets Manager or SSM rather than plain environment variables | Whichever you pick, the two rules don’t change: keys stay server-side, and the callback is a hint to re-check, not a source of truth. # API keys & credentials > The two keys every Pesepay application has, and how to keep them safe. Each application in your Pesepay account (see [Onboarding](/getting-started/onboarding/)) has two keys: | Key | Used for | | ------------------- | ------------------------------------------------------------------------------------------------ | | **Integration key** | Sent as the `authorization` header on every API request | | **Encryption key** | Encrypting requests and decrypting responses — see the [Encryption Guide](/security/encryption/) | ## Sandbox vs live keys [Section titled “Sandbox vs live keys”](#sandbox-vs-live-keys) Sandbox and production are **separate accounts with separate applications**, so you hold two pairs of keys. A pair only works against the environment it was issued for: | Keys from | Base URL they work against | | --------- | ---------------------------------------------------------- | | Sandbox | `https://api.test.sandbox.pesepay.com/payments-engine/...` | | Live | `https://api.pesepay.com/api/payments-engine/...` | Caution **Crossing the two is the most common setup failure, and it doesn’t announce itself clearly.** A sandbox integration key sent to production fails authentication — `403`, *Integration key for the application is not valid* — and a mismatched encryption key fails at the cipher instead, giving you a `500` with *Failed to decrypt your data* or a payload that decrypts into nothing usable. Neither message says “wrong environment”. Before debugging the [encryption scheme](/security/encryption/), confirm that the key and the URL come from the same environment. Label them unmistakably in your configuration — `PESEPAY_SANDBOX_*` and `PESEPAY_LIVE_*` rather than one `PESEPAY_KEY` that changes meaning per deploy — and never let a sandbox key reach a production build, or the reverse. ## Keep requests server-side [Section titled “Keep requests server-side”](#keep-requests-server-side) Danger Never call the Pesepay API directly from frontend/browser code. Doing so exposes your integration key and encryption key to anyone who opens your browser’s network tab. All requests to Pesepay must originate from your server, where the keys stay out of reach. ## If a key is compromised [Section titled “If a key is compromised”](#if-a-key-is-compromised) Deactivate the affected key from the Merchant Control Panel and issue a new one immediately, then update it everywhere it’s used. There’s no grace period — treat any suspected exposure as urgent. ## Rotating keys [Section titled “Rotating keys”](#rotating-keys) Rotating a key changes what your live servers need to encrypt/decrypt with — plan rotations as a deploy, not a dashboard-only change: 1. Generate the new key in the Merchant Control Panel. 2. Deploy your servers with the new key. 3. Confirm new transactions encrypt/decrypt correctly. 4. Deactivate the old key. # Best practices > Security recommendations for integrating with the Pesepay API. * **Always use HTTPS.** Every Pesepay API request must be made over HTTPS — requests are rejected otherwise. * **Keep keys server-side.** See [API Keys & Credentials](/security/api-keys/) — your integration key and encryption key should never reach a browser or mobile app bundle. No traditional backend? Put them in a [serverless function](/sdks/serverless/) instead. * **Verify, don’t assume.** Treat a customer landing on `returnUrl` as a UI event only. Confirm payment success via the [result callback](/webhooks/result-callback/) or a server-to-server [status check](/api/check-payment-status/) before fulfilling an order. * **Treat your `resultUrl` as hostile input.** It’s a public endpoint and callbacks carry no signature, so cross-check the `referenceNumber` against an order you actually created, and confirm the outcome server-to-server — see [Verifying Callbacks](/webhooks/verifying-callbacks/). * **Rotate compromised keys immediately.** Don’t wait for a scheduled rotation if you suspect exposure. * **Log encrypted payloads, not decrypted ones**, if you log requests/responses for debugging — decrypted transaction and customer data shouldn’t sit in plaintext logs any longer than necessary. # Encryption guide > How to encrypt requests and decrypt responses for Pesepay's integration endpoints, in every supported language. Every integration endpoint ([Initiate Transaction](/api/initiate-transaction/), [Make Payment](/api/make-payment/), [Check Payment Status](/api/check-payment-status/)) sends and receives data as an **encrypted payload**, not plain JSON. This is the one page every endpoint page links back to — read it once. ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Value | | -------------------------- | ----------------------------------------------------------------------------------------------- | | Algorithm | AES-256-CBC | | Key | Your application’s 32-character encryption key, from [Onboarding](/getting-started/onboarding/) | | IV (Initialization Vector) | The **first 16 characters** of your encryption key | | Key size | 256 | | Encoding | UTF-8 | | Padding | PKCS5/PKCS7 | Danger If a key is ever compromised, deactivate it and issue a new one from the Merchant Control Panel immediately — see [API Keys & Credentials](/security/api-keys/). ## Try it [Section titled “Try it”](#try-it) Paste your key and a request body below to see the exact payload the API expects — or paste a response payload to read it back. This is the fastest way to check that your own `encrypt` implementation produces the same bytes: encrypt the same JSON here, and the two strings should match character for character. ## Encrypting a request [Section titled “Encrypting a request”](#encrypting-a-request) 1. Build your request body as JSON. 2. Encrypt the JSON string with AES-256-CBC, using your encryption key and the first 16 characters of that key as the IV. 3. Base64-encode the encrypted bytes. 4. Send it as `{"payload": "encrypted_base64_string"}`. * TypeScript/Angular ```typescript import * as CryptoJS from 'crypto-js'; function encrypt(data: object, encryptionKey: string): string { const key = CryptoJS.enc.Utf8.parse(encryptionKey); const iv = CryptoJS.enc.Utf8.parse(encryptionKey.substring(0, 16)); const encrypted = CryptoJS.AES.encrypt(JSON.stringify(data), key, { iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7, }); return encrypted.toString(); } ``` * JavaScript (Node.js) ```javascript const crypto = require('crypto'); function encrypt(data, encryptionKey) { const iv = Buffer.from(encryptionKey.substring(0, 16), 'utf8'); const key = Buffer.from(encryptionKey, 'utf8'); const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'base64'); encrypted += cipher.final('base64'); return encrypted; } ``` * Python ```python import json import base64 from Crypto.Cipher import AES from Crypto.Util.Padding import pad def encrypt(data: dict, encryption_key: str) -> str: key = encryption_key.encode('utf-8') iv = encryption_key[:16].encode('utf-8') cipher = AES.new(key, AES.MODE_CBC, iv) padded = pad(json.dumps(data).encode('utf-8'), AES.block_size) encrypted = cipher.encrypt(padded) return base64.b64encode(encrypted).decode('utf-8') ``` * Java ```java import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.util.Base64; public String encrypt(String jsonData, String encryptionKey) throws Exception { SecretKeySpec key = new SecretKeySpec(encryptionKey.getBytes("UTF-8"), "AES"); IvParameterSpec iv = new IvParameterSpec( encryptionKey.substring(0, 16).getBytes("UTF-8") ); Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); cipher.init(Cipher.ENCRYPT_MODE, key, iv); byte[] encrypted = cipher.doFinal(jsonData.getBytes("UTF-8")); return Base64.getEncoder().encodeToString(encrypted); } ``` * PHP ```php dict: key = encryption_key.encode('utf-8') iv = encryption_key[:16].encode('utf-8') cipher = AES.new(key, AES.MODE_CBC, iv) decrypted = unpad( cipher.decrypt(base64.b64decode(payload)), AES.block_size ) return json.loads(decrypted.decode('utf-8')) ``` * Java ```java import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.util.Base64; public String decrypt(String payload, String encryptionKey) throws Exception { SecretKeySpec key = new SecretKeySpec(encryptionKey.getBytes("UTF-8"), "AES"); IvParameterSpec iv = new IvParameterSpec( encryptionKey.substring(0, 16).getBytes("UTF-8") ); Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); cipher.init(Cipher.DECRYPT_MODE, key, iv); byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(payload)); return new String(decrypted, "UTF-8"); } ``` * PHP ```php Test your integration against Pesepay's sandbox before going live, and what differs from production. The sandbox lets you exercise your whole integration — creating transactions, completing payments, and receiving callbacks — without moving real money. | | Sandbox | Production | | ----------- | ---------------------------------------------------------- | --------------------------------------- | | Base URL | `api.test.sandbox.pesepay.com/payments-engine` | `api.pesepay.com/api/payments-engine` | | Money moved | None | Real | | Keys | Sandbox integration + encryption key from your application | Production integration + encryption key | Every code sample in this documentation defaults to the sandbox URL — swap in your production base URL and keys only once you’ve worked through the [go-live checklist](/getting-started/go-live-checklist/). ## What the sandbox supports [Section titled “What the sandbox supports”](#what-the-sandbox-supports) The sandbox is deliberately smaller than production. Plan your test matrix around what’s actually there: | | Sandbox | Production | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | | Currencies | `USD` only | `USD` and Zimbabwe dollars | | Payment methods | [EcoCash](/payment-methods/ecocash/) `PZW211`, [Visa](/payment-methods/card-payments/) `PZW204`, [Mastercard](/payment-methods/card-payments/) `PZW205` | [All methods](/payment-methods/overview/) | | Amount limits | Different from production — Visa in particular is capped far lower | See the [overview](/payment-methods/overview/) | Caution **You can’t test InnBucks, Omari, Zimswitch or PayGo in the sandbox**, and you can’t test a Zimbabwe dollar checkout at all. Plan a small-value production payment for each of those before you rely on them — it’s on the [go-live checklist](/getting-started/go-live-checklist/). ## What to exercise [Section titled “What to exercise”](#what-to-exercise) Use the [test credentials](/testing/test-credentials/) to trigger both successful and failed payments, so your error handling gets exercised before it meets a real customer. Beyond the happy path, confirm that your [callback handler](/webhooks/result-callback/) is idempotent and that your reconciliation job catches a payment whose [callback never arrived](/webhooks/retries/). # Simulating failures > Making sure your integration handles declines, timeouts, and cancellations gracefully. Beyond the guaranteed-fail [test numbers and cards](/testing/test-credentials/), exercise these scenarios before going live: * **Decline** — use a failing test number/card and confirm your UI shows a clear message and lets the customer retry. * **Customer abandons the flow** — start a transaction, then close the tab before completing it. Confirm your reconciliation (callback or polling) eventually resolves it to a terminal, non-paid status instead of leaving it “pending” forever. See [Transaction Statuses](/resources/transaction-statuses/). * **Duplicate callback delivery** — if you’re relying on the [result callback](/webhooks/result-callback/), send the same result twice and confirm your handler doesn’t double-fulfill the order. * **Network failure mid-request** — kill your connection after sending an initiate/make-payment request but before reading the response. Confirm you can recover using the `referenceNumber` (if you have it) or by checking status before retrying, rather than blindly creating a second transaction. ## Per-method failure modes [Section titled “Per-method failure modes”](#per-method-failure-modes) What a failure actually looks like differs by method — the message for a bad mobile-money number comes from the provider, while a bad card number is rejected by Pesepay’s own validation before it reaches anyone. Each method page lists its own: | Method | Failure modes | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | EcoCash | [Invalid Econet number, wrong PIN, insufficient balance, a failed leg of a multi-part payment](/payment-methods/ecocash/#failure-modes) | | InnBucks | [Expired authorisation code, amount over the ceiling](/payment-methods/innbucks/#failure-modes) | | Omari | [Bad phone format, rejected OTP, wallet errors and amount rejections](/payment-methods/omari/#failure-modes) | | Card payments | [Card number, expiry and CVV validation, declines](/payment-methods/card-payments/#failure-modes) | | Zimswitch | [Bank card declines and amount rejections](/payment-methods/zimswitch/#failure-modes) | | PayGo | [QR expiry and amount rejections](/payment-methods/paygo/#failure-modes) | # Test credentials > Card numbers and mobile money numbers that trigger successful and failed sandbox transactions. Use these in the [sandbox environment](/testing/sandbox-environment/) to deliberately trigger success and failure, so you can verify your integration handles both. ## Mobile money [Section titled “Mobile money”](#mobile-money) | Method | Number | Result | | ------------------------------------ | ------------ | ------------------------ | | [EcoCash](/payment-methods/ecocash/) | `0777777777` | ✅ Successful transaction | | [EcoCash](/payment-methods/ecocash/) | `0770000000` | ❌ Failed transaction | ## Cards [Section titled “Cards”](#cards) | Brand | Number | CVV | Expiry | Result | | ----- | --------------------- | ----- | --------------- | ------------------------ | | Visa | `4867 9600 0000 5461` | `608` | Any future date | ✅ Successful transaction | | Visa | `4867 9650 0500 5002` | `994` | Any future date | ❌ Failed transaction | | CABS | `4054 0540 5405 4430` | `708` | Any future date | ✅ Successful transaction | | CABS | `7047 0570 5705 5730` | `454` | Any future date | ❌ Failed transaction | The CABS cards exercise the locally-issued bank card flow that [Zimswitch](/payment-methods/zimswitch/) uses in production. Caution Sandbox amount limits are **not** the same as production — the sandbox Visa ceiling in particular is far lower. An amount that passes here can be rejected live, so check limits per environment with [Get Payment Methods by Currency](/api/get-payment-methods-by-currency/). ## Worth testing beyond the happy path [Section titled “Worth testing beyond the happy path”](#worth-testing-beyond-the-happy-path) * **A failed payment for every method you support.** Failures produce a [result callback](/webhooks/result-callback/) just like successes do, with a different [status](/resources/transaction-statuses/) — that path is where integrations most often break. * **An amount over the method’s ceiling.** Every method except [EcoCash](/payment-methods/ecocash/#payments-above-the-limit) rejects these outright. See [Simulating failures](/testing/simulating-failures/). * **A lost callback.** Stop your listener, run a payment, and confirm your reconciliation job still settles the order — [callbacks are never retried](/webhooks/retries/). # The result callback > The payload Pesepay POSTs to your resultUrl when a transaction reaches a final status. The `resultUrl` you set when creating a transaction ([Initiate Transaction](/api/initiate-transaction/) or [Make Payment](/api/make-payment/)) is where Pesepay POSTs the transaction’s final result. Use it to mark orders paid without polling — but always confirm the result before you act on it, as described in [Verifying callbacks](/webhooks/verifying-callbacks/). ## When it fires [Section titled “When it fires”](#when-it-fires) Once, when the transaction reaches a [terminal status](/resources/transaction-statuses/) — `SUCCESS`, `FAILED`, `CANCELLED`, `REVERSED`, and so on. Non-terminal statuses like `PENDING`, `PROCESSING`, and `PARTIALLY_PAID` do **not** produce a callback, so a silent endpoint doesn’t mean the payment is fine. ## The request [Section titled “The request”](#the-request) | | | | -------------------------- | ----------------------------------------- | | **Method** | `POST` | | **Content type** | `application/json` | | **`Authorization` header** | Your application’s integration key | | **Body** | The transaction result, as **plain JSON** | Caution Unlike every other Pesepay API response, the callback body is **not encrypted** — don’t run it through the [decryption step](/security/encryption/), it’s already readable JSON. ## Payload [Section titled “Payload”](#payload) ```json { "referenceNumber": "20260901103214123-A1B2C3D4", "dateOfTransaction": "2026-09-01T10:32:14.000+00:00", "applicationId": 1234, "applicationName": "Your Application", "amountDetails": { "amount": 10.0, "currencyCode": "USD", "defaultCurrencyAmount": 10.0, "defaultCurrencyCode": "USD", "transactionServiceFee": 0.3, "customerPayableAmount": 10.3, "totalTransactionAmount": 10.3, "merchantAmount": 10.0 }, "reasonForPayment": "Order #1042 — running shoes", "transactionStatus": "SUCCESS", "transactionStatusCode": 304, "transactionStatusDescription": "Transaction was successfully completed", "resultUrl": "https://example.com/payments/result", "returnUrl": "https://example.com/payments/return", "pollUrl": "https://api.pesepay.com/api/payments-engine/v1/payments/check-payment?referenceNumber=20260901103214123-A1B2C3D4", "transactionMetadata": {}, "splits": [] } ``` | Field | Type | Description | | ------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `referenceNumber` | string | Pesepay’s reference for the transaction — match this to your order | | `dateOfTransaction` | string | When the transaction was created | | `applicationId` | number | The Pesepay application the transaction belongs to | | `applicationName` | string | That application’s name | | `amountDetails` | object | Amounts and fees — see below | | `reasonForPayment` | string | The reason you supplied when creating the transaction | | `transactionStatus` | string | The final [status](/resources/transaction-statuses/) | | `transactionStatusCode` | number | Numeric equivalent of `transactionStatus` | | `transactionStatusDescription` | string | Human-readable description of the status | | `resultUrl` | string | The callback URL this was sent to | | `returnUrl` | string | Where the customer was returned to | | `pollUrl` | string | URL for [checking the status](/api/check-payment-status/) again | | `transactionMetadata` | object | String key/value pairs carried on the transaction. On an application with [split payments](/payments/split-payments/) this also carries the `splitAmountMode` and `splitPrincipalAmount` Pesepay writes | | `splits` | array | The legs of an [EcoCash payment collected in parts](/payment-methods/ecocash/#payments-above-the-limit) — **always empty on the callback**. Despite the name it has nothing to do with [split payments](/payments/split-payments/) | ### `amountDetails` [Section titled “amountDetails”](#amountdetails) | Field | Type | Description | | ------------------------ | ------ | ----------------------------------------------------- | | `amount` | number | The amount charged, in `currencyCode` | | `currencyCode` | string | Currency of the transaction | | `defaultCurrencyAmount` | number | `amount` converted to the platform’s default currency | | `defaultCurrencyCode` | string | The platform’s default currency code | | `transactionServiceFee` | number | The Pesepay service fee | | `customerPayableAmount` | number | What the customer actually pays | | `totalTransactionAmount` | number | Total amount for the transaction | | `merchantAmount` | number | What you receive | ## Handling it [Section titled “Handling it”](#handling-it) 1. **Return `2xx` quickly.** Do slow work — emails, fulfilment, invoicing — asynchronously. 2. **Confirm before you fulfil.** Call [Check Payment Status](/api/check-payment-status/) with the `referenceNumber` and act on that response. See [Verifying callbacks](/webhooks/verifying-callbacks/) for why. 3. **Be idempotent.** Key your handler on `referenceNumber` so a repeated delivery can’t ship an order twice or credit a wallet twice. 4. **Check `transactionStatus`, not just that a callback arrived.** A callback fires for failed and reversed payments too — only `SUCCESS` means you were paid. 5. **Don’t rely on the callback alone.** Failed deliveries are not retried; run a reconciliation job as described in [Retries & reliability](/webhooks/retries/). Caution The customer landing on your `returnUrl` means they *finished the flow*, not that the payment succeeded. Never mark an order paid on `returnUrl` alone. ## A complete handler [Section titled “A complete handler”](#a-complete-handler) This is the whole shape: reject anything not carrying your integration key, acknowledge immediately, then confirm and fulfil out of band. * Node.js payments/result.js ```js const express = require('express'); const { Pesepay } = require('pesepay'); const INTEGRATION_KEY = process.env.PESEPAY_INTEGRATION_KEY; const pesepay = new Pesepay(INTEGRATION_KEY, process.env.PESEPAY_ENCRYPTION_KEY); const app = express(); app.post('/payments/result', express.json(), async (req, res) => { // Pesepay sends your own integration key back. Anything else is not us. if (req.get('authorization') !== INTEGRATION_KEY) { return res.sendStatus(401); } const { referenceNumber } = req.body; // Acknowledge before doing any work — a failed delivery is never retried. res.sendStatus(200); const order = await orders.findByReference(referenceNumber); if (!order) return; // not ours — ignore it if (order.status === 'paid') return; // already handled — idempotent // The callback body proves nothing. Ask Pesepay directly. const result = await pesepay.checkPayment(referenceNumber); if (!result.success || !result.paid) return; await orders.markPaid(order.id); await fulfil(order); }); ``` * Python payments/result.py ```python import os from flask import Flask, request from pesepay import Pesepay INTEGRATION_KEY = os.environ["PESEPAY_INTEGRATION_KEY"] pesepay = Pesepay(INTEGRATION_KEY, os.environ["PESEPAY_ENCRYPTION_KEY"]) app = Flask(__name__) @app.post("/payments/result") def payment_result(): # Pesepay sends your own integration key back. Anything else is not us. if request.headers.get("Authorization") != INTEGRATION_KEY: return "", 401 reference_number = request.get_json(force=True).get("referenceNumber") # Queue the work, then acknowledge — a failed delivery is never retried. queue.enqueue(settle_order, reference_number) return "", 200 def settle_order(reference_number): order = orders.find_by_reference(reference_number) if order is None: # not ours — ignore it return if order.status == "paid": # already handled — idempotent return # The callback body proves nothing. Ask Pesepay directly. result = pesepay.check_payment(reference_number) if not result.success or not result.paid: return orders.mark_paid(order.id) fulfil(order) ``` * PHP payments/result.php ```php checkPayment($referenceNumber); if ($response->success() && $response->paid()) { mark_order_paid($order['id']); fulfil_order($order); } ``` This handler covers a callback that arrives. It does nothing for one that doesn’t, which is the other half of the job — pair it with the reconciliation job in [Retries & reliability](/webhooks/retries/), pointing at the same `settle_order` function. # Retries & reliability > Pesepay does not retry failed callback deliveries — how to build an integration that survives that. The [result callback](/webhooks/result-callback/) is sent **once**, on a best-effort basis, when a transaction reaches a terminal status. Caution **There is no retry.** If your endpoint is down, slow, unreachable, or returns an error, that delivery is lost — Pesepay does not attempt it again, and the response status your endpoint returns doesn’t change anything. Polling is not a fallback you might need; it’s a required part of a correct integration. ## Build for a lost callback [Section titled “Build for a lost callback”](#build-for-a-lost-callback) 1. **Return `2xx` fast.** Acknowledge the callback, then do fulfilment, emails, and invoicing asynchronously. A handler that takes seconds is a handler that times out. 2. **Reconcile on a schedule.** Run a job that finds every order still awaiting payment after a reasonable window — a few minutes is usually enough — and calls [Check Payment Status](/api/check-payment-status/) for each one. This is what catches the transactions whose callback never landed. 3. **Stop only on a terminal status.** Keep checking until `transactionStatus` is [terminal](/resources/transaction-statuses/). `PENDING`, `PROCESSING`, and `PARTIALLY_PAID` all mean the payment is still in flight. 4. **Age out abandoned attempts.** Customers walk away from checkouts. Give an unpaid transaction a sensible expiry in your own system rather than polling it forever. 5. **Keep the handler idempotent.** Your callback handler and your reconciliation job will sometimes process the same result. Key both on `referenceNumber` — see [Verifying callbacks](/webhooks/verifying-callbacks/). ## Deploys and downtime [Section titled “Deploys and downtime”](#deploys-and-downtime) Because there’s no retry, a callback that arrives mid-deploy is simply gone. Two things make that a non-event: reconciliation (step 2), and never treating “no callback” as “no payment”. Anything you’d have done from the callback should be reachable from the reconciliation path too. ## The reconciliation job [Section titled “The reconciliation job”](#the-reconciliation-job) Run this on a schedule. It calls the same settle function as your [callback handler](/webhooks/result-callback/#a-complete-handler), so a payment settles exactly once whichever path finds it first. * Node.js jobs/reconcile.js ```js // Every 5 minutes. async function reconcile() { const stale = await orders.findUnpaidOlderThan({ minutes: 5 }); for (const order of stale) { const result = await pesepay.checkPayment(order.referenceNumber); if (!result.success) continue; // transient — try again next run if (result.paid) { await orders.markPaid(order.id); await fulfil(order); } else if (order.createdAt < hoursAgo(24)) { await orders.expire(order.id); // the customer never came back } } } ``` * Python jobs/reconcile.py ```python # Every 5 minutes. def reconcile(): for order in orders.find_unpaid_older_than(minutes=5): result = pesepay.check_payment(order.reference_number) if not result.success: continue # transient — try again next run if result.paid: orders.mark_paid(order.id) fulfil(order) elif order.created_at < hours_ago(24): orders.expire(order.id) # the customer never came back ``` * PHP jobs/reconcile.php ```php checkPayment($order['reference_number']); if (!$response->success()) { continue; // transient — try again next run } if ($response->paid()) { mark_order_paid($order['id']); fulfil_order($order); } elseif (strtotime($order['created_at']) < strtotime('-24 hours')) { expire_order($order['id']); // the customer never came back } } ``` # Testing callbacks locally > Receiving sandbox result callbacks on your local development machine. Your local dev server isn’t publicly reachable, so `resultUrl` needs a tunnel to receive sandbox callbacks while you build. 1. Start a tunnel to your local server, e.g. with [ngrok](https://ngrok.com/): `ngrok http 3000` 2. Use the HTTPS URL the tunnel gives you as your `resultUrl` when creating sandbox transactions — e.g. `https://abcd1234.ngrok.app/payments/result`. 3. Send a sandbox test payment using the [test credentials](/testing/test-credentials/) and confirm your endpoint receives the POST. The body is plain JSON — see [The result callback](/webhooks/result-callback/) for the field list. 4. Run a **failing** payment too. Failures produce a callback just like successes do, with a different [`transactionStatus`](/resources/transaction-statuses/), and that’s the path integrations most often get wrong. 5. Switch back to your real, publicly reachable `resultUrl` before going to [production](/getting-started/go-live-checklist/). # Verifying callbacks > Confirm a result callback is genuine before you act on it. Your `resultUrl` is a public endpoint — anything on the internet can POST to it. Treat the [result callback](/webhooks/result-callback/) as a *signal that something changed*, and confirm the actual outcome with Pesepay before you fulfil an order. Caution Pesepay does not currently sign callback payloads, and the body is not encrypted. Nothing in the request body alone proves it came from Pesepay, so the server-to-server confirmation in step 2 below is not optional — it is the verification step. ## How to verify [Section titled “How to verify”](#how-to-verify) 1. **Check the `Authorization` header.** Pesepay sends your application’s own integration key in it. Compare it against the key you hold and reject the request if it doesn’t match — this filters out casual forged posts cheaply. 2. **Confirm the outcome server-to-server.** Take `referenceNumber` from the payload and call [Check Payment Status](/api/check-payment-status/). That response is authenticated with your integration key and encrypted with your encryption key, so it can’t be forged. Act on **that** status, not on the status in the callback body. 3. **Match it to your own order.** Look the `referenceNumber` up in your database. If you don’t recognise it, return `2xx` and ignore it — never create an order from a callback you didn’t originate. 4. **Check the amount.** Compare `amountDetails.amount` and `amountDetails.currencyCode` against what your order expects before fulfilling. ## Idempotency [Section titled “Idempotency”](#idempotency) Make the handler safe to run twice for the same `referenceNumber` — record which references you’ve already processed and short-circuit on a repeat. A duplicate delivery, a retried request from your own infrastructure, or a reconciliation job catching up can all deliver the same result more than once.