# WalletD documentation, complete > A wallet operating system: double-entry ledger, top-ups, transfers, > payments with holds, refunds, rewards, subscriptions and credit lines > behind one REST API. Money is integer minor units, every money-moving > call takes an Idempotency-Key, and money only enters through a verified > gateway confirmation. Every page below is also available on its own at https://docs.walletd.io/.md. See https://docs.walletd.io/llms.txt for the index. ============================================================================== # Source: https://docs.walletd.io/index.md ============================================================================== # WalletD for developers WalletD is a wallet operating system. You get a double-entry money core (balances, top-ups, transfers, payments with holds, refunds, rewards, subscriptions, credit lines) behind one REST API, so your product can hold and move money without becoming a ledger company. Authored English guides are also published as raw markdown at the same path plus `.md`, indexed in [llms.txt](/llms.txt), and concatenated into [llms-full.txt](/llms-full.txt) for coding agents. ## Read this first Three facts decide most of your integration, so they come before any code. **Money is integer minor units.** `2500` is $25.00. There are no floats anywhere in the API, in either direction. A currency's exponent tells you where the decimal point goes; you do the formatting, and only for display. **Every money-moving call needs an `Idempotency-Key`.** Not optional, not best-effort: `POST /v1/transfers` without one is rejected. Replaying a key returns the original response byte for byte instead of moving money twice. This is what makes a timeout safe to retry. **Money only enters through a verified gateway confirmation.** Creating a top-up intent does not credit anything. A wallet's balance rises when the payment processor tells WalletD the money arrived, over a signature-verified webhook or a reconciliation query. A client-side "payment succeeded" callback is a UI event, never a credit. ## The shape of an integration ```mermaid flowchart LR A[Your backend] -->|API key| G[WalletD API] B[Your app] -->|user token| G G --> L[(Double-entry ledger)] P[Payment processor] -->|signed webhook| G G -->|signed webhook| A ``` Your backend holds an API key and acts for the whole tenant. Your app holds a short-lived user token and can only act for that one user. Both talk to the same API; the credential decides what is allowed, and WalletD verifies it itself rather than trusting a header from the edge. ## Start here - [Quickstart](/quickstart/): Five calls in ten minutes: create a user, fund the wallet, read the balance, send money, read the history. - [Authentication](/authentication/): API keys, wallet user tokens, IdP tokens, and the scope table. - [Environments and base URLs](/platform/environments-and-limits/): Sandbox versus live keys, what differs between them, and which URL is yours. - [Testing and sandbox](/testing/): Test cards, and how to provoke every failure on purpose. ## Understand the model - [The money model](/money-model/): Minor units, balance versus available versus held, and double entry. - [Idempotency](/idempotency/): Why every money-moving call needs a key, how to choose one, and how to retry. - [Errors](/errors/): RFC 7807 problem responses, the full code table, and what is retryable. - [Limits and quotas](/guides/limits/): Tier limits, credit lines, rate budgets and bulkheads, and where each is enforced. ## Build a money path - [Top-ups](/guides/topups/): Money in, through a gateway intent and a verified confirmation. - [Transfers](/guides/transfers/): Wallet to wallet, with aliases, fees and velocity limits. - [Payments and refunds](/guides/payments/): Instant payments, authorize and capture with holds, and refunds. - [Subscriptions](/guides/subscriptions/): Recurring charges and the dunning state machine. - [Rewards](/guides/rewards/): Cashback, points accrual, conversion rules and caps. - [Payouts](/guides/payouts/): Merchant settlement, payout runs and statements. - [Ecosystem](/guides/ecosystem/): Clients, offerings, discovery and client-funded rewards. - [Commerce](/guides/commerce/): Catalog, orders and the merchant storefront. ## Integrate and operate - [Webhooks](/webhooks/): Receiving events, signature verification, retries and the event catalogue. - [API reference](/reference/): Every operation of the Wallet, Auth and Ledger APIs, generated from the contracts. - [SDKs and tools](/sdks/): The Go SDK, the OpenAPI documents, and a Postman or Bruno collection. - [Coding agents](/ai/): Markdown endpoints, llms.txt, and what agents get wrong about this API. ## Evaluate the platform For a reviewer, an operations lead, or whoever signs the contract: these four pages say how the platform is built, how it is secured, what its limits are, and what happens when something breaks. - [Architecture overview](/platform/architecture/): The services, what is authoritative for money, and what a partner deployment contains. - [Security for integrators](/platform/security/): Credential lifecycle, scopes, signing, retention, and what is not offered today. - [Going live](/platform/going-live/): The checklist: live keys, webhook receivers, reconciliation, monitoring and sign-off. - [Support, SLAs and status](/platform/support/): Severity model, response targets, escalation, and the status page. ## Languages in this guide Every money-moving operation is shown in curl, Go, Python, PHP, JavaScript and Java. The curl example is the contract; the rest are the same request in each language's idiom. Pick a language once and the tabs follow you across the site. ============================================================================== # Source: https://docs.walletd.io/quickstart.md ============================================================================== # Quickstart Five API calls: create a user, fund their wallet, check the balance, send money, read the history. Ten minutes end to end with a sandbox key. ## Before you start ```bash export WALLETD_API="https://api.walletd.example" export WALLETD_API_KEY="sk_sandbox_..." ``` Sandbox keys move no real money. Card top-ups run against the processor's test mode, so you can complete the whole flow with a test card. See [Testing](/testing/). ## 1. Create a wallet user A wallet user is your user, mirrored. `external_id` is your identifier and the only field WalletD needs; it is unique per tenant, so creating the same one twice is a `409`. ### curl ```bash curl -sS -X POST "$WALLETD_API/v1/users" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -d '{"external_id": "user-42", "kind": "consumer", "display_name": "Ada Lovelace", "handle": "ada"}' ``` ```json { "id": "019fee0b-b5d9-7dd6-945f-337ab9dd09bc", "external_id": "user-42", "kind": "consumer", "display_name": "Ada Lovelace", "handle": "ada", "status": "active", "created_at": "2026-08-11T05:12:44.219Z" } ``` ### Go ```go client := walletd.New(os.Getenv("WALLETD_API"), os.Getenv("WALLETD_API_KEY")) var user walletd.User err := client.Do(ctx, http.MethodPost, "/v1/users", map[string]any{ "external_id": "user-42", "kind": "consumer", "display_name": "Ada Lovelace", "handle": "ada", }, &user, "") ``` ### Python ```python client = WalletD() user = client.request("POST", "/v1/users", { "external_id": "user-42", "kind": "consumer", "display_name": "Ada Lovelace", "handle": "ada", }) ``` ### PHP ```php $client = new WalletD(getenv('WALLETD_API'), getenv('WALLETD_API_KEY')); $user = $client->request('POST', '/v1/users', [ 'external_id' => 'user-42', 'kind' => 'consumer', 'display_name' => 'Ada Lovelace', 'handle' => 'ada', ]); ``` ### JavaScript ```javascript const client = new WalletD(process.env.WALLETD_API, process.env.WALLETD_API_KEY); const user = await client.request("POST", "/v1/users", { body: { external_id: "user-42", kind: "consumer", display_name: "Ada Lovelace", handle: "ada" }, }); ``` ### Java ```java WalletD client = new WalletD(System.getenv("WALLETD_API"), System.getenv("WALLETD_API_KEY")); JsonNode user = client.request("POST", "/v1/users", Map.of( "external_id", "user-42", "kind", "consumer", "display_name", "Ada Lovelace", "handle", "ada"), null); ``` Keep the returned `id`. Everything else in the API refers to users by it. ## 2. Put money in A top-up creates an intent at a payment gateway. > **Warning** > > It does not credit the wallet. The balance rises only when the processor > confirms the money arrived. [Top-ups](/guides/topups/) covers the full flow, > including how your app completes the card payment; here is the first half. ### 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-user-42-first" \ -d '{"user_id": "019fee0b-b5d9-7dd6-945f-337ab9dd09bc", "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 var topup walletd.Topup err := client.Do(ctx, http.MethodPost, "/v1/topups", map[string]any{ "user_id": user.ID, "amount": 5000, // $50.00 "gateway": "stripe", }, &topup, "topup-user-42-first") ``` ### Python ```python topup = client.request("POST", "/v1/topups", { "user_id": user["id"], "amount": 5000, # $50.00 "gateway": "stripe", }, idempotency_key="topup-user-42-first") ``` ### PHP ```php $topup = $client->request('POST', '/v1/topups', [ 'user_id' => $user['id'], 'amount' => 5000, // $50.00 'gateway' => 'stripe', ], 'topup-user-42-first'); ``` ### JavaScript ```javascript const topup = await client.request("POST", "/v1/topups", { body: { user_id: user.id, amount: 5000, gateway: "stripe" }, // $50.00 idempotencyKey: "topup-user-42-first", }); ``` ### Java ```java JsonNode topup = client.request("POST", "/v1/topups", Map.of( "user_id", user.get("id").asText(), "amount", 5000, // $50.00 "gateway", "stripe"), "topup-user-42-first"); ``` `amount` is **5000 minor units, which is $50.00**. Hand `next_action.client_secret` to your checkout UI; when the card clears, WalletD credits the wallet. ## 3. Read the balance ### curl ```bash curl -sS "$WALLETD_API/v1/users/$USER_ID/balances" \ -H "Authorization: Bearer $WALLETD_API_KEY" ``` ```json [ { "purpose": "cash", "commodity": "USD", "balance": 5000, "available": 5000, "held": 0 }, { "purpose": "points", "commodity": "POINTS", "balance": 0, "available": 0, "held": 0 } ] ``` ### Go ```go var balances []walletd.Balance err := client.Do(ctx, http.MethodGet, "/v1/users/"+user.ID+"/balances", nil, &balances, "") ``` ### Python ```python balances = client.request("GET", f"/v1/users/{user['id']}/balances") cash = next(b for b in balances if b["purpose"] == "cash") ``` ### PHP ```php $balances = $client->request('GET', "/v1/users/{$user['id']}/balances"); $cash = current(array_filter($balances, fn ($b) => $b['purpose'] === 'cash')); ``` ### JavaScript ```javascript const balances = await client.request("GET", `/v1/users/${user.id}/balances`); const cash = balances.find((b) => b.purpose === "cash"); ``` ### Java ```java JsonNode balances = client.request("GET", "/v1/users/" + userId + "/balances", null, null); ``` A user has one balance per purpose, not one number. `available` is what they can spend right now; `held` is money reserved by an uncaptured authorization. Show `available`. See [The money model](/money-model/). ## 4. Send money ### curl ```bash curl -sS -X POST "$WALLETD_API/v1/transfers" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: transfer-order-8891" \ -d '{"from_user": "'$SENDER'", "to_user": "'$RECIPIENT'", "amount": 1500, "note": "lunch"}' ``` ### Go ```go var txn walletd.Transaction err := client.Do(ctx, http.MethodPost, "/v1/transfers", map[string]any{ "from_user": senderID, "to_user": recipientID, "amount": 1500, "note": "lunch", }, &txn, "transfer-order-8891") ``` ### Python ```python txn = client.request("POST", "/v1/transfers", { "from_user": sender_id, "to_user": recipient_id, "amount": 1500, "note": "lunch", }, idempotency_key="transfer-order-8891") ``` ### PHP ```php $txn = $client->request('POST', '/v1/transfers', [ 'from_user' => $senderId, 'to_user' => $recipientId, 'amount' => 1500, 'note' => 'lunch', ], 'transfer-order-8891'); ``` ### JavaScript ```javascript const txn = await client.request("POST", "/v1/transfers", { body: { from_user: senderId, to_user: recipientId, amount: 1500, note: "lunch" }, idempotencyKey: "transfer-order-8891", }); ``` ### Java ```java JsonNode txn = client.request("POST", "/v1/transfers", Map.of( "from_user", senderId, "to_user", recipientId, "amount", 1500, "note", "lunch"), "transfer-order-8891"); ``` The `Idempotency-Key` is required. It comes from your order, so retrying after a timeout sends once. See [Idempotency](/idempotency/). ## 5. Read the history ```bash curl -sS "$WALLETD_API/v1/users/$USER_ID/transactions?limit=20" \ -H "Authorization: Bearer $WALLETD_API_KEY" ``` Newest first, cursor-paged. Every money movement a user was part of appears here with its type, amount, and reference, which is what a support screen needs. ## What you just built You provisioned a wallet, funded it through a real processor, moved money between two wallets, and read the trail. Everything else in this guide is a variation on those four moves. ## Next - [Top-ups](/guides/topups/) to finish the money-in flow properly, including webhooks. - [Webhooks](/webhooks/) so your backend learns about money without polling. - [Payments and refunds](/guides/payments/) to charge for something. - [The money model](/money-model/) if `available` versus `held` was surprising. ============================================================================== # Source: https://docs.walletd.io/authentication.md ============================================================================== # Authentication Every request carries a bearer credential. WalletD verifies it itself, on every call. It never trusts an identity header from a proxy, so nothing in front of the API can vouch for a caller. ``` Authorization: Bearer ``` Three kinds of credential exist. Which one you send decides both *who* you are and *what you may do*. ## The three credentials ### Tenant API key `sk_live_01H8X...ab_9f2c...` — issued to your backend, acts for your whole tenant. Format is `sk_{env}_{key-id}_{secret}`; the env segment is `live` or `sandbox`, and the key id lets WalletD find the record without scanning. Only the Argon2id hash is stored, so the secret is shown exactly once at creation. If you lose it, rotate it. Use it from a server you control. > **Warning** > > Never ship a tenant API key to a browser, a mobile app, or anything a user can > decompile. It acts for your whole tenant. An app that needs to act for one > person holds a wallet user token instead. ### Wallet user token A short-lived RS256 JWT that acts for exactly one user. Your backend mints one by exchanging its API key: ```bash curl -X POST https://api.walletd.example/v1/auth/user_tokens \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "user_external_id": "your-user-42", "scopes": ["balances:read", "users:read", "transfers:write"], "ttl_seconds": 900 }' ``` The token can only ever act on that user's own wallet, and it cannot request a scope your key does not already hold (you get `403 scope_exceeds_key`). Keep the TTL short; 15 minutes is the default for a reason. This is the credential your app holds. ### IdP access token If your users sign in through the WalletD identity provider, their access token *is* their wallet credential. There is no exchange step and no second account system: WalletD verifies the token against the realm's public keys and provisions the wallet on first contact. Consumers get a fixed scope set decided by the platform, not by the token. ## Scopes A scope is `resource:action`. The credential carries a set; a handler refuses anything outside it with `403 insufficient_scope`. | Scope | Grants | |---|---| | `users:read`, `users:write` | Read and create wallet users | | `balances:read` | Read balances | | `transfers:write` | Send P2P transfers | | `topups:read`, `topups:write` | Read and start top-ups | | `payments:read`, `payments:write` | Read, create, capture and void payments | | `refunds:write` | Refund a payment | | `merchants:read`, `merchants:write` | Read and register merchants | | `subscriptions:read`, `subscriptions:write` | Read and manage subscriptions | | `rewards:read`, `rewards:manage`, `rewards:convert` | Read rewards, manage rules, convert points | | `webhooks:manage` | Manage endpoints and redelivery | | `ledger:read`, `audit:read` | Ledger summary and audit log | | `fees:manage`, `limits:manage`, `credit:write` | Fee plans, tier limits, credit lines | | `catalog:read`, `clients:onboard` | Ecosystem discovery and self-serve onboarding | Two rules that surprise people: - **A user token is confined to its own user regardless of scope.** `balances:read` on a user token does not let it read another user's balance. Self-access is a separate check from the scope check. - **Some surfaces refuse user tokens entirely**, no matter the scope. Credit lines and tier configuration are API-key-or-staff only, because a user must never be able to raise their own limits. ## A client, once, in each language The rest of this guide assumes a small client that does four things: sets the bearer token, sets `Content-Type`, attaches an `Idempotency-Key` on money-moving calls, and turns a problem response into an error you can catch. Write it once. ### curl ```bash export WALLETD_API="https://api.walletd.example" export WALLETD_API_KEY="sk_sandbox_..." # Every example in this guide uses these two variables. curl -sS "$WALLETD_API/v1/users" -H "Authorization: Bearer $WALLETD_API_KEY" ``` ### Go ```go package walletd import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "time" ) type Client struct { BaseURL string APIKey string HTTP *http.Client } func New(baseURL, apiKey string) *Client { return &Client{BaseURL: baseURL, APIKey: apiKey, HTTP: &http.Client{Timeout: 30 * time.Second}} } // Problem is the RFC 7807 body every error carries. Switch on Code, never on // the human-readable Title. type Problem struct { Title string `json:"title"` Status int `json:"status"` Detail string `json:"detail"` Code string `json:"code"` } func (p *Problem) Error() string { return fmt.Sprintf("walletd: %s (%s)", p.Code, p.Detail) } // Do sends one request. idempotencyKey must be non-empty for anything that // moves money; pass "" for reads. func (c *Client) Do(ctx context.Context, method, path string, body, out any, idempotencyKey string) error { var payload io.Reader if body != nil { encoded, err := json.Marshal(body) if err != nil { return err } payload = bytes.NewReader(encoded) } req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, payload) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+c.APIKey) req.Header.Set("Content-Type", "application/json") if idempotencyKey != "" { req.Header.Set("Idempotency-Key", idempotencyKey) } resp, err := c.HTTP.Do(req) if err != nil { return err } defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) if err != nil { return err } if resp.StatusCode >= 400 { problem := &Problem{Status: resp.StatusCode, Code: "unknown"} _ = json.Unmarshal(raw, problem) return problem } if out != nil && len(raw) > 0 { return json.Unmarshal(raw, out) } return nil } ``` ### Python ```python import json import os import requests class Problem(Exception): """The RFC 7807 body every error carries. Switch on .code, not the title.""" def __init__(self, payload, status): self.code = payload.get("code", "unknown") self.detail = payload.get("detail", "") self.status = status super().__init__(f"walletd: {self.code} ({self.detail})") class WalletD: def __init__(self, base_url=None, api_key=None): self.base_url = (base_url or os.environ["WALLETD_API"]).rstrip("/") self.session = requests.Session() self.session.headers.update({ "Authorization": f"Bearer {api_key or os.environ['WALLETD_API_KEY']}", "Content-Type": "application/json", }) def request(self, method, path, body=None, idempotency_key=None): headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {} response = self.session.request( method, self.base_url + path, data=json.dumps(body) if body is not None else None, headers=headers, timeout=30, ) if response.status_code >= 400: try: payload = response.json() except ValueError: payload = {} raise Problem(payload, response.status_code) return response.json() if response.content else None ``` ### PHP ```php code = $payload['code'] ?? 'unknown'; $this->detail = $payload['detail'] ?? ''; $this->httpStatus = $status; parent::__construct("walletd: {$this->code} ({$this->detail})"); } } class WalletD { public function __construct( private string $baseUrl, private string $apiKey, ) { } /** @param array|null $body */ public function request(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): mixed { $headers = [ 'Authorization: Bearer ' . $this->apiKey, 'Content-Type: application/json', ]; if ($idempotencyKey !== null) { $headers[] = 'Idempotency-Key: ' . $idempotencyKey; } $ch = curl_init($this->baseUrl . $path); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 30, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_THROW_ON_ERROR)); } $raw = curl_exec($ch); if ($raw === false) { throw new RuntimeException('walletd: ' . curl_error($ch)); } $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $decoded = $raw === '' ? null : json_decode($raw, true); if ($status >= 400) { throw new WalletDProblem(is_array($decoded) ? $decoded : [], $status); } return $decoded; } } ``` ### JavaScript ```javascript // Node 18+ or any modern browser runtime. No dependencies. export class WalletDProblem extends Error { constructor(payload, status) { super(`walletd: ${payload.code ?? "unknown"} (${payload.detail ?? ""})`); this.name = "WalletDProblem"; this.code = payload.code ?? "unknown"; this.detail = payload.detail ?? ""; this.status = status; } } export class WalletD { constructor(baseUrl, apiKey) { this.baseUrl = baseUrl.replace(/\/$/, ""); this.apiKey = apiKey; } async request(method, path, { body, idempotencyKey } = {}) { const headers = { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json", }; if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey; const response = await fetch(this.baseUrl + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), }); const text = await response.text(); const payload = text ? JSON.parse(text) : null; if (!response.ok) throw new WalletDProblem(payload ?? {}, response.status); return payload; } } ``` ### Java ```java import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; public class WalletD { private final String baseUrl; private final String apiKey; private final HttpClient http = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); private final ObjectMapper mapper = new ObjectMapper(); public WalletD(String baseUrl, String apiKey) { this.baseUrl = baseUrl.replaceAll("/$", ""); this.apiKey = apiKey; } /** The RFC 7807 body every error carries. Switch on code(), not the title. */ public static class Problem extends RuntimeException { public final String code; public final String detail; public final int status; Problem(JsonNode body, int status) { super("walletd: " + body.path("code").asText("unknown")); this.code = body.path("code").asText("unknown"); this.detail = body.path("detail").asText(""); this.status = status; } } public JsonNode request(String method, String path, Object body, String idempotencyKey) throws Exception { HttpRequest.BodyPublisher payload = body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body)); HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(baseUrl + path)) .header("Authorization", "Bearer " + apiKey) .header("Content-Type", "application/json") .timeout(Duration.ofSeconds(30)) .method(method, payload); if (idempotencyKey != null) { builder.header("Idempotency-Key", idempotencyKey); } HttpResponse response = http.send(builder.build(), HttpResponse.BodyHandlers.ofString()); JsonNode parsed = response.body().isEmpty() ? mapper.createObjectNode() : mapper.readTree(response.body()); if (response.statusCode() >= 400) { throw new Problem(parsed, response.statusCode()); } return parsed; } } ``` ## Keeping keys safe - Keep keys in a secret manager or environment, never in source control. A leaked key can move your tenant's money. - Use `sk_sandbox_...` keys everywhere but production. The environment segment is checked against the key record; it does not partition data within a deployment. Use a separate non-production deployment for sandbox work. - Issuance and revocation are audited. When something looks wrong, the audit log answers who issued what and when. **An API key expires 365 days after it is issued.** The expiry is set at issuance. The key-list response includes `created_at`, `expires_at` and `last_used_at`; the create response does not include expiry. Read the listing to schedule rotation before `expires_at`, and handle a null expiry on older records explicitly. No automatic expiry notification is established by this API. Rotate before expiry to avoid `401 unauthenticated` responses. Rotation is the same three steps whether you are rotating on schedule or because a key leaked, and the order matters, because issuing a new key does **not** invalidate the old one: 1. **Create** a second key with the same scopes. 2. **Deploy** it everywhere the old one is used, and confirm traffic has moved. 3. **Revoke** the old key. Revoking first gives you an outage; revoking last gives you an overlap you control. authsvc and walletd each have a 60-second positive cache. Revocation clears only the receiving authsvc process's cache, so replicas can extend stale acceptance toward two minutes plus request latency. Verify rejection of the old key after that allowance; see [Security for integrators](/platform/security/). ## Next - [Idempotency](/idempotency/), which is the other half of a correct client. - [Errors](/errors/) for the codes these clients raise. ============================================================================== # Source: https://docs.walletd.io/testing.md ============================================================================== # Testing ## Sandbox keys A key carries its environment in the token itself: `sk_sandbox_...` versus `sk_live_...`. Sandbox keys move no real money, and a sandbox key pointed at production simply fails to authenticate rather than doing something expensive. Use sandbox for development, CI, and staging. The API surface is identical; only the money is not real. ## Card top-ups Money in runs through the payment processor's test mode. Confirm with a test card and the whole flow works end to end, including the signed webhook. | Card | Behaviour | |---|---| | `4242 4242 4242 4242` | Succeeds | | `4000 0000 0000 0002` | Declined | | `4000 0000 0000 9995` | Declined for insufficient funds | | `4000 0025 0000 3155` | Requires 3D Secure authentication | Any future expiry and any CVC. Server-side, confirm with the processor's test payment method (`pm_card_visa`) and skip the browser entirely, which is what the platform's own gate scripts do. Test-mode top-ups produce genuine `topup.succeeded` webhooks with real signatures, so your verification code is exercised for real. ## Provoking each failure The paths worth testing are the refusals, not the happy one. | To test | Do this | Expect | |---|---|---| | Insufficient funds | Transfer more than `available` | `422 insufficient_funds` | | Tier limit | Set `max_balance` low, then top up past it | `422 tier_limit_exceeded`, and **no** processor intent | | Idempotent replay | Send the same request twice with one key | Identical response, one movement | | Key misuse | Same key, different amount | `409 idempotency_key_reuse` | | Scope | Call with a token lacking the scope | `403 insufficient_scope` | | Cross-user | User token acting on another user | `403 forbidden` | | Cross-tenant | Read another tenant's object id | `404`, not `403` | | Rate limit | Exceed an endpoint budget | `429` with `Retry-After` | | Refund cap | Refund more than captured, cumulatively | `400 invalid_request` | | Hold expiry | Authorize with `hold_ttl_seconds: 60`, wait | `payment.expired`, hold released | | Bad webhook signature | Flip one byte of a delivery body | Your receiver returns `401` | That last one is a test of *your* code, and it is the one people skip. A receiver that accepts a tampered body is the failure this whole model exists to prevent, so assert on it. ## Testing idempotency properly Sending the same request twice is only half the test. The real property is that a *timeout* is safe: ```python def test_replay_is_byte_identical(client, sender, recipient): key = f"test-transfer-{uuid.uuid4()}" body = {"from_user": sender, "to_user": recipient, "amount": 1500} first = client.request("POST", "/v1/transfers", body, idempotency_key=key) second = client.request("POST", "/v1/transfers", body, idempotency_key=key) assert first == second # same response, byte for byte assert balance(client, sender) == before - 1510 # money moved once ``` Assert on the balance, not just on the response. A replay that returns the right JSON while moving money twice would pass a weaker test. ## Running the platform locally The whole stack runs on one machine with Docker: the API, the gateway, Postgres, the identity provider, and a webhook receiver that verifies signatures the way yours should. ```bash make up # build and start everything make demo # provision tenants and run scripted scenarios with exact-balance assertions ``` The demo is a full integration test wearing a demo costume: it provisions through the real APIs and asserts exact balances at every step, so a green run means the money paths behave as documented. Green output is also the fastest way to see request and response shapes for a flow you are about to build. Local top-ups use the processor's test mode, so you need test keys in your environment. Without a processor account, an offline mock rail can be enabled for development, and `GET /v1/topup_methods` will show it in place of the card rail. Your integration code does not change either way, because it reads the rail rather than naming one. ## In CI Three things are worth wiring into your own pipeline: 1. **Idempotent replay** of every money-moving call you make. 2. **Signature verification**, both the accept and the reject case. 3. **Error handling** for `422` and `429` specifically, since those are the ones that reach real users. Point your tests at sandbox, use a fresh `external_id` prefix per run so cases do not collide, and assert balances rather than status codes wherever money is involved. ## Next - [Errors](/errors/) for what each refusal means. - [Webhooks](/webhooks/) for verification. ============================================================================== # Source: https://docs.walletd.io/money-model.md ============================================================================== # The money model You can integrate WalletD by treating balances as numbers that go up and down. You will integrate it *well* if you spend ten minutes on what those numbers are. ## Money is integer minor units Every amount in every request and response is an integer in the currency's smallest unit. | You mean | You send | |---|---| | $25.00 | `2500` | | $0.07 | `7` | | ¥1,200 (no minor unit) | `1200` | There are no floats in the API in either direction. Format for display at the very edge of your system, and never let a float touch an amount on the way in: `0.1 + 0.2` is a rounding bug in an accounting system. ## Balances are per purpose A user does not have "a balance". They have one account per purpose, and each account has its own commodity. ```bash curl -sS "$WALLETD_API/v1/users/$USER_ID/balances" -H "Authorization: Bearer $WALLETD_API_KEY" ``` ```json [ { "purpose": "cash", "commodity": "USD", "balance": 4200, "available": 2700, "held": 1500 }, { "purpose": "points", "commodity": "POINTS", "balance": 650, "available": 650, "held": 0 } ] ``` Three numbers, three meanings: - **`balance`** is the accounting total. - **`held`** is reserved by an authorization that has not been captured or voided yet. It is still the user's money; it is just spoken for. - **`available`** is `balance - held`, and it is the only one that answers "can they spend this?" > **Warning** > > **Show `available` in your UI.** A wallet that displays `balance` will tell a > user they have $42.00 and then decline a $30.00 purchase, which is the single > most common integration bug in this space. If your product shows a hold, show > it as its own line ("$15.00 pending at Hotel X"), never folded into one number. ## Double entry, and why you can trust the number Every movement writes at least two ledger postings that sum to zero. Money is never created or destroyed by an operation, only moved between accounts, and the sum of every posting in a tenant is always zero. That is checked continuously, not assumed. The practical consequence for you: **there is no such thing as a partial money movement.** A transfer either debited the sender and credited the recipient and charged the fee, or none of it happened. A movement is posted to the ledger of record as one balanced posting or not at all; a caller sees either the completed movement or a refusal, never a half-finished one; and a replayed `Idempotency-Key` returns the same answer byte for byte. There is no intermediate state for you to observe or to clean up. ## The float equation Every unit of money in a tenant is somewhere, and the places add up: ``` user cash + merchant settlement + platform fees + treasury = money that entered ``` When a payout leaves, both sides shrink together. When a top-up lands, both sides grow. If you ever reconcile against your processor, this is the identity that has to hold, and the ledger summary endpoint reports it directly. ## Account purposes you will meet | Purpose | Whose | What it holds | |---|---|---| | `cash` | user | Spendable money | | `points` | user | Reward points, a separate commodity | | `settlement` | merchant | Money earned and not yet paid out | | `marketing` | client | A client's own promotional budget | | `fees` | platform | Processing fees WalletD collected | | `fees` | tenant | Fees and marketplace commission the loop collected | | `treasury` | platform | Money that has left for a bank | Points are a genuinely different commodity, not cash with a label. They can only become cash through an explicit conversion with a rule, which is what keeps a rewards programme from silently becoming a currency. See [Rewards](/guides/rewards/). ## What a transaction costs Every money path is priced the same way: a rate in basis points of the amount, plus a flat part in minor units, then held inside a minimum and an optional maximum. A path nobody has priced charges nothing. Percentage fees round up, by fixed policy, and a fee taken out of money never exceeds the money it comes from. Six paths can carry a fee, and the direction differs: | Path | Where it comes from | |---|---| | Payment | Deducted from the merchant's settlement | | Subscription charge | Deducted from settlement, each period | | Marketplace commission | Deducted from settlement, or added to what the buyer pays | | Transfer | Added on top of what the sender pays, so the recipient receives the full amount | | Top-up | Deducted from what the user is credited | | Payout | Deducted from what leaves | Two of those surprise people. A top-up fee does not reduce the money that entered the loop: the processor collected the full amount, treasury grows by all of it, and the fee is the part the wallet keeps rather than credits. A payout fee works the same way in reverse: the merchant is debited what it asked for, only the net leaves, and the fee stays behind. The identity above holds in both cases. Marketplace commission is separate from the payment fee, is charged only on catalog sales, and is reported on the payment as its own `commission` field so you never have to infer it. If your loop adds commission on top rather than deducting it, the amount charged already includes it, because the catalog is the only source of price truth. ## Negative balances are legal, sometimes A user with a credit line can go below zero, down to their limit. That is not an error state and not a bug in your display: it is a business account spending on credit. `available` accounts for the headroom, so the same check still works. Everyone else is floor-enforced at zero. An overdraft that was not explicitly granted is refused with `422 insufficient_funds`. ## Money in, money out **In** is a gateway confirmation and nothing else. Not a client callback, not a redirect landing, not your own belief that a card cleared. The processor tells WalletD over a verified channel and only then does a balance move. See [Top-ups](/guides/topups/). **Out** is deliberately narrow. This is a semi-closed wallet: consumers cannot cash out to a bank. Money leaves through merchant payouts, which post to the ledger first and generate a statement that instructs the actual transfer. See [Payouts](/guides/payouts/). ## Reading the trail Every movement has an id, a type, a status, a timestamp, and its postings. ```bash curl -sS "$WALLETD_API/v1/users/$USER_ID/transactions?limit=20" -H "Authorization: Bearer $WALLETD_API_KEY" curl -sS "$WALLETD_API/v1/transactions?type=payment&limit=50" -H "Authorization: Bearer $WALLETD_API_KEY" ``` The first is a user's own history. The second is the tenant-wide explorer with both sides of every posting resolved, which is what a finance or support screen is built on. Both are cursor-paged, newest first. ## Next - [Top-ups](/guides/topups/) to get money in. - [Payments and refunds](/guides/payments/) for holds and captures, where `held` comes from. - [Rewards](/guides/rewards/) for the points commodity. ============================================================================== # Source: https://docs.walletd.io/idempotency.md ============================================================================== # Idempotency Networks fail after the server has already acted. Your request created a transfer, the response never arrived, and now your code has to decide: retry and risk sending twice, or give up and risk not sending at all. Idempotency keys remove the choice. > **Warning** > > **Every money-moving `POST` requires an `Idempotency-Key` header.** It is not > optional. A request without one is rejected before anything happens. ``` Idempotency-Key: transfer-order-8891 ``` ## What a replay does Once an operation has committed its stored result, a replay returns **that response body and status code**. The domain rows, outbox events and stored response commit together in walletd. The accounting posting commits separately in ledgerd: no database transaction spans the two services. A timeout or failure can therefore occur after the ledger accepted a posting but before walletd recorded the response. Money paths use deterministic posting keys and stored-result probes to recover that result on replay. A `5xx` does not prove that no money moved. Keep the original request and key, retry within a bounded budget, and escalate an unresolved result through your agreed support channel; do not create a replacement payment with a new key. That is what makes a retry safe. If you do not know whether a request succeeded, send it again with the same key. ## Choosing a key A key must be stable for the operation and unique across operations. **Derive it from your business object and integration namespace, or persist a once-generated UUID.** | Key | Verdict | |---|---| | `payout-invoice-8891` | Good. Retries reuse it; a different invoice cannot collide | | `transfer-{your_order_id}` | Good | | `topup-{user_id}-{cart_id}` | Good | | `uuid4()` generated per attempt | Broken. Every retry gets a new key, so every retry moves money | | `transfer-{timestamp}` | Broken, same reason | | `transfer` | Broken. Your second, unrelated transfer is refused as a replay | Generate the key when you decide to do the thing, persist it alongside the business object, and reuse it for every attempt including ones after a process restart. Keys are scoped per tenant. User-token calls add a user namespace, but machine and staff calls retain the supplied key. **Merchant-bound API keys do not add a merchant namespace.** Include your merchant or integration identifier, operation and business-object identifier, for example `merchant-42-transfer-order-8891`. Two merchants using only `order-8891` can collide within the same tenant. A random UUID is also suitable if you generate it once, persist it with the operation and reuse it. The error is generating a new value for each attempt. Idempotency records currently have no automatic expiry or cleanup; do not assume a key becomes reusable after 30 days. ## Same key, different body A replay must be the *same* request. WalletD stores a hash of the operation and its parameters with the key. Reuse a key with a different body and you get: ```json { "type": "about:blank", "title": "Conflict", "status": 409, "code": "idempotency_key_reuse", "detail": "idempotency key reused with different parameters" } ``` That is a bug in your code, not a transient failure. Retrying will not fix it. It usually means a key derived from something too coarse: two different amounts sharing one order id, for example. ## Retrying correctly ```mermaid flowchart TD A[Send with key K] --> B{Response?} B -->|2xx| C[Done, record the result] B -->|409 idempotency_key_reuse| D[Bug: same key, different body. Do not retry] B -->|4xx other| E[Your request is wrong. Fix it, do not retry] B -->|429| F[Wait for Retry-After, resend with K] B -->|5xx or timeout| F F --> A ``` Retry on `429`, on `5xx`, and on a network timeout. Always with the same key. Back off exponentially with jitter, and respect `Retry-After` when it is present. Do not retry on other `4xx`s: they mean the request itself is wrong, and it will be just as wrong the second time. ## In each language The pattern is the same everywhere: pass the key from your business object. ### curl ```bash curl -sS -X POST "$WALLETD_API/v1/transfers" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: transfer-order-8891" \ -d '{"from_user":"'$SENDER'","to_user":"'$RECIPIENT'","amount":1500}' ``` ### Go ```go // Retry the calls that can succeed on a second attempt, always with the same key. func (c *Client) doWithRetry(ctx context.Context, method, path string, body, out any, key string) error { backoff := 200 * time.Millisecond for attempt := range 5 { err := c.Do(ctx, method, path, body, out, key) if err == nil { return nil } var problem *Problem if errors.As(err, &problem) && problem.Status < 500 && problem.Status != 429 { return err // your request is wrong; a retry will not help } if attempt == 4 { return err } jitter := time.Duration(rand.Int64N(int64(backoff / 2))) select { case <-time.After(backoff + jitter): case <-ctx.Done(): return ctx.Err() } backoff *= 2 } return nil } func (c *Client) SendTransfer(ctx context.Context, orderID string, from, to string, amount int64) (*Transaction, error) { var txn Transaction body := map[string]any{"from_user": from, "to_user": to, "amount": amount} // The key comes from the order, so every retry of this order reuses it. err := c.doWithRetry(ctx, http.MethodPost, "/v1/transfers", body, &txn, "transfer-"+orderID) return &txn, err } ``` ### Python ```python import random import time def with_retry(client, method, path, body, idempotency_key, attempts=5): delay = 0.2 for attempt in range(attempts): try: return client.request(method, path, body, idempotency_key) except Problem as problem: retryable = problem.status >= 500 or problem.status == 429 if not retryable or attempt == attempts - 1: raise time.sleep(delay + random.uniform(0, delay / 2)) delay *= 2 def send_transfer(client, order_id, sender, recipient, amount): # The key comes from the order, so every retry of this order reuses it. return with_retry( client, "POST", "/v1/transfers", {"from_user": sender, "to_user": recipient, "amount": amount}, f"transfer-{order_id}", ) ``` ### PHP ```php function withRetry(WalletD $client, string $method, string $path, ?array $body, string $key, int $attempts = 5): mixed { $delay = 200_000; // microseconds for ($attempt = 0; $attempt < $attempts; $attempt++) { try { return $client->request($method, $path, $body, $key); } catch (WalletDProblem $problem) { $retryable = $problem->httpStatus >= 500 || $problem->httpStatus === 429; if (!$retryable || $attempt === $attempts - 1) { throw $problem; } usleep($delay + random_int(0, intdiv($delay, 2))); $delay *= 2; } } return null; } function sendTransfer(WalletD $client, string $orderId, string $from, string $to, int $amount): mixed { // The key comes from the order, so every retry of this order reuses it. return withRetry($client, 'POST', '/v1/transfers', [ 'from_user' => $from, 'to_user' => $to, 'amount' => $amount, ], "transfer-{$orderId}"); } ``` ### JavaScript ```javascript const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export async function withRetry(client, method, path, { body, idempotencyKey }, attempts = 5) { let delay = 200; for (let attempt = 0; attempt < attempts; attempt++) { try { return await client.request(method, path, { body, idempotencyKey }); } catch (error) { const retryable = !(error instanceof WalletDProblem) || error.status >= 500 || error.status === 429; if (!retryable || attempt === attempts - 1) throw error; await sleep(delay + Math.random() * (delay / 2)); delay *= 2; } } } export function sendTransfer(client, orderId, from, to, amount) { // The key comes from the order, so every retry of this order reuses it. return withRetry(client, "POST", "/v1/transfers", { body: { from_user: from, to_user: to, amount }, idempotencyKey: `transfer-${orderId}`, }); } ``` ### Java ```java public JsonNode withRetry(String method, String path, Object body, String key) throws Exception { long delayMillis = 200; for (int attempt = 0; attempt < 5; attempt++) { try { return request(method, path, body, key); } catch (Problem problem) { boolean retryable = problem.status >= 500 || problem.status == 429; if (!retryable || attempt == 4) { throw problem; } Thread.sleep(delayMillis + ThreadLocalRandom.current().nextLong(delayMillis / 2)); delayMillis *= 2; } } throw new IllegalStateException("unreachable"); } public JsonNode sendTransfer(String orderId, String from, String to, long amount) throws Exception { Map body = Map.of("from_user", from, "to_user", to, "amount", amount); // The key comes from the order, so every retry of this order reuses it. return withRetry("POST", "/v1/transfers", body, "transfer-" + orderId); } ``` ## Which calls need a key Anything that moves money or creates a financial object: transfers, top-ups, payments, captures, voids, refunds, subscriptions, point conversions, marketing funding, payouts, purchases, and subscribes. Reads never need one. Configuration changes (fee plans, tier limits, reward rules) do not take one either; they are audited instead, and applying the same configuration twice is harmless by construction. ## Next - [Errors](/errors/) for the full code table and what is retryable. - [Transfers](/guides/transfers/) to put this to work. ============================================================================== # Source: https://docs.walletd.io/errors.md ============================================================================== # Errors Every failure is [RFC 7807](https://datatracker.ietf.org/doc/html/rfc7807) `application/problem+json`, with the same shape whether it came from the API gateway or from the wallet itself. ```json { "title": "Unprocessable Entity", "status": 422, "code": "insufficient_funds" } ``` **Branch on `code`.** It is the stable contract. `title` is derived from the status, and `detail` is an optional human sentence that may be absent or reworded at any time; neither is safe to parse. When `detail` is present it names the specific problem: ```json { "title": "Conflict", "status": 409, "code": "idempotency_key_reuse", "detail": "idempotency key reused with a different request" } ``` ## What each status means for you | Status | Meaning | Retry? | |---|---|---| | `400` | The request is malformed or a field is invalid | No. Fix the request | | `401` | The credential is missing, expired, or wrong | No. Get a valid credential | | `403` | Authenticated, but not allowed to do this | No | | `404` | The object does not exist, or is not yours to see | No | | `409` | Conflicts with existing state | No. See the code | | `422` | Well-formed and permitted, but refused by a money rule | No. Nothing about the request will change the answer | | `429` | Rate limited | **Yes**, after `Retry-After` | | `5xx` | Something broke on our side | **Yes**, with backoff and the same idempotency key | `422` is the one worth internalising. It means the platform understood you perfectly and said no on purpose: the balance is too low, the tier cap is reached, there is nothing to pay out. Surface it to a human; do not loop on it. ## Codes ### Authentication and authorisation | Code | Status | Meaning | |---|---|---| | `unauthenticated` | 401 | No bearer token, or the credential is invalid or expired | | `unknown_tenant` | 401 | The credential resolves to no live tenant | | `tenant_suspended` | 403 | The tenant is suspended; no money may move | | `insufficient_scope` | 403 | The credential is valid but lacks the scope for this call | | `forbidden` | 403 | The caller may not act on this object, typically another user's | | `user_token_required` | 403 | This surface only accepts a wallet user token | | `consumer_token_required` | 403 | This surface only accepts a consumer IdP token | | `platform_scope_only` | 403 | A platform operator credential may only use the tenant surfaces | | `not_client_member` | 403 | The caller is not a member of that client's organization | ### Requests and conflicts | Code | Status | Meaning | |---|---|---| | `invalid_request` | 400 | A field is missing, malformed, or out of range. `detail` names it | | `self_transfer` | 400 | Sender and recipient are the same wallet | | `idempotency_key_reuse` | 409 | The key was used before with different parameters. See [Idempotency](/idempotency/) | | `user_exists`, `merchant_exists`, `rule_exists` | 409 | That external id or rule already exists | ### Money rules | Code | Status | Meaning | |---|---|---| | `insufficient_funds` | 422 | The available balance (plus any credit line) does not cover it | | `insufficient_points` | 422 | Not enough points for the conversion | | `tier_limit_exceeded` | 422 | A tier cap would be breached. Nothing reached the processor | | `no_conversion_rule` | 422 | No active conversion rule for this tenant | | `nothing_to_pay` | 422 | A payout run with a zero settlement balance | | `not_an_item`, `not_a_plan` | 422 | Purchasing a plan, or subscribing to a one-off item | | `user_not_business` | 422 | The operation needs a business user | | `credit_limit_below_balance` | 422 | The new credit limit is below what the account has already drawn; collect first, then lower | ### Not found `user_not_found`, `sender_not_found`, `recipient_not_found`, `payer_not_found`, `merchant_not_found`, `payment_not_found`, `topup_not_found`, `subscription_not_found`, `client_not_found`, `offering_not_found`, `rule_not_found`, `event_not_found`, `tenant_not_found` — all `404`. A `404` also covers "exists, but belongs to another tenant". That is deliberate: an id you do not own must be indistinguishable from an id that does not exist, or the API becomes an enumeration oracle. ### Infrastructure | Code | Status | Meaning | |---|---|---| | `rate_limited` | 429 | Too many requests. Honour `Retry-After` | | `bulkhead_full` | 503 | The gateway's concurrency compartment for this traffic class is full. Transient: retry with backoff and the same idempotency key | | `gateway_unreachable` | 502 | The payment processor could not be reached. Your intent is untouched | | `auth_unavailable` | 503 | The credential service is unreachable | | `reporting_timeout` | 503 | The reporting window covers more movement than the view can aggregate in time; ask for a narrower window | | `org_provisioning_unavailable` | 503 | Identity provisioning is unavailable | | `internal_error` | 500 | Our fault. Retry with the same idempotency key | ## Rate limits `429` carries the headers you need to behave: ``` Retry-After: 20 X-RateLimit-Limit: 60 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1786405126 ``` Limits are layered: a per-endpoint budget and a global one both apply, and the strictest surviving policy sets the headers. Cheap reads have generous budgets; expensive money-in calls are deliberately tight. Wait out `Retry-After` rather than tightening your loop. ## Handling errors well Three things separate a good integration from one that pages someone at 3am. **Never swallow a `422`.** It carries a decision your user needs to hear. "Your balance is $12.00 and this costs $15.00" is a useful sentence; "something went wrong" is not. **Distinguish "did not happen" from "do not know".** A `4xx` is a definitive no. A timeout or `5xx` is *unknown*, and the only correct response is to retry with the same idempotency key until you get a definitive answer. **Log the `code` and the request id.** Every response carries `X-Request-Id`; include it when you ask for help and the exact request can be found. ### Example #### curl ```bash response=$(curl -sS -w '\n%{http_code}' -X POST "$WALLETD_API/v1/transfers" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: transfer-order-8891" \ -d '{"from_user":"'$SENDER'","to_user":"'$RECIPIENT'","amount":1500}') status=$(printf '%s' "$response" | tail -1) body=$(printf '%s' "$response" | sed '$d') if [ "$status" -ge 400 ]; then printf '%s' "$body" | python3 -c 'import sys,json; p=json.load(sys.stdin); print(p["code"], "-", p.get("detail",""))' fi ``` #### Go ```go var problem *walletd.Problem if errors.As(err, &problem) { switch problem.Code { case "insufficient_funds": return fmt.Errorf("top up first: %s", problem.Detail) case "tier_limit_exceeded": return errors.New("this would exceed the account's limit; verify the account to raise it") case "idempotency_key_reuse": // A bug in key derivation, not a transient failure. Page someone. return fmt.Errorf("idempotency key collision: %w", err) default: if problem.Status >= 500 || problem.Status == 429 { return retryLater(err) } return err } } ``` #### Python ```python try: client.request("POST", "/v1/transfers", body, idempotency_key=f"transfer-{order_id}") except Problem as problem: if problem.code == "insufficient_funds": raise TopUpRequired(problem.detail) from problem if problem.code == "tier_limit_exceeded": raise LimitReached("verify the account to raise its limit") from problem if problem.status >= 500 or problem.status == 429: raise RetryLater from problem raise ``` #### PHP ```php try { $client->request('POST', '/v1/transfers', $body, "transfer-{$orderId}"); } catch (WalletDProblem $problem) { match (true) { $problem->code === 'insufficient_funds' => throw new TopUpRequired($problem->detail), $problem->code === 'tier_limit_exceeded' => throw new LimitReached(), $problem->httpStatus >= 500 || $problem->httpStatus === 429 => throw new RetryLater(), default => throw $problem, }; } ``` #### JavaScript ```javascript try { await client.request("POST", "/v1/transfers", { body, idempotencyKey: `transfer-${orderId}` }); } catch (error) { if (!(error instanceof WalletDProblem)) throw error; switch (error.code) { case "insufficient_funds": throw new TopUpRequired(error.detail); case "tier_limit_exceeded": throw new LimitReached(); default: if (error.status >= 500 || error.status === 429) throw new RetryLater(); throw error; } } ``` #### Java ```java try { client.request("POST", "/v1/transfers", body, "transfer-" + orderId); } catch (WalletD.Problem problem) { switch (problem.code) { case "insufficient_funds" -> throw new TopUpRequired(problem.detail); case "tier_limit_exceeded" -> throw new LimitReached(); default -> { if (problem.status >= 500 || problem.status == 429) throw new RetryLater(); throw problem; } } } ``` ## Next - [Idempotency](/idempotency/) for what to do about the retryable ones. - [Testing](/testing/) to trigger each of these on purpose. ============================================================================== # Source: https://docs.walletd.io/guides/topups.md ============================================================================== # 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. ============================================================================== # Source: https://docs.walletd.io/guides/transfers.md ============================================================================== # Transfers: wallet to wallet A transfer moves money between two wallets in the same tenant. It settles synchronously: when the call returns `201`, the money has moved and both balances already reflect it. There is no pending state to poll. ## Send ### curl ```bash curl -sS -X POST "$WALLETD_API/v1/transfers" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: transfer-order-8891" \ -d '{"from_user": "'$SENDER'", "to_user": "'$RECIPIENT'", "amount": 1500, "note": "lunch"}' ``` ```json { "id": "019fee21-5e82-78ab-a274-772da3148c72", "status": "completed", "from_user": "019fee0e-38f0-7630-b211-19ecd59d2825", "to_user": "019fee0e-3908-7aad-8b3b-a468e0491f30", "amount": 1500, "fee": 10, "note": "lunch", "created_at": "2026-08-11T05:24:18.771Z" } ``` ### Go ```go func (c *Client) Transfer(ctx context.Context, orderID, from, to string, amount int64, note string) (*Transaction, error) { var txn Transaction body := map[string]any{"from_user": from, "to_user": to, "amount": amount, "note": note} err := c.Do(ctx, http.MethodPost, "/v1/transfers", body, &txn, "transfer-"+orderID) return &txn, err } ``` ### Python ```python def transfer(client, order_id, sender, recipient, amount, note=""): return client.request("POST", "/v1/transfers", { "from_user": sender, "to_user": recipient, "amount": amount, "note": note, }, idempotency_key=f"transfer-{order_id}") ``` ### PHP ```php function transfer(WalletD $client, string $orderId, string $from, string $to, int $amount, string $note = ''): array { return $client->request('POST', '/v1/transfers', [ 'from_user' => $from, 'to_user' => $to, 'amount' => $amount, 'note' => $note, ], "transfer-{$orderId}"); } ``` ### JavaScript ```javascript export function transfer(client, orderId, from, to, amount, note = "") { return client.request("POST", "/v1/transfers", { body: { from_user: from, to_user: to, amount, note }, idempotencyKey: `transfer-${orderId}`, }); } ``` ### Java ```java public JsonNode transfer(String orderId, String from, String to, long amount, String note) throws Exception { return client.request("POST", "/v1/transfers", Map.of( "from_user", from, "to_user", to, "amount", amount, "note", note), "transfer-" + orderId); } ``` Note the arithmetic: the sender is debited **1510** for a **1500** transfer, because this tenant charges a 10-minor-unit P2P fee. The recipient always receives exactly `amount`; the fee is charged on top, to the sender. Show `amount + fee` on the sender's confirmation screen or your numbers will not match their balance. For the full ledger postings behind a transfer, read the transaction back from `/v1/transactions` or the user's history. ## Send to an alias Users rarely know each other's UUIDs. Send to a handle, phone, or email instead of `to_user`: ```bash curl -sS -X POST "$WALLETD_API/v1/transfers" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: transfer-order-8892" \ -d '{"from_user": "'$SENDER'", "to_alias": {"handle": "ada"}, "amount": 1500}' ``` `to_alias` takes exactly one of `handle`, `phone`, or `email`. An alias that matches nothing is `404 recipient_not_found`; pass `to_user` when you already have the id, since it skips a lookup. ## Metadata `metadata` is a flat string map that rides along with the transaction and comes back on reads. Use it for your own correlation ids: ```json { "from_user": "019f...", "to_alias": { "handle": "ada" }, "amount": 1500, "metadata": { "order_id": "8891", "channel": "ios" } } ``` Do not put anything secret in it. It is returned to anyone who can read the transaction, which includes support staff in the back office. ## What can go wrong | Code | Status | What happened | |---|---|---| | `insufficient_funds` | 422 | Available balance plus any credit line does not cover `amount` + fee | | `tier_limit_exceeded` | 422 | The sender's daily send limit would be breached | | `self_transfer` | 400 | Sender and recipient are the same wallet | | `sender_not_found` | 404 | `from_user` is not a user in this tenant | | `recipient_not_found` | 404 | `to_user` or `to_alias` matched nothing | | `idempotency_key_reuse` | 409 | Same key, different parameters. See [Idempotency](/idempotency/) | `insufficient_funds` and `tier_limit_exceeded` are decisions, not failures. Show them to the user: one means "top up first", the other means "verify the account to raise the limit". ## From a user token A wallet app usually sends transfers on the user's own token rather than through your backend. The call is identical, but the token confines it: `from_user` must be that user. Any other value is `403 forbidden`, whatever scopes the token holds. That is the point of user tokens. Your app cannot move someone else's money even if it is compromised. See [Authentication](/authentication/). ## Velocity limits Send volume is summed per user per day and checked inside the transfer's own transaction, so eight simultaneous sends against a five-send cap land exactly five. You cannot beat it by racing. See [Limits](/guides/limits/). ## Next - [Payments and refunds](/guides/payments/) for paying a merchant rather than a person. - [Limits](/guides/limits/) for the caps. ============================================================================== # Source: https://docs.walletd.io/guides/payments.md ============================================================================== # Payments and refunds A payment moves money from a user's wallet to a merchant's settlement account, minus a platform fee. There are two modes, and picking the right one is most of the integration. | Mode | Use when | Money moves | |---|---|---| | `instant` | You know the final amount now: a coffee, a ticket, a checkout | Immediately | | `authorize` | The final amount comes later: a hotel, a fuel pump, a rental | Reserved now, captured later | ## Register the merchant first A payment needs a merchant to pay. Register each one once. ```bash curl -sS -X POST "$WALLETD_API/v1/merchants" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -d '{"external_id": "cafe-9", "name": "Bloom Coffee", "category": "restaurants"}' ``` `category` is what reward rules can scope to, so set it even if you have no rules yet. ## Instant payment ### curl ```bash curl -sS -X POST "$WALLETD_API/v1/payments" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: payment-order-4471" \ -d '{ "payer_user_id": "'$USER_ID'", "merchant_id": "'$MERCHANT_ID'", "amount": 1840, "mode": "instant", "line_items": [{"name": "Flat white", "amount": 450}, {"name": "Sandwich", "amount": 1390}] }' ``` ```json { "id": "019fee0f-4e5f-7a6b-8c7d-9e0f1a2b3c4d", "status": "captured", "mode": "instant", "payer_user_id": "019f...", "merchant_id": "019f...", "amount": 1840, "captured_amount": 1840, "refunded_amount": 0, "fee": 19, "line_items": [{ "name": "Flat white", "amount": 450 }, { "name": "Sandwich", "amount": 1390 }], "created_at": "2026-08-11T05:31:07.442Z" } ``` ### Go ```go type Payment struct { ID string `json:"id"` Status string `json:"status"` Amount int64 `json:"amount"` CapturedAmount int64 `json:"captured_amount"` RefundedAmount int64 `json:"refunded_amount"` Fee int64 `json:"fee"` } func (c *Client) Charge(ctx context.Context, orderID, payer, merchant string, amount int64, items []map[string]any) (*Payment, error) { var payment Payment body := map[string]any{ "payer_user_id": payer, "merchant_id": merchant, "amount": amount, "mode": "instant", "line_items": items, } err := c.Do(ctx, http.MethodPost, "/v1/payments", body, &payment, "payment-"+orderID) return &payment, err } ``` ### Python ```python def charge(client, order_id, payer, merchant, amount, line_items=None): return client.request("POST", "/v1/payments", { "payer_user_id": payer, "merchant_id": merchant, "amount": amount, "mode": "instant", "line_items": line_items or [], }, idempotency_key=f"payment-{order_id}") ``` ### PHP ```php function charge(WalletD $client, string $orderId, string $payer, string $merchant, int $amount, array $lineItems = []): array { return $client->request('POST', '/v1/payments', [ 'payer_user_id' => $payer, 'merchant_id' => $merchant, 'amount' => $amount, 'mode' => 'instant', 'line_items' => $lineItems, ], "payment-{$orderId}"); } ``` ### JavaScript ```javascript export function charge(client, orderId, payer, merchant, amount, lineItems = []) { return client.request("POST", "/v1/payments", { body: { payer_user_id: payer, merchant_id: merchant, amount, mode: "instant", line_items: lineItems }, idempotencyKey: `payment-${orderId}`, }); } ``` ### Java ```java public JsonNode charge(String orderId, String payer, String merchant, long amount, List> lineItems) throws Exception { return client.request("POST", "/v1/payments", Map.of( "payer_user_id", payer, "merchant_id", merchant, "amount", amount, "mode", "instant", "line_items", lineItems), "payment-" + orderId); } ``` `fee` is the platform's cut, taken from the merchant's side: the payer is debited `amount`, the merchant is credited `amount - fee`. Fees are basis points and **round up**, so a 1% fee on 650 is 7, not 6. `line_items` must sum to `amount`. They are for the receipt and the support screen; the money moves as one posting. ## Authorize and capture When the final amount is not known yet, hold the money and capture later. ```mermaid stateDiagram-v2 [*] --> requires_capture: authorize requires_capture --> captured: capture (up to the held amount) requires_capture --> voided: void requires_capture --> expired: hold TTL passes captured --> [*] voided --> [*] expired --> [*] ``` ### Place the hold ```bash curl -sS -X POST "$WALLETD_API/v1/payments" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: payment-booking-771" \ -d '{ "payer_user_id": "'$USER_ID'", "merchant_id": "'$MERCHANT_ID'", "amount": 65000, "mode": "authorize", "hold_ttl_seconds": 172800 }' ``` The payment comes back `requires_capture` and the user's `held` rises by 65000 while `available` falls by the same. The money is still theirs; it is spoken for. `hold_ttl_seconds` is how long you have. When it passes, the hold is released automatically and the payment becomes `expired`. Set it to slightly longer than your real business window: a two-day hotel hold, a one-hour ride. ### Capture ```bash # Full amount curl -sS -X POST "$WALLETD_API/v1/payments/$PAYMENT_ID/capture" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: capture-booking-771" \ -d '{}' # Or less than was held: the difference is released, not kept curl -sS -X POST "$WALLETD_API/v1/payments/$PAYMENT_ID/capture" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: capture-booking-771" \ -d '{"amount": 58000}' ``` You can capture **up to** the authorized amount, never more. Capturing less releases the remainder immediately. ### Void ```bash curl -sS -X POST "$WALLETD_API/v1/payments/$PAYMENT_ID/void" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: void-booking-771" \ -d '{}' ``` Releases the hold and ends the payment. Use it when the booking is cancelled before service. #### Go ```go // Authorize now, capture the real amount when checkout happens. payment, err := c.Authorize(ctx, "booking-771", payer, merchant, 65000, 48*time.Hour) if err != nil { return err } // ... the stay happens ... final, err := c.Capture(ctx, payment.ID, "capture-booking-771", 58000) if err != nil { return err } // final.CapturedAmount == 58000; the remaining 7000 is back in available. ``` ```go func (c *Client) Authorize(ctx context.Context, orderID, payer, merchant string, amount int64, ttl time.Duration) (*Payment, error) { var payment Payment body := map[string]any{ "payer_user_id": payer, "merchant_id": merchant, "amount": amount, "mode": "authorize", "hold_ttl_seconds": int(ttl.Seconds()), } err := c.Do(ctx, http.MethodPost, "/v1/payments", body, &payment, "payment-"+orderID) return &payment, err } // Capture takes amount == 0 to mean the full authorized amount. func (c *Client) Capture(ctx context.Context, paymentID, key string, amount int64) (*Payment, error) { body := map[string]any{} if amount > 0 { body["amount"] = amount } var payment Payment err := c.Do(ctx, http.MethodPost, "/v1/payments/"+paymentID+"/capture", body, &payment, key) return &payment, err } ``` #### Python ```python def authorize(client, order_id, payer, merchant, amount, hold_seconds): return client.request("POST", "/v1/payments", { "payer_user_id": payer, "merchant_id": merchant, "amount": amount, "mode": "authorize", "hold_ttl_seconds": hold_seconds, }, idempotency_key=f"payment-{order_id}") def capture(client, payment_id, key, amount=None): body = {"amount": amount} if amount is not None else {} return client.request("POST", f"/v1/payments/{payment_id}/capture", body, idempotency_key=key) def void(client, payment_id, key): return client.request("POST", f"/v1/payments/{payment_id}/void", {}, idempotency_key=key) ``` #### PHP ```php function authorize(WalletD $client, string $orderId, string $payer, string $merchant, int $amount, int $holdSeconds): array { return $client->request('POST', '/v1/payments', [ 'payer_user_id' => $payer, 'merchant_id' => $merchant, 'amount' => $amount, 'mode' => 'authorize', 'hold_ttl_seconds' => $holdSeconds, ], "payment-{$orderId}"); } function capture(WalletD $client, string $paymentId, string $key, ?int $amount = null): array { return $client->request('POST', "/v1/payments/{$paymentId}/capture", $amount === null ? [] : ['amount' => $amount], $key); } ``` #### JavaScript ```javascript export function authorize(client, orderId, payer, merchant, amount, holdSeconds) { return client.request("POST", "/v1/payments", { body: { payer_user_id: payer, merchant_id: merchant, amount, mode: "authorize", hold_ttl_seconds: holdSeconds }, idempotencyKey: `payment-${orderId}`, }); } export function capture(client, paymentId, key, amount) { return client.request("POST", `/v1/payments/${paymentId}/capture`, { body: amount === undefined ? {} : { amount }, idempotencyKey: key, }); } ``` #### Java ```java public JsonNode authorize(String orderId, String payer, String merchant, long amount, long holdSeconds) throws Exception { return client.request("POST", "/v1/payments", Map.of( "payer_user_id", payer, "merchant_id", merchant, "amount", amount, "mode", "authorize", "hold_ttl_seconds", holdSeconds), "payment-" + orderId); } public JsonNode capture(String paymentId, String key, Long amount) throws Exception { Object body = amount == null ? Map.of() : Map.of("amount", amount); return client.request("POST", "/v1/payments/" + paymentId + "/capture", body, key); } ``` ## Refunds Refund a captured payment, fully or partly. Cumulative refunds can never exceed what was captured. ```bash curl -sS -X POST "$WALLETD_API/v1/refunds" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: refund-order-4471-1" \ -d '{"payment_id": "'$PAYMENT_ID'", "amount": 950, "reason": "wrong item"}' ``` Two partial refunds of 600 against a 1000 capture: the first succeeds, the second is refused, because 1200 > 1000. Give each refund its own idempotency key; they are separate operations. Refunds reverse proportionally. The fee comes back, and if the payment earned cashback, the cashback is clawed back with it. You do not have to unwind anything yourself. ### How a refund looks when you reconcile Worth knowing before you write a reconciliation, because the obvious guess is wrong: **a refund does not erase or amend the capture.** The original payment transaction stays exactly as it was, and the refund appears as its own transaction of type `refund` carrying a `reference_id` back to the payment. So in `GET /v1/transactions` you will see both, and your gross volume still includes the full capture. That is deliberate. A ledger that rewrote the capture would make the money right and the history wrong, and you would have no way to answer "how much did we sell in March" once refunds arrived in April. Practically: - **Net a payment from the payment**, not from the ledger: `captured_amount - refunded_amount - fee`. Both fields are on the payment and both are authoritative. - **Match a refund to its payment by `reference_id`.** It is not linked by any reversal field. - **Expect two transactions per refunded sale**, and do not treat the second as a duplicate of the first. ## Reading a payment back ```bash curl -sS "$WALLETD_API/v1/payments/$PAYMENT_ID" -H "Authorization: Bearer $WALLETD_API_KEY" ``` `captured_amount` and `refunded_amount` are the two numbers your reconciliation cares about. Net revenue for a payment is `captured_amount - refunded_amount - fee`. ## What can go wrong | Code | Status | What happened | |---|---|---| | `insufficient_funds` | 422 | Not enough available balance plus credit line | | `payer_not_found` / `merchant_not_found` | 404 | Bad id, or not yours | | `payment_not_found` | 404 | Bad payment id on capture, void, or refund | | `invalid_request` | 400 | Capture above the authorized amount, refund above the captured amount, or line items that do not sum to `amount` | Capturing an already-captured payment with the same key returns the original result. Voiding a captured payment is an error: the money is gone, so refund it instead. ## Next - [Rewards](/guides/rewards/) for cashback and points on capture. - [Subscriptions](/guides/subscriptions/) for recurring charges. - [Payouts](/guides/payouts/) to get the merchant's settlement balance out. ============================================================================== # Source: https://docs.walletd.io/guides/subscriptions.md ============================================================================== # Subscriptions and dunning A subscription charges a wallet on a schedule. The design centre is worth knowing before you write any code: **a billing period can be charged at most once, structurally**, no matter how many retries, replicas, or crashes intervene. You do not need to guard against double billing yourself. ## Create one ```bash curl -sS -X POST "$WALLETD_API/v1/subscriptions" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: sub-user-42-foodpass" \ -d '{ "user_id": "'$USER_ID'", "merchant_id": "'$MERCHANT_ID'", "plan_name": "FoodPass", "amount": 999, "interval": "monthly" }' ``` ```json { "id": "019fee10-5f6a-7b7c-8d8e-9f0a1b2c3d4e", "user_id": "019f...", "merchant_id": "019f...", "plan_name": "FoodPass", "amount": 999, "interval": "monthly", "status": "active", "next_charge_at": "2026-08-11T05:35:00Z", "retry_count": 0, "created_at": "2026-08-11T05:35:00.118Z" } ``` `interval` is `weekly` or `monthly`. Pass `start_at` to delay the first charge; omit it and the first charge happens on the next sweep, which is how you make a trial end. Charges happen on a background sweep, so `status` is `active` before any money has moved. Watch `subscription.charged` to know a period was actually paid. ### Go ```go func (c *Client) Subscribe(ctx context.Context, userID, merchantID, plan string, amount int64, interval string) (*Subscription, error) { var sub Subscription body := map[string]any{ "user_id": userID, "merchant_id": merchantID, "plan_name": plan, "amount": amount, "interval": interval, } // One subscription per user per plan: the key says exactly that. key := fmt.Sprintf("sub-%s-%s", userID, plan) err := c.Do(ctx, http.MethodPost, "/v1/subscriptions", body, &sub, key) return &sub, err } ``` ### Python ```python def subscribe(client, user_id, merchant_id, plan, amount, interval="monthly"): return client.request("POST", "/v1/subscriptions", { "user_id": user_id, "merchant_id": merchant_id, "plan_name": plan, "amount": amount, "interval": interval, }, idempotency_key=f"sub-{user_id}-{plan}") ``` ### PHP ```php function subscribe(WalletD $client, string $userId, string $merchantId, string $plan, int $amount, string $interval = 'monthly'): array { return $client->request('POST', '/v1/subscriptions', [ 'user_id' => $userId, 'merchant_id' => $merchantId, 'plan_name' => $plan, 'amount' => $amount, 'interval' => $interval, ], "sub-{$userId}-{$plan}"); } ``` ### JavaScript ```javascript export function subscribe(client, userId, merchantId, plan, amount, interval = "monthly") { return client.request("POST", "/v1/subscriptions", { body: { user_id: userId, merchant_id: merchantId, plan_name: plan, amount, interval }, idempotencyKey: `sub-${userId}-${plan}`, }); } ``` ### Java ```java public JsonNode subscribe(String userId, String merchantId, String plan, long amount, String interval) throws Exception { return client.request("POST", "/v1/subscriptions", Map.of( "user_id", userId, "merchant_id", merchantId, "plan_name", plan, "amount", amount, "interval", interval), "sub-" + userId + "-" + plan); } ``` ## What happens when the wallet is empty Renewal day arrives and the balance is short. The subscription enters dunning rather than failing outright. ```mermaid stateDiagram-v2 active --> past_due: charge failed (retry 1, +1 day) past_due --> past_due: retry 2 (+2 days), retry 3 (+2 days) past_due --> active: any retry succeeds past_due --> paused: retry 3 failed paused --> active: resume active --> canceled: cancel past_due --> canceled: cancel ``` Three retries at +1, +2, +2 days. Each transition emits a webhook, so you can email the user at exactly the right moment: | Event | What to tell the user | |---|---| | `subscription.retry_scheduled` | "We could not charge your wallet. We will try again on the 14th." | | `subscription.paused` | "Your FoodPass is paused. Top up and resume to restart it." | | `subscription.charged` | Receipt | ## Pause, resume, cancel ```bash curl -sS -X POST "$WALLETD_API/v1/subscriptions/$SUB_ID/pause" \ -H "Authorization: Bearer $WALLETD_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: pause-$SUB_ID-1" -d '{}' curl -sS -X POST "$WALLETD_API/v1/subscriptions/$SUB_ID/resume" \ -H "Authorization: Bearer $WALLETD_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: resume-$SUB_ID-1" -d '{}' curl -sS -X POST "$WALLETD_API/v1/subscriptions/$SUB_ID/cancel" \ -H "Authorization: Bearer $WALLETD_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: cancel-$SUB_ID-1" -d '{}' ``` > **Warning** > > **Resume does not bill for missed periods.** The billing period re-anchors at > the moment of resume, so a subscription paused for three months does not charge > three months on the way back. Users hate catch-up charges and they generate > chargebacks; if you need to bill for a gap, do it as an explicit payment the > user agreed to. Cancel is terminal. To offer "cancel at period end", cancel when you receive the last `subscription.charged` for the period you sold. ## Statuses | Status | Meaning | |---|---| | `active` | Charging on schedule | | `past_due` | A charge failed; retries are scheduled. `retry_count` says which attempt is next | | `paused` | Dunning gave up, or someone paused it. Nothing is charged | | `canceled` | Terminal | ## Listing ```bash curl -sS "$WALLETD_API/v1/subscriptions?limit=50" -H "Authorization: Bearer $WALLETD_API_KEY" curl -sS "$WALLETD_API/v1/subscriptions/$SUB_ID" -H "Authorization: Bearer $WALLETD_API_KEY" ``` ## Why you cannot be double-charged The idempotency key for a charge is derived internally from the subscription id and the billing period anchor, and the anchor only moves on a successful charge. Every retry of a failed month computes the same key. Two servers sweeping the same subscription at the same moment compute the same key. A crash after charging but before the bookkeeping replays the stored result instead of charging again. "At most one charge per period" is not careful coordination. It is what the idempotency layer does to anyone who asks twice with the same key. See [Idempotency](/idempotency/). ## Next - [Payments and refunds](/guides/payments/), which is what a charge actually is. - [Webhooks](/webhooks/) for the dunning events. ============================================================================== # Source: https://docs.walletd.io/guides/rewards.md ============================================================================== # Rewards: cashback, points, conversion Rewards are rules, not calls. You do not grant cashback; you write a rule and the platform evaluates it on every capture. That keeps the arithmetic, the caps, and the refund clawbacks out of your code. Three kinds: | Kind | Effect | |---|---| | `cashback` | Credits cash back to the payer | | `accrual` | Credits points, a separate commodity | | `conversion` | Defines the rate at which points may become cash | ## Cashback ```bash curl -sS -X POST "$WALLETD_API/v1/reward_rules" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "kind": "cashback", "params": {"rate_bps": 1000}, "scope": {"categories": ["restaurants"]}, "caps": {"per_user_day": 500} }' ``` 10% (1000 basis points) back on restaurant payments, capped at $5.00 per user per day. > **Warning** > > **Always set `per_user_day`.** The cap is what makes a rule safe to run. > Without one, a rule is an unbounded promise: a user who spends $10,000 in a day > gets $1,000 of your money. Caps are enforced under concurrency, not best-effort. Eight simultaneous captures against a $5.00 daily cap grant exactly $5.00 in total. ## Points ```bash curl -sS -X POST "$WALLETD_API/v1/reward_rules" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -d '{"kind": "accrual", "params": {"rate_bps": 100}, "scope": {"categories": ["restaurants"]}}' ``` One point per 100 minor units spent. Points are a genuinely separate commodity in a separate account, not cash with a label, and they show up as their own balance: ```json [ { "purpose": "cash", "commodity": "USD", "balance": 2460, "available": 2460, "held": 0 }, { "purpose": "points", "commodity": "POINTS", "balance": 80, "available": 80, "held": 0 } ] ``` ## Conversion Points only become cash through an explicit conversion, governed by a rule: ```bash curl -sS -X POST "$WALLETD_API/v1/reward_rules" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -d '{"kind": "conversion", "params": {"minor_per_point": 1, "min_points": 500}}' ``` Then a user converts: ```bash curl -sS -X POST "$WALLETD_API/v1/rewards/convert" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: convert-user-42-1" \ -d '{"user_id": "'$USER_ID'", "points": 650}' ``` 650 points become 650 minor units of cash; the points are debited and the cash credited in one transaction. Below `min_points` it is refused with `422 insufficient_points`; with no conversion rule at all, `422 no_conversion_rule`. That minimum is not bureaucracy. Without a floor, a points programme becomes a currency with a fixed peg, and every accounting question you were avoiding comes back. ### Go ```go func (c *Client) ConvertPoints(ctx context.Context, userID string, points int64, key string) (*Transaction, error) { var txn Transaction body := map[string]any{"user_id": userID, "points": points} err := c.Do(ctx, http.MethodPost, "/v1/rewards/convert", body, &txn, key) return &txn, err } ``` ### Python ```python def convert_points(client, user_id, points, key): return client.request("POST", "/v1/rewards/convert", {"user_id": user_id, "points": points}, idempotency_key=key) ``` ### PHP ```php function convertPoints(WalletD $client, string $userId, int $points, string $key): array { return $client->request('POST', '/v1/rewards/convert', ['user_id' => $userId, 'points' => $points], $key); } ``` ### JavaScript ```javascript export function convertPoints(client, userId, points, key) { return client.request("POST", "/v1/rewards/convert", { body: { user_id: userId, points }, idempotencyKey: key, }); } ``` ### Java ```java public JsonNode convertPoints(String userId, long points, String key) throws Exception { return client.request("POST", "/v1/rewards/convert", Map.of("user_id", userId, "points", points), key); } ``` ## Scopes and caps ```json { "kind": "cashback", "params": { "rate_bps": 500 }, "scope": { "categories": ["restaurants", "transport"], "merchant_ids": ["019f..."] }, "caps": { "per_user_day": 500, "per_user_month": 5000 }, "starts_at": "2026-09-01T00:00:00Z", "ends_at": "2026-09-30T23:59:59Z" } ``` `scope` narrows which payments qualify: by merchant category, by specific merchants, or both. `starts_at` and `ends_at` make a rule a campaign that switches itself on and off, so you do not need a cron job to end a promotion. ## Reading and stopping ```bash curl -sS "$WALLETD_API/v1/reward_rules" -H "Authorization: Bearer $WALLETD_API_KEY" curl -sS "$WALLETD_API/v1/users/$USER_ID/rewards" -H "Authorization: Bearer $WALLETD_API_KEY" curl -sS -X POST "$WALLETD_API/v1/reward_rules/$RULE_ID/disable" \ -H "Authorization: Bearer $WALLETD_API_KEY" ``` Disabling stops future grants and touches nothing already granted. Money that reached a user is theirs. The response carries `disabled_at`, and that timestamp is not decoration: it is what decides whether a sale that was already made still earns. See below. ## Refunds claw back Refund a payment that earned cashback and the cashback is reversed proportionally, in the same transaction as the refund. You do not track it, and you cannot end up with a user who kept the reward for a purchase they returned. ## Rewards are evaluated after capture, against the rules in force at capture Grants happen on a background job triggered by the capture, so `payment.captured` can arrive slightly before `cashback.granted`. If your UI shows a reward, read the balance again on `cashback.granted` rather than assuming it is already there when the payment settles. The important half is what that job reads. **A sale earns what your rulebook said at the instant it was captured, not what it says when the job runs.** Everything follows from that one sentence: | You do this | A sale captured before it | A sale captured after it | |---|---|---| | Publish a rule | earns nothing from it | earns | | Disable a rule | still earns | earns nothing | | A rule reaches its `ends_at` | still earns | earns nothing | So publishing a promotion never pays out on yesterday's sales, and pulling one never claws back cashback from a sale made while it was live, even if that sale's grant has not landed yet. Both directions hold under load, when the job may run seconds or minutes behind the capture. This is worth knowing precisely because the alternative is so easy to build by accident. If the job read your rules at the moment it ran, what a sale earned would depend on how busy the platform was, and you would have no way to explain a customer's balance to them. The one asymmetry: a rule disabled before `disabled_at` existed carries no timestamp, and those are excluded rather than given an invented one. ## Client-funded rewards In the ecosystem model, a client can fund cashback from its own marketing budget rather than the platform's. A rule pinned to a funding merchant draws down that merchant's marketing account, and when the budget runs dry the grant is skipped rather than falling back to platform money. A client's promise never spends someone else's balance. See [Ecosystem](/guides/ecosystem/). ## Next - [Payments and refunds](/guides/payments/), which is what triggers evaluation. - [Ecosystem](/guides/ecosystem/) for client-funded budgets. ============================================================================== # Source: https://docs.walletd.io/guides/limits.md ============================================================================== # Limits and credit lines Two controls sit on either side of a wallet's balance. Tier limits cap how much money may flow; credit lines let specific accounts go below zero. ## Tier limits Every user has a tier. A tier carries caps, and the caps are enforced before anything irreversible happens. ```bash curl -sS -X PUT "$WALLETD_API/v1/tier_limits" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -d '{"tier": "unverified", "max_balance": 3500, "topup_per_day": 5000, "p2p_per_day": 5000}' ``` ```bash curl -sS -X POST "$WALLETD_API/v1/users/$USER_ID/tier" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -d '{"tier": "unverified"}' ``` New users are `standard` unless you say otherwise. > **Warning** > > **An absent tier row, or a zero field, means unlimited.** A tier you never > configured constrains nothing. That is the safe default for a platform that has > not decided yet, but it does mean "I set a tier" and "I set a limit" are two > separate acts. | Field | Caps | |---|---| | `max_balance` | The highest cash balance this tier may hold | | `topup_per_day` | Money in per rolling day | | `p2p_per_day` | Money sent per rolling day | ### When they bite **Top-up caps are checked before the processor is called.** An over-cap top-up returns `422 tier_limit_exceeded` and *no payment intent is ever created*. The user is not charged for money the wallet was always going to refuse, and you never have to reverse anything. **P2P caps are checked inside the transfer's own transaction**, under locks taken in a fixed order. Eight simultaneous sends against a five-send cap land exactly five. You cannot beat the cap by racing it. Both refusals are `422`: the request was understood and permitted, and refused on purpose. Show the user what to do about it, usually "verify your account to raise this limit". ```bash curl -sS "$WALLETD_API/v1/tier_limits" -H "Authorization: Bearer $WALLETD_API_KEY" ``` Configuration and tier assignment are both audited, with the actor recorded. Neither accepts a user token: a user must never be able to raise their own limit. ## Credit lines A credit line lets one account spend below zero, down to a limit. This is how a business account books inventory before it has been paid. ```bash curl -sS -X POST "$WALLETD_API/v1/users/$USER_ID/credit_limit" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -d '{"credit_limit": 500000, "reason": "Q3 agent line, approved by finance"}' ``` $5,000 of headroom. The user can now spend to `-500000` and no further; the next unit over is `422 insufficient_funds`, exactly as if they had run out of cash. Three things to know: - **`available` already accounts for it.** A user with a zero balance and a $5,000 line has `available` of 500000. Your existing balance check keeps working. - **A negative balance is not an error.** Do not paint it red just for being below zero. Show the line and the headroom. - **It is audited and API-key-only.** `credit:write` scope, never a user token, and every change records who did it and why. `reason` is not decoration; it is what an auditor reads a year later. `credit_limit.changed` fires on every change, which is your hook for notifying the account manager. ## Where limits sit in the flow ```mermaid flowchart TD A[POST /v1/topups] --> B{tier cap ok?} B -->|no| C[422 tier_limit_exceeded, no intent created] B -->|yes| D[create processor intent] D --> E[user pays] --> F[verified confirmation] --> G{max_balance ok?} G -->|no| H[refused] G -->|yes| I[wallet credited] ``` ## Next - [Top-ups](/guides/topups/) for the money-in path these caps guard. - [Transfers](/guides/transfers/) for the velocity checks. ============================================================================== # Source: https://docs.walletd.io/guides/payouts.md ============================================================================== # Payouts: money out A merchant earns into a settlement account. A payout moves that balance out of the loop and produces the instruction to pay it. This is a **semi-closed** wallet: consumers cannot cash out to a bank. Money leaves through merchant payouts and nowhere else. That is a product decision with regulatory consequences, not a missing feature. ## Books first, transfer second ```mermaid sequenceDiagram participant You participant W as WalletD participant B as Your bank rail You->>W: POST /v1/clients/{id}/payouts (Idempotency-Key) W->>W: DR settlement / CR treasury, one transaction W-->>You: payout + statement row You->>B: execute the transfer named in the statement ``` The ledger moves first. The payout record *is* the instruction to make a real transfer, and it carries `external_ref` for the reference your bank rail gives back. That ordering is deliberate. Books that lead reality can be reconciled; books that trail it cannot answer "did we already pay this?" during an incident. ## Run one ```bash curl -sS -X POST "$WALLETD_API/v1/clients/$CLIENT_ID/payouts" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: payout-2026-08-11-$CLIENT_ID" \ -d '{}' ``` ```json { "id": "019fee0d-be2a-7090-97ef-c1f7c3162ef6", "client_id": "019f...", "amount": 2000, "rail": "manual", "status": "completed", "external_ref": null, "created_at": "2026-08-11T05:41:52.006Z" } ``` An empty body pays out **the full settlement balance**. The settlement account is zero afterwards, and a second run against a zero balance is refused with `422 nothing_to_pay` rather than creating an empty payout. Derive the idempotency key from the payout *period*, not the moment: `payout-2026-08-11-{client}` retried after a timeout pays once, where a timestamped key would pay twice. ### Go ```go func (c *Client) RunPayout(ctx context.Context, clientID, period string) (*Payout, error) { var payout Payout // The key is the period, so a retry after a timeout cannot pay twice. key := "payout-" + period + "-" + clientID err := c.Do(ctx, http.MethodPost, "/v1/clients/"+clientID+"/payouts", map[string]any{}, &payout, key) return &payout, err } ``` ### Python ```python def run_payout(client, client_id, period): # The key is the period, so a retry after a timeout cannot pay twice. return client.request("POST", f"/v1/clients/{client_id}/payouts", {}, idempotency_key=f"payout-{period}-{client_id}") ``` ### PHP ```php function runPayout(WalletD $client, string $clientId, string $period): array { // The key is the period, so a retry after a timeout cannot pay twice. return $client->request('POST', "/v1/clients/{$clientId}/payouts", [], "payout-{$period}-{$clientId}"); } ``` ### JavaScript ```javascript export function runPayout(client, clientId, period) { // The key is the period, so a retry after a timeout cannot pay twice. return client.request("POST", `/v1/clients/${clientId}/payouts`, { body: {}, idempotencyKey: `payout-${period}-${clientId}`, }); } ``` ### Java ```java public JsonNode runPayout(String clientId, String period) throws Exception { // The key is the period, so a retry after a timeout cannot pay twice. return client.request("POST", "/v1/clients/" + clientId + "/payouts", Map.of(), "payout-" + period + "-" + clientId); } ``` ## List and reconcile ```bash curl -sS "$WALLETD_API/v1/clients/$CLIENT_ID/payouts" -H "Authorization: Bearer $TOKEN" curl -sS "$WALLETD_API/v1/merchants/$MERCHANT_ID/statement" -H "Authorization: Bearer $WALLETD_API_KEY" ``` The statement is the merchant-facing document: what they earned, what was deducted, what was paid out, and when. It is what a merchant's finance team reconciles against their own books. ## Rails `rail` is `manual` today: the payout is a ledger movement plus an instruction a human or a treasury job executes. `external_ref` is where you record the bank reference once the transfer is made, which closes the loop for reconciliation. The field is free-form on purpose. When a local payout rail is added, existing payouts keep their shape and your integration does not change: the same call, a different `rail` value, and `external_ref` filled in automatically instead of by you. ## The float, before and after A payout shrinks both sides of the float equation at once: settlement goes down, treasury goes up, and money that left the loop is accounted for in treasury rather than vanishing. ```bash curl -sS "$WALLETD_API/v1/ledger/summary" -H "Authorization: Bearer $WALLETD_API_KEY" ``` If you reconcile against your bank, this is the endpoint that tells you what should have left. See [The money model](/money-model/). ## What can go wrong | Code | Status | What happened | |---|---|---| | `nothing_to_pay` | 422 | The settlement balance is zero | | `not_client_member` | 403 | The caller is not a member of that client's organization | | `client_not_found` | 404 | Bad client id, or not visible to you | ## Next - [The money model](/money-model/) for the float equation. - [Ecosystem](/guides/ecosystem/) for how a client earns a settlement balance. ============================================================================== # Source: https://docs.walletd.io/guides/ecosystem.md ============================================================================== # The ecosystem: clients, offerings, discovery Everything so far assumed one business integrating a wallet for its own users. The ecosystem is the other shape: many businesses in one shared consumer loop, where a consumer holds a single wallet and spends it at any of them. If you are building a closed-loop wallet for your own users, you can skip this page. ## The pieces | Piece | What it is | |---|---| | **Client** | A business in the loop. Owns a merchant, a settlement account, and a marketing budget | | **Offering** | Something a client sells: an `item` (one-off) or a `plan` (recurring) | | **Discovery** | The consumer-facing directory of published offerings | Client staff authority is **organization membership**, not a role you assign. A person's token carries the organizations they belong to, and every client surface checks membership per call. One human can belong to several organizations, so no "switch account" state exists on the server. ## Onboard a client A consumer becomes a business in one call, on their own token: ```bash curl -sS -X POST "$WALLETD_API/v1/clients" \ -H "Authorization: Bearer $CONSUMER_TOKEN" \ -H "Content-Type: application/json" \ -d '{"display_name": "Bloom Coffee", "category": "restaurants", "description": "Specialty coffee"}' ``` One idempotent call creates the organization, a merchant with its settlement account, a floor-enforced marketing account, and the catalog profile. Retrying with the same name converges on the same client rather than making a second one. One gotcha worth planning for: **the founder's existing token does not yet carry the new organization.** Refresh the token (or prompt for a fresh login) immediately after onboarding, rather than letting them hit a confusing `403` against a business that demonstrably exists. A new client is created `pending`. It can do everything except reach an audience: edit its profile, create offerings, price them, edit them, list them. Publishing waits for the platform to approve it, and until then `POST .../publish` returns `409 client_not_approved` and the client is absent from discovery. Show that state in your UI rather than letting a merchant discover it by clicking. Once you have a client, find it again with: ```bash curl -sS "$WALLETD_API/v1/clients" -H "Authorization: Bearer $FOUNDER_TOKEN" ``` This resolves from the organizations in the caller's token, so you never have to store the client id yourself. ## Publish an offering ```bash # Draft curl -sS -X POST "$WALLETD_API/v1/clients/$CLIENT_ID/offerings" \ -H "Authorization: Bearer $FOUNDER_TOKEN" \ -H "Content-Type: application/json" \ -d '{"kind": "item", "title": "Flat White", "amount": 450}' # Recurring curl -sS -X POST "$WALLETD_API/v1/clients/$CLIENT_ID/offerings" \ -H "Authorization: Bearer $FOUNDER_TOKEN" \ -H "Content-Type: application/json" \ -d '{"kind": "plan", "title": "Coffee Club", "amount": 1500, "interval": "monthly"}' # Publish curl -sS -X POST "$WALLETD_API/v1/clients/$CLIENT_ID/offerings/$OFFERING_ID/publish" \ -H "Authorization: Bearer $FOUNDER_TOKEN" ``` Lifecycle is `draft` to `published` to `archived`. Only published offerings of active clients are discoverable, and publishing twice is `409`. Offerings can be edited in place, which matters because archiving is final and an archived offering can never be republished: ```bash curl -sS -X PUT "$WALLETD_API/v1/clients/$CLIENT_ID/offerings/$OFFERING_ID" \ -H "Authorization: Bearer $FOUNDER_TOKEN" \ -H "Content-Type: application/json" \ -d '{"title": "Flat White", "amount": 450, "image_url": "https://example.com/flat-white.jpg"}' ``` A draft can change anything. A published **plan** can change how it presents itself (title, description, image, metadata) but **not its price**, because subscribers are already paying it; repricing one is `400`, so archive it and publish a replacement. A published **item** may be repriced, and every change is recorded in its price history. Items also carry variants, cost-plus pricing and stock; see [Products, stock, orders and import](/guides/commerce/). ## What a sale earns you ```bash curl -sS "$WALLETD_API/v1/clients/$CLIENT_ID/sales" -H "Authorization: Bearer $FOUNDER_TOKEN" ``` Returns a summary plus the sales themselves, each with what the buyer paid, the marketplace `commission`, the payment `fee`, anything `refunded`, and the `net` that reached settlement. The identity holds per row and in the summary: `net = amount - fee - commission - refunded`. Do not compute a merchant's earnings from the payment amount alone; commission and fee are separate for a reason, and the loop may charge either, both, or neither. ## Discovery and purchase ```bash curl -sS "$WALLETD_API/v1/explore/clients?q=coffee" -H "Authorization: Bearer $CONSUMER_TOKEN" curl -sS "$WALLETD_API/v1/explore/offerings/$OFFERING_ID" -H "Authorization: Bearer $CONSUMER_TOKEN" ``` Then buying takes **only the offering id** (for a product with several variants, or more than one unit, use [orders](/guides/commerce/#orders) instead): ```bash curl -sS -X POST "$WALLETD_API/v1/explore/offerings/$OFFERING_ID/purchase" \ -H "Authorization: Bearer $CONSUMER_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: buy-$OFFERING_ID-cart-771" \ -d '{}' ``` ```bash curl -sS -X POST "$WALLETD_API/v1/explore/offerings/$PLAN_ID/subscribe" \ -H "Authorization: Bearer $CONSUMER_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: sub-$PLAN_ID-user-42" \ -d '{}' ``` **No amount, no merchant, no currency in the request.** The catalog is the price authority, so a client cannot be charged a price a consumer's app made up, and a tampered client cannot pay less. If your app sends an amount here, you have found a different endpoint. Purchasing a plan is `422 not_an_item`; subscribing to an item is `422 not_a_plan`. ## Client-funded rewards A client can pay for its own promotions out of its own earnings. ```bash # Move earnings into the marketing budget curl -sS -X POST "$WALLETD_API/v1/clients/$CLIENT_ID/marketing/fund" \ -H "Authorization: Bearer $FOUNDER_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: fund-$CLIENT_ID-aug" \ -d '{"amount": 200}' # A rule that draws on it curl -sS -X POST "$WALLETD_API/v1/clients/$CLIENT_ID/reward_rules" \ -H "Authorization: Bearer $FOUNDER_TOKEN" \ -H "Content-Type: application/json" \ -d '{"kind": "cashback", "params": {"rate_bps": 1000}, "caps": {"per_user_day": 500}}' ``` ```bash curl -sS "$WALLETD_API/v1/clients/$CLIENT_ID/marketing" -H "Authorization: Bearer $FOUNDER_TOKEN" ``` Funding is guarded against the settlement balance: you cannot budget money you have not earned. And when the marketing balance runs dry, **the grant is skipped rather than falling back to platform money**. A client's promise never spends someone else's balance, which is the property that makes it safe to let clients write their own rules. ## What can go wrong | Code | Status | What happened | |---|---|---| | `not_client_member` | 403 | Not a member of that organization. Often a stale token after onboarding | | `not_an_item` / `not_a_plan` | 422 | Purchase and subscribe were swapped | | `offering_not_found` | 404 | Unpublished, archived, or not yours | | `insufficient_funds` | 422 | Funding marketing beyond the settlement balance, or a consumer who cannot afford the offering | ## Next - [Rewards](/guides/rewards/) for how rules evaluate. - [Payouts](/guides/payouts/) to get a client's earnings out. ============================================================================== # Source: https://docs.walletd.io/guides/commerce.md ============================================================================== # Products, stock, orders and bulk import The ecosystem guide covers onboarding a client and publishing a simple offering. This guide covers the merchant commerce surface built on top of it: variants, cost-plus pricing, stock, cart orders, and importing a whole catalog from a spreadsheet. Everything here is available to a merchant's own token through the same `requireClient` authority as the rest of `/v1/clients/{clientId}`. ## The model in one paragraph An item is a product. Every product owns one or more **variants**, and the variant is what a shopper buys: it carries the SKU, the price, the cost, the stock. A product without options (a mug) has one default variant with no option values, and the portal hides it. A product with options (a tee in three sizes) lists its option names once on the product and one value per option on each variant. `offerings.amount` is kept equal to the lowest live variant price so every older reader still sees "the price". ## Price a product A variant is priced in one of two modes. `manual`: the amount is what you send. `cost_plus`: you send a `cost_amount` and the sell price derives from the most specific markup found: the variant's own, then the product's, then the shop's default. A markup is `{"kind": "percent", "value": 2500}` (basis points of the cost) or `{"kind": "fixed", "value": 150}` (minor units on top). The shop also sets a rounding increment and offset, so a derived 12.34 can land on 13.00 or 12.99. ```bash # Shop defaults: 25% on cost, rounded up to whole units minus one minor unit (x.99) curl -sS -X PUT "$WALLETD_API/v1/clients/$CLIENT_ID/pricing" \ -H "Authorization: Bearer $FOUNDER_TOKEN" -H "Content-Type: application/json" \ -d '{"markup": {"kind": "percent", "value": 2500}, "rounding": {"increment": 100, "offset": -1}}' ``` The response carries `repriced_variants`: how many cost-plus variants moved because of this change. Changing a markup at any level reprices everything below it that has no more specific override, in one transaction. Every price change, including the first, is a row in the price history: ```bash curl -sS "$WALLETD_API/v1/clients/$CLIENT_ID/price_history?offering_id=$OFFERING_ID" \ -H "Authorization: Bearer $FOUNDER_TOKEN" ``` A published **item** may be repriced; the history is the record. A published **plan** may not, because subscribers are already paying it. ## Create a product with variants ```bash curl -sS -X POST "$WALLETD_API/v1/clients/$CLIENT_ID/offerings" \ -H "Authorization: Bearer $FOUNDER_TOKEN" -H "Content-Type: application/json" \ -d '{ "kind": "item", "title": "Logo tee", "options": ["Size", "Colour"], "variants": [ {"sku": "TEE-S-BLK", "option_values": ["S", "Black"], "amount": 2000, "track_inventory": true, "initial_stock": 10, "low_stock_threshold": 2}, {"sku": "TEE-M-BLK", "option_values": ["M", "Black"], "amount": 2000, "track_inventory": true, "initial_stock": 12}, {"sku": "TEE-M-WHT", "option_values": ["M", "White"], "pricing_mode": "cost_plus", "cost_amount": 900, "track_inventory": true} ] }' ``` Later variants go through `POST .../offerings/{offeringId}/variants`, edits through `PUT .../variants/{variantId}`, and retirement through `POST .../variants/{variantId}/archive`. A product keeps at least one variant; archive the product instead. A SKU names one live variant per shop and an archived variant frees its SKU. ## Stock Stock lives on the variant: `track_inventory`, on hand, reserved, `allow_backorder`, and a `low_stock_threshold`. Available is on hand less reserved. A variant that does not track inventory is unlimited (digital goods, services). Every count change is a movement with a reason, and the log is append-only: ```bash # Received a delivery curl -sS -X POST "$WALLETD_API/v1/clients/$CLIENT_ID/offerings/$OFFERING_ID/variants/$VARIANT_ID/stock" \ -H "Authorization: Bearer $FOUNDER_TOKEN" -H "Content-Type: application/json" \ -d '{"delta": 24, "reason": "delivery 4471"}' # Counted the shelf curl -sS -X POST ".../variants/$VARIANT_ID/stock" ... -d '{"set_to": 31, "reason": "stock take"}' # The log curl -sS "$WALLETD_API/v1/clients/$CLIENT_ID/inventory/movements?variant_id=$VARIANT_ID" -H "Authorization: Bearer $FOUNDER_TOKEN" ``` Movement kinds: `adjustment` and `import` (you), `reservation`, `release` and `sale` (orders), `restock` (after a refund). Manual adjustments never take stock below zero; a backordered sale does, and negative on hand is how many units you owe. Shoppers never see counts. Explore shows each variant's `inventory.in_stock` and `inventory.low_stock` and nothing else. ## Orders A shopper buys with a cart from one merchant. Creating the order reserves stock; paying it sells; canceling or expiry releases. ```bash # Reserve (idempotent by key). Prices come from the catalog; never send an amount. curl -sS -X POST "$WALLETD_API/v1/orders" \ -H "Authorization: Bearer $CONSUMER_TOKEN" -H "Content-Type: application/json" \ -H "Idempotency-Key: cart-9f1e" \ -d '{"lines": [{"variant_id": "'$VARIANT_ID'", "quantity": 2}]}' # Pay. Idempotent by order: call it again and you get the same payment. curl -sS -X POST "$WALLETD_API/v1/orders/$ORDER_ID/pay" -H "Authorization: Bearer $CONSUMER_TOKEN" ``` An order is `open` for 15 minutes, then a sweep expires it and releases its stock. `422 insufficient_stock` names the line that cannot be promised. A payment refusal (`422 insufficient_funds`) leaves the order open so the shopper can top up and pay again. The merchant reads its book at `GET /v1/clients/{clientId}/orders`, may cancel an open order, and after refunding a paid one may put its units back with `POST .../orders/{orderId}/restock` (once; damaged goods do not come back). The older `POST /v1/explore/offerings/{offeringId}/purchase` still works for a product with a single variant: it is an order of one unit and answers with the payment, so stock is sold the same way. Each line records the commission the loop takes, resolved offering, then client, then loop default exactly as a single purchase does, and the payment carries the sum. ## Bulk import Download the template, fill it, and upload it with an optional ZIP of pictures: - [catalog-import-template.xlsx](/assets/catalog-import-template.xlsx) (with a help sheet) - [catalog-import-template.csv](/assets/catalog-import-template.csv) - [catalog-import-sample-images.zip](/assets/catalog-import-sample-images.zip) ```bash curl -sS -X POST "$WALLETD_API/v1/clients/$CLIENT_ID/imports" \ -H "Authorization: Bearer $FOUNDER_TOKEN" \ -F "file=@products.xlsx" -F "images=@pictures.zip" ``` The answer is `202` with an import in status `validating`. Poll `GET .../imports/{importId}` until it is `ready`, read the preview at `GET .../imports/{importId}/rows` (each row says `create`, `update` or `error` and why), then `POST .../imports/{importId}/commit`. Nothing lands before the commit. `POST .../cancel` drops it. Rules that matter: - Rows match existing products by **SKU**. Re-importing the same file updates instead of duplicating. - Rows sharing a `product_handle` are variants of one product and must list the same option names in the same order. A row's `image` names a file in the ZIP; `image_url` is an https link we fetch (public addresses only, 5 MB, png/jpeg/webp/gif). Give one or the other. - Money columns are decimals in the shop's currency with at most two places (`12.50`). `markup_value` is a percentage (`25` or `12.5`) for `percent` and an amount for `fixed`. - A blank cell on an update keeps the current value. `stock` on an update sets the count and logs an `import` movement. - `status: published` needs an approved shop; otherwise the product stays a draft and the row says so. - Limits: 5,000 rows, a 10 MB sheet, a 100 MB ZIP. Bad rows never block good ones; they are listed and left out. Imports run on their own queue and finish with an `import.completed` webhook. ## Connect your own system A merchant can issue API keys that act as its business, from the portal's Developers page or over the API: ```bash curl -sS -X POST "$WALLETD_API/v1/clients/$CLIENT_ID/api_keys" \ -H "Authorization: Bearer $FOUNDER_TOKEN" -H "Content-Type: application/json" \ -d '{"name": "POS till 2"}' ``` The answer carries the token once. Send it as a bearer on every `/v1/clients/{clientId}` route above, and on `/v1/explore/*`. A bound key sees and changes only its own business: another merchant's routes answer `403 not_client_member`, and tenant-wide routes (the loop's webhook endpoints, the fee plan, other merchants) are out of reach because the key carries no tenant-wide scope. Only a signed-in member of the business may issue or revoke keys; a key cannot mint keys. Revoke a leaked key with `DELETE .../api_keys/{keyId}`; allow for both authsvc and walletd verification caches (approximately two minutes plus request latency with defaults), then verify rejection. See [Security for integrators](/platform/security/). ### Hear about orders and stock The same key registers webhook endpoints for the business: ```bash curl -sS -X POST "$WALLETD_API/v1/clients/$CLIENT_ID/webhook_endpoints" \ -H "Authorization: Bearer $MERCHANT_KEY" -H "Content-Type: application/json" \ -d '{"url": "https://api.yourshop.com/walletd", "event_filters": ["order.paid", "order.canceled", "inventory.low_stock"]}' ``` The signing secret comes back once. A merchant endpoint receives `product.*`, `order.*`, `inventory.*` and `import.*` events for this business and nothing else; verify every delivery as the [webhooks guide](/webhooks/) shows. `GET .../webhook_endpoints` lists them, `DELETE .../webhook_endpoints/{endpointId}` stops one, and `GET .../webhook_deliveries` is the attempt log. An `order.paid` delivery is the moment to fulfil: its `data.order.lines` name the variants and quantities sold at the prices charged. ### Rewards on a mixed cart Loyalty and cashback rules are evaluated per order line. A cart of several products earns each product's own rule on that product and the merchant's common rule on the rest, so a per-product promotion pays exactly on the product it names, however the shopper mixed the cart. ============================================================================== # Source: https://docs.walletd.io/webhooks.md ============================================================================== # Webhooks Webhooks tell your backend that money moved, without you polling for it. Every delivery is HMAC-signed, and **verifying the signature is not optional**: your endpoint is a public URL, and anyone can POST JSON at it. ## Register an endpoint ```bash curl -sS -X POST "$WALLETD_API/v1/webhook_endpoints" \ -H "Authorization: Bearer $WALLETD_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://api.yourapp.com/webhooks/walletd", "event_filters": ["topup.succeeded", "transfer.completed"]}' ``` ```json { "id": "019fee0d-2c3d-7e4f-9a5b-6c7d8e9f0a1b", "url": "https://api.yourapp.com/webhooks/walletd", "event_filters": ["topup.succeeded", "transfer.completed"], "status": "active", "secret": "whsec_9f2c4a...", "created_at": "2026-08-11T05:20:11.004Z" } ``` > **Warning** > > **The `secret` is shown exactly once.** Store it in your secret manager now. > There is no endpoint that will show it to you again. Omit `event_filters` to > receive everything. One secret per endpoint, and an endpoint belongs to one tenant. If your backend receives for several tenants, select the secret by the delivery's `tenant_id`; verifying tenant A's delivery against tenant B's secret fails exactly like a forgery. ### Merchant endpoints A merchant integrating its own shop, till or sync job registers endpoints for its business rather than for the whole loop. These live under the merchant's own route and accept a merchant-bound API key (see the commerce guide): ```bash curl -sS -X POST "$WALLETD_API/v1/clients/$CLIENT_ID/webhook_endpoints" \ -H "Authorization: Bearer $MERCHANT_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://api.yourshop.com/walletd", "event_filters": ["order.paid", "inventory.low_stock"]}' ``` A merchant endpoint is delivered only the events that belong to that business: `product.*`, `order.*`, `inventory.*` and `import.*`. Payments, transfers, top-ups and every other tenant-wide event never reach it, whatever its filters say. The tenant's own endpoints keep receiving everything, merchants' events included, and each merchant event carries a `client_id` in its envelope so a tenant receiver can route it. `GET` lists the business's endpoints, `DELETE .../webhook_endpoints/{endpointId}` stops deliveries, and `GET .../webhook_deliveries` shows the attempts made to them. The portal's Developers page does the same three things. ## What a delivery looks like ```http POST /webhooks/walletd HTTP/1.1 Content-Type: application/json X-Wallet-Signature: t=1786405949,v1=d0da4da96bafdcfd2bff5b2b68516d0b45c72d039203deb250ed2c25f91af9a2 X-Wallet-Event-Id: 019fedda-88ef-7253-8253-14d9e24723fb { "id": "019fedda-88ef-7253-8253-14d9e24723fb", "type": "topup.succeeded", "tenant_id": "019fedcf-70a4-7ecd-bda1-f45dd0fdc0ca", "created_at": "2026-08-11T05:12:29.922Z", "data": { "topup": { "id": "019fedda-88e6-7453-b2ef-33b30424910e", "user_id": "019fedcf-7183-7a57-82e7-3ca266429b04", "amount": 5000, "status": "succeeded" } } } ``` A delivery about a merchant's catalog, stock or orders also carries `"client_id"` in the envelope, next to `tenant_id`. ## The signature scheme ``` X-Wallet-Signature: t=,v1=.")> ``` To verify: 1. Parse `t` and `v1`. 2. Reject if `t` is further from your clock than the tolerance **you** chose, in either direction. Five minutes is the value we recommend and the one every sample below uses. 3. Recompute `HMAC-SHA256(secret, t + "." + body)` over the **raw bytes you received**. 4. Compare in **constant time**. > **Warning** > > **The replay window is yours, not ours.** WalletD enforces no tolerance of its > own: it signs with the current time on every attempt and delivers. Nothing > rejects an old signature unless your receiver does, so you must reject > deliveries outside your tolerance yourself. Because each retry and each manual > redelivery is signed afresh, a tight tolerance costs you nothing: an event > redelivered a week later still arrives with a current `t`. Two things that break verification and are easy to miss: signing a re-serialised body instead of the raw bytes (key order changes, and the hash changes with it), and comparing with `==` (a timing side channel that leaks the signature byte by byte). ## Verifying, in each language ### curl Not a real receiver, but useful for checking a secret by hand: ```bash BODY=$(cat delivery.json) TS=$(printf '%s' "$SIG_HEADER" | sed 's/t=\([0-9]*\).*/\1/') EXPECTED=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | sed 's/.* //') printf '%s' "$SIG_HEADER" | grep -q "v1=$EXPECTED" && echo verified || echo "NOT verified" ``` ### Go ```go package webhooks import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "io" "net/http" "strconv" "strings" "time" "github.com/walletd-io/walletd-go" ) const tolerance = 5 * time.Minute // Verify reports whether the header matches the body under the secret. func Verify(secret, header string, body []byte, now time.Time) bool { parts := strings.Split(header, ",") if len(parts) != 2 || !strings.HasPrefix(parts[0], "t=") || !strings.HasPrefix(parts[1], "v1=") { return false } seconds, err := strconv.ParseInt(parts[0][2:], 10, 64) if err != nil { return false } signedAt := time.Unix(seconds, 0) if signedAt.Before(now.Add(-tolerance)) || signedAt.After(now.Add(tolerance)) { return false } mac := hmac.New(sha256.New, []byte(secret)) fmt.Fprintf(mac, "%d.%s", seconds, body) expected := hex.EncodeToString(mac.Sum(nil)) // Constant time: a byte-by-byte compare leaks the signature. return hmac.Equal([]byte(expected), []byte(parts[1][3:])) } // enqueue must commit to durable storage before returning nil. func Handler(secret string, enqueue func(tenantID, eventID string, body []byte) error) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // The raw bytes are what was signed. Do not decode and re-encode. body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { w.WriteHeader(http.StatusBadRequest) return } if !Verify(secret, r.Header.Get("X-Wallet-Signature"), body, time.Now()) { w.WriteHeader(http.StatusUnauthorized) return } event, err := walletd.ParseEvent(body) if err != nil { w.WriteHeader(http.StatusBadRequest) return } if err := enqueue(event.TenantID.String(), event.ID.String(), body); err != nil { w.WriteHeader(http.StatusServiceUnavailable) return } w.WriteHeader(http.StatusOK) } } ``` ### Python ```python import hashlib import hmac import time import json from uuid import UUID TOLERANCE_SECONDS = 300 def verify(secret: str, header: str, body: bytes, now: float | None = None) -> bool: now = time.time() if now is None else now try: timestamp_part, signature_part = header.split(",") timestamp = int(timestamp_part.removeprefix("t=")) received = signature_part.removeprefix("v1=") except (ValueError, AttributeError): return False if abs(now - timestamp) > TOLERANCE_SECONDS: return False expected = hmac.new( secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256 ).hexdigest() # Constant time: a byte-by-byte compare leaks the signature. return hmac.compare_digest(expected, received) # Flask. request.data is the raw body; request.json would re-serialise it. @app.post("/webhooks/walletd") def walletd_webhook(): if not verify(WEBHOOK_SECRET, request.headers.get("X-Wallet-Signature", ""), request.data): return "", 401 try: event = json.loads(request.data) tenant_id, event_id = UUID(event["tenant_id"]), UUID(event["id"]) if not tenant_id.int or not event_id.int or not isinstance(event["type"], str) or not event["type"]: return "", 400 except (ValueError, KeyError, TypeError, AttributeError): return "", 400 try: enqueue(str(tenant_id), str(event_id), request.data) # durable commit except Exception: return "", 503 return "", 200 ``` ### PHP ```php WALLETD_TOLERANCE) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret); // Constant time: a byte-by-byte compare leaks the signature. return hash_equals($expected, $received); } // php://input is the raw body. Reading $_POST would not give you the bytes signed. $body = file_get_contents('php://input'); $header = $_SERVER['HTTP_X_WALLET_SIGNATURE'] ?? ''; if (!walletd_verify($webhookSecret, $header, $body)) { http_response_code(401); exit; } try { $event = json_decode($body, true, 512, JSON_THROW_ON_ERROR); foreach (['tenant_id', 'id'] as $field) { $value = $event[$field] ?? null; if (!is_string($value) || !preg_match('/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i', $value) || $value === '00000000-0000-0000-0000-000000000000') { http_response_code(400); exit; } } if (!is_string($event['type'] ?? null) || $event['type'] === '') { http_response_code(400); exit; } } catch (JsonException $e) { http_response_code(400); exit; } try { enqueue(strtolower($event['tenant_id']), strtolower($event['id']), $body); // durable commit } catch (Throwable $e) { http_response_code(503); exit; } http_response_code(200); ``` ### JavaScript ```javascript import crypto from "node:crypto"; import express from "express"; const TOLERANCE_SECONDS = 300; export function verify(secret, header, body, now = Date.now() / 1000) { const parts = String(header).split(","); if (parts.length !== 2 || !parts[0].startsWith("t=") || !parts[1].startsWith("v1=")) return false; const timestamp = Number(parts[0].slice(2)); const received = parts[1].slice(3); if (!Number.isFinite(timestamp) || Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false; const expected = crypto .createHmac("sha256", secret) .update(`${timestamp}.`) .update(body) .digest("hex"); // timingSafeEqual throws on a length mismatch, so check that first. if (expected.length !== received.length) return false; return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received)); } const app = express(); // express.raw, not express.json: the raw bytes are what was signed. app.post("/webhooks/walletd", express.raw({ type: "application/json" }), async (req, res) => { if (!verify(process.env.WALLETD_WEBHOOK_SECRET, req.get("X-Wallet-Signature"), req.body)) { return res.sendStatus(401); } let event; const uuid = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i; const validID = value => typeof value === "string" && uuid.test(value) && value !== "00000000-0000-0000-0000-000000000000"; try { event = JSON.parse(req.body.toString("utf8")); if (!validID(event?.id) || !validID(event?.tenant_id) || typeof event?.type !== "string" || !event.type) return res.sendStatus(400); } catch { return res.sendStatus(400); } try { await enqueue(event.tenant_id.toLowerCase(), event.id.toLowerCase(), req.body); } catch { return res.sendStatus(503); } res.sendStatus(200); }); ``` ### Java ```java import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Instant; import java.util.HexFormat; public final class WalletDWebhooks { private static final long TOLERANCE_SECONDS = 300; public static boolean verify(String secret, String header, byte[] body, Instant now) { if (header == null) return false; String[] parts = header.split(","); if (parts.length != 2 || !parts[0].startsWith("t=") || !parts[1].startsWith("v1=")) return false; long timestamp; try { timestamp = Long.parseLong(parts[0].substring(2)); } catch (NumberFormatException e) { return false; } if (Math.abs(now.getEpochSecond() - timestamp) > TOLERANCE_SECONDS) return false; try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8)); mac.update(body); String expected = HexFormat.of().formatHex(mac.doFinal()); // Constant time: a byte-by-byte compare leaks the signature. return MessageDigest.isEqual( expected.getBytes(StandardCharsets.UTF_8), parts[1].substring(3).getBytes(StandardCharsets.UTF_8)); } catch (Exception e) { return false; } } } ``` ```java // Spring: take the body as byte[] so nothing re-serialises it. @PostMapping(path = "/webhooks/walletd", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity receive( @RequestBody byte[] body, @RequestHeader("X-Wallet-Signature") String signature) { if (!WalletDWebhooks.verify(webhookSecret, signature, body, Instant.now())) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } // objectMapper is the application's Jackson ObjectMapper. java.util.UUID tenantId, eventId; try { var event = objectMapper.readTree(body); tenantId = java.util.UUID.fromString(event.required("tenant_id").asText()); eventId = java.util.UUID.fromString(event.required("id").asText()); if (tenantId.equals(new java.util.UUID(0, 0)) || eventId.equals(new java.util.UUID(0, 0)) || !event.required("type").isTextual() || event.get("type").asText().isEmpty()) { return ResponseEntity.badRequest().build(); } } catch (Exception e) { return ResponseEntity.badRequest().build(); } try { enqueue(tenantId.toString(), eventId.toString(), body); // durable commit } catch (Exception e) { return ResponseEntity.status(503).build(); } return ResponseEntity.ok().build(); } ``` The receiver snippets require an application-provided `enqueue(tenantID, eventID, rawBody)` that commits a durable inbox row under a unique `(tenant_id, event_id)` constraint. An existing row is a successful no-op. These snippets do not provide a queue or database implementation. For Go, `ParseEvent` comes from the Go SDK. A worker must commit its processed marker and local business changes in the same database transaction. Roll back both on failure. For effects in another system, commit an outbox intent with the marker, then deliver it using a stable downstream idempotency key; a local database transaction cannot make a remote effect atomic. Test concurrent duplicates, altered advisory headers, failure before inbox commit, and worker crashes before and after commit. A deduplication map in a unit test does not establish durable crash recovery. ## What a correct receiver does > **Warning** > > **Verify first.** Before parsing, before logging the contents, before anything. > An unverified body is attacker-controlled input, and your endpoint is a public > URL that anyone can POST JSON at. **Be idempotent.** Delivery is at-least-once. Retries after your own timeout, redeliveries, and replays all mean you will see the same event twice. After signature verification, validate the envelope and deduplicate on its signed `(tenant_id, id)`. The HTTP `X-Wallet-Event-Id` header is unsigned and advisory: changing it must never create new work. If an endpoint is configured for one tenant, also require the verified `tenant_id` to match that configuration. **Answer fast, after persistence.** Return `2xx` only after durable inbox acceptance and do the work asynchronously. An in-process goroutine is not durable storage. Return `503` if acceptance fails. The delivery timeout is ten seconds, and a slow endpoint burns its own retry schedule. **Tolerate unknown types.** New event types get added. An unfamiliar `type` should be ignored, not crash your handler. **Never trust the payload as an instruction.** An event is an observation of something that already happened. If you need current state, read it back from the API. ## Retries and redelivery Non-`2xx` responses retry with backoff for roughly a day (12 attempts). Every attempt is recorded: ```bash curl -sS "$WALLETD_API/v1/webhook_deliveries?limit=50" -H "Authorization: Bearer $WALLETD_API_KEY" ``` If your endpoint was down past the retry window, redeliver by hand: ```bash curl -sS -X POST "$WALLETD_API/v1/webhook_events/$EVENT_ID/redeliver" \ -H "Authorization: Bearer $WALLETD_API_KEY" ``` Events commit with walletd's domain state and stored response in its local database. Ledger postings commit separately in ledgerd. A crash after a posting but before the walletd commit can delay the corresponding event until the money operation is repaired. Absence of a webhook is therefore not proof that a payment failed. Retry an uncertain originating request with its original idempotency key and reconcile its result. Once the outbox event has committed, receiver downtime does not remove it. Delivery has a bounded retry budget; use the event and delivery APIs to investigate exhausted attempts and request redelivery. Store the event ID with your own processing result so redelivery cannot apply the business effect twice. ### How long we keep it **Event history is retained for 30 days.** That is the window for `GET /v1/webhook_events`, `GET /v1/webhook_deliveries`, and redelivery: past it, the event and its delivery attempts are removed and there is nothing left to redeliver. Thirty days is generous for an outage and short for an archive. If you need event history beyond that, store it yourself as you receive it. The rule of thumb: treat our history as a recovery buffer, not as your system of record. ### Your endpoint being slow will not slow your payments Worth stating plainly, because on many platforms it is not true. Delivery runs on a worker pool of its own, separate from the pool that evaluates rewards, charges subscriptions, and expires authorizations. If your endpoint is slow, or down, deliveries to you queue up and retry, and nothing else about your integration changes: payments still capture at the same speed, cashback is still granted on time, subscriptions still charge. We learned this the direct way. The two used to share one pool, and during a load test a receiver that ran out of memory took every worker with it: reward evaluation stopped for ten minutes while the platform reported itself healthy. Your endpoint can no longer do that to your own money paths, or to anyone else's. ## Event types | Event | Fires when | |---|---| | `topup.succeeded` | Money arrived and the wallet was credited | | `topup.failed` | The processor declined or cancelled | | `topup.amount_mismatch` | The processor confirmed a different amount or currency than the intent; nothing was credited and an operator must resolve it | | `transfer.completed` | A P2P transfer settled | | `payment.authorized` | A hold was placed | | `payment.captured` | A payment was captured, fully or partly | | `payment.voided` | An authorization was released | | `payment.expired` | A hold aged out and was released | | `refund.completed` | A refund settled | | `cashback.granted` | A cashback rule paid out | | `points.accrued` | Points were earned | | `points.converted` | Points became cash | | `subscription.charged` | A billing period was charged | | `subscription.retry_scheduled` | A charge failed and dunning began | | `subscription.paused` | Dunning gave up, or someone paused it | | `subscription.resumed` | A paused subscription restarted | | `subscription.canceled` | A subscription ended | | `credit_limit.changed` | A credit line was set or changed | | `fee_plan.set` | The tenant fee plan changed | | `product.created` | A merchant added a product to its catalog | | `product.updated` | A product changed (details, price, status published) | | `product.archived` | A product was retired from the catalog | | `order.created` | A shopper reserved a cart; its stock is held until paid, canceled or expired | | `order.paid` | The cart was paid with one payment and its stock sold | | `order.canceled` | The shopper or the merchant released an open cart | | `order.expired` | A cart nobody paid for aged out and its stock was released | | `inventory.low_stock` | A tracked variant fell to or under its low-stock threshold on a sale | | `import.completed` | A bulk catalog import was committed; the payload lists what changed | ## Testing your receiver Point an endpoint at a local tunnel and run a real flow; a sandbox top-up produces a genuine signed delivery. To check the failure path, tamper with one byte of the body and confirm you return `401`. A receiver that accepts a modified body is the bug this whole page exists to prevent. ## Next - [Top-ups](/guides/topups/), the flow that most depends on webhooks. - [Errors](/errors/) for what to return when you cannot process one. ============================================================================== # Source: https://docs.walletd.io/sdks.md ============================================================================== # SDKs and tools There is no SDK you are required to use. The API is plain HTTP and JSON, and [Authentication](/authentication/) gives you a small client per language that every guide builds on. What follows is what exists if you would rather not write that yourself. ## Go ```bash go get github.com/walletd-io/walletd-go@v0.1.2 ``` The first-party Go SDK, version `v0.1.2`. Its generated types describe the contract snapshot used for that release; they do not update when the service changes. These properties matter when integrating it: > **Note** > > Money-moving methods take an idempotency key as a positional parameter, so > omitting the argument is a compile error. An empty value still requires > runtime validation. Persist a nonempty key before attempting a money write. - Problems decode into `*walletd.Problem`, and every problem matches two sentinels: its exact code and its status class. `errors.Is(err, walletd.ErrInsufficientFunds)` and `errors.Is(err, walletd.ErrUnprocessable)` both work, so a code added to the API later still matches the class you already branch on. - Retries honour `Retry-After` on 429 and 503 with bounded backoff, and never retry any other 4xx. That is only safe because the idempotency key is stable across attempts. - `walletd.VerifySignature` implements the [webhook scheme](/webhooks/) in constant time over the raw bytes. - Cursor pagination counts distinct cursor keys rather than rows, because the transaction listing pages by transaction while returning one row per entry. Count distinct transaction IDs when deciding whether another page may exist; a conversion can return multiple entries for one transaction. Keep page sizes in the documented range, 1–100. Apache-2.0. It targets API `0.8.0`; anything not yet wrapped is reachable over plain HTTP with the same credential. The release includes local HTTP tests; the deployment smoke suite has not yet been recorded as executed. Validate your deployment's authentication, retry and pagination flows before rollout. Not every listing supports cursor traversal. Clients, tenant webhook events and user rewards expose a limit without a cursor; tenant webhook endpoints expose neither. Do not interpret a limited result as a complete export. ### Response and webhook boundaries Go SDK v0.1.2 retries interrupted response bodies only for GET/HEAD or keyed writes, preserving the original bytes and key. Exhausted read failures match `ErrTransport`; incompatible JSON is a decode error and is not retried. Redirects are refused even with a custom HTTP client; unexpected non-2xx statuses outside 4xx/5xx match `ErrUnexpectedStatus`. Configure the API host directly. These changes are included in v0.1.2. The release was installed in an independent module and tested against a disposable local sandbox; this does not establish production or partner-environment acceptance. For webhooks, verify first, then use `ParseEvent` and the signed body’s `(tenant_id, id)` as the inbox identity. The HTTP event-ID header is advisory. Acknowledge after durable acceptance and commit local effects with the processed marker. See [the receiver requirements](/webhooks/#what-a-correct-receiver-does). ## Other languages Generate a client from the contract rather than hand-writing one: ```bash # Go oapi-codegen -generate types,client -package walletd openapi.yaml > walletd.gen.go # Python, TypeScript, PHP, Java and others npx @openapitools/openapi-generator-cli generate -i openapi.yaml -g python -o ./walletd-python ``` A TypeScript SDK is planned. The guides show curl, Go, Python, PHP, JavaScript and Java for every money path in the meantime. ## The OpenAPI documents | Document | What it describes | |---|---| | [`/openapi.yaml`](/openapi.yaml) | The wallet API: everything in these guides | | [`/openapi/authsvc.yaml`](/openapi/authsvc.yaml) | Credentials: user tokens, staff login, JWKS, tenant API keys | | [`/openapi/ledgerd.yaml`](/openapi/ledgerd.yaml) | The ledger service, for teams running the stack themselves | All three are OpenAPI 3.0.3. Follow each operation's security requirements: the wallet, staff-authentication and ledger-service credentials are not interchangeable, and some discovery/login routes are unauthenticated. See the [API reference](/reference/). ## An API collection [`/openapi/walletd.postman.json`](/openapi/walletd.postman.json) is a Postman v2.1 collection regenerated from the published wallet contract at developer time and committed with the site. It can lag the service until refreshed. It imports into Postman, Bruno and Insomnia. Set `baseUrl` and `apiKey`. Each keyed operation has its own `idempotency_*` collection variable: fill it with a saved logical-operation key before sending. Reuse that key and identical parameters when retrying; replace it only for a new operation. Postman's pre-request script skips missing or invalid keys. The collection never generates a key per send. Importers that discard Postman scripts leave the header empty: set `Idempotency-Key` manually using the same persistence rule. [Postman's skip-request behavior](https://learning.postman.com/latest-v-12/docs/tests-and-scripts/write-scripts/postman-sandbox-reference/pm-execution) is required for the automatic guard. ## For coding agents Authored English guides are published as raw markdown at the same path plus `.md`, indexed in [`/llms.txt`](/llms.txt) and concatenated into [`/llms-full.txt`](/llms-full.txt). See [AI and agent access](/ai/) for what agents most often get wrong about this API. ============================================================================== # Source: https://docs.walletd.io/ai.md ============================================================================== # AI and agent access These docs are written to be read by coding agents as well as people. Authored English guides have markdown exports. Generated API operation pages are represented by the three OpenAPI contracts; they do not have individual markdown exports. Arabic fallback pages do not establish translated markdown coverage. ## Authored English guides as markdown For an authored English guide, remove the trailing slash and append `.md` to its URL and you get the source, served as `text/markdown`: | Page | Markdown | |---|---| | `/authentication/` | [`/authentication.md`](/authentication.md) | | `/guides/topups/` | [`/guides/topups.md`](/guides/topups.md) | | `/webhooks/` | [`/webhooks.md`](/webhooks.md) | The markdown is generated from the same authored source: a language tab set flattens back into one section per language, a callout into a blockquote, a diagram into its `mermaid` fence. Links inside those files are site-absolute page paths like `/guides/topups/`; use that conversion for authored guide links, and follow the OpenAPI links for reference operations. ## llms.txt [`/llms.txt`](/llms.txt) is the index of exported guides: each page, with a one-line description of what it covers. ``` # WalletD > A wallet operating system: double-entry ledger, top-ups, transfers, > payments with holds, refunds, rewards, subscriptions and credit lines > behind one REST API. ## Docs - [Quickstart](https://docs.walletd.example/quickstart.md): Your first five API calls. - [Authentication](https://docs.walletd.example/authentication.md): API keys, user tokens, IdP tokens, and scopes. - [Idempotency](https://docs.walletd.example/idempotency.md): Why every money-moving call needs a key. ... ``` Start here to decide what to fetch. It follows the [llms.txt convention](https://llmstxt.org/). ## llms-full.txt [`/llms-full.txt`](/llms-full.txt) concatenates the exported English guides into one file. Fetch the OpenAPI documents separately for the generated operation reference. It is large; prefer `llms.txt` plus targeted `.md` fetches when you can. ## OpenAPI The machine-readable API description lives at [`/openapi.yaml`](/openapi.yaml). Generate a client from it rather than hand-writing one, and use it to check a field exists before you send it. See [API reference](/reference/). ## If you are an agent writing an integration Four things decide correctness here, and they are the ones most often got wrong from memory: 1. **Amounts are integer minor units.** `2500` means $25.00. Never send a float, never send a decimal string, and do not divide by 100 before sending. 2. **Every money-moving POST needs an `Idempotency-Key` header**, derived from a business object rather than a timestamp or a fresh UUID per attempt. A key that changes on retry defeats the entire mechanism. Read [Idempotency](/idempotency/) before generating retry logic. 3. **Creating a top-up does not credit a wallet.** Money is credited by a verified gateway confirmation. Do not write code that treats a client-side success callback as a balance change. Read [Top-ups](/guides/topups/). 4. **Verify webhook signatures over the raw bytes, in constant time.** Re-serialising the body changes the hash. Read [Webhooks](/webhooks/). Two more that cause silent bugs rather than errors: - **`available`, not `balance`**, is what a user can spend. The difference is money held by an uncaptured authorization. See [The money model](/money-model/). - **Branch on the `code` field** of an error, never on `title` or `detail`. See [Errors](/errors/). ## Accuracy Code samples are written against the published OpenAPI description and the platform's own live gate scripts, and they use the same field names those scripts assert on. If a sample and the OpenAPI description disagree, the description wins and the sample is a bug worth reporting. ============================================================================== # Source: https://docs.walletd.io/reference.md ============================================================================== # API reference WalletD publishes three OpenAPI contracts. Two of them are yours to call; the third is the ledger underneath, documented because a partner running the stack has to operate it. - [Wallet API](/reference/wallet/): Users, balances, top-ups, transfers, payments, refunds, rewards, subscriptions, the catalog and the ecosystem. This is the integration surface. - [Auth API](/reference/auth/): Wallet user tokens, staff login, tenant API keys, and the JWKS every verifier reads. - [Ledger API](/reference/ledger/): The standalone double-entry ledger. Cluster-internal: read Reaching the ledger before anything else here. ## Conventions These hold for the wallet and auth APIs without exception. The ledger API is a separate service with its own credential; its differences are called out on [Reaching the ledger](/reference/ledger/reaching-the-ledger/). | | | |---|---| | Base URL | Your deployment's API host, for example `https://apigw.walletd.io`. The published contracts carry that host as a placeholder; substitute yours | | Auth | `Authorization: Bearer ` on everything except `GET /healthz`. See [Authentication](/authentication/) | | Amounts | Integer minor units. `2500` is $25.00, and there are no floats in either direction | | Ids | UUIDv7, so they sort by creation time | | Money-moving POSTs | Require an `Idempotency-Key` header. See [Idempotency](/idempotency/) | | Errors | RFC 7807 `application/problem+json`. Branch on the stable `code`, never on `title` or `detail`. See [Errors](/errors/) | | Paging | Cursor-based: `?limit=` and `?cursor=`, newest first | | Rate limits | `429 rate_limited` and `503 bulkhead_full` come from the API gateway, not from the service. Both carry the same problem shape; `429` carries `Retry-After` | An object that exists but belongs to another tenant returns `404`, not `403`. That is deliberate: an id you do not own has to be indistinguishable from an id that does not exist, or the API becomes a way to enumerate other people's data. ## There is no "try it" console Deliberately. On an API whose calls move real balances, a button that fires a live request from a documentation page is a way to make a mistake, not a convenience. Every operation below shows the request in six languages and the response it returns, and none of them sends anything. The consequence worth knowing about: this site makes **no third-party requests at all**. No playground client, no hosted search, no analytics, no fonts from a CDN. It renders on a laptop with no internet, and you can verify that from the artefact rather than taking our word for it. To actually run a call, use a sandbox key against your own deployment. See [Testing and sandbox](/testing/). ## Machine-readable contracts | Document | URL | |---|---| | Wallet API | [`/openapi.yaml`](/openapi.yaml) | | Auth API | [`/openapi/authsvc.yaml`](/openapi/authsvc.yaml) | | Ledger API | [`/openapi/ledgerd.yaml`](/openapi/ledgerd.yaml) | | Postman / Bruno collection (wallet) | [`/openapi/walletd.postman.json`](/openapi/walletd.postman.json) | These are the contracts the services generate their own server stubs from, copied verbatim. The only things added on the way here are the `servers` entry (a contract declares a relative URL because it is served from its own host), display names for the tags, the hand-written code samples, and the two problem codes the gateway emits on a service's behalf. The collection imports into Postman and Bruno. It carries one request per operation with the example bodies from the contract, and two collection variables, `baseUrl` and `apiKey`. ### Generate a client Generate one rather than hand-writing it; the contract is the thing that gets CI-gated, not your wrapper. ```bash # Go — types and a client, from the published document oapi-codegen -generate types,client -package walletd openapi.yaml > walletd.gen.go # Python, TypeScript, PHP, Java and the rest npx @openapitools/openapi-generator-cli generate -i openapi.yaml -g python -o ./walletd-python ``` Both wallet and auth documents are OpenAPI 3.0.3 and declare `bearerAuth` as the global security scheme, so a generated client picks up the header for you. > **Note** > > A generated client gets you types and transport. It does not get you > `Idempotency-Key` on the money paths, problem-code handling, or `Retry-After` > backoff — those are yours to add, and the [Go SDK](/sdks/) has them already. ## Reading an operation page Each operation has its own page, grouped by tag. A page shows the path and method, the authentication it requires, its parameters, the request body schema with an example, and every documented response including the problem codes it can return. The ten money-moving operations additionally carry hand-written samples in curl, Go, Python, PHP, JavaScript and Java — the same six languages, in the same order, as the guides. Those samples are written by hand and reviewed; everywhere else the page shows generated snippets, which are fine for a plain `GET` and should be read as illustrations rather than as tested code. ============================================================================== # Source: https://docs.walletd.io/reference/ledger/reaching-the-ledger.md ============================================================================== # Reaching the ledger `ledgerd` is the standalone double-entry ledger that every WalletD money path posts into. It is a separate service with its own database, its own contract and its own credentials, and it is **not exposed at the public API gateway**. > **Warning** > > There is no public host for this API. The `servers` entry in the published > contract is the placeholder `https://ledger.internal.invalid`, and it is a > placeholder on purpose: publishing the real internal address would be handing > out a target. Substitute your own deployment's internal address. ## Who this section is for **Partners running the stack.** You operate ledgerd yourself, inside your own deployment. You need this to provision consumers, close periods, read a trial balance, and answer an auditor. **Teams consuming the ledger directly.** A service of yours, inside the same cluster, that wants the books rather than the wallet abstraction over them. **Not** a partner integrating against the wallet API over the internet. If you hold a `sk_live_` or `sk_sandbox_` key, everything you need is in the [Wallet API](/reference/wallet/), and the ledger moves underneath it without you calling it. `GET /v1/ledger/summary` and `GET /v1/transactions` on the wallet API are the ledger views that are reachable from outside. ## Why it is not exposed The cluster's public routes are three surfaces: the API gateway, Keycloak, and the admin portal. `walletd`, `authsvc` and `ledgerd` have no public route at all — not an authenticated one, not a restricted one. Nothing outside the cluster can address the ledger, whatever credential it holds. That is the deliberate shape: the ledger of record is the one component where a forged or replayed call cannot be undone by a compensating API call, only by a correcting posting that stays in the history forever. Keeping it off the internet removes an entire class of question. ## Getting a credential Consumers are OIDC service clients. A consumer is created by an operator with database access, never through the API, because **creating a consumer is the authorization to call the service**: ```bash ledgerd consumer create --name walletd --subject ``` The `--subject` is the `sub` claim the identity provider puts in that client's access tokens, and it is what ledgerd matches on. Issue the client credentials in your IdP first, read the subject off a token it issues, then register it here. `ledgerd consumer ensure` is the same call, safe to re-run, which is what a bootstrap job uses; `ledgerd consumer list` shows what is registered. Calls then carry an ordinary bearer token from that client: ``` Authorization: Bearer ``` A consumer only ever sees its own ledgers. A ledger id that belongs to another consumer answers `404`, never `403`, for the same reason the wallet API does: an id you do not own must be indistinguishable from one that does not exist. ## Where it listens | Environment | Address | |---|---| | Compose (`make up` in `ledgerd`) | `http://127.0.0.1:8095` — published on loopback only, so it cannot be reached from another machine on your network | | Inside the compose network | the service's own name on port `8080` | | Kubernetes | a `ClusterIP` Service in the `wallet` namespace, no `HTTPRoute`, no `LoadBalancer` | `GET /healthz` and `GET /version` are unauthenticated: they are the container probes and the deployment check. Everything else requires a consumer token. ## What differs from the wallet API | | | |---|---| | Credential | An OIDC client-credentials token for a registered consumer. Not an `sk_` API key, and not a wallet user token | | Rate limits | None. `429 rate_limited` and `503 bulkhead_full` are the public gateway's, and this service is not behind it | | Errors | RFC 7807, same shape, but the schema is `ProblemDetails` and the codes are the ledger's own (`limit_exceeded`, `precondition_failed`, `event_feed_blocked`, …) | | Idempotency | Required on every mutation that moves money, same `Idempotency-Key` header. `GET /v1/ledgers/{ledgerId}/idempotency/{key}` returns the stored result of a key, which is how a caller finishes a repair after a refusal it did not see the outcome of | | Amounts | Integer minor units of the ledger's own commodity, which is not necessarily the wallet tenant's currency | ## Two-phase postings, in one paragraph Money moves as a balanced posting or not at all. A single-phase movement is `POST /v1/ledgers/{ledgerId}/postings`: legs that sum to zero, committed atomically. A two-phase movement is `POST …/pendings` to reserve, then post or void it — which is what an authorization hold on a payment is underneath. Nothing is ever edited or deleted: a reversal is a contra posting, so the original and its reversal both stand in the history. The [money model](/money-model/) page explains why that matters to a caller. ============================================================================== # Source: https://docs.walletd.io/platform/architecture.md ============================================================================== # Architecture overview This page is for the person deciding whether to build on WalletD, not just against it. It describes what runs, what holds the money, and which component you have to trust for which property. ## Four services and an edge The backend is four runtime components, not thirty: a gateway, an auth service, the `walletd` API, and `ledgerd`, the double-entry ledger. ```mermaid ``` APP --> GW GW -->|"/v1/auth/*"| AUTH GW -->|"/v1/*"| WD WD -->|"introspect key / fetch JWKS"| AUTH WD -->|"postings, over OIDC client credentials"| LD AUTH --> PG WD --> PG LD --> PG WD -->|"signed deliveries"| RCV `} /> **The gateway** terminates the edge. It proxies routes without rewriting bodies, allow-lists the headers each route may carry, and enforces two different things: how often a caller may arrive (rate budgets) and how many of its requests may be in flight at once (concurrency compartments). Both are on [Environments, limits and quotas](/platform/environments-and-limits/). The identity headers `X-User-Id`, `X-Client-Id` and `X-Tenant-Id` are not in any allowlist, so they cannot be spoofed through the edge. **authsvc** is the only service that touches credentials. It issues tenant API keys, mints end-user tokens by exchanging a key, publishes the JWKS that verifies them, and audits every credential event. It stores only Argon2id hashes of key secrets. **walletd** owns the money paths: the state machines, the parties, the policy, the limits, the fee schedule, the webhook outbox. It does not own the books. Every money module is written once against a single internal port that resolves a tenant to the one ledger that answers for it. **ledgerd** is the ledger of record. Balanced multi-leg postings in integer minor units, two-phase reservations, preconditions, periods and statements, a sequenced event feed, and its own invariant verifier. It has its own database and its own database role, so `walletd` module code physically cannot reach money tables. ## What is authoritative for money Only `ledgerd`. This matters more than it sounds, because it is the answer to "what happens when two components disagree". **One ledger per tenant.** A tenant is provisioned by creating its ledger first and then the row that names it, because the ledger is named by the tenant id and has to exist before anything points at it. `tenants.ledger_id` is `NOT NULL`: there is no tenant without a ledger and no configuration in which money lands somewhere else. Every `ledgerd` query is scoped by ledger id, and above that a consumer owns ledgers, so a ledger a caller does not own answers `404` identically to one that does not exist. **Two-phase postings.** A money movement reserves first (a pending), the calling module commits its own state, then the reservation is posted or voided. `ledgerd` enforces that a post cannot exceed what its pending reserved, a rule that used to live in the payment layer and now lives where the money does. Every call is idempotent, so a retry is safe by construction. **Accounts open on first use.** There is no provisioning step that can half-succeed: an owner key resolves to an account whenever a posting names one. The consequence worth knowing is the inverse one. The absence of an account proves nothing about the absence of a party. **The invariants are checked, not assumed.** `ledgerd verify` runs every five minutes on a live deployment. Separately, the books have been recomputed from the raw tables with SQL rather than by asking the service whether it is happy, because a checker and the code it checks share their author's assumptions. Over 5.45 million entries that audit found zero exceptions on conservation, on cached-balance reconciliation, on floor enforcement with and without holds, and on cap claims, and the reporting projection reconciled entry for entry with the ledger of record across two databases. That corpus was load-generated, not production traffic: it is evidence about the mechanism, not about operating a live loop. > **Warning** > > There is one place where a service boundary is visible to you. `walletd` commits its domain rows, its outbox events and the stored idempotent response in one database transaction, and the postings travel to `ledgerd` inside that window as keyed, replayable calls whose ids are derived from your `Idempotency-Key`. Nothing claims those two commits are one. The gap is closed by replay: a crash between the remote call and the local commit heals when the call is repeated, and a refusal asks the ledger of record for the stored result before it is allowed to stand. What this means for your code is that retrying with the same key is not merely permitted, it is how the system reaches a consistent answer. See [Idempotency](/idempotency/). ## How a credential is verified ```mermaid ``` Note over G: budget + compartment checked
identity headers stripped G->>W: proxied, allow-listed headers only W->>A: introspect the key (cached 60s) A-->>W: tenant, scopes, environment Note over W: scope check, then self-check
for user tokens W->>L: reserve, then post (keyed, replayable) L-->>W: balanced postings or a refusal W-->>C: 201, or an RFC 7807 problem `} /> **Nothing inside the cluster trusts the gateway for authorization.** The header allowlists stop identity-header spoofing at the edge, but that is defence in depth, not the fence. `walletd` authenticates every `/v1` request itself: API keys by introspecting them against `authsvc`, end-user tokens locally against the JWKS with issuer, expiry and algorithm pinned. A gateway-injected value would only ever be advisory. Two kinds of principal come out of that. An API key acts tenant-wide, bounded by the scopes minted onto it. A user token carries scopes granted at exchange, bounded by the issuing key's scopes, **and** is pinned to its own user: even holding `balances:read`, a user token reading another user's balance is refused. [Security for integrators](/platform/security/) has the credential formats, lifecycles and rotation. `ledgerd` is not exposed at the public gateway at all. Its consumers authenticate with OIDC client credentials, and `walletd` is one of them. ## What a partner deployment contains Every partner runs its own stack. This is a commercial and a technical position at once: the subscription is per separately deployed partner, and no production PII, identity document, secret or ledger entry is copied into a central WalletD system by default. ```mermaid ``` SVC --> DB SVC --> IDP end RAILS["Partner-contracted rails bank, mobile money, PSP"] KYC["Partner KYC and screening vendors"] WDP["WalletD central plane release artifacts and non-PII health metadata"] partner --> RAILS partner --> KYC WDP -.->|"images, upgrades, signed artifacts"| partner `} /> A deployment carries the four services, the partner's own databases and keys, the identity realm, the channel applications, and the deployment's own operations. The partner owns the regulated business model and licences, customer terms and pricing, the bank and PSP contracts, KYC approval and case disposition, first-line customer service, and the data-controller decisions. WalletD supplies the service binaries and signed deployment artifacts, the infrastructure manifests and upgrade tooling, the specifications and SDKs, security fixes and incident assistance, and control evidence for its own software. Delivery works the same way for every service: each repository runs its own gates and publishes an immutable, sortable image tag, one configuration repository declares what runs, and the cluster pulls from it. A service deploys when its own repository says so, and only that service moves. Access to a partner's data by WalletD's own staff is not a matter of trust or policy prose. It is enforced in code, ticket-bound and time-limited, and described exactly on [Support, SLAs and status](/platform/support/). ## What this architecture does not do Stated plainly, because an evaluation finds these anyway and finding them in a table is better than finding them in a demo. - **Cross-tenant money movement does not exist.** Users, merchants, accounts and every movement live inside one tenant's loop. - **Consumers cannot cash out to a bank.** This is a semi-closed wallet. Money leaves through merchant payouts, which post to the ledger first and produce a statement that instructs the actual transfer. - **Card data never touches the platform.** The processor tokenizes client-side and `walletd` stores payment-intent ids only, which keeps the card scope at SAQ-A. - **Sandbox and live are not separate data planes yet.** The environment is carried on the credential and checked, but the split is a known gap rather than a shipped isolation boundary. See [Environments, limits and quotas](/platform/environments-and-limits/). - **PostgreSQL row-level security is not the second fence yet.** Tenant isolation is enforced in the repository layer on every query and again at posting time by ledger scope. RLS as a third, database-level fence is deliberately deferred and tracked. - **Refunds are contra postings, not linked reversals.** An auditor tracing a refund follows the refund transaction's reference back to the payment. The reversal linkage the schema provides is reserved and unexercised. ## Going deeper A security reviewer, a procurement team or an auditor asks for a different set of material next: the security pack, penetration test summaries, disaster recovery evidence, the compliance posture and the operational runbooks. None of that is on this site. It is maintained separately and is **available on request** through your WalletD contact or `security@walletd.io`. ## Next - [Security for integrators](/platform/security/) for credentials, signing, logging and disclosure. - [Environments, limits and quotas](/platform/environments-and-limits/) for what the edge enforces. - [The money model](/money-model/) for minor units, held versus available, and the float equation. - [Idempotency](/idempotency/) for why a retry is the correct response to a timeout. ============================================================================== # Source: https://docs.walletd.io/platform/environments-and-limits.md ============================================================================== # Environments, limits and quotas ## The base URL is per deployment There is no single `api.walletd.com`. Every partner runs its own stack, so the host you call is issued with your credentials rather than published here. Throughout this site it is written as an environment variable: ```bash export WALLETD_API="https://apigw.your-deployment.example" export WALLETD_API_KEY="sk_sandbox_..." curl -sS "$WALLETD_API/v1/users" -H "Authorization: Bearer $WALLETD_API_KEY" ``` Two conventions hold everywhere. The version lives in the path, not in the host, so every route begins `/v1`. And the host never appears in a response body: identifiers are opaque strings, and nothing the API returns needs to be concatenated onto a base URL to be usable. ## Sandbox and live A credential carries its environment in the token itself. | | | |---|---| | Sandbox | `sk_sandbox__` | | Live | `sk_live__` | The environment segment is not decoration. It is stored on the key record as well as encoded in the token, and verification compares the two: a token whose segment does not match its record fails, and the failure is the same opaque `{"active": false}` as a wrong secret or a revoked key. A sandbox key pointed at a production deployment does not find its record there at all, so it simply fails to authenticate rather than doing something expensive. **What differs between the two is the money, and only the money.** Same routes, same request and response shapes, same problem codes, same webhook signatures, same idempotency semantics. Money in runs through the processor's test mode: a test-mode top-up produces a genuine `topup.succeeded` webhook with a real signature, so your verification code is exercised for real. See [Testing](/testing/) for the test cards and the failure modes worth provoking. > **Warning** > > A non-production environment is a **separate deployment** named in your agreement, not a flag on a request. Within a single deployment the environment segment on a key is checked, but a sandbox key and a live key issued against that same deployment reach the same data. Splitting sandbox and live into distinct data planes is a tracked gap, not a shipped isolation boundary, which is why development work belongs in the non-production deployment rather than behind a sandbox key in the production one. ## Limits Two different things are enforced at the edge, and they answer two different questions. A **rate budget** answers "how often may this caller arrive". A **concurrency compartment** answers "how many requests in this traffic class may be in flight in one gateway process". They are independent: a caller comfortably inside its budget can still find a compartment full, because a compartment fills when the backend behind it slows down rather than when the caller speeds up. Compartments exist so that one slow money path cannot consume the capacity every other route needs. The tables are regenerated at developer time from the staging gateway profile and committed with this site. They describe that source snapshot, not a live measurement or a partner deployment guarantee. Rate-budget identity depends on trusted network information; if the gateway falls back to a shared proxy address, multiple callers share a bucket. A compartment is shared by its traffic class within a gateway process. ### Rate budgets Every policy whose match applies is enforced, not just the most specific one, so an endpoint budget and the API-wide budget are both spent by the same request. `Burst` is the bucket the caller may drain at once; `sustained` is the rate it refills at. `When the store is unreachable` is what the edge does if it cannot reach its counter store: money-moving paths refuse, reads continue. | Policy | Applies to | Burst | Sustained | When the store is unreachable | |---|---|---|---|---| | `staff_login` | `/v1/auth/login` and below, `POST` | 10 | 12/minute | refused | | `auth_token_exchange` | `/v1/auth/user_tokens` and below, `POST` | 50 | 5/second | allowed | | `topup_user` | `/v1/topups` exactly, `POST` | 5 | 3/minute | refused | | `topup_status` | `/v1/topups/` and below, `POST` | 60 | 1/second | allowed | | `p2p_user` | `/v1/transfers` and below, `POST` | 10 | 10/minute | refused | | `reads_user` | `/v1/users` and below, `GET` | 30 | 5/second | allowed | | `gateway_webhooks` | `/v1/gateways/` and below, `POST` | 100 | 20/second | allowed | | `payments_client` | `/v1/payments` and below, `POST` | 200 | 50/second | refused | | `discovery_ip` | `/v1/explore` and below, `GET` | 60 | 10/second | allowed | | `refunds_client` | `/v1/refunds` exactly, `POST` | 30 | 1/second | refused | | `rewards_convert` | `/v1/rewards/convert` exactly, `POST` | 10 | 12/minute | refused | | `client_onboard` | `/v1/clients` exactly, `POST` | 5 | 6/minute | refused | | `client_manage` | `/v1/clients/` and below, `POST` | 30 | 1/second | refused | | `user_admin` | `/v1/users/` and below, `POST` | 30 | 1/second | refused | | `purchase_subscribe` | `/v1/explore/` and below, `POST` | 30 | 1/second | refused | | `tenant_global` | every request | 200 | 50/second | allowed | Anything matching no policy falls to the plugin defaults: burst 100, sustained 25/second, allowed when the store is unreachable. The caller's identity for all of these is its source address, read in order from `client-ip-header:CF-Connecting-IP`, `forwarded-for`, `remote-addr`. It is not the API key: one backend behind one egress address is one identity no matter how many keys it holds. ### Concurrency compartments A compartment caps how many requests of its class may be in flight to a backend at once. Unlike the budgets above, exactly one compartment applies to a request: the first whose match fits, then `default`. `Wait` is how long a request waits for a free slot before it is shed. | Compartment | Applies to | In flight at once | Wait | |---|---|---|---| | `payments` | `/v1/payments` and below, `POST` | 150 | 50 ms | | `transfers` | `/v1/transfers` and below, `POST` | 40 | 50 ms | | `topups` | `/v1/topups` and below, `POST` | 40 | 50 ms | | `refunds` | `/v1/refunds` and below, `POST` | 40 | 50 ms | | `reads` | `GET` | 120 | 0 ms | | `default` | everything not matched above | 80 | 0 ms | > **Warning** > > The published figures come from the `staging` settings profile, which is the gateway image's default. **A partner deployment may be configured differently**, and your agreement governs, not this page. The ceilings are sized from each backend's healthy concurrency and are meant to be tuned against measured capacity. Ask for the profile your deployment runs before you size a client around these numbers. ### Two things that surprise people **The rate-limit identity is the source address, not the API key.** One backend behind one egress address is one identity however many keys it holds, and a busy load generator on one machine is also one identity. If you are sizing a batch job, size it against one budget, not against one per worker. **Every matching budget is spent, not just the most specific one.** A `POST /v1/transfers` spends the `p2p_user` budget and the API-wide `tenant_global` budget on the same request. Compartments work the other way: exactly one applies, the first whose match fits. ## When you are refused Both refusals are `application/problem+json` and both are the **gateway's**, not `walletd`'s. They are the reason the [error code table](/errors/) lists `rate_limited` and `bulkhead_full` separately from every other code: nothing in `walletd` emits them, and no amount of correcting your request body prevents them. ### 429, you are asking too often ```json { "type": "about:blank", "title": "Too Many Requests", "status": 429, "code": "rate_limited", "retry_after_sec": 3 } ``` Note the shape. The gateway's problem body carries `retry_after_sec` and no `detail`, because it is written before any service has seen the request. Alongside it: | Header | Meaning | |---|---| | `Retry-After` | Whole seconds to wait. Always at least `1` | | `X-RateLimit-Limit` | The budget's capacity | | `X-RateLimit-Remaining` | What is left in it | | `X-RateLimit-Used` | What has been spent | | `X-RateLimit-Reset` | Unix seconds at which the budget is whole again | Repeated denials from the same identity are short-circuited for a few seconds without touching the counter store. The `Retry-After` on a short-circuited response is aged by the time since the denial was cached, so it never tells you to wait longer than you actually must. ### 503, the class is momentarily full ```json { "type": "about:blank", "title": "Service Unavailable", "status": 503, "code": "bulkhead_full", "retry_after_sec": 1 } ``` With `Retry-After: 1` and `X-Bulkhead` naming the saturated compartment. This is not an outage and it is not your budget. It means the compartment your request classified into had no free slot, and after waiting its configured window the request was shed rather than queued, so that the overload stayed inside that class. `X-Bulkhead: payments` while reads keep flowing is the system working as designed. ### Retrying either ```mermaid flowchart TD A[Send with your idempotency key] --> B{Response} B -->|2xx| C[Done] B -->|429 rate_limited| D[Wait Retry-After, resend the same key] B -->|503 bulkhead_full| D B -->|5xx or timeout| D B -->|other 4xx| E[Your request is wrong. Do not retry] D --> A ``` Both are retryable, and both must be retried **with the same `Idempotency-Key`**. A `429` or a `503` from the gateway means the request never reached a money path, but your client cannot know that from the status code alone, and reusing the key makes the question irrelevant. Back off exponentially with jitter on top of `Retry-After`. The per-language retry helpers on [Idempotency](/idempotency/) already do this. ### What the edge does when its own store is unavailable The rate limiter keeps its counters in a shared store so budgets hold across gateway replicas. When that store cannot be reached, each policy falls one way or the other, and the table above says which. Money-moving paths fail **closed**: transfers, top-ups, refunds, payments, conversions, client and user administration are refused rather than allowed through unmetered. Reads and the signature-verified processor callbacks fail **open**, because availability wins where no money moves. A circuit breaker stops the gateway hammering a store that is already down. ## Next - [Errors](/errors/) for the full problem code table and what is retryable. - [Idempotency](/idempotency/) for the retry patterns in six languages. - [Security for integrators](/platform/security/) for key lifecycle, scopes and rotation. - [Support, SLAs and status](/platform/support/) if a limit is refusing traffic you believe is legitimate. ============================================================================== # Source: https://docs.walletd.io/platform/going-live.md ============================================================================== # Going live Sandbox and live differ in one thing only: whether the money is real. The API surface is identical, so going live is not a migration. It is a checklist of things that were survivable in testing and are not survivable in production. Each item below says how to *prove* it, because "we handle that" and "we tested that failing" are different states. ## Credentials - **A live key exists and the sandbox key is not in the deployment.** Keys carry their environment in the token (`sk_live_` versus `sk_sandbox_`), so a sandbox key pointed at production fails to authenticate rather than doing something expensive. Prove it by grepping your deployed configuration for `sk_sandbox_`. - **The key is in a secret manager, not in an image, a repository or an environment file in version control.** It is shown once at issuance and stored only as an Argon2id hash, so a lost key is reissued, never recovered. - **You know the key expires.** Keys are issued with a 365-day lifetime. Put the date in the same calendar that holds your certificate renewals, because the expiry arrives as an ordinary `401` with no warning. - **Rotation is create, deploy, verify, then revoke, in that order.** Revoking first is an outage. Revocation reaches the platform within its introspection cache, so allow a minute before assuming an old key is dead. - **Scopes are the narrowest that work.** A key with `*` is a key whose blast radius is the whole tenant. ## Idempotency - **Every money-moving call sends an `Idempotency-Key` derived from a business object**, not a fresh UUID per attempt and not a timestamp. A key that changes on retry defeats the entire mechanism. - **You have tested a replay.** Send the same request twice with one key and assert one movement and an identical response. See [Idempotency](/idempotency/). ## Webhooks - **The receiver verifies the signature over the raw bytes, in constant time, before parsing.** Re-serialising the body changes the hash. - **The receiver rejects deliveries outside a tolerance you chose.** The platform signs with the current time on every attempt and enforces no window of its own, so replay protection is yours. Five minutes is a reasonable default. - **The receiver is idempotent on the verified body’s `(tenant_id, id)`.** `X-Wallet-Event-Id` is unsigned and advisory; changing it must not create work. - **It acknowledges only after durable inbox acceptance, then works asynchronously.** A slow receiver does not slow your payments, but it does burn your retry budget. - **You have tested a bad signature.** Flip one byte of a delivery body and assert your endpoint refuses it. See [Webhooks](/webhooks/). ## Money handling - **Nothing treats a client-side callback as a credit.** A balance rises when a verified gateway confirmation arrives, never when a browser says the payment succeeded. See [Top-ups](/guides/topups/). - **Your UI shows `available`, not `balance`.** Showing `balance` tells a user they have money that an uncaptured authorization has already spoken for. - **Amounts are integer minor units end to end**, with no float anywhere on the path in or out. - **You branch on the `code` field of an error**, never on `title` or `detail`. See [Errors](/errors/). ## Reconciliation and monitoring - **You reconcile against `GET /v1/ledger/summary`**, which reports the identity that has to hold between what entered, what users and merchants hold, and what has left. See [The money model](/money-model/). - **You alert on the events that mean something went wrong rather than nothing happening**: `topup.amount_mismatch` (the processor took a different amount than the intent), `payment.expired` (a hold was never captured), and the dunning events on subscriptions. - **You have a retry and dead-letter path for your own webhook processing.** Event history is retained for 30 days and redelivery is available inside that window, but the platform's history is a recovery buffer, not your system of record. ## Limits - **You have read the [rate budgets and concurrency compartments](/platform/environments-and-limits/)** and your traffic shape fits them, including your retry behaviour under failure. A retry storm is a traffic shape. - **You handle `429` and `503` by honouring `Retry-After`** rather than retrying immediately. ## Support - **You know your severity levels and how to raise an incident.** See [Support, SLAs and status](/platform/support/). - **You know that WalletD support cannot read your data without a grant you create**, and who on your side is allowed to create one. ## Sign-off | Area | Proven by | Owner | Date | |---|---|---|---| | Credentials in a secret manager, scoped, rotation rehearsed | | | | | Idempotent replay tested | | | | | Webhook signature, tolerance and replay tested | | | | | No client-side credit path | | | | | Reconciliation running against the ledger summary | | | | | Alerts on mismatch, expiry and dunning | | | | | Behaviour under 429 and 503 tested | | | | | Incident path and contacts agreed | | | | ============================================================================== # Source: https://docs.walletd.io/platform/security.md ============================================================================== # Security for integrators This page is about the security properties you can rely on while building, and the ones you have to supply yourself. It says what is not offered as plainly as what is, because an integration designed around a control that does not exist is worse than one designed without it. ## Credentials Three things authenticate against the API, and they fail in different ways. ### Tenant API keys ``` sk_{env}_{key-id}_{secret} ``` The `env` segment is `sandbox` or `live`. The key id is a UUID: it is identifying, not secret, and it exists so verification is a single row lookup rather than a scan of every hash. The secret is 32 random bytes. - **Only an Argon2id hash of the secret is stored.** The token is displayed exactly once, at creation. There is no recovery path, and asking us for it will not produce one. If you lose it, issue a new key and revoke the old one. - **Keys expire.** Every key is issued with a one year lifetime. A credential nobody is ever forced to rotate is a credential nobody rotates; a year makes rotation planned work rather than an outage. Put the expiry in your calendar the day you issue the key. - **Verification has two caches.** authsvc caches successful verification for 60 seconds; walletd caches positive introspection for 60 seconds and negative answers for 10 seconds. Cache keys are SHA-256 digests. Revocation clears the receiving authsvc process's cache, but other replicas may retain theirs. With these defaults, allow approximately **two minutes plus request latency** for stale acceptance to disappear, and verify the old key is rejected. This is a cache-derived allowance, not a measured propagation SLA. - **Failures are opaque.** A wrong secret, a revoked key, an expired key and an environment mismatch all answer the same way. Nothing in the response tells an attacker which of those it was. - **Issuance and revocation are audited** in the same database transaction as the change itself. ### Rotation Rotation is create-then-revoke, in that order, and the overlap is the point. 1. Issue a second key with the same scopes. 2. Deploy it. Both keys are valid, so nothing is racing a restart. 3. Confirm the old key has stopped being used. 4. Revoke the old key, and allow for both cache layers, then verify the old key is refused before closing the rotation. Do this on a schedule rather than after an incident. A key you rotate quarterly is a key you can rotate in an hour when you have to. ### End-user tokens An RS256 JWT that acts for exactly one user, minted by your backend exchanging its API key. | Claim | What it carries | |---|---| | `sub` | The user's external id, as you supplied it | | `ten` | The tenant the issuing key belongs to | | `env` | `sandbox` or `live`, from the key | | `scp` | The granted scopes | | `iss`, `exp`, `jti` | Issuer, expiry, token id | The `kid` header names the signing key. `walletd` verifies the token locally against the JWKS with the issuer, the expiry and the algorithm all pinned, so verification costs no network round trip on the hot path. **Default TTL is 15 minutes; the maximum accepted is 1 hour.** Ask for longer and you get an hour, silently clamped. Two rules bound what such a token can do: its scopes cannot exceed the scopes of the key that minted it (`403 scope_exceeds_key` at exchange time), and it is pinned to its own user regardless of scope, so `balances:read` on a user token does not read a different user's balance. > **Warning** > > A user token cannot be revoked before it expires. There is no revocation list for them and no introspection call in their path: the short TTL **is** the revocation mechanism. If you need a user's access to stop, stop minting tokens for them and wait out at most the TTL you chose. This is the single strongest argument for leaving the TTL at 15 minutes rather than raising it for convenience. If your users sign in through the WalletD identity provider instead, their access token is their wallet credential and there is no exchange step. Those tokens carry a fixed consumer scope set decided by the platform, not by the token. ### Signing key rotation Rotation is live and needs no restart. A new signing key is inserted; running replicas reload on an interval, so the JWKS gains the new `kid` while the old one keeps verifying; `walletd` fetches on first sight of an unknown `kid`. Old keys are retired only after every token they signed has expired. Use the longest effective lifetime across user and staff tokens, plus clock-skew allowance, measured from the last issuance with that key. Defaults are a one-hour user-token maximum and a two-hour staff-token lifetime; deployments can override them. A one-hour retirement wait is insufficient for default staff sessions. The tooling refuses to retire the last active key. The verifier holds no lock across its network fetch: one refresh is in flight at a time and stale-but-known keys are served immediately, so a slow JWKS fetch degrades to slightly stale keys rather than to a stalled request queue. ## Scopes and least privilege A scope is `resource:action`. A credential carries a set, and a handler refuses anything outside it with `403 insufficient_scope`. The full table is on [Authentication](/authentication/). Two habits are worth the small effort: **Issue a key per job, not per company.** The key your checkout service uses needs `payments:write` and nothing else. The key your reconciliation job uses needs `ledger:read` and `payments:read`. A single `*` key used by six services means every one of those services is a full compromise, and an incident in any of them forces you to rotate for all six at once. **Mint user tokens with the scopes that screen needs.** The exchange lets you narrow, and narrowing is free. A wallet screen that only displays a balance does not need `transfers:write`. Some surfaces refuse user tokens entirely no matter the scope. Credit lines and tier limits are key-or-staff only, because a user must never be able to raise their own limits. ## Webhooks Every delivery carries a signature over the exact bytes of the body. ``` X-Wallet-Signature: t=1757772000,v1= X-Wallet-Event-Id: 018f... ``` The signed value is `t` and the raw body joined by a literal dot, `"."`, HMAC-SHA256 under your endpoint secret, hex encoded. The secret is `whsec_` plus 32 random bytes and is returned exactly once, at registration. Verifying correctly is four steps, and the order matters: 1. Read the **raw body bytes**, before any JSON parsing or framework re-serialization. A body that has been decoded and re-encoded will not verify, and that is the signature doing its job. 2. Recompute the HMAC and compare in **constant time**. A byte-by-byte comparison that returns early leaks the answer. 3. Reject if `t` is more than **5 minutes** from your clock in either direction. This is what stops a captured delivery being replayed at leisure. 4. Only then parse the payload. Working implementations in six languages are on [Webhooks](/webhooks/). **Idempotency on your side too.** Delivery is at-least-once: an endpoint that times out after processing will be retried, up to 12 attempts spread over roughly a day. Deduplicate on the verified body’s `(tenant_id, id)`; the `X-Wallet-Event-Id` header is unsigned and advisory. Acknowledge after durable inbox acceptance. Commit the worker’s local business effect and processed marker in one transaction. An event processed twice is a bug you own, not one the signature can catch. **Endpoint URLs are validated at registration, not per delivery.** The scheme must be `http` or `https`, the host must resolve, and it must not resolve to a loopback, private, link-local, multicast or unspecified address. **Redirects are refused at delivery time**: a public endpoint answering `307` would otherwise re-post your fully signed body to any target it named, on every retry. If your infrastructure moves an endpoint, register the new URL rather than redirecting to it. ## Idempotency is a safety property, not a convenience Every money-moving `POST` requires an `Idempotency-Key`. A request without one is refused before anything happens. walletd stores the response with its domain state and outbox in one local transaction. ledgerd commits the accounting posting separately. A failure between those commits can leave money posted before a response is stored; deterministic posting keys and stored-result probes support repair on retry. Keep the same request and key when the outcome is uncertain. Once stored, the response body and status are replayed. Reusing the key with different parameters returns `409 idempotency_key_reuse`. Machine keys share a tenant namespace, including merchant-bound keys: include an integration or merchant identifier in the key. Derive the key from your own business object. A key from `uuid4()` per attempt, or from the clock, gives every retry a fresh key and therefore moves money on every retry. [Idempotency](/idempotency/) has the rules and the per-language retry helpers. ## What is recorded, and for how long | What | Contents | Retention | |---|---|---| | Idempotency records | Your key, a hash of the request, the stored response | Kept. No expiry is configured in `walletd` today, so a replay keeps answering. The ledger's own copy of the key is purged after 30 days by default | | Webhook events and delivery attempts | The event payload, the target endpoint, each attempt's status and response code | 30 days by default, then pruned in bounded batches | | Audit log | Every credential and configuration change, with actor, object, before and after | Kept. Append-only by database trigger, and hash-chained per tenant so a rewrite that bypassed the trigger is detectable | | Access log | Sensitive reads, with the support grant that authorised them where one applies | Kept. Append-only by the same trigger | | Request logs | Method, path, status, byte count, duration, request id | Operational retention, set per deployment | Request logs carry no bodies, no headers and no credentials. Internal errors are logged and never returned: a handler that fails unexpectedly answers a generic `500` problem rather than leaking a stack trace or a query. Request bodies are size-capped at 1 MiB for the API and 64 KiB for the auth endpoints, and the auth endpoints reject unknown JSON fields outright. ## Transport TLS terminates at your deployment's edge, in front of the gateway, and `http` is redirected to `https` there. The gateway itself and every service behind it listen on the deployment's own private network. The negotiated cipher suites and the minimum TLS version are therefore a property of that edge, configured with the rest of your infrastructure, and not something the application pins. Ask for your deployment's edge configuration if your review needs a number. Only three surfaces are publicly routed in the reference deployment: the API gateway, the identity endpoints and the operator console. `walletd`, `authsvc` and `ledgerd` are not directly reachable from outside. Outbound webhook deliveries are made over whatever scheme you registered. Register `https`. ## What is not offered today Stated so you can design around it, rather than discover it during a security review. - **No IP allowlisting for your API calls.** There is no configuration, per tenant or per key, that restricts which source addresses may present a credential. Source addresses are used at the edge as a rate-limit identity, which is a capacity control and not an access control. Your API key is the only thing standing between a caller and your tenant, so treat it accordingly. - **No mutual TLS for partner callers.** There is no client-certificate authentication on the public API and no trust bundle for validating one. The edge terminates TLS with a server certificate and does not request a client certificate. Authentication is the bearer credential and nothing else. - **No service-to-service mTLS inside a deployment.** Services communicate over the deployment's private network. This is tracked as a gap in the compliance mapping rather than claimed as implemented. - **No static egress address or client certificate for outbound webhooks.** You cannot pin deliveries to a known source address or certificate. **Verify the signature.** That is the control this platform actually provides, and it is a stronger one than an address allowlist because it survives infrastructure changes on both sides. - **No sandbox and live data-plane split.** The environment is carried on the credential and verified against the key record, but keys of both environments issued against one deployment reach the same data. Non-production work belongs in a non-production deployment. See [Environments, limits and quotas](/platform/environments-and-limits/). - **No tenant-managed encryption keys and no bring-your-own-KMS for the API surface.** Signing keys and secrets are managed within the deployment. If your security review requires one of these, say so during the commercial conversation rather than during integration. They are product decisions with owners, not oversights. ## Reporting a vulnerability Send it to **security@walletd.io**. The same address is published at `/.well-known/security.txt` on this site. Please include enough to reproduce: the endpoint, the request, what you observed and what you expected. If you have a proof of concept, describe it rather than running it against a live partner deployment. **What we commit to.** A report is triaged on the same severity model as any other incident, and acknowledged on the target published for the severity we assign it: 30 minutes for anything we can confirm puts money or availability at risk, and we tell you in the acknowledgement which severity we chose and why. The targets are on [Support, SLAs and status](/platform/support/). **What we ask.** Give us a reasonable window to fix before publishing, do not access, modify or retain data belonging to anyone else, and do not run automated scanning that degrades a live deployment. We do not run a paid bounty programme; we do credit reporters who want to be credited. ## Next - [Authentication](/authentication/) for the scope table and the HTTP client in six languages. - [Webhooks](/webhooks/) for verification code in six languages. - [Idempotency](/idempotency/) for key selection and retry policy. - [Architecture overview](/platform/architecture/) for what verifies what, and where the trust boundaries fall. ============================================================================== # Source: https://docs.walletd.io/platform/support.md ============================================================================== # Support, SLAs and status ## Our staff cannot see your data without your recorded approval This is the part of the support model that is enforced in code rather than promised in a contract, so it is worth stating precisely. A WalletD support engineer signs in with a vendor-support identity. That identity has **no standing access to your data**. Every request it makes against your deployment is refused with `403 support_access_required` until an administrator on your side has created a grant. There is exactly one exception: an engineer may ask whether they currently hold a grant, so the console can show "waiting for your approval" instead of an error page. A grant is created by **your** administrator, from your operator console, and carries: | Field | Rule | |---|---| | The engineer | Named individually. A grant authorises one person, not a role or a team | | `ticket_reference` | Required, up to 128 characters. There is no grant without a ticket | | `reason` | Required, up to 1000 characters, stated in free text | | `expires_at` | Required, in the future, and **at most 24 hours** from creation | The 24 hour ceiling is checked in the service and again as a database constraint, so no configuration, no operator and no code path can extend a grant beyond a day. A longer engagement is a new grant against a new decision, which is the point. **One live grant per engineer.** A second grant for someone who already holds one is refused with `409 support_access_already_active`, so access cannot be quietly stacked or extended by re-approval. **Read-only.** A vendor-support principal is minted with reading scopes and nothing else: users, balances, payments, merchants, rewards, subscriptions, top-ups, webhooks, the ledger and the audit log. There is no write scope to hold, so there is no configuration in which a support engineer moves your money, changes a limit or edits a record. **Every request is recorded against the grant.** While a grant stands, each request the engineer makes writes an access-log row naming the exact approval that authorised it, *including requests that the scope check then refuses*. If that record cannot be written, the request is refused. Access does not proceed without an access record, which is the difference between an audit trail and an audit trail that is missing the interesting minute. **Revocation is immediate, and the evidence outlives the grant.** Your administrator can end a grant at any moment; the engineer's next request is refused. Expired and revoked grants stay in the history, and so does everything recorded under them. The access log and the audit log are append-only: a database trigger refuses `UPDATE` and `DELETE` on both, and the audit log is additionally hash-chained per tenant so a rewrite that bypassed the trigger is still detectable. > **Warning** > > This mechanism governs **WalletD's** staff. Your own support team's authority comes from their standing role in your tenant and is not affected by any of it, which is why nothing here should be read as a restriction on your first-line operations. Grant management lives in the operator console and is marked internal in the API description; it is not part of the integration surface you build against. More broadly: no production PII, identity document, secret or ledger entry is copied into a central WalletD system by default. Remote support works inside your deployment, through your identity boundary. Emergency access uses the same grant mechanism. There is no standing bypass account. ## Severity and response targets These are **WalletD's defaults unless your agreement says otherwise**. Your order form names your SLA tier and its coverage, and where the two disagree the agreement governs. | Severity | Definition | Acknowledge | Update cadence | Coverage | |---|---|---|---|---| | S1 | Money at risk or the API unavailable for the partner | 30 min | hourly | 24×7 | | S2 | A money path degraded, workaround exists | 2 h | every 4 h | 24×7 | | S3 | Non-money defect, integration blocked | 1 business day | daily | business hours | | S4 | Question, documentation, enhancement | 2 business days | on change | business hours | "Acknowledge" means a human has read the ticket, assigned a severity and told you what it is. It does not mean a fix or a root cause. "Update cadence" is the rhythm you can expect until the severity drops or the incident closes, whether or not there is news, because silence during an incident is itself a failure mode. **Severity is assigned by impact, not by feeling.** A `429` on a batch job you can reschedule is not an S1. A payment path returning refusals for live customers is, even if the dashboard is green. If we disagree with your assessment we will say so in the acknowledgement rather than silently downgrade it. A security vulnerability report is triaged on this same model. See [Security for integrators](/platform/security/) for where to send one. ## Escalation 1. **Support** takes the ticket, assigns the severity and owns the updates. 2. **Engineering on-call** is engaged for anything S1 or S2, and for an S3 that turns out to be a defect rather than an integration question. 3. **The founder** is the final escalation, for a target missed, a severity disputed, or a commercial decision the support path cannot make. Escalating is a request you make on the ticket, not a separate address. Raising the same issue through a second channel splits the history and slows it down. ## Incident communication Two different things are published in two different places, because they have two different audiences. **Shared services**, meaning this documentation site, the identity endpoints, the public sites and the demo environment, are on the public status page: - [status.walletd.io](https://status.walletd.io): Live checks for the shared WalletD services. Public, no account needed. **Your own deployment** is not on the public status page. It runs on your infrastructure, under your operations, and its health is not ours to publish. Incidents affecting it are communicated on the channel named in your agreement, on the cadence in the table above. If your deployment appears on the status page at all, it is as a private component group visible only to you. The distinction matters during an incident: a green public status page says nothing about your deployment, and a red one does not necessarily mean your deployment is affected. Check the channel, not the page. ## Next - [Security for integrators](/platform/security/) for credentials, signing, retention and vulnerability reporting. - [Architecture overview](/platform/architecture/) for what a deployment contains and who operates it. - [Errors](/errors/) before raising a ticket about a refusal. Most refusals are documented, deliberate and answered faster by the code table than by us.