Skip to content

API reference

Build on the Unified Payment Gateway

Guides and endpoint reference for authenticating, creating payments, receiving webhooks and reading transactions. Every endpoint has a live Try it console, so you can send real requests without leaving this page.

Getting started

Introduction

What the Unified Payment Gateway API does and how the pieces fit together.

Overview

The Unified Payment Gateway gives your systems one integration for Malawian payment providers. You create a payment with one request, the gateway routes it to PayChangu, OneKhusa, and you receive a signed webhook when it settles. No separate account or code per provider.

It is built for Cool Enterprises projects and their clients. Accounts are reviewed and approved by a platform administrator before they can create payments.

Getting started

Quickstart

Create your first payment from your server in four steps.

1. Create API credentials

Once your account is approved, open Developer / API access in the console, name the integration (for example “ERPNext Production”) and choose Generate credentials. You get an API key (pk_…) and an API secret (sk_…). The secret is shown once, so store both in your server's environment as GATEWAY_API_KEY and GATEWAY_API_SECRET. Your own email and password are not involved.

2. Get an access token

Exchange the key and secret for a token that lasts 15 minutes. Do this from your server, not from a browser or app.

cURL
curl -X POST https://upg-api.coolenterprisesmw.com/api/v1/auth/token \
  -H "Content-Type: application/json" \
  -d '{"apiKey":"'"$GATEWAY_API_KEY"'","apiSecret":"'"$GATEWAY_API_SECRET"'"}'
# -> { "status": "success", "data": { "accessToken": "eyJ…", "tokenType": "Bearer", "expiresIn": 900 } }

3. Create a payment

Send the access token as a Bearer token with the gateway, method, amount and customer. Always include an Idempotency-Key so a retry can never create a second payment.

curl -X POST https://upg-api.coolenterprisesmw.com/api/v1/payments/initialize \
  -H "Authorization: Bearer $GATEWAY_ACCESS_TOKEN" \
  -H "Idempotency-Key: order-1001" \
  -H "Content-Type: application/json" \
  -d '{"gateway":"paychangu","method":"mobile_money","operator":"airtel","amount":5000,"currency":"MWK","customer":{"name":"Chikondi Banda","phone":"0999123456"},"reference":"ORDER-1001"}'

The response's next_action tells you what the customer must do (approve a prompt, send money to an account number, or open a payment page). When the token expires you get 401 TOKEN_EXPIRED: request a new one and retry. API authentication shows a helper that does this for you.

4. Receive the result

Set your webhook URL under Settings. When the payment succeeds, fails, expires or is refunded, the gateway sends a signed event to it. Verify the signature, then mark your order as paid. See Webhooks for the exact steps.

Getting started

API authentication

How external systems (ERP, POS, website, mobile backend) sign in: API key and secret in, short-lived access token out.

How it works

Your ERP, POS, website or mobile backend authenticates with an API key and secret, created in the console. The pair is exchanged for a short-lived access token that you send with every request.

API authentication
WhoExternal systems
CredentialsAPI key + API secret
You receiveAccess token, valid 15 minutes
Used onPayment and read endpoints

Behind the scenes the gateway holds the PayChangu and OneKhusa credentials itself. Your system never needs them: it only ever talks to the gateway.

Five steps

  • Generate credentials. In the console open Developer / API access and create one credential per system.
  • Store the secret on your server. It is shown once and only a one-way hash of it is kept, so it cannot be recovered. Put it in an environment variable or secret manager.
  • Exchange them for a token. POST /api/v1/auth/token with apiKey and apiSecret. You get accessToken and expiresIn (900 seconds).
  • Call the API with the token. Send Authorization: Bearer <accessToken>.
  • Re-authenticate when it expires. Request a new token shortly before expiresIn runs out, or when you receive 401 TOKEN_EXPIRED.

Complete example

# 1. Exchange the credentials (once every ~15 minutes)
curl -X POST https://upg-api.coolenterprisesmw.com/api/v1/auth/token \
  -H "Content-Type: application/json" \
  -d '{"apiKey":"pk_live_xxxxx","apiSecret":"sk_live_xxxxx"}'

