# SatLane API

Non-custodial Bitcoin payments. Start in Sandbox, connect your wallet for Live, and reconcile orders from signed webhooks.

This guide covers integrating SatLane into your app: create invoices from your server, send buyers to hosted checkout or build a custom UI, and fulfill orders from signed webhooks.

**Base URL:** `https://api.satlane.com`

Register at `https://app.satlane.com/signup`, verify your email and create a store. Use a Test key while building. The API origin is `https://api.satlane.com`; hosted checkout URLs are returned by the API and may use a merchant custom domain.

Installations may use either the wallet-free contract or the legacy test-mode configuration. Confirm which is enabled before copying an integration. On an enabled installation, Test invoice creation requires `SatLane-Version: 2026-10-05`; the response has no payable destination. A disabled installation rejects this version with `sandbox_contract_unavailable`.

---

## 1. Overview

1. Your server creates an invoice with `POST /v1/invoices`.
2. Your application sends the buyer to `invoice.hosted_checkout_url`.
3. The buyer sends Bitcoin to the derived address in your wallet.
4. SatLane monitors live payments and confirmations.
5. SatLane sends a signed event to your registered store webhook endpoint.
6. Your server verifies the event, reconciles the order and queues fulfillment.

Your private keys stay in your wallet. You register an extended public key (xpub) with SatLane so it can derive invoice addresses; the registered xpub is encrypted at rest. SatLane assigns a receiving address to each Live invoice and watches the chain. Use the returned invoice ID for order reconciliation; an address is not a permanent order identifier. We never hold private keys; funds settle directly into your wallet on confirmation.

### Platform billing (for integrators)

SatLane supports Starter, Basic, Pro and Custom with billing V4 enabled. Legacy installations retain Trial, Basic, Pro and Custom. The internal `trial` key means Starter in V4. For API integrators:

- Eligible **paid Live** invoices accrue usage fees against your account at the effective plan rate. Complimentary accounts waive new subscription and usage charges; existing balances remain for separate review.
- In Billing V4, Basic and Pro subscription prices are fixed in USD and quoted in Bitcoin at bill issuance. Subscriptions are billed in advance and usage in arrears. Manage bills at `/billing`; this payments integration guide does not define a public plan-checkout API.
- Billing V4 Starter has no trial-exhaustion counter. Unpaid bills can restrict **new Live invoice creation** after their due/grace period. Legacy Trial installations retain their paid-invoice trial limit. Review the account billing page for the applicable restriction.
- Test-mode invoices do not accrue platform fees.

Manage plans, usage, and Bitcoin bills in the app. Subscription entitlements apply across the account; active-store limits and domains per store follow the current catalog. Paid upgrades credit unused plan time; downgrades and cancellations take effect at period end. See [pricing](/pricing) for the current catalog and billing explanation. This guide focuses on the payments API.

### Merchant setup before integration

1. Register and verify your email at `https://app.satlane.com`.
2. Create a store and start in Sandbox. Complete two-factor authentication to manage keys and wallet settings.
3. Create the store’s Test API key. Keep it on your server and send the required version header when creating Test invoices.
4. Register a public HTTPS webhook endpoint for the Test environment. Simulate events and verify signature, duplicate-event and order-reconciliation handling.
5. Configure checkout branding, receipts and optional SMTP credentials in that store’s settings. These settings belong to the store; they are not a single account-wide sender configuration. Custom domains and advanced checkout controls depend on the plan.
6. Review the mainnet wallet and activation checklist, activate Live explicitly, create a Live key and register the Live webhook destination before accepting real payments.

Sandbox and Live data stay separate. Use the hosted checkout URL returned for each invoice, including on custom-domain stores, instead of constructing a platform URL yourself.

---

## 2. Authentication

| Surface                                           | Auth                                             |
| ------------------------------------------------- | ------------------------------------------------ |
| **Server-side API** (`POST /v1/invoices`, etc.)   | `Authorization: Bearer sl_live_…` or `sl_test_…` |
| **Public buyer endpoints** (`/pay/invoices/:id*`) | None. The invoice UUID is the credential.        |
| **Vendor dashboard**                              | Session cookie (dashboard only)                  |

API keys are issued per store from `https://app.satlane.com/stores/<id>/keys`. Complete the dashboard’s two-factor authentication setup and verification before managing API keys or registering a wallet. Each secret is shown once on creation. Store it in your secrets manager.

```bash
curl https://api.satlane.com/v1/invoices \
  -H "Authorization: Bearer sl_test_XXX"
```

Use a test key (`sl_test_…`) while building. Switch to a live key (`sl_live_…`) when the store is live.

---

## 3. Sandbox and Live environments

### Wallet-free Sandbox on enabled installations

On an installation with wallet-free Sandbox enabled, select Sandbox in the dashboard. A new store has pending Live activation, and Test invoice creation does not require an xpub. External integrations use a test API key and send `SatLane-Version: 2026-10-05` on invoice creation. Without that header an enabled Test create returns `sandbox_contract_required` (426).

