Overview

Pycue is a payment gateway providing a unified API for mobile money collections and disbursements in Ghana. It handles MoMo collections (deposits), MoMo transfers (withdrawals), SMS messaging, OTP verification, airtime, and MoMo-funded data bundles.

Base URL

/api/v1

What Pycue is responsible for

Pycue moves money against your organization vault and records the transaction. That is the whole of it.

  • Pycue does not store end-user balances. GET /api/v1/users/:id/balance returns 410 by design. Your platform is the system of record for user balances, and Pycue has no mechanism to change them.
  • Pycue does not check end-user eligibility or limits. It will send a payout for any externalUserId you supply, as long as your organization vault can cover it. Deciding who is owed what is your responsibility.
  • Pycue does not send outbound webhooks. There is no callback from Pycue to your server. See Completion and reconciliation.
  • A payout never appears as type: WITHDRAWAL. Every payout is type: ORG_TRANSFER. Filtering or reconciling on WITHDRAWAL matches nothing, ever.

Currency

All amounts are in GHS (Ghanaian Cedis) as decimal strings with at most 2 decimal places (e.g. "10.50"). Internally stored as pesewas: GHS 10.50 = 1050.

Authentication

All organization-facing API requests require an API key via the Authorization header.

Header
Authorization: Bearer gw_live_4f8a2b3c...

Keys are issued by the platform admin. The raw key (gw_live_* or gw_test_*) is shown only once at creation. Only the SHA-256 hash is stored server-side.

Never expose API keys in client-side code, public repos, or browser networks. Use gw_test_* keys for development.

Core Concepts

Transaction Lifecycle

Every financial operation goes through a state machine:

Deposits: PENDING → SUCCESS (or FAILED)
Withdrawals / Org Transfers: RESERVED (internal) → SUCCESS (or FAILED) after direct provider initiation
A successful transfer is finalized immediately; RESERVED is not a user-facing payout queue.

Users

Users are identified by externalUserId — an opaque string your system owns. Users are auto-created on first transaction; no separate registration endpoint is needed. Users are scoped to their organization.

Balances

Kakosino owns user eligibility. Pycue checks and decrements the organization vault, then sends the payout directly to the recipient's MoMo account. A short-lived internal reservation protects concurrent requests while the provider call is in progress; successful transfers are finalized immediately.

Response Envelope

All responses follow a consistent structure:

Success
{
  "success": true,
  "data": { ... }
}
Error
{
  "success": false,
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "User does not have enough available balance"
  }
}

Deposits

Initiate a MoMo collection (push payment) from a user's mobile money wallet. The user receives a USSD prompt on their phone to approve the payment.

POST/api/v1/deposits201 Created
ParameterTypeDescription
externalUserId*stringYour system's user identifier (1-128 chars, alphanumeric + _.:@-)
amount*stringDecimal amount in GHS with up to 2 decimal places (e.g. "10.50")
phoneNumber*stringGhanaian MoMo number in local format: 0XXXXXXXXX (10 digits)
network*string"MTN", "TELECEL", or "AIRTELTIGO"
clientReference*stringYour unique idempotency key per organization (1-128 chars)
Request
curl -X POST /api/v1/deposits \
  -H "Authorization: Bearer gw_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "externalUserId": "user_12345",
    "amount": "10.50",
    "phoneNumber": "0541234567",
    "network": "MTN",
    "clientReference": "order-42-deposit"
  }'
Response (201)
{
  "success": true,
  "data": {
    "transaction": {
      "id": "a1b2c3d4-...",
      "type": "DEPOSIT",
      "status": "PENDING",
      "amount": "10.50",
      "currency": "GHS",
      "externalUserId": "user_12345",
      "clientReference": "order-42-deposit",
      "providerReference": "903434548908798123",
      "providerTransactionId": "561338065478",
      "createdAt": "2026-08-26T10:00:00.000Z",
      "updatedAt": "2026-08-26T10:00:00.000Z"
    }
  }
}

The initial response is NOT a confirmation of funds. The deposit status is PENDING. Funds are credited only when the network authoritatively confirms the collection via webhook or reconciliation. Poll the transaction status or wait for the webhook.

Withdrawals

Send a Kakosino-authorized payout directly to the recipient's MoMo wallet. Kakosino owns user eligibility and transaction rules; Pycue records the organization transaction, checks the organization vault, and submits the MoMo transfer. Pycue does not maintain a user withdrawal reserve or financial queue.