# 2. Use the accessToken from the response
curl -X POST https://upg-api.coolenterprisesmw.com/api/v1/payments/initialize \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"gateway":"paychangu","method":"mobile_money","operator":"airtel","amount":5000,"currency":"MWK","customer":{"name":"Chikondi Banda","phone":"0999123456"},"reference":"ORDER-1001"}'

Token expiry

Access tokens last expiresIn seconds (900, so 15 minutes). There is no refresh token for integrations: you simply repeat the exchange with your key and secret. Cache the token and reuse it until about a minute before it expires; do not request a new token for every payment. An expired token returns 401 TOKEN_EXPIRED. A token also stops working immediately if its credential is rotated, disabled or revoked.

Scopes

Each credential carries scopes, and its tokens can do only what they allow. Choose the narrowest set the integration needs. An API token can never reach console or admin endpoints, whatever its scopes.

ScopeAllows
payments:createPOST /payments
payments:readGET /payments/{id}, GET /payment-options/providers and /health
transactions:readGET /transactions and GET /transactions/{id}

Sandbox and production

Credentials are created for one environment and say so in their prefix: pk_test_… / sk_test_… for sandbox and pk_live_… / sk_live_… for production. A gateway serves one environment, so a sandbox credential is refused by the production gateway (403 WRONG_ENVIRONMENT) and the other way round. Use the base URL of the environment that matches your credentials.

Rotation and revocation

  • Rotate issues a new secret for the same key. The old secret and all tokens made with it stop working immediately, so deploy the new secret first or accept a short outage.
  • Disable pauses a credential; Enable resumes it. Tokens from before the pause stay invalid.
  • Revoke is permanent. Use it when a secret may have leaked, then create a new credential.
  • Every create, rotate, disable, revoke, token issue and failed attempt is recorded in the credential audit trail.

Authentication errors

HTTPCodeWhat to do
401INVALID_API_CREDENTIALSCheck the key and secret. The response does not say which one was wrong.
401API_CREDENTIAL_REVOKEDRevoked for good. Create a new credential.
401API_CREDENTIAL_DISABLEDEnable it in the console.
401TOKEN_EXPIREDRequest a new access token and retry.
401TOKEN_REVOKEDRotated or paused since issue. Request a new token.
401INVALID_TOKENSend exactly the accessToken you received, as a Bearer token.
403INSUFFICIENT_SCOPECreate or edit a credential with the needed scope.
403WRONG_ENVIRONMENTUse the gateway that matches the credential's environment.
Example
{
  "status": "error",
  "code": "TOKEN_EXPIRED",
  "message": "Access token has expired.",
  "requestId": "0b6f7a3e-…"
}

Rate limits

  • POST /auth/token: only failed attempts count. After 20 failures in 15 minutes from one address you get 429 RATE_LIMITED. Successful exchanges are not throttled.
  • Payment creation: 60 requests per minute per company.
  • All endpoints share a general per-address limit. On 429, back off and retry with the same Idempotency-Key.

Security best practices

  • Keep the API secret on a server you control, in an environment variable or secret manager. Never in a website, mobile app, browser code, repository, log or support ticket.
  • A mobile or web front end should call your backend, and your backend calls the gateway.
  • One credential per system, with the fewest scopes it needs, so you can revoke one without touching the rest.
  • Treat access tokens as secrets too: do not log them or pass them in URLs.
  • Rotate secrets on a schedule and immediately if a person with access leaves or a leak is suspected.
  • Always use HTTPS.

Moving off the raw secret

Sending the sk_… secret on every request is retired: it now answers 401 SECRET_NOT_ACCEPTED_AS_BEARER. The same key pair is still exchanged at POST /api/v1/auth/token, so migrating needs no new credentials - fetch a token and send it as the Bearer token.

Getting started

Access tokens

Exchange your API key and secret for a short-lived access token, and inspect the token you hold.

POST/api/v1/auth/token

Get an access token

