WalletD developers
Guides

Top-ups: money in

The only way money enters a wallet: create an intent, let the user pay, and credit only on a verified gateway confirmation.

A top-up moves money from the outside world into a wallet. It is the only way money enters, and it has one rule that shapes the whole integration:

Creating an intent does not credit anything. The balance rises when the payment processor tells WalletD the money arrived, over a signature-verified webhook or a reconciliation query. Your app's "payment succeeded" callback is a UI event. Treating it as a credit is how a wallet ends up funded by a browser.

The flow

The wallet is credited on the second-to-last arrow, not the one before it.

1. Ask which rails exist

Rails are configured per deployment. Do not hard-code one; ask.

curl -sS "$WALLETD_API/v1/topup_methods" -H "Authorization: Bearer $WALLETD_API_KEY"
[{ "gateway": "stripe", "flow": "client_secret", "currencies": null }]

flow tells your client what to do with the intent:

FlowMeaningYour app
client_secretEmbedded card elementConfirms with the processor's SDK using next_action.client_secret
redirectHosted checkoutSends the user to next_action.redirect_url and handles the return
devOffline test railOnly exists in test environments

currencies is null when the rail takes anything the tenant uses; a list means only those, and a mismatch is refused with 400 before the processor sees it.

2. Create the intent

curl -sS -X POST "$WALLETD_API/v1/topups" \
  -H "Authorization: Bearer $WALLETD_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: topup-cart-8891" \
  -d '{"user_id": "'$USER_ID'", "amount": 5000, "gateway": "stripe"}'
{
  "id": "019fee0c-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
  "user_id": "019fee0b-b5d9-7dd6-945f-337ab9dd09bc",
  "amount": 5000,
  "gateway": "stripe",
  "status": "created",
  "next_action": { "kind": "client_secret", "client_secret": "pi_3U3..._secret_..." },
  "created_at": "2026-08-11T05:13:02.881Z"
}

The Idempotency-Key is required, and here it does double duty: it is also what makes the processor call idempotent. A replay reaches the processor byte for byte identical rather than opening a second charge.

3. Let the user pay

Hand next_action to your client and let the processor's own SDK collect the card.

Card details never reach WalletD and must never reach your server. That is what keeps you out of the expensive end of PCI scope, and it is the processor's SDK, not your form, that collects them.

For client_secret, your web client confirms with the processor's JS SDK. For redirect, send the browser to redirect_url.

When the processor's SDK reports success, show a pending state and wait for the webhook. Do not credit anything in your own UI as final until WalletD says the top-up succeeded.

4. Learn that it landed

Three ways, in order of preference.

Webhook (do this). Subscribe to topup.succeeded, topup.failed, and topup.amount_mismatch. Your backend learns without polling, usually within a second. See Webhooks.

Poll the intent. Cheap, and fine while a user is watching a spinner:

curl -sS "$WALLETD_API/v1/topups/$TOPUP_ID" -H "Authorization: Bearer $WALLETD_API_KEY"

Refresh, when a webhook went missing. This asks the processor directly and applies a terminal answer:

curl -sS -X POST "$WALLETD_API/v1/topups/$TOPUP_ID/refresh" \
  -H "Authorization: Bearer $WALLETD_API_KEY"

Refresh is not a second way to credit a wallet. It runs through the same verified confirmation path a webhook does, so calling it in a loop credits exactly once, and a settled intent is returned untouched without a processor round trip. Use it when a delivery was missed or delayed, and when a user is staring at a "pending" screen and you would rather answer than wait.

A background reconciliation sweep does the same thing on its own for anything left pending, so a permanently missed webhook still resolves without you.

Waiting for a top-up to settle

// Poll the intent, nudging the processor once in a while. Refresh is
// idempotent, so a nudge that races the webhook is harmless.
func (c *Client) AwaitTopup(ctx context.Context, id string) (*Topup, error) {
	ticker := time.NewTicker(time.Second)
	defer ticker.Stop()

	for attempt := 0; ; attempt++ {
		var topup Topup
		path := "/v1/topups/" + id
		method := http.MethodGet
		if attempt > 0 && attempt%5 == 0 {
			path, method = path+"/refresh", http.MethodPost
		}
		if err := c.Do(ctx, method, path, nil, &topup, ""); err != nil {
			return nil, err
		}
		if topup.Status != "created" && topup.Status != "processing" {
			return &topup, nil // terminal; only "succeeded" means credited
		}

		select {
		case <-ticker.C:
		case <-ctx.Done():
			return nil, ctx.Err()
		}
	}
}

Statuses

StatusMeaning
createdIntent exists at the processor; nobody has paid yet
processingThe processor is working on it
succeededConfirmed and credited. Terminal
failedThe processor declined or cancelled. Nothing was credited. Terminal
expiredAbandoned without a terminal answer. Terminal
amount_mismatchThe processor confirmed a different amount or currency than the intent. Nothing was credited; an operator resolves it. Terminal

Treat any status you do not recognize as not-credited: only succeeded ever means money landed.

Limits

Tier limits are checked before the processor is called. An over-cap top-up returns 422 tier_limit_exceeded and no payment intent is ever created, so a user cannot be charged for money the wallet was always going to refuse. See Limits.

Adding a rail

Rails are pluggable by design: a gateway declares its name, flow, and currencies, creates intents, verifies its own callbacks, and answers reconciliation queries. Every verified outcome, whatever the rail, flows through the one confirmation path that can credit a wallet.

For you as an integrator that means your code does not change when a rail is added. Read /v1/topup_methods, honour next_action, and a new local rail appears in your app with no release.

Next

  • Webhooks to receive topup.succeeded properly.
  • Limits for the caps that refuse a top-up early.
  • Testing for test cards and how to force a decline.

On this page