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,
"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
| Field | What it means |
|---|---|
amount_sats | The invoice amount, in satoshis. Vendor-facing source of truth. |
amount_paid_sats | Running 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_sats | Per-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_rate | The BTC/USD rate we locked at creation. Network price moves after this point don't change what the buyer owes. |
late_payment_deadline_at | ISO timestamp past which payments are no longer auto-credited. We keep watching the address until then. |
conf_threshold | Confirmations required before status flips to paid. Defaults: 1 below $100 invoice value, 2 at $100+. |
fee_sats | Platform 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
| Status | Meaning | Terminal? |
|---|---|---|
pending | No payment seen yet | no |
seen | Payment 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 |
paid | Payment confirmed (≥ conf_threshold) and amount within amount_sats ± amount_tolerance_sats | no (could be reverted via reorg) |
expired | pending past expires_at with no detected payment | no — address still watched through the grace window for a possible late_paid |
late_paid | Confirmed payment arrived after expires_at but inside the grace window | no (could be reverted) |
underpaid | Confirmed 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 |
overpaid | Confirmed cumulative amount exceeds amount_sats + amount_tolerance_sats | no — you may want to refund the difference |
requires_review | Routing landed on "no-match" (payment to a recycled address with no matching invoice), or cross-check disagreed. Manual admin action. | no |
reverted | Previously paid, then a chain reorg removed the tx | yes |
cancelled | Vendor cancelled before payment | yes |
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:
- Sums all non-reverted
paymentsrows for that invoice + the new tx. - If the total now lands within
[amount_sats − tolerance, amount_sats + tolerance], the invoice flips topaid(orlate_paidif past expiry). - If still short, it stays
underpaidandamount_paid_satsreflects the new total. - If the total now exceeds
amount_sats + tolerance, it transitions tooverpaid.
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 type | Fired when | May fire more than once? |
|---|---|---|
invoice.created | New invoice via POST /v1/invoices | no |
invoice.payment_seen | Payment in mempool, 0 conf | no, once per invoice |
invoice.paid | Cumulative confirmed amount lands within tolerance, before expiry | no |
invoice.late_paid | Cumulative confirmed amount lands within tolerance, after expiry but inside grace | no |
invoice.expired | pending 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.underpaid | Cumulative 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.overpaid | Cumulative confirmed amount exceeds amount_sats + tolerance | no |
invoice.payment_reverted | A reorg orphaned the block containing the payment. Reverse fulfillment if you already shipped. Rare. | very rare |
invoice.requires_review | Routing produced "no-match" or cross-check disagreed | rare |
invoice.cancelled | Vendor or admin cancelled | no |
Always deduplicate by
event_id, never byinvoice.id+event_type. Top-up payments will produce repeatedinvoice.underpaidevents, and retries from our dispatcher produce repeated events with the sameevent_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:
- 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.
Errors POST /v1/invoices can return
The most-called endpoint. Plan for these.
401 — authentication
| Code | When |
|---|---|
api_key_invalid | Missing Authorization header, malformed, or the key doesn't exist. Check the header is Authorization: Bearer sl_live_… or sl_test_…. |
api_key_revoked | Key was revoked from the dashboard or by an admin. Mint a new one. |
api_key_wrong_env | Calling a live store with a test key, or vice versa. Use the key that matches the store's mode. |
403 — authenticated but blocked
| Code | When |
|---|---|
auth_account_suspended | Vendor account suspended by an admin. No invoice creation until reinstated. Contact support. |
404 — resource missing
| Code | When |
|---|---|
not_found (Store) | The store the API key belongs to was archived. Restore it or use a different store. |
409 — conflict / not configured
| Code | When | Resolution |
|---|---|---|
no_active_xpub | Store 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_exceeded | Wallet'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_conflict | The 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
| Code | Cause |
|---|---|
validation_error | Zod 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_error | Idempotency-Key header > 255 chars or empty. |
invalid_currency | Currency code not in our supported list (only USD at MVP). |
invalid_amount | Sats amount ≤ 0, or fiat amount rounds to zero sats at the current rate. |
429 — rate limited
| Code | When | Resolution |
|---|---|---|
rate_limited | More 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.
| Code | When |
|---|---|
chain_syncing | The 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_full | The platform host is critically low on disk. We block writes to avoid losing webhook delivery state. Operator gets paged automatically; resolves itself. |
database_unavailable | Postgres unreachable. Rare. |
Recommended client retry policy
| HTTP | Action |
|---|---|
| 200 / 201 | Use the response. |
| 400, 401, 403, 404, 409 | Stop. These are caller bugs or configuration errors. Log and surface to the user. |
| 429 | Backoff using Retry-After, then retry. |
| 503 | Exponential backoff (e.g. 1s → 2s → 4s → 8s → 16s, max 5 tries). Show "Service temporarily unavailable" if exhausted. |
| Other 5xx | Treat 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 endpointsauth_totp_required(401) — 2FA-gated endpoint, prompt for code and call/v1/auth/totp/verifyauth_email_not_verified(403) — vendor email not yet verifiedinvoice_not_cancellable(409) — invoice already paid / late_paid / cancelled / expiredinvoice_expired(410) — payment flow hit a fully-expired invoiceinvoice_already_paid(409) — duplicate paid transition attemptnot_found— UUID doesn't match anything you owngone(410) — resource intentionally removed