# cred.diy API Documentation (Updated November 2026)

## Overview
cred.diy is a credit management platform that allows you to issue, deduct, and track credits for your application users. It includes hosted checkout via Stripe, credit swaps, referral programs, pre-sales, cash redemption, and webhook notifications.

## Base URL
https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api

## Authentication
All API requests (except the public x402 routes) require an API key in the x-api-key header (Authorization: Bearer <key> also works). Request bodies are JSON for POST and PATCH. A DELETE may carry a JSON body or none: its parameters can then go in the query string (e.g. `DELETE /users?user_email=user@example.com`, `DELETE /pricing-tiers?id=…`), and a body value wins over the same query parameter.
```
x-api-key: sk_live_your_api_key
```

Every API key starts with sk_live_; there are no test-mode (sk_test_) keys, and any other prefix is rejected with 401 — by credits-api and by hosted checkout alike. The project is resolved from the key. (Your own Stripe secret key under Payments → Your Own Keys may be a Stripe test or live key; that is a different credential.) Keys can carry an expiration date (30 days to 1 year), and it is enforced: an expired key gets 401 `{ "error": "API key has expired", "code": "API_KEY_EXPIRED" }` from credits-api, hosted checkout, payouts and process-referral. Rotate before expiry. Rate limit: 100 requests/minute per key (public x402 routes: 30/minute per IP). Each authenticated credits-api call costs 1 platform credit (CRD).

**Request IDs:** every response (credits-api, hosted checkout, charge-saved, x402, payouts, the MCP server) carries an `X-Request-Id` header, and every error body includes the same value as `request_id`. Send your own `X-Request-Id` to trace a call; quote it to support. Every error code is listed under "Errors & Request IDs" at the end of this document.

## Platform Credits (CRD)
Every authenticated credits-api request costs **1 CRD** (Cred platform credit). CRD is a site-wide balance tied to your Cred account, **not** to a specific project.
- **Cost:** 1 CRD per call, charged before the request runs — so validation errors, 404s and idempotent replays cost 1 CRD too. Rate-limited calls (429, `code: "RATE_LIMITED"`) are rejected before the charge and cost nothing. Hosted checkout and the public x402 routes are not metered.
- **Where to check:** Your CRD balance is visible in the dashboard header
- **Top-up:** Dashboard → Billing (card, or stablecoin via x402), or pay inline (see "Paying for Any Call Inline")

If you receive a **402 Payment Required** with `code: "PLATFORM_CREDITS_EXHAUSTED"` (also `error_code`; error "Insufficient platform credits"), add more CRD. This is distinct from a POST /charge-saved 402, which is the end user's card (`error_code: "CARD_DECLINED"`). When x402 is enabled the 402 carries payment requirements so the caller can pay and retry the same request.

## Idempotency (issue, deduct, refund, swap, redeem, escrow hold)
Send an `Idempotency-Key` header (or an `idempotency_key` body field; the header wins) on POST /issue, POST /deduct and POST /refund. Max 255 characters (longer → 400 INVALID_IDEMPOTENCY_KEY). Keys are scoped to your project, shared by those three endpoints, and never expire.
- A retry with the same key returns the first result with `"idempotent_replay": true`; no credits move again and no second webhook fires. On a replay `new_balance` is the user's current balance.
- The same key on a different request (other user, amount, type, credit type — or for refunds any different body) → 409 `IDEMPOTENCY_KEY_REUSED`.
- POST /charge-saved requires a key (400 `IDEMPOTENCY_KEY_REQUIRED`); its replays carry `"replayed": true`.
- POST /swap, POST /redeem and POST /escrow/hold also accept a key, each in its own namespace scoped to your project: a retry returns the original result with `"idempotent_replay": true`, and the same key on a different request is 409 `IDEMPOTENCY_KEY_REUSED`. Without a key, /redeem uses `reference_id` as its key.

## User Identity Model

**IMPORTANT: Email-Based Wallet Identity**

Users are identified primarily by their **email address** within a project:
- One email = one wallet across all your apps/credit types within a project
- When you provide a user_email, the system will find or create a user with that email
- If the same email is used with different user_id values, they share the same credit balance
- This allows users to access their credits across multiple apps that use your project

**Best Practices:**
- Always include `user_email` when creating users or issuing credits
- Use `user_id` (external_id) for your internal tracking, but rely on email for user identity
- Users with the same email will automatically share balances across your apps

## Finding Your IDs

### Project ID
Dashboard → Project Selector dropdown → hover project → click "ID" button

### Credit Type ID
Dashboard → Credit Types → "Credit Type ID" column → copy button

### Pricing Tier ID
Dashboard → Pricing Tiers → each card has a "Copy ID" button

### API Key
Dashboard → API Keys → create new or copy existing (always prefixed sk_live_)

## Endpoints

### Get Balance
GET /balance
Returns credit balances for a user. Supports email-first lookup. An unknown user returns `{ "user_id": ..., "balances": [] }` (200), not a 404.

Query Parameters:
- email (preferred): User's email address — takes priority over user_id
- user_id (fallback): Your unique identifier (if contains @, treated as email)
- credit_type_id (optional): Filter to a specific credit type

```bash
# Recommended: Use email for reliable cross-app wallet lookup
curl -X GET "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/balance?email=user@example.com" \
  -H "x-api-key: sk_live_your_api_key"

# Alternative: Use user_id (falls back to external_id lookup)
curl -X GET "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/balance?user_id=user_123" \
  -H "x-api-key: sk_live_your_api_key"
```

Response:
```json
{
  "user_id": "user_123",
  "email": "user@example.com",
  "balances": [{
    "credit_type_id": "uuid-here",
    "credit_type_name": "Tokens",
    "credit_type_symbol": "TKN",
    "balance": 75
  }]
}
```

### Issue Credits
POST /issue
Issues credits to a user. Creates the user if they don't exist. If user_email matches an existing user, credits go to that wallet. user_id is required (user_email alone is not enough).

Body Parameters:
- user_id (required): Your unique identifier (used as external_id)
- credit_type_id (required): The credit type UUID
- amount (required): Amount of credits to issue (positive number)
- reason (optional): Reason for the transaction
- reference_id (optional): Your reference ID for tracking
- user_email (highly recommended): User's email — enables unified wallet across apps
- user_name (optional): User's display name
- signup_credit_type_id (optional): Attribution, first-touch
- wallet_address (optional): Mint this issuance to a specific EVM address, overriding the one on the user. Only consulted when the credit type has mint_on_issue set.
- idempotency_key (optional): or the Idempotency-Key header — see Idempotency

```bash
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/issue" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "user_123",
    "user_email": "user@example.com",
    "credit_type_id": "ct_abc123",
    "amount": 100,
    "reason": "Welcome bonus"
  }'
```

Response:
```json
{
  "success": true,
  "transaction_id": "tx_xyz789",
  "user_id": "user_123",
  "credit_type_id": "ct_abc123",
  "amount_issued": 100,
  "new_balance": 600
}
```
A replay of the same idempotency key returns the same fields plus `"idempotent_replay": true`.

On a credit type that mints, the response also carries an `on_chain` block. The ledger is the system of record and the chain follows it: a mint that cannot be sent never fails, reverses, or delays the issuance. See "Minting on Issue" under Wallets & On-Chain Credits.

**Why include user_email?**
- Users have a unified wallet across all your apps
- Deductions work correctly even if user_id differs between apps
- Same email = same credit balance, regardless of which app issued the credits

### Deduct Credits
POST /deduct
Deducts credits from a user's balance. Returns 400 with code "INSUFFICIENT_CREDITS" if balance is too low, and 404 "USER_NOT_FOUND" for an unknown user (deduct never creates users). Supports email-first lookup. If the deduct leaves the balance below the user's auto top-up threshold (POST /auto-topup), the response also has "auto_topup_triggered": true and a pack is bought on their saved card in the background.

Body Parameters:
- user_id (optional): Your unique identifier (required if user_email not provided). If contains @, treated as email.
- user_email (recommended): User's email — takes priority over user_id for lookup
- credit_type_id (required): The credit type UUID
- amount (required): Amount of credits to deduct (positive number)
- reason (optional): Reason for the deduction
- reference_id (optional): Your reference ID for tracking
- signup_credit_type_id (optional): Attribution backfill, first-touch
- idempotency_key (optional): or the Idempotency-Key header — see Idempotency

```bash
# Recommended: Use email for reliable cross-app wallet lookup
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/deduct" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "user_email": "user@example.com",
    "credit_type_id": "ct_abc123",
    "amount": 50,
    "reason": "Premium feature usage"
  }'

# Alternative: Use user_id (falls back to external_id lookup)
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/deduct" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "user_123",
    "credit_type_id": "ct_abc123",
    "amount": 50
  }'
```

Response:
```json
{
  "success": true,
  "transaction_id": "tx_xyz790",
  "user_id": "user_123",
  "credit_type_id": "ct_abc123",
  "amount_deducted": 50,
  "new_balance": 550
}
```

Error Response (Insufficient Credits) — HTTP 400:
```json
{
  "error": "Insufficient credits",
  "code": "INSUFFICIENT_CREDITS",
  "requested_amount": 50
}
```

### List Transactions
GET /transactions?user_id={user_id}&limit={limit}&offset={offset}
Returns transaction history with pagination.

Query Parameters:
- email (optional): Filter by user email (looked up first)
- user_id (optional): Filter by user (an unknown user returns an empty list)
- credit_type_id (optional): Filter by credit type
- limit (optional): Number of results (default 50, max 100)
- offset (optional): Pagination offset

```bash
curl -X GET "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/transactions?user_id=user_123&limit=10" \
  -H "x-api-key: sk_live_your_api_key"
```

Response:
```json
{
  "transactions": [
    {
      "id": "tx_xyz790",
      "user_id": "user_123",
      "credit_type_id": "ct_abc123",
      "credit_type_name": "Gold Coins",
      "credit_type_symbol": "GC",
      "amount": -50,
      "type": "deduct",
      "reason": "Premium feature usage",
      "reference_id": null,
      "created_at": "2024-01-15T10:30:00Z"
    }
  ],
  "limit": 10,
  "offset": 0
}
```

### List Credit Types
GET /credit-types
Returns all active credit types for your project.

The credit type's exchange_rate is USD per credit for custom checkout and payouts: 100 credits at 0.01 cost or redeem for $1. Pricing tiers retain their own package prices. Swap-pair exchange_rate is a separate target-credit/source-credit ratio.

```bash
curl -X GET "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/credit-types" \
  -H "x-api-key: sk_live_your_api_key"
```

Response:
```json
{
  "credit_types": [
    {
      "id": "ct_abc123",
      "name": "Gold Coins",
      "symbol": "GC",
      "description": "In-game premium currency",
      "exchange_rate": 0.01,
      "is_redeemable": false,
      "is_on_chain": false,
      "blockchain": null,
      "contract_address": null,
      "chain_id": null,
      "token_decimals": null,
      "mint_on_issue": false,
      "deployment_type": null
    },
    {
      "id": "ct_def456",
      "name": "API Credits",
      "symbol": "API",
      "description": "Credits for API usage",
      "exchange_rate": 0.001,
      "is_redeemable": false,
      "is_on_chain": true,
      "blockchain": "base-sepolia",
      "contract_address": "0x5FbDB2315678afecb367f032d93F642f64180aa3",
      "chain_id": 84532,
      "token_decimals": 18,
      "mint_on_issue": true,
      "deployment_type": "managed"
    }
  ]
}
```

