# 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 <credential>
```

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
<?php

class WalletDProblem extends RuntimeException
{
    public string $code;
    public string $detail;
    public int $httpStatus;

    public function __construct(array $payload, int $status)
    {
        $this->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<string,mixed>|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<String> 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.
