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
┌──────────┐ 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_satsaccrues 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
| Surface | Auth |
|---|---|
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 dashboard | Session 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.
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 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 Simulator or POST …/simulate | No; the chain does |
| Real BTC at stake? | No | Yes |
Platform fee_sats accrued? | No | Yes, 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
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
| 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" today. |
amount_sats | string | one of | Skip fiat conversion; charge exact sats (digits only). |
order_ref | string | optional | Your internal order ID. Max 255 chars. |
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 | Buyer email for receipts / support. |
expires_in_minutes | int | optional | 5–120. Default comes from store settings. |
metadata | object | optional | Free-form string → string map (values max 255 chars). Echoed on webhooks. |
Provide either (amount + currency) or amount_sats, not both.
Response
{
"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
| Field | What it means |
|---|---|
amount_sats | Invoice amount in satoshis. Vendor-facing source of truth. |
amount_paid_sats | Running total of sats received on-chain so far (non-reverted). Remaining = amount_sats - amount_paid_sats. |
amount_tolerance_sats | Slack 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_rate | BTC/USD rate locked at creation. Later price moves do not change what the buyer owes. |
late_payment_deadline_at | ISO timestamp past which payments are no longer auto-credited. We keep watching until then. |
conf_threshold | Confirmations required before status flips to paid. Defaults: 1 below $100 invoice value, 2 at $100+. |
fee_sats | Platform take-rate on this invoice, accrued to your vendor account when a live invoice is paid. Visible in dashboard billing. |
payment_phase | Buyer-facing lifecycle dimension computed at read time (not persisted). Useful for custom UIs. |
hosted_checkout_url | Ready-to-redirect hosted payment page. |
5. Checkout
You have two options.
Option A: Hosted checkout (easiest)
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:
// 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
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
{
"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):
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);
}
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
| Status | Meaning | Terminal? |
|---|---|---|
pending | No payment seen yet | no |
seen | Payment detected in mempool (0 conf). Once seen, we do not auto-expire even if expires_at elapses; the next block decides. | no |
paid | Payment confirmed (≥ conf_threshold) and amount within amount_sats ± amount_tolerance_sats | no (can become 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 (can become reverted) |
underpaid | Confirmed amount is less than amount_sats − amount_tolerance_sats. Buyer can top up; we auto-merge. | 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 tolerance), the invoice becomes underpaid and the watcher keeps listening. When a second on-chain transaction lands on the same address:
- Sum all non-reverted
paymentsfor that invoice plus the new tx. - If the total 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 exceeds
amount_sats + tolerance, it becomesoverpaid.
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 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 within tolerance, before expiry | no |
invoice.late_paid | Cumulative confirmed amount 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 below amount_sats − tolerance. Fires on every short payment (top-ups can produce multiple). | yes |
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 |
invoice.grace_ending | Opt-in: fires once near the end of the late-payment grace window (endpoint must enable graceEndingEnabled) | no |
invoice.reopened | Invoice reopened after a prior terminal-ish state (lifecycle edge case) | rare |
Always deduplicate by
event_id, never byinvoice.id+event_type. Top-ups produce repeatedinvoice.underpaidevents, and dispatcher retries reuse the sameevent_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:
- Updates the invoice status in our DB
- Fires the matching webhook with
livemode: false - 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.
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"
}'
| Field | Type | Required | Notes |
|---|---|---|---|
event | string | yes | One of: seen, paid, underpaid, overpaid, expired, late_paid, reverted, cancelled |
amount_sats | string | optional | Override 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)
| Method | Path | Notes |
|---|---|---|
POST | /v1/invoices | Create. API key. Send Idempotency-Key. |
GET | /v1/invoices | List with cursor pagination. |
GET | /v1/invoices/:id | Fetch one. |
POST | /v1/invoices/:id/cancel | Cancel pending. |
POST | /v1/invoices/:id/simulate | Test mode only; fire any event. |
POST | /v1/stores/:id/test-invoice | One-click test invoice (session). |
GET | /v1/stores/:id/webhooks | List webhook endpoints. |
POST | /v1/stores/:id/webhooks | Add endpoint. |
POST | /v1/stores/:id/webhooks/:wid/test | Synthetic test delivery. |
Public (buyer's browser → ours)
| Method | Path | Notes |
|---|---|---|
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/*.
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
| Endpoint | Limit |
|---|---|
POST /v1/invoices | 100 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:
{
"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
| Code | When |
|---|---|
api_key_invalid | Missing Authorization header, malformed, or the key does not exist. Use 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. |
402 payment required (billing lock)
| Code | When |
|---|---|
billing_overdue | Live invoicing locked: unpaid plan invoice, exhausted trial, or suspended subscription. Pay or choose a plan at /billing. |
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 have not seen recent funding. | Bump the gap limit in Electrum (recommend 100+) or rotate xpubs. |
idempotency_conflict | The 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
| 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 names the field. |
validation_error | Idempotency-Key header > 255 chars or empty. |
invalid_currency | Currency code not supported (only USD today). |
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. | 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.
| Code | When |
|---|---|
chain_syncing | Bitcoin node is in initial block download. We refuse new invoices against a stale tip. |
disk_full | Host critically low on disk. Writes blocked to protect webhook delivery state. |
database_unavailable | Postgres unreachable. Rare. |
Recommended client retry policy
| HTTP | Action |
|---|---|
| 200 / 201 | Use the response. |
| 400, 401, 402, 403, 404, 409 | Stop. Caller bugs, billing lock, or configuration errors. Log and surface to the user. |
| 429 | Back off using retry_after_seconds, then retry. |
| 503 | Exponential backoff (1s → 2s → 4s → 8s → 16s, max 5 tries). |
| Other 5xx | Treat 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 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 does not match anything you owngone(410): resource intentionally removed