Connecting a fiat deposit gateway

How to take a customer's first card or bank deposit — the readiness console, the sixteen bundled gateways, the credential test, the webhook URL the vendor needs, and why a saved key does nothing until you restart.

16 min readUpdated 3 September 2026deposits, payments, gateways, stripe, webhooks

Nothing on a fresh install can take money. Sixteen fiat deposit gateways are seeded into the database, every one of them switched off, and none of them holds a credential. This page is how you turn one of them into a working payment path.

The screen is Admin → Finance → Payment Systems → Payment Gateways (/admin/finance/deposit/gateway), and the first thing to understand about it is that it is not a CRUD table.

The readiness console

  1. Open a gateway card, then its money tab

There is no "create gateway" button and its absence is not a missing permission. The set of gateways is fixed by what is bundled under api/finance/deposit/fiat/<alias> in the backend — sixteen integrations, no more — and the seeder writes one row per integration. You enable, configure and price them; you do not invent them.

So the page is a gallery of cards, one per gateway, and each card answers the question a table could not: can this gateway take a payment right now? A row's status column answers "is it switched on", which is a different question and the reason a list of sixteen green switches could belong to an install that cannot process a single deposit.

Each card reports four facts, all of them computed rather than stored:

Fact What it means
supported An integration for this alias is bundled in this build
credentialsComplete Every required environment variable is present
missingRequired The exact variable names that are unset
mode test, live or unknown — inferred, not configured

The header rail counts Gateways, Accepting deposits (switched on and able to authenticate), Need credentials, and Currencies covered. Above the grid, a destructive banner appears when any gateway is switched on with credentials missing — "N gateways are switched on and cannot authenticate" — with a button that filters straight to them. The filters are All, Accepting deposits, Needs attention, Switched off and No integration.

The status toggle is deliberately not blocked when credentials are missing — you may be staging a change. But a gateway in that state is offered to customers on the deposit form and fails at the vendor on every attempt, with a generic error. The toggle raises a destructive toast naming how many variables are unset the moment you do it.

status: true and all three readiness checks failing is a real, reachable state. On a freshly seeded install every one of the sixteen is status: false with zero credentials set, so switching one on before setting its variables produces exactly that.

The sixteen bundled profiles, by alias:

stripe · paypal · paystack · mollie · adyen · klarna · authorizenet · dlocal · transfi · eway · paysafe · payu · paytm · payfast · ipay88 · 2checkout

They are defined in backend/src/utils/deposit-gateway/registry.ts, which carries — per gateway — the environment variables it reads, the URLs it builds, the vendor dashboard pages you have to visit, the order to do them in, and the traps that make a wrong setup look like a right one. Everything the admin screens show about a gateway comes from there.

Credentials live in .env, not in the database

This is the single fact that costs operators the most time.

A deposit_gateway row holds the title, logo, fees, limits, currency list and the status switch. It holds no credentials. Every key, secret, merchant id and sandbox flag is read from the backend process's environment — which is loaded from .env at the repository root, once, when the backend starts.

Editing .env while the platform is running changes nothing. The running process still holds the values it read at boot, so the gateway carries on failing exactly as it did before and the readiness card carries on reporting the variable as unset.

Restart with pnpm restart from the project root after every credential change. For PayPal specifically, NEXT_PUBLIC_APP_PAYPAL_CLIENT_ID is inlined into the frontend bundle at build time — that one needs a rebuild, not a restart.

Nothing sends a credential value back to the browser. The readiness endpoint returns a boolean for "is it set", plus at most a leading key-type marker (sk_live, pk_test, AQE) for secrets, and the full value only for things that are public by construction: URL paths, true/false flags, and publishable keys the frontend already ships.

The per-gateway screen

  1. Open a gateway card, then its money tab

Open a card and you land on /admin/finance/deposit/gateway/[id]. The left column is the setup guide, in a deliberate order — verdict, then the test that can change the verdict, then the values the test needs, then the URLs that have to leave the page, then the walkthrough. The right column is the row itself: title, description, image, currencies, and the fee and limit fields (fixedFee, percentageFee, minAmount, maxAmount), which can be set once for all currencies or overridden per currency.

The four panels that matter, in the order they appear:

  1. Connection — the verdict, and the Test button that can change it.

  2. Environment Variables — every variable this gateway reads, split into required and optional, each with whether it is set and what it is for. It also builds a paste-ready .env block containing the keys you still need.

  3. URLs to give the vendor — the webhook and return URLs, derived from your public site URL. Copy buttons, because these have to leave the page and be pasted into a vendor dashboard.

  4. Set up <vendor> — the ordered walkthrough for this specific gateway, several of whose steps depend on the one before.

Below those sit What goes wrong with this one — the vendor-specific traps — and a reference panel with regions, settlement, pricing and links.

The Test button

POST/api/admin/finance/deposit/gateway/{id}/testpermission: edit.deposit.gateway
Tests a gateway's credentials against the vendor

Testing before saving is the point. Every one of these vendors fails a bad credential somewhere you are not looking — at a customer's checkout, or in PayPal's case not at all until the buttons silently fail to render. The only moment a wrong key is cheap to find is while it is still on your clipboard.