The only request that carries your API secret. Returns a Bearer token that is valid for expiresIn seconds (900 = 15 minutes). The secret is never returned and never needs to be sent again until the token expires.

API key + secret (body)

Request body (JSON)

NameTypeDescription
apiKeyrequiredstringThe public key of an integration credential (pk_live_… or pk_test_…).
apiSecretrequiredstringIts secret (sk_live_… or sk_test_…). Shown once when you created it.
curl -X POST "https://upg-api.coolenterprisesmw.com/api/v1/auth/token" \
  -H "Content-Type: application/json" \
  -d '{"apiKey":"pk_live_xxxxxxxxxxxxxxxxxxxxxxxx","apiSecret":"sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}'
Example response
{
  "status": "success",
  "data": {
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
    "tokenType": "Bearer",
    "expiresIn": 900,
    "scope": "payments:create payments:read transactions:read",
    "environment": "live"
  }
}
GET/api/v1/auth/token/info

Inspect your access token

Shows which credential, environment and scopes the token you are holding carries, and when it expires. Handy for checking an integration. A console session is not accepted here.

API access token
curl -X GET "https://upg-api.coolenterprisesmw.com/api/v1/auth/token/info" \
  -H "Authorization: Bearer $GATEWAY_ACCESS_TOKEN"
Example response
{
  "status": "success",
  "data": {
    "credential_id": "6650c2…",
    "environment": "live",
    "scopes": ["payments:create", "payments:read", "transactions:read"],
    "expires_at": "2026-09-30T10:15:00.000Z",
    "legacy": false
  }
}

Getting started

Conventions

Base URL, authentication, idempotency, envelopes, pagination and amounts.

Base URL

All paths are relative to the gateway host you were given, for example https://upg-api.coolenterprisesmw.com. Use HTTPS only.

Authentication

Exchange your API key and secret at POST /api/v1/auth/token, then send Authorization: Bearer <accessToken> on every payment and read endpoint. See API authentication. Use Try it on any endpoint to run a request straight from this page.

Idempotency

Send Idempotency-Key (8 to 120 characters) on POST /api/v1/payments/initialize. Repeating a request with the same key returns the original payment with status 200 instead of creating another. Reusing a key with a different amount, gateway or method returns 409 IDEMPOTENCY_KEY_REUSED. Use one key per order, such as your order id.

Responses

Successful responses look like { "status": "success", "data": … }. Lists add a pagination block. Failures use the error shape described under Errors.

Pagination

List endpoints accept page (from 1) and pageSize (1 to 100, default 20) and return pagination.total and pagination.totalPages.

Dates and amounts

  • Timestamps are ISO 8601 in UTC, e.g. 2026-09-30T10:04:12.000Z.
  • Date filters (from, to) are YYYY-MM-DD days in Malawi time (UTC+2) and inclusive.
  • Amounts are plain numbers in the major unit (25000 means MWK 25,000), with at most two decimals. Always read the currency next to an amount.

Payments

Payments

Create a payment on any enabled gateway and read its status.

POST/api/v1/payments/initialize

Create a payment

Creates a payment on the chosen gateway. The payment starts as pending; the response tells you what the customer must do next in next_action. You are notified by webhook when it settles.

API access token

Request body (JSON)

NameTypeDescription
gatewayrequiredstringpaychangu or onekhusa. See Gateways for what each supports.
methodrequiredstringPayChangu: mobile_money, bank_transfer. OneKhusa: timed_transfer, hosted_checkout.
operatorstringMobile money operator id from the providers list (PayChangu mobile_money only), e.g. airtel.
amountrequirednumberGreater than zero, at most two decimals. PayChangu requires whole MWK.
currencystringThree-letter code. Defaults to MWK.
customer.namestringCustomer's full name.
customer.phonestringMalawian mobile number (0999123456 or 265999123456). Required for PayChangu mobile money.
customer.emailstringCustomer email address.
descriptionstringUp to 200 characters.
referencestringYour own order or invoice reference (up to 100 characters). Echoed back and searchable in the console.
callback_urlstringOverrides your default webhook URL for this payment.
return_urlstringWhere hosted-checkout customers are sent after paying.
curl -X POST "https://upg-api.coolenterprisesmw.com/api/v1/payments/initialize" \
  -H "Authorization: Bearer $GATEWAY_ACCESS_TOKEN" \
  -H "Idempotency-Key: order-1001" \
  -H "Content-Type: application/json" \
  -d '{"gateway":"onekhusa","method":"timed_transfer","amount":25000,"currency":"MWK","customer":{"name":"Thoko Phiri","email":"thoko@example.com"},"description":"School fees","reference":"INV-2044"}'
