SatLane
Documentation

SatLane API

Non-custodial Bitcoin payments. Plug in your xpub, accept BTC, get 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

For local development or self-hosting, point the same paths at http://localhost:4000.


1. Overview

Code
┌──────────┐   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     │
                                      └──────────┘

Your xpub stays in Electrum (or equivalent). SatLane derives one fresh address per invoice and watches the chain. We never hold private keys; funds settle directly into your wallet on confirmation.

Platform billing (for integrators)

SatLane is a hybrid plan product (Trial, Basic, Pro, Custom). For API integrators:

  • On each paid live invoice, fee_sats accrues against your vendor account (take-rate from your plan).
  • Plan subscription invoices (monthly sats for Basic/Pro) are paid in Bitcoin from the vendor dashboard at /billing. There is no public billing API for plan checkout.
  • Unpaid plan invoices or an exhausted trial can lock new live invoice creation until you pay or choose a plan at /billing/plans.
  • Test-mode invoices do not accrue platform fees.

Manage plans, usage, and Bitcoin plan invoices in the app. This guide focuses on the payments API.


2. Authentication

SurfaceAuth
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 dashboardSession cookie (dashboard only)

API keys are issued per store from app.satlane.com/stores/<id>/keys. Each secret is shown once on creation. Store it in your secrets manager.

GETcURL
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. Test mode vs live mode

Each store has a test_mode toggle. New stores default to test mode so you can integrate 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 Simulator or POST …/simulateNo; the chain does
Real BTC at stake?NoYes
Platform fee_sats accrued?NoYes, on paid invoices

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


4. Create an invoice

POSTcURL
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" }
  }'

Always send an Idempotency-Key on create. We cache the response for 24 hours per key so retries after a network blip return the same invoice instead of creating duplicates.

Request fields

FieldTypeRequiredNotes
amountnumberone ofFiat amount. We lock a BTC/USD rate and convert to sats.
currencystringone ofMust be "USD" today.
amount_satsstringone ofSkip fiat conversion; charge exact sats (digits only).
order_refstringoptionalYour internal order ID. Max 255 chars.
callback_urlstringoptionalPer-invoice webhook URL (overrides store-level endpoints).
success_urlstringoptionalHosted checkout redirects here after payment.
buyer_emailstringoptionalBuyer email for receipts / support.
expires_in_minutesintoptional5–120. Default comes from store settings.
metadataobjectoptionalFree-form string → string map (values max 255 chars). Echoed on webhooks.

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

Response

JSON
{
  "invoice": {
    "id": "22872e14-4216-4c78-8fe1-088ea649f3c2",
    "store_id": "…",
    "vendor_id": "…",
    "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,
    "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": "1502",
    "payment_uri": "bitcoin:tb1q…?amount=0.00150234&label=…",
    "hosted_checkout_url": "https://pay.satlane.com/i/22872e14-…",
    "payment_phase": "awaiting_payment",
    "order_ref": "ORD-12345",
    "created_at": "2026-05-16T11:15:00.000Z",
    "paid_at": null
  }
}

Response fields worth understanding

FieldWhat it means
amount_satsInvoice amount in satoshis. Vendor-facing source of truth.
amount_paid_satsRunning total of sats received on-chain so far (non-reverted). Remaining = amount_sats - amount_paid_sats.
amount_tolerance_satsSlack 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_rateBTC/USD rate locked at creation. Later price moves do not change what the buyer owes.
late_payment_deadline_atISO timestamp past which payments are no longer auto-credited. We keep watching until then.
conf_thresholdConfirmations required before status flips to paid. Defaults: 1 below $100 invoice value, 2 at $100+.
fee_satsPlatform take-rate on this invoice, accrued to your vendor account when a live invoice is paid. Visible in dashboard billing.
payment_phaseBuyer-facing lifecycle dimension computed at read time (not persisted). Useful for custom UIs.
hosted_checkout_urlReady-to-redirect hosted payment page.

5. Checkout

You have two options.

Option A: Hosted checkout (easiest)

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

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

Option B: Custom checkout UI

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

JavaScript
// 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}`);
es.addEventListener('invoice.paid', (e) => {
  const { invoice } = JSON.parse(e.data);
  // Show success, 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);
};

Prefer WebSocket? Use live.stream_url instead (same JSON payload per event).

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 your callback_url (per-invoice) or to webhook endpoints configured on the store.

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 response_body on the delivery in the dashboard.

Timeout: 10 seconds per attempt. Write the side effect (mark order paid) and respond 200 quickly. Queue slow work after the ack.

Headers

POSTHTTP
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 matches the public invoice shape from POST /v1/invoices.

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 (using @satlane/webhooks):

JavaScript
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:

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:

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);
}

