Zaptrain API

Build payments into your application.

The Zaptrain API provides a RESTful interface to create, manage, and track invoices paid over Bitcoin Lightning. All API requests use application/json for request and response bodies.

The base URL for all API endpoints is https://zaptrain.com.

This documentation covers the v1 API. For a machine-readable OpenAPI specification, visit /api/docs/openapi.json. For interactive exploration, try the Swagger UI.

Authentication

API Key (Bearer Token)

All v1 API endpoints accept Bearer token authentication using API keys. Generate an API key from Settings → Developer settings and include it in theAuthorization header.

Authorization: Bearer isvk_abc123def456...

API keys are prefixed with isvk_. They are shown only once when created. If you lose an API key, delete it and create a new one in your settings.

Session Cookie

Browser sessions authenticated via the Zaptrain web app automatically include session cookies, which is useful for reading the API from a browser console on zaptrain.com. Cookies are ambient credentials, so any request that changes something (POST, PATCH, DELETE) must come from zaptrain.com itself: a cross-site request carrying only cookies is rejected with 403. From your own server or another origin, use an isvk_ API key instead.

Error Responses

If authentication fails, the API returns a 401 Unauthorized status. State-rule violations (sending a paid invoice, reminding an unsent invoice, canceling an expired one) return 409 with a machine code in error (invoice_not_open or invoice_not_sendable) and a human-readable message. Invalid amounts return 400 with invalid_amount or invalid_tax.

{
  "error": "Unauthorized"
}

Hosted checkout

Add a “Pay with Lightning” button to your own site in about twenty lines of server code. Zaptrain hosts the payment page; your customer is sent back to you when they have paid. It works like Stripe Checkout: a redirect, not an embedded widget — there is no JavaScript library to load, and the hosted page cannot be placed in an iframe.

How it works

  1. Your server creates a session with POST /api/v1/checkout/sessions and gets back a hosted_url.
  2. You redirect the customer's browser to that URL. They scan the QR with any Lightning wallet.
  3. Zaptrain sends them to your return_url with ?session_id=…&status=paid&ts=…&signature=….
  4. Your server checks the signature, then confirms with GET /api/v1/checkout/sessions/{id} before fulfilling.

Before you start

In Settings → Developer settings create an API key (isvk_…) and reveal your checkout signing secret. Keep both on your server as environment variables — never in browser code.

1 · The button

<!-- Any page on your site. Plain form POST — no JavaScript required. -->
<form method="post" action="/buy">
  <input type="hidden" name="orderId" value="order_abc123">
  <button type="submit">Pay with Lightning ⚡</button>
</form>

2 · Create the session and redirect

// server.js (Node 18+, Express) — your server, never the browser
app.post("/buy", async (req, res) => {
  const r = await fetch("https://zaptrain.com/api/v1/checkout/sessions", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.ZAPTRAIN_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      line_items: [{ description: "Premium plan", quantity: 1, unit_price_sats: 50000 }],
      customer_email: req.body.email,
      client_reference_id: req.body.orderId,
      return_url: "https://merchant.example/checkout/success",
      cancel_url: "https://merchant.example/checkout/cancel",
    }),
  });
  const session = await r.json();
  res.redirect(303, session.hosted_url); // customer pays on zaptrain.com
});

3 · Handle the return and fulfill

import crypto from "crypto";

