---
operation_id: mint_trial_key
method: POST
path: /v1/keys/trial
summary: Self-Service-Trial-Key minten (7 Tage, read-only)
tags: [keys]
stability: beta
since_version: 0.1.0
auth: none
data_product: platform
snapshot_aware: false
attribution_block: false
rate_limit_tier: public
related:
  - /v1/keys/trial/{key}
  - /v1/admin/api-keys
---
# `POST /v1/keys/trial` — Self-Service-Trial-Key

Mintet ohne Registrierung einen befristeten Trial-API-Key (tier `trial`).
Der Request-Body ist optional und kennt zwei freiwillige Felder:

| Feld | Typ | Bedeutung |
|---|---|---|
| `source` | Slug `[a-z0-9_-]`, max. 64 | Kampagnen-/Kanal-Zuordnung (z.B. `"dachgold"`, `"landing"`). Keine PII. |
| `email` | E-Mail-Adresse | Kontaktadresse zur Key-Zuordnung. Optional, **ungeprüft**, kein Mailversand. |

Der Key wird genau einmal im Klartext zurückgegeben (`Cache-Control: no-store`)
und ist danach nicht wiederherstellbar — gespeichert wird nur SHA-256-Hash +
Prefix.

```bash
# PII-frei (DACHGOLD-Muster)
curl -X POST https://api.lodapi.de/v1/keys/trial \
  -H "Content-Type: application/json" \
  -d '{"source": "dachgold"}'

# mit Lead-Zuordnung (lodapi.de/key-Muster)
curl -X POST https://api.lodapi.de/v1/keys/trial \
  -H "Content-Type: application/json" \
  -d '{"source": "landing", "email": "konsti@scenerii.com"}'
```

Response (200):

```json
{
  "key": "lod_td8j5b86m6s32ujqbdmf8wwah5sv99xd",
  "tier": "trial",
  "expires_at": "2026-07-14T10:12:01Z",
  "limits": { "requests_per_minute": 60, "requests_per_day": 5000 },
  "scopes": ["read"],
  "docs_url": "https://lodapi.de/docs"
}
```

- **TTL**: 7 Tage ab Mint. Abgelaufene Keys werden beim Lookup wie revoked
  behandelt (401 auf der Daten-API); die Row bleibt für Conversion-Analytics.
