# Mail-To-Nostr — Agent & Integration Reference

Send emails to `npub1...@mail-to-nostr.com` and they arrive as encrypted Nostr Direct Messages (NIP-04). Free Tier works out of the box. Optional paid features activate for 30 or 365 days via Lightning.

All endpoints are on the same host. Base URL: `https://mail-to-nostr.com`

## Endpoints

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/features` | GET | Feature catalog with metadata + pricing (always fetch at runtime) |
| `/prices` | GET | Price list (per-feature, per-duration) |
| `/scrypt-params` | GET | Scrypt parameters + salts for client-side pubkey hashing |
| `/pubkey` | GET | Gateway Nostr public key (for encryption key derivation) |
| `/checkout` | POST | Buy + bind features in one step — returns BOLT11 invoice |
| `/checkout/{payment_hash}/status` | GET | Poll payment status — **returns nonce on success, then deletes the record** |
| `/purchase-status` | GET | Check active features for a pubkey hash |
| `/renew` | POST | Renew active features — returns BOLT11 invoice |

---

## GET /features

Returns the full feature catalog. **Always fetch at runtime** — features and prices may change.

```bash
curl https://mail-to-nostr.com/features
```

Response fields per feature:

| Field | Type | Description |
|-------|------|-------------|
| `key` | string | Feature identifier (e.g. `"relays"`, `"email"`) |
| `name` | string | Human-readable display name |
| `description` | string | What the feature does |
| `max_value` | int | Maximum purchasable value (1 = flat/toggle, >1 = quantity or tier) |
| `bound_type` | string | How the bound value is encoded: `"int"`, `"string"`, `"list"` |
| `bound_format_hint` | string | Example format for the bound value |
| `price_per_unit` | int | Price in sats per unit or tier |
| `unit_label` | string\|null | Unit label (`"relay"`, `"tier"`, null = flat pricing) |

If `unit_label` is `"tier"`, `max_value` is the highest tier (e.g. 2 = two tiers). If `null` and `max_value` > 1, it's a quantity. If `null` and `max_value` = 1, it's a flat one-time purchase.

Top-level response also includes `currency` (`"sats"`), `ttl_days`, `rate_limits` (`free_tier_mails_per_day`, `bonus_mails_per_feature`), and `eu_blocked` (boolean).

## GET /prices

```bash
curl https://mail-to-nostr.com/prices
```

Response:

| Field | Type | Description |
|-------|------|-------------|
| `currency` | string | `"sats"` |
| `prices` | object | `{feature_key: price}` — 30-day prices |
| `annual_prices` | object | `{feature_key: price}` — 365-day prices |
| `allowed_durations` | list | Allowed `duration_days` values (typically `[30, 365]`) |
| `rate_limits` | object | Free tier daily limit + bonus per feature |
| `eu_blocked` | bool | Whether the requesting IP is EU-blocked |

## GET /scrypt-params

```bash
curl https://mail-to-nostr.com/scrypt-params
```

Response:

| Field | Type | Description |
|-------|------|-------------|
| `pubkey_salt` | string | 64 hex chars — salt for pubkey hashing |
| `username_salt` | string | 64 hex chars — salt for username hashing |
| `N` | int | scrypt cost factor |
| `r` | int | scrypt block size |
| `p` | int | scrypt parallelism |
| `dklen` | int | output length in bytes |

## GET /pubkey

```bash
curl https://mail-to-nostr.com/pubkey
```

Returns the Gateway's Nostr public key (hex). Used to derive the checkout encryption key (see [Encryption](#encryption) below).

Response: `{"pubkey": "<64 hex chars>"}`

Gateway public key (hex): `ff68da295ddde0dc08362af8e549341dc168be7919dbee53d1870e69d9c38fe8`

---

## POST /checkout

Buy and bind features in a single call. The Payment Processor validates the request, calculates the price, creates a BOLT11 hold invoice, and returns it. Once paid, features are activated automatically.

### Request body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `pf` | object | yes | Purchased feature quantities: `{"relays": 2, "anonymous_sender": 1}` |
| `hp` | string | yes | Hash of recipient pubkey: `"scrypt:N=..:r=..:p=..:<hash>"` |
| `ef` | string | yes | NIP-44 encrypted bound features (concrete values) |
| `epf` | string | yes | NIP-44 encrypted purchased features (same as `pf`, encrypted) |
| `duration_days` | int | no | `30` (default) or `365` |
| `username_pub` | string | no | scrypt-derived pubkey when buying the `email` feature |
| `username_target_enc` | string | no | NIP-44 encrypted customer pubkey for username binding |
| `ownership_nonce` | string | no | Nonce from original purchase — proof of ownership for renew or re-register |

**Which pubkey?** The `hp` field is the hash of the **customer's** Nostr public key — the person who will receive emails as Nostr DMs. If you are buying features for yourself, hash your own npub.

### Response

| Field | Type | Description |
|-------|------|-------------|
| `invoice` | string | BOLT11 Lightning invoice |
| `payment_hash` | string | Payment hash for status tracking |
| `amount_sat` | int | Total amount in sats |
| `expires_at` | string | ISO 8601 timestamp (invoice expiry) |

**The nonce is NOT in this response.** After paying, poll `GET /checkout/{payment_hash}/status` — the nonce appears in the response once payment succeeds.

### Errors

| Status | Error | Cause |
|--------|-------|-------|
| 400 | various | Validation errors (invalid features, missing fields, etc.) |
| 403 | `not_available_in_eu` | EU IP detected |
| 503 | `Payment processor not ready` | Breez SDK not initialized |

---

## GET /checkout/{payment_hash}/status

Poll the status of a checkout request. This is how you detect payment completion and retrieve the nonce.

```bash
curl https://mail-to-nostr.com/checkout/abc123def456.../status
```

### Response

| Field | Type | Description |
|-------|------|-------------|
| `status` | string | `"pending"`, `"paid"`, `"success"`, `"failed"`, `"claim_failed"` |
| `amount_sat` | int | Invoice amount |
| `created_at` | string | ISO 8601 timestamp |
| `expires_at` | string | ISO 8601 timestamp |
| `paid_at` | string\|null | When payment was received |
| `nonce` | string\|null | **Purchase nonce** — appears here after successful payment |
| `error` | string\|null | Error details if failed |

**Payment lifecycle:**
1. `pending` — Invoice created, awaiting payment
2. `paid` — Payment received, processing (bind + claim)
3. `success` — Features activated, `nonce` is now available
4. `failed` — Bind failed, HTLC expires, funds return to payer

**Poll every 2-5 seconds after payment.** Once `status` is `"success"`, save the `nonce` — you need it for `/purchase-status` and `/renew`.

**Checkout records are retained for 7 days, but deleted on first successful poll.** When `status` reaches `"success"`, the record (including the nonce) is returned once and then immediately deleted — the next poll returns 404. If you never poll, the record survives 7 days (sensitive fields are sanitized after 1h, but the nonce is kept). Save the nonce as soon as you see it — `/purchase-status` and `/renew` work indefinitely with the nonce (it's stored permanently in the Gateway database).

---

## GET /purchase-status

Check which features are active for a pubkey hash.

### Query parameters

| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `h` | string | yes | Hashed pubkey — repeat for multiple: `?h=hash1&h=hash2` |
| `nonce` | string | yes | Purchase nonce (from `/checkout/{hash}/status` after payment) |

```bash
curl "https://mail-to-nostr.com/purchase-status?h=scrypt:N=16384:r=6:p=1:<hash>&nonce=<nonce>"
```

### Response

| Field | Type | Description |
|-------|------|-------------|
| `found` | bool | Whether an active purchase exists |
| `is_active` | bool | Whether features are currently active |
| `expires_at` | string | ISO 8601 expiry timestamp |

The raw pubkey never leaves the client — only the scrypt hash is sent.

---

## POST /renew

Renew currently active features. Reads the existing feature set, calculates the price for the chosen duration, and returns a new BOLT11 invoice. On payment, the expiry is extended.

### Request body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `hash_pubkey` | string | yes | Hashed pubkey: `"scrypt:N=..:r=..:p=..:<hash>"` |
| `nonce` | string | yes | Purchase nonce (from original checkout) |
| `duration_days` | int | no | `30` (default) or `365` |

```bash
curl -X POST https://mail-to-nostr.com/renew \
  -H "Content-Type: application/json" \
  -d '{
    "hash_pubkey": "scrypt:N=16384:r=6:p=1:<hash>",
    "nonce": "<your-nonce>",
    "duration_days": 365
  }'