Type candidate values into the Connection panel and press Test. Those values overlay the configured environment for the duration of one request and are then discarded. Nothing is written to .env, nothing is written to the database, no payment or order is created, and no credential value is ever written to the log — only the variable names you supplied. Only keys the gateway's own profile declares are accepted; anything else in the body is dropped.

The answer is one of four verdicts:

Verdict Meaning
valid The vendor accepted the credential on a read-only call
invalid The vendor explicitly rejected it — a 401/403, or an authentication error in the body
unknown The check could not be completed: a timeout, a 5xx, no outbound network, an unexpected response shape
unsupported This vendor exposes no read-only authenticated endpoint, so presence and shape were checked instead

The distinction is there on purpose. On several of these vendors, regenerating a key immediately invalidates the one in production — so reporting "the vendor was down" as "your key is wrong" sends you to break a working integration. Only an explicit rejection is reported as invalid.

Six of the sixteen are format-onlypaysafe, payu, paytm, payfast, ipay88 and 2checkout — because they authenticate per-payment signatures and have no endpoint that can be read without creating something. For those, Test proves the values are present and well formed; a sandbox deposit is what proves them correct.

The test is deliberately kept off the audit trail: it tests values you have typed but not saved, so there is no record for an audit entry to point at.

The webhook URL

For the gateways that have one, the address the vendor must call back is:

https://your-site.example/api/finance/deposit/fiat/<alias>/webhook

It is built from the gateway's alias, and the URLs panel shows the absolute form for your install.

The registry is keyed on alias because id cannot be joined against anything — the seeder gives some rows a literal id (stripe, mollie) and mints a UUID for others. alias is what the deposit form puts in the request path and what the integration folder is named, so it is the only stable link between a database row and its handler.

The origin comes from APP_PUBLIC_URL, falling back to NEXT_PUBLIC_SITE_URL, then FRONTEND_URL, then http://localhost:3000. If the URLs on this page look wrong, that chain is what to fix — not the vendor dashboard.

Some gateways expose an override variable (APP_PAYSTACK_WEBHOOK_ENDPOINT, APP_MOLLIE_WEBHOOK_ENDPOINT and similar). When one is set, the page shows the override, because that is the URL the vendor will actually be given.

Stripe, PayPal and eWAY now have webhooks too, and each needs a secret set before it will accept one. They previously confirmed a payment only by reading it back from the vendor when the customer returned — so a customer who closed the tab, lost signal or had the popup blocked was charged by the vendor and credited by nothing at all, with no record on this platform that the payment had happened.

Give the vendor the same /api/finance/deposit/fiat/<alias>/webhook URL as any other gateway, and set the matching secret:

Gateway Secret Where it comes from
Stripe APP_STRIPE_WEBHOOK_SECRET Stripe dashboard → Developers → Webhooks → your endpoint → Signing secret (whsec_…)
PayPal APP_PAYPAL_WEBHOOK_ID PayPal developer dashboard → your app → Webhooks → the webhook's ID (not a secret — PayPal signs with a certificate and the platform verifies by calling PayPal back)
eWAY APP_EWAY_WEBHOOK_SECRET MYeWAY → the notification's signing key

All three handlers answer 503 when their secret is missing, rather than trusting the request body. That is not an oversight to work around: the webhook route is public and it credits wallets, so an unverified endpoint would let anyone who can reach the URL credit any account. A refused webhook leaves you exactly where you were before these existed; a trusted one is worse than that.

You will see it in the backend log as "Webhook received but APP_STRIPE_WEBHOOK_SECRET is not set; refusing".

The events each one needs to be subscribed to:

Gateway Subscribe to
Stripe checkout.session.completed, checkout.session.async_payment_succeeded, charge.refunded, charge.dispute.created
PayPal PAYMENT.CAPTURE.COMPLETED, PAYMENT.CAPTURE.REFUNDED, PAYMENT.CAPTURE.REVERSED
eWAY the transaction notification your account offers; the platform re-reads the transaction from the eWAY API rather than trusting the payload, so the envelope shape does not matter

Subscribing to more than this is harmless — anything else is acknowledged and ignored, because a non-2xx on an event the platform does not use makes the vendor retry it for days and eventually disable your endpoint, taking the events it does use down with it.

The webhook and the customer's browser return can both fire, in either order, for the same payment. Both credit under the same idempotency key, so whichever arrives second stops without writing anything. You do not have to choose one or the other, and you should not disable the browser return to "avoid duplicates" — it is the faster of the two and the customer is watching it.

Where a gateway has a webhook, it is what credits the wallet independently of the customer's browser. Several vendors keep a separate webhook URL per mode — Paystack is the notable one — so going live with only the test webhook configured means every real payment succeeds at the vendor and is never credited here.

Adyen is worse in one respect: without APP_ADYEN_HMAC_KEY set, notifications arrive, fail verification and are dropped silently. Set the HMAC key at the same time as the webhook, never later.

Refunds and chargebacks

A refund you issue from the vendor's dashboard, and a chargeback a cardholder raises with their bank, both take the money out of your merchant account. Until recently only dLocal took it back out of the customer's wallet — on every other gateway the customer kept the credited balance and nothing in the ledger recorded that you had paid it back.

