← Developers
Example shop in one file
A complete integration with no dependencies (Node.js 18+): a checkout page with the SplitPay button, a server route that creates the group payment with your secret key, and a webhook handler that confirms the booking on group_payment.paid. We run this exact file in our production tests.
- In your dashboard under Developers, create a test secret key and a webhook endpoint pointing to
https://your-url/webhooks/splitpay. - Save the file as server.mjs and start it:
terminal
SPLITPAY_SECRET_KEY=sp_test_… \ SPLITPAY_WEBHOOK_SECRET=whsec_… \ PUBLIC_URL=https://your-public-url \ node server.mjs
Webhooks need a public URL. Locally, a tunnel works: cloudflared tunnel --url http://localhost:4242
server.mjs
/**
* SplitPay example merchant: a complete integration in one file, no dependencies.
* Node 18+.
*
* SPLITPAY_SECRET_KEY=sp_test_… SPLITPAY_WEBHOOK_SECRET=whsec_… PUBLIC_URL=https://your-shop.example node server.mjs
*
* 1. GET / checkout page with the "Pay with SplitPay" button (sdk.js)
* 2. POST /api/splitpay/checkout YOUR server creates the group payment (secret key stays here)
* 3. POST /webhooks/splitpay verifies the signature, confirms the booking on group_payment.paid
* 4. GET /booking/:reference order status page (success_url)
*/
import http from "node:http";
import crypto from "node:crypto";
const PORT = Number(process.env.PORT ?? 4242);
const SPLITPAY = (process.env.SPLITPAY_BASE ?? "https://splitpay.pro").replace(/\/$/, "");
const SECRET_KEY = process.env.SPLITPAY_SECRET_KEY; // sp_test_… or sp_live_…, server only
const WEBHOOK_SECRET = process.env.SPLITPAY_WEBHOOK_SECRET; // whsec_… from Developers › Webhooks
const PUBLIC_URL = (process.env.PUBLIC_URL ?? `http://localhost:${PORT}`).replace(/\/$/, "");
if (!SECRET_KEY) throw new Error("Set SPLITPAY_SECRET_KEY");
// Your own data. The price always comes from here, never from the browser.
const bookings = new Map(); // reference -> { amount, description, guests, status, groupPaymentId, events: [] }
const processedEvents = new Set(); // in production: a table with a UNIQUE event id
function newBooking() {
const reference = `BOOKING-${crypto.randomInt(10000, 99999)}`;
bookings.set(reference, { amount: 120000, description: "Val Thorens Ski Trip, 4 nights", guests: 4, status: "pending", groupPaymentId: null, events: [] });
return reference;
}
async function createGroupPayment(reference) {
const b = bookings.get(reference);
// Idempotency-Key: a double click, a retry after a timeout or two tabs can never create
// two group payments for this booking; SplitPay returns the first result again.
const res = await fetch(`${SPLITPAY}/api/v1/group-payments`, {
method: "POST",
headers: { Authorization: `Bearer ${SECRET_KEY}`, "Content-Type": "application/json", "Idempotency-Key": `booking-${reference}` },
body: JSON.stringify({
reference,
amount: b.amount,
currency: "EUR",
description: b.description,
participants: b.guests,
success_url: `${PUBLIC_URL}/booking/${reference}`,
cancel_url: `${PUBLIC_URL}/`,
webhook_url: `${PUBLIC_URL}/webhooks/splitpay`,
}),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error?.message ?? `SplitPay ${res.status}`);
b.groupPaymentId = json.id;
b.status = "awaiting_payment";
return json.checkout_url;
}
function verifySignature(header, rawBody) {
if (!WEBHOOK_SECRET || !header) return false;
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=", 2)));
if (!parts.t || !parts.v1 || Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto.createHmac("sha256", WEBHOOK_SECRET).update(`${parts.t}.${rawBody}`).digest("hex");
return expected.length === parts.v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
const escape = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
function page(title, body) {
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(title)}</title>
<style>body{font-family:system-ui,sans-serif;max-width:560px;margin:40px auto;padding:0 16px;color:#0f172a}.card{border:1px solid #e2e8f0;border-radius:16px;padding:20px;margin:16px 0}
button{width:100%;padding:14px;border-radius:12px;border:0;background:#0f172a;color:#fff;font-size:16px;font-weight:600;cursor:pointer}button[aria-busy=true]{opacity:.6}.muted{color:#64748b;font-size:14px}</style></head><body>${body}</body></html>`;
}
function checkoutPage() {
const reference = newBooking();
const b = bookings.get(reference);
return page(
"Checkout",
`<h1>Checkout</h1>
<div class="card"><strong>${escape(b.description)}</strong><p class="muted">${b.guests} guests · ${escape(reference)}</p><p><strong>€${(b.amount / 100).toFixed(2)}</strong></p></div>
<script src="${SPLITPAY}/sdk/v1.js"></script>
<button data-splitpay data-endpoint="/api/splitpay/checkout" data-reference="${escape(reference)}">Pay with SplitPay</button>
<p class="muted">Let everyone pay their own share.</p>
<p id="error" class="muted" style="color:#b91c1c"></p>
<script>document.addEventListener("splitpay:error", (e) => { document.getElementById("error").textContent = e.detail.message; });</script>`,
);
}
function bookingPage(reference) {
const b = bookings.get(reference);
if (!b) return null;
const label = b.status === "confirmed" ? "Booking confirmed ✓" : b.status === "awaiting_payment" ? "Waiting for your group to pay…" : b.status;
return page(
"Booking",
`<h1 id="status" data-status="${escape(b.status)}">${escape(label)}</h1><p class="muted">${escape(reference)}</p>
<div class="card"><strong>Webhooks received</strong><ul>${b.events.map((e) => `<li>${escape(e)}</li>`).join("")}</ul></div>
${b.status === "confirmed" ? "" : "<script>setTimeout(() => location.reload(), 3000)</script>"}`,
);
}
function readBody(req) {
return new Promise((resolve, reject) => {
let data = "";
req.on("data", (c) => {
data += c;
if (data.length > 1e6) req.destroy();
});
req.on("end", () => resolve(data));
req.on("error", reject);
});
}
function send(res, status, body, type = "text/html; charset=utf-8") {
res.writeHead(status, { "Content-Type": type });
res.end(body);
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, PUBLIC_URL);
try {
if (req.method === "GET" && url.pathname === "/") return send(res, 200, checkoutPage());
if (req.method === "POST" && url.pathname === "/api/splitpay/checkout") {
const { reference } = JSON.parse((await readBody(req)) || "{}");
if (!bookings.has(reference)) return send(res, 404, JSON.stringify({ error: { message: "Booking not found" } }), "application/json");
const checkoutUrl = await createGroupPayment(reference);
return send(res, 200, JSON.stringify({ checkout_url: checkoutUrl }), "application/json");
}
if (req.method === "POST" && url.pathname === "/webhooks/splitpay") {
const raw = await readBody(req);
if (!verifySignature(req.headers["splitpay-signature"], raw)) return send(res, 400, "invalid signature", "text/plain");
const event = JSON.parse(raw);
if (processedEvents.has(event.id)) {
const dup = bookings.get(event.reference);
if (dup) dup.duplicatesIgnored = (dup.duplicatesIgnored ?? 0) + 1;
return send(res, 200, "duplicate", "text/plain");
}
processedEvents.add(event.id);
const b = bookings.get(event.reference);
if (b) {
b.events.push(event.type);
if (event.type === "group_payment.paid") b.status = "confirmed";
if (event.type === "group_payment.expired" || event.type === "group_payment.cancelled") b.status = event.type.split(".")[1];
}
return send(res, 200, "ok", "text/plain");
}
const m = url.pathname.match(/^\/booking\/([A-Z0-9-]+)$/);
if (req.method === "GET" && m) return send(res, bookings.has(m[1]) ? 200 : 404, bookingPage(m[1]) ?? "Not found");
if (req.method === "GET" && url.pathname.startsWith("/api/bookings/")) {
const b = bookings.get(url.pathname.split("/").pop());
return send(res, b ? 200 : 404, JSON.stringify(b ? { status: b.status, events: b.events, groupPaymentId: b.groupPaymentId, duplicatesIgnored: b.duplicatesIgnored ?? 0 } : { error: "not found" }), "application/json");
}
send(res, 404, "Not found", "text/plain");
} catch (err) {
console.error(err.message);
send(res, 500, JSON.stringify({ error: { message: "Something went wrong" } }), "application/json");
}
});
server.listen(PORT, () => console.log(`Example shop on ${PUBLIC_URL} (port ${PORT}), SplitPay: ${SPLITPAY}`));