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/v1What 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/balancereturns410by 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
externalUserIdyou 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 istype: ORG_TRANSFER. Filtering or reconciling onWITHDRAWALmatches 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.
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:
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": true,
"data": { ... }
}{
"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.
/api/v1/deposits201 Created| Parameter | Type | Description |
|---|---|---|
externalUserId* | string | Your system's user identifier (1-128 chars, alphanumeric + _.:@-) |
amount* | string | Decimal amount in GHS with up to 2 decimal places (e.g. "10.50") |
phoneNumber* | string | Ghanaian MoMo number in local format: 0XXXXXXXXX (10 digits) |
network* | string | "MTN", "TELECEL", or "AIRTELTIGO" |
clientReference* | string | Your unique idempotency key per organization (1-128 chars) |
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"
}'{
"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.
/api/v1/withdrawals201 Created| Parameter | Type | Description |
|---|---|---|
externalUserId* | string | Your system's user identifier |
amount* | string | Decimal amount in GHS (e.g. "10.50") |
accountNumber* | string | Recipient MoMo number (digits only, 6-32 chars) |
channel* | string | "MTN", "TELECEL", or "AIRTELTIGO" |
accountName* | string | Recipient's name (1-100 chars) |
clientReference* | string | Your unique idempotency key per organization |
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"
}'{
"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.
/api/v1/org-transfers201 Created| Parameter | Type | Description |
|---|---|---|
amount* | string | Decimal amount in GHS (e.g. "500.00") |
accountNumber* | string | Recipient MoMo number (digits only, 6-32 chars) |
channel* | string | "MTN", "TELECEL", or "AIRTELTIGO" |
accountName* | string | Recipient's name (1-100 chars) |
clientReference* | string | Your unique idempotency key per organization |
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"
}'{
"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
0means 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
/api/v1/balanceAuth{
"success": true,
"data": {
"currency": "GHS",
"vaultPosition": "1250.00"
}
}User Balance
/api/v1/users/:externalUserId/balanceAuth{
"success": true,
"data": {
"externalUserId": "user_12345",
"currency": "GHS",
"availableBalance": "50.00",
"reservedBalance": "10.00"
}
}Transactions
List Transactions
/api/v1/transactionsAuth| Parameter | Type | Description |
|---|---|---|
type | string | "DEPOSIT" or "ORG_TRANSFER" — every payout is ORG_TRANSFER |
status | string | Filter by status (PENDING, SUCCESS, FAILED, etc.) |
externalUserId | string | Filter by user |
limit | number | Results per page (1-100, default 20) |
cursor | string | Pagination cursor (UUID from previous response) |
{
"success": true,
"data": {
"transactions": [ { "id": "...", "type": "DEPOSIT", "status": "SUCCESS", ... } ],
"pagination": {
"hasMore": true,
"nextCursor": "a1b2c3d4-...",
"limit": 20
}
}
}Get Transaction
/api/v1/transactions/:idAuthReturns 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.
/api/v1/airtime201 Created| Parameter | Type | Description |
|---|---|---|
destination* | string | Airtime recipient phone number (0XXXXXXXXX) |
phoneNumber* | string | Phone number used for the MoMo prompt; use the destination for balance-funded airtime |
network* | string | MTN, TELECEL, or AIRTELTIGO |
amount* | string | Airtime amount in GHS |
fundingSource* | string | BALANCE or MOMO |
clientReference* | string | Unique idempotency key per organization |
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"
}'/api/v1/airtime/:idAuthPoll 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.
/api/v1/data-bundles/servicesAuthReturns the active data bundle services and their provider service IDs.
/api/v1/data-bundles/offers/:serviceId/:phoneNumberAuthReturns the packages available for a service and recipient number. Use the returned package id, display name, and amount when starting a purchase.
/api/v1/data-bundles201 CreatedThis purchase flow is MoMo-funded. Pycue does not advertise a balance-funded data bundle flow.
| Parameter | Type | Description |
|---|---|---|
phoneNumber* | string | Data bundle recipient and MoMo phone number (0XXXXXXXXX) |
network* | string | MTN, TELECEL, or AIRTELTIGO |
serviceId* | string | Service id returned by the services endpoint |
packageId* | string | Package id returned by the offers endpoint |
packageName* | string | Package display name returned by the offers endpoint |
amount* | string | Package amount in GHS |
clientReference* | string | Unique idempotency key per organization |
externalUserId | string | Your user or customer identifier |
name | string | Recipient name, when available |
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"
}'/api/v1/data-bundles/:idAuthPoll this endpoint after the MoMo prompt. The purchase becomes SUCCESS only after Pycue confirms the provider status.
SMS
/api/v1/sms/sendAuth| Parameter | Type | Description |
|---|---|---|
message* | string | SMS body (1-480 characters) |
recipients* | string[] | Array of Ghanaian phone numbers in 0XXXXXXXXX format |
{
"message": "Your OTP code is 4829. Valid for 5 minutes.",
"recipients": ["0541234567", "0209876543"]
}{
"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
/api/v1/otp/sendAuth| Parameter | Type | Description |
|---|---|---|
phoneNumber* | string | Ghanaian phone number (0XXXXXXXXX) |
message* | string | SMS body — must contain the placeholder <%%otp_code%%> |
expiry | number | Minutes until code expires (1-60, default 5) |
length | number | Code length (4-8, default 4) |
{
"phoneNumber": "0541234567",
"message": "Your verification code is <%%otp_code%%>. It expires in 5 minutes.",
"expiry": 5,
"length": 6
}{
"success": true,
"data": {
"requestId": "05dd890a-f560-45aa-9dc1-0ef0ea72757b",
"prefix": "JQmr"
}
}Verify SMS OTP
/api/v1/otp/verifyAuth| Parameter | Type | Description |
|---|---|---|
requestId* | UUID | The request ID from the send response |
phoneNumber* | string | Same phone number used in send |
code* | string | The code the user received (4-8 digits) |
Email OTP
Same flow as SMS OTP but for email addresses.
/api/v1/otp/email/send| Parameter | Type | Description |
|---|---|---|
email* | string | Recipient email address |
subject* | string | Email subject (1-120 chars) |
message* | string | Email body — must contain <%%otp_code%%> |
expiry | number | Minutes (1-60, default 5) |
length | number | Code length (4-8, default 4) |
/api/v1/otp/email/verify| Parameter | Type | Description |
|---|---|---|
requestId* | UUID | Request ID from email send |
email* | string | Same email used in send |
code* | string | The 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
- React to the response of your own request.
POST /api/v1/withdrawalsreturns the final state for an accepted transfer. A201means the network accepted the payout. - Poll until the status is final. Query
GET /api/v1/transactions/:id, orGET /api/v1/transactions?status=…to sweep. A payment is final when its status isSUCCESS,FAILEDorCANCELLED.
| Status | Meaning | Action |
|---|---|---|
| PENDING | Accepted locally, not yet confirmed | Do nothing. Keep polling. |
| PROCESSING | Seen in flight at the network | Do nothing. Keep polling. |
| RESERVED | Ambiguous. Outcome unknown | Do nothing. Keep polling. Never assume it failed. |
| SUCCESS | Settled exactly once | Commit your side. Safe to retry if your write was interrupted. |
| FAILED | Did not complete | Release your reservation. Retry is safe. |
| CANCELLED | Refunded or withdrawn | Release 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
{
"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:
transaction_id,client_reference, orext_transaction_idmust match a local provider or client reference. For transfers,providerReferenceis the exact boundedclient_referencesubmitted for the payout.- The
amountmust match the stored transaction amount exactly. - 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
{
"success": true,
"data": {
"received": true,
"processed": true
}
}Possible reason values when processed: false:
| Parameter | Type | Description |
|---|---|---|
UNKNOWN_TRANSACTION | string | No local transaction matches the transaction_id |
AMOUNT_MISMATCH | string | Payload amount does not match stored amount |
AWAITING_PROVIDER_CONFIRMATION | string | Webhook says success but provider status check disagrees |
ALREADY_SETTLED | string | Transaction was already credited or failed |
STATUS_VERIFICATION_FAILED | string | Provider status check call failed |
Errors
All errors follow the standard envelope with an error.code and error.message:
| Code | HTTP | Meaning |
|---|---|---|
UNAUTHORIZED | 401 | Missing or invalid API key |
FORBIDDEN | 403 | Not permitted for this resource |
ORG_SUSPENDED | 403 | Organization is suspended |
NOT_FOUND | 404 | Resource not found |
INVALID_REQUEST | 400 | Request validation failed |
INSUFFICIENT_BALANCE | 422 | User lacks funds for this withdrawal |
INSUFFICIENT_CREDIT | 422 | Organization lacks SMS credit for this send |
CONFLICT | 409 | Resource already exists (e.g. org name) |
PROVIDER_ERROR | 502 | Upstream network failure |
CONFIGURATION_ERROR | 503 | Missing gateway configuration |
INTERNAL_ERROR | 500 | Unexpected 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.
// 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