# Still Pay

> Hosted crypto invoices. Price in USD, send a payment page, get a signed webhook when the transfer confirms.

Unique deposit address per invoice. One hour to pay unless you set `ttl_seconds`. **Credit the order only on `paid`.** Do not fulfill on `underpaid` or `expired`.

Replace `{origin}` with your Still Pay host.

Human docs: `{origin}/docs`
This file: `{origin}/docs.md`
Index for agents: `{origin}/llms.txt`

## Start

1. Create an account and open the dashboard.
2. In Apps, create a project. The API key is shown once — copy it immediately.
3. Paste a public HTTPS webhook URL on that project and save the signing secret.
4. `POST` an invoice with `Authorization: Bearer sk_live_…`
5. Send the payer `payment_url`. They pick asset and network on the hosted page.

## Auth

Merchant API uses the project key. Dashboard uses a session cookie.

Live keys look like `sk_live_…`. They are shown once. The table only keeps a mask. Revoke a leaked key and issue a new one.

Every `/api/v1/…` call is scoped to the project that owns the key.

- Missing, invalid, or revoked key → `401`

## Invoices

Create, read, list, or expire. The payer never sees your key.

| Field | Notes |
| --- | --- |
| `amount_usd` | Required. Positive USD amount as a string. |
| `order_id` | Optional. Unique per project. Echoed on the webhook. Duplicate → `409`. |
| `description` | Optional. Shown on the payment page. |
| `metadata` | Optional JSON. Returned on GET. **Not** included on the webhook. |
| `ttl_seconds` | Optional. Default `3600`. Min `300`, max `604800`. |
| `webhook_url` | Optional public HTTPS override for this invoice. |
| `success_url` / `cancel_url` | Optional return links after the payer is done. |

```bash
curl -s {origin}/api/v1/invoices \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "amount_usd": "10.00",
    "order_id": "ord_123",
    "description": "Pro plan",
    "ttl_seconds": 3600
  }'
```

Create response (before the payer picks a method):

```json
{
  "id": "inv_01HZX…",
  "status": "pending",
  "amount_usd": "10.00",
  "order_id": "ord_123",
  "description": "Pro plan",
  "payment_url": "{origin}/pay/inv_01HZX…",
  "metadata": {},
  "asset": null,
  "chain": null,
  "address": null,
  "tx_hash": null,
  "paid_amount": null,
  "underpaid": false,
  "expires_at": "2026-08-21T16:00:00+00:00",
  "created_at": "2026-08-21T15:00:00+00:00"
}
```

`asset`, `chain`, and `address` fill in after the payer picks a method. Poll `GET /api/v1/invoices/{id}` if a webhook is late.

```bash
curl -s {origin}/api/v1/invoices/{id} \
  -H "Authorization: Bearer sk_live_…"
```

List: `GET /api/v1/invoices` with `limit` (1–200), `offset`, `q`, and `status` (`open`, `pending`, `awaiting_payment`, `confirming`, `paid`, `underpaid`, `expired`). Response is `{ items, total }`.

Expire an open or underpaid invoice: `POST /api/v1/invoices/{id}/expire`.

| Code | Meaning |
| --- | --- |
| `400` | Bad amount, ttl out of range, or webhook URL not public HTTPS. |
| `401` | Missing, invalid, or revoked API key. |
| `404` | Invoice not in this project. |
| `409` | `order_id` already used, or the invoice cannot be expired. |

## Webhooks

POST JSON to your project URL on `paid`, `expired`, and `underpaid`. At-least-once, with retries. The event `id` stays the same on retries.

| Header | Value |
| --- | --- |
| `X-Webhook-Event` | `paid` · `expired` · `underpaid` |
| `X-Webhook-Id` | `{invoice_id}:{event}` |
| `X-Webhook-Timestamp` | Unix seconds. Reject if older than ~5 minutes. |
| `X-Webhook-Signature` | `sha256=` HMAC-SHA256 of `{timestamp}.{body}` |

Body example (`paid`):

```json
{
  "id": "inv_01HZX…:paid",
  "event": "paid",
  "invoice_id": "inv_01HZX…",
  "order_id": "ord_123",
  "status": "paid",
  "amount_usd": "10.00",
  "paid_amount": "10.00412",
  "asset": "USDT",
  "chain": "tron",
  "tx_hash": "a1b2c3…",
  "address": "T…",
  "paid_at": "2026-08-21T15:12:04+00:00"
}
```

Verify in Python:

```python
import hashlib, hmac, time

def verify(secret: str, body: bytes, timestamp: str, signature: str) -> bool:
    if abs(time.time() - int(timestamp)) > 300:
        return False
    digest = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(f"sha256={digest}", signature)
```

URL must be public HTTPS. After a 2xx we stop. Non-2xx retries up to 8 times (1 min, then 5 min, 15 min, 1 h, 6 h, 12 h, 24 h). Store `id` and ignore duplicates. A later `paid` after `underpaid` is a different `id`. If you are unsure, GET the invoice before crediting the order.

## Status

The payer picks asset and network on the hosted page. You do not.

| Status | Meaning | Merchant action |
| --- | --- | --- |
| `pending` | Created. No method yet. | Wait. |
| `awaiting_payment` | Address assigned. Waiting for the transfer. | Wait. |
| `confirming` | EVM transfer seen; waiting for confirmation blocks. No `paid` webhook yet. | Do not fulfill. |
| `paid` | Full expected amount after network confirmations. Amounts above the quote are not added to merchant balance (see Terms). | **Fulfill the order.** |
| `underpaid` | Partial amount. Tracking continues until expiry; top-ups add up and a `paid` webhook may follow. | Do not fulfill. |
| `expired` | TTL ran out without a full payment. | Do not fulfill. |

`paid` is not mempool. We wait this many blocks first:

| Confirmations | Networks |
| --- | --- |
| 1 | Bitcoin, Litecoin, Solana, Gram (TON) |
| 5 | Ethereum, Base, Arbitrum, BSC |
| 15 | Tron |

Methods the payer can pick: BTC, LTC, SOL, GRAM, TRX, BNB, ETH, USDT, and USDC, on the networks above. Gram uses a shared deposit address and a unique memo. Balance is USD. Payouts go out in USDT or USDC from the dashboard. The payer covers the network fee shown on the payment page. Amounts sent above the invoice quote are not credited to merchant balance — see Terms of Use (`/terms`).
