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
| Surface | Auth |
|---|---|
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 dashboard | Session 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 mode | Live mode | |
|---|---|---|
| Watcher subscribes to address? | No (simulated) | Yes |
Webhook livemode field | false | true |
Invoice environment field | test | live |
| Vendor triggers events? | Yes, via dashboard Simulator card | No — chain does |
| Real BTC at stake? | No | Yes |
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
| 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" at MVP. |
amount_sats | string | one of | Skip fiat conversion, charge exact sats. |
order_ref | string | optional | Your internal order ID. We don't constrain format. |
callback_url | string | optional | Per-invoice webhook URL (overrides store-level endpoints). |
success_url | string | optional | Hosted checkout redirects here after payment. |
buyer_email | string | optional | If set, we can email the buyer a receipt later (Phase 9). |
expires_in_minutes | int | optional | 5–120, default from store settings. |
metadata | object | optional | Free-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
| Status | Meaning | Terminal? |
|---|---|---|
pending | No payment seen yet | no |
seen | Payment detected in mempool (0 conf) | no |
paid | Payment confirmed (≥ conf_threshold) | no (could be reverted via reorg) |
expired | Past expires_at, still inside grace window — address still watched | no |
late_paid | Payment arrived after expires_at but inside grace window | no (could be reverted) |
underpaid | Buyer sent less than amount_sats | no — you decide whether to ship |
overpaid | Buyer sent more | no — you may want to refund the difference |
requires_review | Internal cross-check mismatch — manual review needed | no |
reverted | Previously paid, then a chain reorg removed the tx | yes |
cancelled | Vendor cancelled before payment | yes |
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 type | Fired when |
|---|---|
invoice.created | New invoice via POST /v1/invoices |
invoice.payment_seen | Payment in mempool, 0 conf |
invoice.paid | Payment confirmed (status: paid) |
invoice.late_paid | Payment arrived after expiry but inside grace (status: late_paid) |
invoice.expired | expires_at passed without sufficient payment |
invoice.underpaid | Confirmed payment is less than expected |
invoice.overpaid | Confirmed payment exceeds expected |
invoice.payment_reverted | Reorg invalidated a previously confirmed payment |
invoice.requires_review | Cross-check disagreement — needs manual review |
invoice.cancelled | Vendor 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:
- Updates the invoice status in our DB
- Fires the matching webhook to your endpoint with
livemode: false - 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
| Endpoint | Limit |
|---|---|
POST /v1/invoices | 100 req/min per API key |
| Auth endpoints (login, signup) | 5 req/sec per IP |
| Everything else | 20 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.