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:
| Flow | Meaning | Your app |
|---|---|---|
client_secret | Embedded card element | Confirms with the processor's SDK using next_action.client_secret |
redirect | Hosted checkout | Sends the user to next_action.redirect_url and handles the return |
dev | Offline test rail | Only 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
| Status | Meaning |
|---|---|
created | Intent exists at the processor; nobody has paid yet |
processing | The processor is working on it |
succeeded | Confirmed and credited. Terminal |
failed | The processor declined or cancelled. Nothing was credited. Terminal |
expired | Abandoned without a terminal answer. Terminal |
amount_mismatch | The 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.