Zaptrain API

Build payments into your application.

The Zaptrain API provides a RESTful interface to create, manage, and track invoices and subscriptions paid over Bitcoin Lightning. Every payment is an invoice: a one-time invoice, or one payment of a subscription (those carry a subscription object). Plans, subscribers and invites have their own endpoints under Subscriptions API. 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 API v2: invoices, hosted checkout, plans, subscriptions and invites, priced in sats or U.S. dollars (see Pricing currency). For a machine-readable OpenAPI specification, visit /api/docs/openapi-v2.json. For interactive exploration, try the Swagger UI.

Legacy v1 (sats only). The v1 endpoints keep working unchanged for existing integrations, but only ever see sats: a USD invoice or plan (and a plan's subscribers and invites) is left out of v1 lists and answers 404 there. Their reference is the v1 spec. New integrations should use v2.

Authentication

API Key (Bearer Token)

All 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. Canceling a subscription payment returns 409 subscription_invoice: cancel the subscription instead. The v2 money checks answer 400 with { error, message } — see Pricing currency for the codes.

The Subscriptions API (plans, subscriptions, invites) answers errors as { error: <code>, message, field? }, where field is the snake_case request field at fault (e.g. unit_price, customer_email). Codes: invalid_request and invalid_json (400), not_found (404), not_allowed, already_subscribed, lexe_required and payment_failed (409; a failed retry), rate_limited (429), email_failed (502) and save_failed (500). An unknown id in the path returns 404 with { "error": "Not found" }.

{
  "error": "Unauthorized"
}

Pricing currency

An invoice is priced in sats or in U.S. dollars, and is always paid in bitcoin over Lightning. Every request that sets a price names its currency — SATS or USD — and every amount is a whole number in that currency's smallest unit: sats for SATS, cents for USD. amount, tax_amount and items' unit_price follow this rule; so "unit_price": 12000 is 12,000 sats or $120.00 depending on currency.

Fields that end in _sats are always real sats: sats_received (what arrived; null until paid), fee_sats (Zaptrain's fee — for USD, null until paid) and quote.amount_sats.

A USD invoice is converted when the payer opens the payment page: the sats are fixed then, at the Coinbase Exchange BTC/USD rate, rounded up to a whole sat, and held for a few minutes before a fresh quote replaces it. Once it is paid, the invoice carries the quote it was paid at — amount_sats, usd_rate (a decimal string, e.g. "67123.45"), rate_source and rate_at. SATS invoices, and unpaid ones, have quote: null.

// POST /api/v2/invoices
{
  "currency": "USD",
  "customer_email": "jane@example.com",
  "items": [{ "description": "Logo design", "quantity": 1, "unit_price": 12000 }]
}

// ...after it is paid (GET /api/v2/invoices/43/payment)
{
  "status": "paid",
  "currency": "USD",
  "amount": 12000,
  "sats_received": 178776,
  "fee_sats": 1788,
  "quote": { "amount_sats": 178776, "usd_rate": "67123.45", "rate_source": "coinbase_exchange", "rate_at": "2026-10-19T14:00:02Z" }
}

Errors (400)

currency_requiredcurrency was left out. There is no default — not even the merchant's Settings default (read it from GET /users/me if you want to follow it).
unsupported_currencyAnything other than SATS or USD.
legacy_fieldA v1 name was sent (unit_price_sats, tax_amount_sats, amount_cents); the message names the v2 field.
invalid_amountA price that isn't a whole number in the smallest unit (12.5, or "1200" as a string). Never rounded.
below_minimumA USD total under $0.50.
usd_not_enabledU.S. dollar pricing isn't switched on yet. Zaptrain turns it on for every merchant at once.

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/v2/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=… (status=no_payment_required for a subscription to a plan that starts later).
  4. Your server checks the signature, then confirms with GET /api/v2/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/v2/checkout/sessions", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.ZAPTRAIN_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      currency: "USD", // or "SATS"; prices are in its smallest unit
      line_items: [{ description: "Premium plan", quantity: 1, unit_price: 2500 }], // $25.00
      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/v2/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");
  // amount_total is what you charged (in session.currency); sats_received is what arrived.
  await fulfillOrder(session.client_reference_id, session.amount_total, session.currency);
  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 (or no_payment_required in subscription mode, below).

Subscriptions

The same flow sells a subscription to one of your plans. Create the session with "mode": "subscription" and plan (the plan id) instead of line_items; the plan sets the price and its currency (currency may be left out; if sent it must match, or 400 currency_mismatch). client_reference_id becomes the subscription's, so it comes back in the subscription.* webhooks; in this mode it is up to 128 letters, digits and _ . : @ -. An archived or ended plan returns 409 plan_unavailable; an unknown plan returns 404.

curl -X POST https://zaptrain.com/api/v2/checkout/sessions \
  -H "Authorization: Bearer isvk_abc123..." \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "subscription",
    "plan": "O9yN2uK0xjoYgLQXUYBj",
    "customer_email": "ada@example.com",
    "client_reference_id": "user_42",
    "return_url": "https://merchant.example/welcome",
    "cancel_url": "https://merchant.example/pricing"
  }'
{
  "id": "cs_Q2wE4rT6yU8iO0pA1sD3f",
  "object": "checkout.session",
  "mode": "subscription",
  "status": "open",
  "client_reference_id": "user_42",
  "customer_email": "ada@example.com",
  "currency": "SATS",
  "amount_total": null,
  "payment_status": "unpaid",
  "sats_received": null,
  "invoice": null,
  "plan": { "id": "O9yN2uK0xjoYgLQXUYBj", "name": "Daily Beans" },
  "subscription": null,
  "hosted_url": "https://zaptrain.com/checkout/Q2wE4rT6yU8iO0pA1sD3f",
  ...
}

The hosted page shows the plan's subscribe form. Once the customer subscribes and the first payment goes through (or right away, for a plan that starts later) they are sent to your return_url with the same signed query (session_id, status, ts, signature, client_reference_id). The session then carries subscription (id, status, payment_method: automatic or pay_each_time), and invoice is the first payment's invoice (null until they subscribe). payment_status is paid once the first payment arrives, or no_payment_required when the plan starts later and the session completed with nothing charged. Grant access from the GET on the session or from the subscription.created webhook.

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 — and when a subscription starts, falls past due or is canceled. Your system learns about payments and subscribers without polling.

Events

invoice.paidAn invoice was paid. Sent once per invoice; delivery may be repeated, so treat id as the idempotency key. amount is in currency's smallest unit (sats, or cents), sats_received is the sats that arrived, and quote is how a USD invoice was priced (null for SATS). amount_sats is kept as an alias for sats_received.
subscription.createdSomeone subscribed to one of your plans and the first payment went through (or, for a plan that starts later, they signed up and periods_paid is 0 until the start). Carries data.subscription with its id, your client_reference_id (from ?ref= on the subscribe link), payment_method (automatic or pay_each_time), amount and currency (charged each period, tax included, in the plan's currency's smallest unit; tax_percent; amount_sats is kept for SATS plans and null for USD ones), max_payments / ends_at when the plan ends by itself, and current_period_end. Grant access here.
subscription.past_dueA renewal is overdue: an automatic payment failed (Zaptrain retries), or a pay-each-time renewal wasn't paid by its due day (it stays payable for a grace period). The subscriber is emailed either way; decide how long to keep access open.
subscription.cancelledCancelled by the subscriber, by you, after automatic renewals kept failing, when a pay-each-time renewal went unpaid, or because it reached the plan's end (cancel_reason: ended). Revoke access here. Each successful renewal arrives as invoice.paid with an invoice.subscription object.
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",
      "currency": "USD",
      "amount": 450,
      "sats_received": 6705,
      "amount_sats": 6705,
      "quote": { "amount_sats": 6705, "usd_rate": "67123.45", "rate_source": "coinbase_exchange", "rate_at": "2026-09-13T17:03:40Z" },
      "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.
    const inv = event.data.invoice; // amount is in inv.currency's smallest unit; sats_received is real sats
    markOrderPaid(inv.public_id, inv.amount, inv.currency);
  }
  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 run with Zaptrain's hourly renewal job and on live traffic, so an endpoint that was down for an hour gets its events within about an hour of coming back. The webhook is a notification, not the record: confirm with GET /api/v2/invoices/{id} or the checkout session before fulfilling, and keep the payment link as the customer's receipt.