```

### Response

| Field | Type | Description |
|-------|------|-------------|
| `invoice` | string | BOLT11 Lightning invoice |
| `payment_hash` | string | Payment hash for status tracking |
| `amount_sat` | int | Total amount in sats |

On payment, the expiry is extended by `duration_days` from the current expiry (not from payment time — remaining days are preserved).

**The nonce stays the same.** Renewal does not generate a new nonce — it extends the expiry of the features bound to the original nonce. Continue using the same nonce for all future `/purchase-status` and `/renew` calls.

### Errors

| Status | Error | Cause |
|--------|-------|-------|
| 400 | `No active purchase found` | No active features for this hash+nonce |
| 400 | `Nothing to renew` | Active features but price = 0 |
| 400 | `Invalid duration_days` | Duration not in `allowed_durations` |
| 403 | `not_available_in_eu` | EU IP detected |
| 502 | `Gateway unreachable` | Cannot verify purchase with Gateway |
| 503 | `Payment processor not ready` | Breez SDK not initialized |

---

## GET /renew/{payment_hash}/status

Poll the status of a renewal payment. Same pattern as checkout status polling.

```bash
curl https://mail-to-nostr.com/renew/abc123def456.../status
```

### Response

| Field | Type | Description |
|-------|------|-------------|
| `status` | string | `"pending"`, `"success"`, `"failed"` |
| `amount_sat` | int | Invoice amount |
| `new_expires_at` | string\|null | New expiry timestamp after successful renewal |

---

## Encryption

Feature configurations are encrypted before sending to the server. The server cannot read your feature values until an email arrives with your public key.

### NIP-44

This service uses [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md) ("Encrypted Direct Messages") for encryption. NIP-44 is a Nostr encryption standard based on HKDF + ChaCha-Poly1305.

**Reference implementations:**
- **JavaScript/Browser:** [`@noble/ciphers`](https://github.com/paulmillr/noble-ciphers) (used by our frontend)
- **Python:** [`pynostr`](https://github.com/honzonM/python-nostr) — `from pynostr.nip44 import NIP44Encryption`
- **Rust:** [`nostr-sdk`](https://github.com/rust-nostr/nostr) — `nip44::encrypt()`
- **Go:** [`nfield/go-nostr`](https://github.com/nfield/go-nostr)

NIP-44 takes a **sender private key** and a **recipient public key**, derives a conversation key via ECDH (secp256k1), then encrypts with HKDF + ChaCha-Poly1305. The output is a base64-encoded payload string.

### Checkout Encryption: Derived Key

The checkout flow uses a **deterministically derived key** — not your actual Nostr private key. This means you don't need to expose your nsec during checkout.

**Derivation:**

```
derived_privkey = sha256(gateway_pubkey_hex + customer_pubkey_hex)
```

Then ensure it's a valid secp256k1 private key (re-hash if 0 or ≥ curve order — practically never happens).

**You use this derived private key as the NIP-44 sender key**, encrypting to the Gateway's public key. The Gateway can derive the same key (it knows both pubkeys) and decrypt.

### What to Encrypt

Two JSON blobs, each base64-encoded before NIP-44 encryption:

**1. Bound features (`ef`)** — concrete feature values:

```json
{"relays": ["wss://relay.example.com", "wss://relay2.example.com"]}
```

**2. Purchased features (`epf`)** — same content as the `pf` field, encrypted:

```json
{"relays": 2}
```

**Format inside NIP-44:** `base64(JSON_string)` — the plaintext fed to NIP-44 encrypt is the base64 encoding of the normalized JSON string, not the raw JSON.

### JSON Normalization

Before base64-encoding, normalize the JSON:

1. **Lowercase** all keys
2. **Sort** keys alphabetically
3. **Remove** entries where value is `0` (but keep `false` — booleans are preserved)
4. **Compact JSON** — no whitespace, no newlines (`separators=(',', ':')`)

**Example:**

```python
import json, base64