- **Limits**: 60 req/min, 5000 req/Tag — deklariert, Enforcement soft
  (ADR-0014 „Quota = soft"): sichtbar über `X-Lodapi-Quota-Used` /
  `X-Lodapi-Quota-Limit` (Budget = 7 × 5000 = 35 000).
- **Scope**: read-only — die gesamte `/v1`-Daten-API ist read-only, das
  `scopes`-Feld dokumentiert den Contract.
- **`email`** (seit 2026-08-17, Konsti-Entscheidung „Mail gegen Key"): landet
  in `lodapi_meta.api_key.contact_email` — dieselbe Spalte wie beim
  Admin-Mint, dadurch ohne Extra-Code in `GET /v1/admin/api-keys` sichtbar.
  Keine Verifikation, kein Double-Opt-in, **kein Versand aus diesem
  Endpoint**. Ein Mint ohne Adresse liefert exakt denselben Key — die
  Adresse ist nie Voraussetzung. Die Antwort spiegelt sie **nicht** zurück
  (Response-Contract unverändert). Rechtsgrundlage/Speicherdauer/Löschweg:
  `05_operations/trial-key-email-capture.md` (Prüfauftrag Themis).
- **Abuse-Schutz auf dem Mint**: max. N Keys pro IP pro 24 h
  (`LODAPI_TRIAL_MINTS_PER_IP_PER_DAY`) → `429` mit `Retry-After`-Header und
  `retry_after`-Member (Sekunden) im problem+json-Body.
- **Vom Keyless-Tageslimit ausgenommen** (seit T-0079): `/v1/keys/**` zählt
  nicht gegen die 1.000 keyless Requests pro IP und Tag und wird auch nie mit
  der Keyless-429 beantwortet. Der Weg zum Key darf nicht hinter dem Limit
  liegen, aus dem der Key herausführt — siehe [Limits](/docs/guides/rate-limits).
- **Kill-Switch**: `LODAPI_TRIAL_KEYS_ENABLED=0` → `503`. Clients sollen
  still degradieren (Angebot nicht zeigen, später erneut versuchen) — der
  Endpoint darf jederzeit ausfallen, ohne Consumer zu brechen.
- **Kontext**: eingeführt für die DACHGOLD-Promo-Integration (Key als
  „Take-away" nach dem ersten Spiel-Run, Konsti-Entscheidung „Key nach
  erstem Wow, kein Gatekeeper"); generisch für jede Kampagne nutzbar.
  Zweiter Consumer: die Self-Serve-Seite `lodapi.de/key` (`source: "landing"`).

## Stolperdrähte

- Das Per-IP-Limit greift auf die **echte Peer-Adresse**, nicht auf
  `request.client.host`: uvicorn läuft mit `--forwarded-allow-ips "*"` und
  liefert dort den **ersten** `X-Forwarded-For`-Eintrag — den kann der Client
  selbst schreiben, die Mint-Quote wäre per Header beliebig oft zurücksetzbar
  (T-0080, gefixt 2026-08-19). Gezählt wird der **rechteste** Eintrag, den
  Caddy anhängt; gemeinsamer Helper `keyless_limit.client_ip()`, denselben
  nutzt das Keyless-Tageslimit. Regressionstest
  `tests/test_trial_keys.py::TestMintIpIsSpoofSafe` fährt dafür den echten
  Prod-ASGI-Stack inklusive `ProxyHeadersMiddleware` — ohne die Schicht ist
  der Spoof im Test gar nicht nachstellbar und ein Test dagegen wertlos.
- Hängt ein **eigener** Reverse-Proxy des Nutzers davor (statt Direkt-Call vom
  Browser), zählt dessen Ausgangs-IP für alle seine Nutzer zusammen. CORS-Origins
  sind offen (`*`): Browser können direkt gegen `api.lodapi.de` minten, ein
  Proxy ist nicht nötig.
- **CORS-Preflight**: ein JSON-POST (`Content-Type: application/json`) ist
  kein „simple request" — der Browser schickt vorher `OPTIONS`. `POST` muss
  dafür in `allow_methods` der CORSMiddleware stehen (seit 2026-08-17, davor
  `["GET", "OPTIONS"]` → **jeder** browser-seitige Mint scheiterte am
  Preflight mit `400 Disallowed CORS method`). curl merkt davon nichts —
  der Fehler ist nur im Browser sichtbar. Festgenagelt in
  `tests/test_trial_keys.py::test_cors_preflight_allows_browser_mint`.
  Ein bodyloser `POST` ohne Header bleibt preflight-frei und funktioniert
  auch ohne diese Einstellung — dann aber **ohne `source` und ohne `email`**:
  ein Body braucht `Content-Type: application/json`, und der macht den
  Request preflight-pflichtig. **Lead-Capture aus dem Browser hängt damit am
  deployten CORS-Fix** — es gibt keinen preflight-freien Weg, der die Adresse
  mitnimmt.
- **Kein `email` in der URL.** Ein `?email=`-Query-Param wäre der naheliegende
  Trick, um den Preflight zu umgehen — er schreibt die Adresse aber in
  Caddy-Access-Logs und `api_key_usage.path`, also an zwei Stellen ohne
  Löschkonzept. Bewusst nicht gebaut.
- Zusätzliche, unbekannte Body-Felder werden von Pydantic still verworfen
  (kein `extra="forbid"`) — ein Tippfehler wie `mail` statt `email` fällt
  also **nicht** als 422 auf, die Adresse ist dann einfach weg.
- `source` ist ein Slug (`[a-z0-9_-]`, max. 64) — alles andere gibt 422.
  Eine syntaktisch kaputte `email` gibt ebenfalls 422 (kein Key wird geminted).

> **Limit-Stand (2026-08-19)**: Code-Default 3 Mints/IP/24 h; **prod läuft auf 10** (`LODAPI_TRIAL_MINTS_PER_IP_PER_DAY=10` in `code/prod/.env`, Decision 2026-08-17-06). Frontend nennt bewusst keine Zahl.