API Reference
Complete documentation of every endpoint in the HexAI Payment Gateway API. All endpoints are prefixed with /api/v1.
Base URL
One base URL serves both environments — your API key prefix selects which one you hit. There is no separate sandbox host to switch to.
https://api.hpg.hexai.gm/api/v1sk_test_…Sandbox — no real money moves; magic amounts trigger test outcomes.
sk_live_…Production — real transactions against live rails.
Integrating each rail
HPG routes each payment to one of several underlying rails. Choose a rail per request with the optional provider field — omit it and HPG defaults to WAVE; an unknown or disabled provider returns 422 provider_not_enabled. The rails do not integrate the same way: Wave and Waychit redirect the customer, while APS is a two-step wallet + OTP charge with no redirect. Pick a rail below for its exact end-to-end flow.
- 1
Create the collection
Call
/collections/initiate.provideris optional — it defaults toWAVE. You get back aredirect_urland aPENDINGstatus.Request
curl -X POST https://api.hpg.hexai.gm/api/v1/collections/initiate \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "amount": "10.50", "currency": "GMD", "client_reference": "ORD-12345", "customer_name": "Awa Ceesay", "customer_mobile": "+2207123456", "success_url": "https://yourapp.com/success", "error_url": "https://yourapp.com/failed" }'200Response
{ "status": "success", "data": { "transaction_id": "b3f1c2a4-5d6e-7f80-9a1b-2c3d4e5f6a7b", "client_reference": "ORD-12345", "amount": "10.50", "currency": "GMD", "status": "PENDING", "provider": "WAVE", "redirect_url": "https://checkout.wave.com/c/cos-abc123", "next_action": null, "created_at": "2026-07-28T12:00:00.000Z" } } - 2
Send the customer to Wave
Redirect the browser to
data.redirect_url. The customer approves the payment in Wave, then returns to yoursuccess_url/error_url. - 3
Get the final result
The redirect is not proof of payment. Trust the
payment.succeeded/payment.failedwebhook, or pollGET /collections/status/{client_reference}.
POST /payouts/send.Sandbox Mode
Sandbox is the same API with fake money behind it — same endpoints, same responses, same webhooks — so your integration works unchanged when you go live. Reach it with a test key (sk_test_*), or flip your dashboard to Test mode with the toggle in the header to watch the same transactions arrive as you make them.
Magic amounts force a specific outcome, and the conventions are shared across every rail (Wave, APS and Waychit), so a sandbox suite written once behaves the same whichever provider you set:
| Amount (GMD) | Outcome | Simulated Behavior |
|---|---|---|
| 100.00 | SUCCESS | Transaction completes successfully |
| 200.00 | PENDING | Transaction stays pending (test polling) |
| 400.00 | FAILED | Insufficient funds error |
| 403.00 | FAILED | Account blocked error |
| 408.00 | EXPIRED | Checkout session times out before the customer pays |
| 429.00 | FAILED | Rate limit exceeded |
| 499.00 | CANCELLED | Customer abandons the checkout |
| 500.00 | ERROR | Internal server error (test retries) |
EXPIRED and CANCELLED are not failures. Nothing was charged and nothing was refused — you simply lost the customer, so re-offering the same checkout is a valid next step. A FAILED collection was actually declined. The amounts echo the HTTP status they stand for, so the table is guessable: 408 Request Timeout, 499 client closed request.
Payouts draw on a per-rail float you fund yourself in Sandbox → Rail floats. A payout larger than the float is held as AWAITING_LIQUIDITY rather than failed — exactly what happens live when a rail is short.
Amounts
Amounts are decimal strings in major units — "134.00", not minor-unit integers. There is no cent-to-dalasi conversion to get wrong on your side.
Payout amounts must be whole dalasi. The mobile money rail rejects any payout carrying butut precision, so "134.00" settles where "134.06" is refused. HPG catches this at the API boundary and returns 400 amount_not_whole rather than letting the rail refuse it asynchronously half a minute later:
{
"status": "error",
"code": "amount_not_whole",
"message": "Payout amount must be a whole GMD value — got 134.06. The payment rail rejects sub-unit amounts."
}Treat it as a permanent validation error — retrying the same amount will always fail. Round down and carry the remainder into the recipient's next payout: rounding up disburses money your balance does not back, and dropping the remainder loses it a few bututs at a time. This bites hardest on amounts derived from a percentage — a commission split or a net figure lands on a fractional value most of the time.
Collections are unaffected. They run through a different endpoint on the rail and accept decimal amounts such as "10.50".
Work in minor units internally. Hold balances as integer bututs and build the decimal string only when you make the request. Floating-point arithmetic on money drifts — 0.1 + 0.2 is not 0.3 — and a payout that is rounded for display but sent unrounded fails with a confusing audit trail.
Webhooks
HPG sends a single, provider-agnostic webhook to your endpoint whenever a transaction status changes — the payload is identical whether the payment ran over Wave, APS, or Waychit. This is especially important for APS and WAYCHIT_CARD, which have no redirect/polling flow: the webhook is your primary signal that money moved.
Payload Example
{
"event": "payment.succeeded",
"data": {
"id": "tx_abc123",
"type": "collection",
"reference": "INV-001",
"amount": "500.00",
"currency": "GMD",
"status": "SUCCEEDED",
"provider": "WAVE",
"environment": "LIVE",
"completed_at": "2026-01-01T12:00:00.000Z"
}
}Headers HPG sends
x-hexai-signature— HMAC-SHA256 of the raw JSON body, keyed with your webhook secretwave-signature— same value, kept as a legacy alias for existing Wave-style consumersX-Hexai-Event— the event type (e.g.payment.succeeded)X-Hexai-Sandbox— present andtrueonly for sandbox traffic
Event types
These are every event HPG sends. Switch on event — names are past-tense resource.event and will not change. Note that a lost customer (expired, cancelled) is reported separately from a decline (failed), because only one of them is worth retrying as-is.
payment.succeeded— a collection settledpayment.failed— a collection was declinedpayment.expired— the checkout session timed out before the customer acted; nothing was chargedpayment.cancelled— the customer abandoned the checkout; nothing was chargedpayout.succeeded— a payout reached the recipientpayout.failed— a payout could not be deliveredpayout.pending— a payout is in flight, awaiting settlementpayout.reversed— a payout was reversed within the 3-day window
Verify x-hexai-signature by recomputing the HMAC-SHA256 of the exact request body with your webhook secret and comparing in constant time before you act on the event. The wave-signature header carries the same value, so existing verifiers keep working unchanged.
Error Handling
The API uses standard HTTP status codes and returns errors in a consistent JSON format.
| Status Code | Meaning | Common Causes |
|---|---|---|
| 200 | Success | Request completed successfully |
| 400 | Bad Request | Invalid parameters, missing required fields, malformed JSON |
| 401 | Unauthorized | Missing or invalid API key |
| 403 | Forbidden | Valid API key but insufficient permissions |
| 404 | Not Found | Resource doesn't exist (invalid transaction ID, etc.) |
| 429 | Rate Limited | Too many requests in a short time |
| 500 | Internal Error | Server-side issue (retry with exponential backoff) |
| 503 | Service Unavailable | Temporary maintenance or overload |
Error Response Format
All error responses follow this structure:
{
"status": "error",
"error": {
"code": "INSUFFICIENT_BALANCE",
"message": "Your account balance is insufficient for this payout.",
"details": {
"required_amount_bututs": 50000,
"available_balance_bututs": 30000
}
}
}Verification Examples
# Verify manually using openssl
echo -n '{"event":"payment.succeeded",...}' | \
openssl dgst -sha256 -hmac "YOUR_WEBHOOK_SECRET"