Example response
{
  "status": "success",
  "message": "Payment created.",
  "data": {
    "id": "6f1b2c1e-8e0a-4b8e-9d3c-2f0a5e7c1a11",
    "status": "pending",
    "gateway": "onekhusa",
    "method": "timed_transfer",
    "amount": 25000,
    "currency": "MWK",
    "reference": "INV-2044",
    "expires_at": "2026-09-30T10:15:00.000Z",
    "next_action": {
      "type": "timed_transfer",
      "timed_account_number": "1100482913",
      "expires_at": "2026-09-30T10:15:00.000Z",
      "message": "Ask the customer to send exactly 25000 MWK to this account number before it expires."
    }
  }
}
GET/api/v1/payments/{id}

Get a payment

Returns the payment. While it is pending, the gateway also asks the provider for the latest state, so this is safe to poll (no more than once every few seconds). Webhooks are the primary way to learn the result.

API access token
curl -X GET "https://upg-api.coolenterprisesmw.com/api/v1/payments/6f1b2c1e-8e0a-4b8e-9d3c-2f0a5e7c1a11" \
  -H "Authorization: Bearer $GATEWAY_ACCESS_TOKEN"
Example response
{
  "status": "success",
  "data": {
    "id": "6f1b2c1e-8e0a-4b8e-9d3c-2f0a5e7c1a11",
    "status": "successful",
    "gateway": "onekhusa",
    "amount": 25000,
    "currency": "MWK",
    "reference": "INV-2044",
    "fee": 500,
    "completed_at": "2026-09-30T10:04:12.000Z"
  }
}
GET/api/v1/payment-options/providers

List gateways

Which gateways are enabled on this platform, with their methods, currencies and mobile money operators. Needs the payments:read scope. Also works with a console session.

API access token
curl -X GET "https://upg-api.coolenterprisesmw.com/api/v1/payment-options/providers" \
  -H "Authorization: Bearer $GATEWAY_ACCESS_TOKEN"
Example response
{
  "status": "success",
  "data": [
    {
      "id": "paychangu",
      "display_name": "PayChangu",
      "enabled": true,
      "methods": ["mobile_money", "bank_transfer"],
      "currencies": ["MWK"],
      "operators": [{ "id": "airtel", "name": "Airtel Money" }, { "id": "tnm", "name": "TNM Mpamba" }]
    }
  ]
}
GET/api/v1/payment-options/health

Gateway health

Live connectivity check of every enabled gateway (cached for 30 seconds). Gateways without credentials report inactive.

API access token
curl -X GET "https://upg-api.coolenterprisesmw.com/api/v1/payment-options/health" \
  -H "Authorization: Bearer $GATEWAY_ACCESS_TOKEN"
Example response
{
  "status": "success",
  "data": {
    "status": "operational",
    "checked_at": "2026-09-30T10:00:00.000Z",
    "providers": [{ "name": "PayChangu", "status": "operational", "latency_ms": 212 }]
  }
}

Payments

Webhooks

Signed events sent to your server when a payment settles.

Set the endpoint

Save a public HTTPS URL under Settings in the console (or PUT /api/v1/api-keys/webhook). Use Send test event to confirm your server answers with a 2xx status. A single payment can override the address with callback_url.

Events

