Documentation

Gateway API integration guide

Everything you need to integrate DiGii Sente as a payment gateway. Authentication, HMAC signing, endpoints, webhooks, and a testing checklist.

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.

LanguageCanonical body
Node / BrowserJSON.stringify(body) (already canonical by default)
Pythonjson.dumps(body, separators=(',', ':')) (the separators kwarg is required)
PHPjson_encode($body) (canonical by default)
Gojson.Marshal(body) (canonical by default)
RubyJSON.generate(body) (canonical by default)
Anywhere elseConfirm 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.
  • METHOD is uppercase (POST, GET, PUT, DELETE).
  • path is the full URL path INCLUDING any query string (/api/gateway/v1/transactions?page=1 for example).
  • body is 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 your Bearer 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 the X-Signature or X-Timestamp headers.

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

  1. Overview
  2. Getting Started
  3. Authentication
  4. Request Signing (HMAC)
  5. API Endpoints
  6. Webhooks
  7. Payment Flow
  8. Idempotency
  9. Environments (Sandbox & Production)
  10. Error Handling
  11. Rate Limits
  12. Code Examples
  13. Testing Checklist
  14. FAQ
  15. Support
  16. 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:

  1. Your server calls our API to request a payment from a customer.
  2. 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.
  3. Money moves: Customer → Your partner wallet (or straight to a destinationAccount you specify, minus the DiGii fee).
  4. 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.

RailtransactionTypeDefault
Collect (inbound)GATEWAY_COLLECTION100 UGX flat (until admin sets a global)
Payout - WalletGATEWAY_PAYOUT_WALLET0 (free, DiGii-internal)
Payout - MoMoGATEWAY_PAYOUT_MOMO0 (admin sets after ISW cost is negotiated)
Payout - BankGATEWAY_PAYOUT_BANK0 (admin sets after ISW cost is negotiated)
RefundGATEWAY_REFUND0 (opt-in)
Bill paymentsbiller-derived (UEDCL_PAYMENT, NWSC_*, etc.)per biller, see admin config
AirtimeAIRTIME_PURCHASE0 (subsidised by biller surcharge)
Data bundlesbiller-derived per bundle0 by default
URA tax (PAYE, VAT, OTHER)BUSINESS_WALLET_URA_PAYMENTFLAT 5,000 UGX (fee routes to URA_FEE sysacct on our side)
NSSF (individual, company)BUSINESS_WALLET_NSSF_PAYMENTFLAT 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):

RailSuccess testFailure test
Collect (any format)0700000012 / +2567000000120700000021 / +256700000021
Payout to Wallet or MoMophone ends in 12phone ends in 21
Payout to Bankaccount 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:

  1. Complete your integration against sandbox — happy path + failure path.
  2. Enable 2FA in your developer portal (required before live).
  3. Ask your DiGii account manager to promote you to LIVE. You'll receive a "You are LIVE" email.
  4. 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.
  5. From that moment, sk_live_ moves real money. Your sk_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:

RailTriggerCustomer approves viaTypical time to settle
walletCustomer is on DiGii SentePush notification + 4-digit PIN in-appInstant
momoAny MTN or Airtel Uganda numberSTK push on the phone + MoMo PIN on dialerSeconds (up to 15 min if delayed)
auto (default)We pick per customerwallet if they have a DiGii account, else momoSame 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:

RailRecipientHow it lands
WALLETDiGii Sente userInstant, atomic wallet-to-wallet
MOMOMTN or Airtel Uganda phoneProvider-mediated (usually seconds)
BANKAny of 28 Ugandan banksRTGS or real-time rail, auto-selected by amount
AUTOAny of the aboveResolves 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:

CredentialFormatPurpose
Public Keypk_test_xxxxxxxxIdentifies your account (not secret)
Secret Keysk_test_xxxxxxxxAuthenticates 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 POST requests
  • Return a 2xx status 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 PrefixEnvironmentCan Move Real Money?
sk_test_SandboxNo
sk_live_ProductionYes

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

HeaderDescription
AuthorizationBearer sk_test_xxx or Bearer sk_live_xxx
X-SignatureHMAC-SHA256 signature (hex)
X-TimestampUnix timestamp in seconds (e.g., 1711036800)
Content-Typeapplication/json

How to Compute the Signature

Signing string format:

{timestamp}.{HTTP_METHOD}.{full_path}.{JSON_body}

Steps:

  1. Get the current Unix timestamp in seconds (not milliseconds).
  2. 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.
  3. Compute HMAC-SHA256 using your secret key as the key and the signing string as the message.
  4. 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:

FieldTypeRequiredDescription
phoneNumberstringYesCustomer's phone number (e.g., 0771234567 or 256771234567)
amountnumberYesAmount in UGX (minimum 500)
referencestringYesYour unique reference for this payment (used for idempotency)
paymentMethodstringNoauto (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.
destinationAccountstringNoDiGii 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).
currencystringNoCurrency code (default: UGX)
descriptionstringNoPayment description shown to the customer
metadataobjectNoAny 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):

  1. The customer gets a push notification: "Business X is requesting 50,000 UGX for Payment for Order #001".
  2. They open DiGii Sente and confirm with their 4-digit PIN.
  3. Money moves instantly.
  4. Your webhook receives payment.completed.
  5. If not confirmed within 15 minutes, the request expires.

Mobile Money rail (paymentMethod: "momo" or "auto" for a non-DiGii phone):

  1. An MTN / Airtel STK push lands on the customer's phone.
  2. They enter their MoMo PIN on the dialer.
  3. The provider settles - usually within seconds, occasionally minutes if the network is slow.
  4. Our poller reconciles every 30 seconds; on confirmation your webhook receives payment.completed.
  5. Important: if our request to the MoMo provider times out (60s), status stays PENDING and momoStatus: PENDING - don't retry with the same reference, 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 get payment.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. Use auto (default) if you'd rather auto-fall-back to MoMo for non-DiGii phones.
  • The reference field (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 pays amount + fee; you receive the full amount.
  • destinationAccount requires the target DiGii account to exist. Invalid IDs return COLLECTION_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 reference you sent when creating the payment)
  • Our reference (the reference field 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

StatusDescription
PENDINGPayment request created, waiting for customer to confirm
COMPLETEDCustomer confirmed, money has been transferred
CANCELLEDCustomer declined the payment
EXPIREDPayment was not confirmed within 15 minutes
REFUNDEDPayment was refunded (fully)
FAILEDPayment processing failed

5.3 Refund Payment

Refund a completed payment back to the customer.

POST /payments/refund

Required scope: payments.refund

Request Body:

FieldTypeRequiredDescription
referencestringYesThe DiGii reference of the original payment (e.g., GW-TXN-A1B2C3D4)
amountnumberNoAmount to refund (defaults to full original amount)
reasonstringNoReason 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 COMPLETED payments.
  • The refund amount cannot exceed the original payment amount.
  • Partial refunds are supported by specifying an amount less 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:

ParameterTypeDefaultDescription
pageinteger1Page number
limitinteger20Results per page (max 100)
statusstring,Filter by status (PENDING, COMPLETED, FAILED, etc.)
typestring,Filter by type (COLLECTION, REFUND)
fromstring,Start date (ISO 8601, e.g., 2026-03-01)
tostring,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:

FieldTypeRequiredDescription
phoneNumberstringYesPhone 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:

FieldTypeRequiredDescription
methodstringYesOne of WALLET, MOMO, BANK, AUTO
amountintegerYesAmount in UGX (min 500, max 50,000,000, whole numbers only)
referencestringYesYour unique reference for this payout (used for idempotency)
phonestringConditionalRequired for WALLET (unless email/displayId provided), MOMO, or AUTO
emailstringConditionalWALLET alternative to phone
displayIdstringConditionalWALLET alternative to phone (e.g. DS549434)
bankCodestringConditionalRequired for BANK (see banks catalog)
accountNumberstringConditionalRequired for BANK
providerstringNoMTN or AIRTEL - auto-detected from the phone prefix when omitted
railstringNoRTGS or REALTIME for BANK - auto-selected by amount when omitted
typedNamestringNoIf supplied, response includes a nameMatch.band (HIGH/MEDIUM/LOW). We do NOT reject on LOW - you enforce your own policy.
narrationstringNoDescription shown to the recipient
metadataobjectNoAny 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

StatusDescription
pendingMoney debited from your partner wallet, rail dispatched, awaiting confirmation
completedRail confirmed delivery / wallet transfer atomic
failedRail 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 reference twice returns the original payout.
  • Provider timeouts leave the payout pending - do NOT retry; poll GET /payouts/:reference or wait for the webhook.
  • AUTO routes to WALLET if the phone belongs to a DiGii user, otherwise falls back to MOMO.

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:

operationFee rowbillerId?
COLLECTGATEWAY_COLLECTIONno
REFUNDGATEWAY_REFUNDno
PAYOUT_WALLETGATEWAY_PAYOUT_WALLETno
PAYOUT_MOMOGATEWAY_PAYOUT_MOMOno
PAYOUT_BANKGATEWAY_PAYOUT_BANKno
TAX_URABUSINESS_WALLET_URA_PAYMENTno
TAX_NSSFBUSINESS_WALLET_NSSF_PAYMENTno
BILL_PAYbiller-derivedyes
AIRTIME_BUYbiller-derivedyes
DATA_BUYbiller-derivedyes

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):