Reject events with timestamps older than 5 minutes (replay protection). During secret rotation we keep the previous secret valid for 24 hours; pass both to secrets: [current, previous].


7. Invoice statuses

StatusMeaningTerminal?
pendingNo payment seen yetno
seenPayment detected in mempool (0 conf). Once seen, we do not auto-expire even if expires_at elapses; the next block decides.no
paidPayment confirmed (≥ conf_threshold) and amount within amount_sats ± amount_tolerance_satsno (can become 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 (can become reverted)
underpaidConfirmed amount is less than amount_sats − amount_tolerance_sats. Buyer can top up; we auto-merge.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 tolerance), the invoice becomes underpaid and the watcher keeps listening. When a second on-chain transaction lands on the same address:

  1. Sum all non-reverted payments for that invoice plus the new tx.
  2. If the total 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 exceeds amount_sats + tolerance, it becomes overpaid.

Webhook implication: you may receive invoice.underpaid more than once for the same invoice, then invoice.paid / invoice.late_paid / invoice.overpaid when the cumulative total settles. Deduplicate by event_id only, never by invoice.id.

Hosted checkout surfaces this automatically: an underpaid invoice shows a "Send remaining X sats" CTA with a fresh bitcoin: URI for only the remaining amount.


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 within tolerance, before expiryno
invoice.late_paidCumulative confirmed amount 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 below amount_sats − tolerance. Fires on every short payment (top-ups can produce multiple).yes
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
invoice.grace_endingOpt-in: fires once near the end of the late-payment grace window (endpoint must enable graceEndingEnabled)no
invoice.reopenedInvoice reopened after a prior terminal-ish state (lifecycle edge case)rare

Always deduplicate by event_id, never by invoice.id + event_type. Top-ups produce repeated invoice.underpaid events, and dispatcher retries reuse the same event_id.


9. Sandbox and simulate

While the store is in test mode, exercise every webhook path without waiting on the chain.

Dashboard Simulator

On each invoice's detail page, the Simulator card can trigger any event (with optional amount override for under/overpaid). Each click:

  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-mode invoices only. Auth: API key or vendor session.

POSTcURL
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"
  }'
FieldTypeRequiredNotes
eventstringyesOne of: seen, paid, underpaid, overpaid, expired, late_paid, reverted, cancelled
amount_satsstringoptionalOverride 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").

Once your handler returns 200 for the events you care about, flip the store to live, register your mainnet xpub, and go to production.


10. Endpoint reference

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

Authed (your server → ours)

MethodPathNotes
POST/v1/invoicesCreate. API key. Send Idempotency-Key.
GET/v1/invoicesList with cursor pagination.
GET/v1/invoices/:idFetch one.
POST/v1/invoices/:id/cancelCancel pending.
POST/v1/invoices/:id/simulateTest mode only; fire any event.
POST/v1/stores/:id/test-invoiceOne-click test invoice (session).
GET/v1/stores/:id/webhooksList webhook endpoints.
POST/v1/stores/:id/webhooksAdd endpoint.
POST/v1/stores/:id/webhooks/:wid/testSynthetic test delivery.

Public (buyer's browser → ours)

MethodPathNotes
GET/pay/invoices/:idInvoice + store branding snapshot.
GET/pay/invoices/:id/eventsSSE stream.
GET/pay/invoices/:id/streamWebSocket alternative to SSE.

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

Plan management, subscription invoices, and usage live in the vendor dashboard (/billing). There is no public REST surface for choosing Trial/Basic/Pro.


11. Rate limits

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

429 responses use error code rate_limited and include details.retry_after_seconds. Honor that value before retrying.


12. Errors

All errors return the same shape:

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

CodeWhen
api_key_invalidMissing Authorization header, malformed, or the key does not exist. Use 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.

402 payment required (billing lock)

CodeWhen
billing_overdueLive invoicing locked: unpaid plan invoice, exhausted trial, or suspended subscription. Pay or choose a plan at /billing.

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 have not seen recent funding.Bump the gap limit in Electrum (recommend 100+) or rotate xpubs.
idempotency_conflictThe same Idempotency-Key was reused with a different request body.Reuse the key with the original body (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 names the field.
validation_errorIdempotency-Key header > 255 chars or empty.
invalid_currencyCurrency code not supported (only USD today).
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.Back off using retry_after_seconds.

503 temporary infrastructure (retry safe)

These mean the call would have succeeded without an infra condition. Retry with exponential backoff.

CodeWhen
chain_syncingBitcoin node is in initial block download. We refuse new invoices against a stale tip.
disk_fullHost critically low on disk. Writes blocked to protect webhook delivery state.
database_unavailablePostgres unreachable. Rare.

Recommended client retry policy

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

Always send an Idempotency-Key when retrying creates. We cache the response for 24 hours per key.

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