Built on v1? Webhooks have one shape for every API version, so a v1 integration also receives invoice.paid for invoices priced in USD, and subscription.* events for subscribers to USD plans. Read currency first: for USD, amount is in cents and the invoice answers 404 on v1's GET /api/v1/invoices/{id}, so confirm it with GET /api/v2/invoices/{id} instead, or ignore it if you only sell in sats. amount_sats is always the sats that arrived, in either currency; on a USD plan's subscription events it is null, and the plan and subscription answer 404 on v1.
GET/api/v2/users/me

Get merchant profile

Returns the authenticated merchant's profile including branding settings and account info. default_pricing_currency is the merchant's default from Settings (read-only here) — use it if you want new invoices to follow it; the API never applies it for you.

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",
  "default_pricing_currency": "SATS"
}
PATCH/api/v2/users/me

Update merchant profile

Update brand name, brand color and default memo. Sending default_pricing_currency returns 400 read_only_field: the merchant changes it in Settings.

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",
  "default_pricing_currency": "SATS"
}
GET/api/v2/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/v2/invoices

List invoices

A page of the merchant's invoices in both currencies, newest first. Every payment is an invoice: one-time invoices, and each subscription payment (with a subscription object; null on one-time invoices). Filter with type, the same as the Invoices screen. A paid USD invoice carries its quote.

