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#
// 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#
// 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#
- The payment is still recorded.
- The gateway gets a 500, so it sends the notification again.
- The event stays owed. It comes back with the same
idandredelivery: true.
Do not catch and hide errors inside your handler. Throwing is how the retry happens.
A safety net: deliver what is owed#
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:
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#
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
idand arrives withredelivery: 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.