EventSent when
payment.successfulThe customer paid and the provider confirmed it.
payment.failedThe provider reported a failure.
payment.cancelledThe payment was cancelled or its payment window expired.
payment.refundedA successful payment was reversed.
webhook.testYou pressed Send test event.
Event body
{
  "id": "evt_5c1e…",
  "type": "payment.successful",
  "created_at": "2026-09-30T10:04:13.000Z",
  "data": {
    "id": "6f1b2c1e-8e0a-4b8e-9d3c-2f0a5e7c1a11",
    "status": "successful",
    "gateway": "onekhusa",
    "amount": 25000,
    "currency": "MWK",
    "reference": "INV-2044",
    "fee": 500
  }
}

Verify the signature

Each request has X-UPG-Signature: t=<unix>,v1=<hex>. Compute HMAC-SHA256 over the string "<t>.<raw body>" using your account signing secret (the secret of the original account key under Developer / API access, not an integration credential) and compare it to v1 in constant time. Reject the event if it does not match or if t is more than five minutes old.

Node.js
import crypto from "node:crypto";

// Use express.raw({ type: "application/json" }) so the body is the untouched bytes.
export function verify(rawBody, header, apiSecret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto.createHmac("sha256", apiSecret).update(`${parts.t}.${rawBody}`).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  return fresh && a.length === b.length && crypto.timingSafeEqual(a, b);
}

Retries

Answer with any 2xx status within 10 seconds. Otherwise the gateway retries after 1, 5, 15 and 60 minutes, then 4 and 12 hours, and stops after six attempts. Make your handler idempotent: a retry resends the same bytes with the same event id, so dedupe on it rather than on arrival order.

Every attempt is recorded under Settings in the console - the event, the address it went to, how many times we tried, the HTTP status your server returned and the error when there was one. Records are kept for 90 days. Only the company owner account can read them.

Payments

Gateways

What each gateway supports and how its payment flow works.

PayChangu

  • mobile_money - the customer gets a prompt on their phone and approves it with their PIN. Needs operator (see the providers list, e.g. airtel) and a Malawian customer.phone. Amounts are whole MWK.
  • bank_transfer - returns a dedicated account number in next_action; the customer transfers the exact amount to it. PayChangu enables this for banks and financial institutions, so it may be unavailable to your account.
  • Status is confirmed by asking PayChangu directly, both on a schedule and whenever you fetch the payment.

OneKhusa

  • timed_transfer - returns a one-time account number in next_action. The customer sends exactly the amount from any supported bank or mobile money wallet within 15 minutes. After that the payment becomes cancelled.
  • hosted_checkout - returns a redirect URL to OneKhusa's payment page. Set return_url to bring the customer back to your site.
  • OneKhusa confirms payments by signed webhook. The gateway rejects events with a bad signature and holds any event whose amount differs from the payment for review.

Account

Credentials & webhooks

Console endpoints (signed-in people only) for creating and managing the credentials your integrations use, and for the webhook endpoint. An API access token cannot call these.

GET/api/v1/api-credentials

List integration credentials

Every credential your company holds. Keys are masked and secrets are never included.

Console session (people)
curl -X GET "https://upg-api.coolenterprisesmw.com/api/v1/api-credentials" \
  -b cookies.txt
Example response
{
  "status": "success",
  "data": [
    {
      "id": "6650c2…", "name": "ERPNext Production", "environment": "live",
      "api_key": "pk_live_••••••••••••k3Zq", "secret_hint": "sk_live_••••••••••••9fAb",
      "scopes": ["payments:create", "payments:read"], "status": "active", "last_used_at": "…"
    }
  ],
  "meta": { "environment": "live", "scopes": ["payments:create", "payments:read", "transactions:read"], "token_ttl_seconds": 900 }
}
POST/api/v1/api-credentials

Create a credential

Creates a key and secret for one integration. The secret is returned in this response only. Only a one-way hash of it is stored, so it cannot be shown again.

Console session (people)

Request body (JSON)

