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

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#

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

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

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) {
  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.

Test it#

Use sk_test_ keys and Yoco's test cards. Payments made this way have mode: "test".