Parameters

ParameterInTypeRequiredDescription
limitqueryinteger—Max results to return (1–100, default 20)
offsetqueryinteger—Number of records to skip (default 0)
typequerystring—all (default), one_time, or recurring
subscriptionquerystring—A subscription id: only its payments

Examples

{
  "invoices": [
    {
      "id": 43,
      "public_id": "mK2nQpX9wP5rT7vB3cD1e",
      "status": "paid",
      "display_status": "paid",
      "customer_name": "Jane Smith",
      "customer_email": "jane@example.com",
      "currency": "USD",
      "amount": 12000,
      "tax_percent": 0,
      "tax_amount": 0,
      "sats_received": 178776,
      "fee_sats": 1788,
      "quote": {
        "amount_sats": 178776,
        "usd_rate": "67123.45",
        "rate_source": "coinbase_exchange",
        "rate_at": "2026-10-19T14:00:02Z"
      },
      "memo": "Logo design",
      "due_date": "2026-10-26",
      "expiry_date": null,
      "paid_at": "2026-10-19T14:05:11Z",
      "created_at": "2026-10-19T09:00:00Z",
      "updated_at": "2026-10-19T14:05:11Z",
      "subscription": null
    },
    {
      "id": 42,
      "public_id": "xK9mPqR3vN7wL2sT8uY4a",
      "status": "sent",
      "display_status": "sent",
      "customer_name": "Jane Smith",
      "customer_email": "jane@example.com",
      "currency": "SATS",
      "amount": 55000,
      "tax_percent": 10,
      "tax_amount": 0,
      "sats_received": null,
      "fee_sats": 550,
      "quote": null,
      "memo": "Web design work",
      "due_date": "2026-11-01",
      "expiry_date": null,
      "paid_at": null,
      "created_at": "2026-10-18T00:00:00Z",
      "updated_at": "2026-10-18T12:00:00Z",
      "subscription": null
    }
  ],
  "total": 23
}
POST/api/v2/invoices

Create invoice

Create an unsent invoice (status draft). currency is required; every price is a whole number in its smallest unit — sats for SATS, cents for USD (12000 = $120.00). A fraction is refused (invalid_amount), never rounded. A USD total must be at least $0.50 (below_minimum). v1 names (unit_price_sats, tax_amount_sats, amount_cents) return 400 legacy_field naming the v2 field. Nothing is emailed; for USD the sats are fixed when the customer opens the payment page.