NameTypeDescription
namerequiredstring2-80 characters. Which system uses it.
environmentstringlive or test. Must match the gateway you are on; defaults to it.
scopesstring[]Defaults to payments:create, payments:read and transactions:read. See Scopes.
curl -X POST "https://upg-api.coolenterprisesmw.com/api/v1/api-credentials" \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{"name":"ERPNext Production","scopes":["payments:create","payments:read"]}'
Example response
{
  "status": "success",
  "message": "Credential created. Copy the API secret now - it is shown only once.",
  "data": { "id": "6650c2…", "name": "ERPNext Production", "environment": "live", "api_key": "pk_live_…", "api_secret": "sk_live_…", "status": "active" }
}
POST/api/v1/api-credentials/{id}/rotate

Rotate a secret

Issues a new secret for the same key. The old secret and every access token issued with it stop working immediately. The new secret is shown once.

Console session (people)
curl -X POST "https://upg-api.coolenterprisesmw.com/api/v1/api-credentials/6650c2e1a9d3f0b1c2d3e4f5/rotate" \
  -b cookies.txt
Example response
{ "status": "success", "data": { "id": "6650c2…", "api_key": "pk_live_…", "api_secret": "sk_live_…" } }
POST/api/v1/api-credentials/{id}/disable

Disable or enable a credential

/disable pauses a credential and kills its live tokens; /enable turns it back on (tokens from before the pause stay dead, request a new one).

Console session (people)
curl -X POST "https://upg-api.coolenterprisesmw.com/api/v1/api-credentials/6650c2e1a9d3f0b1c2d3e4f5/disable" \
  -b cookies.txt
Example response
{ "status": "success", "data": { "id": "6650c2…", "status": "disabled" } }
POST/api/v1/api-credentials/{id}/revoke

Revoke a credential

Permanent. The credential can no longer get tokens and its live tokens are rejected at once. Create a new credential for further access.

Console session (people)
curl -X POST "https://upg-api.coolenterprisesmw.com/api/v1/api-credentials/6650c2e1a9d3f0b1c2d3e4f5/revoke" \
  -b cookies.txt
Example response
{ "status": "success", "data": { "id": "6650c2…", "status": "revoked" } }
GET/api/v1/api-credentials/audit/events

Credential audit trail

Credential created, rotated, disabled, revoked; tokens issued; failed API authentications. Never includes secrets or tokens.

Console session (people)

Query parameters

NameTypeDescription
limitnumberHow many events to return, 1-200 (default 50).
groupsstringComma-separated families to keep: lifecycle (api_credential.*), failures (api_auth.*), tokens (api_token.*). Omit it to get all three - the console asks for lifecycle,failures so a revocation is not buried under routine token issuance.
curl -X GET "https://upg-api.coolenterprisesmw.com/api/v1/api-credentials/audit/events?limit=20" \
  -b cookies.txt
Example response
{ "status": "success", "data": [{ "event": "api_token.issued", "outcome": "success", "credential_id": "6650c2…", "ip": "203.0.113.7", "at": "…" }] }
GET/api/v1/api-keys/info

Get account key status

The original account-level key pair (it signs your webhooks). Shows whether it is active and when it was used. The key is masked; the secret is never returned. Company owner account only.

Console session (people)
curl -X GET "https://upg-api.coolenterprisesmw.com/api/v1/api-keys/info" \
  -b cookies.txt
Example response
{ "status": "success", "data": { "api_key": "pk_abcd••••••••••••wxyz", "has_key": true, "active": true, "environment": "live", "created_at": "…", "last_used_at": "…" } }
POST/api/v1/api-keys/regenerate

Rotate the account key

Creates a new key and secret. The old ones stop working immediately. The secret is shown once. Company owner account only.

Console session (people)
curl -X POST "https://upg-api.coolenterprisesmw.com/api/v1/api-keys/regenerate" \
  -b cookies.txt
Example response
{ "status": "success", "data": { "api_key": "pk_…", "api_secret": "sk_…" } }
POST/api/v1/api-keys/deactivate

Deactivate the account key

Stops every request signed with the current secret until you rotate. Company owner account only.

Console session (people)
curl -X POST "https://upg-api.coolenterprisesmw.com/api/v1/api-keys/deactivate" \
  -b cookies.txt
Example response
{ "status": "success" }
GET/api/v1/api-keys/test

Test your access token

A cheap authenticated call to confirm your access token works (it also reports the environment and scopes). Also accepts a console session.