# Input (unnormalized)
config = {"Relays": ["wss://a.com", "wss://b.com"], "rate_limit": 0}

# Step 1+2: lowercase + sort
normalized = {"relays": ["wss://a.com", "wss://b.com"]}  # rate_limit removed (value=0)

# Step 4: compact JSON
json_str = json.dumps(normalized, separators=(',', ':'))
# → '{"relays":["wss://a.com","wss://b.com"]}'

# Base64 encode (this is the plaintext for NIP-44)
plaintext = base64.b64encode(json_str.encode()).decode()
# → 'eyJyZWxheXMiOlsid3NzOi8vYS5jb20iLCJ3c3M6Ly9iLmNvbSJdfQ=='
```

**Nested values** (arrays, strings) are left as-is. Only top-level keys are lowercased and sorted.

---

## Checkout Flow (Step by Step)

### Step 1: Get scrypt parameters

```bash
curl https://mail-to-nostr.com/scrypt-params
```

```json
{
  "pubkey_salt": "<64 hex chars>",
  "username_salt": "<64 hex chars>",
  "N": 16384, "r": 6, "p": 1, "dklen": 32
}
```

### Step 2: Compute hash_pubkey

Hash the **customer's** pubkey (hex, not npub) with scrypt. The customer pubkey is the Nostr public key of the person who will receive emails as encrypted DMs.

```python
hash_pubkey = "scrypt:N=16384:r=6:p=1:" + scrypt(
    bytes.fromhex(customer_pubkey_hex),
    salt=bytes.fromhex(pubkey_salt),
    N=16384, r=6, p=1, dklen=32
).hex()
```

### Step 3: Get Gateway pubkey

```bash
curl https://mail-to-nostr.com/pubkey
# {"pubkey": "ff68da295ddde0dc08362af8e549341dc168be7919dbee53d1870e69d9c38fe8"}
```

### Step 4: Derive checkout encryption key

```python
import hashlib

