ZyloPaydocs

Handling refunds

A safe refund flow for your back office, with idempotency, pending outcomes and failure handling.

This guide builds a refund flow that never pays twice and never loses track of a refund. Read Refunds first for the rules.

1. Record the intent to refund first

Create the refund in your own database before you call ZyloPay, with a stable ID. That ID becomes the idempotency key.

const ret = await db.returns.create({ paymentId: 'pay_1042', amount: 1000000, status: 'requested' });

2. Call ZyloPay with that key

import ZyloPay from '@zylopay/node';

const zp = new ZyloPay(process.env.ZYLOPAY_SECRET_KEY!);

try {
  const refund = await zp.refunds.create(
    { payment: ret.paymentId, amount: ret.amount, reason: 'Customer return', metadata: { return_id: ret.id } },
    { idempotencyKey: `refund-${ret.id}` },
  );
  await db.returns.update(ret.id, { refundId: refund.id, status: refund.status });
} catch (err) {
  if (err instanceof ZyloPay.errors.InvalidRequestError) {
    // amount_too_large, payment_already_refunded, refund_window_expired, refund_daily_limit_reached...
    await db.returns.update(ret.id, { status: 'refused', reason: err.code });
  } else {
    // Unknown outcome: leave it 'requested'. A retry with the same key is safe.
    throw err;
  }
}

3. Handle each outcome

OutcomeMeaningYour action
succeededFunds returned.Close the return.
pendingSent; the receipt was not seen within the request.Wait for refund.succeeded or refund.failed. Do not retry.
failedNothing moved (failure_code).Show the reason. A new attempt is a new refund with a new key.
409 refund_in_progressAnother refund of this payment is being sent.Retry the same call later.
409 idempotency_request_in_progressYour earlier call with this key is still running.Retry with the same key later.
Timeout or 5xxUnknown.Retry with the same key.

4. Follow up with webhooks

Subscribe to refund.* and update the return by metadata.return_id or the refund ID. refund.created arrives first with pending, then refund.succeeded or refund.failed. payment.refunded updates the payment's amount_refunded.

5. Sweep stuck returns

A scheduled job re-reads returns still requested or pending after a few minutes:

for (const ret of await db.returns.stale()) {
  const page = await zp.refunds.list({ payment: ret.paymentId });
  const refund = page.data.find((r) => r.metadata.return_id === ret.id);
  if (refund) await db.returns.update(ret.id, { refundId: refund.id, status: refund.status });
}

Refunds from other places

Refunds made in the dashboard (source: "dashboard"), on the POS (source: "pos") or by ZyloPay support under dual control (source: "admin") share the same ledger and send the same events. Your webhook handler sees them all.

Balance

Refunds are paid from your balance in the settlement contract. Keep enough there to cover expected refunds before you withdraw.

On this page