Quickstart
Goal: signed-up vendor → first paid invoice in 10 minutes. The four pieces you actually need: the mental model, authentication, creating an invoice, receiving the paid webhook.
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.
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.
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. |
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].