raw = hashlib.sha256(
    (gateway_pubkey_hex + customer_pubkey_hex).encode()
).hexdigest()
# → This is your derived private key (for NIP-44 sender)

# Guard: re-hash if not a valid secp256k1 private key (extremely rare — ~2^-128)
SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
while int(raw, 16) == 0 or int(raw, 16) >= SECP256K1_N:
    raw = hashlib.sha256(raw.encode()).hexdigest()
derived_priv = raw
```

### Step 5: Normalize + encrypt bound + purchased features

Encrypt two JSON blobs (see [Encryption](#encryption) for details):

- **Bound features** (concrete values): `{"relays": ["wss://relay.example.com"]}` → normalize → base64 → NIP-44 encrypt → `ef`
- **Purchased features** (quantities): `{"relays": 1}` → normalize → base64 → NIP-44 encrypt → `epf`

### Step 6: POST /checkout

```bash
curl -X POST https://mail-to-nostr.com/checkout \
  -H "Content-Type: application/json" \
  -d '{
    "pf": {"relays": 1},
    "hp": "scrypt:N=16384:r=6:p=1:<hash>",
    "ef": "<NIP-44 encrypted bound features>",
    "epf": "<NIP-44 encrypted purchased features>",
    "duration_days": 30
  }'
```

Response:

```json
{
  "invoice": "lnbc1000n1p3qqq...",
  "payment_hash": "abcdef0123...",
  "amount_sat": 20,
  "expires_at": "2026-07-10T18:00:00Z"
}
```

### Step 7: Pay + poll for nonce

Open any Lightning wallet, paste the invoice, pay. Then poll:

```bash
curl https://mail-to-nostr.com/checkout/abcdef0123.../status
```

```json
{
  "status": "success",
  "nonce": "9f3a7b2c...",
  "paid_at": "2026-07-11T12:00:00Z"
}
```

**The server does NOT send the nonce automatically.** There is no DM, webhook, or callback. You must poll `/checkout/{payment_hash}/status` until `status` is `"success"`, then extract the nonce from the response.

**Save the nonce.** You need it for `/purchase-status` and `/renew`.

### Step 8: Verify (optional)

```bash
curl "https://mail-to-nostr.com/purchase-status?h=scrypt:N=16384:r=6:p=1:<hash>&nonce=9f3a7b2c..."
```

---

## Complete Python Example

```python
import hashlib, json, base64
from hashlib import scrypt
# You need a NIP-44 library, e.g. pynostr:
# pip install pynostr
from pynostr.nip44 import NIP44Encryption
from pynostr.key_utils import PrivateKey

BASE = "https://mail-to-nostr.com"
GATEWAY_PUBKEY = "ff68da295ddde0dc08362af8e549341dc168be7919dbee53d1870e69d9c38fe8"