POST/api/v1/withdrawals201 Created
ParameterTypeDescription
externalUserId*stringYour system's user identifier
amount*stringDecimal amount in GHS (e.g. "10.50")
accountNumber*stringRecipient MoMo number (digits only, 6-32 chars)
channel*string"MTN", "TELECEL", or "AIRTELTIGO"
accountName*stringRecipient's name (1-100 chars)
clientReference*stringYour unique idempotency key per organization
Request
curl -X POST /api/v1/withdrawals \
  -H "Authorization: Bearer gw_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "externalUserId": "user_12345",
    "amount": "5.00",
    "accountNumber": "0549876543",
    "channel": "MTN",
    "accountName": "Kwame Asante",
    "clientReference": "payout-99-withdraw"
  }'
Response (201)
{
  "success": true,
  "data": {
    "transaction": {
      "id": "e5f6g7h8-...",
      "type": "ORG_TRANSFER",
      "status": "SUCCESS",
      "amount": "5.00",
      "currency": "GHS",
      "externalUserId": "user_12345",
      "clientReference": "payout-99-withdraw",
      "providerReference": "payout-99-withdraw",
      "providerTransactionId": "305243536672",
      "createdAt": "2026-08-26T10:05:00.000Z",
      "updatedAt": "2026-08-26T10:05:00.000Z"
    }
  }
}

Insufficient balance returns 422. Withdrawals check and reserve funds atomically. For concurrent requests competing for the same funds, one wins the reservation and the other gets INSUFFICIENT_BALANCE.

Daily limit applies. All withdrawals (user-level and org-level) count toward the organization's daily withdrawal limit. Exceeding the limit returns INVALID_REQUEST.

Org Transfers

Transfer funds directly from the organization's vault to an external MoMo account. Unlike user withdrawals, no externalUserIdis involved — money comes from the organization's vault position.

Use this for organizations whose users only deposit (e.g. e-commerce) and the org admin needs to withdraw accumulated funds.

POST/api/v1/org-transfers201 Created
ParameterTypeDescription
amount*stringDecimal amount in GHS (e.g. "500.00")
accountNumber*stringRecipient MoMo number (digits only, 6-32 chars)
channel*string"MTN", "TELECEL", or "AIRTELTIGO"
accountName*stringRecipient's name (1-100 chars)
clientReference*stringYour unique idempotency key per organization
Request
curl -X POST /api/v1/org-transfers \
  -H "Authorization: Bearer gw_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "amount": "500.00",
    "accountNumber": "0244123456",
    "channel": "MTN",
    "accountName": "Acme Corp Payout",
    "clientReference": "org-payout-jan-12"
  }'
Response (201)
{
  "success": true,
  "data": {
    "transactionId": "f8a9b0c1-...",
    "duplicate": false,
    "status": "SUCCESS"
  }
}

Vault balance is checked. The transfer fails with INSUFFICIENT_BALANCEif the organization's vault position minus any in-flight transfers is less than the requested amount.

Daily limit applies. Org transfers count toward the same daily withdrawal limit as user withdrawals.

Daily Limits

Each organization has a configurable daily withdrawal limit that applies across all withdrawal types (user withdrawals and org transfers). The limit resets at midnight UTC.

How it works

  • Before each withdrawal or transfer, the system sums all successful withdrawals + transfers for today (UTC).
  • If the sum + requested amount exceeds the limit, the request is rejected with INVALID_REQUEST.
  • A limit of 0 means unlimited (default).
  • The limit is set per organization by the platform admin via the admin portal.

Example

If an org's daily limit is GHS 10,000.00 and they've already transferred GHS 7,500.00 today, a withdrawal or transfer for more than GHS 2,500.00 will be rejected.

Balances

Organization Vault

GET/api/v1/balanceAuth
Response
{
  "success": true,
  "data": {
    "currency": "GHS",
    "vaultPosition": "1250.00"
  }
}

User Balance

GET/api/v1/users/:externalUserId/balanceAuth
Response
{
  "success": true,
  "data": {
    "externalUserId": "user_12345",
    "currency": "GHS",
    "availableBalance": "50.00",
    "reservedBalance": "10.00"
  }
}

Transactions

List Transactions

GET/api/v1/transactionsAuth
ParameterTypeDescription
typestring"DEPOSIT" or "ORG_TRANSFER" — every payout is ORG_TRANSFER
statusstringFilter by status (PENDING, SUCCESS, FAILED, etc.)
externalUserIdstringFilter by user
limitnumberResults per page (1-100, default 20)
cursorstringPagination cursor (UUID from previous response)
Response
{
  "success": true,
  "data": {
    "transactions": [ { "id": "...", "type": "DEPOSIT", "status": "SUCCESS", ... } ],
    "pagination": {
      "hasMore": true,
      "nextCursor": "a1b2c3d4-...",
      "limit": 20
    }
  }
}

Get Transaction

GET/api/v1/transactions/:idAuth

Returns a single transaction. If the transaction is still PENDING, PROCESSING, or an ambiguous RESERVED transfer, a best-effort reconciliation check runs first. Normal accepted transfers are finalized immediately.

Airtime

