Essential Nigeria
Wallet API v1.0
Live
πŸ“–

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 Header
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGci...
Token Expiry: Tokens expire per the 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 Response Shape
{ // Every non-2xx uses ONE envelope β€” branch on `code`,
    //  never on the English `message`.
    "status":  false,
    "code":    "voucher.category_mismatch",   // stable machine-readable id
    "message": "This voucher is only valid for food purchases, not medical.",
    "details": {                              // optional structured context
        "expected": "food",
        "received": "medical"
    },
    "errors": {                               // field-level map, 422 only
        "cart_kobo": ["The cart kobo must be at least 0."]
    },
    "error": "…"                              // DEPRECATED: alias of `message`
}

status, code and message are present on every failure. details and errors appear only when they add context. The legacy error field is kept as an alias of message for older clients β€” migrate to code.

`code` HTTP Meaning
validation.failed 422 Required field missing or a rule failed (see `errors`)
rate_limited 429 Too many requests β€” back off and retry later
unauthenticated 401 No/expired JWT
forbidden 403 Authenticated but not allowed (e.g. not admin)
not_found 404 No route matches
server_error 500 Unexpected server-side failure
voucher.not_found 422 Unknown, expired, or not yours to use
voucher.exhausted 422 Already used by this account
voucher.expired 422 Voucher past its expires_at
voucher.no_value 422 No value left for this transaction
voucher.self_redeem 422 Creator cannot redeem their own voucher
voucher.not_checkout_enabled 422 No settlement merchant attached
voucher.category_mismatch 422 Order category β‰  voucher category (`details`)
voucher.already_used 422 Reassigned after it had redemptions
voucher.merchant_not_found 422 merchant_email has no wallet account
voucher.merchant_url_invalid 422 merchant_url not an Essential Group platform
voucher.not_active 422 Pause requested on a non-active voucher
voucher.not_paused 422 Resume requested on a non-paused voucher
voucher.no_account 422 No wallet account matches the redemption phone
otp.invalid 422 Wrong verification code
otp.expired 422 Code expired, consumed, or unknown
otp.rate_limited 429 Too many codes requested for one number
sms.unavailable 502 Could not send the SMS
reservation.not_found 404 Unknown reference / other merchant’s
reservation.expired 422 Hold window elapsed
reservation.already_confirmed 422 Already settled β€” cannot release
reservation.already_released 422 Already released
reservation.reversed 422 Reversed β€” cannot confirm or release
reservation.inactive 422 Prior reservation on this order is dead
merchant.key_missing 401 No `esk_` bearer token
merchant.key_invalid 401 Key unknown, revoked, or expired
merchant.key_not_found 404 Key id not owned by this merchant
merchant.key_revoked 422 Rotate attempted on a revoked key
merchant.no_wallet 500 Settlement merchant has no wallet
widget.key_missing 401 No publishable key supplied
widget.key_invalid 401 Publishable key unknown or revoked
platform.disabled 503 Platform service token not configured
platform.unauthorized 401 Missing or wrong platform service token
platform.merchant_not_found 422 merchant_email has no wallet account
platform.key_not_found 404 No merchant key with that id
wallet.insufficient_funds 422 Balance too low to fund the voucher
πŸ‘€

Auth Endpoints

🏦

Customers

πŸ’³

Dedicated Virtual Accounts

Dedicated Virtual Accounts (DVAs) are unique bank account numbers assigned to each user via Paystack. Incoming transfers to the DVA are detected and credited to the user's wallet.
βž•

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

  1. User taps Fund Wallet on the dashboard and enters ₦2,000.
  2. The app calls POST /wallet/fund/initialize with amount_kobo: 200000. Essential Wallet asks Paystack to open a checkout session tagged with metadata: { purpose: "wallet_funding", user_id } and returns an authorization_url.
  3. 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.
  4. Paystack redirects back to the app's callback URL with ?reference=....
  5. 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.
  6. Independently, Paystack's own webhook delivers charge.success for the same reference. If it arrives before step 5, step 5 just reports credited: false for 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.

🎟️ Accepting vouchers at your store? Start here. A dedicated, step-by-step guide for merchants and developers β€” the no-code widget and the full REST API, with everything you need to integrate. Open the guide β†’

Who can create what

