SatLane
Documentation

Quickstart

Goal: signed-up vendor → first paid invoice in 10 minutes. The four pieces you actually need: the mental model, authentication, creating an invoice, receiving the paid webhook.

The mental model

┌──────────┐   1. POST /v1/invoices    ┌─────────┐
│ Your     │ ────────────────────────▶│ SatLane │
│ server   │                          │  API    │
│          │ ◀──────────────────────── │         │
└──────────┘   { invoice + payment_uri └────┬────┘
        │                                   │
        │ 2. Redirect buyer to              │ 5. POST webhook
        │    invoice.hosted_checkout_url    │    invoice.paid
        ▼                                   ▼
┌──────────┐                          ┌──────────┐
│  Buyer   │   3. Buyer pays the BTC  │ Your     │
│ browser  │      address from their  │ webhook  │
│          │      Bitcoin wallet      │ handler  │
└──────────┘                          └──────────┘
                                           │
                                           ▼ 6. fulfil order
                                      ┌──────────┐
                                      │  Your    │
                                      │  app     │
                                      └──────────┘

The xpub stays on your machine in Electrum. SatLane derives one fresh address per invoice and watches for payment. We never see your private keys — funds settle directly into your wallet on confirmation.


Authentication

SurfaceAuth
Server-side API (POST /v1/invoices, etc.)Authorization: Bearer sl_live_… or sl_test_…
Public buyer endpoints (/pay/invoices/:id*)None — invoice UUID is the only secret
Vendor dashboardSession cookie (only relevant if you're using the dashboard)

API keys are issued per-store from the dashboard at app.satlane.com/stores/<id>/keys. Each key is shown once on creation. Store them in your secrets manager.


Create an invoice

curl -X POST https://api.satlane.com/v1/invoices \
  -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",
    "callback_url": "https://yourshop.com/webhooks/satlane",
    "success_url": "https://yourshop.com/orders/ORD-12345/thanks",
    "buyer_email": "buyer@example.com",
    "expires_in_minutes": 15,
    "metadata": { "cart_id": "abc123" }
  }'

Request fields

FieldTypeRequiredNotes
amountnumberone ofFiat amount. We lock a BTC/USD rate and convert to sats.
currencystringone ofMust be "USD" at MVP.
amount_satsstringone ofSkip fiat conversion, charge exact sats.
order_refstringoptionalYour internal order ID. We don't constrain format.
callback_urlstringoptionalPer-invoice webhook URL (overrides store-level endpoints).
success_urlstringoptionalHosted checkout redirects here after payment.
buyer_emailstringoptionalIf set, we can email the buyer a receipt later (Phase 9).
expires_in_minutesintoptional5–120, default from store settings.
metadataobjectoptionalFree-form string → string map echoed on every webhook.

Response

{
  "invoice": {
    "id": "22872e14-4216-4c78-8fe1-088ea649f3c2",
    "status": "pending",
    "environment": "test",
    "address": "tb1q…",
    "amount_sats": "150234",
    "amount_btc": "0.00150234",
    "amount_fiat": 49.99,
    "fiat_currency": "USD",
    "btc_usd_rate": 33280.45,
    "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": "1502",
    "payment_uri": "bitcoin:tb1q…?amount=0.00150234&label=…",
    "hosted_checkout_url": "https://pay.satlane.com/i/22872e14-…",
    "order_ref": "ORD-12345",
    "created_at": "2026-05-16T11:15:00.000Z",
    "...": "..."
  }
}

Response fields worth understanding

FieldWhat it means
amount_satsThe invoice amount, in satoshis. Vendor-facing source of truth.
amount_paid_satsRunning total of sats received on-chain so far (non-reverted). Lets you compute remaining = amount_sats - amount_paid_sats without an aggregate query.
amount_tolerance_satsPer-invoice slack on the expected amount. Payments within [amount_sats − tolerance, amount_sats + tolerance] count as exact. Defaults are computed from the platform-wide setting payment_tolerance_bp (basis points, default 25 bp = 0.25%) clamped to [10, 1000] sats. This absorbs wallet fee estimation drift and BTC/fiat rate movement between invoice creation and broadcast. Set the platform setting to 0 to require strict matches.
btc_usd_rateThe BTC/USD rate we locked at creation. Network price moves after this point don't change what the buyer owes.
late_payment_deadline_atISO timestamp past which payments are no longer auto-credited. We keep watching the address until then.
conf_thresholdConfirmations required before status flips to paid. Defaults: 1 below $100 invoice value, 2 at $100+.
fee_satsPlatform fee accrued against your vendor account on this invoice's successful payment. Already factored into your dashboard's "unbilled" total.

Receive webhooks

We POST signed JSON to your callback_url (per-invoice) or to webhook endpoints configured on the store.

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

Retries (5xx + network errors / timeouts): initial attempt followed by retries at 1m → 5m → 30m → 2h → 12h → 24h. That's 7 total attempts before the delivery transitions to dead_letter (replayable from the dashboard).

Permanent failures (4xx): treated as a vendor-side bug and not retried. Delivery moves straight to failed so we don't hammer a broken endpoint for 24 hours. Common causes: wrong URL, expired auth on your reverse proxy, signature verification raising on a legitimate event. Check the delivery's response_body in your dashboard.

Timeout: 10 seconds per attempt. Make your handler write the side effect (mark order paid in your DB) and respond 200 quickly. If you must do slow work, ack first and do the slow work in a queue.

Headers

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

Body shape

{
  "event_id": "evt_abc123",
  "event_type": "invoice.paid",
  "created_at": "2026-05-16T11:25:00.000Z",
  "livemode": true,
  "data": {
    "invoice": { /* same shape as POST /v1/invoices response */ }
  }
}

Verify the signature

The signature header format is t=<timestamp>,v1=<hex>. The signed payload is ${timestamp}.${rawRequestBody} and the HMAC algorithm is SHA-256 with your endpoint secret as the key.

Node (using our published helper):

import { verifySignature } from '@satlane/webhooks';

app.post('/webhooks/satlane', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.header('X-SatLane-Signature');
  try {
    verifySignature(req.body, sig, { secrets: [process.env.SATLANE_WEBHOOK_SECRET] });
  } catch {
    return res.status(400).end();
  }
  const event = JSON.parse(req.body);
  // safe to act on event.data.invoice...
  res.status(200).end();
});

Python:

import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300):
    parts = dict(p.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 = f'{t}.{raw_body.decode()}'.encode()
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, v1):
        raise ValueError('signature mismatch')

PHP:

function verifySatlaneSignature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
    $parts = [];
    foreach (explode(',', $header) as $p) {
        [$k, $v] = explode('=', $p, 2);
        $parts[$k] = $v;
    }
    $t = (int) $parts['t']; $v1 = $parts['v1'];
    if (abs(time() - $t) > $tolerance) return false;
    $expected = hash_hmac('sha256', "{$t}.{$rawBody}", $secret);
    return hash_equals($expected, $v1);
}

Vendor SDKs should reject events with timestamps older than 5 minutes (replay protection). During secret rotation we keep the old secret valid for 24 hours — your verifier can pass both to secrets: [current, previous].