# ── Convert npub → hex ──
# npub uses bech32 encoding. Decode the data part to get the 32-byte hex pubkey.
# Python one-liner using pynostr:
from pynostr.key_utils import pubkey_nip19
from pynostr.nip19 import nip19_decode  # or use bech32 library directly

# Alternative minimal bech32 decoder (no dependency):
def npub_to_hex(npub: str) -> str:
    """Decode npub (bech32) to 64-char hex pubkey."""
    import hashlib
    CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
    if not npub.startswith("npub1"):
        raise ValueError("Not an npub")
    data = npub[5:]  # strip "npub1"
    # Decode bech32 → bytes
    values = [CHARSET.index(c) for c in data[:-6]]  # strip checksum
    # Convert 5-bit groups to 8-bit bytes
    bits = 0
    nbits = 0
    result = bytearray()
    for v in values:
        bits = (bits << 5) | v
        nbits += 5
        while nbits >= 8:
            nbits -= 8
            result.append((bits >> nbits) & 0xFF)
    return result[:32].hex()  # first 32 bytes = pubkey

customer_npub = "npub1..."
pubkey_hex = npub_to_hex(customer_npub)
print(f"Pubkey hex: {pubkey_hex}")

# Step 1: Get scrypt params (use requests or httpx)
import requests
params = requests.get(f"{BASE}/scrypt-params").json()

# Step 2: Hash pubkey
hp = "scrypt:N=16384:r=6:p=1:" + scrypt(
    bytes.fromhex(pubkey_hex),
    salt=bytes.fromhex(params["pubkey_salt"]),
    N=16384, r=6, p=1, dklen=32,
).hex()

# Step 3: Derive encryption key
# sha256(gw_pub + cust_pub) — may need re-hash if ≥ curve order (extremely rare)
SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
raw = hashlib.sha256((GATEWAY_PUBKEY + pubkey_hex).encode()).hexdigest()
while int(raw, 16) == 0 or int(raw, 16) >= SECP256K1_N:
    raw = hashlib.sha256(raw.encode()).hexdigest()
derived_priv = raw

# Step 4: Normalize + encrypt
def normalize(obj):
    norm = {}
    for k, v in sorted(obj.items()):
        if v == 0 and not isinstance(v, bool):
            continue
        norm[k.lower()] = v
    return json.dumps(norm, separators=(',', ':'))

bound_config = {"relays": ["wss://relay.example.com"]}
purchased = {"relays": 1}

ef_plaintext = base64.b64encode(normalize(bound_config).encode()).decode()
epf_plaintext = base64.b64encode(normalize(purchased).encode()).decode()

# NIP-44 encrypt (using derived key as sender, gateway pubkey as recipient)
nip44 = NIP44Encryption(PrivateKey(derived_priv))
ef = nip44.encrypt(ef_plaintext, GATEWAY_PUBKEY)
epf = nip44.encrypt(epf_plaintext, GATEWAY_PUBKEY)

# Step 5: Checkout
resp = requests.post(f"{BASE}/checkout", json={
    "pf": purchased,
    "hp": hp,
    "ef": ef,
    "epf": epf,
    "duration_days": 30,
})
data = resp.json()
print(f"Invoice: {data['invoice']}")
print(f"Amount: {data['amount_sat']} sats")

# Step 6: Pay the invoice in your Lightning wallet, then poll:
# NOTE: The server does NOT send the nonce via DM or webhook.
# You must poll /checkout/{hash}/status yourself.
import time
while True:
    status = requests.get(
        f"{BASE}/checkout/{data['payment_hash']}/status"
    ).json()
    if status["status"] == "success":
        print(f"Nonce: {status['nonce']}")
        nonce = status["nonce"]
        break
    elif status["status"] == "failed":
        print(f"Failed: {status.get('error')}")
        break
    time.sleep(3)
```

---

## SMTP Usage

Send emails to Nostr public keys via standard SMTP (port 25, STARTTLS):

```
To: npub1...@mail-to-nostr.com
From: you@example.com
Subject: Hello