API access token
curl -X GET "https://upg-api.coolenterprisesmw.com/api/v1/api-keys/test" \
  -H "Authorization: Bearer $GATEWAY_ACCESS_TOKEN"
Example response
{ "status": "success", "message": "Your access token is valid.", "data": { "method": "api_token", "environment": "live", "scopes": ["payments:create"] } }
PUT/api/v1/api-keys/webhook

Set the webhook endpoint

Saves the URL that receives payment events. Send an empty string to turn webhooks off. Company owner account only.

Console session (people)

Request body (JSON)

NameTypeDescription
urlrequiredstringPublic HTTPS URL (HTTP is allowed only outside production).
curl -X PUT "https://upg-api.coolenterprisesmw.com/api/v1/api-keys/webhook" \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{"url":"https://shop.example.com/gateway-webhook"}'
Example response
{ "status": "success", "data": { "url": "https://shop.example.com/gateway-webhook" } }
POST/api/v1/api-keys/webhook/test

Send a test event

Sends a signed webhook.test event to your endpoint and reports whether it answered with a 2xx status. Company owner account only.

Console session (people)
curl -X POST "https://upg-api.coolenterprisesmw.com/api/v1/api-keys/webhook/test" \
  -b cookies.txt
Example response
{ "status": "success", "message": "Test event delivered." }
GET/api/v1/api-keys/webhook/deliveries

Read the delivery log

Every event we sent to your endpoint with the attempt count, the HTTP status your server returned and the error when there was one. Company owner account only. Records are kept for 90 days.

Console session (people)

Query parameters

NameTypeDescription
limitnumberHow many events to return, 1-100 (default 25).
curl -X GET "https://upg-api.coolenterprisesmw.com/api/v1/api-keys/webhook/deliveries" \
  -b cookies.txt
Example response
{ "status": "success", "data": [{ "id": "66a1…", "event": "payment.successful", "status": "delivered", "url": "https://shop.example.com/gateway-webhook", "payment_id": "66a0…", "gateway": "onekhusa", "attempts": 2, "response_status": 200, "created_at": "2026-09-30T10:04:13.000Z", "delivered_at": "2026-09-30T10:05:14.000Z" }], "meta": { "delivered": 41, "retrying": 1, "dropped": 0, "max_attempts": 6, "backoff_minutes": [1, 5, 15, 60, 240, 720] } }

Account

Transactions

The payment ledger for your company (or the whole platform for administrators). Listing and reading are also open to integrations through the transactions:read scope.

GET/api/v1/transactions

List transactions

Paginated, newest first. Company owners and their team see the company's payments; platform administrators see every merchant.

API access token

Query parameters

NameTypeDescription
pageinteger1-based page number. Default 1.
pageSizeinteger1-100. Default 20.
statusstringpending, successful, failed, cancelled or refunded.
providerstringpaychangu or onekhusa.
fromdateFirst day, YYYY-MM-DD (Malawi time).
todateLast day, inclusive.
querystringMatches payment id, references, customer name, email or phone.
curl -X GET "https://upg-api.coolenterprisesmw.com/api/v1/transactions?status=successful&provider=onekhusa&pageSize=5" \
  -H "Authorization: Bearer $GATEWAY_ACCESS_TOKEN"
Example response
{
  "status": "success",
  "data": [
    { "id": "6f1b…", "reference": "INV-2044", "status": "successful", "provider": "OneKhusa", "method": "Timed account transfer", "amount": 25000, "currency": "MWK", "created_at": "…" }
  ],
  "pagination": { "page": 1, "pageSize": 20, "total": 120, "totalPages": 6 },
  "totals": [{ "status": "successful", "currency": "MWK", "count": 42, "amount": 1050000 }]
}
GET/api/v1/transactions/{id}

Get a transaction

Full record with fee, net, payer instructions, webhook delivery state and the event timeline.

API access token
curl -X GET "https://upg-api.coolenterprisesmw.com/api/v1/transactions/6f1b2c1e-8e0a-4b8e-9d3c-2f0a5e7c1a11" \
  -H "Authorization: Bearer $GATEWAY_ACCESS_TOKEN"