Request Body

ParameterInTypeRequiredDescription
currencystringYes"SATS" or "USD" (any case). No default
itemsarrayYesLine items: { description, quantity (int ≥1, default 1), unit_price (int ≥0, smallest unit) }
tax_percentnumber—Tax percentage, 0–100 (default 0)
tax_amountinteger—Flat tax in the smallest unit (default 0)
customer_namestring—Customer name
customer_emailstring—Validated if given. Needed only for send/remind — otherwise share the payment link yourself
memostring—Invoice description/memo
due_datestring—Due date (YYYY-MM-DD)
expiry_datestring—Expiry date (YYYY-MM-DD), on or after the due date

Examples

{
  "id": 43,
  "public_id": "mK2nQpX9wP5rT7vB3cD1e",
  "status": "draft",
  "display_status": "draft",
  "customer_name": "Jane Smith",
  "customer_email": "jane@example.com",
  "currency": "USD",
  "amount": 12000,
  "tax_percent": 0,
  "tax_amount": 0,
  "sats_received": null,
  "fee_sats": null,
  "quote": null,
  "memo": "Logo design",
  "due_date": "2026-10-26",
  "expiry_date": null,
  "paid_at": null,
  "created_at": "2026-10-19T09:00:00Z",
  "updated_at": "2026-10-19T09:00:00Z",
  "subscription": null,
  "items": [
    {
      "description": "Logo design",
      "quantity": 1,
      "unit_price": 12000
    }
  ]
}
GET/api/v2/invoices/{id}

Get invoice

One invoice with its line items. sats_received is null until paid; quote is set only on a paid USD invoice. subscription is null for a one-time invoice; on a subscription payment it is { id, plan: { id, name }, period, payment_number, max_payments } (period counts from 0, payment_number from 1). Another merchant's invoice returns 404.

Parameters

ParameterInTypeRequiredDescription
idpathintegerYesInvoice internal ID (not public_id)

Examples

{
  "id": 42,
  "public_id": "xK9mPqR3vN7wL2sT8uY4a",
  "status": "sent",
  "display_status": "overdue",
  "customer_name": "Jane Smith",
  "customer_email": "jane@example.com",
  "currency": "SATS",
  "amount": 55000,
  "tax_percent": 10,
  "tax_amount": 0,
  "sats_received": null,
  "fee_sats": 550,
  "quote": null,
  "memo": "Web design work",
  "due_date": "2026-10-18",
  "expiry_date": null,
  "paid_at": null,
  "created_at": "2026-10-11T00:00:00Z",
  "updated_at": "2026-10-11T12:00:00Z",
  "subscription": null,
  "items": [
    {
      "description": "Design work",
      "quantity": 1,
      "unit_price": 50000
    }
  ]
}
POST/api/v2/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/v2/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/v2/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' }. A subscription payment (subscription is not null) can't be canceled either: it returns 409 { error: 'subscription_invoice' } — cancel the subscription instead. 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": "xK9mPqR3vN7wL2sT8uY4a",
    "status": "cancelled",
    "display_status": "cancelled",
    "currency": "SATS",
    "amount": 55000,
    "customer_name": "Jane Smith",
    "customer_email": "jane@example.com"
  }
}
GET/api/v2/invoices/{id}/payment

Get payment status

Whether an invoice is paid and what arrived: sats_received, fee_sats, paid_at, and for USD the quote it was paid at. The open Lightning request is never returned — send the customer to the payment page or a hosted checkout.

Parameters

ParameterInTypeRequiredDescription
idpathintegerYesInvoice internal ID

Examples

{
  "id": 43,
  "public_id": "mK2nQpX9wP5rT7vB3cD1e",
  "status": "paid",
  "currency": "USD",
  "amount": 12000,
  "sats_received": 178776,
  "fee_sats": 1788,
  "paid_at": "2026-10-19T14:05:11Z",
  "quote": {
    "amount_sats": 178776,
    "usd_rate": "67123.45",
    "rate_source": "coinbase_exchange",
    "rate_at": "2026-10-19T14:00:02Z"
  }
}
POST/api/v2/checkout/sessions