Test invoices have `environment: "test"`, `address: null`, `payment_uri: null` and a simulated `payment_destination` with null network/address/URI. Do not generate a QR code, wallet link or payment address for them. Simulate outcomes from invoice details or the authorized simulation endpoint. Test activity is isolated from Live receipts, allocations and billing; it does not consume real Bitcoin or accrue a Live payment fee.

Live uses a Live API key, explicit store activation, an active mainnet receiving xpub, account eligibility and synchronized chain services. Selecting the Live workspace changes the view; it does not activate a store. Pausing stops new Live invoices while existing payments and their webhook processing continue. Sandbox stays available independently.

Register each receiving extended public key with only one store. SatLane compares the underlying public key and chain code across all stores, including inactive wallets and archived stores. Changing xpub/ypub/zpub prefixes, network labels or derivation paths does not bypass this check. Use a separate wallet account for a second store. Historical conflicting keys cannot be reactivated or used to create new invoices; existing invoices remain available for payment monitoring.


### Legacy installations with rollout disabled

The legacy store `test_mode` toggle defaults to true. When enabled, it forces Test invoices regardless of the key environment; existing legacy creation requires an active registered xpub. These records can contain an address but are simulated while store test mode is on: do not send real funds.

With the legacy toggle off, the API key environment selects Live/mainnet or Test/testnet wallet requirements. Confirm the returned environment rather than inferring behavior from a key prefix. Legacy destinations and historical records are not silently converted to the new wallet-free contract. Real testnet support is not part of the new simulated Sandbox contract.

---

## 4. Create an invoice

The following request uses illustrative order values. Response IDs, addresses, rates and timestamps below are examples, not current market rates or reusable payment destinations.

```bash
curl -X POST https://api.satlane.com/v1/invoices \
  -H "SatLane-Version: 2026-10-05" \
  -H "Authorization: Bearer sl_test_XXX" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-123-attempt-1" \
  -d '{
    "amount": 49.99,
    "currency": "USD",
    "order_ref": "ORD-12345",
    "success_url": "https://yourshop.com/orders/ORD-12345/thanks",
    "buyer_email": "buyer@example.com",
    "expires_in_minutes": 15,
    "metadata": { "cart_id": "abc123" }
  }'
```

This creation example targets an installation with wallet-free Sandbox enabled. Use your deployment origin and confirmed contract for another installation. A webhook endpoint is optional for manual checkout; configure one for the matching environment before testing automated fulfillment.

Always send an `Idempotency-Key` on API creation. Successful responses are retained for 24-hour replay. Enabled creation stores the invoice and idempotency result atomically under a scoped reservation; reuse the same key and body after an uncertain result. A conflicting body is rejected. Legacy installations only provide the older response-cache behavior, so use sequential retries. Neither API replay nor invoice creation removes the need for order-level reconciliation and webhook deduplication. The session-authenticated dashboard form does not expose this API-key idempotency contract.

### Request fields

| Field                | Type   | Required | Notes                                                                                                                                |
| -------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `amount`             | number | one of   | Fiat amount. We lock a BTC/USD rate and convert to sats.                                                                             |
| `currency`           | string | one of   | Must be `"USD"` today.                                                                                                               |
| `amount_sats`        | string | one of   | Skip fiat conversion; charge exact sats (digits only).                                                                               |
| `order_ref`          | string | optional | Your internal order ID. Max 255 chars.                                                                                               |
| `callback_url`       | string | optional | Stored invoice metadata. Current delivery uses registered store webhook endpoints; this field does not override them.                |
| `success_url`        | string | optional | Hosted checkout redirects here after payment.                                                                                        |
| `buyer_email`        | string | optional | Buyer email for receipts / support.                                                                                                  |
| `expires_in_minutes` | int    | optional | 5–120. Public API default is the configured platform expiry (15 minutes by default). The dashboard store form uses the store expiry. |
| `metadata`           | object | optional | Free-form `string → string` map (values max 255 chars). Echoed on webhooks.                                                          |

Provide either `(amount + currency)` **or** `amount_sats`, not both.

### Response

Illustrative enabled Sandbox response. Nullable destinations must stay null in your models and UI. Live responses contain a mainnet destination; legacy responses may contain a Test destination.

```json
{
  "invoice": {
    "id": "22872e14-4216-4c78-8fe1-088ea649f3c2",
    "store_id": "…",
    "vendor_id": "…",
    "status": "pending",
    "environment": "test",
    "address": null,
    "payment_destination": {
      "kind": "simulated",
      "network": null,
      "address": null,
      "payment_uri": null
    },
    "amount_sats": "150234",
    "amount_btc": "0.00150234",
    "amount_fiat": 49.99,
    "fiat_currency": "USD",
    "btc_usd_rate": 33280.45,
    "rate_locked_at": "2026-05-16T11:15:00.000Z",
    "amount_tolerance_sats": "375",
    "amount_paid_sats": "0",
    "expires_at": "2026-05-16T11:30:00.000Z",
    "late_payment_grace_minutes": 60,
    "late_payment_deadline_at": "2026-05-16T12:30:00.000Z",
    "conf_threshold": 1,
    "fee_sats": "0",
    "payment_uri": null,
    "hosted_checkout_url": "https://pay.satlane.com/i/22872e14-4216-4c78-8fe1-088ea649f3c2",
    "payment_phase": "awaiting_payment",
    "order_ref": "ORD-12345",
    "created_at": "2026-05-16T11:15:00.000Z",
    "paid_at": null
  }
}
```

