CoinwakaCoinwaka
Coinwaka Pay

Pay API reference

One integration for Coinwaka Balance, M-Pesa, card, and PayPal. You create a payment intent, send the customer to its hosted checkout, and listen for a signed webhook. Coinwaka handles the rails, quotes, and settlement.

Get sandbox keys instantly on the developer console . Live keys require business verification (KYB).

Authentication

Authenticate with your secret key in the Authorization header. Keys are scoped: payments:read, payments:write, checkout:write, refunds:write, webhooks:write. Sandbox keys start with cwk_test_sk_ and never move real money; live keys start with cwk_live_sk_.

Authorization: Bearer cwk_test_sk_...
Content-Type: application/json
Idempotency-Key: order-10045   # optional, recommended on creates

Errors return a consistent shape with an HTTP status, a machine-readable code, and a request id you can quote to support:

{ "error": { "code": "invalid_api_key", "message": "...", "request_id": "req_..." } }

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 Coinwaka wallet.

Create

curl https://api.coinwaka.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": ["coinwaka_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.coinwaka.com/pay/pi_...",
  "merchant_reference": "ORDER-10045",
  "expires_at": "2026-06-10T12:40:00Z"
}

Redirect the customer to checkout_url. The hosted checkout is Coinwaka-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 payment

The 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

createdawaiting_paymentconfirming 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_paymentconfirmingpaid 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.

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.

Webhooks

Register HTTPS endpoints on the developer console. Coinwaka 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 duplicate

Verify the signature

Each request carries Coinwaka-Signature, Coinwaka-Timestamp, and Coinwaka-Event-Id. Recompute the HMAC over `${timestamp}.${rawBody}` with your endpoint secret and compare in constant time. Coinwaka-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.

Refunds

Coinwaka 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 Coinwaka 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.

  1. Create intents with an Idempotency-Key and store the returned id.
  2. Fulfil only on payment_intent.paid (webhook or retrieve), never on the redirect.
  3. Verify webhook signatures and deduplicate on the event id.
  4. Handle expired by creating a fresh intent.
  5. Switch keys to live; the API surface is identical.

Building in-person payments instead? See Lipa na Crypto for merchants