Create a checkout session

mode "payment" (the default) creates an invoice for line_items and a hosted payment page for it; currency is required and each unit_price is a whole number ≥ 1 in its smallest unit (sats, or cents). mode "subscription" creates a hosted page with a plan's subscribe form instead; currency is optional there and, if sent, must match the plan's (400 currency_mismatch). Redirect the customer to hosted_url; afterwards they are sent to your return_url with a signed query string. An archived or ended plan returns 409 { error: 'plan_unavailable' }; an unknown plan returns 404. Validation errors are the same as for invoices (currency_required, legacy_field, invalid_amount, below_minimum, usd_not_enabled).

Request Body

ParameterInTypeRequiredDescription
modestring—"payment" (default) or "subscription"
currencystring—Required in payment mode: "SATS" or "USD". Optional in subscription mode (must match the plan)
line_itemsarray—Required in payment mode, not allowed in subscription mode. 1–50 items: { description (≤500 chars), quantity (int ≥1), unit_price (int ≥1, smallest unit) }
planstring—Required in subscription mode: the plan id. The plan sets the price
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. In subscription mode it becomes the subscription's client_reference_id (echoed in its webhooks) and must be up to 128 letters, digits and _ . : @ -
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",
  "mode": "payment",
  "status": "open",
  "currency": "USD",
  "amount_total": 3600,
  "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-10-20T18:00:00.000Z",
  "completed_at": null,
  "cancelled_at": null,
  "created_at": "2026-10-19T18:00:00.000Z",
  "payment_status": "unpaid",
  "sats_received": null,
  "invoice": {
    "public_id": "k3Jd9sLq2mN8pQ4rT6vX1",
    "currency": "USD",
    "amount": 3600
  }
}
GET/api/v2/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. amount_total is what you charged (in currency's smallest unit); sats_received is what actually arrived. In subscription mode, "no_payment_required" means the customer subscribed to a plan that starts later and nothing was charged yet; subscription.id is the subscription to grant access for. 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",
  "mode": "payment",
  "status": "complete",
  "currency": "USD",
  "amount_total": 3600,
  "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-10-20T18:00:00.000Z",
  "completed_at": "2026-10-19T18:04:12.000Z",
  "cancelled_at": null,
  "created_at": "2026-10-19T18:00:00.000Z",
  "payment_status": "paid",
  "sats_received": 53634,
  "invoice": {
    "public_id": "k3Jd9sLq2mN8pQ4rT6vX1",
    "currency": "USD",
    "amount": 3600
  },
  "hosted_url": "https://zaptrain.com/checkout/V1StGXR8_Z5jdHi6B-myT"
}
GET/api/v2/plans

List subscription plans

Your plans, active and archived, with price, tax, schedule, start and end, payment methods, subscribe link and active subscriber count.

Examples

{
  "plans": [
    {
      "id": "O9yN2uK0xjoYgLQXUYBj",
      "name": "Daily Beans",
      "description": null,
      "currency": "SATS",
      "unit_price": 1000,
      "tax_percent": 5,
      "amount": 1050,
      "interval": "daily",
      "starts_on": null,
      "ends_on": null,
      "max_payments": 30,
      "payment_methods": {
        "automatic": true,
        "pay_each_time": true
      },
      "status": "active",
      "subscribe_url": "https://zaptrain.com/subscribe/O9yN2uK0xjoYgLQXUYBj",
      "active_subscribers": 4,
      "created_at": "2026-10-16T22:44:10.000Z"
    }
  ]
}
GET/api/v2/plans/{id}

Get a plan

One plan, in the same shape as GET /plans (without active_subscribers). An unknown id returns 404.

Parameters

ParameterInTypeRequiredDescription
idpathstringYesPlan id

Examples

