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

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#

TypeScriptapp/api/yoco/webhook/route.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#

TypeScriptlib/on-cash.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#

GatewayCheck
YocoSignature, an age under three minutes, test or live mode matching your key
NetcashThe notification is unsigned, so Netcash's own status check is asked and only its answer is believed
BothThe 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#

TypeWhen
payment.paidThe gateway confirmed the full amount
payment.failedThe gateway says it failed or was declined. Ignored if the payment is already paid.
payment.mismatchThe gateway confirmed a different amount, or the payment was paid a second time. reason says which.
refund.succeededA refund went through, including one made in the gateway's portal
refund.failedThe gateway could not complete a refund

The event object#

FieldMeaning
idThe same on every redelivery. Use it to ignore repeats.
typeOne of the types above
paymentThe payment id
referenceYour order number
amountRand: the payment's amount, or the refund's on refund events
methodHow the customer paid, when known
gateway, modeWhere it happened, test or live
verifiedAlways true
refundThe refund id, on refund events
reasonOn failed and mismatch events
redeliverytrue when an earlier attempt to give you this event failed
created_atISO timestamp
dataThe 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#

TypeScript
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()#

FieldMeaning
responseSend it back to the gateway as it is
handledfalse when the notification is not about a callCash payment
ignoredWhy nothing happened, when nothing did
eventsThe events your handler received
errorWhat went wrong when the response is not a 200. Log it.

Responses handle can give:

StatusWhen
200Done, or ignored (not ours, a repeat, a test event on a live key)
400signature_invalid: the Yoco event's signature or age failed
500config_invalid (a missing secret or gateway), handler_failed (your handler threw), or the request could not be read
503gateway_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:

TypeScript
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#

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