Any user can create a voucher β€” fixed or percentage. Creating one debits the creator's own wallet up front for the full possible payout, the same way buying a gift card would: amount_kobo Γ— total_uses for a fixed voucher, or max_amount_kobo Γ— total_uses for a percentage voucher (the cap is the worst case per use, and is required for percentage vouchers). This is what makes it safe to let any user mint one β€” nothing is created from nothing, and the creator always funds it. 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.

  1. 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 its esk_... merchant key to fetch the voucher's value, category, and validity up front, and shows the shopper a discount preview.
  2. 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-otp with 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 opaque otp_reference (never the code itself).
  3. 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/redeem with otp_reference, otp, cart_kobo: 2500000, category: "food" (what this cart actually is β€” must match the voucher's own category), and the eStore's own order_reference. Essential Wallet verifies the code, resolves which wallet account it belongs to, and reserves the ₦5,000 discount β€” no money moves yet β€” responding with amount_to_charge_kobo: 2000000 and a redemption_reference.
  4. The eStore charges Paystack for ₦20,000 β€” the pre-computed remainder, not the original cart total.
  5. 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 as merchant_email when the voucher was created) β€” never the customer's wallet.
  6. 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.
  7. 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

  1. 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_email at creation time.
  2. 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. Issuing the key is also the approval step: a store can take part in the voucher programme only once an Essential Wallet administrator has vetted it and issued one, so partners are registered, approved merchants β€” not just anyone who signs up.
  3. 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.
  4. 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-otp with it, still just your merchant key. A 6-digit code is texted to that number.
  5. Ask the shopper for the code they just received (another field in the same modal), and call POST /merchant/vouchers/redeem with otp_reference, otp, the real cart_kobo, the cart's category (rejected with 422 if it doesn't match the voucher's own), and your own order_reference β€” this is what reserves the discount.
  6. Charge Paystack for the returned amount_to_charge_kobo β€” not the original cart total.
  7. 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.
  8. 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).
  9. 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.