Deliver airtime using either your organization vault balance or a MoMo payment prompt. MoMo-funded purchases remain PENDING or PROCESSING until Pycue confirms the provider payment, then airtime is sent.

POST/api/v1/airtime201 Created
ParameterTypeDescription
destination*stringAirtime recipient phone number (0XXXXXXXXX)
phoneNumber*stringPhone number used for the MoMo prompt; use the destination for balance-funded airtime
network*stringMTN, TELECEL, or AIRTELTIGO
amount*stringAirtime amount in GHS
fundingSource*stringBALANCE or MOMO
clientReference*stringUnique idempotency key per organization
Request
curl -X POST /api/v1/airtime \
  -H "Authorization: Bearer gw_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "destination": "0541008285",
    "phoneNumber": "0541008285",
    "network": "MTN",
    "amount": "10.00",
    "fundingSource": "MOMO",
    "clientReference": "airtime-order-42"
  }'
GET/api/v1/airtime/:idAuth

Poll this endpoint for MoMo-funded purchases. Once the status is SUCCESS, airtime delivery has been confirmed by Pycue.

Data Bundles

Offer recipient-specific data bundles through a MoMo payment prompt. Pycue fetches the available services and packages live, starts the purchase, and lets you poll for the final status.

GET/api/v1/data-bundles/servicesAuth

Returns the active data bundle services and their provider service IDs.

GET/api/v1/data-bundles/offers/:serviceId/:phoneNumberAuth

Returns the packages available for a service and recipient number. Use the returned package id, display name, and amount when starting a purchase.

POST/api/v1/data-bundles201 Created

This purchase flow is MoMo-funded. Pycue does not advertise a balance-funded data bundle flow.

ParameterTypeDescription
phoneNumber*stringData bundle recipient and MoMo phone number (0XXXXXXXXX)
network*stringMTN, TELECEL, or AIRTELTIGO
serviceId*stringService id returned by the services endpoint
packageId*stringPackage id returned by the offers endpoint
packageName*stringPackage display name returned by the offers endpoint
amount*stringPackage amount in GHS
clientReference*stringUnique idempotency key per organization
externalUserIdstringYour user or customer identifier
namestringRecipient name, when available
Request
curl -X POST /api/v1/data-bundles \
  -H "Authorization: Bearer gw_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "phoneNumber": "0541008285",
    "network": "MTN",
    "serviceId": "service-id-from-services",
    "packageId": "package-id-from-offers",
    "packageName": "1GB",
    "amount": "10.00",
    "clientReference": "bundle-order-42"
  }'
GET/api/v1/data-bundles/:idAuth

Poll this endpoint after the MoMo prompt. The purchase becomes SUCCESS only after Pycue confirms the provider status.

SMS

POST/api/v1/sms/sendAuth
ParameterTypeDescription
message*stringSMS body (1-480 characters)
recipients*string[]Array of Ghanaian phone numbers in 0XXXXXXXXX format
Request
{
  "message": "Your OTP code is 4829. Valid for 5 minutes.",
  "recipients": ["0541234567", "0209876543"]
}
Response (201)
{
  "success": true,
  "data": {
    "campaignId": "camp_1",
    "recipientCount": 2,
    "unitsConsumed": 2
  }
}

SMS requires prepaid credit. Each send deducts one credit unit per recipient from your organization's balance. Out-of-credit requests return 422 INSUFFICIENT_CREDIT. Buy credit from Pycue (the platform credits your balance directly).

OTP

Send SMS OTP

POST/api/v1/otp/sendAuth
ParameterTypeDescription
phoneNumber*stringGhanaian phone number (0XXXXXXXXX)
message*stringSMS body — must contain the placeholder <%%otp_code%%>
expirynumberMinutes until code expires (1-60, default 5)
lengthnumberCode length (4-8, default 4)
Request
{
  "phoneNumber": "0541234567",
  "message": "Your verification code is <%%otp_code%%>. It expires in 5 minutes.",
  "expiry": 5,
  "length": 6
}
Response (201)
{
  "success": true,
  "data": {
    "requestId": "05dd890a-f560-45aa-9dc1-0ef0ea72757b",
    "prefix": "JQmr"
  }
}

Verify SMS OTP

POST/api/v1/otp/verifyAuth
ParameterTypeDescription
requestId*UUIDThe request ID from the send response
phoneNumber*stringSame phone number used in send
code*stringThe code the user received (4-8 digits)

Email OTP

Same flow as SMS OTP but for email addresses.

POST/api/v1/otp/email/send
ParameterTypeDescription
email*stringRecipient email address
subject*stringEmail subject (1-120 chars)
message*stringEmail body — must contain <%%otp_code%%>
expirynumberMinutes (1-60, default 5)
lengthnumberCode length (4-8, default 4)
POST/api/v1/otp/email/verify
ParameterTypeDescription
requestId*UUIDRequest ID from email send
email*stringSame email used in send
code*stringThe code the user received (4-8 digits)

