Troubleshooting
The gateway failures that look like something else — localhost checkout links, currency errors from the wrong list, merchants who cannot make keys, webhooks that never verify, and balances that never become money.
Most gateway tickets are one of nine things. They are grouped by who reports them, because the symptom a merchant describes and the cause on your server are usually in different places.
Nothing responds at all
The extension row is off, or the backend has not been restarted since you turned
it on. Routes are registered at boot. Check Extensions → Payment Gateway,
then pnpm restart.
The route registered but the licence check failed. /api/gateway and
/api/admin/gateway are both covered by the gateway extension licence. Check
the activation under Extensions, and check that the server can reach
updates.mashdiv.com — licence validation is an outbound HTTPS call with a
72-hour grace period, so a firewall change takes three days to show up here.
Same cause. The navigation entry is gated on the gateway extension being
enabled.
The pages and their data endpoints are gated separately. /admin/gateway/payout
in particular has no page-level permission, so a user without
view.gateway.payout gets the layout with nothing in it rather than a 403.
Grant the whole set from Install.
"Checkout is broken"
APP_PUBLIC_URL is unset. The API returns 201, the merchant redirects, and
the buyer's browser tries to open a page on their own machine. Nothing errors
server-side and there is no log line.
APP_PUBLIC_URL="https://exchange.example.com"Restart the backend. Existing pending payments keep the bad URL stored on the row — the merchant must create fresh sessions.
APP_DEFAULT_LOCALE is not in .env.example and defaults to en. Set it
explicitly if your install's default locale differs.
Three possible causes, in order of likelihood:
- They are not signed in. The wallet list requires an authenticated user.
- They hold no balance in any enabled currency. The checkout can only spend wallets that exist on this platform — there is no card rail.
gatewayAllowedWalletTypesis empty or does not include anything they hold. Check the Wallets tab in the gateway settings.
ECO pricing goes through the ecosystem matching engine. Without the Ecosystem
addon installed and running, the price resolves to zero and the payment is
refused. Either install Ecosystem or remove ECO from the wallet map.
Not a fault. The buyer's quote is checked against a freshly computed rate with a
2% tolerance; a volatile market or a page left open crosses it. The payment
stays PENDING and the buyer can retry. This is deliberately not reported
to the merchant as payment.failed.
"The API rejects everything"
The merchant's status is not ACTIVE. Approve it on the merchant screen. Note
that a brand-new merchant already holds four working-looking API keys — they
were minted at registration and are refused until approval.
Different field. verificationStatus must read VERIFIED before a merchant can
create additional keys. Approving status does not set it. Use the Verify
action.
A pk_ key was used on an endpoint that writes. Only sk_live_ and sk_test_
can create payments, cancel them or refund.
The key carries an allowlist and the caller is not on it. Client IP is read from
x-forwarded-for (first entry), then x-real-ip, then cf-connecting-ip. If
your reverse proxy sets none of these the allowlist rejects everything —
including calls from the right address. Also note the CIDR arithmetic is
IPv4-only; an IPv6 range only matches as an exact string.
The currency is missing from the merchant's allowedCurrencies. Edit the
merchant.
The currency is missing from the platform's gatewayAllowedWalletTypes map,
or is listed under a different wallet type. Edit the gateway settings. Codes are
compared literally after upper-casing, and a currency enabled for FIAT is not
enabled for SPOT.
Two different ceilings. The first is the merchant's own transactionLimit, the
second is the platform's gatewayMaxPaymentAmount. Both are compared directly
against the payment amount in the payment's own currency, despite being
labelled USD.
Almost always a test/live key mismatch. A sk_test_ key cannot see a live
payment and returns 404 rather than 403, deliberately. Call
GET /v1/validate and check the mode field.
The create path ends in /create. The in-app reference at /gateway/docs
prints the shorter form on its overview panel; it is wrong.
"We never get webhooks"
The webhook destination is a field on each create-payment call. There is no
merchant-level webhook setting: the webhookUrl on an API key and on the
merchant profile are both accepted, stored and never read. Add webhookUrl to
the create request.
Three classic mistakes. The signed string is <timestamp>.<raw body>, not the
body alone. The header value carries a sha256= prefix that is part of the
comparison. And the body must be hashed exactly as received — re-serialising the
JSON changes the bytes. The key is the merchant's webhookSecret, not an API
key. Recipe in Webhooks.
Five attempts, then FAILED forever. Backoff is 1 minute, 5 minutes, 30
minutes, 2 hours, 24 hours. The retry attempts and delay fields on the settings
screen are inert — those numbers are fixed in code.
processGatewayWebhookRetries is a cron job. If the cron worker is not running,
one minute of downtime on the merchant's endpoint loses the event permanently.
Check Admin → System → Cron.
The merchant's webhookSecret could not be resolved. An unsigned webhook is
never sent, because a receiver cannot tell one apart from a forgery. Check the
merchant row.
By design, effectively: the plugin does not send a webhookUrl when it creates a
payment, so nothing is ever delivered to it. Orders reconcile through the return
handler and an hourly WordPress cron sweep instead. See
WooCommerce.
"Where is my money"
Check the cron worker first — processGatewayPayouts is what proposes payouts,
hourly. Then check three things on the merchant: is the balance above their
payoutThreshold; is their status still ACTIVE (the job only looks at active
merchants); and is gatewayEnabled on, because the job skips its entire run when
it is off while payments keep crediting pending.
That is the design. A payout sits at PENDING until an admin approves it. There
is no auto-approval for any schedule, including INSTANT. Work the queue at
/admin/gateway/payout.
INSTANT means the payout is proposed on the next hourly run rather than
waiting for a period boundary. It still waits for approval.
The balance was refunded after the payout was proposed. The error names both figures. Reject that payout — the next run will propose the correct, smaller amount.
Rejecting blocks that payout for that period; it does not take the money back.
The funds stay in pending and a later period proposes them again. That is
intentional. To withhold funds, suspend the merchant.
Refunds are drawn from the merchant's pending balance. If the money was
already paid out and they hold nothing else in that currency, the transaction
rolls back and nobody is refunded. Keep the payout SLA long enough that your
refund window closes first.
Things that are not broken
A short list of behaviour that gets reported as a bug and is not.
- Daily and monthly limits do nothing. They are stored, displayed and never
enforced. Only the per-payment
transactionLimitand the platform min/max bite. - Global Test Mode does nothing. The API key prefix decides the mode.
- The webhook retry settings do nothing. Five attempts on a fixed ladder.
- The checkout design gallery does not save. It is a preview tool; the live
design comes from the
gatewayCheckoutDesignsetting and defaults to Dark Premium. BIWEEKLYandMANUALin the payout schedule dropdown are not implemented. The job understandsINSTANT,DAILY,WEEKLYandMONTHLY.- The admin settings screen needs
edit.settingsto save, notedit.gateway.settings. - API keys cannot be shown again. They are stored as SHA-256 hashes with four characters of tail. Rotation is the only recovery.
Getting a clean diagnosis fast
-
Validate the key.
GET /api/gateway/v1/validatereports the merchant, its status, the mode and the key's permissions in one call. It separates a key problem from a request problem immediately. -
Read the error body, not the status. Every failure returns
{ "message": …, "statusCode": … }, and the message names the specific list, limit or state that refused you. -
Check the payment row in the admin payment screen. Status, test flag, allocations and the stored
checkoutUrlandwebhookUrlare all there. -
Check the webhook rows. Every attempt records what you sent, the status the endpoint returned and its response body. "We never got it" is answerable in one look.
-
Check the cron category. Three of the six most common complaints on this page are a stopped cron worker.