Pay API reference
One integration for Mavunta Balance, M-Pesa, card, and PayPal. You create a payment intent, send the customer to its hosted checkout, and listen for a signed webhook. Mavunta handles the rails, quotes, and settlement.
New here? Start with the guides . Get sandbox keys instantly on the developer console . Live keys require business verification (KYB).
Authentication
Authenticate with your secret key in the Authorization header. There are three key types: secret (cwk_live_sk_) for full server-side use, restricted (cwk_live_rk_) limited to the scopes you choose, and publishable (cwk_live_pk_) for client-side, read-only use. Sandbox keys carry the test tag (for example cwk_test_sk_) and never move real money.
Scopes (assign a subset to a restricted key): payments:read, payments:write, checkout:write, payment_links:read, payment_links:write, refunds:read, refunds:write, webhooks:read, webhooks:write, balances:read, settlements:read, reports:read.
Authorization: Bearer cwk_test_sk_...
Content-Type: application/json
Idempotency-Key: order-10045 # optional, recommended on createsVerify a key
GET /v1/auth/verify authenticates a key without moving anything. It is the safe way to test a live key: it confirms identity, scopes, IP allowance, and environment without creating a real payment.
curl https://api.mavunta.com/v1/auth/verify -H "Authorization: Bearer cwk_live_sk_..."
{
"livemode": true,
"environment": "live",
"merchant_id": "...",
"app_id": "...",
"key_id": "...",
"scopes": ["payments:read", "payments:write"],
"ip_allowed": true,
"status": "active"
}Errors, request ids, and rate limits
Every response carries a request_id (also in the Mavunta-Request-Id header) plus environment and livemode. Quote the request id to support. Errors return a consistent envelope:
{
"error": {
"type": "authentication_error",
"code": "invalid_api_key",
"message": "API key is invalid or revoked.",
"param": null,
"request_id": "req_test_...",
"docs_url": "https://developers.mavunta.com/docs/errors/invalid_api_key"
}
}Each call is metered per key. Responses include Mavunta-RateLimit-Limit, -Remaining, and -Reset; exceeding the limit returns 429 rate_limit_exceeded.
Payment intents
A payment intent represents one expected payment. You price in fiat (for example KES); the customer pays by any allowed method; you receive the settlement asset (USDT by default) in your Mavunta wallet.
Create
curl https://api.mavunta.com/v1/payment-intents \
-H "Authorization: Bearer cwk_test_sk_..." \
-H "Idempotency-Key: order-10045" \
-H "Content-Type: application/json" \
-d '{
"amount": "2500",
"currency": "KES",
"settlement_currency": "USDT",
"payment_methods": ["mavunta_balance", "mpesa", "card"],
"merchant_reference": "ORDER-10045",
"description": "Sneakers size 42",
"expires_in_minutes": 30
}'{
"id": "pi_...",
"status": "awaiting_payment",
"amount": "2500",
"currency": "KES",
"settlement_currency": "USDT",
"pay_amount": "19.32",
"checkout_url": "https://www.mavunta.com/pay/pi_...",
"merchant_reference": "ORDER-10045",
"expires_at": "2026-06-10T12:40:00Z"
}Redirect the customer to checkout_url. The hosted checkout is Mavunta-branded and offers every allowed method valid for the currency (M-Pesa is KES only; card is KES and USD; PayPal covers non-KES currencies).
Retrieve, list, and cancel
GET /v1/payment-intents/:id
GET /v1/payment-intents # newest first; ?limit=20&starting_after=pi_...&status=paid
POST /v1/payment-intents/:id/cancel # only while awaiting paymentThe list is scoped to your key's mode: test keys return sandbox intents, live keys return live intents. Page with starting_after (the last id of the previous page) until has_more is false.
Statuses
created → awaiting_payment → confirming → paid (terminal, fulfil the order), or expired, cancelled, failed, refunded. Treat the webhook or a fresh retrieve as the source of truth; never the redirect alone.
External wallet (on-chain) payments
Pass external_wallet in payment_methods when you settle in USDT or USDC. The hosted checkout offers the launch networks (TRON, Solana, Base, Polygon) and shows the customer an address plus an exact amount whose final decimals uniquely identify the payment. Once the deposit confirms on-chain the intent moves awaiting_payment → confirming → paid and your webhook fires. The customer must send the exact amount shown; a different amount, asset, or network is not credited automatically.
Receiving limits
Live merchants have receiving limits: a per-payment cap plus rolling daily and monthly caps. A payment over a cap is declined at pay time with MERCHANT_LIMIT_EXCEEDED and the intent stays awaiting_payment until it expires. Your merchant dashboard warns as you approach a cap; contact support to raise limits for your business.
Payment links
A payment link is a reusable URL: each open mints a fresh payment intent. Fix the amount or let the customer enter it; optionally cap how many times it can be paid and when it expires.
curl https://api.mavunta.com/v1/payment-links \
-H "Authorization: Bearer cwk_test_sk_..." \
-H "Content-Type: application/json" \
-d '{
"title": "Website design deposit",
"amount": "15000",
"currency": "KES",
"max_payments": 1,
"expires_at": "2026-06-20T23:59:59Z"
}'{
"id": "pl_...",
"object": "payment_link",
"url": "https://www.mavunta.com/pay/l/pl_...",
"status": "active",
"paid_count": 0,
"max_payments": 1
}GET /v1/payment-links/:id returns the link with its current paid_count. Only successful payments count toward max_payments; abandoned checkouts never consume the cap.
Rates and quotes
GET /v1/rates # supported settlement assets + live reference prices
POST /v1/quotes # lock a fiat → asset quote for ~10 minutes
{ "source_currency": "KES", "target_asset": "USDT", "amount": "2500" }Payment intents embed their own quote at creation; you only need these endpoints for price displays or pre-checkout estimates.
Balances, settlements, and endpoints
Read your settlement balances and per-payment settlement records, and manage webhook endpoints over the API (a test key sees sandbox balances; endpoints run in the key's environment).
GET /v1/balances # settlement balances per asset (scope balances:read)
GET /v1/settlements # one record per paid payment (scope settlements:read)
GET /v1/reports/payments # ?from=&to=&format=csv|json (scope reports:read)
GET /v1/reports/settlements # reconciliation exports, CSV or JSON
POST /v1/webhook_endpoints # create; signing secret returned once (webhooks:write)
GET /v1/webhook_endpoints # list (webhooks:read)
GET /v1/webhook_endpoints/:id
DELETE /v1/webhook_endpoints/:id{
"object": "list",
"data": [
{ "asset": "USDT", "total": "1240.50", "available": "1190.50", "on_hold": "50.00" }
]
}Webhooks
Register HTTPS endpoints on the developer console. Mavunta signs every event and retries failed deliveries with exponential backoff for up to 8 attempts; you can inspect and replay every delivery from the console. Return any 2xx to acknowledge. You can rotate an endpoint's signing secret or pause it from the console at any time; rotation takes effect immediately, so deploy the new secret to your receiver first.
Events
payment_intent.paid # the payment settled; fulfil the order
payment_intent.refunded # the merchant refunded the payment
payment_intent.disputed # a card/PayPal chargeback was opened on the payment
payment_intent.duplicate_payment # the customer paid this intent twice; you hold an
# extra credit and should expect a refund of the duplicateVerify the signature
Each request carries Mavunta-Signature, Mavunta-Timestamp, and Mavunta-Event-Id. Recompute the HMAC over `${timestamp}.${rawBody}` with your endpoint secret and compare in constant time. Mavunta-Event-Id is stable across retries; use it to deduplicate.
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(secret: string, timestamp: string, rawBody: string, signature: string) {
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')
return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex'))
}Payload
{
"id": "evt_...",
"type": "payment_intent.paid",
"api_version": "2026-06-01",
"created_at": "2026-06-10T12:06:10Z",
"data": {
"id": "pi_...",
"status": "paid",
"amount": "2500",
"currency": "KES",
"payment_method": "mpesa",
"settlement_currency": "USDT",
"settlement_status": "available",
"merchant_reference": "ORDER-10045"
}
}settlement_status is available when the settlement is spendable now, or pending (with settlement_available_at) while a new merchant's funds sit in the pending-settlement window.
Poll the event feed
Prefer webhooks, but you can also read events directly (scope payments:read) to backfill after downtime or reconcile state. Pass after with the last event id you processed to stream only newer events, oldest first. This powers the CLI listen command.
GET /v1/events # newest first; ?limit=50&type=payment_intent.paid
GET /v1/events?after=evt_... # only events newer than evt_..., oldest first
{ "object": "list", "data": [ { "id": "evt_...", "type": "payment_intent.paid", "data": { } } ], "has_more": false }Refunds
Mavunta Balance payments can be refunded (full or partial, once per payment) from the merchant dashboard; the refund is a reverse transfer to the customer's Mavunta wallet and fires payment_intent.refunded. For M-Pesa, card, and PayPal payments, raise a refund request via the API (scope refunds:write); our team processes it on the original rail and you are notified when it resolves.
POST /v1/payment-intents/:id/refund-request
{ "reason": "Customer returned item" }
{ "object": "refund_request", "payment_intent": "pi_...", "status": "requested" }One refund per payment. Balance-rail payments return use_dashboard_refund: they move money out of the owner's wallet, so they require the owner's transaction PIN in the dashboard.
Sandbox and go-live
With a cwk_test_sk_ key every intent is created in sandbox: the hosted checkout simulates each method instantly and no money moves, while statuses and webhooks behave exactly like production. To go live: submit your business details on the developer console, pass KYB review, then create a cwk_live_sk_ key.
- Create intents with an Idempotency-Key and store the returned id.
- Fulfil only on
payment_intent.paid(webhook or retrieve), never on the redirect. - Verify webhook signatures and deduplicate on the event id.
- Handle
expiredby creating a fresh intent. - Switch keys to live; the API surface is identical.
Simulating outcomes
The developer console has a Sandbox tester: create a test payment and drive it to any outcome (success, failed, underpaid, overpaid, expired, cancelled, review), which moves the intent and fires the matching webhook. M-Pesa test numbers set the outcome automatically on a test payment: +254700000001 succeeds, +254700000002 is cancelled, +254700000003 is insufficient funds, +254700000004 times out, +254700000009 goes to review. Reset sandbox data at any time; live data is never touched.
Fire a sample event
From a test key you can post a sample event of any type to your sandbox endpoints without creating a payment (scope payments:write); this powers the CLI trigger command. Live keys are rejected.
POST /v1/sandbox/webhooks/trigger
{ "type": "payment_intent.paid" }
{ "object": "event_trigger", "type": "payment_intent.paid", "status": "sent" }SDKs and OpenAPI
Official SDKs wrap every endpoint, retry safely with idempotency keys, and verify webhook signatures for you, in Node.js / TypeScript, PHP, and Python.
npm install mavunta # Node.js / TypeScript
composer require mavunta/mavunta-php # PHP
pip install mavunta # Pythonimport Mavunta from 'mavunta'
const mavunta = new Mavunta({ secretKey: process.env.MAVUNTA_SECRET_KEY })
const intent = await mavunta.paymentIntents.create({
amount: '2500',
currency: 'KES',
settlement_currency: 'USDT',
payment_methods: ['mpesa', 'card', 'mavunta_balance'],
})
// redirect the customer to intent.checkout_urlEvery endpoint is also described by a machine-readable OpenAPI 3.1 spec, the source of truth for this reference. Generate a typed client in any language, import it into Postman or Insomnia, or feed it to OpenAPI Generator.
# download the spec
curl -O https://www.mavunta.com/openapi.yaml
# generate a client (example: TypeScript)
npx @openapitools/openapi-generator-cli generate \
-i https://www.mavunta.com/openapi.yaml -g typescript-fetch -o ./mavuntaBuilding in-person payments instead? See Lipa na Crypto for merchants