# Top-ups: money in

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:

> **Warning**
>
> **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

```mermaid
sequenceDiagram
    participant App as Your app
    participant You as Your backend
    participant W as WalletD
    participant P as Processor

    You->>W: POST /v1/topups (Idempotency-Key)
    W->>P: create intent
    W-->>You: intent + next_action
    You-->>App: next_action
    App->>P: confirm the card
    P-->>App: succeeded (UI only)
    P->>W: signed webhook
    W->>W: verify, then credit the wallet
    W->>You: topup.succeeded webhook
```

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.

```bash
curl -sS "$WALLETD_API/v1/topup_methods" -H "Authorization: Bearer $WALLETD_API_KEY"
```

```json
[{ "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

```bash
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"}'
```

```json
{
  "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"
}
```

### Go

```go
type Topup struct {
	ID         string      `json:"id"`
	UserID     string      `json:"user_id"`
	Amount     int64       `json:"amount"`
	Gateway    string      `json:"gateway"`
	Status     string      `json:"status"`
	NextAction *NextAction `json:"next_action"`
}

type NextAction struct {
	Kind         string `json:"kind"`
	ClientSecret string `json:"client_secret,omitempty"`
	RedirectURL  string `json:"redirect_url,omitempty"`
}

func (c *Client) StartTopup(ctx context.Context, userID string, amount int64, gateway, cartID string) (*Topup, error) {
	var topup Topup
	body := map[string]any{"user_id": userID, "amount": amount, "gateway": gateway}
	err := c.Do(ctx, http.MethodPost, "/v1/topups", body, &topup, "topup-cart-"+cartID)
	return &topup, err
}
```

### Python

```python
def start_topup(client, user_id, amount, gateway, cart_id):
    return client.request("POST", "/v1/topups", {
        "user_id": user_id,
        "amount": amount,
        "gateway": gateway,
    }, idempotency_key=f"topup-cart-{cart_id}")
```

### PHP

```php
function startTopup(WalletD $client, string $userId, int $amount, string $gateway, string $cartId): array
{
    return $client->request('POST', '/v1/topups', [
        'user_id' => $userId,
        'amount' => $amount,
        'gateway' => $gateway,
    ], "topup-cart-{$cartId}");
}
```

### JavaScript

```javascript
export function startTopup(client, userId, amount, gateway, cartId) {
  return client.request("POST", "/v1/topups", {
    body: { user_id: userId, amount, gateway },
    idempotencyKey: `topup-cart-${cartId}`,
  });
}
```

### Java

```java
public JsonNode startTopup(String userId, long amount, String gateway, String cartId) throws Exception {
    return client.request("POST", "/v1/topups", Map.of(
            "user_id", userId,
            "amount", amount,
            "gateway", gateway), "topup-cart-" + cartId);
}
```

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.

> **Warning**
>
> **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](/webhooks/).

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

```bash
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:

```bash
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

#### Go

```go
// 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()
		}
	}
}
```

#### Python

```python
def await_topup(client, topup_id, timeout=60):
    deadline = time.monotonic() + timeout
    attempt = 0
    while time.monotonic() < deadline:
        # Refresh is idempotent, so nudging the processor occasionally is safe.
        if attempt and attempt % 5 == 0:
            topup = client.request("POST", f"/v1/topups/{topup_id}/refresh")
        else:
            topup = client.request("GET", f"/v1/topups/{topup_id}")
        if topup["status"] not in ("created", "processing"):
            return topup  # terminal; only "succeeded" means credited
        attempt += 1
        time.sleep(1)
    raise TimeoutError(f"top-up {topup_id} still pending")
```

#### PHP

```php
function awaitTopup(WalletD $client, string $topupId, int $timeoutSeconds = 60): array
{
    $deadline = time() + $timeoutSeconds;
    $attempt = 0;
    while (time() < $deadline) {
        // Refresh is idempotent, so nudging the processor occasionally is safe.
        $topup = ($attempt > 0 && $attempt % 5 === 0)
            ? $client->request('POST', "/v1/topups/{$topupId}/refresh")
            : $client->request('GET', "/v1/topups/{$topupId}");
        if (!in_array($topup['status'], ['created', 'processing'], true)) {
            return $topup; // terminal; only "succeeded" means credited
        }
        $attempt++;
        sleep(1);
    }
    throw new RuntimeException("top-up {$topupId} still pending");
}
```

#### JavaScript

```javascript
export async function awaitTopup(client, topupId, timeoutMs = 60_000) {
  const deadline = Date.now() + timeoutMs;
  for (let attempt = 0; Date.now() < deadline; attempt++) {
    // Refresh is idempotent, so nudging the processor occasionally is safe.
    const topup = attempt > 0 && attempt % 5 === 0
      ? await client.request("POST", `/v1/topups/${topupId}/refresh`)
      : await client.request("GET", `/v1/topups/${topupId}`);
    // terminal when no longer pending; only "succeeded" means credited
    if (topup.status !== "created" && topup.status !== "processing") return topup;
    await sleep(1000);
  }
  throw new Error(`top-up ${topupId} still pending`);
}
```

#### Java

```java
public JsonNode awaitTopup(String topupId, Duration timeout) throws Exception {
    Instant deadline = Instant.now().plus(timeout);
    for (int attempt = 0; Instant.now().isBefore(deadline); attempt++) {
        // Refresh is idempotent, so nudging the processor occasionally is safe.
        JsonNode topup = (attempt > 0 && attempt % 5 == 0)
                ? client.request("POST", "/v1/topups/" + topupId + "/refresh", null, null)
                : client.request("GET", "/v1/topups/" + topupId, null, null);
        String status = topup.path("status").asText();
        if (!status.equals("created") && !status.equals("processing")) {
            return topup; // terminal; only "succeeded" means credited
        }
        Thread.sleep(1000);
    }
    throw new IllegalStateException("top-up " + topupId + " still pending");
}
```

## 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](/guides/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](/webhooks/) to receive `topup.succeeded` properly.
- [Limits](/guides/limits/) for the caps that refuse a top-up early.
- [Testing](/testing/) for test cards and how to force a decline.