Eleven of the sixteen gateways now reverse a deposit when the vendor tells them to. What happens is the same on all of them:

  1. The customer's wallet is debited by the amount they actually received — the gross charge minus the platform fee that was withheld at deposit time. A 100 USD deposit with a 3 USD fee credited 97, so reversing it takes 97 back, not 100.

  2. The platform fee is written back as a loss. The vendor pulled the whole payment, so the fee credited to the Super Admin is revenue on money you no longer have. Leaving it booked would mean the customer funded your fee on a payment that never completed.

  3. If the wallet cannot cover it, whatever is there is taken and the remainder is recorded as a pending receivable, the wallet is suspended, and every admin gets a critical notification naming the amount. This is the common case — a customer who deposits, withdraws and then charges back has nothing left.

  4. The deposit is marked REFUNDED only after the money has actually moved.

Step 3 suspends the wallet, and the wallet check runs on credits as well as debits — so the customer cannot pay the balance back until you re-enable it. That is deliberate: an account that has charged back money it no longer holds should not keep transacting while you decide what to do. But it does mean you have to act before they can settle up, and the admin notification says so.

Find the unrecovered amounts under Finance → Transactions as REFUND rows with status PENDING.

A partial refund is taken back only where the vendor states the refunded portion, which today means dLocal. There, the stated portion is debited, the platform fee stays earned on the part of the deposit that still stands, and a later full refund takes only the remainder. Everywhere else a partial refund reverses nothing and writes a log line naming the amounts: reversing the full deposit on a 10% refund would take ten times too much out of the customer's wallet. Handle those by hand.

Everything above describes the engine's design, and the design is what shipped. The step that finds the wallet to debit looked it up by a currency field on the transaction row, and that column does not exist — so on every gateway that used the engine the reversal stopped at "no wallet", recorded nothing, and left the customer holding the money. Only dLocal actually took funds back, because it carried its own copy of the logic.

The lookup now resolves the wallet the deposit was credited to. Refunds and chargebacks your vendors reported before you update this release were not reversed: reconcile them against your vendor statement, and use Finance → Transactions to correct any that still matter.

Five gateways still cannot reverse: 2checkout, iPay88, Klarna, PayFast and TransFi. Each is a different reason — some send no refund notification at all, some need a separate integration that is not built — and on those, a refund or chargeback still leaves the customer holding the money. Reconcile them against your vendor statement.

Test mode is inferred, not switched

There is no global "test mode" switch for deposits. Each gateway's mode is worked out from evidence, in this order:

  1. No credentials at all — reported as unknown. A flag or a URL path is configuration, not a credential, so neither counts as evidence.

  2. An explicit base-URL override — where the integration has one (APP_TRANSFI_BASE_URL), a host containing sandbox, test or staging means test. It beats the sandbox flag, because that is the precedence the integration itself uses.

  3. The sandbox flag, where the integration has one — seven do: APP_PAYSTACK_SANDBOX, APP_TRANSFI_SANDBOX, APP_PAYSAFE_SANDBOX, APP_PAYU_SANDBOX, APP_PAYTM_SANDBOX, APP_PAYFAST_SANDBOX, and APP_ADYEN_ENVIRONMENT.

  4. The key prefixsk_test_ / sk_live_, test_ / live_. This is what decides for Stripe and Mollie, which have no flag at all.

So a Stripe install pointed at sk_test_ charges nothing and declines real customers' cards with no useful message, and the only thing that tells you is the prefix. Check it after every deployment.

Getting one live

The specifics differ per vendor and the per-gateway screen carries them. The shape is always this:

  1. Choose a gateway your customers can actually pay with. Regions and settlement are on each card's reference panel — Paystack for West Africa, PayU or Paytm for India, Mollie for the EEA, dLocal for Latin America.

  2. Get the credentials from the vendor, starting with their test or sandbox pair. Several vendors — dLocal, TransFi, Paysafe, Klarna — onboard by contract rather than self-service, so start that conversation early.

  3. Write them into .env on the server. The Environment Variables panel gives you a paste-ready block containing exactly the keys still missing.

  4. Give the vendor the webhook URL from the URLs panel, on the correct mode tab, and copy back any signing secret it hands you.

  5. Restart the backendpnpm restart from the project root. Nothing you wrote in step 3 exists to the platform until you do.

  6. Press Test credentials on the Connection panel and read the verdict.

  7. Switch the gateway on with the toggle, and set its currency list, fees and limits on the right-hand column. A currency you add here is not enabled at the vendor — the list can only narrow what they already accept.

  8. Make one real test deposit and confirm it lands in the wallet. This is the only step that proves the webhook, and no amount of credential testing substitutes for it.

  9. Swap in the live credentials, update the live webhook, and restart again.

  • Environment variables — the full .env reference, including every gateway variable and the two rules that decide whether an edit takes effect.
  • Settings reference — the Deposits master switch under Wallet, which refuses deposits platform-wide regardless of gateway state.
  • The admin panel — where the deposit queue and the manual deposit methods sit.