# callCash docs # Introduction callCash is one client for South African payments. You call one function, and it talks to Yoco or Netcash for you, checks every payment with the gateway before you hear about it, and hands your code the same event shape whichever gateway took the money. ```ts import { callCash } from "callcash"; const payment = await callCash({ amount: 250, reference: "order_1042" }); ``` callCash never holds money. Your customer pays your gateway, and your gateway pays your bank, exactly as it does today. ## What works today The `callcash` package, version 0.1.0. It runs **inside your own server**, on **your own** Yoco and Netcash accounts. There is no callCash server in the path. - **Yoco** (cards) and **Netcash Pay Now** (cards, instant EFT, bank EFT, cash at a shop, vouchers). - A built-in **test gateway** that needs no account and moves no money. - Payments, refunds, verified webhooks, exactly-once events, routing and failover between your gateways. ## What is planned - A hosted callCash API with `cc_test_` and `cc_live_` keys. The [API page](https://callcash.riverrr.co.za/api) describes it. It is not built yet. - A hosted checkout page with your name and colours. - Ozow, Peach Payments, iKhokha, PayFast and Paystack. - PHP and Python packages. ## Before you go live The SDK passes its own tests against stand-in gateways, but it has **not yet run against the real Yoco or Netcash**. We are proving it inside our own apps first. Read [Status](https://callcash.riverrr.co.za/docs/status) for exactly what is verified, what is assumed and what is not built. ## Where to go next - [Quickstart](https://callcash.riverrr.co.za/docs/quickstart): a test payment in a few lines, no account. - [Install](https://callcash.riverrr.co.za/docs/install): it is not on npm yet, so this matters. - [Yoco](https://callcash.riverrr.co.za/docs/yoco) and [Netcash](https://callcash.riverrr.co.za/docs/netcash): connect your real accounts. - [Use with AI agents](https://callcash.riverrr.co.za/docs/ai-agents): Claude Code, Codex, Cursor and Gemini CLI. --- # Quickstart The test gateway is built in. It needs no gateway account and moves no money, so you can write and test your payment code today. ## 1. Install The package is not on npm yet. See [Install](https://callcash.riverrr.co.za/docs/install) for how to add it from the repo. ## 2. Create a payment ```ts import { CallCash } from "callcash"; const cash = new CallCash(); // no config: the test gateway const payment = await cash.payments.create({ amount: 250, reference: "order_1" }); // payment.status === "pending", payment.gateway === "test" ``` `amount` is rand: `250` means R250.00. `reference` is your own order number. ## 3. Decide what happens The test gateway has no checkout page: `checkout_url` and `checkout_form` are both `null`. You decide the outcome in code instead. ```ts async function onCash(event) { if (event.type === "payment.paid") console.log("fulfil", event.reference, event.amount); } await cash.test.pay(payment.id, onCash); // your handler gets payment.paid ``` Other outcomes to rehearse, each on a fresh payment: ```ts await cash.test.fail(id, onCash, "insufficient funds"); // payment.failed await cash.test.pay(id, onCash, { amount: 2.5 }); // the gateway confirms the wrong total: payment.mismatch await cash.test.pay(id, onCash, { method: "instant_eft" }); // paid by instant EFT ``` ## 4. Read it back ```ts const p = await cash.payments.get(payment.id); // p.status === "paid", p.verified === true, p.paid_at is set ``` ## What the test gateway does not do - It keeps payments in memory. A restart forgets them. - It sends no webhooks. `cash.test.pay()` stands in for the webhook. - It shows no checkout page. To see a real checkout, use [Yoco](https://callcash.riverrr.co.za/docs/yoco) test keys or [Netcash](https://callcash.riverrr.co.za/docs/netcash) test mode. - Refunds on test payments succeed straight away. ## Next When your handler does the right thing for paid, failed and mismatch, connect a real gateway: [Configuration](https://callcash.riverrr.co.za/docs/configuration). --- # Install **callcash is not published to npm yet.** `npm i callcash` will not work until it is. Until then, install it from the callCash repo. ## Requirements - Node.js 20 or later. - An ES module project, or `import()` from CommonJS. The package is ESM only. - Server-side code. It uses `node:crypto`, so never import it into a client component or a browser bundle. - No runtime dependencies. ## From a folder next to your app ```bash cd callcash/sdk npm install npm run build # writes dist/, which the package points at cd ../../your-app npm i ../callcash/sdk ``` npm links the folder, so rebuilding `sdk/` updates your app. Run `npm run build` in `sdk/` after every pull. ## As a tarball ```bash cd callcash/sdk npm install && npm run build npm pack # writes callcash-0.1.0.tgz cd ../../your-app npm i ../callcash/sdk/callcash-0.1.0.tgz ``` Use this when you want a fixed copy, for example on a deploy server. ## Once it is on npm ```bash npm i callcash ``` ## Imports ```ts import { callCash, CallCash, configure, CallCashError, isCallCashError, MemoryStore } from "callcash"; import { firestoreStore } from "callcash/firestore"; ``` The types ship with the package (`dist/index.d.ts`), so your editor shows what every field means. ## For your coding agent ```bash npx callcash agents ``` Adds callCash's rules to your agent's instructions file. See [Use with AI agents](https://callcash.riverrr.co.za/docs/ai-agents). ## Check the package itself ```bash cd callcash/sdk npm test # builds, then runs the tests against stand-in gateways ``` --- # Configuration ## Three ways to run it | Config | What happens | Money | |---|---|---| | nothing | The built-in test gateway, in memory. | None moves | | `gateways` and `store` | callCash runs in your server on your own Yoco and Netcash accounts. | Customer pays your gateway, your gateway pays your bank | | `key: "cc_test_…"` | A client for the hosted API. | **Planned.** The server does not exist yet | Giving both `key` and `gateways` throws `config_invalid`. ## Your own gateways ```ts // lib/cash.ts (server only) import { CallCash } from "callcash"; import { firestoreStore } from "callcash/firestore"; 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: "https://example.co.za/paid", }); ``` ## Options | Option | Type | What it does | |---|---|---| | `gateways.yoco.secretKey` | string | Your Yoco secret key. `sk_test_` runs in test mode, anything else in live mode. | | `gateways.yoco.webhookSecret` | string | The `whsec_` secret from registering your Yoco webhook. Needed to accept Yoco events. | | `gateways.netcash.serviceKey` | string | Your Pay Now service key. | | `gateways.netcash.test` | boolean | `true` while "Make test mode active" is ticked in your Netcash dashboard. Marks payments as test. | | `gateways.netcash.vendorKey` | string | Your software vendor key if you are a Netcash ISV. Netcash's default is used otherwise. | | `routes` | object | Which gateways to try, in order, per way of paying. See [Payments](https://callcash.riverrr.co.za/docs/payments#routing). | | `store` | `Store` | Where payments are kept. **Required with real gateways.** | | `returnUrl` | string | Where customers land after paying. Yoco needs it. | | `key` | string | A `cc_test_` or `cc_live_` key for the hosted API. Planned. | | `baseUrl` | string | The hosted API's address. Planned. | | `fetch` | function | Replace `fetch`, for tests. | | `now` | function | Replace the clock, for tests. | A gateway named in `routes` but missing from `gateways` throws `config_invalid` when the client is created. ## The store Real gateways need a store that outlives your process, or the constructor throws `config_invalid`. The store is what makes a payment count once when a gateway sends the same notification twice. - **Firestore:** `firestoreStore(db, collection?)` from `callcash/firestore`. One document per payment, in `callcashPayments` unless you name another collection. It works with `firebase-admin`'s Firestore without depending on it, and needs no composite index. - **Your own:** implement the `Store` interface (`create`, `get`, `find`, `update`, `list`). `update` must read, change and write as one atomic step, for example in a database transaction. - **`MemoryStore`:** for tests only. A restart forgets everything. ## callCash() and configure() `callCash(input)` is a shortcut for `client().payments.create(input)`. Set the client it uses once, where your server starts: ```ts import { configure, callCash } from "callcash"; configure({ gateways: { yoco: { secretKey: process.env.YOCO_SECRET_KEY! } }, store, returnUrl }); const payment = await callCash({ amount: 250, reference: "order_1042" }); ``` Without `configure()`, `callCash()` uses `CALLCASH_KEY` (and `CALLCASH_URL`) from the environment when set, which means the planned hosted API. Otherwise it uses the test gateway. ## Environment variables Keep keys in environment variables, never in code or in a client bundle. The names are yours; these docs use: ```bash YOCO_SECRET_KEY=sk_test_... YOCO_WEBHOOK_SECRET=whsec_... NETCASH_PAYNOW_KEY=... NETCASH_TEST=1 ``` --- # Yoco Yoco takes card payments. callCash opens a Yoco checkout, sends your customer to Yoco's page, and believes the payment only once a signed Yoco event confirms it. ## What you need - A Yoco account with online payments switched on. - Your **secret key** from the Yoco portal. `sk_test_` keys run in test mode; any other key runs live. - A **webhook** registered with Yoco that points at your Yoco webhook route, and the `whsec_` **secret** Yoco gives you for it. Yoco's own documentation shows how to register one. - A **return URL** for customers to land on afterwards. ## Configure ```ts export const cash = new CallCash({ gateways: { yoco: { secretKey: process.env.YOCO_SECRET_KEY!, webhookSecret: process.env.YOCO_WEBHOOK_SECRET }, }, store, returnUrl: "https://example.co.za/paid", }); ``` Without `returnUrl` (or `return_url` on the call), creating a Yoco payment throws `config_invalid`. Without `webhookSecret`, Yoco events are refused with `config_invalid`, because they cannot be trusted. ## Taking a payment ```ts const payment = await cash.payments.create({ amount: 250, reference: "order_1042", gateway: "yoco" }); // redirect the customer to payment.checkout_url ``` What callCash sends Yoco: - The amount in cents, in ZAR. - An `Idempotency-Key` equal to the payment id, so a retry after a dropped connection cannot open a second checkout. - `metadata.callcash` set to the payment id, plus `metadata.reference`. Your own `metadata` goes along too, but cannot overwrite those two. - Three return addresses built from your `returnUrl`, with `?status=success|cancelled|failed&reference=…&payment=…` added. If Yoco answers 5xx or 429, callCash tries up to three times, then throws `gateway_down` (retryable) so routing can try your next gateway. ## The return URL is not proof A customer arriving at `?status=success` tells you nothing you can trust: anyone can type that address. Show a "we're confirming your payment" page there, and fulfil the order only in your webhook handler. ## The 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) { const r = await cash.webhooks.handle("yoco", req, onCash); return r.response; } ``` What callCash checks before your handler runs: - The `webhook-signature` header matches an HMAC-SHA256 of the event, using your `whsec_` secret. - The event is no more than three minutes old (Yoco's recommendation), so a captured event cannot be replayed later. - The event's mode matches your key, so a test event never reaches a live deployment, or the other way round. - For a payment, the status is `succeeded`, the currency is ZAR and the amount matches. A different amount gives `payment.mismatch`, and the payment is not marked paid. An event without `metadata.callcash` is not a callCash payment: `handle` returns `handled: false`, so older code can still process it. Yoco events callCash understands: `payment.succeeded`, `payment.failed`, `refund.succeeded`, `refund.failed`. ## Refunds Yoco refunds only work on payments made with **live** keys. With test keys, `refund()` throws `refund_unsupported`. See [Refunds](https://callcash.riverrr.co.za/docs/refunds). ## Test it Use `sk_test_` keys and Yoco's test cards. Payments made this way have `mode: "test"`. --- # Netcash Netcash Pay Now takes cards, instant EFT, bank EFT, cash at a shop and vouchers. It works differently from Yoco: your customer's browser posts a form to Netcash, and Netcash tells your server afterwards with a notification that is **not signed**. ## What you need - A Netcash account with **Pay Now** switched on. Netcash only gives you test mode once the account exists. - Your **Pay Now service key** (in the Netcash dashboard). - Access to **NetConnector** in your Netcash login. If you cannot see it, ask your Netcash advisor to enable it for your user. ## Configure ```ts export const cash = new CallCash({ gateways: { netcash: { serviceKey: process.env.NETCASH_PAYNOW_KEY!, test: process.env.NETCASH_TEST === "1" }, }, store, }); ``` Set `test: true` while "Make test mode active" is ticked in your Netcash dashboard, so payments are marked `mode: "test"`. If you are a Netcash ISV, add `vendorKey`; otherwise Netcash's default software vendor key is used. ## The four URLs Netcash does not take return addresses from the payment. You type four URLs into the dashboard: **Account profile → NetConnector → Pay Now**. | URL | What to put there | |---|---| | Accept | The page customers land on after paying, for example `https://example.co.za/paid` | | Decline | The page for a declined payment | | Redirect | Where Netcash sends customers in other cases | | Notify | Your Netcash webhook route, for example `https://example.co.za/api/netcash/notify` | **Leave out any question mark.** callCash sends `ref=` in the `m10` field, and Netcash adds it to the Accept, Decline and Redirect addresses itself. Without the Notify URL you never hear about cash or EFT payments that complete later. ## Taking a payment ```ts const payment = await cash.payments.create({ amount: 250, reference: "order_1042", gateway: "netcash" }); // payment.checkout_url is null; payment.checkout_form is { action, fields } ``` Nothing is sent to Netcash at this point. `checkout_form` is the form the customer's browser must post. Send it to the browser as JSON and submit it there: ```ts // in the browser function postToNetcash({ action, fields }: { action: string; fields: Record }) { const form = document.createElement("form"); form.method = "POST"; form.action = action; for (const [name, value] of Object.entries(fields)) { const input = document.createElement("input"); input.type = "hidden"; input.name = name; input.value = value; form.appendChild(input); } document.body.appendChild(form); form.submit(); } ``` The fields callCash fills in: | Field | Value | |---|---| | `m1` | Your Pay Now service key. Pay Now expects it in the form; build the form on the server so the key is not in your JavaScript bundle. | | `m2` | The software vendor key | | `p2` | callCash's own reference for this payment (`gateway_ref`, `CC` and 16 letters and digits) | | `p3` | Your `description`, or your reference, cut to 50 characters | | `p4` | The amount in rand, two decimals | | `Budget` | `Y` | | `m4` | The callCash payment id (comes back as Extra1) | | `m5` | Your reference, cut to 50 characters (Extra2) | | `m9` | The customer's email, when you gave one | | `m10` | `ref=` | ## The 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) { const r = await cash.webhooks.handle("netcash", req, onCash); return r.response; } ``` ## Why every notification is checked with Netcash The Notify post carries no signature. Anyone who learns your Notify URL could post `TransactionAccepted=true` to it. So callCash treats the post as a hint and asks Netcash directly: 1. It reads `Reference` and `RequestTrace` from the post. 2. It calls Netcash's `TransactionStatus/Check` with the `RequestTrace`. 3. It believes only Netcash's answer: the reference must be this payment's, and the amount must match. A different amount gives `payment.mismatch` and the payment is not marked paid. If Netcash's status check cannot be reached, or does not answer in JSON, `handle` responds **503** and Netcash sends the notification again later. Nothing is marked paid on a failed check. Netcash retries notifications until it gets a 200, so the same payment can arrive more than once. callCash turns repeats into one event. Cash at a shop and EFT payments can notify much later than card payments, sometimes the next business day. Show the customer a pending state, not a failure. ## How the customer paid Netcash's `Method` code is turned into callCash's `method`: 1 card, 2 bank EFT, 3 cash, 4 instant EFT, 10 voucher. Any other code becomes `other`. This list is from our own integration and is still to be confirmed against live data. ## The R100 limit A new Pay Now service starts with a **R100 transaction limit**. Above it, the card option simply does not show on Netcash's page, and nothing errors. Test with amounts under R100, and ask your Netcash advisor to raise the limit before you launch. ## Refunds Netcash has no refund call that callCash has verified, so `refund()` throws `refund_unsupported`. Refund in the Netcash portal, then record it. See [Refunds](https://callcash.riverrr.co.za/docs/refunds). --- # Payments ## Create ```ts const payment = await cash.payments.create({ amount: 250, // rand, two decimals at most reference: "order_1042", // your own order number, 1 to 100 characters email: "customer@example.co.za", // optional description: "Order 1042", // optional; 50 characters at most on Netcash metadata: { order: "1042" }, // optional; values become strings method: "card", // optional; steers routing gateway: "yoco", // optional; skips routing return_url: "https://example.co.za/paid", // optional; overrides config.returnUrl }); ``` Then send the customer to pay: ```ts if (payment.checkout_url) { // Yoco: redirect to the link } else if (payment.checkout_form) { // Netcash: send { action, fields } to the browser and post it as a form (see the Netcash page) } ``` The payment is stored before any gateway hears of it, so a notification can never arrive for a payment callCash does not know. ## Amounts `amount` is rand: `250` is R250.00 and `99.5` is R99.50. Anything that is not a positive number with at most two decimals throws `amount_invalid` before a gateway is called. If your app stores cents, divide by 100 going in. ## References Your `reference` decides what happens when the same order is paid for twice: | The last payment with this reference is… | What `create` does | |---|---| | paid | Throws `reference_used` (409). Use a new reference. | | pending, under 30 minutes old, same gateway, same amount | Returns that same payment. A double click opens one checkout. | | pending, under 30 minutes old, same gateway, different amount | Throws `reference_used`. Use a new reference to charge a different amount. | | pending and older, or on another gateway | Creates a new payment. | | failed | Creates a new payment. A failure frees the reference. | ## Routing `routes` in the config says which gateways to try, in order, for each way of paying: ```ts routes: { card: ["yoco", "netcash"], default: ["netcash"] } ``` How a payment picks its gateway: 1. `gateway` on the call wins, and skips routing. 2. Otherwise `routes[method]` when you pass a `method`. 3. Otherwise `routes.default`. 4. Otherwise every gateway you configured, in the order you listed them. 5. With no real gateways, the test gateway. ## Failover Only a gateway that is **down** hands over to the next one in the route: a network error, a timeout (15 seconds), a 5xx or a 429. Those throw `gateway_down`, which is retryable. A gateway that **refuses** the payment (a 4xx, `gateway_rejected`) is not tried elsewhere, because the next one would most likely refuse it too. The payment is left `failed` with the reason in `failure`, and the error is thrown. Failover only helps if you are approved on more than one gateway. That is mostly agencies and bigger shops. ## Get, list, update ```ts const p = await cash.payments.get("pay_…"); // not_found if there is no such payment const recent = await cash.payments.list({ status: "paid", gateway: "yoco", limit: 50 }); const owed = await cash.payments.list({ undelivered: true }); // payments with events your handler has not taken yet await cash.payments.update("pay_…", { metadata: { shipped: "yes" } }); // merges into metadata ``` `list` returns newest first, 100 at most unless you set `limit`. ## The payment object | Field | Meaning | |---|---| | `id` | `pay_` and 16 letters and digits | | `status` | `pending`, `paid` or `failed` | | `amount` | Rand | | `currency` | Always `ZAR` | | `reference` | Your order number | | `email` | Or `null` | | `gateway` | `yoco`, `netcash` or `test` | | `mode` | `test` or `live` | | `method` | `card`, `instant_eft`, `bank_eft`, `cash`, `voucher`, `other`, or `null` until paid | | `verified` | `true` once the gateway itself confirmed the payment, never on a notification's word | | `checkout_url` | Yoco's link, or `null` | | `checkout_form` | Netcash's `{ action, fields }`, or `null` | | `metadata` | Your data, as strings | | `refunded` | Rand refunded so far (succeeded refunds only) | | `refunds` | Each refund: `id`, `amount`, `status`, `failure` | | `gateway_ref` | The reference the gateway knows this payment by (`CC…`) | | `gateway_id` | Yoco's checkout id, or Netcash's RequestTrace | | `failure` | Why it failed, or `null` | | `created_at`, `paid_at`, `failed_at` | ISO timestamps | --- # Webhooks and events Each gateway calls a route in your app when something happens. You pass that request to callCash, and your handler gets one event shape, already checked with the gateway. ## One route per gateway ```ts // app/api/yoco/webhook/route.ts export async function POST(req: Request) { const r = await cash.webhooks.handle("yoco", req, onCash); return r.response; } // app/api/netcash/notify/route.ts export async function POST(req: Request) { const r = await cash.webhooks.handle("netcash", req, onCash); return r.response; } ``` **Always return `r.response` as it is.** Its status tells the gateway whether to send the notification again. ## One handler ```ts // lib/on-cash.ts import type { CashEvent } from "callcash"; export async function onCash(event: CashEvent) { switch (event.type) { case "payment.paid": await markOrderPaid(event.reference, event.amount); // must be safe to run twice break; case "payment.failed": await markOrderFailed(event.reference, event.reason); break; case "payment.mismatch": await alertSomeone(event.reference, event.reason); // do not fulfil break; } } ``` ## What is checked before your handler runs | Gateway | Check | |---|---| | Yoco | Signature, an age under three minutes, test or live mode matching your key | | Netcash | The notification is unsigned, so Netcash's own status check is asked and only its answer is believed | | Both | The confirmed amount must equal the payment's amount, or you get `payment.mismatch` and the payment stays unpaid | Every event your handler receives has `verified: true`. ## Events | Type | When | |---|---| | `payment.paid` | The gateway confirmed the full amount | | `payment.failed` | The gateway says it failed or was declined. Ignored if the payment is already paid. | | `payment.mismatch` | The gateway confirmed a different amount, or the payment was paid a second time. `reason` says which. | | `refund.succeeded` | A refund went through, including one made in the gateway's portal | | `refund.failed` | The gateway could not complete a refund | ## The event object | Field | Meaning | |---|---| | `id` | The same on every redelivery. Use it to ignore repeats. | | `type` | One of the types above | | `payment` | The payment id | | `reference` | Your order number | | `amount` | Rand: the payment's amount, or the refund's on refund events | | `method` | How the customer paid, when known | | `gateway`, `mode` | Where it happened, test or live | | `verified` | Always `true` | | `refund` | The refund id, on refund events | | `reason` | On failed and mismatch events | | `redelivery` | `true` when an earlier attempt to give you this event failed | | `created_at` | ISO timestamp | | `data` | The whole payment, as it is now | ## Exactly once - The same notification sent twice produces one event. - Two notifications arriving at the same moment still run your handler once: an event is claimed while your handler runs. A claim lapses after a minute, so a crashed process does not strand the event. - Even so, **write your handler to be safe to run twice** for the same `event.id`. Networks and processes fail in ways no library can rule out. ## When your handler throws 1. The payment is still recorded. 2. The gateway gets a **500**, so it sends the notification again. 3. The event stays owed. It comes back with the same `id` and `redelivery: true`. Do not catch and hide errors inside your handler. Throwing is how the retry happens. ## A safety net: deliver what is owed ```ts const { delivered, failed } = await cash.events.deliver(onCash); ``` This hands every event still owed to your handler. Run it from a cron job, or after your handler has been down. One payment failing does not stop the rest. ## The result of handle() | Field | Meaning | |---|---| | `response` | Send it back to the gateway as it is | | `handled` | `false` when the notification is not about a callCash payment | | `ignored` | Why nothing happened, when nothing did | | `events` | The events your handler received | | `error` | What went wrong when the response is not a 200. Log it. | Responses `handle` can give: | Status | When | |---|---| | 200 | Done, or ignored (not ours, a repeat, a test event on a live key) | | 400 | `signature_invalid`: the Yoco event's signature or age failed | | 500 | `config_invalid` (a missing secret or gateway), `handler_failed` (your handler threw), or the request could not be read | | 503 | `gateway_down` (Netcash's status check could not be reached) or `api_error` (the store failed): the gateway should retry | ## Running beside older payment code When `handled` is `false`, the notification belongs to something callCash did not create, such as checkouts your old code opened. Pass it on: ```ts export async function POST(req: Request) { const r = await cash.webhooks.handle("yoco", req.clone(), onCash); if (r.handled) return r.response; return oldYocoHandler(req); // unchanged } ``` Use `req.clone()` so the old code can still read the body. ## Listing events ```ts const events = await cash.events.list({ after: "evt_…", limit: 100 }); ``` Oldest first. `after` continues from an event id. ## Who retries, and for how long Today callCash runs inside your server, so callCash doesn't send you webhooks. Yoco and Netcash send them to your routes, and `cash.webhooks.handle()` checks each one before your handler sees it. - **If your handler throws,** we answer the gateway with a 500. The gateway sends the notification again on its own retry schedule, which belongs to Yoco or Netcash, not to us. The event keeps its `id` and arrives with `redelivery: true`. - **If the gateway gives up,** the event is still owed in your store. Run `cash.events.deliver(onCash)` from a cron, every few minutes, and nothing is lost. - **The hosted service is planned.** It will send its own webhooks, and its retry window and schedule will be published here before it handles a payment. ## When the money arrives callCash never holds money, so settlement is between your gateway and your bank, on the gateway's payout terms. Check your Yoco or Netcash agreement for when payouts happen. A `payment.paid` event means the gateway confirmed the payment. It doesn't mean the money has reached your bank yet. --- # Refunds ```ts await cash.payments.refund(payment.id); // everything not yet refunded await cash.payments.refund(payment.id, { amount: 50 }); // part of it, in rand ``` Only a **paid** payment can be refunded. callCash reserves the amount on the payment before it asks the gateway, so two refund requests at once can never give back more than was paid. Asking for too much throws `refund_invalid` and says how much is left. ## Yoco - The refund is sent to Yoco with an idempotency key, so a retry cannot refund twice. - It comes back `pending`. It becomes `succeeded` when Yoco's `refund.succeeded` event arrives at your webhook route, and your handler gets `refund.succeeded`. - Only payments made with **live** keys can be refunded. With test keys, `refund()` throws `refund_unsupported`. - If Yoco refuses, you get `gateway_rejected` and the reserved amount is released, so you can try again. ## Netcash Netcash Pay Now has no refund call that callCash has verified, so `refund()` throws `refund_unsupported`. 1. Refund the payment in the Netcash portal. 2. Record it, so your totals stay right: ```ts await cash.payments.recordRefund(payment.id, { amount: 50 }); // leave out amount for the full remainder ``` `recordRefund` marks the refund `succeeded` straight away and your handler gets `refund.succeeded` the next time events are delivered (for example with `cash.events.deliver(onCash)`). ## Refunds made in a gateway's portal If a refund event arrives for a refund callCash did not start, callCash records it against the payment so `refunded` stays true. ## Test gateway Refunds on test gateway payments succeed immediately. --- # Errors Every failure is a `CallCashError`: ```ts import { isCallCashError } from "callcash"; try { await cash.payments.create({ amount, reference }); } catch (e) { if (isCallCashError(e)) { e.code; // "amount_invalid" e.status; // 422 e.message; // says what went wrong and what to do e.retryable; // true when nothing was charged and trying again is safe } throw e; } ``` Retry only when `retryable` is `true`. Messages are written for developers; show your customers your own words. ## Codes | Code | Status | Retry | When | What to do | |---|---|---|---|---| | `amount_invalid` | 422 | No | `amount` is not rand above 0 with at most two decimals, or is over R9,999,999.99 | Send rand, like `250` or `99.50`. If you store cents, divide by 100. | | `reference_invalid` | 422 | No | `reference` is empty or longer than 100 characters | Use your own order number. | | `reference_used` | 409 | No | The reference was already paid, or is open for a different amount | Use a new reference, or read the existing payment. | | `gateway_down` | 503 | Yes | A gateway did not answer: network error, a 15 second timeout, a 5xx or 429, or Netcash's status check failed | Retry. Routing already tried your backup gateway. In a webhook, the 503 makes the gateway retry. | | `gateway_rejected` | 502 | No | Yoco refused to open a checkout or a refund | Read the message, which includes Yoco's answer. The payment is left failed. | | `signature_invalid` | 400 | No | A Yoco event's signature did not match, it was over three minutes old, or it was not JSON | Check `webhookSecret` is the secret for this webhook. Check your server clock. | | `not_found` | 404 | No | There is no payment with that id | Check the id. | | `refund_invalid` | 400 | No | The payment is not paid, is fully refunded, or you asked for more than is left | Refund only what is left. | | `refund_unsupported` | 400 | No | Netcash payments, or Yoco payments made with test keys | Netcash: refund in the portal, then `recordRefund()`. | | `config_invalid` | 500 | No | The setup is wrong: see below | Fix the config. | | `api_error` | varies | When 5xx | The hosted API (planned) answered without a code; also the body of a webhook response when the request could not be read or the store failed | Retry when the status is 5xx. | | `gateway_limit` | 402 | No | Declared for a gateway's transaction limit. **Not thrown by version 0.1.0.** | Netcash's R100 limit shows as a missing card option, not an error. See [Netcash](https://callcash.riverrr.co.za/docs/netcash#the-r100-limit). | ## config_invalid, in detail - Real gateways without a `store`. - Both `key` and `gateways` given. - A gateway named in `routes` or `gateway` that is not in `gateways`. - An empty Yoco `secretKey` or Netcash `serviceKey`. - A Yoco payment with no `returnUrl` or `return_url`. - A Yoco event arriving with no `webhookSecret` configured. - `cash.test.pay()` on a payment that is not on the test gateway. - A `cc_` key that does not start with `cc_test_` or `cc_live_`. - Calling a hosted-only method (`webhooks.delete`) without a key, or a direct-only method (`webhooks.handle`, `recordRefund`, `events.deliver`, `test.*`) with one. ## handler_failed Not a thrown error. It is the code in the 500 response `webhooks.handle` gives the gateway when your handler throws. The event stays owed and is delivered again. --- # Testing Test in three steps, from no account to real gateways in test mode. ## 1. The test gateway, in your test suite No account, no network, no money. Your handler runs exactly as it will in production. ```ts import { test } from "node:test"; import assert from "node:assert/strict"; import { CallCash } from "callcash"; test("a paid order is fulfilled once", async () => { const cash = new CallCash(); const fulfilled: string[] = []; const onCash = async (e) => { if (e.type === "payment.paid") fulfilled.push(e.reference); }; const p = await cash.payments.create({ amount: 250, reference: "order_1" }); await cash.test.pay(p.id, onCash); assert.deepEqual(fulfilled, ["order_1"]); }); ``` Rehearse what goes wrong: | Call | Your handler gets | |---|---| | `cash.test.pay(id, onCash)` | `payment.paid` | | `cash.test.fail(id, onCash, "insufficient funds")` | `payment.failed` with that reason | | `cash.test.pay(id, onCash, { amount: 2.5 })` | `payment.mismatch`, and the payment stays unpaid | | `cash.test.pay(id, onCash)` twice on a paid payment | `payment.mismatch`: paid a second time | | A handler that throws | The event stays owed; `cash.events.deliver(onCash)` brings it back with `redelivery: true` | `cash.test.pay` and `cash.test.fail` only work on payments made on the test gateway. ## 2. Yoco test keys Configure Yoco with an `sk_test_` key. Payments open a real Yoco checkout and Yoco's test cards work on it. Events arrive at your webhook route with `mode: "test"`. Refunds are not possible on test payments. Yoco must be able to reach your webhook route, so run your app on a public address (a preview deployment, or a tunnel to your machine). ## 3. Netcash test mode Tick "Make test mode active" in the Netcash dashboard and set `test: true`. Keep test amounts **under R100**: a new Pay Now service hides the card option above that limit. Netcash calls the Notify URL you typed into NetConnector, so that address must be public too. ## Testing your own store If you write your own `Store`, test that two `update` calls at the same moment cannot both win. That atomic step is what stops a payment counting twice. --- # Framework examples ## Next.js (App Router) **The client, server only:** ```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:** ```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](https://callcash.riverrr.co.za/docs/netcash#taking-a-payment)). **The webhooks:** ```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; } ``` ```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:** ```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: ```ts 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. --- # Troubleshooting ## The card option is missing on Netcash's page Your amount is probably above your Pay Now transaction limit. New services start at **R100**, and above it Netcash hides the card option without an error. Test under R100, and ask your Netcash advisor to raise the limit. ## Netcash notifications never arrive - Is the **Notify** URL set in NetConnector (Account profile → NetConnector → Pay Now)? - Is it a public address? Netcash cannot reach `localhost`. - Is there a `?` in any of the four URLs? Remove it. - Can you not see NetConnector? Ask your Netcash advisor to enable it for your user. ## Netcash notifications arrive but nothing is marked paid Look at `r.error` and `r.ignored` from `webhooks.handle`: - `503 gateway_down`: Netcash's status check could not be reached. Netcash will notify again. - `reference mismatch`: Netcash's status check is about a different reference. - `payment.mismatch` event: Netcash confirmed a different amount than the payment. ## Yoco events fail with signature_invalid - Is `webhookSecret` the `whsec_` secret for **this** webhook? Each registered webhook has its own. - Is the body reaching callCash untouched? In Express, use `express.raw()` on the webhook route, before any `express.json()`. - Is your server clock right? Events older than three minutes are refused. ## Yoco events are ignored with "mode mismatch" A test event reached a live key, or the other way round. Check which key this deployment uses. ## handle() returns handled: false The notification is not about a callCash payment. For Yoco, the event has no `metadata.callcash`. For Netcash, the reference is not one callCash issued. If older code opened that payment, pass the request on to it (see [Webhooks](https://callcash.riverrr.co.za/docs/webhooks#running-beside-older-payment-code)). ## My handler ran but the gateway keeps retrying Your handler threw. callCash answered 500 so the gateway retries, and the event comes back with `redelivery: true`. Fix the error; do not catch it and return 200. ## reference_used when the customer tries again The reference was already paid, or is open for a different amount. Use a new reference per attempt at a different amount. A failed payment frees its reference. ## config_invalid on start-up See the list on the [Errors page](https://callcash.riverrr.co.za/docs/errors#config_invalid-in-detail). The most common: real gateways with no `store`, or a gateway in `routes` that is not in `gateways`. ## "Cannot use import statement" or "require() of ES Module" The package is ESM only. Use `import`, or `await import("callcash")` from CommonJS. ## It works locally, then forgets payments You are on the test gateway or `MemoryStore`, which keep everything in memory. Use a real store with real gateways. --- # Use callCash with AI agents Coding agents write a lot of payment code now. Payment code is where a confident wrong guess costs real money, so callCash gives agents the rules and the docs in a form they read well. ## 1. Add the rules to your project ```bash npx callcash agents ``` This adds a short callCash section to your agent's instructions file, pointing at the rules that ship inside the package (`node_modules/callcash/AGENTS.md`). | Flag | File | Agent | |---|---|---| | `--codex` | `AGENTS.md` | Codex, and most agents that read `AGENTS.md` | | `--claude` | `CLAUDE.md` (imports the rules with `@node_modules/callcash/AGENTS.md`) | Claude Code | | `--cursor` | `.cursor/rules/callcash.mdc` | Cursor | | `--gemini` | `GEMINI.md` | Gemini CLI | | `--all` | All four | | With no flags, it updates the files your project already has, or writes `AGENTS.md`. It is safe to run again. Each file gets one section between `` and ``; running it again refreshes only that section, and nothing outside it is touched. A `.cursor/rules/callcash.mdc` you wrote yourself is left alone. ## 2. What the rules say The short version, so you know what your agent is being told: - Amounts are rand, not cents. - Never mark an order paid on the redirect. Only in the webhook handler, on `payment.paid`. - The handler must be safe to run twice. Key it on `event.id`. - Always return `r.response` from the webhook route, and let handler errors throw. - Real gateways need a store. Start with the test gateway. - Yoco gives a link; Netcash gives a form the browser must post. - Server only. Never log keys or put them in client code. - Do not invent `cc_` keys or hosted API calls: the hosted API is planned, not built. ## 3. Docs your agent can read Every page of these docs is also plain Markdown: - **[/llms.txt](https://callcash.riverrr.co.za/llms.txt)**: the index, in the llms.txt format. Give your agent this link first. - **[/llms-full.txt](https://callcash.riverrr.co.za/llms-full.txt)**: every page in one file. - **/docs/<page>.md**: any single page, for example [/docs/webhooks.md](https://callcash.riverrr.co.za/docs/webhooks.md). Each page here has a **Copy page as Markdown** button too. ## A prompt to paste ```text Add payments to this app with the callcash package. Read node_modules/callcash/AGENTS.md and https://callcash.riverrr.co.za/llms.txt first. Start with the built-in test gateway (new CallCash() with no config) and write tests that use cash.test.pay and cash.test.fail. Fulfil orders only in the webhook handler on payment.paid, make the handler idempotent by event.id, and return r.response from the webhook routes. Keep all callcash code on the server and read keys from environment variables. ``` ## MCP There is no callCash MCP server yet. The Markdown docs and the rules file cover what an agent needs today. --- # Status Version 0.1.0, 12 November 2026. For testing inside our own apps. Not published to npm. ## Verified By the package's own tests (36 of them), against stand-in gateways, with no network and no money: - Amounts, and reference reuse. - Routing and failover: a gateway that is down hands over; one that refuses does not. - Yoco signature checking, including the three-minute age limit and test or live mode. - Netcash status checking, including a 503 when the check is down. - Exactly-once events, and redelivery after a failed handler. - Refunds, including never refunding more than was paid when two requests arrive at once. - The Firestore store, against a stand-in database. - The hosted client's request shapes. - `npx callcash agents`. ## Not verified **Nothing here has talked to the real Yoco or Netcash, or to a real Firestore.** The gateway calls are copied from the working payment code in our own app, Road Quest, so the request shapes are known to work there. The new parts around them are not proven against live systems. That is the next step, inside Road Quest, on Yoco test keys and Netcash test mode. ## Assumed - Yoco's `refund.succeeded` event carries the checkout's metadata. - Yoco refund events state the refunded amount in `payload.amount`. If not, the oldest open refund is matched. - Netcash accepts an 18-character letters-and-digits reference (it allows 25). - Netcash method codes 1, 2, 3, 4 and 10 mean card, bank EFT, cash, instant EFT and voucher. ## Not built - The hosted callCash API and `cc_test_` / `cc_live_` keys. - The hosted checkout page. - `callcash listen` (forwarding webhooks to your machine). - PHP and Python packages. - Ozow, Peach Payments, iKhokha, PayFast and Paystack. - Signing for callCash's own outgoing webhooks. - An MCP server for agents. - The `gateway_limit` error (declared, never thrown).