### Create/Update User
POST /users
Creates a new user (201, "created": true) or returns the existing one (200, "created": false). Matches email first, then external_id; any email/name/metadata/wallet sent is applied to the existing user, but its external_id is never changed.

Body Parameters:
- external_id (required): Your unique identifier for the user
- email (recommended): User's email — PRIMARY identifier for wallet identity
- name (optional): User's display name
- metadata (optional): Custom metadata for the user (replaces stored metadata on an existing user; a stored `referral_code` is kept unless you send that key)
- signup_credit_type_id (optional): Attribution, first-touch
- wallet_address (optional): EVM address for on-chain credits. Stored as *asserted* — wallet_verified_at stays null. See Wallets & On-Chain Credits.
- signup_url (strongly recommended): the app/page the user signed up from. See "App origin" under Signup Attribution — without it the user is created with blank attribution.

```bash
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/users" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "user_123",
    "email": "user@example.com",
    "name": "John Doe",
    "signup_url": "https://app1.example.com/signup?ref=twitter",
    "metadata": { "plan": "pro", "signup_source": "web" }
  }'
```

Response:
```json
{
  "user": {
    "id": "uuid-xxx",
    "external_id": "user_123",
    "email": "user@example.com",
    "name": "John Doe",
    "metadata": { "plan": "pro", "signup_source": "web" },
    "project_id": "proj_xxx",
    "created_at": "2024-01-15T10:30:00Z",
    "updated_at": "2024-01-15T10:30:00Z"
  },
  "created": true
}
```

**Important:** If a user with the same email already exists, the existing user is returned (not a new one created). This ensures one wallet per email.

### Update User
PATCH /users
Update an existing user, found by user_email first, then external_id. At least one updatable field is required — otherwise 400 `NO_FIELDS_TO_UPDATE`. 404 `USER_NOT_FOUND` for an unknown user.

Body Parameters:
- external_id or user_email (one required): The user to update
- name (optional): New display name
- email (optional): New email address (must be unique within project)
- metadata (optional): Replaces the stored metadata (not merged), except that the user's `referral_code` (from /referrals/generate-code) is kept unless you send that key yourself
- signup_url / signup_host / signup_credit_type_id (optional): Overwrite attribution — PATCH is the one call that does
- wallet_address (optional): EVM address for on-chain credits, or null to detach. Changing it clears any previous verification; re-sending the address already on file is a no-op.

```bash
curl -X PATCH "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/users" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "user_123",
    "name": "Jane Doe",
    "metadata": { "plan": "enterprise" }
  }'
```

### Delete User
DELETE /users
Delete a user with their balances, transactions, escrow holds and auto top-up rules. This action cannot be undone. Swap history is kept as a financial record, so a user who has swapped cannot be deleted: 409 `{ "code": "USER_HAS_HISTORY", "swap_transactions": n }`. 404 `USER_NOT_FOUND` for an unknown user.

Body or query-string parameters:
- external_id (alias user_id) or user_email (one required): The user to delete

```bash
curl -X DELETE "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/users" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "external_id": "user_123" }'

# or, with no body
curl -X DELETE "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/users?user_email=user@example.com" \
  -H "x-api-key: sk_live_your_api_key"
```

### Bulk Sync Users
POST /users/bulk
Sync up to 1000 users at once. Users are matched first by email, then by external_id. On a matched user, only the fields the row carries are updated: omit name or metadata to keep the stored values. Sent metadata replaces the stored object but keeps a stored `referral_code`.

Body Parameters:
- users (required): Array of user objects (max 1000): external_id (required), email, name, metadata, wallet_address, signup_url, signup_host, signup_credit_type_id. One invalid row fails on its own and is reported in `errors`, not the batch.
- signup_url (strongly recommended): default app origin for the batch. A per-user `signup_url` overrides it. Users left with no app origin are still synced, just with blank attribution.

```bash
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/users/bulk" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "x-app-origin: https://app1.example.com" \
  -H "Content-Type: application/json" \
  -d '{
    "users": [
      { "external_id": "user_1", "email": "user1@example.com", "name": "User One" },
      { "external_id": "user_2", "email": "user2@example.com", "name": "User Two" },
      { "external_id": "user_3", "email": "user3@example.com", "name": "User Three" }
    ]
  }'
```

Response:
```json
{
  "success": true,
  "created": 2,
  "updated": 1
}
```
`"errors": [...]` is added when any row failed.

### List Users
GET /users?limit={limit}&offset={offset}
Returns all end users for your project with pagination. Max limit is 1000.

Query Parameters:
- email (optional): Return that one user as `{ "user": ... }` (404 USER_NOT_FOUND if absent)
- external_id (optional): Return that one user as `{ "user": ... }`
- signup_host / signup_credit_type_id (optional): Filter the list
- limit (optional): Max results (default: 100, max: 1000)
- offset (optional): Pagination offset

```bash
# List all users
curl -X GET "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/users?limit=100" \
  -H "x-api-key: sk_live_your_api_key"

# Get single user by external_id
curl -X GET "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/users?external_id=user_123" \
  -H "x-api-key: sk_live_your_api_key"
```

## Signup Attribution

Every end user carries three optional attribution fields you can use to track which app / landing page / marketing channel they came from and to target promos per app:

- `signup_url` — full URL the user signed up from (e.g. `https://app1.example.com/signup?ref=twitter`)
- `signup_host` — normalized lowercase hostname. **Auto-derived by cred** from `signup_url` if omitted; you can override by sending it explicitly.
- `signup_credit_type_id` — the credit type the user signed up for (useful for cross-app / multi-product attribution)

**Semantics: first-touch, never overwritten.** Once these fields are set on an end user, later calls will not change them — except `PATCH /users`, which sets what you send. Safe to send on every call — cred latches the first non-null value.

**Blank attribution is backfilled, not frozen.** Because cred latches the first *non-null* value, a user created without attribution is filled in by their next call that carries an app origin (`POST /users`, `/users/bulk`, `/issue`, `/deduct`). You do not need to do anything special for users created before you started sending these fields — they self-heal on their next attributed call. Only users who never call again stay blank; backfill those from the dashboard.

**Who sends what:** cred has no way to know which of your apps a user came from, so *your app* must send `signup_url` and `signup_credit_type_id`. `signup_host` is derived automatically.

**App origin.** Optional but strongly recommended on `POST /users`, `POST /users/bulk`, `POST /issue` and `POST /deduct`. Calls without an app origin always succeed (backwards compatible) but leave attribution blank. Supply one of:
- `signup_url` (or `signup_host`) in the body — preferred for server-to-server calls
- an `x-app-origin: https://yourapp.com` request header
- a browser `Origin`/`Referer` header (set automatically for calls made from the browser)