Your message here.
```

Recipients who purchased the `email` feature can also use readable usernames:

```
To: user@mail-to-nostr.com
```

HTML emails are converted to Markdown (tracking pixels stripped). Attachments are discarded. Plain text preferred when available; HTML used when text/plain is missing or a stub (e.g. "view in browser"). Max 5 MB.

### NIP-05 Opt-in (Optional)

NIP-05 is **off by default**. To activate it, the pubkey owner sends a NIP-04 encrypted DM to the gateway:

- **Gateway pubkey:** `npub1la5d522amhsdczpk9tuw2jf5rhqk30ner8d7u573su8xnkwr3l5q5jg28t`
- **DM content:** `user@mail-to-nostr.com` (or just `user`)

The gateway decrypts the DM, verifies the sender is the registered owner of that username, and enables NIP-05. After that:

```
GET https://mail-to-nostr.com/.well-known/nostr.json?name=user
→ {"names": {"user": "<pubkey_hex>"}}
```

NIP-05 stays active as long as:
1. The email feature is active (not expired)
2. The registered owner has not changed (ownership transfer invalidates NIP-05 until the new owner sends a DM)

Free tier: 5 emails/day. Each purchased feature adds a bonus daily allowance.

---

## Email Username Feature

The `email` feature lets users receive emails at `<username>@mail-to-nostr.com` instead of `<npub>@mail-to-nostr.com`. The username is never stored — instead, a deterministic Nostr keypair is derived from it via scrypt.

### How It Works

The username → pubkey mapping is stored as `scrypt(username) → NIP-44-encrypted-customer-pubkey`. The server can look up the scrypt-derived pubkey but cannot decrypt the target (your real npub) until an email arrives.

### Step-by-Step (Agent)

**1. Derive username keypair** (scrypt, using `username_salt` from `/scrypt-params`):

The scrypt output is interpreted directly as a secp256k1 private key. Since the output is random 32 bytes, it may (with probability ~2^-128) be invalid (0 or ≥ curve order). In that case, repeatedly SHA-256 the hex string until valid. This is deterministic — both sides (browser JS and Python) always derive the same keypair.

```python
import hashlib

SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141

def ensure_valid_privkey(priv_hex: str) -> str:
    """Ensure a derived private key is valid for secp256k1 (1 ≤ k < n).
    If invalid (0 or ≥ curve order), repeatedly SHA-256 until valid.
    In practice this loop never executes — but both client and server
    must implement it identically to guarantee the same keypair."""
    val = int(priv_hex, 16)
    while val == 0 or val >= SECP256K1_N:
        priv_hex = hashlib.sha256(priv_hex.encode()).hexdigest()
        val = int(priv_hex, 16)
    return priv_hex

username_bytes = username.encode("utf-8")
derived = scrypt(
    username_bytes,
    salt=bytes.fromhex(params["username_salt"]),
    N=16384, r=6, p=1, dklen=32,
)
username_priv_hex = ensure_valid_privkey(derived.hex())

# Derive pubkey from private key via secp256k1 (x-only, 32 bytes)
# Using coincurve (pip install coincurve):
from coincurve import PrivateKey
sk = PrivateKey(bytes.fromhex(username_priv_hex))
pub_compressed = sk.public_key.format(compressed=True)  # 33 bytes
username_pub_hex = pub_compressed[1:].hex()  # drop prefix → x-only 32 bytes