### Response fields worth understanding

| Field                      | What it means                                                                                                                                                                                                                        |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `amount_sats`              | Invoice amount in satoshis. Vendor-facing source of truth.                                                                                                                                                                           |
| `amount_paid_sats`         | Running total of sats received on-chain so far (non-reverted). Remaining = `amount_sats - amount_paid_sats`.                                                                                                                         |
| `amount_tolerance_sats`    | Slack on the expected amount. Payments within `[amount_sats − tolerance, amount_sats + tolerance]` count as exact. Defaults come from platform setting `payment_tolerance_bp` (default 25 bp = 0.25%), clamped to `[10, 1000]` sats. |
| `btc_usd_rate`             | BTC/USD rate locked at creation. Later price moves do not change what the buyer owes.                                                                                                                                                |
| `late_payment_deadline_at` | ISO timestamp past which payments are no longer auto-credited. Active monitoring runs through that deadline; recovery monitoring may follow.                                                                                         |
| `conf_threshold`           | Confirmations required before status flips to `paid`. Defaults: 1 below $100 invoice value, 2 at $100+.                                                                                                                              |
| `fee_sats`                 | Platform take-rate on this invoice, accrued to your vendor account when a **live** invoice is paid. Visible in dashboard billing.                                                                                                    |
| `payment_phase`            | Buyer-facing lifecycle dimension computed at read time. Values include `awaiting_payment`, `payment_detected`, `confirming`, `settled`, `shortfall`, `excess`, `needs_review`, `grace_period`, `recovery_window`, and `closed`.      |
| `payment_summary`          | Expected, received and remaining sats, latest-payment confirmations, latest transaction ID and grace deadline. Received totals are not confirmed coverage. Treat sats values as strings.                                                                                                            |
| `checkout_urls`            | Optional checkout URL alternatives. Prefer `hosted_checkout_url` for the standard hosted flow; do not assume alternatives are always present.                                                                                        |
| `hosted_checkout_url`      | Ready-to-redirect hosted payment page.                                                                                                                                                                                               |

---

## 5. Checkout

You have two options.

### Option A: Hosted checkout (easiest)

```js
const { invoice } = await createInvoice(...);
res.redirect(invoice.hosted_checkout_url);
```

For a Live invoice with a payable destination, the buyer sees a mobile-first payment page with QR code, address, countdown, status pill, and "Open in wallet". Enabled Sandbox instead displays a simulated-payment view with no payable QR, address or wallet link. The page uses live updates and can redirect to your `success_url` after the payment flow completes. A browser redirect alone must never trigger order fulfillment; use a verified webhook or authenticated server-side invoice lookup.

### Option B: Custom checkout UI

Render your own frontend. SatLane exposes public (unauthenticated) buyer endpoints:

```js
// 1. Fetch the snapshot (invoice ID is the credential)
const res = await fetch(`https://api.satlane.com/pay/invoices/${invoiceId}`);
const { invoice, store, live } = await res.json();

// 2. Render invoice.payment_uri as a QR code