{
  "id": "O9yN2uK0xjoYgLQXUYBj",
  "name": "Daily Beans",
  "description": null,
  "currency": "SATS",
  "unit_price": 1000,
  "tax_percent": 5,
  "amount": 1050,
  "interval": "daily",
  "starts_on": null,
  "ends_on": null,
  "max_payments": 30,
  "payment_methods": {
    "automatic": true,
    "pay_each_time": true
  },
  "status": "active",
  "subscribe_url": "https://zaptrain.com/subscribe/O9yN2uK0xjoYgLQXUYBj",
  "created_at": "2026-10-16T22:44:10.000Z"
}
POST/api/v2/plans

Create a plan

Same fields and rules as the Create plan screen, with the price as currency + unit_price (before tax); the response adds the computed amount (tax included). Set max_payments or ends_on, not both. Errors name the v2 field in field.

Request Body

ParameterInTypeRequiredDescription
namestringYesUp to 80 characters
currencystringYes"SATS" or "USD". Fixed once the plan exists. USD needs USD pricing switched on (400 usd_not_enabled) and a total of at least $0.50, tax included (400 below_minimum); subscribers pay in bitcoin at the rate when each payment is taken
unit_priceintegerYesPrice per period before tax, a whole number in the currency's smallest unit (sats, or cents: 1000 = $10.00). Sending amount_sats returns 400 legacy_field
intervalstringYesdaily, weekly, monthly or yearly
descriptionstring—Up to 280 characters; shown to subscribers
tax_percentnumber—Added to the price; subscribers pay the total
starts_onstring—YYYY-MM-DD (your timezone): joining earlier pays from this day
max_paymentsinteger—Ends after this many payments
ends_onstring—YYYY-MM-DD: no payments after this day
payment_methodsobject—{ automatic, pay_each_time } (both true by default)

Examples

{
  "id": "O9yN2uK0xjoYgLQXUYBj",
  "name": "Daily Beans",
  "description": null,
  "currency": "SATS",
  "unit_price": 1000,
  "tax_percent": 5,
  "amount": 1050,
  "interval": "daily",
  "starts_on": null,
  "ends_on": null,
  "max_payments": 30,
  "payment_methods": {
    "automatic": true,
    "pay_each_time": true
  },
  "status": "active",
  "subscribe_url": "https://zaptrain.com/subscribe/O9yN2uK0xjoYgLQXUYBj",
  "active_subscribers": 4,
  "created_at": "2026-10-16T22:44:10.000Z"
}
PATCH/api/v2/plans/{id}

Rename a plan

Only name and description can change. Subscribers agreed to the price, tax, schedule and dates: create a new plan for different terms.

Parameters

ParameterInTypeRequiredDescription
idpathstringYesPlan id

Request Body

ParameterInTypeRequiredDescription
namestring—New name (up to 80 characters)
descriptionstring—New description (up to 280 characters)

Examples

{
  "id": "O9yN2uK0xjoYgLQXUYBj",
  "name": "Daily Beans",
  "description": null,
  "currency": "SATS",
  "unit_price": 1000,
  "tax_percent": 5,
  "amount": 1050,
  "interval": "daily",
  "starts_on": null,
  "ends_on": null,
  "max_payments": 30,
  "payment_methods": {
    "automatic": true,
    "pay_each_time": true
  },
  "status": "active",
  "subscribe_url": "https://zaptrain.com/subscribe/O9yN2uK0xjoYgLQXUYBj",
  "active_subscribers": 4,
  "created_at": "2026-10-16T22:44:10.000Z"
}
POST/api/v2/plans/{id}/archive

Archive a plan

Stops new sign-ups. Existing subscribers keep paying until cancelled. Archiving an archived plan returns 409 not_allowed.

Parameters

ParameterInTypeRequiredDescription
idpathstringYesPlan id

Examples

{
  "id": "O9yN2uK0xjoYgLQXUYBj",
  "name": "Daily Beans",
  "description": null,
  "currency": "SATS",
  "unit_price": 1000,
  "tax_percent": 5,
  "amount": 1050,
  "interval": "daily",
  "starts_on": null,
  "ends_on": null,
  "max_payments": 30,
  "payment_methods": {
    "automatic": true,
    "pay_each_time": true
  },
  "status": "archived",
  "subscribe_url": "https://zaptrain.com/subscribe/O9yN2uK0xjoYgLQXUYBj",
  "active_subscribers": 4,
  "created_at": "2026-10-16T22:44:10.000Z"
}
GET/api/v2/subscriptions

