SatLane
Documentation

SatLane integration guide

Non-custodial Bitcoin payments. Plug in your xpub, accept BTC, get signed webhooks.

This document covers everything a vendor needs to integrate SatLane into their app — either by redirecting buyers to our hosted checkout page or by building a custom checkout UI in their own frontend.


1. 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.


2. 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.


3. Test mode vs live mode

Each store has a test_mode toggle. New stores default to test mode so you can build your integration end-to-end without spending real BTC.

Test modeLive mode
Watcher subscribes to address?No (simulated)Yes
Webhook livemode fieldfalsetrue
Invoice environment fieldtestlive
Vendor triggers events?Yes, via dashboard Simulator cardNo — chain does
Real BTC at stake?NoYes

sl_test_* and sl_live_* API keys both work on test-mode stores. Going live requires a registered mainnet xpub and flipping the store toggle.


4. 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,
    "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,
    "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",
    "...": "..."
  }
}

5. Show the buyer the invoice

You have two options.

Option A — Redirect to our hosted checkout (easiest)

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

Buyer sees a polished, mobile-first payment page with QR code, address, countdown, status pill, and "Open in wallet" button. Auto-updates via Server-Sent Events when the payment confirms, then redirects to your success_url.

Option B — Build your own checkout UI

Render whatever you want in your own frontend. SatLane gives you everything you need:

// 1. Fetch the snapshot (no auth — 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 in your UI

// 3. Subscribe to live status updates via SSE
const es = new EventSource(`https://api.satlane.com${live.events_url}`);
es.addEventListener('invoice.paid', (e) => {
  const { invoice } = JSON.parse(e.data);
  // Show success screen, redirect, etc.
});
es.addEventListener('invoice.expired', (e) => { /* ... */ });
es.addEventListener('invoice.payment_seen', (e) => { /* "Detected, waiting for confirmation" */ });

// Or listen to the generic message event — every status change fires one:
es.onmessage = (e) => {
  const payload = JSON.parse(e.data);
  console.log(payload.event_type, payload.invoice.status);
};

If your stack prefers WebSocket over SSE, use live.stream_url instead (same JSON payload, one per event).


6. Receive webhooks

We POST signed JSON to your callback_url (per-invoice) or to webhook endpoints configured on the store. Retries: 1m → 5m → 30m → 2h → 12h → 24h (6 attempts), then dead-letter (replayable from dashboard).

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].


7. Invoice statuses

StatusMeaningTerminal?
pendingNo payment seen yetno
seenPayment detected in mempool (0 conf)no
paidPayment confirmed (≥ conf_threshold)no (could be reverted via reorg)
expiredPast expires_at, still inside grace window — address still watchedno
late_paidPayment arrived after expires_at but inside grace windowno (could be reverted)
underpaidBuyer sent less than amount_satsno — you decide whether to ship
overpaidBuyer sent moreno — you may want to refund the difference
requires_reviewInternal cross-check mismatch — manual review neededno
revertedPreviously paid, then a chain reorg removed the txyes
cancelledVendor cancelled before paymentyes

8. Webhook event types

Every event type matches its status transition and carries the same payload shape ({ event_id, event_type, created_at, livemode, data: { invoice } }).

Event typeFired when
invoice.createdNew invoice via POST /v1/invoices
invoice.payment_seenPayment in mempool, 0 conf
invoice.paidPayment confirmed (status: paid)
invoice.late_paidPayment arrived after expiry but inside grace (status: late_paid)
invoice.expiredexpires_at passed without sufficient payment
invoice.underpaidConfirmed payment is less than expected
invoice.overpaidConfirmed payment exceeds expected
invoice.payment_revertedReorg invalidated a previously confirmed payment
invoice.requires_reviewCross-check disagreement — needs manual review
invoice.cancelledVendor cancelled the invoice

9. Testing your integration

While the store is in test mode, use the Simulator card on each invoice's detail page in your dashboard. You can trigger any of the events above (with optional amount override for under/overpaid) without waiting for real chain events. Each click:

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

That's it. Once your handler responds 200 for all event types you care about, flip the store toggle to live, register your mainnet xpub, and you're in production.


10. Endpoint reference

Authed (your server → ours)

POST   /v1/auth/login                          # vendor session login (if scripting dashboard)
POST   /v1/invoices                            # create invoice
GET    /v1/invoices                            # list (cursor pagination)
GET    /v1/invoices/:id                        # fetch one
POST   /v1/invoices/:id/cancel                 # cancel
POST   /v1/invoices/:id/simulate               # test-mode only — fire any event
POST   /v1/stores/:id/test-invoice             # one-click test invoice (session auth)
GET    /v1/stores/:id/webhooks                 # list webhook endpoints
POST   /v1/stores/:id/webhooks                 # add endpoint
POST   /v1/stores/:id/webhooks/:wid/test       # send synthetic test event

Public (buyer's browser → ours)

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/* so vendor frontends on any domain can call these directly.


11. Rate limits

EndpointLimit
POST /v1/invoices100 req/min per API key
Auth endpoints (login, signup)5 req/sec per IP
Everything else20 req/sec per IP

429 responses include a Retry-After header.


12. Errors

All errors return the same shape:

{
  "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.

Common codes: auth_required, api_key_invalid, validation_error, not_found, gap_limit_exceeded, rate_limited, idempotency_conflict, chain_syncing.