Quickstart
This is the shortest path to a working payment. You will create a payment intent, send the customer to a hosted checkout, then confirm the result from a signed webhook. Everything here runs in sandbox with a cwk_test_ key, so no real money moves.
cwk_test_sk_). Keep secret keys server-side only. Live keys (cwk_live_) are issued after verification, covered in Go live.1. Create a payment intent
A payment intent represents one expected payment. You price it in fiat and choose the settlement asset you want to receive (USDT by default). Send the request from your server with an Idempotency-Key so a retry never creates a duplicate.
curl https://api.mavunta.com/v1/payment-intents \
-H "Authorization: Bearer cwk_test_sk_..." \
-H "Idempotency-Key: order-1001" \
-H "Content-Type: application/json" \
-d '{
"amount": "2500",
"currency": "KES",
"settlement_currency": "USDT",
"payment_methods": ["card", "mpesa", "mavunta_balance"],
"merchant_reference": "ORDER-1001",
"description": "Order 1001",
"expires_in_minutes": 30
}'The response carries the intent id and the hosted checkout URL:
{
"id": "pi_test_...",
"status": "awaiting_payment",
"amount": "2500",
"currency": "KES",
"settlement_currency": "USDT",
"pay_amount": "19.32",
"checkout_url": "https://www.mavunta.com/pay/pi_test_...",
"merchant_reference": "ORDER-1001",
"expires_at": "2026-06-21T12:40:00Z",
"request_id": "req_test_...",
"environment": "test",
"livemode": false
}Store pi_test_... against your order. Every response also includes a request_id (and a Mavunta-Request-Id header); quote it to support if you need help with a specific call.
2. Send the customer to checkout
Redirect the customer to the intent's checkout_url. The hosted checkout is Mavunta-branded and offers every method valid for the currency: card and Mavunta Balance broadly, M-Pesa for KES, and PayPal for non-KES currencies. You do not handle card numbers or phone prompts yourself.
// after creating the intent
return res.redirect(intent.checkout_url)3. Receive and verify the webhook
Add an endpoint in the console and Mavunta will POST a signed event when the payment resolves. A successful payment sends payment_intent.paid. Verify the signature before trusting the body: recompute an HMAC-SHA256 over `${timestamp}.${rawBody}` with your endpoint secret and compare in constant time. You must read the raw request body, not a re-serialized object.
import express from 'express'
import { createHmac, timingSafeEqual } from 'node:crypto'
const app = express()
const SECRET = process.env.MAVUNTA_WEBHOOK_SECRET // cwk_whsec_...
// raw body is required for signature verification
app.post('/webhooks/mavunta', express.raw({ type: '*/*' }), (req, res) => {
const ts = req.header('Mavunta-Timestamp') || ''
const sig = req.header('Mavunta-Signature') || ''
const raw = req.body.toString('utf8')
const expected = createHmac('sha256', SECRET).update(`${ts}.${raw}`).digest('hex')
const ok =
sig.length === expected.length &&
timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))
if (!ok) return res.status(400).send('bad signature')
const event = JSON.parse(raw)
if (event.type === 'payment_intent.paid') {
markOrderPaid(event.data.merchant_reference, event.data) // step 4
}
res.sendStatus(200) // ack with any 2xx
})Prefer not to hand-roll it? The official SDKs verify for you with mavunta.webhooks.verify(...). See SDKs.
4. Mark the order paid (once)
Deliveries are retried until you return a 2xx, so the same event can arrive more than once. Deduplicate on Mavunta-Event-Id (stable across retries) and make fulfilment idempotent, keyed on your merchant_reference:
function markOrderPaid(reference, intent) {
const order = orders.get(reference)
if (!order || order.status === 'paid') return // already handled
order.status = 'paid'
order.settledAmount = intent.amount
order.settlementCurrency = intent.settlement_currency
fulfil(order)
}5. Try it without writing a server
You can drive the whole flow from the console and the CLI before you deploy anything. The CLI forwards live sandbox events to your local machine, so you can build the handler with no public URL:
npm install -g @mavunta/cli
export MAVUNTA_SECRET_KEY=cwk_test_sk_...
mavunta listen --forward-to http://localhost:3000/webhooks/mavunta
# in another terminal, drive an outcome:
mavunta trigger payment_intent.paidFrom the console's Sandbox tester you can also create a test payment and push it to paid, failed, expired, underpaid, and more, each firing the matching webhook against your real backend.
You shipped a payment
That is the full loop. From here:
- Webhooks for the full event catalog, retries, and replay.
- Full API reference for payment links, refunds, balances, and settlements.
- Go live when you are ready to accept real money.