Accept Lightning payments with the CashPay API
Create package-based invoices from your server, show the Lightning invoice to your customer, then credit them when CashPay notifies your webhook. All business calls require HMAC signatures.
https://btc-lightning.com/v1
HTTPS only
JSON
Overview
Typical flow for a merchant backend.
List packages
Fetch enabled packages and amounts configured for your account.
Create a payment
Send package_id + your order number. Receive a BOLT11 invoice.
Customer pays
Display the invoice (QR / wallet). Amount is fixed by the package.
Webhook + verify
On payment.paid, verify the signature, then credit your user idempotently.
API Explorer
Verify connectivity and call live endpoints from this page. Signing runs only in your browser — the API Secret is never uploaded as plaintext and never written to our logs from this UI.
{}
Authentication
Every business endpoint requires these headers. GET /v1/health is public and does not require signing.
| Header | Description |
|---|---|
X-Api-Key | Your public API key (e.g. mk_live_…) |
X-Timestamp | Unix timestamp in seconds (string of digits) |
X-Nonce | Random string, 8–64 characters. Must be unique per request |
X-Signature | Lowercase hex HMAC-SHA256 of the canonical string (see below) |
Content-Type | application/json for requests with a body |
Accept | application/json (recommended) |
- Timestamp must be within ±300 seconds of server time.
- Reusing the same nonce with the same API key returns
401. - If an IP allowlist is configured on your account, requests must originate from an allowed IP.
Two secrets — do not mix
| Secret | Used for | Header |
|---|---|---|
| API Secret | Outbound API calls you send to CashPay | X-Signature |
| Webhook Secret | Inbound webhooks CashPay sends to your notify_url |
X-CashPay-Signature |
Request signing
Build a canonical string, then compute HMAC-SHA256 with your API Secret.
Canonical string
Join the five parts with a real newline character (\n), not spaces and not an empty separator.
{timestamp}\n{nonce}\n{METHOD}\n{pathWithQuery}\n{sha256Hex(body)}
| Part | Rules |
|---|---|
timestamp | Same value as X-Timestamp |
nonce | Same value as X-Nonce |
METHOD | Uppercase HTTP method: GET, POST, … |
pathWithQuery |
Request path starting with /, plus query string if present.
Examples: /v1/packages, /v1/payments?merchant_order_no=ORD-1.
Do not include the scheme or host.
|
sha256Hex(body) |
Hex SHA-256 of the raw request body bytes.
For empty body (typical GET), hash the empty string:
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
Signature
X-Signature = hex( HMAC_SHA256( api_secret, canonical_string ) )
Compare signatures using a constant-time equality check. Send the hex digest in lowercase.
Common signing mistakes
- Concatenating the five parts without
\n→Invalid signature. - GET with a body (e.g. HTTP clients serializing
nullas"null") while hashing an empty body → mismatch. For GET, send no body. - Signing
/v1/paymentsbut calling/v1/payments?merchant_order_no=…(or the reverse) → mismatch. Path + query must match exactly. - Using Webhook Secret instead of API Secret (or trailing spaces when pasting the secret) → mismatch.
List packages
/v1/packagesReturns enabled packages for the authenticated merchant. Amounts are authoritative — never invent or override them client-side.
Response 200
{
"items": [
{
"id": "a038f7a9-f20f-4f9d-8aa4-6b53662139c2",
"name": "Starter",
"amount_usd": "100.00",
"sort_order": 0
}
]
}
Create payment
/v1/paymentsRequest body
| Field | Required | Description |
|---|---|---|
package_id | Yes | Package id from GET /v1/packages |
merchant_order_no | Yes | Your unique order id (max 64 chars). Field name is exactly merchant_order_no — not order_no / out_trade_no. Used for idempotency and echoed in webhooks as data.merchant_order_no |
notify_url | No | HTTPS URL for this payment’s webhook. Falls back to your default notify URL. If the URL includes your order id in the path, webhook signing must use that full path |
metadata | No | JSON object echoed back on reads (max ~2KB) |
amount_usd. Amounts come only from the selected package.
Sending amount_usd returns 422.
Example request
{
"package_id": "a038f7a9-f20f-4f9d-8aa4-6b53662139c2",
"merchant_order_no": "ORD-10086",
"notify_url": "https://merchant.example.com/hooks/cashpay",
"metadata": { "user_id": "u10086" }
}
Response 201 (new) or 200 (idempotent replay)
{
"id": "9236f138-07b5-4d1c-9fe6-bea17a29bc06",
"merchant_order_no": "ORD-10086",
"package_id": "a038f7a9-f20f-4f9d-8aa4-6b53662139c2",
"package_name": "Starter",
"amount_usd": "100.00",
"status": "pending",
"bolt11": "lnbc…",
"pay_url": "https://www.example.com/pay/invoice/…",
"expires_at": "2026-07-18 16:20:00",
"paid_at": null,
"created_at": "2026-07-18 16:10:00",
"metadata": { "user_id": "u10086" }
}
- Repeating the same
merchant_order_noreturns the existing payment (idempotent). - Redirect the payer to
pay_url(hosted checkout), and/or presentbolt11(Lightning invoice string), untilstatusbecomespaidor the invoice expires. - Payment statuses you may see:
pending,paid,expired. - IDs: response
idis the CashPay payment id; webhookdata.invoice_idis the invoice id used inpay_url. Do not confuse them withmerchant_order_no. pay_urlmay benullif the hosted checkout base URL is not configured on the CashPay side — you can still usebolt11.
Get payment
/v1/payments/{id}Lookup by CashPay payment id (UUID). Same response shape as create.
/v1/payments?merchant_order_no={order}Lookup by your order number. Path used for signing must include the query string, e.g. /v1/payments?merchant_order_no=ORD-10086.
Webhooks
When a payment is confirmed, CashPay sends POST to your notify_url (HTTPS only)
with event payment.paid.
X-CashPay-* headers on your notify endpoint while integrating.
Signature verification requires those headers plus the raw body — body-only logs are not enough to debug.
Headers
| Header | Description |
|---|---|
Content-Type | application/json |
X-CashPay-Timestamp | Unix timestamp (seconds). Same role as X-Timestamp on API requests |
X-CashPay-Nonce | Unique nonce. Same role as X-Nonce on API requests |
X-CashPay-Signature | Lowercase hex HMAC-SHA256 of the canonical string (Webhook Secret) |
Verify exactly like inbound API signing
{X-CashPay-Timestamp}\n{X-CashPay-Nonce}\nPOST\n{pathWithQuery}\n{sha256Hex(rawBody)}
- Use your Webhook Secret (not the API Secret) as the HMAC key.
pathWithQueryis the path (and query, if any) of your notify URL — the full path CashPay called. Examples:https://merchant.example.com/hooks/cashpay→/hooks/cashpay;https://merchant.example.com/pay/notify/ORD-10086→/pay/notify/ORD-10086(include the order segment).- Hash the raw request body bytes; do not parse-then-restringify before verifying.
- Reject if timestamp is outside a reasonable window (e.g. ±300 seconds).
- After you accept the event for processing, respond with HTTP
2xxquickly. Non-2xx responses are retried by CashPay.
Payload
data.merchant_order_no (nested under data).
CashPay does not send a top-level order_no field. Map to your internal name only inside your own code.
{
"id": "d5a63767-7f4d-4c21-8e2a-f887f4a8152e",
"event": "payment.paid",
"created_at": "2026-07-18T08:16:33Z",
"data": {
"id": "9236f138-07b5-4d1c-9fe6-bea17a29bc06",
"merchant_order_no": "ORD-10086",
"package_id": "a038f7a9-f20f-4f9d-8aa4-6b53662139c2",
"package_name": "Starter",
"amount_usd": "100.00",
"invoice_id": "142bd039-5c60-4a6f-9cc0-e8583d7b91d9",
"paid_at": "2026-07-18 16:11:54",
"status": "paid"
}
}
Merchant responsibilities
- Verify
X-CashPay-Signaturewith Webhook Secret before trusting the body. - Confirm
event === "payment.paid"anddata.amount_usdmatches your order expectation. - Credit using
data.idordata.merchant_order_noas an idempotency key — webhooks may be delivered more than once. - Never trust browser redirects or unpaid invoice display as payment success.
- HTTP
2xxmeans CashPay will stop retrying that delivery attempt. Your JSON business result (if any) is separate — handle failures in your own system after a valid signed event.
Errors
Errors return JSON: {"message":"…"}.
| HTTP | Meaning |
|---|---|
401 | Missing/invalid headers, bad signature, expired timestamp, or reused nonce |
403 | API disabled for the merchant, or IP not allowlisted |
404 | Payment not found (or not owned by this API key) |
422 | Validation error (missing fields, invalid package, amount_usd sent, etc.) |
429 | API key temporarily locked |
500 | Unexpected server error |
/v1/healthPublic health check. Example: {"ok":true,"service":"cashpay-merchant-api"}
Troubleshooting
Self-check guide for the most common integration failures. No secrets are shown here.
| Symptom | What to check |
|---|---|
401 / Invalid signature |
Canonical parts joined with \n; correct secret type; path+query matches the URL; GET has empty body; no extra spaces in the secret |
401 / Timestamp expired |
Server clock skew; send Unix seconds (not milliseconds) |
401 / Nonce already used |
Generate a new nonce for every request |
| Webhook accepted by CashPay but your app rejects the order | Read data.merchant_order_no (not a renamed field); ensure your local order is still payable; log X-CashPay-* headers while debugging |
| Webhook signature always fails on your side | Use Webhook Secret; method is always POST; path includes every path segment of your notify URL; hash raw body bytes |
Code examples
Copy-paste helpers that list packages (smoke test). Keep secrets on your server. Code samples stay in English. For an interactive demo, use the API Explorer above.
Health needs no auth. For signed calls, generate headers with your backend (or the Explorer), then:
# 1) Connectivity (no credentials)
curl -sS https://btc-lightning.com/v1/health
# 2) List packages — replace the four auth headers from your signer
curl -sS https://btc-lightning.com/v1/packages \
-H "Accept: application/json" \
-H "X-Api-Key: mk_live_..." \
-H "X-Timestamp: 1710000000" \
-H "X-Nonce: $(openssl rand -hex 16)" \
-H "X-Signature: <hmac_hex>"
import crypto from "node:crypto";
const BASE = "https://btc-lightning.com";
const API_KEY = process.env.CASHPAY_API_KEY;
const API_SECRET = process.env.CASHPAY_API_SECRET;
async function signedRequest(method, pathWithQuery, body = null) {
const raw = body == null ? "" : JSON.stringify(body);
const ts = String(Math.floor(Date.now() / 1000));
const nonce = crypto.randomBytes(16).toString("hex");
const bodyHash = crypto.createHash("sha256").update(raw).digest("hex");
const canonical = [ts, nonce, method.toUpperCase(), pathWithQuery, bodyHash].join("\n");
const signature = crypto.createHmac("sha256", API_SECRET).update(canonical).digest("hex");
const res = await fetch(BASE + pathWithQuery, {
method,
headers: {
Accept: "application/json",
...(raw ? { "Content-Type": "application/json" } : {}),
"X-Api-Key": API_KEY,
"X-Timestamp": ts,
"X-Nonce": nonce,
"X-Signature": signature,
},
body: raw || undefined,
});
const data = await res.json();
if (!res.ok) throw new Error(JSON.stringify(data));
return data;
}
// Smoke test
console.log(await fetch(BASE + "/v1/health").then((r) => r.json()));
console.log(await signedRequest("GET", "/v1/packages"));
// await signedRequest("POST", "/v1/payments", {
// package_id: "...",
// merchant_order_no: "ORD-10086",
// });
import hashlib, hmac, json, os, secrets, time, urllib.request
BASE = "https://btc-lightning.com"
API_KEY = os.environ["CASHPAY_API_KEY"]
API_SECRET = os.environ["CASHPAY_API_SECRET"]
def signed_request(method: str, path_with_query: str, body: dict | None = None):
raw = b"" if body is None else json.dumps(body, separators=(",", ":")).encode()
ts = str(int(time.time()))
nonce = secrets.token_hex(16)
canonical = "\n".join([
ts, nonce, method.upper(), path_with_query,
hashlib.sha256(raw).hexdigest(),
])
sig = hmac.new(API_SECRET.encode(), canonical.encode(), hashlib.sha256).hexdigest()
req = urllib.request.Request(
BASE + path_with_query,
data=raw or None,
method=method.upper(),
headers={
"Accept": "application/json",
"X-Api-Key": API_KEY,
"X-Timestamp": ts,
"X-Nonce": nonce,
"X-Signature": sig,
**({"Content-Type": "application/json"} if raw else {}),
},
)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode())
print(urllib.request.urlopen(BASE + "/v1/health").read().decode())
print(signed_request("GET", "/v1/packages"))
<?php
$base = 'https://btc-lightning.com';
$apiKey = getenv('CASHPAY_API_KEY');
$secret = getenv('CASHPAY_API_SECRET');
function cashpay_signed_request(string $method, string $pathWithQuery, ?array $body = null): array {
global $base, $apiKey, $secret;
$raw = $body === null ? '' : json_encode($body, JSON_UNESCAPED_UNICODE);
$ts = (string) time();
$nonce = bin2hex(random_bytes(16));
$canonical = implode("\n", [
$ts, $nonce, strtoupper($method), $pathWithQuery, hash('sha256', $raw),
]);
$sig = hash_hmac('sha256', $canonical, $secret);
$headers = [
'Accept: application/json',
'X-Api-Key: ' . $apiKey,
'X-Timestamp: ' . $ts,
'X-Nonce: ' . $nonce,
'X-Signature: ' . $sig,
];
if ($raw !== '') {
$headers[] = 'Content-Type: application/json';
}
$ch = curl_init($base . $pathWithQuery);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_POSTFIELDS => $raw !== '' ? $raw : null,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
]);
$out = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$data = json_decode($out, true) ?: [];
if ($code < 200 || $code >= 300) {
throw new RuntimeException($out);
}
return $data;
}
echo file_get_contents($base . '/v1/health'), PHP_EOL;
print_r(cashpay_signed_request('GET', '/v1/packages'));
rawBody = read_raw_request_body()
timestamp = header("X-CashPay-Timestamp")
nonce = header("X-CashPay-Nonce")
signature = header("X-CashPay-Signature")
path = request_path_with_query() // e.g. "/hooks/cashpay"
canonical = timestamp + "\n" + nonce + "\n" + "POST\n" + path + "\n" + sha256_hex(rawBody)
expected = hex(hmac_sha256(webhook_secret, canonical))
if !secure_compare(expected, lowercase(signature)): reject
if abs(now - int(timestamp)) > 300: reject
event = json_parse(rawBody)
# credit once using event.data.id
respond 200
OpenAPI
Machine-readable contract for Postman, Insomnia, codegen, and API gateways:
/docs/openapi.json
- Import into Postman → run the same smoke tests as the Explorer.
- Does not include internal implementation details — only public request/response shapes.
Go-live checklist
- Store API Secret and Webhook Secret only on your backend.
- Create at least one enabled package and note its
package_id. - Set a default HTTPS
notify_url(or pass one per payment). - Implement signature verification before any balance credit.
- Make credits idempotent on
payment id/merchant_order_no. - Optional: configure an IP allowlist for your API servers.
- Test: create payment → pay invoice → confirm webhook arrives and verifies.
- While integrating, log webhook
X-CashPay-*headers and verify againstdata.merchant_order_no.