List subscriptions

Newest first, in the same shape as the subscription.* webhooks. Each subscription's payments are invoices: GET /invoices?subscription={id}.

Parameters

ParameterInTypeRequiredDescription
planquerystring—Only this plan's subscribers
statusquerystring—active, past_due or cancelled
limitqueryinteger—1–100, default 20
offsetqueryinteger—Default 0

Examples

{
  "subscriptions": [
    {
      "id": "aB3dE5fG7hJ9kL1mN3pQ",
      "client_reference_id": "user_42",
      "status": "active",
      "payment_method": "automatic",
      "plan": {
        "id": "O9yN2uK0xjoYgLQXUYBj",
        "name": "Daily Beans"
      },
      "customer_name": "Ada",
      "customer_email": "ada@example.com",
      "currency": "SATS",
      "amount": 1050,
      "amount_sats": 1050,
      "tax_percent": 5,
      "interval": "daily",
      "periods_paid": 3,
      "max_payments": 30,
      "ends_at": null,
      "current_period_end": "2026-10-20T09:00:00.000Z",
      "last_error_code": null,
      "cancel_reason": null,
      "created_at": "2026-10-17T09:00:00.000Z",
      "cancelled_at": null
    }
  ],
  "total": 1
}
GET/api/v2/subscriptions/{id}

Get a subscription

One subscription, in the same shape as GET /subscriptions and the subscription.* webhooks. Its payments are invoices: GET /invoices?subscription={id}. An unknown id returns 404.

Parameters

ParameterInTypeRequiredDescription
idpathstringYesSubscription id

Examples

{
  "id": "aB3dE5fG7hJ9kL1mN3pQ",
  "client_reference_id": "user_42",
  "status": "active",
  "payment_method": "automatic",
  "plan": {
    "id": "O9yN2uK0xjoYgLQXUYBj",
    "name": "Daily Beans"
  },
  "customer_name": "Ada",
  "customer_email": "ada@example.com",
  "currency": "SATS",
  "amount": 1050,
  "amount_sats": 1050,
  "tax_percent": 5,
  "interval": "daily",
  "periods_paid": 3,
  "max_payments": 30,
  "ends_at": null,
  "current_period_end": "2026-10-20T09:00:00.000Z",
  "last_error_code": null,
  "cancel_reason": null,
  "created_at": "2026-10-17T09:00:00.000Z",
  "cancelled_at": null
}
POST/api/v2/subscriptions/{id}/cancel

Cancel a subscription

The subscriber is emailed, no more payments are taken, and Zaptrain deletes its wallet connection.

Parameters

ParameterInTypeRequiredDescription
idpathstringYesSubscription id

Examples

{
  "id": "aB3dE5fG7hJ9kL1mN3pQ",
  "client_reference_id": "user_42",
  "status": "cancelled",
  "payment_method": "automatic",
  "plan": {
    "id": "O9yN2uK0xjoYgLQXUYBj",
    "name": "Daily Beans"
  },
  "customer_name": "Ada",
  "customer_email": "ada@example.com",
  "currency": "SATS",
  "amount": 1050,
  "amount_sats": 1050,
  "tax_percent": 5,
  "interval": "daily",
  "periods_paid": 3,
  "max_payments": 30,
  "ends_at": null,
  "current_period_end": null,
  "last_error_code": null,
  "cancel_reason": "merchant",
  "created_at": "2026-10-17T09:00:00.000Z",
  "cancelled_at": "2026-10-21T10:00:00.000Z"
}
POST/api/v2/subscriptions/{id}/retry

Retry a past-due payment

Automatic subscriptions only; once per 30 seconds per subscription.

Parameters

ParameterInTypeRequiredDescription
idpathstringYesSubscription id

Examples