// 3. Subscribe to live status updates via SSE
const es = new EventSource(`https://api.satlane.com${live.events_url}`);
// SatLane sends ordinary message events, not named SSE events.
es.onmessage = (event) => {
  const payload = JSON.parse(event.data);
  renderInvoiceStatus(payload.invoice);
  // payload.event_type distinguishes lifecycle changes.
  // UI updates are not proof for server-side order fulfillment.
};
es.onerror = () => {
  // EventSource reconnects automatically. Refetch the snapshot
  // after reconnection; do not assume replay of every missed event.
};
// Close when the checkout component unmounts:
// es.close();
```

For WebSocket, resolve the relative `live.stream_url` against the API origin and change HTTPS to WSS. It carries the same live UI payload. These connections do not guarantee replay of missed events; refetch the invoice snapshot when reconnecting.

```js
const url = new URL(live.stream_url, 'https://api.satlane.com');
url.protocol = 'wss:';
const socket = new WebSocket(url);
socket.onmessage = (event) => {
  const payload = JSON.parse(event.data);
  renderInvoiceStatus(payload.invoice);
};
// Close the socket when your checkout component unmounts.
```

CORS is open (`Access-Control-Allow-Origin: *`) on `/pay/*` so vendor frontends on any domain can call these directly.

---

## 6. Receive webhooks

We POST signed JSON to active webhook endpoints registered on the store for the invoice environment. Configure these endpoints in the dashboard. Use a publicly reachable HTTPS endpoint. SatLane cannot reach localhost on your computer; expose a local receiver through an HTTPS tunnel. Loopback, private-network destinations, credential-bearing URLs and redirects are rejected. Destination DNS is rechecked for delivery. A stored invoice `callback_url` is not currently used as a delivery destination.

**Success:** any `2xx`. We mark delivery `success` and stop.

**Retries (5xx + network errors / timeouts):** initial attempt, then retries at 1m → 5m → 30m → 2h → 12h → 24h. That is **7 total attempts** before the delivery moves to `dead_letter` (replayable from the dashboard).

**Permanent failures (4xx):** not retried. Delivery moves to `failed`. Common causes: wrong URL, expired auth on your reverse proxy, signature verification rejecting a legitimate event. Check the delivery result and recorded attempt diagnostics in the dashboard. Response excerpts are bounded.

The transport uses a 10-second delivery deadline as well as bounded connection/header/body waits, and records a bounded response excerpt. Redirects are not followed. Persist the verified event and queue its processing in one durable transaction, then return `2xx` quickly. Perform slow fulfillment work in the worker. If durable storage fails, return `5xx` so SatLane can retry.

### Headers

```http
POST /your-handler HTTP/1.1
Content-Type: application/json
User-Agent: SatLane-Webhook/1.0
X-SatLane-Signature: t=1721481600,v1=2a3b4c5d…
X-SatLane-Event-Id: evt_abc123
X-SatLane-Event-Type: invoice.paid
```

Header names are case-insensitive on the wire (`x-satlane-signature`, etc.).

### Body shape

```json
{
  "event_id": "evt_abc123",
  "event_type": "invoice.paid",
  "created_at": "2026-05-16T11:25:00.000Z",
  "livemode": true,
  "data": {
    "invoice": {}
  }
}
```

`data.invoice` uses the public invoice fields for real lifecycle events. Optional checkout URL fields can differ by event producer. Synthetic dashboard test deliveries can carry `data.message` instead; do not treat them as payment evidence.

### Verify the signature

Signature header format: `t=<unix_seconds>,v1=<hex>`.

Signed payload: `${timestamp}.${rawRequestBody}`

Algorithm: HMAC-SHA256 with your endpoint secret as the key.

**Node.js (standalone verifier using the built-in crypto module):**

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

function verifySatLaneSignature(rawBody, header, secret) {
  if (!Buffer.isBuffer(rawBody) || !secret || typeof header !== 'string') {
    throw new Error('Missing raw body, signature or webhook secret');
  }
  const parts = Object.fromEntries(
    header.split(',').map((part) => {
      const [key, value] = part.trim().split('=');
      return [key, value];
    }),
  );
  if (!/^\d+$/.test(parts.t ?? '') || !/^[a-f0-9]{64}$/i.test(parts.v1 ?? '')) {
    throw new Error('Malformed signature');
  }
  const timestamp = Number(parts.t);
  if (!Number.isSafeInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
    throw new Error('Signature timestamp outside tolerance');
  }
  const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();
  const supplied = Buffer.from(parts.v1, 'hex');
  if (!timingSafeEqual(expected, supplied)) throw new Error('Invalid signature');
}
```

**Express route example:** install Express separately. Register this route before any global JSON body parser so `req.body` is the original Buffer. Adapt the durable event storage and fulfillment queue to your application; the comments below are integration steps, not a complete order-processing implementation.

```js
app.post('/webhooks/satlane', express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    verifySatLaneSignature(
      req.body,
      req.header('X-SatLane-Signature'),
      process.env.SATLANE_WEBHOOK_SECRET,
    );
  } catch {
    return res.status(400).end();
  }
  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
    if (!event || typeof event.event_id !== 'string' || !event.event_id ||
        typeof event.event_type !== 'string') return res.status(400).end();
  } catch {
    return res.status(400).end();
  }
  try {
    // Implement this in your application. In one database transaction:
    // insert event_id with a unique constraint and save a processing job.
    // An already-saved event is a successful no-op, not a new fulfillment.
    await persistEventAndQueueAtomically(event);
    return res.status(200).end();
  } catch {
    return res.status(503).end();
  }
});
```

`persistEventAndQueueAtomically` is an application function you must implement; it is not a SatLane SDK function. Before fulfilling in its worker, validate the invoice, order, expected amount/currency and environment, then apply an idempotent order transition.

The repository's `@satlane/webhooks` package is an internal workspace package. This guide does not require installing it from the public npm registry.

**Python:**

```python
import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300):
    if not secret or not isinstance(raw_body, bytes):
        raise ValueError('missing secret or raw body')
    parts = dict(p.strip().split('=', 1) for p in header.split(','))
    t, v1 = int(parts['t']), parts['v1']
    if abs(time.time() - t) > tolerance:
        raise ValueError('timestamp out of tolerance')
    signed = str(t).encode('ascii') + b'.' + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, v1):
        raise ValueError('signature mismatch')
```

**PHP:**

```php
function verifySatlaneSignature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
    $parts = [];
    foreach (explode(',', $header) as $p) {
        $pair = explode('=', trim($p), 2);
        if (count($pair) !== 2) return false;
        [$k, $v] = $pair;
        $parts[$k] = $v;
    }
    if ($secret === '' || !isset($parts['t'], $parts['v1']) ||
        !preg_match('/^[0-9]+$/D', $parts['t']) ||
        !preg_match('/^[a-f0-9]{64}$/iD', $parts['v1'])) return false;
    $t = (int) $parts['t']; $v1 = strtolower($parts['v1']);
    if (abs(time() - $t) > $tolerance) return false;
    $expected = hash_hmac('sha256', "{$t}.{$rawBody}", $secret);
    return hash_equals($expected, $v1);
}
```

Reject timestamps outside a five-minute tolerance in either direction. Verification errors, including malformed headers, must reject the request. Deduplicate valid event IDs durably; timestamp validation alone is not a replacement for duplicate detection. During secret rotation, accept signatures from either your current secret or your saved previous secret during the 24-hour transition. The standalone examples take one secret; extend your handler to try both during that window.

---

## 7. Invoice statuses

| Status            | Meaning                                                                                                                               | Terminal?                                                                     |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `pending`         | No payment seen yet                                                                                                                   | no                                                                            |
| `seen`            | Payment detected but not yet settled. Expiry alone does not mark it unpaid; stale unconfirmed payments can move to review.    | no                                                                            |
| `paid`            | Payment confirmed (≥ `conf_threshold`) and amount within `amount_sats ± amount_tolerance_sats`                                        | no (can become `reverted` via reorg)                                          |
| `expired`         | `pending` past `expires_at` with no detected payment                                                                                  | no; address still watched through the grace window for a possible `late_paid` |
| `late_paid`       | Confirmed payment arrived after `expires_at` but inside the supported late-payment flow                                               | no (can become `reverted`)                                                    |
| `underpaid`       | Confirmed amount is less than `amount_sats − amount_tolerance_sats`. Buyer can top up; we auto-merge.                                 | no                                                                            |
| `overpaid`        | Confirmed cumulative amount exceeds `amount_sats + amount_tolerance_sats`                                                             | no; you may want to refund the difference                                     |
| `requires_review` | Routing landed on "no-match" (payment to a recycled address with no matching invoice), or cross-check disagreed. Manual admin action. | no                                                                            |
| `reverted`        | Previously paid, then a chain reorg removed the tx                                                                                    | yes                                                                           |
| `cancelled`       | Vendor cancelled before payment                                                                                                       | yes                                                                           |

### Top-up payments (short-pay recovery)

If the customer sends less than the expected amount, they can send the remaining balance to the **same invoice address**. Do not create a second order or a fresh invoice merely to collect the shortfall.

1. SatLane adds distinct transaction outputs assigned to that invoice. Duplicate detections do not increase the total, and reversed outputs are excluded.
2. Split payments combine even when the first payment is still unconfirmed. `amount_paid_sats` is the received total; it is not proof that every payment has confirmed.
3. A final outcome waits until every output included in the cumulative total reaches `conf_threshold`. A later confirmed top-up cannot confirm an earlier mempool payment.
4. Within tolerance, the invoice becomes `paid`, or `late_paid` if completion is after expiry. A smaller total remains underpaid; a larger confirmed total becomes `overpaid`.

For example, an invoice expects 100,000 sats. The customer sends 60,000, then 40,000. The received total is 100,000, but fulfillment waits for the final paid event. If the 40,000 confirms first, the earlier 60,000 must still confirm.

Hosted checkout asks for only the remaining amount. Once the full balance is received, it hides payment controls and says to wait for confirmation. In a custom checkout, use `payment_phase` and the remaining amount to show the same behavior. Never ask for another full payment because confirmations are pending.

**Webhook handling:** events describe status changes, not every top-up. An additional short payment that leaves the status unchanged does not necessarily emit another `invoice.underpaid` event. Delivery retries reuse the event ID. Deduplicate by `event_id`, then make fulfillment idempotent at the order level so distinct events cannot ship the same order twice.

### Late, excess and reversed payments

- A late payment observed by the watcher may settle as `late_paid`, including through a supported recovery path. The grace deadline is not a guarantee that later Bitcoin is ignored or refunded. Check the current invoice and apply your policy for an order that has already expired.
- `overpaid` means the customer sent excess Bitcoin. SatLane does not automatically refund it. Review the invoice and decide whether to fulfill and return the difference using your wallet.
- `requires_review` is not an instruction to fulfill. Ambiguous address routing or failed chain cross-checks need review.
- `reverted` means a previously accepted payment lost its on-chain basis. Reconcile the order and notify the merchant. Do not automatically fulfill again or refund without reviewing the current state.

### Fulfillment checklist

Only treat `invoice.paid` or `invoice.late_paid` as automatic payment completion after verifying the signature and reconciling the expected order, amount, currency and environment. Browser redirects, SSE updates, `seen`, a zero remaining amount, and a received total alone are not authorization to fulfill.

---

## 8. Webhook event types

Invoice lifecycle webhooks carry the same payload shape (`{ event_id, event_type, created_at, livemode, data: { invoice } }`).

| Event type                 | Fired when                                                                                                                                                                       | May fire more than once?             |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| `invoice.created`          | New invoice via `POST /v1/invoices`                                                                                                                                              | no                                   |
| `invoice.payment_seen`     | Payment observed, typically in mempool before confirmation                                                                                                                       | Do not rely on a once-only guarantee |
| `invoice.paid`             | Cumulative confirmed amount within tolerance, before expiry                                                                                                                      | no                                   |
| `invoice.late_paid`        | Cumulative confirmed amount within tolerance, after expiry in a supported late/recovery flow                                                                                                      | no                                   |
| `invoice.expired`          | `pending` invoice's `expires_at` elapsed with no payment. A detected payment is monitored instead of expiring solely by the clock; stale unconfirmed payments can require review. | no                                   |
| `invoice.underpaid`        | Transition to a short-payment state. A top-up that leaves status unchanged may not create a new event.                                                        | **yes**                              |
| `invoice.overpaid`         | Cumulative confirmed amount exceeds `amount_sats + tolerance`                                                                                                                    | no                                   |
| `invoice.payment_reverted` | A reorg orphaned the block containing the payment. Reconcile your order and follow your refund or fulfillment policy. Already completed fulfillment may not be reversible.       | very rare                            |
| `invoice.requires_review`  | Routing produced "no-match" or cross-check disagreed                                                                                                                             | rare                                 |
| `invoice.cancelled`        | Vendor or admin cancelled                                                                                                                                                        | no                                   |
| `invoice.grace_ending`     | Opt-in: fires once near the end of the late-payment grace window (endpoint must enable `graceEndingEnabled`)                                                                     | no                                   |
| `invoice.reopened`         | Invoice reopened after a prior terminal-ish state (lifecycle edge case)                                                                                                          | rare                                 |

> The table describes typical lifecycle emissions, not an exactly-once delivery guarantee. Reopened invoices and additional payments can produce further events. Always deduplicate by `event_id`, never by `invoice.id` + `event_type`. A new status transition can create a new event ID; an unchanged short-payment state may not. Dispatcher retries reuse the same `event_id`.

---

## 9. Sandbox and simulate

For a Test invoice, exercise supported lifecycle scenarios without waiting on the chain. On the enabled wallet-free installation no receiving wallet or real Bitcoin is involved. Scenarios follow valid state transitions; not every event is valid from every status.

### Dashboard Simulator

On a Test invoice detail page, simulation controls offer supported scenarios (with amount inputs where relevant). Accepted actions:

1. Updates the invoice status in our DB
2. Fires the matching webhook with `livemode: false`
3. Pushes the new status over SSE/WebSocket to open checkout pages

### API: `POST /v1/invoices/:id/simulate`

Test invoices only. Auth: a matching API key or the authorized vendor session. Use a stable `action_id` when retrying the same simulation action on an enabled installation.

```bash
curl -X POST https://api.satlane.com/v1/invoices/22872e14-4216-4c78-8fe1-088ea649f3c2/simulate \
  -H "Authorization: Bearer sl_test_XXX" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "paid",
    "amount_sats": "150234"
  }'
```

| Field         | Type   | Required | Notes                                                                                                                                                                     |
| ------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `event`       | string | yes      | One of: `seen`, `paid`, `underpaid`, `overpaid`, `expired`, `late_paid`, `reverted`, `cancelled`                                                                          |
| `action_id`   | string | optional | Stable action identity, 1–128 characters, for retrying an enabled simulation without repeating it.                                                                        |
| `amount_sats` | string | optional | Override simulated payment amount. Defaults: full amount for `paid`/`seen`, ~60% for `underpaid`, ~150% for `overpaid`. Ignored for `expired` / `cancelled` / `reverted`. |

Response: `{ "invoice": { … } }` with the updated public invoice.

Simulations never bump `unbilledFeeSats`. Legacy alias: `POST /v1/invoices/:id/simulate_paid` (same as `event: "paid"`).

Before accepting real payments, verify your webhook handling, configure and review the mainnet wallet, confirm chain and account eligibility, explicitly activate the store in Live and use a Live API key. Workspace switching alone never activates new payments. Live payment monitoring also depends on the configured chain services being available and synchronized.

---

## 10. Endpoint reference

Base URL: `https://api.satlane.com` (local / self-host: `http://localhost:4000`).

### Invoice API (your server → ours)

| Method | Path                        | Notes                                    |
| ------ | --------------------------- | ---------------------------------------- |
| `POST` | `/v1/invoices`              | Create. API key. Send `Idempotency-Key`. |
| `GET`  | `/v1/invoices`              | List with cursor pagination.             |
| `GET`  | `/v1/invoices/:id`          | Fetch one.                               |
| `POST` | `/v1/invoices/:id/cancel`   | Cancel pending.                          |
| `POST` | `/v1/invoices/:id/simulate` | Test mode only; fire any event.          |

### Dashboard setup (vendor session and required verification)

Webhook endpoint configuration, secret rotation, delivery replay and the one-click test invoice are dashboard/session operations. They are not API-key invoice endpoints. Configure your store and webhook destination in the dashboard before beginning the server integration.

### Public (buyer's browser → ours)

| Method | Path                       | Notes                              |
| ------ | -------------------------- | ---------------------------------- |
| `GET`  | `/pay/invoices/:id`        | Invoice + store branding snapshot. |
| `GET`  | `/pay/invoices/:id/events` | SSE stream.                        |
| `GET`  | `/pay/invoices/:id/stream` | WebSocket alternative to SSE.      |

CORS is open (`Access-Control-Allow-Origin: *`) on `/pay/*`.

> Plan management, subscription invoices, and usage live in the vendor dashboard (`/billing`). Billing routes require an authenticated merchant session. Store API keys cannot change subscription plans.

---

## 11. Rate limits

`POST /v1/invoices` currently has a configured limit of 100 requests per minute. The limiter runs before API-key authentication, so its current bucket is the request IP, not a guaranteed independent allowance per API key. Deployments may also apply limits outside the application.

Do not assume a blanket 5 requests/second login limit or 20 requests/second global limit. Routes opt into application limits individually.

A 429 response uses error code `rate_limited` and includes `error.details.retry_after_seconds`. Wait at least that interval, add jitter and retry sequentially. Preserve the original idempotency key and body for invoice creation.

---

## 12. Errors

Application errors normally use the following envelope. Rate-limit responses can omit `request_id`; include it when available in support requests:

```json
{
  "error": {
    "code": "no_active_xpub",
    "message": "Store has no active xpub for this environment...",
    "request_id": "b9fc7e29-587f-4dda-b220-86d7144893fe"
  }
}
```

Include the `request_id` when contacting support. It correlates to our server logs.

### Errors `POST /v1/invoices` can return

**401 authentication**

| Code              | When                                                                                                                        |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `api_key_invalid` | Missing `Authorization` header, malformed, or the key does not exist. Use `Authorization: Bearer sl_live_…` or `sl_test_…`. |
| `api_key_revoked` | Key was revoked from the dashboard or by an admin. Mint a new one.                                                          |

**403 authenticated but blocked**

| Code                     | When                                                                        |
| ------------------------ | --------------------------------------------------------------------------- |
| `auth_account_suspended` | Vendor account suspended by an admin. No invoice creation until reinstated. |

**402 payment required (billing lock)**

| Code              | When                                                                                                                                                                        |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `billing_overdue` | Live invoicing restricted by account billing. Check `/billing` for the applicable unpaid bill or account restriction. Only legacy Trial installations use trial exhaustion. |

**404 resource missing**

| Code                  | When                                                                                |
| --------------------- | ----------------------------------------------------------------------------------- |
| `not_found` (`Store`) | The store the API key belongs to was archived. Restore it or use a different store. |

**409 conflict / not configured**

| Code                           | When                                                                                                                                                                       | Resolution                                                                    |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `no_active_xpub`               | Store has no active xpub for this environment. Live invoices need a mainnet xpub; wallet-free Sandbox needs no xpub; legacy Test uses its existing receiving-wallet rules. | Add an xpub at `app.satlane.com/stores/<id>/xpubs`.                           |
| `gap_limit_exceeded`           | Wallet's gap limit is within 5 of being reached and we have not seen recent funding.                                                                                       | Bump the gap limit in Electrum (recommend 100+) or rotate xpubs.              |
| `sandbox_contract_unavailable` | Versioned Sandbox requested on an installation with rollout disabled.                                                                                                      | Confirm the installation mode; do not invent a payable fallback.              |
| `store_live_not_active`        | New Live creation requested before activation or while paused.                                                                                                             | Review the wallet and explicitly activate or reactivate the store.            |
| `idempotency_conflict`         | The same `Idempotency-Key` was reused with a **different request body**.                                                                                                   | Reuse the key with the original body (cached response) or generate a new key. |

**426 contract required**

Enabled Test invoice creation without the version header returns `sandbox_contract_required`. Send `SatLane-Version: 2026-10-05` after confirming this installation enables the contract.

**400 validation**

| Code                           | Cause                                                                                                                                                                                                                              |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `validation_error`             | Zod rejected the body. Common: missing both `(amount + currency)` and `amount_sats`, providing both, `expires_in_minutes` outside `[5, 120]`, invalid `callback_url`, `metadata` value > 255 chars. The `message` names the field. |
| `validation_error`             | `Idempotency-Key` header > 255 chars or empty.                                                                                                                                                                                     |
| `unsupported_contract_version` | The SatLane-Version header does not name the supported contract version.                                                                                                                                                           |
| `invalid_currency`             | Currency code not supported (only `USD` today).                                                                                                                                                                                    |
| `invalid_amount`               | Sats amount ≤ 0, or fiat amount rounds to zero sats at the current rate.                                                                                                                                                           |

**429 rate limited**

| Code           | When                                                                                      | Resolution                            |
| -------------- | ----------------------------------------------------------------------------------------- | ------------------------------------- |
| `rate_limited` | Invoice-route limit or enabled Sandbox hourly/daily/global/active-invoice budget reached. | Back off using `retry_after_seconds`. |

**503 temporary infrastructure (retry safe)**

These indicate a temporary infrastructure condition. Retry with backoff using the same idempotency key and body; they do not guarantee that every other validation will pass.

| Code                   | When                                                                                   |
| ---------------------- | -------------------------------------------------------------------------------------- |
| `chain_syncing`        | Bitcoin node is in initial block download. We refuse new invoices against a stale tip. |
| `disk_full`            | Host critically low on disk. Writes blocked to protect webhook delivery state.         |
| `database_unavailable` | Postgres unreachable. Rare.                                                            |

### Recommended client retry policy

A connection timeout can leave the outcome uncertain. Keep the same idempotency key and request body, reconcile the order, and retry sequentially. Never create a fresh payment request merely because the first response was lost.

| HTTP                         | Action                                                                                 |
| ---------------------------- | -------------------------------------------------------------------------------------- |
| 200 / 201                    | Use the response.                                                                      |
| 400, 401, 402, 403, 404, 409 | Stop. Caller bugs, billing lock, or configuration errors. Log and surface to the user. |
| 429                          | Back off using `retry_after_seconds`, then retry.                                      |
| 503                          | Exponential backoff (1s → 2s → 4s → 8s → 16s, max 5 tries).                            |
| Other 5xx                    | Treat as a bug on our side. Log `request_id`, escalate.                                |

**Always send an `Idempotency-Key`** when retrying creates. Successful responses are saved for sequential replay for 24 hours per key. Use globally unique keys and reconcile uncertain outcomes before starting a new create.

### Errors from other endpoints

A non-exhaustive selection:

- `auth_required` (401): session cookie missing on dashboard endpoints
- `auth_totp_required` (401): 2FA-gated endpoint; prompt for code and call `/v1/auth/totp/verify`
- `auth_email_not_verified` (403): vendor email not yet verified
- `invoice_not_cancellable` (409): invoice already paid / late_paid / cancelled / expired
- `invoice_expired` (410): payment flow hit a fully-expired invoice
- `invoice_already_paid` (409): duplicate paid transition attempt
- `not_found`: UUID does not match anything you own
- `gone` (410): resource intentionally removed

## 13. Billing and payment restrictions

### What is billed

Billing belongs to the merchant account, across its stores. With Billing V4 enabled, Basic and Pro have a fixed USD subscription price paid using the Bitcoin quote on each bill. Eligible paid Live invoices accrue usage at the account's effective rate. Sandbox does not create real usage charges. Use [pricing](/pricing) and your Billing page for the current catalog, negotiated terms and existing balances.

### Due dates and the billing lock

New bills currently have a payment due date seven days after issuance, followed by a seven-day grace period. Use each bill's actual `due_at` and `lock_at` values instead of calculating dates in your integration. Existing bills retain their recorded dates.

When an applicable unpaid bill reaches `lock_at`, the backend blocks **new Live invoice creation across all stores** and new Live activation. API integrations receive HTTP `402` with `billing_overdue`. A dashboard warning is not the enforcement mechanism: the shared invoice service checks billing eligibility.

Merchants can still sign in, view history, use Sandbox and open Billing to pay. Existing customer invoices keep processing, including top-ups, confirmations and webhook delivery. A billing lock differs from an administrator suspending the account, which has broader access restrictions.

### Returning to Live invoicing

Pay the outstanding bill using the address and exact Bitcoin amount displayed in Billing. A broadcast or an unconfirmed transfer does not clear the lock. The billing watcher must record sufficient confirmed payment. Any other overdue bill or account restriction can keep Live creation locked.

Authorized administrators can review recorded payment or waive a bill through audited controls. A waiver's coverage behavior depends on the selected resolution; merchants should read the updated bill and plan state rather than assume every waiver grants a paid plan period.

Complimentary access waives new subscription and usage charges. It does not silently erase older balances. Starter in V4 is pay-as-you-go rather than a limited paid-invoice trial; legacy Trial installations retain their exhaustion rules.

### Billing errors in an integration

If creation returns `billing_overdue`, stop repeating the create request. Show the merchant a link to their account Billing page, retain the logical order/idempotency identity, and retry only after the restriction is resolved. Do not switch a real customer's order to Sandbox as a workaround.

---

## Local and self-hosted development

For a local installation, use its configured URLs, such as `http://localhost:3001` for the dashboard and `http://localhost:4000` for the API. These addresses refer to your own computer and are not public SatLane endpoints. Wallet-free Sandbox and legacy compatibility depend on the installation's enabled contract. A local setup does not establish the availability of a production deployment. Keep deployment-specific acceptance notes outside customer integration examples.
