← 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.

  1. In your dashboard under Developers, create a test secret key and a webhook endpoint pointing to https://your-url/webhooks/splitpay.
  2. 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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}`));