DiGii Sente Payment Gateway, API Integration Guide
Version: 1.0
Last Updated: March 2026
Base URL: https://api.digiisente.com/api/gateway/v1
Sandbox URL: https://api.digiisente.com/api/gateway/v1
Before you sign your first request
Three things will save you hours of debugging on day one.
1. Sign over canonical (no-whitespace) JSON
The backend reconstructs your body as JSON.stringify(req.body), which produces compact JSON with no spaces or newlines. The HMAC bytes you sign over MUST equal the bytes you send.
If you pretty-print your body for logging or readability, do that after signing.
| Language | Canonical body |
|---|---|
| Node / Browser | JSON.stringify(body) (already canonical by default) |
| Python | json.dumps(body, separators=(',', ':')) (the separators kwarg is required) |
| PHP | json_encode($body) (canonical by default) |
| Go | json.Marshal(body) (canonical by default) |
| Ruby | JSON.generate(body) (canonical by default) |
| Anywhere else | Confirm your library defaults to no-whitespace JSON output |
2. X-Timestamp is epoch seconds, not milliseconds
The header must be the current UTC time in seconds since epoch, within 5 minutes of server time. Off-by-1000 (sending milliseconds) is the second most common cause of INVALID_SIGNATURE we see.
X-Timestamp: 1780012958 <- correct (10 digits, epoch seconds)
X-Timestamp: 1780012958123 <- wrong (13 digits, epoch milliseconds)
3. The signing string format is exact
{timestamp}.{METHOD}.{path}.{body}
- Periods separate the four fields.
METHODis uppercase (POST,GET,PUT,DELETE).pathis the full URL path INCLUDING any query string (/api/gateway/v1/transactions?page=1for example).bodyis the canonical JSON from rule 1 above, or the literal string{}for requests with no body (GET / DELETE).
When something fails
The 401 response body's error field tells you which gate rejected:
INVALID_API_KEY: key not found or has been revoked. Check yourBearer sk_test_...is correct.INVALID_SIGNATURE: HMAC mismatch. 99% of the time this is rule 1 (body whitespace) or rule 3 (path missing query string).TIMESTAMP_EXPIRED: clock skew. Check your server time is in sync, and you are sending seconds not milliseconds.MISSING_SIGNATURE: you forgot to include theX-SignatureorX-Timestampheaders.
If you are stuck after checking the above, email developers@digiisente.com with the partner ID, the X-Request-Id from your failing response headers, and the exact signingString you computed (mask the secret). We will trace the request through CloudWatch and tell you which byte differed.
Table of Contents
- Overview
- Getting Started
- Authentication
- Request Signing (HMAC)
- API Endpoints
- Webhooks
- Payment Flow
- Idempotency
- Environments (Sandbox & Production)
- Error Handling
- Rate Limits
- Code Examples
- Testing Checklist
- FAQ
- Support
- Related DiGii Sente rails (situational awareness)
1. Overview
The DiGii Sente Payment Gateway lets your business accept payments from DiGii wallet users or from MTN / Airtel Mobile Money numbers on the same endpoint. The flow is simple:
- Your server calls our API to request a payment from a customer.
- Either (a) the DiGii Sente app shows an in-app request the customer approves with their PIN, or (b) an MTN / Airtel STK push lands on the customer's phone and they approve with their MoMo PIN.
- Money moves: Customer → Your partner wallet (or straight to a
destinationAccountyou specify, minus the DiGii fee). - We send a webhook to your server confirming the payment.
Key features:
- Two collection rails on one endpoint: DiGii wallet (in-app PIN approval) or Mobile Money STK push (MTN + Airtel, auto-detected from phone prefix)
paymentMethod: "auto"picks the best rail per customer - wallet if they're on DiGii, else MoMo- Customer must explicitly approve every payment (no auto-debit)
- Real-time push notifications for wallet users
- Webhook notifications for payment events
- Full refund support (wallet rail)
- Sandbox environment for testing
- Idempotent requests (safe to retry)
Fees
DiGii charges a small fee per rail. Fees are configurable per partner - the defaults below apply unless you've negotiated a custom rate.
| Rail | transactionType | Default |
|---|---|---|
| Collect (inbound) | GATEWAY_COLLECTION | 100 UGX flat (until admin sets a global) |
| Payout - Wallet | GATEWAY_PAYOUT_WALLET | 0 (free, DiGii-internal) |
| Payout - MoMo | GATEWAY_PAYOUT_MOMO | 0 (admin sets after ISW cost is negotiated) |
| Payout - Bank | GATEWAY_PAYOUT_BANK | 0 (admin sets after ISW cost is negotiated) |
| Refund | GATEWAY_REFUND | 0 (opt-in) |
| Bill payments | biller-derived (UEDCL_PAYMENT, NWSC_*, etc.) | per biller, see admin config |
| Airtime | AIRTIME_PURCHASE | 0 (subsidised by biller surcharge) |
| Data bundles | biller-derived per bundle | 0 by default |
| URA tax (PAYE, VAT, OTHER) | BUSINESS_WALLET_URA_PAYMENT | FLAT 5,000 UGX (fee routes to URA_FEE sysacct on our side) |
| NSSF (individual, company) | BUSINESS_WALLET_NSSF_PAYMENT | FLAT 5,000 UGX (fee routes to NSSF_FEE sysacct on our side) |
Your effective rate is returned inline on every response as the fee field. Contact your DiGii account manager to negotiate custom rates - they're set on your ApiPartner record and apply automatically to all subsequent calls (no code change needed on your side).
Preview the fee before you charge. Every partner-facing operation supports a preview endpoint - see Section 5.10: Fee Preview. Same fee calc as the real charge, so no drift possible. Cache client-side for ~60s.
Sandbox mode
Every new partner starts in isolated sandbox mode. Your sk_test_... key runs against a fully simulated pipeline: DiGii creates the ApiTransaction row, fires the webhook, and returns the same response shape as production, but no real money moves. Test freely — you can't accidentally debit anyone.
Test scenario conventions (identifier suffix drives outcome):
| Rail | Success test | Failure test |
|---|---|---|
| Collect (any format) | 0700000012 / +256700000012 | 0700000021 / +256700000021 |
| Payout to Wallet or MoMo | phone ends in 12 | phone ends in 21 |
| Payout to Bank | account ends in 12 (e.g. BOA + 098542680012) | account ends in 21 (e.g. BOA + 098542680021) |
Recommended test amount: 5000 UGX (behaviour is driven by the identifier suffix, not the amount — any amount works, but keeping to 5000 makes your test dashboards clean).
Going live:
- Complete your integration against sandbox — happy path + failure path.
- Enable 2FA in your developer portal (required before live).
- Ask your DiGii account manager to promote you to LIVE. You'll receive a "You are LIVE" email.
- Log into the portal and generate a fresh
sk_live_+pk_live_pair yourself. DiGii never sees your live secret — it's shown once, in your own session, and never stored on our side. - From that moment,
sk_live_moves real money. Yoursk_test_continues to route through the simulated sandbox path for ongoing regression testing.
Inbound collections
POST /payments/collect covers two inbound rails, selected via the paymentMethod field:
| Rail | Trigger | Customer approves via | Typical time to settle |
|---|---|---|---|
wallet | Customer is on DiGii Sente | Push notification + 4-digit PIN in-app | Instant |
momo | Any MTN or Airtel Uganda number | STK push on the phone + MoMo PIN on dialer | Seconds (up to 15 min if delayed) |
auto (default) | We pick per customer | wallet if they have a DiGii account, else momo | Same as chosen rail |
Bank collections are not a rail today - banks are payout-only (see below). If you need bank pay-in, talk to your DiGii account manager about the RTGS pull that's in the pipeline.
Outbound payouts
You can also send money out of your partner wallet to any of three rails:
| Rail | Recipient | How it lands |
|---|---|---|
WALLET | DiGii Sente user | Instant, atomic wallet-to-wallet |
MOMO | MTN or Airtel Uganda phone | Provider-mediated (usually seconds) |
BANK | Any of 28 Ugandan banks | RTGS or real-time rail, auto-selected by amount |
AUTO | Any of the above | Resolves to WALLET first, falls back to MOMO |
Bank coverage spans ABC Capital, ABSA, Afriland, Bank of Africa, Bank of Baroda, Bank of India, Cairo, Centenary, Citibank, dfcu, Diamond Trust, Ecobank, Equity, EXIM, Finance Trust, GTBank, Housing Finance, I&M (Orient), KCB, NCBA, Opportunity, PostBank, Stanbic, Standard Chartered, Tropical, UBA, Uganda Development Bank, and UGAFODE. Fetch the live registry (with per-bank RTGS/REALTIME availability + current fee tier) any time via GET /api/banks - public read, ETag-cacheable so your nightly sync is essentially free.
The payout endpoints (POST /payouts/send, POST /payouts/validate-recipient, GET /payouts/:reference) share the same auth, HMAC, idempotency and rate-limit surface as collections. See Send Payout.
2. Getting Started
Step 1: Get Your Credentials
Contact DiGii Sente to register as an API partner. Once approved, you will receive:
| Credential | Format | Purpose |
|---|---|---|
| Public Key | pk_test_xxxxxxxx | Identifies your account (not secret) |
| Secret Key | sk_test_xxxxxxxx | Authenticates API requests , keep this secret |
Important: Your secret key is shown only once when generated. Store it securely. If lost, you must request a new key pair.
Step 2: Configure Your Webhook URL
Provide a publicly accessible HTTPS URL where we will send payment event notifications. Your webhook endpoint must:
- Accept
POSTrequests - Return a
2xxstatus code within 10 seconds - Be served over HTTPS
Step 3: Start in Sandbox
Use your sk_test_ key to test the full payment flow without moving real money. When ready, request promotion to production to receive sk_live_ keys.
3. Authentication
All API requests must include your secret key in the Authorization header:
Authorization: Bearer sk_test_your_secret_key_here
Key Types
| Key Prefix | Environment | Can Move Real Money? |
|---|---|---|
sk_test_ | Sandbox | No |
sk_live_ | Production | Yes |
Security Rules
- Never expose your secret key in client-side code, mobile apps, or public repositories
- Never share your secret key over email or chat , use a secure channel
- All requests must be made server-to-server (your backend → our API)
- Production keys require your partner account to be in LIVE status
- Production requests are verified against your IP whitelist (configured by admin)
4. Request Signing (HMAC)
Every request must include an HMAC-SHA256 signature to verify integrity. This prevents tampering and replay attacks.
Required Headers
| Header | Description |
|---|---|
Authorization | Bearer sk_test_xxx or Bearer sk_live_xxx |
X-Signature | HMAC-SHA256 signature (hex) |
X-Timestamp | Unix timestamp in seconds (e.g., 1711036800) |
Content-Type | application/json |
How to Compute the Signature
Signing string format:
{timestamp}.{HTTP_METHOD}.{full_path}.{JSON_body}
Steps:
- Get the current Unix timestamp in seconds (not milliseconds).
- Build the signing string by joining:
timestamp, HTTP method (e.g.,POST), the full request path (e.g.,/api/gateway/v1/payments/collect), and the JSON request body. - Compute HMAC-SHA256 using your secret key as the key and the signing string as the message.
- Send the hex-encoded result as
X-Signature.
Example (Node.js):
const crypto = require('crypto');
function signRequest(secretKey, method, path, body, timestamp) {
const bodyString = JSON.stringify(body || {});
const signingString = `${timestamp}.${method}.${path}.${bodyString}`;
return crypto
.createHmac('sha256', secretKey)
.update(signingString)
.digest('hex');
}
// Usage
const timestamp = Math.floor(Date.now() / 1000);
const signature = signRequest(
'sk_test_your_key',
'POST',
'/api/gateway/v1/payments/collect',
{ phoneNumber: '0771234567', amount: 5000, reference: 'ORDER-001' },
timestamp
);
Example (Python):
import hmac
import hashlib
import json
import time
def sign_request(secret_key, method, path, body, timestamp):
body_string = json.dumps(body or {}, separators=(',', ':'))
signing_string = f"{timestamp}.{method}.{path}.{body_string}"
return hmac.new(
secret_key.encode(),
signing_string.encode(),
hashlib.sha256
).hexdigest()
timestamp = int(time.time())
signature = sign_request(
'sk_test_your_key',
'POST',
'/api/gateway/v1/payments/collect',
{'phoneNumber': '0771234567', 'amount': 5000, 'reference': 'ORDER-001'},
timestamp
)
Example (PHP):
function signRequest($secretKey, $method, $path, $body, $timestamp) {
$bodyString = json_encode($body ?: new stdClass());
$signingString = "{$timestamp}.{$method}.{$path}.{$bodyString}";
return hash_hmac('sha256', $signingString, $secretKey);
}
$timestamp = time();
$signature = signRequest(
'sk_test_your_key',
'POST',
'/api/gateway/v1/payments/collect',
['phoneNumber' => '0771234567', 'amount' => 5000, 'reference' => 'ORDER-001'],
$timestamp
);
Timestamp Validation
- Requests with timestamps older than 5 minutes or in the future are rejected.
- Always use the current server time. Keep your server clock synchronized (NTP).
5. API Endpoints
All endpoints use the base URL: https://api.digiisente.com/api/gateway/v1
5.1 Collect Payment
Request a payment from a customer. The same endpoint covers two rails:
- DiGii wallet - the customer approves an in-app request with their 4-digit PIN.
- Mobile Money - the customer approves an MTN or Airtel STK push on their phone with their MoMo PIN.
By default (paymentMethod: "auto") we route to the DiGii wallet if the customer has a DiGii account, and fall back to Mobile Money if they don't. You can also force a rail explicitly.
POST /payments/collect
Required scope: payments.collect
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
phoneNumber | string | Yes | Customer's phone number (e.g., 0771234567 or 256771234567) |
amount | number | Yes | Amount in UGX (minimum 500) |
reference | string | Yes | Your unique reference for this payment (used for idempotency) |
paymentMethod | string | No | auto (default), wallet, or momo. Forces the rail when set. auto picks wallet if the customer is on DiGii, else falls back to momo. Case-insensitive. Aliases accepted: MOBILE_MONEY = momo, DIGII_WALLET = wallet. Also accepted under the field name method for symmetry with /payouts/send. |
destinationAccount | string | No | DiGii account ID (e.g. DS549434) to route the funds directly into. Defaults to your own partner wallet. Useful when you're collecting on behalf of a third party (schools, marketplaces). |
currency | string | No | Currency code (default: UGX) |
description | string | No | Payment description shown to the customer |
metadata | object | No | Any additional data you want attached to this payment |
Fee is added on top of amount - the customer is charged amount + fee, and your partner wallet (or the destinationAccount) receives the full amount. The fee you'll be charged is returned as fee on every response.
Example 1 - Auto (wallet-preferred, MoMo fallback):
curl -X POST https://api.digiisente.com/api/gateway/v1/payments/collect \
-H "Authorization: Bearer sk_test_your_key" \
-H "Content-Type: application/json" \
-H "X-Signature: abc123..." \
-H "X-Timestamp: 1711036800" \
-d '{
"phoneNumber": "0771234567",
"amount": 50000,
"reference": "ORDER-2026-001",
"description": "Payment for Order #001",
"metadata": { "orderId": "001", "customerName": "John" }
}'
Success Response - wallet rail resolved (201 Created):
{
"success": true,
"message": "Payment request created. Customer will be notified via DiGii app.",
"data": {
"reference": "GW-TXN-A1B2C3D4",
"partnerReference": "ORDER-2026-001",
"type": "COLLECTION",
"status": "PENDING",
"amount": 50000,
"fee": 750,
"netAmount": 50000,
"currency": "UGX",
"customerPhone": "256771234567",
"description": "Payment for Order #001",
"paymentMethod": "wallet",
"confirmedAt": null,
"expiresAt": "2026-03-15T12:15:00.000Z",
"createdAt": "2026-03-15T12:00:00.000Z"
}
}
Example 2 - Force MTN / Airtel Mobile Money:
curl -X POST https://api.digiisente.com/api/gateway/v1/payments/collect \
-H "Authorization: Bearer sk_test_your_key" \
-H "Content-Type: application/json" \
-H "X-Signature: abc123..." \
-H "X-Timestamp: 1711036800" \
-d '{
"phoneNumber": "0771234567",
"amount": 50000,
"reference": "ORDER-2026-002",
"paymentMethod": "momo",
"description": "Payment for Order #002"
}'
Success Response - MoMo STK push dispatched (201 Created):
{
"success": true,
"message": "MoMo STK push sent to customer phone.",
"data": {
"reference": "GW-TXN-E5F6G7H8",
"partnerReference": "ORDER-2026-002",
"type": "COLLECTION",
"status": "PENDING",
"amount": 50000,
"fee": 750,
"netAmount": 50000,
"currency": "UGX",
"customerPhone": "256771234567",
"description": "Payment for Order #002",
"paymentMethod": "momo",
"confirmedAt": null,
"expiresAt": "2026-03-15T12:15:00.000Z",
"createdAt": "2026-03-15T12:00:00.000Z"
}
}
The customer sees a push on their SIM asking them to confirm 51,750 UGX (amount + fee) to DiGii Sente. Provider (MTN vs Airtel) is auto-detected from the phone prefix.
How each rail settles:
Wallet rail (paymentMethod: "wallet" or "auto" for a DiGii user):
- The customer gets a push notification: "Business X is requesting 50,000 UGX for Payment for Order #001".
- They open DiGii Sente and confirm with their 4-digit PIN.
- Money moves instantly.
- Your webhook receives
payment.completed. - If not confirmed within 15 minutes, the request expires.
Mobile Money rail (paymentMethod: "momo" or "auto" for a non-DiGii phone):
- An MTN / Airtel STK push lands on the customer's phone.
- They enter their MoMo PIN on the dialer.
- The provider settles - usually within seconds, occasionally minutes if the network is slow.
- Our poller reconciles every 30 seconds; on confirmation your webhook receives
payment.completed. - Important: if our request to the MoMo provider times out (60s),
statusstaysPENDINGandmomoStatus: PENDING- don't retry with the samereference, and don't assume failure. The poller will settle it either way. If the customer never approved, the request expires after 15 minutes and you getpayment.failed.
Notes:
- If the customer phone number is not registered on DiGii Sente and you forced
paymentMethod: "wallet", the payment request is still created but the customer must register first to complete it. Useauto(default) if you'd rather auto-fall-back to MoMo for non-DiGii phones. - The
referencefield (your reference) must be unique per payment. Sending the same reference again returns the existing payment (see Idempotency) - safe to retry on network errors. - Fee is charged on top of
amount. Customer paysamount + fee; you receive the fullamount. destinationAccountrequires the target DiGii account to exist. Invalid IDs returnCOLLECTION_ERROR.
5.2 Check Payment Status
Look up a payment by its reference.
GET /payments/:reference
Required scope: payments.collect
You can use either:
- Your reference (the
referenceyou sent when creating the payment) - Our reference (the
referencefield returned in the response, e.g.,GW-TXN-A1B2C3D4)
Example Request:
curl -X GET https://api.digiisente.com/api/gateway/v1/payments/ORDER-2026-001 \
-H "Authorization: Bearer sk_test_your_key" \
-H "X-Signature: abc123..." \
-H "X-Timestamp: 1711036800"
Success Response:
{
"success": true,
"data": {
"reference": "GW-TXN-A1B2C3D4",
"partnerReference": "ORDER-2026-001",
"type": "COLLECTION",
"status": "COMPLETED",
"amount": 50000,
"fee": 750,
"netAmount": 49250,
"currency": "UGX",
"customerPhone": "256771234567",
"description": "Payment for Order #001",
"confirmedAt": "2026-03-15T12:02:30.000Z",
"expiresAt": "2026-03-15T12:15:00.000Z",
"createdAt": "2026-03-15T12:00:00.000Z"
}
}
Payment Statuses
| Status | Description |
|---|---|
PENDING | Payment request created, waiting for customer to confirm |
COMPLETED | Customer confirmed, money has been transferred |
CANCELLED | Customer declined the payment |
EXPIRED | Payment was not confirmed within 15 minutes |
REFUNDED | Payment was refunded (fully) |
FAILED | Payment processing failed |
5.3 Refund Payment
Refund a completed payment back to the customer.
POST /payments/refund
Required scope: payments.refund
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
reference | string | Yes | The DiGii reference of the original payment (e.g., GW-TXN-A1B2C3D4) |
amount | number | No | Amount to refund (defaults to full original amount) |
reason | string | No | Reason for the refund |
Example Request:
curl -X POST https://api.digiisente.com/api/gateway/v1/payments/refund \
-H "Authorization: Bearer sk_test_your_key" \
-H "Content-Type: application/json" \
-H "X-Signature: abc123..." \
-H "X-Timestamp: 1711036800" \
-d '{
"reference": "GW-TXN-A1B2C3D4",
"reason": "Customer requested cancellation"
}'
Success Response:
{
"success": true,
"message": "Refund processed successfully",
"data": {
"reference": "GW-R-TXN-E5F6G7H8",
"partnerReference": "REFUND-ORDER-2026-001",
"type": "REFUND",
"status": "COMPLETED",
"amount": 50000,
"fee": 0,
"netAmount": 50000,
"currency": "UGX",
"customerPhone": "256771234567",
"description": "Customer requested cancellation",
"confirmedAt": "2026-03-15T14:00:00.000Z",
"expiresAt": null,
"createdAt": "2026-03-15T14:00:00.000Z"
}
}
Important notes:
- You can only refund
COMPLETEDpayments. - The refund amount cannot exceed the original payment amount.
- Partial refunds are supported by specifying an
amountless than the original. - The original processing fee is NOT refunded , it is deducted from your partner wallet balance.
- Your partner wallet must have sufficient balance to cover the refund.
- The customer receives the refund instantly in their DiGii wallet.
5.4 Check Balance
Check your partner wallet balance.
GET /balance
Required scope: balance.read
Example Request:
curl -X GET https://api.digiisente.com/api/gateway/v1/balance \
-H "Authorization: Bearer sk_test_your_key" \
-H "X-Signature: abc123..." \
-H "X-Timestamp: 1711036800"
Success Response:
{
"success": true,
"data": {
"balance": 1250000,
"currency": "UGX"
}
}
5.5 List Transactions
Retrieve your transaction history with pagination and filters.
GET /transactions
Required scope: transactions.read
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
limit | integer | 20 | Results per page (max 100) |
status | string | , | Filter by status (PENDING, COMPLETED, FAILED, etc.) |
type | string | , | Filter by type (COLLECTION, REFUND) |
from | string | , | Start date (ISO 8601, e.g., 2026-03-01) |
to | string | , | End date (ISO 8601, e.g., 2026-03-15) |
Example Request:
curl -X GET "https://api.digiisente.com/api/gateway/v1/transactions?status=COMPLETED&page=1&limit=10" \
-H "Authorization: Bearer sk_test_your_key" \
-H "X-Signature: abc123..." \
-H "X-Timestamp: 1711036800"
Success Response:
{
"success": true,
"data": [
{
"reference": "GW-TXN-A1B2C3D4",
"partnerReference": "ORDER-2026-001",
"type": "COLLECTION",
"status": "COMPLETED",
"amount": 50000,
"fee": 750,
"netAmount": 49250,
"currency": "UGX",
"customerPhone": "256771234567",
"description": "Payment for Order #001",
"confirmedAt": "2026-03-15T12:02:30.000Z",
"createdAt": "2026-03-15T12:00:00.000Z"
}
],
"pagination": {
"page": 1,
"limit": 10,
"total": 47,
"pages": 5
}
}
5.6 Validate User
Check whether a phone number has a DiGii Sente wallet before requesting payment.
POST /validate/user
Required scope: payments.collect
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
phoneNumber | string | Yes | Phone number to validate |
Example Request:
curl -X POST https://api.digiisente.com/api/gateway/v1/validate/user \
-H "Authorization: Bearer sk_test_your_key" \
-H "Content-Type: application/json" \
-H "X-Signature: abc123..." \
-H "X-Timestamp: 1711036800" \
-d '{"phoneNumber": "0771234567"}'
Response , User Exists:
{
"success": true,
"data": {
"exists": true,
"name": "John Doe",
"verified": true
}
}
Response , User Not Found:
{
"success": true,
"data": {
"exists": false
}
}
Usage tip: Call this before collecting payment to give your customers a better experience. If the user doesn't exist, you can prompt them to download DiGii Sente first.
5.7 Send Payout
Send money from your partner wallet out to a DiGii wallet, MTN/Airtel Mobile Money, or a Ugandan bank account.
POST /payouts/send
Required scope: payouts.send
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
method | string | Yes | One of WALLET, MOMO, BANK, AUTO |
amount | integer | Yes | Amount in UGX (min 500, max 50,000,000, whole numbers only) |
reference | string | Yes | Your unique reference for this payout (used for idempotency) |
phone | string | Conditional | Required for WALLET (unless email/displayId provided), MOMO, or AUTO |
email | string | Conditional | WALLET alternative to phone |
displayId | string | Conditional | WALLET alternative to phone (e.g. DS549434) |
bankCode | string | Conditional | Required for BANK (see banks catalog) |
accountNumber | string | Conditional | Required for BANK |
provider | string | No | MTN or AIRTEL - auto-detected from the phone prefix when omitted |
rail | string | No | RTGS or REALTIME for BANK - auto-selected by amount when omitted |
typedName | string | No | If supplied, response includes a nameMatch.band (HIGH/MEDIUM/LOW). We do NOT reject on LOW - you enforce your own policy. |
narration | string | No | Description shown to the recipient |
metadata | object | No | Any extra data you want attached to this payout |
Example - send to a DiGii wallet (AUTO):
curl -X POST https://api.digiisente.com/api/gateway/v1/payouts/send \
-H "Authorization: Bearer sk_test_your_key" \
-H "Content-Type: application/json" \
-H "X-Signature: abc123..." \
-H "X-Timestamp: 1780012958" \
-d '{
"method": "AUTO",
"amount": 25000,
"reference": "PAYROLL-2026-07-042",
"phone": "0771234567",
"typedName": "John Doe",
"narration": "July 2026 salary"
}'
Example - send to Mobile Money:
curl -X POST https://api.digiisente.com/api/gateway/v1/payouts/send \
-d '{
"method": "MOMO",
"amount": 25000,
"reference": "PAYROLL-2026-07-043",
"phone": "0771234567",
"provider": "MTN"
}'
Example - send to a bank:
curl -X POST https://api.digiisente.com/api/gateway/v1/payouts/send \
-d '{
"method": "BANK",
"amount": 2500000,
"reference": "SUPPLIER-INV-8814",
"bankCode": "STANBIC",
"accountNumber": "9030001234567",
"typedName": "Acme Supplies Ltd"
}'
Success Response (WALLET - atomic):
{
"success": true,
"data": {
"reference": "GW-P-A1B2C3D4",
"partnerReference": "PAYROLL-2026-07-042",
"type": "PAYOUT",
"status": "completed",
"method": "WALLET",
"resolvedVia": "WALLET",
"recipient": { "name": "John Doe", "phone": "256771234567", "displayId": "DS549434" },
"nameMatch": { "score": 100, "band": "HIGH" },
"amount": 25000,
"fee": 0,
"netAmount": 25000,
"totalCharged": 25000,
"currency": "UGX",
"narration": "July 2026 salary",
"completedAt": "2026-07-12T14:00:03.000Z",
"createdAt": "2026-07-12T14:00:03.000Z"
}
}
Success Response (MOMO/BANK - provider dispatched):
{
"data": {
"status": "pending",
"iswReference": "ISW-778812345",
"responseCode": "90009"
}
}
Status will flip to completed (webhook payout.completed) or failed (webhook payout.failed, partner wallet auto-refunded) once the rail confirms.
Payout Statuses
| Status | Description |
|---|---|
pending | Money debited from your partner wallet, rail dispatched, awaiting confirmation |
completed | Rail confirmed delivery / wallet transfer atomic |
failed | Rail rejected - your partner wallet is auto-refunded the full charge |
Important notes:
- Your partner wallet must hold at least
amount + fee. Fees are set in DiGii's Fee Management panel and can be a global default OR a per-partner override negotiated with your account manager. See Fees. - Same idempotency rules as collect: sending the same
referencetwice returns the original payout. - Provider timeouts leave the payout
pending- do NOT retry; pollGET /payouts/:referenceor wait for the webhook. AUTOroutes toWALLETif the phone belongs to a DiGii user, otherwise falls back toMOMO.
5.8 Validate Recipient
Pre-flight lookup - resolve a recipient's canonical name across any rail before sending. Optionally scores it against a name you type.
POST /payouts/validate-recipient
Required scope: payouts.send
Request Body: same recipient fields as Send Payout - method, plus phone/email/displayId/bankCode/accountNumber/provider/typedName. No amount or reference required.
Success Response:
{
"success": true,
"data": {
"found": true,
"method": "AUTO",
"resolvedVia": "WALLET",
"status": "VERIFIED",
"recipient": { "name": "John Doe", "phone": "256771234567", "displayId": "DS549434", "accountType": "user" },
"nameMatch": { "score": 96, "band": "HIGH" }
}
}
Not-found Response:
{
"success": true,
"data": {
"found": false,
"method": "MOMO",
"message": "Could not verify this number with the provider.",
"responseCode": "70038"
}
}
Bank lookup availability: Real-time bank account name lookup is live via the REALTIME rail (as of 2026-06-18). Banks without a REALTIME item (e.g. CITIBANK) return status: "LOOKUP_NOT_YET_AVAILABLE" - confirm the name out-of-band before sending.
5.9 Check Payout Status
Look up a payout by its reference.
GET /payouts/:reference
Required scope: payouts.send
Works with either your reference or our reference (GW-P-...). Response mirrors the POST /payouts/send success body, plus current status + failure reason if any.
5.10 Fee Preview
Compute the exact DiGii fee for a planned operation without touching any state. Same fee-calc path as the real charges, so numbers here always match numbers on the actual request. Cacheable client-side for ~60s.
GET /fees/preview?operation=<OP>&amount=<UGX>[&billerId=<id>]
Required scope: none. Preview leaks no partner or customer data.
Supported operations:
| operation | Fee row | billerId? |
|---|---|---|
COLLECT | GATEWAY_COLLECTION | no |
REFUND | GATEWAY_REFUND | no |
PAYOUT_WALLET | GATEWAY_PAYOUT_WALLET | no |
PAYOUT_MOMO | GATEWAY_PAYOUT_MOMO | no |
PAYOUT_BANK | GATEWAY_PAYOUT_BANK | no |
TAX_URA | BUSINESS_WALLET_URA_PAYMENT | no |
TAX_NSSF | BUSINESS_WALLET_NSSF_PAYMENT | no |
BILL_PAY | biller-derived | yes |
AIRTIME_BUY | biller-derived | yes |
DATA_BUY | biller-derived | yes |
Example — URA tax preview:
curl -X GET "https://api.digiisente.com/api/gateway/v1/fees/preview?operation=TAX_URA&amount=5000000" \
-H "X-API-Key: sk_live_..." \
-H "X-Timestamp: 1717670400" \
-H "X-Signature: <hmac>"
Response (200):
{
"success": true,
"data": {
"operation": "TAX_URA",
"amount": 5000000,
"fee": 5000,
"tax": 0,
"totalDebited": 5005000,
"feeType": "BUSINESS_WALLET_URA_PAYMENT",
"rail": "URA"
}
}
Example — bill payment preview (biller-scoped):
curl -X GET "https://api.digiisente.com/api/gateway/v1/fees/preview?operation=BILL_PAY&billerId=uedcl_a&amount=100000" \
-H "X-API-Key: sk_live_..." \
-H "X-Timestamp: 1717670400" \
-H "X-Signature: <hmac>"
Response (200): adds billerId + billerName fields, rail reflects the biller category (e.g. ELECTRICITY).
Error responses: 400 MISSING_OPERATION / 400 INVALID_AMOUNT / 400 UNSUPPORTED_OPERATION / 400 MISSING_BILLER_ID (for BILL_PAY/AIRTIME_BUY/DATA_BUY without billerId) / 404 BILLER_NOT_FOUND.
When the admin hasn't configured a fee row yet: response returns fee: 0 (safe fallback so preview stays informative) with a server-side warning log. As soon as admin sets the rate in /dashboard/fees, subsequent previews return the real number.
6. Webhooks
Webhooks notify your server in real-time when payment events occur. This is the recommended way to confirm payments rather than polling.
Webhook Events
Inbound (collections):
| Event | Trigger |
|---|---|
payment.completed | Customer confirmed the payment |
payment.failed | Customer declined or payment failed |
payment.refunded | A refund was processed |
payment.pending | Payment created, awaiting customer confirmation (opt-in) |
payment.expired | Payment request timed out unclaimed (opt-in) |
Outbound (payouts):
| Event | Trigger |
|---|---|
payout.pending | Payout debited from your wallet, rail dispatched, awaiting confirmation |
payout.completed | Rail confirmed delivery |
payout.failed | Rail rejected - your partner wallet was auto-refunded |
payout.reversed | Rare late-reversal after .completed (e.g. MoMo/Bank bounce) |
Payout events are opt-in - enable them per webhook in the developer dashboard.
Webhook Payload
{
"event": "payment.completed",
"data": {
"reference": "GW-TXN-A1B2C3D4",
"partnerReference": "ORDER-2026-001",
"type": "COLLECTION",
"status": "COMPLETED",
"amount": 50000,
"fee": 750,
"netAmount": 49250,
"currency": "UGX",
"customerPhone": "256771234567",
"description": "Payment for Order #001",
"confirmedAt": "2026-03-15T12:02:30.000Z",
"createdAt": "2026-03-15T12:00:00.000Z"
},
"timestamp": "2026-03-15T12:02:31.000Z"
}
Webhook Headers
| Header | Description |
|---|---|
Content-Type | application/json |
X-Webhook-Signature | HMAC-SHA256 signature of the payload body |
X-Webhook-Event | Event type (e.g., payment.completed) |
X-Webhook-Timestamp | ISO 8601 timestamp of when the webhook was sent |
X-Webhook-Retry | Retry attempt number (0 for first delivery) |
Verifying Webhook Signatures
Always verify webhook signatures to ensure the request is genuinely from DiGii Sente and hasn't been tampered with.
Your webhook secret is provided when you configure your webhook URL. Use it to verify:
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, webhookSecret) {
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// In your webhook handler:
app.post('/webhooks/digii', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const rawBody = JSON.stringify(req.body); // Use raw body if available
if (!verifyWebhookSignature(rawBody, signature, 'your_webhook_secret')) {
return res.status(401).send('Invalid signature');
}
const { event, data } = req.body;
switch (event) {
case 'payment.completed':
// Mark order as paid
console.log(`Payment ${data.partnerReference} completed!`);
break;
case 'payment.failed':
// Handle failed payment
break;
case 'payment.refunded':
// Handle refund
break;
}
// Always return 200 quickly
res.status(200).send('OK');
});
import hmac
import hashlib
from flask import Flask, request
app = Flask(__name__)
def verify_webhook_signature(payload, signature, webhook_secret):
expected = hmac.new(
webhook_secret.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
@app.route('/webhooks/digii', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Webhook-Signature')
raw_body = request.get_data(as_text=True)
if not verify_webhook_signature(raw_body, signature, 'your_webhook_secret'):
return 'Invalid signature', 401
event = request.json['event']
data = request.json['data']
if event == 'payment.completed':
print(f"Payment {data['partnerReference']} completed!")
return 'OK', 200
<?php
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'];
$rawBody = file_get_contents('php://input');
$webhookSecret = 'your_webhook_secret';
$expectedSignature = hash_hmac('sha256', $rawBody, $webhookSecret);
if (!hash_equals($expectedSignature, $signature)) {
http_response_code(401);
echo 'Invalid signature';
exit;
}
$payload = json_decode($rawBody, true);
$event = $payload['event'];
$data = $payload['data'];
switch ($event) {
case 'payment.completed':
// Mark order as paid
break;
case 'payment.failed':
// Handle failure
break;
case 'payment.refunded':
// Handle refund
break;
}
http_response_code(200);
echo 'OK';
Webhook Retry Policy
If your endpoint doesn't return a 2xx response within 10 seconds, we retry with exponential backoff:
| Attempt | Delay After Failure |
|---|---|
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 15 minutes |
| 4th retry | 1 hour |
| 5th retry (final) | 4 hours |
After 5 failed attempts, the webhook is marked as permanently failed. You can always use the Check Payment Status endpoint to poll for the current state.
Best Practices
- Return 200 immediately, then process the event asynchronously.
- Handle duplicates , we may deliver the same event more than once. Use the
referenceto deduplicate. - Verify signatures on every request.
- Use HTTPS , we only deliver webhooks to HTTPS URLs.
- Log raw payloads for debugging.
7. Payment Flow
Sequence Diagram
Your Server DiGii API Customer App
| | |
|-- POST /payments/collect --> |
| | |
|<-- 201 Created ----------| |
| (status: PENDING) | |
| |-- Push Notification -->|
| | |
| | Customer opens |
| | app and reviews |
| | payment request |
| | |
| |<-- Confirm with PIN --|
| | |
| | [Money transfers] |
| | Customer → Partner |
| | |
|<-- Webhook: payment.completed |
| (status: COMPLETED) | |
| | |
| [Update your order] | |
Step-by-Step
- Your server calls
POST /payments/collectwith the customer's phone number, amount, and your unique reference. - DiGii creates a payment request and sends a push notification to the customer's DiGii Sente app.
- Customer opens the app, sees: "Business X is requesting 50,000 UGX for Order #001".
- Customer enters their PIN to confirm (or taps Decline to cancel).
- DiGii transfers the money: customer wallet is debited, your partner wallet is credited (minus fee).
- DiGii sends a
payment.completedwebhook to your server. - Your server verifies the webhook signature and updates the order status.
Alternative: Polling
If you prefer not to use webhooks, you can poll the payment status:
GET /payments/ORDER-2026-001
Poll every 5-10 seconds for up to 15 minutes (the payment expiry window).
8. Idempotency
Every payment request is idempotent based on your reference field. If you send the same reference twice:
- The first call creates the payment and returns
201 Created. - Subsequent calls with the same reference return the existing payment with
200 OKand a message: "Payment request already exists".
This means it is safe to retry failed requests without risking duplicate charges.
// Second call with same reference
{
"success": true,
"message": "Payment request already exists",
"data": { ... } // Same payment data as original
}
Best practice: Use a deterministic reference tied to your order (e.g., ORDER-2026-001), not a random ID.
9. Environments (Sandbox & Production)
| Sandbox | Production | |
|---|---|---|
| API Key prefix | sk_test_ | sk_live_ |
| Real money | No | Yes |
| IP whitelist | Not enforced | Enforced |
| Partner status required | SANDBOX | LIVE |
| Push notifications | Yes (to test users) | Yes |
| Webhooks | Yes | Yes |
Sandbox Testing
In sandbox mode, the full flow works end-to-end:
- Payment requests are created
- Push notifications are sent to real DiGii accounts
- Customers can confirm/decline
- Webhooks fire to your endpoint
- No real money moves , sandbox transactions are isolated
Going Live
When you're ready for production:
- Contact DiGii Sente to request Go Live promotion.
- We review your integration and activate your account.
- You receive
sk_live_production keys. - Configure your production server IPs for whitelisting.
- Update your server to use the
sk_live_key.
10. Error Handling
Error Response Format
All errors follow a consistent format:
{
"success": false,
"error": "ERROR_CODE",
"message": "Human-readable description of the error"
}
Error Codes
| HTTP Status | Error Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR | Missing or invalid request fields |
| 401 | UNAUTHORIZED | Missing Authorization header |
| 401 | INVALID_KEY_FORMAT | Key doesn't match sk_live_ or sk_test_ format |
| 401 | INVALID_API_KEY | Key not found or has been revoked |
| 401 | KEY_EXPIRED | API key has passed its expiry date |
| 401 | MISSING_SIGNATURE | Missing X-Signature or X-Timestamp header |
| 401 | TIMESTAMP_EXPIRED | Timestamp is more than 5 minutes old |
| 401 | INVALID_SIGNATURE | HMAC signature doesn't match |
| 403 | PARTNER_INACTIVE | Partner account is not active (pending, suspended, or revoked) |
| 403 | ENVIRONMENT_MISMATCH | Using production key with non-LIVE partner |
| 403 | IP_NOT_ALLOWED | Request IP not in your whitelist (production only) |
| 403 | INSUFFICIENT_SCOPE | Your account doesn't have permission for this endpoint |
| 404 | NOT_FOUND | Transaction not found |
| 429 | , | Rate limit exceeded |
Business Logic Errors (400)
| Error Code | Message Examples |
|---|---|
COLLECTION_ERROR | "Amount exceeds per-transaction limit of 1,000,000 UGX" |
COLLECTION_ERROR | "Daily transaction limit exceeded" |
COLLECTION_ERROR | "Monthly transaction limit exceeded" |
REFUND_ERROR | "Can only refund completed transactions" |
REFUND_ERROR | "Refund amount exceeds original payment" |
REFUND_ERROR | "Insufficient partner wallet balance for refund" |
Recommended Error Handling
try {
const response = await fetch(url, options);
const data = await response.json();
if (!data.success) {
switch (data.error) {
case 'VALIDATION_ERROR':
// Fix your request parameters
break;
case 'INVALID_API_KEY':
case 'KEY_EXPIRED':
// Check your API key configuration
break;
case 'TIMESTAMP_EXPIRED':
// Sync your server clock
break;
case 'INVALID_SIGNATURE':
// Check your signature computation
break;
default:
// Log and alert
console.error(`API Error: ${data.error} , ${data.message}`);
}
}
} catch (networkError) {
// Network failure , safe to retry (requests are idempotent)
}
11. Rate Limits
| Environment | Limit |
|---|---|
| Sandbox | 60 requests per minute |
| Production | 60 requests per minute (configurable per partner) |
When rate limited, you'll receive:
HTTP 429 Too Many Requests
Best practices:
- Implement exponential backoff on 429 responses.
- Cache balance lookups , don't check balance before every payment.
- Use webhooks instead of polling where possible.
12. Code Examples
Complete Node.js Integration
const crypto = require('crypto');
const fetch = require('node-fetch'); // or native fetch in Node 18+
class DiGiiGateway {
constructor(secretKey, baseUrl = 'https://api.digiisente.com/api/gateway/v1') {
this.secretKey = secretKey;
this.baseUrl = baseUrl;
}
/**
* Sign a request with HMAC-SHA256
*/
sign(method, path, body) {
const timestamp = Math.floor(Date.now() / 1000);
const bodyString = JSON.stringify(body || {});
const signingString = `${timestamp}.${method}.${path}.${bodyString}`;
const signature = crypto
.createHmac('sha256', this.secretKey)
.update(signingString)
.digest('hex');
return { timestamp, signature };
}
/**
* Make an authenticated API request
*/
async request(method, endpoint, body = null) {
const path = `/api/gateway/v1${endpoint}`;
const { timestamp, signature } = this.sign(method, path, body);
const headers = {
'Authorization': `Bearer ${this.secretKey}`,
'Content-Type': 'application/json',
'X-Signature': signature,
'X-Timestamp': timestamp.toString(),
};
const options = { method, headers };
if (body && method !== 'GET') {
options.body = JSON.stringify(body);
}
const response = await fetch(`${this.baseUrl}${endpoint}`, options);
return response.json();
}
/**
* Request a payment from a customer
*/
async collectPayment(phoneNumber, amount, reference, description = null, metadata = null) {
return this.request('POST', '/payments/collect', {
phoneNumber,
amount,
reference,
description,
metadata,
});
}
/**
* Check payment status
*/
async getPaymentStatus(reference) {
return this.request('GET', `/payments/${reference}`);
}
/**
* Refund a payment
*/
async refundPayment(reference, amount = null, reason = null) {
return this.request('POST', '/payments/refund', {
reference,
amount,
reason,
});
}
/**
* Check wallet balance
*/
async getBalance() {
return this.request('GET', '/balance');
}
/**
* List transactions
*/
async getTransactions(params = {}) {
const query = new URLSearchParams(params).toString();
return this.request('GET', `/transactions${query ? '?' + query : ''}`);
}
/**
* Validate if a phone number has a DiGii wallet
*/
async validateUser(phoneNumber) {
return this.request('POST', '/validate/user', { phoneNumber });
}
}
// ============================================
// USAGE EXAMPLE
// ============================================
const gateway = new DiGiiGateway('sk_test_your_secret_key');
// 1. Check if customer has a DiGii wallet
const validation = await gateway.validateUser('0771234567');
if (!validation.data.exists) {
console.log('Customer needs to register on DiGii Sente first');
}
// 2. Request payment
const payment = await gateway.collectPayment(
'0771234567', // Customer phone
50000, // 50,000 UGX
'ORDER-2026-001', // Your unique reference
'Payment for Premium Plan',
{ planId: 'premium', userId: '12345' }
);
console.log('Payment reference:', payment.data.reference);
// Customer now receives a push notification...
// 3. Check status (or wait for webhook)
const status = await gateway.getPaymentStatus('ORDER-2026-001');
console.log('Payment status:', status.data.status);
// 4. Refund if needed
const refund = await gateway.refundPayment(
payment.data.reference, // DiGii reference
null, // Full refund
'Customer requested cancellation'
);
// 5. Check your balance
const balance = await gateway.getBalance();
console.log('Balance:', balance.data.balance, balance.data.currency);
Complete Python Integration
import hashlib
import hmac
import json
import time
import requests
class DiGiiGateway:
def __init__(self, secret_key, base_url='https://api.digiisente.com/api/gateway/v1'):
self.secret_key = secret_key
self.base_url = base_url
def _sign(self, method, path, body=None):
timestamp = int(time.time())
body_string = json.dumps(body or {}, separators=(',', ':'))
signing_string = f"{timestamp}.{method}.{path}.{body_string}"
signature = hmac.new(
self.secret_key.encode(),
signing_string.encode(),
hashlib.sha256
).hexdigest()
return timestamp, signature
def _request(self, method, endpoint, body=None, params=None):
path = f"/api/gateway/v1{endpoint}"
timestamp, signature = self._sign(method, path, body)
headers = {
'Authorization': f'Bearer {self.secret_key}',
'Content-Type': 'application/json',
'X-Signature': signature,
'X-Timestamp': str(timestamp),
}
url = f"{self.base_url}{endpoint}"
response = requests.request(
method, url,
headers=headers,
json=body,
params=params,
)
return response.json()
def collect_payment(self, phone_number, amount, reference, description=None, metadata=None):
return self._request('POST', '/payments/collect', {
'phoneNumber': phone_number,
'amount': amount,
'reference': reference,
'description': description,
'metadata': metadata,
})
def get_payment_status(self, reference):
return self._request('GET', f'/payments/{reference}')
def refund_payment(self, reference, amount=None, reason=None):
return self._request('POST', '/payments/refund', {
'reference': reference,
'amount': amount,
'reason': reason,
})
def get_balance(self):
return self._request('GET', '/balance')
def get_transactions(self, **params):
return self._request('GET', '/transactions', params=params)
def validate_user(self, phone_number):
return self._request('POST', '/validate/user', {
'phoneNumber': phone_number,
})
# ============================================
# USAGE EXAMPLE
# ============================================
gateway = DiGiiGateway('sk_test_your_secret_key')
# Request payment
result = gateway.collect_payment(
phone_number='0771234567',
amount=50000,
reference='ORDER-2026-001',
description='Payment for Premium Plan',
)
print(f"Payment status: {result['data']['status']}")
print(f"DiGii reference: {result['data']['reference']}")
Complete PHP Integration
<?php
class DiGiiGateway {
private $secretKey;
private $baseUrl;
public function __construct($secretKey, $baseUrl = 'https://api.digiisente.com/api/gateway/v1') {
$this->secretKey = $secretKey;
$this->baseUrl = $baseUrl;
}
private function sign($method, $path, $body = null) {
$timestamp = time();
$bodyString = json_encode($body ?: new stdClass());
$signingString = "{$timestamp}.{$method}.{$path}.{$bodyString}";
$signature = hash_hmac('sha256', $signingString, $this->secretKey);
return [$timestamp, $signature];
}
private function request($method, $endpoint, $body = null) {
$path = "/api/gateway/v1{$endpoint}";
[$timestamp, $signature] = $this->sign($method, $path, $body);
$headers = [
"Authorization: Bearer {$this->secretKey}",
"Content-Type: application/json",
"X-Signature: {$signature}",
"X-Timestamp: {$timestamp}",
];
$ch = curl_init("{$this->baseUrl}{$endpoint}");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
if ($body && $method !== 'GET') {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true);
}
public function collectPayment($phoneNumber, $amount, $reference, $description = null, $metadata = null) {
return $this->request('POST', '/payments/collect', [
'phoneNumber' => $phoneNumber,
'amount' => $amount,
'reference' => $reference,
'description' => $description,
'metadata' => $metadata,
]);
}
public function getPaymentStatus($reference) {
return $this->request('GET', "/payments/{$reference}");
}
public function refundPayment($reference, $amount = null, $reason = null) {
return $this->request('POST', '/payments/refund', [
'reference' => $reference,
'amount' => $amount,
'reason' => $reason,
]);
}
public function getBalance() {
return $this->request('GET', '/balance');
}
public function getTransactions($params = []) {
$query = http_build_query($params);
return $this->request('GET', "/transactions" . ($query ? "?{$query}" : ""));
}
public function validateUser($phoneNumber) {
return $this->request('POST', '/validate/user', [
'phoneNumber' => $phoneNumber,
]);
}
}
// ============================================
// USAGE EXAMPLE
// ============================================
$gateway = new DiGiiGateway('sk_test_your_secret_key');
$result = $gateway->collectPayment(
'0771234567',
50000,
'ORDER-2026-001',
'Payment for Premium Plan'
);
echo "Payment status: " . $result['data']['status'] . "\n";
echo "Reference: " . $result['data']['reference'] . "\n";
13. Testing Checklist
Before going live, verify the following in sandbox:
Basic Flow
- Authenticate with
sk_test_key successfully - Request signature is computed correctly (no
INVALID_SIGNATUREerrors) - Create a payment request (
POST /payments/collect) - Customer receives push notification on DiGii app
- Customer confirms payment with PIN
- Receive
payment.completedwebhook - Verify webhook signature in your handler
- Payment status shows
COMPLETEDwhen polled
Edge Cases
- Duplicate reference returns existing payment (idempotency)
- Payment expires after 15 minutes (status becomes
EXPIRED) - Customer declines payment (status becomes
CANCELLED, webhook firespayment.failed) - Invalid phone number returns appropriate error
- Amount below 500 UGX returns validation error
- Invalid API key returns
401 - Missing signature returns
401 - Expired timestamp (>5 min old) returns
401
Refunds
- Full refund processes successfully
- Partial refund (specific amount) works
- Cannot refund more than original amount
- Cannot refund a non-completed payment
- Customer receives refund notification
Webhooks
- Your endpoint returns
200within 10 seconds - You handle duplicate webhook deliveries gracefully
- You verify the
X-Webhook-Signatureon every webhook - You process events asynchronously (don't block the response)
14. FAQ
Q: What phone number formats are accepted? A: We accept multiple formats and normalize automatically:
0771234567(local)256771234567(international without +)+256771234567(international with +)
Q: What happens if the customer doesn't have a DiGii account?
A: The payment request is still created with status PENDING, but no push notification is sent. The response will indicate: "Customer phone not found in DiGii , they must register first." You can use the Validate User endpoint to check beforehand.
Q: How long does a payment request stay active?
A: 15 minutes. After that, the status changes to EXPIRED. You would need to create a new payment request.
Q: Can I charge recurring payments automatically? A: No. Every payment requires the customer to explicitly open the app and confirm with their PIN. There is no auto-debit feature , this is by design for customer protection.
Q: What fees are charged?
A: A processing fee is deducted from each successful payment. The fee is calculated when the payment request is created and shown in the fee field. The netAmount field shows what you actually receive. Fee rates are configured by DiGii and may vary.
Q: Can I do partial refunds?
A: Yes. Specify an amount less than the original payment in the refund request. Note that the original processing fee is never refunded.
Q: What if my webhook endpoint is down? A: We retry with exponential backoff up to 5 times over approximately 5 hours. You can always poll the Check Payment Status endpoint as a fallback.
Q: Is the API available 24/7? A: Yes. The API is designed for high availability with zero-downtime deployments.
Q: What currency is supported? A: Currently only UGX (Ugandan Shilling).
Q: What is the minimum and maximum payment amount? A: Minimum is 500 UGX. Maximum depends on your partner configuration (per-transaction, daily, and monthly limits are set by the DiGii admin team).
Q: Can I use the same reference across sandbox and production? A: Yes. Sandbox and production are completely isolated , the same reference can exist in both environments without conflict.
15. Support
For integration support, API key requests, or to report issues:
- Email: [Contact your DiGii Sente account manager]
- Response time: Within 24 hours on business days
When reporting an issue, please include:
- Your Partner ID (e.g.,
DP123456) - The request reference or DiGii reference
- The full error response (JSON)
- The timestamp of the request
- Whether you're using sandbox or production
16. Related DiGii Sente rails (situational awareness)
For context, DiGii Sente ships other bill / airtime / data / tax rails outside the gateway. They share the same 3-phase money-mover pattern, the same getBillerFeeSysacct biller-derived fee routing (URA_FEE / NSSF_FEE / BILLS_FEE), and the same {fee, tax, totalDebited, feeType, rail} response shape you see here, but they are NOT gateway partner endpoints today.
Listed so you know they exist, not as a partner integration surface:
| Rail | Consumer | Auth | Wallet debited |
|---|---|---|---|
/api/school-wallet/* | School partners (QM etc.) | X-School-API-Key + bursar PIN | School's own DiGii wallet |
/api/landlord/* | DiGii Rent portal (landlords) | Landlord JWT + bills.pay perm | Landlord's DiGii wallet |
/api/payroll/* | DiGii Business portal (payroll) | Payroll JWT + OWNER / ADMIN role | Business wallet (employerId, type='payroll') |
Each of the three rails now exposes a GET /fee-preview?operation=<BILL_PAY \| AIRTIME_BUY \| DATA_BUY \| TAX_URA \| TAX_NSSF>&amount=X&billerId=Y endpoint with the same response shape as the gateway /fees/preview covered above. Same fee-calc path as the real charge, so preview and actual debit cannot drift.
If a school partner (using /api/school-wallet/*) is what you are looking for, that IS a partner-facing surface today - separate auth model (X-School-API-Key per school) and a separate onboarding runbook. Contact your DiGii account manager for that flow.