# Take M-Pesa, Airtel Money and card payments with one API.

*Web page: https://kweepa.com/developers/malipo · Updated 2026-11-01*

> One API for M-Pesa, Airtel Money and card payments, refunds and payouts, with signed webhooks.

Malipo is Kweepa's payments server. Your application asks it for a payment, a refund or a payout, by M-Pesa, Airtel Money or card, and hears by signed webhook how it went, without holding a Daraja, Airtel or Jenga credential of its own.

**Who it is for:** Developers and companies whose web or mobile application takes payments, and who would rather not integrate each provider on its own.

> **Not open to outside developers yet.** Malipo's public address, `malipo.kweepa.com`, is not switched on, so this guide describes the API as it is built, and as it will be served there. To build against it first, [tell us what you are making](https://kweepa.com/contact?service=malipo).

## What it does

- **One API, three providers**: A payment is the same request whether M-Pesa, Airtel Money or a card carries it. Provider credentials stay sealed in Malipo, never in your application.
- **Hosted checkout pages**: Open a payment page for an amount, send the customer to it, and hear when it was paid. It shows your name, logo and colour, and cards are typed on Equity Bank's Jenga page alone.
- **Refunds and payouts**: Money given back the way it came, and payouts to M-Pesa or Airtel wallets, such as a rider's pay, with a daily limit on live payouts.
- **Signed webhooks**: Every payment, refund and payout reported to your server, signed with HMAC-SHA256 and sent again with growing pauses if it is not answered. Missed events can be read back.
- **Money that adds up**: An idempotency key on every request that moves money, so that one sent again after a timeout never charges twice, and every movement booked in a ledger that is never changed.
- **Test mode and a dashboard**: Test keys work from the first day against a built-in simulator, with phone numbers that succeed, fail or cancel on purpose. The dashboard shows payments, keys and your team.

## What you get

- One request format for M-Pesa, Airtel and cards
- No provider secrets in your code or on your servers
- Every shilling in and out on the ledger

## Connects to

- M-Pesa (Safaricom Daraja)
- Airtel Money
- Equity Jenga (card payments)
- Kweepa Identity
- REST and JSON
- HMAC-signed webhooks

## How Malipo fits into your application

Malipo sits between your server and the payment providers. Your server asks it for a payment, a refund or a payout, and hears by webhook how it went. Your customers meet it only on the hosted checkout page and in the prompt on their phone.

### At a glance

