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)
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
- Your server creates a session with
POST /api/v2/checkout/sessionsand gets back ahosted_url. - You redirect the customer's browser to that URL. They scan the QR with any Lightning wallet.
- Zaptrain sends them to your
return_urlwith?session_id=…&status=paid&ts=…&signature=…(status=no_payment_requiredfor a subscription to a plan that starts later). - 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");
});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
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.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.cancel_reason: ended). Revoke access here. Each successful renewal arrives as invoice.paid with an invoice.subscription object.{
"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
});GET /api/v2/invoices/{id} or the checkout session before fulfilling, and keep the payment link as the customer's receipt.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./api/v2/users/meGet 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"
}/api/v2/users/meUpdate 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
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| brand_name | string | — | Display name for invoices | |
| brand_color | string | — | Hex color (e.g., #d26434) | |
| default_memo | string | — | 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"
}/api/v2/users/me/usageGet 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"
}/api/v2/invoicesList 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
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| limit | query | integer | — | Max results to return (1–100, default 20) |
| offset | query | integer | — | Number of records to skip (default 0) |
| type | query | string | — | all (default), one_time, or recurring |
| subscription | query | string | — | 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
}/api/v2/invoicesCreate 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
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| currency | string | Yes | "SATS" or "USD" (any case). No default | |
| items | array | Yes | Line items: { description, quantity (int ≥1, default 1), unit_price (int ≥0, smallest unit) } | |
| tax_percent | number | — | Tax percentage, 0–100 (default 0) | |
| tax_amount | integer | — | Flat tax in the smallest unit (default 0) | |
| customer_name | string | — | Customer name | |
| customer_email | string | — | Validated if given. Needed only for send/remind — otherwise share the payment link yourself | |
| memo | string | — | Invoice description/memo | |
| due_date | string | — | Due date (YYYY-MM-DD) | |
| expiry_date | string | — | 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
}
]
}/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
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | integer | Yes | Invoice 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
}
]
}/api/v2/invoices/{id}/sendSend 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
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | integer | Yes | Invoice internal ID |
Request Body
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| subject | string | — | Custom email subject | |
| message | string | — | Custom message to include in email body |
Examples
{
"ok": true,
"status": "sent"
}/api/v2/invoices/{id}/remindSend 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
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | integer | Yes | Invoice internal ID |
Request Body
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| mode | string | — | 'same' to resend original, 'new' to regenerate (default 'new') | |
| subject | string | — | Custom email subject (mode='new' only) | |
| message | string | — | Custom message (mode='new' only) |
Examples
{
"ok": true,
"status": "sent"
}/api/v2/invoices/{id}/cancelCancel 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
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | integer | Yes | Invoice 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"
}
}/api/v2/invoices/{id}/paymentGet 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
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | integer | Yes | Invoice 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"
}
}/api/v2/checkout/sessionsCreate 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
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| mode | string | — | "payment" (default) or "subscription" | |
| currency | string | — | Required in payment mode: "SATS" or "USD". Optional in subscription mode (must match the plan) | |
| line_items | array | — | Required in payment mode, not allowed in subscription mode. 1–50 items: { description (≤500 chars), quantity (int ≥1), unit_price (int ≥1, smallest unit) } | |
| plan | string | — | Required in subscription mode: the plan id. The plan sets the price | |
| return_url | string | Yes | https:// URL (http://localhost allowed) the customer returns to after paying; ≤2048 chars | |
| cancel_url | string | Yes | URL for the customer's Cancel link on the hosted page | |
| customer_email | string | — | Pre-fills the invoice's customer | |
| client_reference_id | string | — | 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 _ . : @ - | |
| metadata | object | — | Up to 50 keys, ≤8 KB; returned on retrieve | |
| expires_in | integer | — | 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
}
}/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
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| id | string | Yes | Session 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"
}/api/v2/plansList 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"
}
]
}/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
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | Yes | Plan 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"
}/api/v2/plansCreate 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
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| name | string | Yes | Up to 80 characters | |
| currency | string | Yes | "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_price | integer | Yes | Price 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 | |
| interval | string | Yes | daily, weekly, monthly or yearly | |
| description | string | — | Up to 280 characters; shown to subscribers | |
| tax_percent | number | — | Added to the price; subscribers pay the total | |
| starts_on | string | — | YYYY-MM-DD (your timezone): joining earlier pays from this day | |
| max_payments | integer | — | Ends after this many payments | |
| ends_on | string | — | YYYY-MM-DD: no payments after this day | |
| payment_methods | object | — | { 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"
}/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
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | Yes | Plan id |
Request Body
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| name | string | — | New name (up to 80 characters) | |
| description | string | — | 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"
}/api/v2/plans/{id}/archiveArchive a plan
Stops new sign-ups. Existing subscribers keep paying until cancelled. Archiving an archived plan returns 409 not_allowed.
Parameters
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | Yes | Plan 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"
}/api/v2/plans/{id}/subscribe-linkGet a subscribe link
The plan's link with a customer's name and email filled in, and your own id as client_reference_id. Nothing is emailed. The customer finishes subscribing themselves.
Parameters
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | Yes | Plan id |
Request Body
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| customer_name | string | — | Pre-fills the form | |
| customer_email | string | — | Pre-fills the form | |
| client_reference_id | string | — | Your id for them, echoed in webhooks |
Examples
{
"url": "https://zaptrain.com/subscribe/O9yN2uK0xjoYgLQXUYBj?name=Ada&email=ada%40example.com&ref=user_42"
}/api/v2/subscriptionsList subscriptions
Newest first, in the same shape as the subscription.* webhooks. Each subscription's payments are invoices: GET /invoices?subscription={id}.
Parameters
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| plan | query | string | — | Only this plan's subscribers |
| status | query | string | — | active, past_due or cancelled |
| limit | query | integer | — | 1–100, default 20 |
| offset | query | integer | — | 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
}/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
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | Yes | Subscription 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
}/api/v2/subscriptions/{id}/cancelCancel a subscription
The subscriber is emailed, no more payments are taken, and Zaptrain deletes its wallet connection.
Parameters
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | Yes | Subscription 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"
}/api/v2/subscriptions/{id}/retryRetry a past-due payment
Automatic subscriptions only; once per 30 seconds per subscription.
Parameters
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | Yes | Subscription 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
}
}/api/v2/invitesList 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
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| status | query | string | — | 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"
}
]
}/api/v2/invitesEmail 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
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| plan | string | Yes | Plan id | |
| customer_name | string | Yes | Up to 100 characters | |
| customer_email | string | Yes | Where the invite goes | |
| note | string | — | 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"
}/api/v2/invites/{id}/resendRe-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
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | Yes | Invite 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"
}/api/v2/invites/{id}/removeRemove 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
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | Yes | Invite 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.