### Endpoints that accept attribution
Send these fields as top-level body parameters on:
- `POST /users` and `POST /users/bulk` (optional but strongly recommended — these calls succeed without an app origin, they just leave attribution blank until a later call supplies one)
- `POST /issue` and `POST /deduct` (optional but recommended; stamps the user on first-touch if they don't exist yet)
- `POST /hosted-checkout` (signup_url / signup_host only; stored on the resulting purchase)
- Presale checkout


Example — creating a user with attribution:
```bash
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/users" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "user_123",
    "email": "user@example.com",
    "signup_url": "https://app1.example.com/signup?ref=twitter",
    "signup_credit_type_id": "ct_abc123"
  }'
```

### Filtering users by attribution
`GET /users` accepts two additional filter query params:
- `signup_host` — filter by normalized hostname (e.g. `app1.example.com`)
- `signup_credit_type_id` — filter by the credit type the user signed up for

```bash
curl -X GET "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/users?signup_host=app1.example.com&limit=100" \
  -H "x-api-key: sk_live_your_api_key"
```

### Signup Analytics
GET /signup-analytics
User counts, purchase counts, and revenue grouped by `signup_host`. Pass `days=0` for all-time (default: 30).

```bash
curl -X GET "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/signup-analytics?days=30" \
  -H "x-api-key: sk_live_your_api_key"
```

Response:
```json
{
  "days": 30,
  "breakdown": [
    {
      "signup_host": "app1.example.com",
      "signup_credit_type_id": "ct_abc",
      "signup_credit_type_name": "GEMS",
      "user_count": 1250,
      "purchase_count": 87,
      "revenue": 4321.50
    },
    {
      "signup_host": "app2.example.com",
      "signup_credit_type_id": null,
      "signup_credit_type_name": null,
      "user_count": 340,
      "purchase_count": 12,
      "revenue": 599.88
    }
  ]
}
```

### List Signup Hosts
GET /signup-hosts
Every distinct `signup_host` recorded on your users, sorted. Use it to populate a filter without hardcoding the apps you happen to remember.

```bash
curl -X GET "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/signup-hosts" \
  -H "x-api-key: sk_live_your_api_key"
```

Response:
```json
{
  "hosts": ["app1.example.com", "app2.example.com", "www.example.com"]
}
```

### List Pricing Tiers
GET /pricing-tiers
Returns the active pricing tiers for your project, cheapest first. Optional `credit_type_id` (UUID, matched case-insensitively; anything else → 400) returns the tiers that can be bought for that credit type: tiers whose credit_type_ids include it plus tiers that apply to all credit types (credit_type_ids = []). Prices are returned as strings with exactly 2 decimal places (e.g., "9.99"). Each tier includes `credit_type_ids` — an empty array means the tier applies to ALL active credit types in the project; otherwise it is restricted to the listed credit types.

```bash
curl -X GET "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/pricing-tiers" \
  -H "x-api-key: sk_live_your_api_key"
```

Response:
```json
{
  "pricing_tiers": [{
    "id": "tier_abc123",
    "name": "Starter Pack",
    "description": "Perfect for getting started",
    "credits": 100,
    "price": "9.99",
    "currency": "USD",
    "billing_type": "one_time",
    "is_active": true,
    "is_popular": false,
    "credit_type_ids": []
  }]
}
```

### Create Pricing Tier
POST /pricing-tiers
Create a new pricing tier programmatically. Prices are normalized to 4 decimal places for storage and returned as 2-decimal strings.

**Validation Rules:**
- price must be ≥ $0.50 (Stripe minimum) or exactly $0.00 for free tiers
- credits must be a positive integer
- Price-per-credit ratio must not exceed $1.00
- billing_type: one_time (default), recurring or both — anything else is a 400 (on create and update). Subscriptions need recurring/both; saved-card charges and auto top-up need one_time/both
- credit_type_ids (optional) must be an array of this project's credit type UUIDs; omit or pass [] to apply to all

```bash
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/pricing-tiers" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Enterprise Pack",
    "description": "For high-volume users",
    "credits": 5000,
    "price": 299.99,
    "currency": "USD",
    "billing_type": "one_time",
    "is_popular": false,
    "credit_type_ids": ["ct_abc123"]
  }'
```

Response (201):
```json
{
  "success": true,
  "pricing_tier": {
    "id": "tier_xyz789",
    "name": "Enterprise Pack",
    "description": "For high-volume users",
    "credits": 5000,
    "price": "299.99",
    "currency": "USD",
    "billing_type": "one_time",
    "is_active": true,
    "is_popular": false,
    "credit_type_ids": ["ct_abc123"],
    "created_at": "2026-02-21T10:30:00Z"
  }
}
```

### Update Pricing Tier
PATCH /pricing-tiers
Body: `id` (required) plus any of name, description, credits, price, currency, billing_type, is_active, is_popular, credit_type_ids. Same validation as create. Returns `{ "success": true, "pricing_tier": {...} }`; 404 "Pricing tier not found"; 400 "No updatable fields provided".

### Delete Pricing Tier
DELETE /pricing-tiers
Body `{ "id": "tier_xyz789", "force": false }` (or `?id=…&force=true`). Auto top-up rules hold the tier they buy, so a tier in use is never deleted by accident (from the API or the dashboard): while any rules buy it the call is refused with 409 `{ "code": "TIER_IN_USE", "auto_topup_rules": 2 }`. Move those rules to another tier, or send `force: true` to delete the tier and its rules together in one transaction. The dashboard's Pricing Tiers page offers the same choice ("Delete tier and N auto top-up rules"). Deactivating the tier instead keeps history but makes its auto top-up charges fail. Returns `{ "success": true, "deleted_id": "tier_xyz789" }`, plus `auto_topup_rules_deleted` when forced over rules; 404 "Pricing tier not found".

## Credit Swaps

Let users exchange one credit type for another. Swap pairs (exchange rate, fee, min/max) are configured in the dashboard. A swap runs from the pair's source type to its target type. On a pair with is_bidirectional, send `"direction": "reverse"` to spend the target type for the source type at 1/exchange_rate (same fee, min/max converted to the spent type); on other pairs that is 400 SWAP_NOT_BIDIRECTIONAL.

### List Swap Pairs
GET /swap-pairs
Returns available swap pairs. Supports filtering by source/target credit type.

Query Parameters:
- source_credit_type_id (optional): Filter by source credit type
- target_credit_type_id (optional): Filter by target credit type
- include_inactive (optional): Include inactive pairs (default: false)

```bash
curl -X GET "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/swap-pairs" \
  -H "x-api-key: sk_live_your_api_key"

# With filters
curl -X GET "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/swap-pairs?source_credit_type_id=ct_abc123" \
  -H "x-api-key: sk_live_your_api_key"
```

Response:
```json
{
  "swap_pairs": [{
    "id": "swap_abc123",
    "source_credit_type_id": "ct_source",
    "target_credit_type_id": "ct_target",
    "exchange_rate": 10,
    "rate_type": "fixed",
    "fee_percent": 2.5,
    "min_swap_amount": 10,
    "max_swap_amount": 10000,
    "is_bidirectional": true,
    "is_active": true,
    "created_at": "2026-01-05T16:49:35Z",
    "source_credit_type": { "id": "ct_source", "name": "Gold Coins", "symbol": "GC", "project_id": "..." },
    "target_credit_type": { "id": "ct_target", "name": "Silver Tokens", "symbol": "ST", "project_id": "..." }
  }],
  "total": 1
}
```

### Execute Swap
POST /swap
Execute an atomic swap between two credit types.

Body Parameters:
- user_id or user_email (one required): The user (email looked up first)
- swap_pair_id (required): The swap pair ID from GET /swap-pairs
- source_amount (required): Source credits to spend, within the pair's min/max
- idempotency_key (optional): or the Idempotency-Key header. A retry with the same key returns the original swap with `"idempotent_replay": true` — no balances move and no second credits.swapped fires. Use one key per swap.

Only pairs whose source credit type is in your project can be used (403 "Swap pair not accessible for this API key").

```bash
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/swap" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "user_123",
    "swap_pair_id": "swap_abc123",
    "source_amount": 100
  }'
```

Response:
```json
{
  "success": true,
  "transaction": {
    "id": "swap_tx_xyz789",
    "source_amount": 100,
    "target_amount": 975,
    "fee_amount": 2.5,
    "exchange_rate": 10,
    "source_credit": { "id": "ct_source", "name": "Gold Coins", "symbol": "GC", "project_id": "..." },
    "target_credit": { "id": "ct_target", "name": "Silver Tokens", "symbol": "ST", "project_id": "..." }
  }
}
```

**Exchange Rate Calculation:** fee = source_amount × fee_percent/100 (in source credits); target = (source_amount − fee) × exchange_rate
Example: Swapping 100 Gold Coins at 10:1 with a 2.5% fee: fee_amount 2.5, target (100 − 2.5) × 10 = 975 Silver Tokens

Error Response (Insufficient Balance) — HTTP 400:
```json
{
  "error": "Insufficient balance",
  "code": "INSUFFICIENT_CREDITS",
  "required": 100
}
```

**Swap Integration Flow:**
1. Discover pairs: Call GET /swap-pairs to find available exchange options
2. Preview swap: Calculate expected output using exchange_rate and fee_percent
3. Execute swap: Call POST /swap with user_id (or user_email), swap_pair_id, source_amount (and "direction": "reverse" for target → source on a bidirectional pair), plus an Idempotency-Key so a retry never swaps twice
4. Handle webhooks: Listen for credits.swapped events for real-time notifications

## Payment Rails
Cred has three ways to take money from buyers. They differ in who the merchant of record is, which decides who a processor terminates when something goes wrong.

| Rail | Merchant of record | Who can use it | Pays end users out? |
| --- | --- | --- | --- |
| Stripe Connect (Cred's platform account) | Cred | Permitted business categories only | Yes |
| Your own Stripe keys | You | Any category | Yes |
| Stablecoin over x402 | No processor involved | Any category | No |

**If a card processor won't approve the business.** Processors decline a long list of lawful businesses (adult, gambling, cannabis/CBD, firearms, supplements, pharma, crypto trading, lending, dating). Those categories cannot use Cred's platform Stripe account — under Connect it is Cred's own account that answers for them, and one restricted merchant would cost every other operator on the platform their payments. They can still use every other part of Cred, on either of two rails:
- **Own processor keys** — Dashboard → Payments → Your Own Keys. The operator becomes merchant of record; hosted checkout, refunds and payouts all keep working. Whether the processor permits the business is between the operator and that processor.
- **Stablecoin over x402** — Dashboard → Agent Payments. No card network, no underwriting, no chargebacks. Settles directly to an address the operator controls; Cred never custodies funds. See "Agent Payments (x402)" below.

**The one gap:** cash redemption (paying an end user real money for credits) runs on Stripe Connect Express transfers. There is no stablecoin equivalent, so a redeemable credit type on the crypto rail can be bought but not cashed out. Sell-only credit types are unaffected.

**Declaring a category.** Each project declares what it sells under Dashboard → Payments → Business & compliance, and accepts the Acceptable Use Policy (https://cred.diy/acceptable-use). Connect onboarding and platform-Connect checkout return HTTP 403 with code AUP_NOT_ACCEPTED, AUP_VERSION_STALE, or CATEGORY_RESTRICTED until that is done — the response names the rails that remain available.

**Own keys: the Stripe webhook.** On your own keys Stripe reports payments to an endpoint in your Stripe account. Add one (Stripe Dashboard → Developers → Webhooks → "Add endpoint") with the URL shown under Payments → Your Own Keys: `https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/payment-webhook?project_id=<your project id>`. Select `checkout.session.completed`, `checkout.session.expired`, `payment_intent.succeeded` and `payment_intent.payment_failed`, then paste the endpoint's signing secret (whsec_…) into "Webhook Signing Secret" on the same page (it can be saved on its own once your secret key is saved). Each project has its own secret; deliveries are verified with it and only touch that project's purchases; other event types are acknowledged and ignored. Without it, purchases and saved-card charges are still credited by the reconciliation sweep, just not instantly — every path credits a payment exactly once. Failed verification shows in Stripe's delivery log as 400 `WEBHOOK_SECRET_NOT_CONFIGURED` (no secret saved), `INVALID_SIGNATURE` (wrong or rotated secret), `INVALID_PROJECT` or `MISSING_SIGNATURE`.

**Reporting.** Every rail writes the same ledger. Purchase rows carry payment_provider ("stripe" or "x402"), so revenue and purchase history read the same whichever way a customer paid.

## Hosted Checkout
POST https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/hosted-checkout
Creates a Stripe checkout session for credit purchases. Requires your own Stripe keys (used first when present) or a Stripe Connect account with charges enabled. Credits are issued when Stripe reports the payment, with a credits.issued webhook carrying purchase_id. Authenticated with your sk_live_ API key (401 for any other prefix); does not cost CRD.

Returns HTTP 403 when the project is using Cred's platform Stripe account but has not declared a permitted business category — see Payment Rails above.

Body Parameters:
- user_id (required): Your unique identifier for the user
- pricing_tier_id (optional): ID of the pricing tier to use (OR use credit_type_id + amount)
- credit_type_id (optional): Credit type ID (required if not using pricing_tier_id)
- amount (optional): Number of credits to purchase (required if not using pricing_tier_id). USD price = amount × exchange_rate (USD per credit), rounded to cents.
- quantity (optional): Units/seats, integer 1–10000 (default 1). Charges unit price × quantity and issues unit credits × quantity
- billing (optional): "recurring" (aliases "subscription", "monthly") starts a monthly subscription — needs a pricing_tier_id whose billing_type is recurring or both. Omitted, empty or "one_time" is a one-off payment regardless of the tier's billing_type. Anything else → 400
- save_payment_method (optional, boolean): keep the card for POST /charge-saved and auto top-up. One-time checkouts only (400 with billing: "recurring")
- user_email (optional): Pre-fill user's email in checkout
- user_name (optional): User's name for the order
- success_url (optional): URL to redirect after successful payment (default: origin/payment-success?session_id=...)
- cancel_url (optional): URL to redirect if payment is cancelled (default: origin/payment-cancelled)
- signup_url / signup_host (optional): Attribution, stored on the purchase (else x-app-origin / Origin / Referer)

```bash
# Option 1: Using a pricing tier
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/hosted-checkout" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "user_123",
    "pricing_tier_id": "tier_abc123",
    "user_email": "user@example.com",
    "success_url": "https://yourapp.com/success",
    "cancel_url": "https://yourapp.com/cancelled"
  }'

# Option 2: Using credit_type_id + amount (price calculated from exchange_rate)
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/hosted-checkout" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "user_123",
    "credit_type_id": "ct_abc123",
    "amount": 1000,
    "user_email": "user@example.com"
  }'
```

Response:
```json
{
  "checkout_url": "https://checkout.stripe.com/c/pay/cs_xxx...",
  "url": "https://checkout.stripe.com/c/pay/cs_xxx...",
  "session_id": "cs_xxx...",
  "purchase_id": "purchase_abc123",
  "billing": "one_time",
  "expires_at": "2026-11-03T19:00:00.000Z"
}
```

Redirect the user to the returned `url` (same as `checkout_url`) to complete payment.

Errors: 401 "Invalid or missing API key" / "Invalid API key"; 400 for bad input ("user_id is required", "Either pricing_tier_id or (credit_type_id + amount) is required", "Invalid or inactive pricing tier", "This pricing tier is one-time only...", "quantity must be a positive integer (1–10000)") and for unconfigured payments (`reason`: no_stripe_setup, stripe_connect_incomplete, stripe_account_not_selected); 403 AUP_NOT_ACCEPTED / AUP_VERSION_STALE / CATEGORY_RESTRICTED on platform Connect.

**How it works:**
1. Call this endpoint to create a Stripe Checkout session
2. Redirect your user to the returned url
3. User completes payment on Stripe's hosted checkout page
4. Stripe webhook automatically issues credits to the user's balance
5. User is redirected to your success_url

**Pricing Options:**
- Pricing Tier: Use pre-configured packages from your Pricing Tiers page
- Dynamic Amount: Use credit_type_id + amount for custom quantities. USD price = amount × exchange_rate (USD per credit), rounded to cents. For example, 100 credits at 0.01 cost $1.00.

## Cash Redemption

Let players cash out credits to a real debit card or bank account via Stripe Connect. Each credit type has an `is_redeemable` toggle and an `exchange_rate` (USD per credit) set in the dashboard.

**Prereqs:** Configure your own Stripe secret key under Payments → Your Own Keys (both endpoints return 400 STRIPE_KEYS_MISSING without it), and enable "Redeemable for Cash" on the credit type.

### Onboard Payout
POST /onboard-payout
Create (or reuse) a Stripe Express account for a player and return a hosted onboarding URL where they enter their debit card / bank details. The acct_xxx is saved on the end user and reused for future redemptions.

Body Parameters:
- user_id (optional): Your external user id (required if user_email omitted)
- user_email (recommended): Player email (recommended for unified wallets)
- return_url (required): URL Stripe redirects to after onboarding completes
- refresh_url (required): URL Stripe redirects to if the onboarding link expires
- country (optional): ISO country code for the connected account (default "US")

```bash
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/onboard-payout" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "user_email": "player@example.com",
    "return_url": "https://yourapp.com/payouts/done",
    "refresh_url": "https://yourapp.com/payouts/retry"
  }'
```

Response:
```json
{
  "success": true,
  "stripe_account_id": "acct_1Nv8...",
  "onboarding_url": "https://connect.stripe.com/setup/...",
  "payouts_enabled": false,
  "charges_enabled": false,
  "details_submitted": false
}
```

### Redeem Credits
POST /redeem
Redeem credits for cash. Atomically deducts credits and transfers USD (credits × exchange_rate) to the player's connected Stripe account. If the Stripe transfer fails, credits are automatically refunded.

Body Parameters:
- user_id (optional): Your external user id (required if user_email omitted)
- user_email (recommended): Player email (recommended for unified wallets)
- credit_type_id (required): The credit type to redeem. Must have is_redeemable = true.
- amount (required): Number of credits to redeem (positive)
- destination (required): Stripe connected account id (acct_xxx) from /onboard-payout
- currency (optional): ISO currency code (default "usd")
- reason (optional): Optional description shown on the Stripe transfer
- reference_id (optional): Your reference ID. Used as the idempotency key when no Idempotency-Key is sent.
- destination must be this user's own account from /onboard-payout (else 400 INVALID_DESTINATION). If Stripe refuses the transfer the credits are returned; if the outcome is unknown the call returns 502 PAYOUT_PENDING with the key — retry with the same key to finish it. If you never retry, the reconciliation sweep settles it once it is 15 minutes old: it finds the transfer on Stripe (marks it paid) or re-sends it under the same Stripe idempotency key, and returns the credits only if Stripe definitively refuses. It never pays or refunds twice; a later retry answers with that outcome. A claim the sweep cannot settle safely (for example the transfer request cannot be rebuilt, or Stripe's 24-hour idempotency window has passed without a conclusive search) is flagged needs_review and left for manual review — neither paid nor refunded automatically; retries keep answering PAYOUT_PENDING until then.
- idempotency_key (optional): Or the Idempotency-Key header (recommended). The same key on a different redeem is 409 IDEMPOTENCY_KEY_REUSED.
- metadata (optional): Stored on the transfer and the transaction
The rate always comes from the credit type's exchange_rate; a redeem_rate in the body is ignored.

```bash
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/redeem" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: cashout_42" \
  -d '{
    "user_email": "player@example.com",
    "credit_type_id": "ct_abc123",
    "amount": 100,
    "destination": "acct_1Nv8...",
    "reference_id": "cashout_42"
  }'
```

Response:
```json
{
  "success": true,
  "transaction_id": "tx_xyz",
  "stripe_transfer_id": "tr_1Nv9...",
  "credits_redeemed": 100,
  "exchange_rate": 1,
  "payout_amount": 100,
  "currency": "usd",
  "destination": "acct_1Nv8...",
  "new_balance": 400
}
```

Common Errors:
```json
// 400 - Credit type not marked redeemable in dashboard
{ "error": "...", "code": "NOT_REDEEMABLE" }

// 400 - Stripe keys not configured for this project
{ "error": "...", "code": "STRIPE_KEYS_MISSING" }

// 400 - User does not have enough credits
{ "error": "Insufficient credits", "code": "INSUFFICIENT_CREDITS", "requested_amount": 100 }

// 400 - destination is not this user's own payout account
{ "error": "...", "code": "INVALID_DESTINATION" }

// 402 - Stripe rejected the transfer; credits already refunded
{ "error": "...", "code": "STRIPE_TRANSFER_FAILED", "stripe_error": "...", "new_balance": 500 }

// 502 - outcome unknown; credits still deducted. Retry with the same key (or wait for the sweep)
{ "error": "...", "code": "PAYOUT_PENDING", "stripe_error": "...", "transaction_id": "tx_xyz", "idempotency_key": "cashout_42" }
```


## Subscriptions (recurring billing)
Charge a customer monthly and add their credits on every paid renewal.

Start one from Hosted Checkout with `"billing": "recurring"` and a `pricing_tier_id` whose billing type is `recurring` or `both`. Without `billing`, checkout stays a one-off payment (existing behaviour). Each paid invoice — the first and every renewal — issues the tier's credits and fires `credits.issued`.

### GET /subscriptions
Query: `user_id`, `email`, `status` (`active` = active/trialing/past_due, or an exact Stripe status).
```json
{ "subscriptions": [{ "id": "…", "stripe_subscription_id": "sub_…", "user_id": "user_123", "end_user_id": "…", "user_email": "user@example.com", "pricing_tier_id": "…", "credit_type_id": "…", "credits_per_period": 1000, "quantity": 1, "price": "10.00", "currency": "USD", "interval": "month", "payment_mode": "connect", "status": "active", "current_period_end": "2026-11-02T19:00:00Z", "cancel_at_period_end": false, "canceled_at": null, "created_at": "…" }] }
```

### POST /subscriptions/cancel
Body: ONE of `subscription_id` (cred id or Stripe `sub_…`), `user_id`, or `email` (cancels every live subscription for that customer). Optional `at_period_end` (default `true`: the customer keeps the plan until the paid period ends; `false`: stop now, no refund). Idempotent.
```json
{ "success": true, "at_period_end": true, "subscriptions": [{ "id": "…", "status": "active", "cancel_at_period_end": true }] }
```

### POST /subscriptions/portal
Get a Stripe-hosted page where the customer updates their card (default) or views invoices. Body: ONE of `subscription_id`, `user_id`, or `email` (most recent subscription), optional `return_url` and `flow` (`payment_method_update` default, or `overview`). Plan changes and cancellation are disabled on this page — use `/subscriptions/cancel`. The URL is short-lived: create it when the customer clicks, don't store it.
```json
{ "url": "https://billing.stripe.com/p/session/…", "subscription": { "id": "…", "status": "past_due" } }
```

### POST /subscriptions/quantity
Sync the seat count on a per-seat plan. Body: ONE of `subscription_id`, `user_id`, or `email` (their one live subscription), plus `quantity` (integer 1–10000). Takes effect at the next renewal with no proration: it bills `quantity` × the per-seat price and issues `quantity` × the per-seat credits. 404 when there is no live subscription; 409 when user_id/email match more than one (pass subscription_id).
```json
{ "success": true, "subscription": { "id": "…", "quantity": 5, "credits_per_period": 5000, "status": "active" } }
```

### Subscription webhook events
- `subscription.created` — first checkout completed
- `subscription.renewed` — renewal paid and credits added (`amount` = credit count, plus `new_balance`, `invoice_id`)
- `subscription.cancel_scheduled` — set to end at period end
- `subscription.canceled` — subscription has ended; no further charges
- `subscription.payment_failed` — renewal payment failed (Stripe retries); includes `fix_payment` telling you to call `POST /subscriptions/portal` and send the customer to the returned URL
Lifecycle events carry a `subscription` object in the shape above. Projects using their own Stripe keys get renewals and cancellations within ~10 minutes (cred polls Stripe); Connect projects get them immediately.

## Saved cards & auto top-up
Keep a buyer's card from a one-time checkout and buy packs on it later without the buyer (e.g. when their balance runs low).

Save a card: `POST /hosted-checkout` with `"save_payment_method": true` (one-time only; 400 with `billing: "recurring"`). Once that purchase is paid, the card is stored — one per user, a new save replaces it.

### GET /payment-method
Query: `user_id`. Stripe ids are never returned.
```json
{ "payment_method": { "brand": "visa", "last4": "4242", "exp_month": 12, "exp_year": 2030, "created_at": "…", "updated_at": "…" } }
```
`{ "payment_method": null }` when none is saved.

### DELETE /payment-method
`user_id` in a JSON body (`{ "user_id": "user_123" }`) or the query string (`?user_id=user_123`). Detaches the card in Stripe and forgets it. Returns `{ "deleted": true }`, or `{ "deleted": false, "code": "no_saved_payment_method" }`.

### POST /charge-saved
Body: `user_id`, `pricing_tier_id` (a `one_time` or `both` tier), optional `credit_type_id`; idempotency key REQUIRED (`Idempotency-Key` header or `idempotency_key`). A retry with the same key returns the first attempt's outcome (`replayed: true`) and never charges twice. The credit issue carries `reference_id` = `purchase_id`, and a `credits.issued` webhook fires with `billing: "auto_topup"`.
```json
// 200 charged + credited
{ "status": "succeeded", "purchase_id": "…", "payment_intent_id": "pi_…", "credits": 10000, "new_balance": 12500 }
// 202 settling — credits arrive by webhook
{ "status": "processing", "purchase_id": "…", "payment_intent_id": "pi_…" }
// 402 declined, or requires_action (3-D Secure needs the buyer: send them through checkout).
// "error" repeats "message"; "error_code" is CARD_DECLINED or CARD_AUTHENTICATION_REQUIRED; "code" is Stripe's.
{ "status": "failed", "error": "…", "error_code": "CARD_DECLINED", "purchase_id": "…", "code": "card_declined", "decline_code": "insufficient_funds", "message": "…", "requires_action": false }
```
Errors: 400 `IDEMPOTENCY_KEY_REQUIRED` / `tier_not_one_time`; 404 `no_saved_payment_method`; 409 `IDEMPOTENCY_KEY_REUSED` (same key, different user) or `saved_payment_method_unusable` (card on a Stripe account the project no longer charges through — save again via checkout); 502 `charge_not_started` (retry with the SAME key).
Own Stripe keys: a charge that settles later is credited when Stripe reports it, which on your own keys comes from the webhook endpoint you add in your Stripe account (Payments → Your Own Keys). Select `payment_intent.succeeded` and `payment_intent.payment_failed` alongside `checkout.session.completed` / `checkout.session.expired`, and save that endpoint's signing secret (whsec_…) there. Without them saved-card charges are still credited by the reconciliation sweep, just not instantly. The sync charge, the webhook and the sweep claim the same `pi:<id>`, so a charge is credited exactly once.

### POST /auto-topup
Make the top-up automatic: when a deduct leaves the balance below `threshold`, cred charges the saved card for one pack of `pricing_tier_id` in the background (the deduct response then carries `auto_topup_triggered: true`; it is absent while the rule is off, already charging, backing off or at its daily cap). Balances that fall another way (escrow capture, refunds) are caught by a sweep within ~10 minutes. One rule per user per credit type; POST again to replace it. If the balance is already below threshold when saved (a user with no balance yet counts as 0), the top-up starts immediately. Each top-up buys one pack; if the balance is still below threshold afterwards, the next deduct or sweep buys another, up to `max_per_day`. At most one charge per rule is in flight, and each is idempotent, so concurrent deducts never double-charge. Deleting the user (DELETE /users) deletes their rules.
Body: `user_id`, `pricing_tier_id` (a `one_time` or `both` tier), `threshold` (credits, > 0); optional `credit_type_id` (defaults to the tier's), `enabled` (default true; false pauses), `max_per_day` (1–100 charges per rolling 24h, default 5).
```json
{ "auto_topup": { "user_id": "user_123", "credit_type_id": "…", "pricing_tier_id": "tier_10k", "threshold": 500, "enabled": true, "max_per_day": 5, "charges_last_24h": 0, "disabled_reason": null, "consecutive_failures": 0, "last_attempt_at": null, "last_status": null, "last_error": null, "last_purchase_id": null, "next_attempt_at": null, "created_at": "…", "updated_at": "…" },
  "payment_method": { "brand": "visa", "last4": "4242", "exp_month": 12, "exp_year": 2030 } }
```
No saved card: `payment_method` is null and a `warning` says nothing is charged until the user saves one. Errors: 400 bad threshold/max_per_day/enabled, invalid or inactive tier, or `tier_not_one_time`; 404 `USER_NOT_FOUND` (create the user first). GET/DELETE return 400 for a `credit_type_id` that is not a UUID.

### GET /auto-topup
Query: `user_id`, optional `credit_type_id`. Returns `{ "auto_topup": {…} | null, "payment_method": … }` for one credit type, or `{ "auto_topups": [ … ], "payment_method": … }` for all of the user's rules.

### DELETE /auto-topup
Body (or query string): `{ "user_id": "user_123", "credit_type_id": "…" }` (omit `credit_type_id` to delete all the user's rules). Returns `{ "deleted": 1 }`. The saved card is kept.

### When an automatic top-up fails
- Declined: retried after 1 hour; after 3 declines in a row the rule turns off (`enabled: false`, `disabled_reason: "repeated_failures"`).
- 3-D Secure required: cannot happen without the buyer, so the rule turns off at once (`disabled_reason: "authentication_required"`) — send them through checkout.
- Nothing to charge (no saved card, payments not configured, tier gone): no charge; retried hourly, stays enabled. `last_status` holds the code (e.g. `no_saved_payment_method`).
- Saving a new card through checkout turns declined / 3-D Secure rules back on; so does POSTing the rule again.

### Auto top-up webhook events
- `auto_topup.succeeded` — `user_id`, `credit_type_id`, `pricing_tier_id`, `threshold`, `balance_before`, `purchase_id`, `credits`, `new_balance`
- `auto_topup.failed` — the same identifiers plus `code`, `decline_code`, `message`, `requires_action`, `disabled`, `disabled_reason`
A `credits.issued` event with `billing: "auto_topup"` also fires for each pack credited.

## Refunds
POST /refund
Reverse a credit purchase or transaction. Claws back the issued credits (or returns spent ones for a deduct) and, for Stripe-backed purchases, optionally issues a money refund — money first, so credits only move if Stripe accepts it. A source can be refunded once in full, or in partials up to its original amount. Send an Idempotency-Key so a retried refund returns the first one (`"idempotent_replay": true`). Escrow and payout transactions cannot be refunded here (400 NOT_REFUNDABLE). Money refunds run on the Stripe account that took the charge — your own keys or Stripe Connect (a Connect refund also reverses the transfer to you). STRIPE_KEYS_MISSING means the charge was on your own account and no active key is configured. Fires `credits.refunded` with `direction` "clawback" or "return".

Identify what to refund with ONE of:
- purchase_id: Refund a specific credit purchase
- stripe_payment_intent_id: Look up the purchase by its Stripe PaymentIntent
- transaction_id: Reverse a specific credit transaction
- reference_id: Reverse the most recent non-refund transaction with this reference

Body Parameters:
- amount (optional): Partial refund in credits (defaults to the full original amount); the money refund is prorated
- reason (optional): Reason stored on the refund transaction
- refund_payment (optional): Also issue a Stripe money refund when the source was a payment (default: true)
- metadata (optional): Custom metadata object
- idempotency_key (optional): or the Idempotency-Key header

```bash
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/refund" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "purchase_id": "pur_abc123",
    "reason": "Customer requested refund"
  }'
```

Response:
```json
{
  "success": true,
  "refund_transaction_id": "tx_refund_123",
  "user_id": "user_123",
  "credit_type_id": "ct_abc123",
  "refunded_credits": 100,
  "partial": false,
  "new_balance": 200,
  "credits_clawed_back": true,
  "payment_refunded": true,
  "stripe_refund_id": "re_1Nv9...",
  "source": "purchase"
}
```

Common Errors:
```json
// 404
{ "error": "Purchase not found", "code": "PURCHASE_NOT_FOUND" }
{ "error": "Transaction not found", "code": "TRANSACTION_NOT_FOUND" }

// 409 - Already refunded, or idempotency key used for a different request
{ "error": "Purchase already refunded", "code": "ALREADY_REFUNDED" }
{ "error": "...", "code": "IDEMPOTENCY_KEY_REUSED" }

// 400 - PURCHASE_NOT_REFUNDABLE, REFUND_EXCEEDS_ORIGINAL (cumulative), NOT_REFUNDABLE, INSUFFICIENT_CREDITS, STRIPE_KEYS_MISSING

// 402 - Stripe money refund failed; no credits were changed
{ "error": "Stripe refund failed; no credits were changed", "code": "STRIPE_REFUND_FAILED", "stripe_error": "..." }
```

## Escrow / Credit Holds
Reserve credits from a user's balance into escrow, then either release them back to the holder or capture (settle) them — optionally crediting a different end user for peer-to-peer / marketplace flows. Lifecycle: hold → release | capture. A partial capture automatically releases the uncaptured remainder back to the holder.

### Create Hold
POST /escrow/hold
Move credits out of a user's available balance and into a held state. Returns 201.

Body Parameters:
- user_id (optional): Your external user id (required if user_email omitted)
- user_email (optional): User email (recommended for unified wallets)
- credit_type_id (required): The credit type to hold
- amount (required): Number of credits to reserve (positive)
- reason (optional): Description stored on the hold
- reference_id (optional): Your reference id (e.g. order_789)
- expires_at (optional): ISO timestamp, informational (not auto-enforced)
- metadata (optional): Custom metadata object

```bash
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/escrow/hold" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "user_email": "buyer@example.com",
    "credit_type_id": "ct_abc123",
    "amount": 100,
    "reference_id": "order_789"
  }'
```

Response:
```json
{
  "success": true,
  "hold_id": "hold_abc123",
  "status": "held",
  "user_id": "user_123",
  "credit_type_id": "ct_abc123",
  "amount": 100,
  "new_balance": 150
}
```

### Release Hold
POST /escrow/release
Return the full held amount to the original holder's balance.

Body Parameters:
- hold_id (required): The hold to release
- reason (optional): Description stored on the release

```bash
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/escrow/release" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "hold_id": "hold_abc123" }'
```

### Capture Hold
POST /escrow/capture
Settle a hold. Optionally credit a different end user (marketplace / P2P). Any uncaptured remainder is released back to the holder.

Body Parameters:
- hold_id (required): The hold to capture
- amount (optional): Partial capture amount (defaults to the full held amount)
- to_user_id / to_user_email (optional): Settle the captured credits to a different user
- reason (optional): Description stored on the capture
- metadata (optional): Custom metadata object

```bash
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/escrow/capture" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "hold_id": "hold_abc123",
    "to_user_email": "seller@example.com"
  }'
```

Response:
```json
{
  "success": true,
  "hold_id": "hold_abc123",
  "status": "captured",
  "credit_type_id": "ct_abc123",
  "captured_amount": 100,
  "remainder_released": 0,
  "from_user_id": "user_123",
  "to_user_id": "seller_456",
  "recipient_new_balance": 100,
  "holder_new_balance": 150
}
```

### List Holds
GET /escrow?status=held
List escrow holds for the project with optional filtering and pagination.

Query Parameters:
- status (optional): Filter by status (held, released, captured, expired)
- reference_id (optional): Filter by your reference id
- user_id / email (optional): Filter by holder (email-first lookup)
- limit (optional): Results per page (default 50, max 100)
- offset (optional): Pagination offset

```bash
curl -X GET "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/escrow?status=held&limit=50" \
  -H "x-api-key: sk_live_your_api_key"
```
Response: `{ "holds": [...], "total": 12, "limit": 50, "offset": 0 }`. Errors across escrow: 400 INSUFFICIENT_CREDITS / CAPTURE_EXCEEDS_HELD / INVALID_AMOUNT, 404 USER_NOT_FOUND / HOLD_NOT_FOUND, 409 HOLD_NOT_ACTIVE.

## Agent Payments (x402)

x402 is HTTP 402 made payable: a server answers with the price, the client signs a stablecoin payment, and the same request continues. An AI agent with a wallet can buy credits without a Cred account, an API key, or a human — the settled payment IS the credential.

Four routes are public and skip API-key validation, CRD metering and the normal rate limit (they are metered per client IP instead): `GET /x402/supported`, `POST /x402/purchase`, `POST /x402/platform-purchase` and `POST /x402/presale-purchase`. The last two are the ones a *browser wallet* pays — see "Paying from a browser wallet" below.

### Discover What Is Accepted
GET /x402/supported?project_id={project_id}
Public — no credentials. Returns the scheme, network, asset and facilitator. Pass project_id to also get that operator's credit price list and the asset THEY settle in.

```bash
curl -X GET "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/x402/supported?project_id=proj_abc123"
```

Response:
```json
{
  "x402Version": 2,
  "enabled": true,
  "kinds": [{ "x402Version": 2, "scheme": "exact", "network": "eip155:84532" }],
  "asset": {
    "address": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
    "name": "USDC",
    "decimals": 6,
    "network": "eip155:84532"
  },
  "facilitator": "https://x402.org/facilitator",
  "facilitator_auth": "none",
  "platform_topup": { "credits": 100, "symbol": "CRD", "price_usd": 1, "amount": "1000000", "description": "...", "pay_to": "0x..." },
  "platform_packages": [{ "package_id": "growth", "name": "Growth", "credits": 25000, "price_usd": 20, "amount": "20000000", "pay_to": "0x..." }],
  "project": {
    "project_id": "proj_abc123",
    "accepts_x402": true,
    "purchase_url": "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/x402/purchase",
    "credit_type": { "id": "ct_abc123", "name": "Gold Coins", "symbol": "GC" },
    "price_per_credit_usd": 0.01,
    "min_credits": 1,
    "max_credits": 10000,
    "pay_to": "0x...",
    "kinds": [{ "x402Version": 2, "scheme": "exact", "network": "eip155:84532" }],
    "asset": { "address": "0x...", "name": "USDC", "decimals": 6, "network": "eip155:84532" }
  }
}
```

When x402 is off the response is `{ "x402Version": 2, "enabled": false, "kinds": [], "disabled_reason": "..." }`. A project that does not sell over x402 shows `"project": { "project_id": "...", "accepts_x402": false }`.

**Facilitators.** The default facilitator (https://x402.org/facilitator) is testnet-only: with a mainnet X402_NETWORK and that facilitator, x402 is disabled with a disabled_reason. Production uses Coinbase CDP (https://api.cdp.coinbase.com/platform/v2/x402), whose calls are signed with COINBASE_API_KEY_ID and COINBASE_API_KEY_SECRET (both required; PEM or base64 Ed25519) — `facilitator_auth` then reads "api_key".

The `project.asset` block may differ from the top-level one — see "Settling in a different asset" below. Read the project's, not the platform's, when buying an operator's credits.

### Buy Credits by Paying
POST /x402/purchase
Public — no API key. Call once with no payment to receive HTTP 402 carrying the price, sign the requirements with your wallet, then call again with the payload in the PAYMENT-SIGNATURE header.

Body Parameters:
- project_id (required): Project selling the credits
- credits (required): How many to buy, within the project's min/max
- credit_type_id (optional): Defaults to the project's configured x402 credit type
- buyer_id (optional): Identifier to credit. Defaults to `x402:{your paying address}`
- buyer_email (optional): Email to attach to the buyer record
- buyer_name (optional): Display name for the buyer record

```bash
# 1. Ask, with no payment attached. Returns 402.
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/x402/purchase" \
  -H "Content-Type: application/json" \
  -d '{"project_id": "proj_abc123", "credits": 500}'

# 2. Sign the requirements (EIP-3009 for USDC), then retry with the payload.
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/x402/purchase" \
  -H "Content-Type: application/json" \
  -H "PAYMENT-SIGNATURE: <base64 PaymentPayload>" \
  -d '{"project_id": "proj_abc123", "credits": 500}'
```

The 402 carries the PaymentRequired object in the PAYMENT-REQUIRED header (base64 JSON) and in the body. On success the settlement receipt comes back in the PAYMENT-RESPONSE header, and the body is:
```json
{ "success": true, "credits_purchased": 500, "credit_type": { "id": "...", "name": "Gold Coins", "symbol": "GC" }, "new_balance": 500, "buyer_id": "x402:0xAgent...", "payer": "0xAgent...", "price_paid": 5, "currency": "USDC", "network": "eip155:84532", "transaction": "0xabc...", "x402_payment_id": "..." }
```
Errors: 400 project_id missing / credits out of bounds (with min_credits, max_credits) / wrong credit_type_id; 404 project does not accept x402; 503 x402 disabled or the project's receiving address is malformed; 429 over 30 requests/minute per IP.

### Paying for Any Call Inline
When an API key runs out of CRD, any credits-api call returns 402 with payment requirements instead of simply failing. Sign them and retry the SAME request with a PAYMENT-SIGNATURE header: the top-up settles and the original request continues in the same round trip. The settlement receipt is returned in PAYMENT-RESPONSE.

This is what keeps an autonomous caller running without a human going to buy credits.

### List Payments Received
GET /x402/receipts?status=settled&limit=50
Requires an API key. Payments taken on your project's behalf.

Query Parameters:
- status (optional): settled, pending, failed
- limit (optional): Results to return (default 50, max 200). No offset — newest first.

Response: `{ "payments": [{ "id", "kind", "status", "scheme", "network", "asset", "pay_to", "payer", "amount_atomic", "amount_usd", "transaction_hash", "resource", "credit_type_id", "credits_granted", "error", "created_at", "settled_at" }], "count": 1 }`

```bash
curl -X GET "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/x402/receipts?status=settled&limit=50" \
  -H "x-api-key: sk_live_your_api_key"
```

### Settling in a different asset
By default every project quotes the platform's asset (a USD stablecoin on the platform's network). An operator can override it in Dashboard → Agent Payments: token contract, decimals, network, and the token's EIP-712 domain name and version.

`GET /x402/supported?project_id=...` publishes the project's own `asset` and `kinds` alongside the platform's, so an agent learns which token to hold before it signs anything.

The asset and its decimals are one setting: an override without decimals is ignored rather than half-applied, because quoting a new contract with the old contract's decimals is a payment wrong by orders of magnitude.

The quote is `price_per_credit_usd x credits` expressed in the asset's units, with no price source — which assumes one unit of the asset is one dollar. That holds for a USD stablecoin and fails for anything that floats. Accepting a price-volatile token needs a price feed and is not supported.

### Paying from a browser wallet
Nothing about x402 requires an agent. The `exact` scheme on EVM is an EIP-3009 `transferWithAuthorization`, and `eth_signTypedData_v4` in a browser wallet produces exactly that — so a person clicking Pay and an agent retrying a 402 send byte-identical payloads, and settle through the same path.

Two consequences: the payer needs **no native gas** (the facilitator broadcasts what they signed), and there is **no approve step** (the authorisation and the transfer are one signature).

Cred ships three wallet-payable surfaces:
- **Dashboard → Billing** — operators buy CRD packages — or a custom amount — with stablecoin instead of a card. `POST /x402/platform-purchase` with `{ "package_id": "growth" }` or `{ "credits": 25000 }` (custom amounts are priced at the volume rate of the largest package reached, never above the next package's price), which is the one x402 route that authenticates (it credits a named account, so it reads your dashboard session rather than trusting a user id in the body).
- **/pay/{project_id}** — a hosted checkout page where your own customers buy your credits with a wallet. Posts to the same public `POST /x402/purchase` an agent uses. Prefill it: `/pay/{project_id}?credits=500&user_id=user_123&email=them@example.com`.
- **/presale/{presale_id}** — presale buyers pay in stablecoin. `POST /x402/presale-purchase`, public, settling to the operator's own address.

```bash
# What the browser does, in curl. Unpaid first — the server sets the price.
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/x402/presale-purchase" \
  -H "Content-Type: application/json" \
  -d '{"presale_id": "ps_abc123", "email": "buyer@example.com"}'
# → 402 with a PAYMENT-REQUIRED header; sign it and retry with PAYMENT-SIGNATURE.
```

### Configuring x402
Enabling x402, setting the receiving wallet, the price per credit, the purchase bounds and the settlement asset are all done in Dashboard → Agent Payments; a presale's crypto address is set on the presale itself. None of it is settable over the API — these decide where money lands, and an API key is a credential your app servers hold.

## Wallets & On-Chain Credits

A credit type can be deployed as an ERC-20, and issuing credits then mints tokens to the user who earned them. That needs an address, so every end user can carry one.

Two ways to attach it, and the difference is recorded on the user:
- **Asserted** — you send `wallet_address` on POST /users, PATCH /users or POST /users/bulk. One call, no signature. `wallet_verified_at` stays null.
- **Verified** — the holder signs a nonce this server chose. `wallet_verified_at` is set.

Both are enough to receive a mint. Addresses are stored lowercase.

Deploying the token, enabling `mint_on_issue`, and setting where x402 payments settle are NOT available over the API — they stay in the dashboard behind the operator login, because each one either spends gas or decides where money lands.

### Attach an Address (no signature)
PATCH /users
Send `wallet_address` alongside any other user field. Pass `null` to detach. Changing the address clears any previous verification.

```bash
curl -X PATCH "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/users" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"user_email": "user@example.com", "wallet_address": "0x1234567890abcdef1234567890abcdef12345678"}'
```

### Request a Challenge
POST /wallet/challenge
Issue a single-use message for the user to sign. The message names your project, not Cred — it is what the user reads in their wallet popup, so display it unmodified. Valid for ten minutes.

Body Parameters:
- external_id (optional): Your id for the user (required if user_email omitted)
- user_email (optional): User email (recommended — email-first lookup)
- address (required): The EVM address the user is claiming

```bash
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/wallet/challenge" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"user_email": "user@example.com", "address": "0x1234567890abcdef1234567890abcdef12345678"}'
```

Response:
```json
{
  "nonce": "9f8e7d6c5b4a39281706",
  "address": "0x1234567890abcdef1234567890abcdef12345678",
  "message": "Your App wants to verify that you control this wallet.\n\nSigning this message proves ownership. It does not authorise any\ntransaction, transfer, or spending approval.\n\nAddress:    0x1234...\nNonce:      9f8e7d6c5b4a39281706\nIssued at:  2026-08-17T12:00:00.000Z\nExpires at: 2026-08-17T12:10:00.000Z",
  "expires_at": "2026-08-17T12:10:00.000Z"
}
```

### Verify a Signature
POST /wallet/verify
Hand back the signature. On success the address is stored on the end user and marked verified. A challenge is single-use and a rejected signature consumes it too, so a retry needs a fresh challenge.

Body Parameters:
- nonce (required): The nonce from /wallet/challenge
- signature (required): personal_sign output over the challenge message
- address (optional): Guard — rejected if it is not the address the nonce was issued for
- chain_id (optional): Chain the proof was signed on, recorded as provenance

```bash
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/wallet/verify" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"nonce": "9f8e7d6c5b4a39281706", "signature": "0x..."}'
```

Response:
```json
{
  "verified": true,
  "user": {
    "id": "8f3c1e2a-...",
    "external_id": "user_123",
    "email": "user@example.com",
    "wallet_address": "0x1234567890abcdef1234567890abcdef12345678",
    "wallet_verified_at": "2026-08-17T12:01:04.221Z"
  }
}
```

### Read a Wallet
GET /wallet?email=user@example.com
Returns the address on file and whether it was ever proved.

```bash
curl -X GET "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/wallet?email=user@example.com" \
  -H "x-api-key: sk_live_your_api_key"
```

Response:
```json
{
  "user_id": "user_123",
  "email": "user@example.com",
  "wallet_address": "0x1234567890abcdef1234567890abcdef12345678",
  "verified": true,
  "verified_at": "2026-08-17T12:01:04.221Z"
}
```

### Detach a Wallet
DELETE /wallet
Ledger balances are untouched and tokens already minted stay in the wallet they were sent to.

```bash
curl -X DELETE "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/wallet" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"user_email": "user@example.com"}'
```

### Minting on Issue
When a credit type has `mint_on_issue` enabled, POST /issue returns an `on_chain` block alongside the balance. The ledger is the system of record and the chain follows it: a mint that cannot be sent NEVER fails, reverses, or delays the credit issuance.

POST /issue also accepts an optional `wallet_address` to mint that one issuance to a different address than the one on the user.

```json
{
  "success": true,
  "transaction_id": "txn_abc123",
  "amount_issued": 100,
  "new_balance": 100,
  "on_chain": {
    "status": "enqueued",
    "mint_id": "mint_abc",
    "transaction_hash": "0x9f8e..."
  }
}
```

Every reason a mint does not happen is reported rather than silently skipped:
```json
{
  "success": true,
  "transaction_id": "txn_abc123",
  "amount_issued": 100,
  "new_balance": 100,
  "on_chain": {
    "status": "skipped",
    "reason": "no wallet address for this end user — set end_users.wallet_address to mint to them"
  }
}
```

### Depositing Tokens for Credits
Tokens can move the other way: a credit type can collect deposits at an address, and tokens sent there credit the depositor's ledger balance.

This runs opposite to everything else — normally the ledger is the system of record and the chain follows it. A deposit is the chain telling the ledger something happened, so the guards are strict:

- **Only the configured token, only the configured address.** Any other token sent there is recorded and rejected, never credited.
- **Confirmations.** Only blocks with the operator's chosen depth behind them are scanned. The default is 12.
- **Mints are refused.** A Transfer from the zero address is a mint, not a deposit — crediting it would mean whoever holds the mint key holds a credit printer.
- **Idempotent** on (chain_id, transaction_hash, log_index). One transaction can carry several transfers into the same address, so the hash alone is not unique.
- **Attribution is strict.** The paying address must already be on an end user (see Wallets). Tokens from an unrecognised address are recorded as `unattributed` and held, not credited — attach the address to a user to resolve it.

**Deposits and mint_on_issue are mutually exclusive**, enforced by a database constraint. Minting mirrors an issuance rather than moving it, so a holder issued 100 credits also holds 100 tokens; if they could deposit those back for another 100 credits they would double their balance and could repeat it forever.

Configuration — deposit address, credits per token, confirmations — is dashboard-only. Deposited tokens are held at the deposit address; nothing is burned.

### List Deposits
GET /deposits?user_id={user_id}&status=credited
Deposits observed against your project's deposit addresses. Read-only: an API caller cannot assert that a deposit happened.

Query Parameters:
- user_id / email (optional): Filter by depositor (email-first lookup)
- credit_type_id (optional): Filter by credit type
- status (optional): observed, credited, unattributed, rejected
- transaction_hash (optional): Look up one deposit
- limit (optional): Results per page (default 50, max 200)
- offset (optional): Pagination offset

```bash
curl -X GET "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/deposits?status=unattributed&limit=50" \
  -H "x-api-key: sk_live_your_api_key"
```

Response:
```json
{
  "deposits": [
    {
      "id": "dep_abc123",
      "credit_type_id": "ct_abc123",
      "end_user_id": "8f3c1e2a-...",
      "chain_id": 8453,
      "contract_address": "0x5FbDB2315678afecb367f032d93F642f64180aa3",
      "from_address": "0x1234567890abcdef1234567890abcdef12345678",
      "to_address": "0xdeposit...",
      "amount_atomic": "1000000000000000000",
      "block_number": 21500000,
      "transaction_hash": "0xabc...",
      "log_index": 2,
      "credits_per_token": 1000,
      "credits_granted": 1000,
      "status": "credited",
      "reason": null,
      "created_at": "2026-08-18T04:10:00.000Z",
      "credited_at": "2026-08-18T04:10:01.221Z"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1 }
}
```

Amounts are decimal strings because a token balance at 18 decimals exceeds Number.MAX_SAFE_INTEGER. Parse with BigInt, not Number.

### Reading the Contract
GET /credit-types returns the on-chain facts for each type: `is_on_chain`, `blockchain`, `contract_address`, `chain_id`, `token_decimals`, `mint_on_issue` and `deployment_type` ("managed", "self" or "thirdweb"). Enough to build an explorer link, convert credits to atomic units, or offer "add this token to your wallet".

## Referral Program

Set up and manage referral programs programmatically. Configure rewards for both referrers and referees, generate unique referral codes, and track referral conversions through the API.

### Get Referral Settings
GET /referral-settings
Get the current referral program settings for your project.

```bash
curl -X GET "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/referral-settings" \
  -H "x-api-key: sk_live_your_api_key"
```

Response:
```json
{
  "settings": {
    "id": "rs_abc123",
    "is_active": true,
    "referrer_credits": 100,
    "referee_credits": 50,
    "credit_type_id": "ct_abc123",
    "credit_type_name": "Gold Coins",
    "credit_type_symbol": "GC",
    "created_at": "2024-01-15T10:30:00Z",
    "updated_at": "2024-01-15T10:30:00Z"
  }
}
```

### Configure Settings
POST /referral-settings
Create or update referral program settings for your project.

Body Parameters:
The first save creates the program and fills omitted fields with the defaults below (201). Later saves are partial updates (200): only the fields you send change — send `credit_type_id: null` to clear it explicitly.
- is_active (optional): Enable/disable the referral program (default: true)
- referrer_credits (optional): Credits to award the referrer (default: 100)
- referee_credits (optional): Credits to award the new user (default: 0)
- credit_type_id (optional): The credit type to use for referral rewards (default null — /referrals/apply then fails until one is set)

```bash
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/referral-settings" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "is_active": true,
    "referrer_credits": 100,
    "referee_credits": 50,
    "credit_type_id": "ct_abc123"
  }'
```

### Generate Referral Code
POST /referrals/generate-code
Generate a unique referral code for a user. If the user already has a code, returns the existing one. The code is stored as the user's `metadata.referral_code`; Users metadata updates keep it unless they send that key.

Body Parameters:
- user_id (required): Your unique identifier for the user

```bash
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/referrals/generate-code" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "user_id": "user_123" }'
```

Response:
```json
{
  "success": true,
  "user_id": "user_123",
  "referral_code": "ABC12XYZ"
}
```

### Apply Referral Code
POST /referrals/apply
Apply a referral code when a new user signs up. Automatically issues credits to both the referrer and referee based on your settings. 400 codes: INVALID_REFERRAL_CODE, ALREADY_REFERRED, SELF_REFERRAL; 400 also when the program is inactive or has no credit type.

Body Parameters:
- user_id (required): Your unique identifier for the new user being referred
- referral_code (required): The referral code to apply

```bash
curl -X POST "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/referrals/apply" \
  -H "x-api-key: sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "new_user_456",
    "referral_code": "ABC12XYZ"
  }'
```

Response:
```json
{
  "success": true,
  "referral_id": "ref_xyz789",
  "referrer_credits_issued": 100,
  "referee_credits_issued": 50
}
```

### List Referrals
GET /referrals?status=completed&limit=50
List all referrals for your project with optional filtering and pagination.

Query Parameters:
- status (optional): Filter by status (pending, completed)
- referrer_user_id (optional): Filter by the referrer's external_id (or email)
- referrer_email (optional): Filter by the referrer's email
- limit (optional): Max results (default: 50, max: 100)
- offset (optional): Pagination offset

```bash
curl -X GET "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api/referrals?status=completed&limit=10" \
  -H "x-api-key: sk_live_your_api_key"
```

Response:
```json
{
  "referrals": [
    {
      "id": "ref_xyz789",
      "referral_code": "ABC12XYZ",
      "referrer_user_id": "uuid-xxx",
      "referred_user_id": "uuid-yyy",
      "status": "completed",
      "referrer_credits_earned": 100,
      "referee_credits_earned": 50,
      "completed_at": "2024-01-15T10:30:00Z",
      "created_at": "2024-01-15T10:30:00Z"
    }
  ],
  "total": 25,
  "limit": 10,
  "offset": 0
}
```

**Referral Program Integration Flow:**
1. Configure settings: Set up your referral rewards using POST /referral-settings
2. Generate codes: When a user wants to refer others, call POST /referrals/generate-code
3. Share codes: Display the referral code to the user in your app
4. Apply codes: When a new user signs up with a code, call POST /referrals/apply
5. Track results: Monitor your referral program with GET /referrals

## Pre-Sale Checkout
POST https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/presale-checkout
Creates a checkout session for pre-sale purchases. No API key required — uses presale_id.
Body: presale_id (required), email (required), name, units, wallet_address, success_url, cancel_url, signup_url. Response: `{ "url", "session_id", "order_id", "units" }`.

## Webhooks
Configure webhooks to receive real-time notifications. Test endpoints with the dashboard ping button.

Available events:
- credits.issued — Credits were added to a user — including Stripe purchases, which carry a purchase_id
- credits.deducted — Credits were removed from a user
- credits.refunded — A credit transaction was reversed; direction is clawback (credits removed) or return (spent credits given back)
- credits.swapped — A user swapped one credit type for another
- escrow.held — Credits were reserved into an escrow hold
- escrow.released — An escrow hold was released back to the user
- escrow.captured — An escrow hold was settled (optionally to another user)
- referral.completed — A referral was applied and its credits issued
- subscription.created — A recurring checkout created a subscription
- subscription.renewed — A renewal was paid and its credits were added
- subscription.cancel_scheduled — A subscription will end after its paid period
- subscription.canceled — A subscription ended
- subscription.payment_failed — A renewal payment failed and needs customer action
- auto_topup.succeeded — A low balance was topped up on the saved card
- auto_topup.failed — An automatic top-up could not charge the saved card

Every delivery carries a unique event id as both "id" and "event_id", the event name as "event" and "type", and a "created_at" timestamp, plus the event-specific fields. The same values are mirrored in the X-Cred-Event-Id, X-Cred-Event and X-Cred-Timestamp headers — use the event id for idempotency. A delivery is attempted up to three times on network errors, 5xx and 429; other 4xx responses are not retried.

**Credit type scope.** An endpoint can be scoped to specific credit types in Dashboard → Webhooks. A scoped endpoint only receives events whose credit_type_id (either side of a swap, or subscription.credit_type_id) is in its scope; an event with no credit type is still delivered. Unscoped endpoints receive every event they subscribe to.

**credits.refunded** carries `direction`: "clawback" (credits removed — a purchase or issue was refunded) or "return" (spent credits given back), plus refund_transaction_id, amount, new_balance, source, partial, payment_refunded and stripe_refund_id.

Webhook payloads are signed with HMAC-SHA256 (hex digest of the raw body). Verify using the X-Cred-Signature header.

## MCP Server (AI Agents)

cred.diy exposes a Model Context Protocol (MCP) server so AI agents can manage credits, users, pricing, swaps, and referrals programmatically.

### MCP Endpoint
`https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/cred-mcp`
Transport: Streamable HTTP

### Authentication (Headers)
- `x-api-key`: Your cred.diy API key
- `x-project-id`: Optional; the API key identifies the project

### Available Tools (48)
Credits: issue_credits, deduct_credits, get_balance, get_transactions, refund_credits
Credit Types: list_credit_types
Users: create_user, get_users, update_user, delete_user, bulk_create_users, get_signup_analytics
Pricing: list_pricing_tiers, create_pricing_tier, update_pricing_tier, delete_pricing_tier
Checkout: create_checkout_session
Subscriptions: list_subscriptions, cancel_subscription, update_subscription_quantity, create_subscription_portal_link
Saved Cards: get_saved_payment_method, delete_saved_payment_method, charge_saved_card
Auto Topup: get_auto_topup, save_auto_topup, delete_auto_topup
Escrow: create_escrow_hold, release_escrow_hold, capture_escrow_hold, list_escrow_holds
Swaps: list_swap_pairs, execute_swap
Payouts: redeem_credits, onboard_payout_account
Referrals: get_referral_settings, save_referral_settings, generate_referral_code, apply_referral, list_referrals
Wallets: set_user_wallet, get_user_wallet, request_wallet_challenge, verify_wallet_signature, list_deposits
x402: x402_supported, buy_credits_x402, list_x402_receipts

Every tool except x402_supported and buy_credits_x402 authenticates with the x-api-key header (or an api_key argument). The key identifies the project, so x-project-id is optional. Each call costs 1 CRD, even when the API rejects it (except a 429). create_checkout_session and the two public x402 tools are free. A call rejected with 429 RATE_LIMITED costs nothing. Rejections come back as tool errors with http_status, the API error code and request_id (quote it to support). Pass idempotency_key to issue_credits, deduct_credits, execute_swap, create_escrow_hold and redeem_credits so retries are safe (forwarded as the Idempotency-Key header). refund_credits and charge_saved_card require one. An optional x-app-origin header records your app as signup attribution.

The two x402 tools need no API key — the payment is the credential — so an agent that has never heard of Cred can discover a price and buy credits with them.

The wallet pair is split deliberately: request_wallet_challenge returns a message, the user's wallet signs it somewhere the agent does not control, and verify_wallet_signature submits the result.

A second, read-only server, Cred.DIY Hub (`https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/mcp`), signs in with your cred.diy account over OAuth instead of an API key. Its tools are list_projects, list_credit_types, list_pricing_tiers and list_end_users.

### Claude Desktop Config
```json
{
  "mcpServers": {
    "cred": {
      "url": "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/cred-mcp",
      "headers": {
        "x-api-key": "sk_live_your_api_key",
        "x-project-id": "your_project_id"
      }
    }
  }
}
```

## Dashboard Features
- **Getting Started Checklist** — guided setup for new projects
- **Cmd+K Command Palette** — quick navigation across all pages
- **CSV Export** — download users and transactions data
- **Breadcrumb Navigation** — context-aware page hierarchy
- **Date Range Filters** — filter analytics by 7d, 14d, 30d
- **API Key Expiration** — set 30d to 1yr expiration dates
- **Webhook Testing** — ping endpoints before going live
- **Skeleton Loading** — polished loading states throughout
- **Purchase History** — view all credit purchases on Billing page
- **Confirmation Dialogs** — safety prompts for destructive actions

## Rate Limits
- 100 requests per minute per API key (public x402 routes: 30 per minute per IP)
- Over the limit: 429 `{ "error": "Rate limit exceeded. Try again later.", "code": "RATE_LIMITED" }` with an X-RateLimit-Reset header (unix seconds)
- The limit is checked before the 1 CRD charge, so a rate-limited call costs nothing
- Response headers include:
  - X-RateLimit-Limit: Maximum requests per window (100)
  - X-RateLimit-Remaining: Requests remaining in current window
  - X-Credits-Remaining: Your remaining CRD balance (on 402 responses)
  - X-Request-Id: this call's request id (see "Errors & Request IDs")

## Errors & Request IDs
Every error is JSON: `error` (human-readable), `code` (machine-readable, when one applies), the fields that code documents, and `request_id`. Branch on `code` (and `error_code` on 402s), never on the message.

**Request IDs:** every response carries an `X-Request-Id` header (exposed to browsers via CORS), and every JSON error body (status >= 400) carries the same value as `request_id`. Send your own `X-Request-Id` (8–128 chars of letters, digits, `. _ : -`) to trace a call end to end; without one (or with a malformed one) a UUID is generated. Quote it to support. The SDK returns it as `requestId` on every response and `error.requestId` on failures, and accepts `{ requestId }` in per-call options.

```
HTTP/1.1 400 Bad Request
X-Request-Id: 6f1c2e4a-9b7d-4c1e-8a2f-3d5b7e9f1a2c

{ "error": "Insufficient credits", "code": "INSUFFICIENT_CREDITS", "requested_amount": 100, "request_id": "6f1c2e4a-9b7d-4c1e-8a2f-3d5b7e9f1a2c" }
```

### HTTP statuses
- 400: Invalid parameters or JSON, or a business rule (INSUFFICIENT_CREDITS, NO_FIELDS_TO_UPDATE, SWAP_NOT_BIDIRECTIONAL, …)
- 401: Missing, unknown, disabled or expired API key (INVALID_API_KEY, API_KEY_DISABLED, API_KEY_EXPIRED)
- 402: Out of CRD (PLATFORM_CREDITS_EXHAUSTED, payable via x402 when enabled), or Stripe refused money (STRIPE_REFUND_FAILED, STRIPE_TRANSFER_FAILED, CARD_DECLINED)
- 403: Platform Stripe Connect blocked for the project (AUP_NOT_ACCEPTED, AUP_VERSION_STALE, CATEGORY_RESTRICTED)
- 404: User or resource not found; an unknown endpoint lists available_endpoints
- 409: Conflict: IDEMPOTENCY_KEY_REUSED, TIER_IN_USE, USER_HAS_HISTORY, ALREADY_REFUNDED, HOLD_NOT_ACTIVE
- 429: RATE_LIMITED — free (no CRD charged); back off until X-RateLimit-Reset
- 500: Internal error — quote the request_id to support
- 502: Stripe's outcome unknown: PAYOUT_PENDING (/redeem) or charge_not_started (/charge-saved) — retry with the same idempotency key

### Authentication, metering and rate limits
- `INVALID_API_KEY` (401; every API-key call): Missing, malformed (not sk_live_) or unknown API key.
- `API_KEY_DISABLED` (401; every API-key call): The key was disabled on the API Keys page.
- `API_KEY_EXPIRED` (401; every API-key call): The key's expiration date has passed. Create a new key; expiry is enforced.
- `RATE_LIMITED` (429; every API-key call): Over 100 requests/minute for this key (30/minute per IP on the public x402 routes). Checked before the 1 CRD charge, so a 429 costs nothing. Retry after X-RateLimit-Reset (unix seconds).
- `PLATFORM_CREDITS_EXHAUSTED` (402; every API-key call): Your CRD (platform credit) balance is spent. Sent as both code and error_code, with credits_remaining; when x402 is enabled the 402 also carries a PAYMENT-REQUIRED offer you can pay and retry the same request with.
- `AUP_NOT_ACCEPTED / AUP_VERSION_STALE / CATEGORY_RESTRICTED` (403; checkout and charges on platform Stripe Connect): The project has not accepted the current acceptable-use policy, or its business category is restricted on the platform account.

### Idempotency
- `INVALID_IDEMPOTENCY_KEY` (400; any write with a key): The idempotency key is longer than 255 characters.
- `IDEMPOTENCY_KEY_REUSED` (409; /issue, /deduct, /refund, /swap, /redeem, /escrow/hold, /charge-saved): This key was already used for a different request. A retry of the same request returns the first result (idempotent_replay: true) instead.
- `IDEMPOTENCY_KEY_REQUIRED` (400; /charge-saved): Off-session charges need an Idempotency-Key header (or idempotency_key).

### Credits, users and pricing tiers
- `INSUFFICIENT_CREDITS` (400; /deduct, /escrow/hold, /redeem, /swap, /refund (clawback)): The user's balance is too low. /deduct and /redeem include requested_amount; /swap includes required. Not 422.
- `INVALID_CREDIT_TYPE` (400; /issue, /deduct, /escrow/hold, referral settings): credit_type_id is not an active credit type in this project.
- `USER_NOT_FOUND` (404; user-scoped endpoints): No user with that user_email / user_id (external_id) in this project. Only /issue and POST /users create users.
- `NO_FIELDS_TO_UPDATE` (400; PATCH /users): The body has none of email, name, metadata, signup_url, signup_host, signup_credit_type_id, wallet_address.
- `USER_HAS_HISTORY` (409; DELETE /users): The user has swap history, kept as a financial record, so the user cannot be deleted (swap_transactions gives the count).
- `TIER_IN_USE` (409; DELETE /pricing-tiers): Auto top-up rules buy this tier (auto_topup_rules gives the count). Move them, or retry with force: true to delete the rules and the tier in one transaction.
- `tier_not_one_time` (400; /charge-saved, POST /auto-topup): The tier is recurring-only; saved-card charges need a one-time (or both) tier.

### Escrow holds
- `HOLD_NOT_FOUND` (404; /escrow/release, /escrow/capture): No hold with that id in this project.
- `HOLD_NOT_ACTIVE` (409; /escrow/release, /escrow/capture): The hold was already released or captured.
- `CAPTURE_EXCEEDS_HELD` (400; /escrow/capture): The capture amount is more than the hold.
- `INVALID_AMOUNT` (400; /escrow/*): amount must be a positive number.

### Swaps
- `SWAP_NOT_BIDIRECTIONAL` (400; /swap): direction "reverse" on a one-way pair. Enable Bidirectional on the pair to swap target → source.
- `INVALID_DIRECTION` (400; /swap): direction must be "forward" or "reverse".

### Refunds
- `PURCHASE_NOT_FOUND` (404; /refund): No purchase with that purchase_id / stripe_payment_intent_id in this project.
- `TRANSACTION_NOT_FOUND` (404; /refund): No transaction with that transaction_id / reference_id in this project.
- `ALREADY_REFUNDED` (409; /refund): The source was already refunded in full.
- `PURCHASE_NOT_REFUNDABLE` (400; /refund): Only completed purchases can be refunded.
- `NOT_REFUNDABLE` (400; /refund): Redeem and escrow transactions have their own reversal flows and cannot be refunded here.
- `REFUND_EXCEEDS_ORIGINAL` (400; /refund): The amount is more than what is left to refund.
- `STRIPE_REFUND_FAILED` (402; /refund): Stripe refused the money refund; no credits moved.

### Cash payouts (redeem)
- `NOT_REDEEMABLE` (400; /redeem): The credit type is not marked Redeemable for Cash.
- `PAYOUT_TOO_SMALL` (400; /redeem): amount × exchange_rate rounds to zero.
- `INVALID_DESTINATION` (400; /redeem): destination is not this user's own payout account from /onboard-payout (or they have none).
- `STRIPE_KEYS_MISSING` (400; /redeem, /onboard-payout): The project has no Stripe secret key under Payments → Your Own Keys.
- `STRIPE_TRANSFER_FAILED` (402; /redeem): Stripe refused the transfer; the credits were returned (new_balance).
- `PAYOUT_PENDING` (502; /redeem): Stripe's answer was lost; the credits stay deducted. Retry with the returned idempotency_key to finish it — it never pays twice. If you never retry, the reconciliation sweep settles it after 15 minutes (paid, or credits returned only if Stripe definitively refuses). A claim the sweep cannot settle safely is flagged needs_review for manual review instead of being paid or refunded; retries keep answering PAYOUT_PENDING until then.

### Saved cards (POST /charge-saved, auto top-up)
- `CARD_DECLINED` (402; /charge-saved): Sent as error_code. code is Stripe's (e.g. card_declined), with decline_code, message and purchase_id.
- `CARD_AUTHENTICATION_REQUIRED` (402; /charge-saved): Sent as error_code with requires_action: true — the bank wants the buyer present (3-D Secure). Send them through hosted checkout.
- `no_saved_payment_method` (404; /charge-saved): The user has no saved card. (DELETE /payment-method answers 200 { deleted: false } with this code instead.)
- `saved_payment_method_unusable` (409; /charge-saved): The card was saved on a Stripe account the project no longer charges through. The buyer must save a card again at checkout.
- `charge_not_started` (502; /charge-saved): Stripe could not be reached to start the charge. Retry with the same idempotency key; it never charges twice.

### Referrals
- `INVALID_REFERRAL_CODE` (400; /referrals/apply): No referrer has this code.
- `ALREADY_REFERRED` (400; /referrals/apply): The user already used a referral code.
- `SELF_REFERRAL` (400; /referrals/apply): A user cannot use their own code.

### Own-keys Stripe webhook (payment-webhook, seen in Stripe's delivery log)
- `MISSING_SIGNATURE` (400; payment-webhook): The delivery has no Stripe-Signature header.
- `INVALID_PROJECT` (400; payment-webhook): ?project_id in the endpoint URL is not a valid project id.
- `WEBHOOK_SECRET_NOT_CONFIGURED` (400; payment-webhook): No signing secret saved for this project. Save the endpoint's whsec_ secret under Payments → Your Own Keys.
- `INVALID_SIGNATURE` (400; payment-webhook): The signature does not match the saved signing secret (wrong or rotated whsec_).

### SDK-side (no HTTP response)
- `TIMEOUT` (—; @cred-diy/sdk): The request exceeded the client timeout (default 30 s).
- `NETWORK_ERROR` (—; @cred-diy/sdk): fetch failed, or no fetch implementation is available.
- `INVALID_RESPONSE` (—; @cred-diy/sdk): The API answered 2xx with a body that is not JSON.

**Note:** Insufficient user credits returns 400 with code "INSUFFICIENT_CREDITS", not 422.