- **Base URL**: `https://malipo.kweepa.com/api/v1`
- **Authentication**: `Authorization: Bearer mlp_test_…` or `mlp_live_…`. The key decides the mode.
- **Format**: JSON. Amounts are in minor units: `150000` is KES 1,500.00.
- **Safe to repeat**: An `Idempotency-Key` is required on every request that moves money.
- **Webhooks**: Signed with HMAC-SHA256, tried again for up to 10 attempts.
- **Errors**: [RFC 9457](https://datatracker.ietf.org/doc/html/rfc9457) problem details.

1. **Your server asks**: `POST /payments` with the amount, the method, a reference and an `Idempotency-Key`.
2. **Malipo prompts the customer**: A prompt on the phone for M-Pesa and Airtel Money; Jenga's card page for a card.
3. **The customer approves**: They enter their PIN on the phone, or pay on the card page.
4. **The provider answers**: Malipo does not take a callback on trust: it asks the provider how the payment stands, and that answer settles it.
5. **Malipo books it**: The payment ends in a status. A payment that succeeded goes in the app's ledger.
6. **Your server hears**: A signed webhook, such as `payment.succeeded`, is posted to your endpoint.

### The words the guide uses

- **App**: One application that takes money through Malipo. Its payments, refunds, payouts, balance, events, webhook endpoints and keys are its own, and invisible to every other app.
- **Mode**: Every app has a test mode and a live mode. Test moves no money: it goes through a built-in simulator, or a provider's sandbox. Live moves real money, once Kweepa has let the app go live.
- **Route**: Says which provider account an app's payments of one method and mode go through: the Kweepa paybill everybody shares, or the app's own. Only Kweepa's administrators see provider accounts and their secrets.
- **API key**: A secret for your server, `mlp_test_…` or `mlp_live_…`. Daraja, Airtel and Jenga credentials are never in your code or on your servers: they stay sealed in Malipo.
- **Webhook**: How Malipo tells your server what happened, signed so that you can check it came from Malipo.

### What changes for your application

- It holds **one API key and one webhook secret**, instead of consumer keys, passkeys, client secrets, PINs and private keys.
- It **never sees a card**. A customer pays on Jenga's card page, so card details stay out of your systems altogether.
- It hears of every payment, refund and payout by **one signed webhook**, instead of a callback for each provider, each with its own shape.
- It can **test everything without provider credentials**, through the simulator, from the day it is signed up.

## Get an app and an API key

### Step 1: Make the app

Sign in at `https://malipo.kweepa.com/dashboard/` with your Kweepa account and press **New app**. Anybody signed in can make an app, up to five, and becomes its owner. It can take test payments at once, and has a first test key.

> **Or have it made for you** Kweepa's administrators can make an app and name its owner, if you would rather not sign up yourself.

### Step 2: Keep the test key

The key is shown once, and only its hash is kept. Put it in your server's environment, beside the address and, soon, the webhook secret:

```bash
MALIPO_URL=https://malipo.kweepa.com
MALIPO_API_KEY=mlp_test_...        # mlp_live_... once the app is live
MALIPO_WEBHOOK_SECRET=whsec_...
```

### Step 3: Send it with every request

Every request carries `Authorization: Bearer <key>`. Your app's key opens `/api/v1/**` for your app, in the key's mode.

> **Keys are for servers** Keep them out of browsers and phone apps. A customer pays on the hosted checkout page, which needs no key. Malipo sends no CORS headers to a page of another site.

### What to know about keys

- **The key decides the mode.** A test key only ever sees and makes test objects; a live key only live ones. There is no `livemode` field to get wrong.
- A key that is lost is revoked and replaced, not recovered. Make another, deploy it, then revoke the old one in the dashboard.
- Live keys can only be made for a live app, by an owner of it. When an app stops being live, its live keys are revoked with it. A suspended app's keys open nothing.
- Requests with an app's key are held to 200 at once and 1,200 a minute.

### Who can do what in the dashboard

- **Owners**: Change the app, its team and its keys, refund and pay out live money, and ask to go live.
- **Developers**: Make test keys and webhook endpoints, and refund and pay out test money.
- **Viewers**: Look.

## Take a payment

A payment is the same request whether M-Pesa, Airtel Money or a card carries it.

Ask for a payment of KES 1,500 by M-Pesa:

**curl**

```bash
curl -s -X POST https://malipo.kweepa.com/api/v1/payments \
  -H "Authorization: Bearer $MALIPO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-10247-1" \
  -d '{"method":"MPESA","amount":150000,"currency":"KES",
       "phone":"0712345678","reference":"ORDER-10247"}'
```

**Java (Spring)**

```java
RestClient malipo = RestClient.builder()
        .baseUrl(url + "/api/v1")
        .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
        .build();

record NextAction(String type, String url, String message) {}
record Payment(String id, String status, String method, long amount,
        String currency, String reference, String receipt,
        String failureCode, String failureMessage,
        NextAction nextAction) {}

Payment payment = malipo.post().uri("/payments")
        .header("Idempotency-Key", "order-10247-1")
        .body(Map.of("method", "MPESA", "amount", 150000,
                "currency", "KES", "phone", "0712345678",
                "reference", "ORDER-10247"))
        .retrieve().body(Payment.class);
```

The answer is `PROCESSING`, with `nextAction.type` set to `APPROVE_ON_PHONE`: the prompt is on the customer's phone. Tell them to enter their PIN, then wait for the webhook, or ask for the payment:

```text
POST /payments
{"id":"2610191649...","status":"PROCESSING","nextAction":{"type":"APPROVE_ON_PHONE",...},...}

GET /payments/2610191649...      (a few seconds later)
{"status":"SUCCEEDED","receipt":"S8K2M1Q0ZP",...}
```

### The request

**Fields of a payment**

| Field | What it is |
|---|---|
| `method` | `MPESA`, `AIRTEL_MONEY` or `CARD`. |
| `amount` | In **minor units**: `150000` is KES 1,500.00. Mobile money takes whole shillings (a multiple of 100), KES 1 to 250,000 unless the account says otherwise. |
| `currency` | `KES` for mobile money. A card also takes what its Jenga merchant takes (`KES` and `USD` by default). |
| `phone` | For mobile money: `0712345678`, `712345678`, `254712345678` or `+254712345678`. |
| `reference` | Your own: an order or invoice number. Shown in the dashboard and searchable. |
| `description`, `customer` | What the customer and your people are shown. |
| `returnUrl` | For `CARD`: where the customer's browser is sent back to once they have paid or given up. |
| `accountReference` | For M-Pesa: what the prompt shows as the account, 12 letters and digits. Otherwise the app's prefix and your reference. |
| `metadata` | Your own JSON, up to 4 KB, handed back as it was in every answer and webhook. |

### Make it safe to send again

**`Idempotency-Key` is required.** Send a value that is unique to what is being paid for. The same key with the same request answers with the payment the first one made, `200` with `Idempotent-Replayed: true`, and charges nothing again, so a request that timed out can always be sent again. The same key with a different request is refused, `409`.

- **A payment**: One key for each thing being paid for and each attempt: `order-10247-1`. If the customer gives up on a prompt and tries again, that is a new attempt: `order-10247-2`.
- **A refund**: The refund's own: `refund-order-10247`.
- **A payout**: The thing it pays for: `rider-552-2026-10-19`.

### How a payment goes

1. **PENDING**: Asked for and not yet sent to the provider.
2. **PROCESSING**: With the provider, or waiting for the customer.

It ends in one of:

- **SUCCEEDED**: The money arrived. `receipt` is the provider's: the M-Pesa receipt number, the Airtel Money id or the card receipt.
- **FAILED**: It did not go through, with a `failureCode` such as `insufficient_funds`, `wrong_pin`, `customer_busy` or `declined`.
- **CANCELLED**: The customer said no.
- **EXPIRED**: Nobody answered in time: 3 minutes for mobile money, 30 for cards.

- **Mobile money**: The answer is `PROCESSING` with `nextAction.type` `APPROVE_ON_PHONE`.
- **Card**: The answer holds `nextAction.type` `REDIRECT` and `nextAction.url`, Jenga's card page: send the customer's browser there. They come back to your `returnUrl` with `payment_id` added. Their coming back says nothing about how it went: the payment's status and its webhook do.

> **A payment that succeeds late still succeeds** Money that arrives after a payment expired, or after its provider first said it failed, is money: the payment becomes `SUCCEEDED` and you get `payment.succeeded`. Act on the status the payment has, not on the order events arrive in.

### Find payments again

```http
GET /api/v1/payments?status=SUCCEEDED&method=MPESA&q=ORDER-10247&from=2026-10-01T00:00:00Z&limit=50
GET /api/v1/payments/{paymentId}
```

Lists are newest first, `limit` up to 100. `nextCursor`, with `hasMore`, is the `cursor` of the next page. `q` finds an id, your reference, a receipt or a phone number.

## Send customers to a hosted checkout page

The simplest integration: no payment form, no phone number to validate, no card page to send anybody to.

Open a page for KES 2,500:

```bash
curl -s -X POST https://malipo.kweepa.com/api/v1/checkout-sessions \
  -H "Authorization: Bearer $MALIPO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount":250000,"currency":"KES","reference":"ORDER-10248",
       "description":"Family meal","customer":{"phone":"0712345678"},
       "successUrl":"https://foodtogo.co.ke/orders/10248/paid",
       "cancelUrl":"https://foodtogo.co.ke/cart"}'
```

```text
{"id":"…","url":"https://malipo.kweepa.com/checkout/Qm9…","status":"OPEN",
 "methods":["MPESA","AIRTEL_MONEY","CARD"],…}
```

Send the customer to `url`. The page shows your app's name, logo and colour, the amount and the ways to pay (`methods`, or every way the app can take that amount). The customer picks one, approves it on their phone or pays on the card page, and is sent to `successUrl` with `session_id` added. You hear `checkout.completed`, with the session and its `paymentId`, by webhook.

- A page takes one payment in flight at a time and 8 attempts in all, and stays open an hour unless `expiresInMinutes` says otherwise (5 to 1440). An unpaid page expires, and you hear `checkout.expired`.
- A payment that succeeds after its page expired completes it all the same.
- `POST /api/v1/checkout-sessions/{checkoutSessionId}/cancel` closes a page nobody is paying on.
- Pass an `Idempotency-Key` to open a page safely twice. It is optional here, since a page moves no money.

## Hear about every payment

### Step 1: Register an endpoint

Tell Malipo where your server listens, in the dashboard or with the app's key. The endpoint belongs to the key's mode: test events go to test endpoints only, live events to live ones.

```bash
curl -s -X POST https://malipo.kweepa.com/api/v1/webhook-endpoints \
  -H "Authorization: Bearer $MALIPO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://api.foodtogo.co.ke/hooks/malipo","eventTypes":["*"]}'
```

The answer holds the endpoint's signing `secret` (`whsec_…`). The dashboard shows it again on purpose. The address must be `https` and on the public internet; redirects are not followed.

### Step 2: Check the signature before anything else

`v1` is the HMAC-SHA256, in hex, of `t`, a dot, and the body exactly as it arrived, under the endpoint's secret. Work it out over the raw bytes, before any JSON parsing, and turn away what is more than five minutes old.

**Java**

```java
static boolean verify(byte[] rawBody, String header, String secret)
        throws GeneralSecurityException {
    Map<String, String> parts = new HashMap<>();
    for (String part : header.split(","))
        parts.put(part.substring(0, part.indexOf('=')),
                part.substring(part.indexOf('=') + 1));
    long timestamp = Long.parseLong(parts.get("t"));
    if (Math.abs(Instant.now().getEpochSecond() - timestamp) > 300)
        return false;
    Mac hmacSha256 = Mac.getInstance("HmacSHA256");
    hmacSha256.init(new SecretKeySpec(
            secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    hmacSha256.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
    byte[] expected = HexFormat.of().formatHex(hmacSha256.doFinal(rawBody))
            .getBytes(StandardCharsets.UTF_8);
    return MessageDigest.isEqual(expected,
            parts.get("v1").getBytes(StandardCharsets.UTF_8));
}
```

**Node**

```js
// Express: app.post('/hooks/malipo', express.raw({ type: 'application/json' }), handler)
const crypto = require('crypto');

function verify(rawBody, header, secret) {
  const { t: timestamp, v1: signature } = Object.fromEntries(
    header.split(',').map((part) => part.split('=')));
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = crypto.createHmac('sha256', secret)
    .update(timestamp + '.').update(rawBody).digest('hex');
  return signature.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
```

### Step 3: Act on the status, once for each event

In one transaction: skip the event if its id is stored already, store the id, and set the order's payment state from `data.object.status` (`SUCCEEDED` means paid, whatever it was before). Keep Malipo's payment id on the order (`payment.id`, or a checkout session's `paymentId`) so that a refund can name it. Answer `2xx` within 10 seconds, and do slow work after answering.

> **The webhook path is open to the internet** It takes no session and no CSRF token: the signature is the authentication. Serve it over https.

### The events

**Events Malipo sends**

| Event | When |
|---|---|
| `payment.succeeded` | The money arrived. It may come for a payment that had failed or expired: the money came late. |
| `payment.failed`, `payment.cancelled`, `payment.expired` | It ended without money. |
| `refund.succeeded`, `refund.failed` | How a refund went. |
| `payout.succeeded`, `payout.failed` | How a payout went. |
| `checkout.completed`, `checkout.expired` | A checkout page was paid, or ran out. |
| `webhook.test` | Sent from the dashboard, or by `POST /webhook-endpoints/{webhookEndpointId}/test`. |

### What a delivery looks like

```http
POST /hooks/malipo
Content-Type: application/json
User-Agent: Malipo-Webhooks/1.0
X-Malipo-Event: payment.succeeded
X-Malipo-Event-Id: 2610191650042A1B2C3D4E5F
X-Malipo-Delivery: 2610191650047F6E5D4C3B2A
X-Malipo-Signature: t=1792432204,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

{ "id": "2610191650042A1B2C3D4E5F", "object": "event", "type": "payment.succeeded",
  "livemode": true, "app": "foodtogo", "createdAt": "2026-10-19T16:50:04.402Z",
  "data": { "object": { "id": "2610191649…", "object": "payment", "status": "SUCCEEDED",
                        "amount": 150000, "currency": "KES", "reference": "ORDER-10247",
                        "receipt": "SJK7RT61SV", … } } }
```

`data.object` is exactly what `GET` of that payment, refund, payout or session answers.

### What to count on

- Answer `2xx` within 10 seconds. Anything else is tried again with a pause that doubles from 30 seconds up to 6 hours, 10 attempts in all. Then it is given up and shown to Kweepa's administrators, and can be sent again.
- A delivery may arrive **more than once**: drop event ids you have seen.
- Deliveries are **not ordered**. Act on the `status` of the object an event carries: a `payment.succeeded` can come after a `payment.expired` of the same payment, and the payment is then paid.
- An event is queued in the transaction of the change it reports: you never hear of something that was rolled back, and never miss something that was not.
- Missed some while your server was down? They are all in `GET /api/v1/events`.

## Refund a payment

```bash
curl -s -X POST https://malipo.kweepa.com/api/v1/refunds \
  -H "Authorization: Bearer $MALIPO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: refund-order-10247" \
  -d '{"paymentId":"2610191649…","reason":"Order cancelled"}'
```

- Money goes back the way it came: an M-Pesa reversal, an Airtel Money refund, a card refund.
- **M-Pesa and Airtel Money give back all of a payment or nothing**: leave `amount` out. A card payment may be refunded in parts, up to what is left of it.
- The amount is **taken from the app's balance when the refund is asked for**. A balance that does not cover it refuses the refund (`422`, `insufficient-balance`). A refund that fails gives the money back to the balance and makes the payment refundable again, and you hear `refund.failed`.
- Statuses: `PENDING`, `PROCESSING`, then `SUCCEEDED` or `FAILED`. A refund its provider never answers for stays `PROCESSING` and is put in front of an administrator. It is not failed by the clock, which could refund twice.
- `GET /api/v1/refunds?paymentId=…` lists the refunds of a payment.

## Pay money out

Money from the app's balance to an M-Pesa (B2C) or Airtel Money wallet: winnings, a rider's pay, a refund of more than a payment.

```bash
curl -s -X POST https://malipo.kweepa.com/api/v1/payouts \
  -H "Authorization: Bearer $MALIPO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: rider-552-2026-10-19" \
  -d '{"method":"MPESA","amount":120000,"currency":"KES","phone":"0722000111",
       "name":"Otieno","reference":"RIDER-552"}'
```

- Live payouts need Kweepa's leave for the app, and stop at the app's limit for the Nairobi day (`422`, with `dailyLimit` and `paidToday`). Test payouts need nothing.
- Like a refund, the amount is taken from the balance at once and returned if the payout fails.
- KES 10 to 250,000 a payout unless the provider account says otherwise, in whole shillings.
- A payout whose result never comes (a timeout in Daraja's queue, an ambiguous Airtel answer) stays `PROCESSING` until an administrator settles it from the provider's portal. Sending money twice is worse than waiting.

## Read your balance and ledger

```http
GET /api/v1/balance
[{"currency":"KES","livemode":true,"available":1835000,...}]

GET /api/v1/ledger?currency=KES&limit=50
every movement, newest first, with the balance it left
```

**Kinds of ledger entry**

| Kind | What it is |
|---|---|
| `PAYMENT` | A payment succeeded: plus its amount. |
| `REFUND`, `REFUND_REVERSAL` | A refund was asked for: minus. It failed: plus. |
| `PAYOUT`, `PAYOUT_REVERSAL` | A payout was asked for: minus. It failed: plus. |
| `ADJUSTMENT` | An administrator's entry, with a note: a settlement paid to the app's bank, a correction. |

The balance is what the app may refund and pay out. Live entries are never changed or deleted, by the application or by anybody with SQL (a trigger refuses it): a mistake is set right by another entry.

## Test without credentials

A new app's test routes go to the **simulator**, which is Malipo pretending to be a provider. A simulated customer answers after 4 seconds, and how depends on the end of their phone number.

**What the simulator does**

| Phone ends in | What happens |
|---|---|
| `…1032` | The customer cancels: `CANCELLED`. |
| `…1037` | Nobody answers: `EXPIRED`. |
| `…0001` | The wallet is short: `FAILED`, `insufficient_funds`. |
| `…2001` | The wrong PIN: `FAILED`, `wrong_pin`. |
| anything else | `SUCCEEDED`, with a receipt such as `S8K2M1Q0ZP`. |

- A test card payment's `nextAction.url` is the simulator's card page, with **Approve** and **Decline**.
- Refunds always succeed. A payout to a number ending in `0001` fails.
- The simulator keeps the real rules: mobile money is refunded whole, amounts are whole shillings, the same limits apply.
- To test against a provider's own sandbox instead (Daraja's sandbox, Airtel's UAT, Jenga's UAT), Kweepa's administrators make a test provider account for it and point your app's test route at it.

## Errors and limits

Errors are RFC 9457 problem details: match on `type`, show `detail`.

```json
{ "type": "/errors/insufficient-balance", "title": "Unprocessable Content", "status": 422,
  "detail": "The app's balance of KES 0.00 does not cover KES 1,000.00", "available": 0,
  "currency": "KES", "trace_id": "9f2c41d07a6b3e58" }
```

**Statuses**

| Status | When |
|---|---|
| `400` | The request is malformed: a field missing or wrong, no `Idempotency-Key`. |
| `401` | No credentials, or not accepted. A suspended app. |
| `403` | The credentials do not open this. The app is not live. |
| `404` | Not found, or not yours. |
| `409` | An `Idempotency-Key` used for another request, a payment already in flight on a checkout page, or a state that does not allow it. |
| `422` | Understood, and not possible: no route for the method, an amount out of bounds, a balance that does not cover it, a partial mobile money refund, a daily payout limit. |
| `429` | Too many requests. `Retry-After` says when to come back. |
| `502`, `503` | Kweepa Identity, which signs people in to the dashboard, cannot be reached, or the server has no secrets key set. |

> **When a request that moves money fails or times out** Send **the same request again with the same `Idempotency-Key`**. It can never charge twice.

## Go live

### Step 1: Test with the simulator, from the first day

Every flow, including the failures (phones ending `0001`, `1032`, `1037` and `2001`) and the duplicates (send the same request twice).

### Step 2: Test with the providers' sandboxes

Kweepa's administrators make test provider accounts with the sandbox credentials (Daraja sandbox, Airtel UAT, Jenga UAT) and point your test routes at them. Take a payment of each kind from the dashboard's test payment form.

### Step 3: Ask to go live

From the dashboard: your business name, a contact, and what the payments are for. Kweepa approves it and points your app's live routes at the live provider accounts, the shared Kweepa paybill or your own.

### Step 4: Make a live key and a live webhook endpoint

An owner makes the live key. Test and live endpoints are separate: register the live one too.

### Step 5: Take one small real payment, then refund it

Watch it in the dashboard, with what was said to the provider.

### Step 6: Switch your key

Change `MALIPO_API_KEY` to the live key. Keep a test key for staging.

> **Where the money goes** Into the provider account your app's route names: for your business, your own paybill, till or card merchant account, so that the money lands with you. Malipo carries the requests, checks every payment with the provider and keeps the records.

## Questions people ask

### Where does the money go?

Into the provider account your app's route names: for your business, your own paybill, till or card merchant account, so that the money lands with you. Malipo carries the requests, checks every payment with the provider and keeps the records.

### Can we try it without M-Pesa credentials?

Yes. A new app's test key works at once against Malipo's simulator, where phone numbers ending in certain digits succeed, run short of money, get a wrong PIN or cancel, so that every path can be tried. The providers' own sandboxes can be used too.

### Do card numbers pass through our servers?

No. The customer types the card on Equity Bank's Jenga payment page, never on yours or on Malipo's, so card details stay out of your systems altogether.

### How does an app go live?

It is tested with the simulator, then with the providers' sandboxes. Its owner asks to go live from the dashboard; once Kweepa approves it and sets its live routes, the owner makes a live key and a live webhook endpoint.

## Next step

Request a demo: https://kweepa.com/contact?service=malipo · Email: ops@kweepa.com
Provider: Kweepa Technologies, Nakuru, Kenya (https://kweepa.com/)