{
  "outcome": "paid",
  "code": null,
  "subscription": {
    "id": "aB3dE5fG7hJ9kL1mN3pQ",
    "client_reference_id": "user_42",
    "status": "active",
    "payment_method": "automatic",
    "plan": {
      "id": "O9yN2uK0xjoYgLQXUYBj",
      "name": "Daily Beans"
    },
    "customer_name": "Ada",
    "customer_email": "ada@example.com",
    "currency": "SATS",
    "amount": 1050,
    "amount_sats": 1050,
    "tax_percent": 5,
    "interval": "daily",
    "periods_paid": 3,
    "max_payments": 30,
    "ends_at": null,
    "current_period_end": "2026-10-20T09:00:00.000Z",
    "last_error_code": null,
    "cancel_reason": null,
    "created_at": "2026-10-17T09:00:00.000Z",
    "cancelled_at": null
  }
}
GET/api/v2/invites

List invites

Invites you've emailed, newest first. Open invites (sent) by default, as the Subscriptions screen lists them. subscription is the id of the subscription an invite led to, once the customer subscribes.

Parameters

ParameterInTypeRequiredDescription
statusquerystring—sent (default), subscribed, removed or all

Examples

{
  "invites": [
    {
      "id": "Zq8xY2wV4uT6sR8pN0mL",
      "plan": {
        "id": "O9yN2uK0xjoYgLQXUYBj",
        "name": "Daily Beans"
      },
      "customer_name": "Bo",
      "customer_email": "bo@example.com",
      "note": null,
      "status": "sent",
      "subscription": null,
      "sent_at": "2026-10-17T10:00:00.000Z",
      "created_at": "2026-10-17T10:00:00.000Z"
    }
  ]
}
POST/api/v2/invites

Email a plan to a customer

Emails the plan's subscribe link with their details filled in (Send to customer). Sending to the same customer again re-sends it; refused with 409 already_subscribed if they're already subscribed, and 502 email_failed if the email couldn't be sent.

Request Body

ParameterInTypeRequiredDescription
planstringYesPlan id
customer_namestringYesUp to 100 characters
customer_emailstringYesWhere the invite goes
notestring—Included in the email (≤500 chars)

Examples

{
  "id": "Zq8xY2wV4uT6sR8pN0mL",
  "plan": {
    "id": "O9yN2uK0xjoYgLQXUYBj",
    "name": "Daily Beans"
  },
  "customer_name": "Bo",
  "customer_email": "bo@example.com",
  "note": null,
  "status": "sent",
  "subscription": null,
  "sent_at": "2026-10-17T10:00:00.000Z",
  "created_at": "2026-10-17T10:00:00.000Z"
}
POST/api/v2/invites/{id}/resend

Re-send an invite

Emails an open invite again. Only open (sent) invites qualify; others return 404. 409 already_subscribed if the customer has subscribed since; 502 email_failed if the email couldn't be sent.

Parameters

ParameterInTypeRequiredDescription
idpathstringYesInvite id

Examples

{
  "id": "Zq8xY2wV4uT6sR8pN0mL",
  "plan": {
    "id": "O9yN2uK0xjoYgLQXUYBj",
    "name": "Daily Beans"
  },
  "customer_name": "Bo",
  "customer_email": "bo@example.com",
  "note": null,
  "status": "sent",
  "subscription": null,
  "sent_at": "2026-10-17T10:00:00.000Z",
  "created_at": "2026-10-17T10:00:00.000Z"
}
POST/api/v2/invites/{id}/remove

Remove an invite

Takes an open invite off the list. The emailed link is the plan's public link, so it keeps working; it just isn't tracked. Only open (sent) invites qualify; others return 404.

Parameters

ParameterInTypeRequiredDescription
idpathstringYesInvite id

Examples

{
  "id": "Zq8xY2wV4uT6sR8pN0mL",
  "plan": {
    "id": "O9yN2uK0xjoYgLQXUYBj",
    "name": "Daily Beans"
  },
  "customer_name": "Bo",
  "customer_email": "bo@example.com",
  "note": null,
  "status": "removed",
  "subscription": null,
  "sent_at": "2026-10-17T10:00:00.000Z",
  "created_at": "2026-10-17T10:00:00.000Z"
}

Need help? Contact us.

API documentation · Zaptrain