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

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.

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


7. Invoice statuses

StatusMeaningTerminal?
pendingNo payment seen yetno
seenPayment detected in mempool (0 conf). Once a payment is in seen, the chain owns the lifecycle and we do not auto-expire it even if expires_at elapses — the next block decides.no
paidPayment confirmed (≥ conf_threshold) and amount within amount_sats ± amount_tolerance_satsno (could be reverted via reorg)
expiredpending past expires_at with no detected paymentno — address still watched through the grace window for a possible late_paid
late_paidConfirmed payment arrived after expires_at but inside the grace windowno (could be reverted)
underpaidConfirmed amount is less than amount_sats − amount_tolerance_sats. Buyer can send a top-up and we'll auto-merge (see top-up flow below).no
overpaidConfirmed cumulative amount exceeds amount_sats + amount_tolerance_satsno — you may want to refund the difference
requires_reviewRouting landed on "no-match" (payment to a recycled address with no matching invoice), or cross-check disagreed. Manual admin action.no
revertedPreviously paid, then a chain reorg removed the txyes
cancelledVendor cancelled before paymentyes

Top-up payments (short-pay recovery)

If a buyer sends less than the invoice amount (outside the tolerance), the invoice transitions to underpaid and the watcher keeps listening. When a second on-chain transaction lands on the same address, the watcher:

  1. Sums all non-reverted payments rows for that invoice + the new tx.
  2. If the total now lands within [amount_sats − tolerance, amount_sats + tolerance], the invoice flips to paid (or late_paid if past expiry).
  3. If still short, it stays underpaid and amount_paid_sats reflects the new total.
  4. If the total now exceeds amount_sats + tolerance, it transitions to overpaid.

What this means for your webhook handler: you may receive invoice.underpaid more than once for the same invoice (each top-up fires the matching event), followed by invoice.paid / invoice.late_paid / invoice.overpaid when the cumulative total settles. Deduplicate by event_id only — never by invoice.id. The invoice can leave underpaid and arrive at paid after a follow-up payment.

The hosted checkout page surfaces this automatically: an underpaid invoice shows a "Send remaining X sats" CTA with a fresh bitcoin: URI encoding only the remaining amount, so the buyer can't double-pay by rescanning the original QR.


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 whenMay fire more than once?
invoice.createdNew invoice via POST /v1/invoicesno
invoice.payment_seenPayment in mempool, 0 confno, once per invoice
invoice.paidCumulative confirmed amount lands within tolerance, before expiryno
invoice.late_paidCumulative confirmed amount lands within tolerance, after expiry but inside graceno
invoice.expiredpending invoice's expires_at elapsed with no payment. Note: seen invoices never receive invoice.expired; if you got invoice.payment_seen, wait for the next event.no
invoice.underpaidCumulative confirmed amount is below amount_sats − tolerance. Fires on every short payment, so a top-up flow can produce two of these followed by invoice.paid.yes — see top-up
invoice.overpaidCumulative confirmed amount exceeds amount_sats + toleranceno
invoice.payment_revertedA reorg orphaned the block containing the payment. Reverse fulfillment if you already shipped. Rare.very rare
invoice.requires_reviewRouting produced "no-match" or cross-check disagreedrare
invoice.cancelledVendor or admin cancelledno

Always deduplicate by event_id, never by invoice.id + event_type. Top-up payments will produce repeated invoice.underpaid events, and retries from our dispatcher produce repeated events with the same event_id (your dedupe target).


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.

Errors POST /v1/invoices can return

The most-called endpoint. Plan for these.

401 — authentication

CodeWhen
api_key_invalidMissing Authorization header, malformed, or the key doesn't exist. Check the header is Authorization: Bearer sl_live_… or sl_test_….
api_key_revokedKey was revoked from the dashboard or by an admin. Mint a new one.
api_key_wrong_envCalling a live store with a test key, or vice versa. Use the key that matches the store's mode.

403 — authenticated but blocked

CodeWhen
auth_account_suspendedVendor account suspended by an admin. No invoice creation until reinstated. Contact support.

404 — resource missing

CodeWhen
not_found (Store)The store the API key belongs to was archived. Restore it or use a different store.

409 — conflict / not configured

CodeWhenResolution
no_active_xpubStore has no active xpub for this environment. Live invoices need a mainnet xpub; test invoices need any active xpub.Add an xpub at app.satlane.com/stores/<id>/xpubs.
gap_limit_exceededWallet's gap limit is within 5 of being reached and we haven't seen recent funding.Bump the gap limit in your Electrum wallet (recommend 100+) or rotate xpubs. Address recycling absorbs some of this for expired-unpaid invoices.
idempotency_conflictThe same Idempotency-Key was reused with a different request body.Either reuse the key with the original body (we'll return the cached response) or generate a new key.

400 — validation

CodeCause
validation_errorZod 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 field names the offending field.
validation_errorIdempotency-Key header > 255 chars or empty.
invalid_currencyCurrency code not in our supported list (only USD at MVP).
invalid_amountSats amount ≤ 0, or fiat amount rounds to zero sats at the current rate.

429 — rate limited

CodeWhenResolution
rate_limitedMore than 100 invoice creations per minute on one API key.Honor the Retry-After header (seconds).

503 — temporary infrastructure issue (retry safe)

These mean the call would have succeeded if not for an infra condition. Retry with exponential backoff.

CodeWhen
chain_syncingThe Bitcoin node is in initial block download (verificationprogress < 0.999). We refuse new invoices to avoid issuing payable addresses against a stale tip. Usually only happens right after a fresh deploy.
disk_fullThe platform host is critically low on disk. We block writes to avoid losing webhook delivery state. Operator gets paged automatically; resolves itself.
database_unavailablePostgres unreachable. Rare.

Recommended client retry policy

HTTPAction
200 / 201Use the response.
400, 401, 403, 404, 409Stop. These are caller bugs or configuration errors. Log and surface to the user.
429Backoff using Retry-After, then retry.
503Exponential backoff (e.g. 1s → 2s → 4s → 8s → 16s, max 5 tries). Show "Service temporarily unavailable" if exhausted.
Other 5xxTreat as a bug on our side. Log request_id, escalate.

Always send an Idempotency-Key when retrying. We cache the response for 24 hours per key, so retries after a transient network error return the same invoice instead of creating duplicates.

Errors from other endpoints

A non-exhaustive selection (see packages/shared/src/errors.ts for the full catalog):

  • 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 doesn't match anything you own
  • gone (410) — resource intentionally removed