🎟️ New to voucher integration? The integration guide walks through the whole flow end-to-end, plus a no-code widget for stores without a developer. Open the guide β†’

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 key) The store, as a business Every endpoint on this page, including checkout redemption and settlement Store's server env/secrets only β€” never sent to a browser
epk_... (publishable key) The same store, for the widget only The scoped, OTP-gated /public/widget/* endpoints Safe to embed in the partner's page β€” it is public by design

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.

Terminal β€” on api-wallet, after the merchant has an Essential Wallet account
php artisan merchant:create-key you@yourstore.com --label="my store β€” production"

# Merchant API key created. Copy the secret now β€” it will not be shown again:
#
#   Secret    esk_OriC12b7AKrhQJtu7J6pcQN32YVJjkFWWo6PSShE
#             (server-to-server; never put this in a page)
#   Public    epk_9tQ4mWv2kZ7pR1sX8bY6nL3cH5jD0aF4gE2kM7v
#             (safe to embed in the voucher widget)
#
# +----+------------------+-----------------------+--------------+
# | ID | Merchant         | Label                 | Prefix       |
# +----+------------------+-----------------------+--------------+
# | 3  | you@yourstore.com| my store β€” production | esk_OriC12b7 |
# +----+------------------+-----------------------+--------------+

Only the secret key's SHA-256 hash is stored β€” if it's lost, issue a new one, there's no way to recover the plaintext. The publishable key is stored as-is, because it is not a secret: it is meant to be published in a page, and it can only ever reach the scoped widget endpoints, never the settlement ones.

One key per settlement account β€” including per host

A key is bound to the single Essential Wallet account it was issued for, and every request is scoped to that account's merchant_user_id. A multi-store partner therefore needs one wallet account and one key per store (for example, one per host/property on a marketplace) so each store's redemptions settle to its own wallet. Register each account, then run merchant:create-key once per account β€” or issue them from the admin console at Merchant credentials. Rotating or revoking one key never affects the others.

Key lifecycle (merchant key)

A store manages its own credentials with the same key it authenticates with β€” it can only ever see or act on its own keys, and a secret is shown exactly once (when issued or rotated), never in a listing. Rotating keeps the previous secret valid for a grace window so a deploy doesn't have to be atomic; revoking is immediate.

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.

Drop-in widget (publishable key)

For partners who'd rather not write any server code, the widget is a single hosted script that renders a voucher box right on their page. It runs the same phone + OTP flow as the eStore API, but from the browser, using the merchant's publishable key (epk_…) β€” never the secret. Paste two lines and it works; no build step, no dependencies.

Why a publishable key can safely sit in a page

It is scoped to this one merchant's own vouchers, and it only opens the OTP-gated widget endpoints below. It cannot list your redemptions, pause a voucher, or move money β€” those still require the secret key, server-side. The shopper's one-time SMS code remains the thing that proves which wallet is redeeming, exactly as in the API flow.

HTML β€” paste onto any page (Stripe-checkout style)
<script src="https://YOUR-API-HOST/voucher-widget.js" async></script>

<div
    data-essential-voucher-widget
    data-publishable-key="epk_9tQ4mWv2kZ7pR1sX8bY6nL3cH5jD0aF4gE2kM7v"
    data-api-base="https://YOUR-API-HOST"
    data-cart-kobo="2500000"
    data-category="food"
    data-auto-confirm="true"></div>

Attributes

  • data-publishable-key β€” required; the epk_ key from merchant:create-key.
  • data-api-base β€” your API origin; defaults to where the script is served from.
  • data-cart-kobo β€” the current order total in kobo. Needed for the discount, especially percentage vouchers. Update it live with EssentialVoucher.updateCart(el, kobo).
  • data-category β€” the category to redeem against; defaults to the voucher's own category.
  • data-auto-confirm="true" β€” settle immediately after the OTP is verified, so no server is needed. Leave off if your backend will call confirm itself.
  • data-order-reference β€” your own order id; a unique one is generated if omitted.
  • data-accent / data-title β€” optional look-and-feel.

The widget dispatches DOM events on its container β€” essential-voucher:lookup, essential-voucher:redeemed (its detail carries the reservation) and essential-voucher:error β€” so a site can react without any callback URL. Under the hood it calls these endpoints, all scoped to the publishable key's merchant and CORS-enabled for embedding:

  • GET /public/widget/vouchers/{code}?cart_kobo=…
  • POST /public/widget/vouchers/{code}/redeem/request-otp
  • POST /public/widget/vouchers/redeem β€” and, in auto-confirm mode, POST /public/widget/vouchers/redeem/{reference}/confirm

Every request carries the key in an X-Publishable-Key header (you may also pass publishable_key in the body or query string). A missing or revoked key returns 401; a voucher that settles to a different merchant reads as 422 "This voucher is invalid or has expired." β€” the same non-enumerable answer the API gives.

Voucher management (merchant key)

Everyday management of the vouchers that settle to your store, plus a way to reverse a settlement. Everything here is scoped to the calling key's own merchant_user_id β€” a voucher or redemption that settles to a different merchant reads back as a plain 404, identical to one that doesn't exist.

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:

  1. Store calls GET /merchant/vouchers/FOOD2026?cart_kobo=800000 with its esk_... key. Response: { "valid": true, "category": "food", "preview_value_kobo": 500000 }.
  2. 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.
  3. Even if the store's own check had a bug and it went ahead with the OTP flow anyway β€” POST /merchant/vouchers/redeem with category: "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.
πŸ”Œ

Platform Service (first-party provisioning)

A narrow, server-to-server surface for trusted first-party Essential Group platforms (for example estay, or a marketplace onboarding its own hosts) to provision merchant credentials for their sellers. Instead of an operator running merchant:create-key by hand and pasting the secret into the platform, the platform mints a merchant key for one of its sellers' wallet accounts and receives the secret exactly once.

Authenticated with a shared platform secret β€” never a merchant key or a wallet JWT

This surface is authenticated by a single shared secret, WALLET_PLATFORM_SERVICE_TOKEN, sent as Authorization: Bearer …. It is enabled only when that value is configured β€” otherwise every call fails closed with 503 platform.disabled. Because it moves credentials, it is deliberately narrower than the merchant API: there is no per-merchant scoping, because a platform is not a merchant. Each platform is issued its own token by an operator β€” treat it like a production secret, and rate it at 30 requests/minute.

πŸͺͺ

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

Outbound webhooks β€” the wallet calls you

A merchant can register one HTTPS endpoint and receive the voucher reservation lifecycle as it happens, signed so the receiver can verify each delivery came from us. Webhooks are best-effort and never block the money path: if the endpoint is unreachable the redemption still settles, and the attempt is retried with backoff and logged. Registering an endpoint opts you into all events β€” there is no per-event subscription.

Events and payload

Event Fires when
reservation.created A checkout hold is placed (redeem succeeds, payment still pending)
reservation.confirmed The hold is settled β€” the merchant wallet is credited
reservation.released The hold is released before settlement (cancelled/abandoned order)
reservation.expired The hold lapses past its 30-minute window without confirm or release
Delivery body
{ 
    "id":         "evt_vch_wzgl5phlmmyj_2c1f9a",   // stable event id
    "type":       "reservation.confirmed",            // one of the four events
    "created_at": "2026-10-15T16:08:11+00:00",
    "data": {
        "redemption_reference": "vch_wzgl5phlmmyj",   // pass to GET /merchant/redemptions/{reference}
        "voucher_code":         "FQDW1B0HT2",
        "order_reference":      "E2E-ORDER-9",         // the order_reference you supplied at redeem
        "status":               "confirmed",           // pending | confirmed | released | expired
        "discount_kobo":        9000,
        "expires_at":           "2026-10-15T16:37:58+00:00",
        "confirmed_at":         "2026-10-15T16:08:11+00:00"
    }
}

Verifying a delivery

Every request carries three headers. Recompute the signature with your signing secret and reject the delivery unless it matches β€” this is what proves the payload came from Essential Wallet.

Headers
{ X-Essential-Event:     reservation.confirmed
X-Essential-Timestamp: 1760546291
X-Essential-Signature: v1=5b3c…                       // HMAC-SHA256(timestamp + "." + body, signing_secret)
User-Agent:            EssentialWallet-Webhooks/1.0 }

The expected signature is v1=HMAC_SHA256(timestamp + "." + body, signing_secret). The timestamp is part of the signed string, so a captured delivery can't be replayed with a new header. Return any 2xx to acknowledge; any non-2xx (or a timeout) is retried up to 6 times over roughly 24 hours (1m, 5m, 15m, 1h, 3h, 6h).

Inbound webhook β€” Paystack calls the wallet

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
Essential Nigeria

Essential Nigeria Wallet API β€” v1.0.0 β€” Β© 2026

Powered by Paystack Β· Built with Laravel