callCash()by riverrr
Get early access
Open as .md

Framework examples

Next.js (App Router)#

The client, server only:

TypeScriptlib/cash.ts
// lib/cash.ts (import this only from route handlers and other server code)
import { CallCash } from "callcash";
import { firestoreStore } from "callcash/firestore";
import { db } from "./firebase-admin";

export const cash = new CallCash({
  gateways: {
    yoco: { secretKey: process.env.YOCO_SECRET_KEY!, webhookSecret: process.env.YOCO_WEBHOOK_SECRET },
    netcash: { serviceKey: process.env.NETCASH_PAYNOW_KEY!, test: process.env.NETCASH_TEST === "1" },
  },
  routes: { card: ["yoco", "netcash"], default: ["netcash"] },
  store: firestoreStore(db),
  returnUrl: `${process.env.APP_URL}/paid`,
});

Start a payment:

TypeScriptapp/api/pay/route.ts
// app/api/pay/route.ts
import { NextResponse } from "next/server";
import { isCallCashError } from "callcash";
import { cash } from "@/lib/cash";

export async function POST(req: Request) {
  const { orderId } = await req.json();
  const order = await loadOrder(orderId); // your code: the price comes from your database, never the browser

  try {
    const p = await cash.payments.create({ amount: order.total, reference: order.id, email: order.email, metadata: { order: order.id } });
    return NextResponse.json(p.checkout_url ? { url: p.checkout_url } : { form: p.checkout_form });
  } catch (e) {
    if (isCallCashError(e)) return NextResponse.json({ error: e.code }, { status: e.status });
    throw e;
  }
}

In the browser, redirect to url, or post form (the postToNetcash function on the Netcash page).

The webhooks:

TypeScriptapp/api/yoco/webhook/route.ts
// app/api/yoco/webhook/route.ts
import { cash } from "@/lib/cash";
import { onCash } from "@/lib/on-cash";

export async function POST(req: Request) {
  return (await cash.webhooks.handle("yoco", req, onCash)).response;
}
TypeScriptapp/api/netcash/notify/route.ts
// app/api/netcash/notify/route.ts
import { cash } from "@/lib/cash";
import { onCash } from "@/lib/on-cash";

export async function POST(req: Request) {
  return (await cash.webhooks.handle("netcash", req, onCash)).response;
}

The handler:

TypeScriptlib/on-cash.ts
// lib/on-cash.ts
import type { CashEvent } from "callcash";

export async function onCash(event: CashEvent) {
  if (event.type === "payment.paid") await markOrderPaid(event.reference); // a no-op if already paid
}

The return page shows "confirming your payment" and reads the order's status from your database. It never marks anything paid.

Express#

webhooks.handle takes a standard Request. Give Express the raw body on the webhook routes and build one:

TypeScript
import express from "express";
import { cash } from "./cash.js";
import { onCash } from "./on-cash.js";

const app = express();

function toRequest(req: express.Request): Request {
  const headers = new Headers();
  for (const [k, v] of Object.entries(req.headers)) if (typeof v === "string") headers.set(k, v);
  return new Request(`https://${req.get("host")}${req.originalUrl}`, { method: "POST", headers, body: req.body });
}

async function send(res: express.Response, response: Response) {
  res.status(response.status).type("application/json").send(await response.text());
}

// raw body: the Yoco signature is checked against the exact bytes
app.post("/webhooks/yoco", express.raw({ type: "*/*" }), async (req, res) => {
  await send(res, (await cash.webhooks.handle("yoco", toRequest(req), onCash)).response);
});

app.post("/webhooks/netcash", express.raw({ type: "*/*" }), async (req, res) => {
  await send(res, (await cash.webhooks.handle("netcash", toRequest(req), onCash)).response);
});

app.post("/pay", express.json(), async (req, res) => {
  const order = await loadOrder(req.body.orderId);
  const p = await cash.payments.create({ amount: order.total, reference: order.id });
  res.json(p.checkout_url ? { url: p.checkout_url } : { form: p.checkout_form });
});

Register the webhook routes before any global express.json(), or the body will already be parsed and the Yoco signature will not match.