EventTrigger
payment.completedCustomer confirmed the payment
payment.failedCustomer declined or payment failed
payment.refundedA refund was processed
payment.pendingPayment created, awaiting customer confirmation (opt-in)
payment.expiredPayment request timed out unclaimed (opt-in)

Outbound (payouts):

EventTrigger
payout.pendingPayout debited from your wallet, rail dispatched, awaiting confirmation
payout.completedRail confirmed delivery
payout.failedRail rejected - your partner wallet was auto-refunded
payout.reversedRare 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

HeaderDescription
Content-Typeapplication/json
X-Webhook-SignatureHMAC-SHA256 signature of the payload body
X-Webhook-EventEvent type (e.g., payment.completed)
X-Webhook-TimestampISO 8601 timestamp of when the webhook was sent
X-Webhook-RetryRetry 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:

AttemptDelay After Failure
1st retry1 minute
2nd retry5 minutes
3rd retry15 minutes
4th retry1 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 reference to 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

  1. Your server calls POST /payments/collect with the customer's phone number, amount, and your unique reference.
  2. DiGii creates a payment request and sends a push notification to the customer's DiGii Sente app.
  3. Customer opens the app, sees: "Business X is requesting 50,000 UGX for Order #001".
  4. Customer enters their PIN to confirm (or taps Decline to cancel).
  5. DiGii transfers the money: customer wallet is debited, your partner wallet is credited (minus fee).
  6. DiGii sends a payment.completed webhook to your server.
  7. 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 OK and 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)

SandboxProduction
API Key prefixsk_test_sk_live_
Real moneyNoYes
IP whitelistNot enforcedEnforced
Partner status requiredSANDBOXLIVE
Push notificationsYes (to test users)Yes
WebhooksYesYes

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:

  1. Contact DiGii Sente to request Go Live promotion.
  2. We review your integration and activate your account.
  3. You receive sk_live_ production keys.
  4. Configure your production server IPs for whitelisting.
  5. 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 StatusError CodeDescription
400VALIDATION_ERRORMissing or invalid request fields
401UNAUTHORIZEDMissing Authorization header
401INVALID_KEY_FORMATKey doesn't match sk_live_ or sk_test_ format
401INVALID_API_KEYKey not found or has been revoked
401KEY_EXPIREDAPI key has passed its expiry date
401MISSING_SIGNATUREMissing X-Signature or X-Timestamp header
401TIMESTAMP_EXPIREDTimestamp is more than 5 minutes old
401INVALID_SIGNATUREHMAC signature doesn't match
403PARTNER_INACTIVEPartner account is not active (pending, suspended, or revoked)
403ENVIRONMENT_MISMATCHUsing production key with non-LIVE partner
403IP_NOT_ALLOWEDRequest IP not in your whitelist (production only)
403INSUFFICIENT_SCOPEYour account doesn't have permission for this endpoint
404NOT_FOUNDTransaction not found
429,Rate limit exceeded

Business Logic Errors (400)

Error CodeMessage 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"
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

EnvironmentLimit
Sandbox60 requests per minute
Production60 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_SIGNATURE errors)
  • Create a payment request (POST /payments/collect)
  • Customer receives push notification on DiGii app
  • Customer confirms payment with PIN
  • Receive payment.completed webhook
  • Verify webhook signature in your handler
  • Payment status shows COMPLETED when polled

Edge Cases

  • Duplicate reference returns existing payment (idempotency)
  • Payment expires after 15 minutes (status becomes EXPIRED)
  • Customer declines payment (status becomes CANCELLED, webhook fires payment.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 200 within 10 seconds
  • You handle duplicate webhook deliveries gracefully
  • You verify the X-Webhook-Signature on 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

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:

RailConsumerAuthWallet debited
/api/school-wallet/*School partners (QM etc.)X-School-API-Key + bursar PINSchool's own DiGii wallet
/api/landlord/*DiGii Rent portal (landlords)Landlord JWT + bills.pay permLandlord's DiGii wallet
/api/payroll/*DiGii Business portal (payroll)Payroll JWT + OWNER / ADMIN roleBusiness 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.