# Or using ecdsa (pip install ecdsa):
# from ecdsa import SigningKey, SECP256k1
# sk = SigningKey.from_string(bytes.fromhex(username_priv_hex), curve=SECP256k1)
# username_pub_hex = sk.get_verifying_key().to_string("uncompressed").hex()[2:]  # drop 04 prefix
```

**2. Encrypt customer pubkey** — NIP-44 encrypt your real pubkey hex, using `username_priv` as sender and `gateway_pubkey` as recipient:

```python
username_target_enc = nip44_encrypt(
    sender_priv=username_priv_hex,
    recipient_pub=GATEWAY_PUBKEY,
    plaintext=customer_pubkey_hex,
)
```

**3. Add to checkout request:**

```json
{
  "pf": {"email": 1, "relays": 1},
  "hp": "scrypt:N=16384:r=6:p=1:<hash>",
  "ef": "<NIP-44 encrypted bound features>",
  "epf": "<NIP-44 encrypted purchased features>",
  "username_pub": "<username_pub_hex from step 1>",
  "username_target_enc": "<username_target_enc from step 2>",
  "duration_days": 30
}
```

**4. Renewing or Re-registering a Username** — The `ownership_nonce` is the nonce from the **original purchase** (not the most recent renewal). It serves as proof of ownership. There are three scenarios:

| Scenario | Feature state | `check-username` | What to do |
|----------|--------------|-------------------|------------|
| **New registration** | — | 200 | `POST /checkout` without `ownership_nonce` |
| **Renew** (feature still active) | active | 409 | `POST /renew` with the current nonce (no `ownership_nonce` needed — `/renew` uses the active nonce directly) |
| **Re-register** (feature expired, username still yours) | expired | 409 | `POST /checkout` with `ownership_nonce` set to the nonce from the original purchase |

**Key points:**

- `/check-username` returns 409 for **any** username that was ever registered — active or expired. The binding is permanent.
- `ownership_nonce` is checked against `beleg_nonce` — the nonce stored at first registration. This nonce never changes, even after renewals.
- **Without the original nonce, an expired username cannot be re-registered** — it is permanently locked. This is by design (security).
- `/renew` does **not** generate a new nonce. It extends the expiry of the existing bound feature. The nonce stays the same.

```json
{
  "...": "...",
  "ownership_nonce": "<nonce-from-original-purchase>"
}
```

### Check Username Availability

```bash
curl "https://mail-to-nostr.com/check-username?pub=<username_pub_hex>"
```

| HTTP Status | Meaning |
|-------------|---------|
| 200 | Username available (new registration — no `ownership_nonce` needed) |
| 409 | Username taken (either active or expired — pass `ownership_nonce` to re-register, or use `/renew` if still active) |

---

## Test Vectors

Verify your implementation against these known-good outputs. All use fixed test inputs — not real keys.

### Test Vector 1: scrypt hash_pubkey

```
pubkey_hex:    3bf0c63fcb93463407af97a5e5ee64d883f107299b96e8c1f3a3a4f4a3c3e3c3
salt (hex):    a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2
scrypt params: N=16384, r=6, p=1, dklen=32
→ hash_pubkey: scrypt:N=16384:r=6:p=1:b6b7ce16fad82471c8754a32f124d997086abd1c74714af9c878b3729541f995
```

### Test Vector 2: Derived checkout key

```
gateway_pub:    ff68da295ddde0dc08362af8e549341dc168be7919dbee53d1870e69d9c38fe8
customer_pub:   3bf0c63fcb93463407af97a5e5ee64d883f107299b96e8c1f3a3a4f4a3c3e3c3
sha256(gw+cust): 30d4c12079a325d3a36eaf74c8826e0b89f3aeb4ba3afd87fe8264f7c6b7ef29
valid secp256k1: yes (0 < val < N)
→ derived_priv:  30d4c12079a325d3a36eaf74c8826e0b89f3aeb4ba3afd87fe8264f7c6b7ef29
```

### Test Vector 3: JSON normalization + base64

```
input:  {"Relays": ["wss://a.com", "wss://b.com"], "rate_limit": 0, "shadow_pubkey": false}
        ↓ lowercase keys, sort, remove value=0 (keep false), compact
norm:   {"relays":["wss://a.com","wss://b.com"],"shadow_pubkey":false}
→ base64: eyJyZWxheXMiOlsid3NzOi8vYS5jb20iLCJ3c3M6Ly9iLmNvbSJdLCJzaGFkb3dfcHVia2V5IjpmYWxzZX0=
```

### Test Vector 4: Username key derivation

```
username:     alice
salt (hex):    c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3
scrypt params: N=16384, r=6, p=1, dklen=32
→ priv_hex:   0f8818dffe81ffd0c2a24737628ff1dee85c323a4af223db3d002da0a566b838  (valid, no re-hash needed)
→ pub_hex:    b42d65b2bcc7860b4817d069698c3b246d890d24367a898879cd185e006083a7
```

---

## Privacy

See [privacy documentation](/privacy.md) for a complete overview of what is stored, how, and for how long.

Key points:
- No plaintext pubkeys stored (only scrypt hashes)
- Feature configs stored NIP-44 encrypted
- No email content persisted
- No IP addresses logged (Postfix → `/dev/null`, nginx → off)

---

## EU Restriction

Paid features are not offered to EU residents. EU IPs get Free Tier only. `/checkout` and `/renew` return `403 not_available_in_eu`.
