Wallet API
v1.0
API Documentation
Essential Nigeria Wallet β Backend REST API
Base URL
https://api-ewallet.eroot.ng/api
Version
v1.0.0
Format
application/json
About this API
This is the backend API powering the Essential Nigeria e-wallet platform. It handles user authentication, dedicated virtual accounts via Paystack, peer-to-peer transfers, cooperative savings (Ajo), fixed deposits, voucher management, and webhook processing.
Authentication
Protected endpoints require a JWT Bearer token in the Authorization header.
Obtain a token via POST /login.
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGci...
JWT_TTL config value (default 60 minutes). On expiry the API returns 401 Token expired.
Error Codes
| Code | Meaning | Common Cause |
|---|---|---|
| 200 | OK | Request succeeded |
| 201 | Created | Resource created successfully |
| 400 | Bad Request | Missing required field or invalid parameter |
| 401 | Unauthorized | Missing, expired, or invalid JWT token |
| 403 | Forbidden | Action not permitted for this user |
| 404 | Not Found | Resource does not exist |
| 422 | Unprocessable Entity | Validation failed β check errors object |
| 500 | Internal Server Error | Unexpected server-side exception |
| 502 | Bad Gateway | Upstream Paystack API call failed |
{
"error": "Invalid credentials", // single error string
"errors": { // validation error map (422)
"email": ["The email field is required."],
"amount": ["The amount must be at least 1."]
},
"message": "Human-readable description"
}
Auth Endpoints
Customers
Dedicated Virtual Accounts
Fund Wallet
A second way to fund a wallet, on top of a bank transfer into the dedicated account above β card or
bank payment via Paystack's hosted checkout, initiated on demand from the dashboard. Two things credit
the wallet for the same payment, both idempotent on the same Paystack reference:
the verify call below (the fast path, when the
browser returns from checkout) and the Paystack webhook (the
reliable path, in case it never does). Whichever lands first wins β the other is a no-op.
Walkthrough: a β¦2,000 top-up
- User taps Fund Wallet on the dashboard and enters β¦2,000.
- The app calls
POST /wallet/fund/initializewithamount_kobo: 200000. Essential Wallet asks Paystack to open a checkout session tagged withmetadata: { purpose: "wallet_funding", user_id }and returns anauthorization_url. - The user is redirected to that URL and pays by card or bank on Paystack's own page β Essential Wallet never sees the card details.
- Paystack redirects back to the app's callback URL with
?reference=.... - The app calls
GET /wallet/fund/verify/{reference}, which checks the payment with Paystack directly and β only if it actually succeeded β credits β¦2,000 to the wallet. - Independently, Paystack's own webhook delivers
charge.successfor the same reference. If it arrives before step 5, step 5 just reportscredited: falsefor a reference already credited β the wallet is still only credited once.
Transfers
Unverified accounts are capped
Users who haven't completed KYC verification can transfer up to
config('kyc.unverified_daily_transfer_limit_kobo') (default β¦50,000) cumulatively per day.
Exceeding it returns 422 with code: "KYC_REQUIRED".
Transfer Control
Balance sync, OTP management, and transfer finalization.
Admin only β not a per-user setting
The three endpoints below toggle Paystack's account-wide OTP requirement β it applies to every transfer on the platform for every
user, not just the caller's own. All three require users.is_admin and return
403 for regular users.
Beneficiaries
Savings
All savings endpoints are prefixed with /savings and require authentication.
Fixed Deposits
Prefix: /fixed-deposits. All endpoints require authentication.
Ajo (Cooperative Savings)
Ajo is a traditional Nigerian rotating savings system. A group of members each contribute a fixed amount every cycle; one member receives the entire pot per cycle.
Prefix: /ajo. All endpoints require authentication.
Vouchers
Vouchers are redeemable codes with two redemption modes. Wallet mode (the default) credits the redeemer's own
Essential Wallet balance β a personal reward, unconnected to any purchase. Checkout mode settles the voucher's
value to a merchant instead, designed for a third-party storefront (an "eStore") that wants a voucher to actually discount a real
order. Voucher types: fixed (flat NGN amount) or percentage (% of cart value) β both work in either
mode. Prefix: /vouchers. All endpoints require authentication.
Who can create what
Fixed vouchers can be created by any user β creating one debits the creator's own wallet for the full possible payout
(amount_kobo Γ total_uses), the same way buying a gift card would. This is what makes it
safe to let any user mint one β nothing is created from nothing. Percentage vouchers have no fixed amount to back at creation
time, so those remain admin/staff-only (users.is_admin). A creator can never redeem
their own voucher.
Every voucher has a category, and it's enforced β not just a label
Every voucher is minted with a category:
general, food,
medical, transport,
education, utilities, or
shopping.
Example: a β¦5,000 voucher is created with category: "food".
At checkout, an eStore selling medical supplies calls POST /vouchers/redeem with
mode: "checkout" and category: "medical"
for its cart. The API rejects it β 422 "This voucher is only valid for food purchases, not medical."
β and no discount is reserved. The same call with category: "food" against a grocery
eStore succeeds.
This is only enforced in checkout mode, where the store declares what the order is actually for. In wallet mode the credit lands in the redeemer's general wallet balance and becomes fungible with everything else in there β there's no purchase to check the category against, so it's informational only there (surfaced in the redemption message and in the wallet app's voucher list).
How a voucher affects a real purchase (checkout mode)
Walk through the exact scenario of a β¦25,000 product and a β¦5,000 voucher, using checkout mode end-to-end. This is the
merchant-key + OTP flow β the one a third-party eStore should use. It authenticates every single call with
only the eStore's own esk_... merchant key; the shopper
never leaves the eStore's own checkout page, never sees a wallet login screen, and no wallet JWT is ever issued to or handled by
the eStore. See Merchant / eStore Integration for the full endpoint reference.
- Customer has a β¦25,000 product in their cart on the eStore, and enters a voucher code at checkout. The eStore's backend calls
GET /merchant/vouchers/{code}with itsesk_...merchant key to fetch the voucher's value, category, and validity up front, and shows the shopper a discount preview. - The checkout modal asks for the phone number linked to the shopper's Essential Wallet account. The eStore's backend calls
POST /merchant/vouchers/{code}/redeem/request-otpwith that phone number β still just the merchant key, nothing from the shopper. Essential Wallet resolves the phone number to a wallet account, texts it a 6-digit code, and responds with an opaqueotp_reference(never the code itself). - The shopper types the code they received by SMS into the same modal β no redirect, no popup. The eStore's backend calls
POST /merchant/vouchers/redeemwithotp_reference,otp,cart_kobo: 2500000,category: "food"(what this cart actually is β must match the voucher's own category), and the eStore's ownorder_reference. Essential Wallet verifies the code, resolves which wallet account it belongs to, and reserves the β¦5,000 discount β no money moves yet β responding withamount_to_charge_kobo: 2000000and aredemption_reference. - The eStore charges Paystack for β¦20,000 β the pre-computed remainder, not the original cart total.
- Paystack payment succeeds. The eStore calls
POST /merchant/vouchers/redeem/{reference}/confirm(merchant key). This is the moment β¦5,000 actually moves: it's credited to the merchant's own Essential Wallet balance (the account registered asmerchant_emailwhen the voucher was created) β never the customer's wallet. - Between the merchant's Paystack settlement (β¦20,000, minus Paystack's fees) and the Essential Wallet credit (β¦5,000), the merchant ends up holding the full β¦25,000 of value β just split across two accounts instead of one.
- If the Paystack charge fails or the customer abandons checkout, the eStore calls
POST /merchant/vouchers/redeem/{reference}/release(merchant key) to free the voucher slot back up β or does nothing and lets the 30-minute hold expire on its own.
There's a second, JWT-based way to reach the same mode: "checkout" reservation
POST /vouchers/redeem (documented below, in this section) also accepts
mode: "checkout" when called with a shopper's own wallet JWT instead of a
merchant key β that's what powers checkout redemption inside the Essential Wallet app itself. A genuine third-party
eStore has no way to obtain a shopper's wallet JWT (it's a different party on a different server), so it should always use the
merchant-key + OTP flow above instead β never try to get a shopper's JWT into your own backend.
eStore integration checklist
- The merchant creates an Essential Wallet account once (same signup any user goes through) and shares that email with whoever
creates the voucher, so it can be set as
merchant_emailat creation time. - Get a merchant API key (
esk_...) issued for the store β see Merchant / eStore Integration. This one key authenticates every call below; you never need a wallet JWT for any of it. - When the shopper enters a code, use that merchant key to call
GET /merchant/vouchers/{code}β returns value, category, validity, remaining uses, merchant name, and an optional discount preview via?cart_kobo=. Use it to show the shopper what the code is worth and reject an obvious category mismatch before going any further. - Ask the shopper for the phone number on their Essential Wallet account (a field in your own checkout modal β no redirect),
and call
POST /merchant/vouchers/{code}/redeem/request-otpwith it, still just your merchant key. A 6-digit code is texted to that number. - Ask the shopper for the code they just received (another field in the same modal), and call
POST /merchant/vouchers/redeemwithotp_reference,otp, the realcart_kobo, the cart'scategory(rejected with 422 if it doesn't match the voucher's own), and your ownorder_referenceβ this is what reserves the discount. - Charge Paystack for the returned
amount_to_charge_koboβ not the original cart total. - On Paystack success, call
POST /merchant/vouchers/redeem/{reference}/confirm(merchant key). This is what actually pays the merchant β nothing is settled before this call. - On Paystack failure or an abandoned cart, call
POST /merchant/vouchers/redeem/{reference}/release(optional β an unconfirmed reservation also expires on its own after 30 minutes). - For reconciliation afterwards, use the merchant key again against
GET /merchant/redemptions/{reference}to check settlement status without needing the shopper's session.
The trust boundary that remains
Essential Wallet has no way to independently verify that the eStore's Paystack charge actually succeeded β confirm
is an attested call from the eStore's own server, not something checked against Paystack directly. A compromised or buggy eStore
integration could call confirm without ever having charged the customer, paying
the merchant for nothing. This is the same category of trust assumption already noted for merchant_name/merchant_url
being informational β treat the eStore's own server as the thing responsible for only calling confirm after a real, verified Paystack success.
Merchant / eStore Integration
The server-to-server surface for a third-party storefront ("eStore"): looking up a voucher, redeeming one at checkout, and checking settlement status β all authenticated with a single business-level credential, your merchant API key. No wallet login, redirect, or JWT is ever needed on the eStore's side for any of this.
One credential for eStores: your merchant key. Never a wallet JWT.
The wallet's own frontend authenticates its calls with a JWT issued to a logged-in Essential Wallet user. An eStore is a different party, on a different server, and is never issued one of those JWTs β there is no supported way for a third-party eStore to obtain a shopper's wallet JWT, and it should not try to.
| Credential | Identifies | Used for | Lives where |
|---|---|---|---|
| esk_... (merchant API key) | The store, as a business | Every endpoint on this page, including checkout redemption | Store's server env/secrets only β never sent to a browser |
So how does Essential Wallet know which shopper is redeeming, if the eStore never holds a shopper
credential? By phone number + a one-time code texted to it β see
POST /merchant/vouchers/{code}/redeem/request-otp below. The shopper
types both into the eStore's own checkout modal; nothing about it involves the wallet's own login screen.
Getting a key
There's no self-serve merchant portal β an operator issues a key on the api-wallet backend. But this command
does not create anything for you β it only looks up an already-existing
users row by email and attaches a key
to it. Run it against an email nobody has registered yet, and it fails.
The email argument must belong to a real, already-registered Essential Wallet user
you@yourstore.com below is a placeholder β swap it for the
merchant's actual email, and that person/business must sign up for an Essential Wallet account
first (the normal /register flow, same as any user β
via the wallet app's sign-up page, or POST /register directly).
This is the same account that gets set as merchant_email
when creating vouchers β see Vouchers. Run the command
before that signup exists, and it fails immediately:
$ php artisan merchant:create-key you@yourstore.com --label="my store β production" No Essential Wallet account found for that email.
Register that email with the wallet first, then re-run the exact same command β no other change needed.
php artisan merchant:create-key you@yourstore.com --label="my store β production" # Merchant API key created. Copy it now β it will not be shown again: # # esk_OriC12b7AKrhQJtu7J6pcQN32YVJjkFWWo6PSShE # # +----+------------------+-----------------------+--------------+ # | ID | Merchant | Label | Prefix | # +----+------------------+-----------------------+--------------+ # | 3 | you@yourstore.com| my store β production | esk_OriC12b7 | # +----+------------------+-----------------------+--------------+
Only the key's SHA-256 hash is stored β if it's lost, issue a new one, there's no way to recover the plaintext.
What this endpoint deliberately leaves out
Who created the voucher and who has redeemed it are not in this response, even though the merchant-key requirement
means only a registered integrator can call it at all. Those are wallet users' own account activity, not the
voucher's business-facing data β handing a customer's identity to a third-party store isn't something this
endpoint does, regardless of which merchant is asking. used_count/uses_remaining
tell you how many times it's been used without saying by whom.
Checkout redemption (merchant key + OTP)
The four endpoints below are how a third-party eStore actually redeems a voucher at checkout β the merchant-key equivalent of
/vouchers/redeem and its confirm/release
friends, but authenticated with your merchant key throughout instead of a shopper's wallet JWT. See the walkthrough and
checklist in Vouchers for the end-to-end scenario these four calls fit into.
Worked example: rejecting a category mismatch
A shopper on a medical-supplies eStore tries to apply a food voucher. The store's own backend catches this cheaply, and the wallet API enforces it independently even if the store didn't bother checking:
- Store calls
GET /merchant/vouchers/FOOD2026?cart_kobo=800000with itsesk_...key. Response:{ "valid": true, "category": "food", "preview_value_kobo": 500000 }. - Store compares
"food"against its own cart category,"medical"β mismatch. It shows the shopper "This voucher can't be used here" without ever calling redeem. No customer JWT was needed for this rejection. - Even if the store's own check had a bug and it went ahead with the OTP flow anyway β
POST /merchant/vouchers/redeemwithcategory: "medical"and a valid, verified OTP β the wallet API rejects it independently:422 "This voucher is only valid for food purchases, not medical."β no discount is ever reserved.
KYC Verification
Optional identity verification (NIN or BVN), backed by VerifyNG, a separately-deployed identity-verification
microservice. Requires VERIFYNG_BASE_URL,
VERIFYNG_CLIENT_KEY, and
VERIFYNG_API_SECRET to be configured β without
them, /kyc/verify returns a 502 with a clear
"not configured" message rather than failing silently.
Webhooks
Important β Webhook Security
All Paystack webhook requests include an x-paystack-signature header containing an HMAC-SHA512 signature. The webhook handler verifies this against your PAYSTACK_SECRET_KEY before processing.
Webhook Behaviour Summary
| Event | Action |
|---|---|
| charge.success (DVA) | Credits the wallet immediately, keyed to the Paystack transaction reference β safe against duplicate webhook delivery |
| charge.success (wallet_funding) | Credits the wallet for the user_id in metadata β same idempotency guard, and a no-op if GET /wallet/fund/verify already credited this reference first |
| transfer.success | Updates WalletTransaction status β success (wallet was already debited when the transfer was initiated) |
| transfer.failed | Updates WalletTransaction status β failed and credits the wallet back β guarded against double-refunding on retries |
| transfer.reversed | Updates WalletTransaction status β reversed and credits the wallet back β same guard as transfer.failed |