Example response
{ "status": "success", "data": { "id": "6f1b…", "status": "successful", "fee": 500, "net": 24500, "events": [{ "label": "Payment created", "status": "pending", "occurred_at": "…" }] } }
POST/api/v1/transactions/{id}/refresh

Check status now

Asks the provider for the latest state of a pending payment and returns the updated record.

Console session (people)
curl -X POST "https://upg-api.coolenterprisesmw.com/api/v1/transactions/6f1b2c1e-8e0a-4b8e-9d3c-2f0a5e7c1a11/refresh" \
  -b cookies.txt
Example response
{ "status": "success", "data": { "id": "6f1b…", "status": "pending" } }

Reference

Errors

The single error shape and every code you can receive.

Error shape

Failed requests return a non-2xx status and this body:

Error response
{
  "status": "error",
  "code": "VALIDATION_ERROR",
  "message": "Please correct the highlighted fields.",
  "requestId": "0b6f7a3e-…",
  "errors": { "email": "Enter a valid email address." }
}
  • code - a stable identifier. Branch on this, not on message.
  • message - a readable explanation you can show to users.
  • requestId - quote this to support when reporting a problem.
  • errors - optional per-field messages.

Error codes

HTTPCodeMeaning
400VALIDATION_ERRORA field is missing or invalid. See `errors`. Related codes: `INVALID_AMOUNT`, `INVALID_PHONE`, `INVALID_OPERATOR`, `UNSUPPORTED_METHOD`.
400PROVIDER_REJECTEDThe provider refused the request. `message` explains why.
401INVALID_API_CREDENTIALSWrong API key or secret. The response never says which.
401API_CREDENTIAL_REVOKEDThe credential was revoked. Create a new one in the console.
401API_CREDENTIAL_DISABLEDThe credential is paused. Enable it in the console.
401TOKEN_EXPIREDThe access token has expired. Request a new one and retry.
401TOKEN_REVOKEDThe credential's secret was rotated or it was paused after this token was issued. Request a new token.
401INVALID_TOKENThe token is malformed or altered, or the Authorization header is not `Bearer <access token>`.
401MISSING_AUTHENTICATIONNo Bearer token on an API-only endpoint.
401SECRET_NOT_ACCEPTED_AS_BEARERYou sent an API secret instead of an access token. Exchange it at POST /api/v1/auth/token first.
401ACCOUNT_DEACTIVATEDThe merchant account is deactivated.
403FORBIDDENYour account or role may not do this.
403INSUFFICIENT_SCOPEThe token's credential lacks the scope this endpoint needs.
403WRONG_ENVIRONMENTA sandbox credential on the live gateway, or the reverse.
403MERCHANT_NOT_APPROVEDYour company has not been approved to process payments yet.
404NOT_FOUNDThe record does not exist or is not visible to you.
409IDEMPOTENCY_KEY_REUSEDThat Idempotency-Key was used for a different payment.
429RATE_LIMITEDToo many requests. Wait and retry.
502 / 504PROVIDER_ERROR / PROVIDER_TIMEOUTThe provider could not be reached. For payment creation the payment stays `pending` and is confirmed automatically.
503GATEWAY_NOT_CONFIGUREDThat gateway is not enabled on this platform.

Handling errors

JavaScript
const res = await fetch(url, options);
const body = await res.json().catch(() => null);

if (!res.ok) {
  if (body?.code === "MERCHANT_NOT_APPROVED") return showPendingApproval();
  throw new Error(body?.message ?? "The request failed.");
}

Reference

Payment statuses

Every status a payment can be in.

Payment statuses

StatusMeaning
pendingCreated and waiting for the customer or the provider.
successfulPaid and confirmed by the provider.
failedThe provider rejected or failed the payment. No money was taken.
cancelledCancelled by the customer, or the payment window expired. A late confirmation can still move it to successful.
refundedA successful payment was reversed.

Something missing or unclear?

Quote the requestId from any error and contact support.

Contact support