// 1. Check the signature on the return redirect (cheap, immediate)
function verifyReturn({ session_id, status, ts, signature }) {
  const canonical = `${session_id.replace(/^cs_/, "")}.${status}.${ts}`;
  const expected = crypto
    .createHmac("sha256", Buffer.from(process.env.ZAPTRAIN_SIGNING_SECRET, "hex"))
    .update(canonical).digest("hex");
  const a = Buffer.from(signature, "hex"), b = Buffer.from(expected, "hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= 600; // 10 min
  return a.length === b.length && crypto.timingSafeEqual(a, b) && fresh;
}

// 2. Confirm with the API before you fulfill (authoritative)
app.get("/checkout/success", async (req, res) => {
  if (!verifyReturn(req.query)) return res.status(400).send("Bad signature");
  const r = await fetch(`https://zaptrain.com/api/v1/checkout/sessions/${req.query.session_id}`, {
    headers: { Authorization: `Bearer ${process.env.ZAPTRAIN_API_KEY}` },
  });
  const session = await r.json();
  if (session.payment_status !== "paid") return res.redirect("/checkout/pending");
  await fulfillOrder(session.client_reference_id, session.amount_paid_sats);
  res.render("thanks");
});
Why both checks? The signature proves the redirect came from Zaptrain and is recent (10-minute window). The GET proves the money actually arrived — it is the only thing to fulfill on. A customer can close the tab before the redirect, so also poll the session if your success page is never hit. Sessions are open → complete or expired;payment_status is unpaid or paid.

A complete runnable example (Express, with the mock wallet for local testing) is available on request — email support@zaptrain.com and we'll send it over. Endpoint details are in the Checkout Sessions API section below.

Webhooks

Register a URL under Settings → Developer settings → Webhook and Zaptrain POSTs a signed event to it the moment an invoice is paid — whether it was created in the dashboard, through the API, by an AI assistant or through a hosted checkout. Your system learns about the payment without polling.

Events

invoice.paidAn invoice was paid. Sent once per invoice; delivery may be repeated, so treat id as the idempotency key.
test.pingSent by the "Send test event" button with a sample invoice. Same shape; ignore it in production code.
{
  "id": "evt_V1StGXR8_Z5jdHi6B-myT",
  "type": "invoice.paid",
  "created_at": "2026-09-13T17:04:12.418Z",
  "data": {
    "invoice": {
      "id": 67,
      "public_id": "IxJx-Btnzc3NyWJbi0tQf",
      "status": "paid",
      "customer_name": "Alice",
      "customer_email": "alice@example.com",
      "amount_sats": 1500,
      "currency": "SATS",
      "memo": "Latte",
      "due_date": "2026-09-19",
      "expiry_date": null,
      "paid_at": "2026-09-13T17:04:11.900Z",
      "payment_url": "https://zaptrain.com/pay/IxJx-Btnzc3NyWJbi0tQf",
      "created_at": "2026-09-12T06:57:03.101Z"
    }
  }
}

Verifying the signature

Events follow the Standard Webhooks spec: three headers — webhook-id, webhook-timestamp (unix seconds) and webhook-signature (v1,<base64>) — and the signed string is id.timestamp.body with HMAC-SHA256 over the raw body, keyed by your secret's bytes (base64 after the whsec_ prefix). Any Standard Webhooks library verifies it; here is the check by hand:

import crypto from "crypto";
import express from "express";

const app = express();
const SECRET = process.env.ZAPTRAIN_WEBHOOK_SECRET; // whsec_… from Settings → Developer → Webhook

// Raw body: the signature covers the exact bytes we received.
app.post("/zaptrain/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const id = req.header("webhook-id");
  const ts = req.header("webhook-timestamp");
  const sigs = (req.header("webhook-signature") || "").split(" ");
  const body = req.body.toString("utf8");

  // Reject anything older than 5 minutes (replay protection).
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(400).send("stale");

  const key = Buffer.from(SECRET.replace(/^whsec_/, ""), "base64");
  const expected = "v1," + crypto.createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest("base64");
  const ok = sigs.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
  if (!ok) return res.status(400).send("bad signature");

  const event = JSON.parse(body);
  if (event.type === "invoice.paid") {
    // Idempotent: you may see the same event.id more than once.
    markOrderPaid(event.data.invoice.public_id, event.data.invoice.amount_sats);
  }
  res.sendStatus(200); // any 2xx within 5 s counts as delivered
});
Delivery rules. Answer with any 2xx within 5 seconds. Anything else — a timeout, a 5xx, a redirect — is retried after 30 seconds, 5 minutes and 30 minutes, then marked failed; the last few deliveries and their status are shown in Settings. Retries piggy-back on live traffic, so an endpoint that was down for an hour gets its events on the next payment or dashboard visit. The webhook is a notification, not the record: confirm with GET /api/v1/invoices/{id} or the checkout session before fulfilling, and keep the payment link as the customer's receipt.
GET/api/v1/users/me

Get merchant profile

Returns the authenticated merchant's profile including branding settings and account info.

Examples

{
  "id": 1,
  "name": "Acme Corp",
  "email": "hello@acme.com",
  "brand_name": "Acme",
  "brand_color": "#d26434",
  "logo_url": "/api/logo/abc123.png",
  "default_memo": "Thank you!",
  "created_at": "2024-01-15T10:30:00Z"
}
PATCH/api/v1/users/me

Update merchant profile

Update mutable profile fields: brand name, brand color, and default memo. All other fields are ignored.

Request Body