Completion and reconciliation

Pycue does not push completion events to you. A payout is only observable on your side through the response to your own request, or by polling. If your balance logic waits for an inbound callback, it will never fire.

There is no outbound webhook

Some organizations have a webhook URL configured for them. Pycue stores that value but does not currently deliver anything to it, and it must not be relied on. Do not build a balance-decrement path that depends on receiving a callback from Pycue.

The two supported paths

  1. React to the response of your own request. POST /api/v1/withdrawals returns the final state for an accepted transfer. A 201 means the network accepted the payout.
  2. Poll until the status is final. Query GET /api/v1/transactions/:id, or GET /api/v1/transactions?status=… to sweep. A payment is final when its status is SUCCESS, FAILED or CANCELLED.
StatusMeaningAction
PENDINGAccepted locally, not yet confirmedDo nothing. Keep polling.
PROCESSINGSeen in flight at the networkDo nothing. Keep polling.
RESERVEDAmbiguous. Outcome unknownDo nothing. Keep polling. Never assume it failed.
SUCCESSSettled exactly onceCommit your side. Safe to retry if your write was interrupted.
FAILEDDid not completeRelease your reservation. Retry is safe.
CANCELLEDRefunded or withdrawnRelease your reservation.

Recommended sweep

Run GET /api/v1/transactions?type=ORG_TRANSFER&status=RESERVED on a schedule and re-drive anything still unresolved. Poll status=PENDING and status=PROCESSING too. This is the only way to discover a completion you were not present for — a worker restart, a network blip, or a request whose response you never received.

Idempotent replays

clientReference is unique per organization at the database level. If you send the same key twice you receive the original transaction back with 200 and duplicate: true — never a second payout. The replayed response is also the authoritative record of that payout, so a client that discards a 200as “nothing to do” is dropping a real completion signal. Branch on duplicate, not on the HTTP status alone.

Webhooks

Pycue receives network callbacks for collections and direct MoMo transfers, and validates every payload before applying any financial transition. This is handled entirely on Pycue’s side: there is nothing for you to configure, and your application neither sends nor receives it.

Callbacks are never trusted at face value. The payload shape Pycue receives is:

Callback payload

Body
{
  "amount": "10.50",
  "status": "success",
  "transaction_id": "903434548908798123",
  "ext_transaction_id": "561338065478",
  "phone_number": "0541234567"
}

Security Model

Network callbacks are not signed, so Pycue compensates with three guards:

  1. transaction_id, client_reference, or ext_transaction_id must match a local provider or client reference. For transfers, providerReference is the exact bounded client_reference submitted for the payout.
  2. The amount must match the stored transaction amount exactly.
  3. Before crediting, Pycue re-verifies the status with an authoritative server-to-server call to GET /api/v1/payment-api/checkstatus/{transaction_id}.

Webhook Response

Response (always 200)
{
  "success": true,
  "data": {
    "received": true,
    "processed": true
  }
}

Possible reason values when processed: false:

ParameterTypeDescription
UNKNOWN_TRANSACTIONstringNo local transaction matches the transaction_id
AMOUNT_MISMATCHstringPayload amount does not match stored amount
AWAITING_PROVIDER_CONFIRMATIONstringWebhook says success but provider status check disagrees
ALREADY_SETTLEDstringTransaction was already credited or failed
STATUS_VERIFICATION_FAILEDstringProvider status check call failed

Errors

All errors follow the standard envelope with an error.code and error.message:

CodeHTTPMeaning
UNAUTHORIZED401Missing or invalid API key
FORBIDDEN403Not permitted for this resource
ORG_SUSPENDED403Organization is suspended
NOT_FOUND404Resource not found
INVALID_REQUEST400Request validation failed
INSUFFICIENT_BALANCE422User lacks funds for this withdrawal
INSUFFICIENT_CREDIT422Organization lacks SMS credit for this send
CONFLICT409Resource already exists (e.g. org name)
PROVIDER_ERROR502Upstream network failure
CONFIGURATION_ERROR503Missing gateway configuration
INTERNAL_ERROR500Unexpected server error

Idempotency

Both deposits and withdrawals are idempotent via the clientReference field. Sending the same clientReference with the same externalUserId returns the original transaction without creating a new one.

Idempotent request
// First call: creates a new deposit (201)
// Retry with same clientReference: returns existing (200)
{
  "externalUserId": "user_12345",
  "amount": "10.50",
  "phoneNumber": "0541234567",
  "network": "MTN",
  "clientReference": "order-42-deposit"
}

Safe to retry. Network timeouts, duplicate button clicks, and webhook replays are all handled safely. The clientReference is unique per organization at the database level.

Pycue Gateway · API Reference