ParameterInTypeRequiredDescription
brand_namestring—Display name for invoices
brand_colorstring—Hex color (e.g., #d26434)
default_memostring—Default text on invoices

Examples

{
  "id": 1,
  "name": "Acme Corp",
  "email": "hello@acme.com",
  "brand_name": "Acme Updated",
  "brand_color": "#1e4d8c",
  "logo_url": "/api/logo/abc123.png",
  "default_memo": "Thanks for your business.",
  "created_at": "2024-01-15T10:30:00Z"
}
GET/api/v1/users/me/usage

Get fee rate

Returns the current fee rate applied to all invoices created by this merchant.

Examples

{
  "fee_rate": 0.01,
  "fee_rate_pct": "1%",
  "description": "1% of each paid invoice"
}
GET/api/v1/invoices

List invoices

Retrieve a paginated list of invoices for the authenticated merchant, ordered by creation date (newest first).

Parameters

ParameterInTypeRequiredDescription
limitqueryinteger—Max results to return (1–100, default 20)
offsetqueryinteger—Number of records to skip (default 0)

Examples

{
  "invoices": [
    {
      "id": 42,
      "public_id": "xK9mPqR3vN7w",
      "merchant_id": 1,
      "customer_name": "Jane Smith",
      "customer_email": "jane@example.com",
      "status": "sent",
      "currency": "SATS",
      "amount_cents": 50000,
      "memo": "Web design work",
      "due_date": "2024-04-01",
      "bolt11": "lnbc500u1p0sample...",
      "lexe_index": "abc123",
      "lexe_status": "pending",
      "lexe_status_msg": "invoice generated",
      "fee_sats": 500,
      "tax_percent": 10,
      "tax_amount_sats": 5000,
      "created_at": "2024-03-01T00:00:00Z",
      "updated_at": "2024-03-01T00:00:00Z",
      "sent_at": "2024-03-01T12:00:00Z"
    }
  ],
  "total": 23
}
POST/api/v1/invoices

Create invoice

Create a new invoice with status draft (shown as Unsent in the dashboard). Nothing is emailed; the payment link is minted when the customer opens the payment page or when you send it.

Request Body

ParameterInTypeRequiredDescription
customer_namestring—Customer name (optional)
customer_emailstring—Customer email address (optional; validated if given). Needed only for send/remind — otherwise share the payment link yourself.
memostring—Invoice description/memo
due_datestring—Due date (ISO 8601 format)
itemsarrayYesLine items with description, quantity, unit_price_sats
tax_percentnumber—Tax percentage (default 0)
tax_amount_satsinteger—Flat tax in satoshis (default 0)

Examples

{
  "id": 43,
  "public_id": "mK2nQpX9wP5r",
  "merchant_id": 1,
  "customer_name": "Jane Smith",
  "customer_email": "jane@example.com",
  "status": "draft",
  "currency": "SATS",
  "amount_cents": 55000,
  "memo": "Web design work",
  "due_date": "2024-04-01",
  "bolt11": null,
  "lexe_index": null,
  "lexe_status": null,
  "fee_sats": 550,
  "tax_percent": 10,
  "tax_amount_sats": 5000,
  "created_at": "2024-03-15T10:30:00Z",
  "updated_at": "2024-03-15T10:30:00Z",
  "sent_at": null
}
GET/api/v1/invoices/{id}

Get invoice

Retrieve a single invoice by its internal ID. Returns full invoice details including status, payment info, and line items.

Parameters

ParameterInTypeRequiredDescription
idpathintegerYesInvoice internal ID (not public_id)

Examples

{
  "id": 42,
  "public_id": "xK9mPqR3vN7w",
  "merchant_id": 1,
  "customer_name": "Jane Smith",
  "customer_email": "jane@example.com",
  "status": "sent",
  "currency": "SATS",
  "amount_cents": 50000,
  "memo": "Web design work",
  "due_date": "2024-04-01",
  "bolt11": "lnbc500u1p0sample...",
  "lexe_index": "abc123",
  "lexe_status": "pending",
  "lexe_status_msg": "invoice generated",
  "fee_sats": 500,
  "tax_percent": 10,
  "tax_amount_sats": 5000,
  "created_at": "2024-03-01T00:00:00Z",
  "updated_at": "2024-03-01T12:00:00Z",
  "sent_at": "2024-03-01T12:00:00Z",
  "items": [
    {
      "id": 101,
      "invoice_id": 42,
      "description": "Design work",
      "quantity": 1,
      "unit_price_sats": 50000
    }
  ]
}
POST/api/v1/invoices/{id}/send

Send invoice email

Send invoice to customer via email. Optionally customize the email subject and message. Updates invoice status to 'sent'; calling it again on a sent invoice re-sends. Returns 409 { error: 'invoice_not_sendable' } for paid, canceled or expired invoices.

Parameters

ParameterInTypeRequiredDescription
idpathintegerYesInvoice internal ID

Request Body

ParameterInTypeRequiredDescription
subjectstring—Custom email subject
messagestring—Custom message to include in email body

Examples

{
  "ok": true,
  "status": "sent"
}
POST/api/v1/invoices/{id}/remind

Send reminder email

Send a payment reminder to customer. Use mode='same' to resend the original email, or mode='new' to regenerate with optional custom message. Only sent, attempted or overdue invoices qualify — returns 409 { error: 'invoice_not_open' } otherwise (send an unsent invoice first).

Parameters

ParameterInTypeRequiredDescription
idpathintegerYesInvoice internal ID

Request Body

ParameterInTypeRequiredDescription
modestring—'same' to resend original, 'new' to regenerate (default 'new')
subjectstring—Custom email subject (mode='new' only)
messagestring—Custom message (mode='new' only)

Examples

{
  "ok": true,
  "status": "sent"
}
POST/api/v1/invoices/{id}/cancel

Cancel an invoice

Soft-cancel a sent (attempted / overdue) invoice. Preserves the invoice row and history and voids the open Lightning request. Paid and expired invoices cannot be canceled, and unsent invoices must be deleted instead — those return 409 { error: 'invoice_not_open' }. Canceling an already-canceled invoice is a no-op (idempotent).

Parameters

ParameterInTypeRequiredDescription
idpathintegerYesInvoice internal ID

Examples

{
  "ok": true,
  "status": "cancelled",
  "invoice": {
    "id": 42,
    "public_id": "xK9mPqR3vN7w",
    "status": "cancelled",
    "amount_cents": 50000,
    "customer_name": "Jane Smith",
    "customer_email": "jane@example.com"
  }
}
GET/api/v1/invoices/{id}/payment

Get payment status

Get payment and Lightning Network status for an invoice. Includes BOLT11 code, payment status, and finalization timestamp.

Parameters

ParameterInTypeRequiredDescription
idpathintegerYesInvoice internal ID

Examples

{
  "id": 42,
  "public_id": "xK9mPqR3vN7w",
  "status": "sent",
  "bolt11": "lnbc500u1p0sample...",
  "lexe_status": "completed",
  "lexe_status_msg": "payment received",
  "lexe_finalized_at": "2024-03-02T14:30:00Z",
  "amount_sats": 50000,
  "fee_sats": 500
}
POST/api/v1/checkout/sessions

Create a checkout session

Creates an invoice and a hosted payment page for it. Redirect the customer to hosted_url; after payment they are sent to your return_url with a signed query string.

Request Body

ParameterInTypeRequiredDescription
line_itemsarrayYes1–50 items: { description (≤500 chars), quantity (int ≥1), unit_price_sats (int ≥1) }
return_urlstringYeshttps:// URL (http://localhost allowed) the customer returns to after paying; ≤2048 chars
cancel_urlstringYesURL for the customer's Cancel link on the hosted page
customer_emailstring—Pre-fills the invoice's customer
client_reference_idstring—Your order id (≤200 chars); echoed on the return redirect
metadataobject—Up to 50 keys, ≤8 KB; returned on retrieve
expires_ininteger—Seconds until the session expires: 1800–604800, default 86400

Examples

{
  "id": "cs_V1StGXR8_Z5jdHi6B-myT",
  "object": "checkout.session",
  "status": "open",
  "hosted_url": "https://zaptrain.com/checkout/V1StGXR8_Z5jdHi6B-myT",
  "return_url": "https://merchant.example/checkout/success",
  "cancel_url": "https://merchant.example/checkout/cancel",
  "client_reference_id": "order_abc123",
  "metadata": {
    "order_id": "abc123"
  },
  "customer_email": "alice@example.com",
  "expires_at": "2026-09-12T18:00:00.000Z",
  "completed_at": null,
  "cancelled_at": null,
  "created_at": "2026-09-11T18:00:00.000Z",
  "payment_status": "unpaid",
  "amount_paid_sats": 0,
  "invoice": {
    "public_id": "k3Jd9sLq2",
    "amount_sats": 50000
  }
}
GET/api/v1/checkout/sessions/{id}

Retrieve a checkout session

The source of truth for fulfillment. Accepts cs_<id> or the bare id. Fulfill when payment_status is "paid" — never on the redirect alone. Another merchant's session returns 404.

Parameters

ParameterInTypeRequiredDescription
idstringYesSession id, e.g. cs_V1StGXR8_Z5jdHi6B-myT

Examples

{
  "id": "cs_V1StGXR8_Z5jdHi6B-myT",
  "object": "checkout.session",
  "status": "complete",
  "return_url": "https://merchant.example/checkout/success",
  "cancel_url": "https://merchant.example/checkout/cancel",
  "client_reference_id": "order_abc123",
  "metadata": {
    "order_id": "abc123"
  },
  "customer_email": "alice@example.com",
  "expires_at": "2026-09-12T18:00:00.000Z",
  "completed_at": "2026-09-11T18:04:12.000Z",
  "cancelled_at": null,
  "created_at": "2026-09-11T18:00:00.000Z",
  "payment_status": "paid",
  "amount_paid_sats": 50000,
  "invoice": {
    "public_id": "k3Jd9sLq2",
    "amount_sats": 50000
  }
}

Need help? Contact us.

API documentation · Zaptrain