Payments

Refunds

Refunds send MUNY from your settlement wallet back to the wallet that paid, verified on chain like every other Muny transfer. Full and partial refunds are supported.

Two flows#

Muny never takes custody of keys it doesn't already manage. Which flow applies depends on your settlement wallet.

Settlement walletFlowStatuses
Managed (Muny custody provider)Muny signs and sends the refund for you.pending → submitted → confirmed
External (your own wallet)Muny prepares the transfer; you sign it with your wallet and submit it.requires_signature → submitted → confirmed

Managed wallet

ts
// Full refund (defaults to the remaining refundable amount)
const refund = await muny.payments.refund(payment.id, { reason: "Customer request" });

// Partial refund
await muny.payments.refund(payment.id, { amount: "50", reason: "Duplicate seat", idempotency_key: "refund_order_48291_1" });

refund.status; // "pending" → "submitted" → "confirmed"

External (non-custodial) wallet

ts
const refund = await muny.payments.refund(payment.id, { amount: "249" });
// refund.status === "requires_signature"
// refund.transaction_to_sign → base64 transaction (network fee sponsored by Muny)

// Sign it with the settlement wallet (wallet adapter, hardware wallet, your signer)…
const tx = VersionedTransaction.deserialize(Buffer.from(refund.transaction_to_sign!, "base64"));
const signed = await wallet.signTransaction(tx);

// …and hand it back. Muny co-signs as fee payer, checks it matches what it prepared, and broadcasts.
await muny.refunds.submit(refund.id, Buffer.from(signed.serialize()).toString("base64"));

Muny can't alter what you sign

The submitted transaction must match the prepared one byte for byte; Muny only adds the fee-payer signature. If the prepared transaction expires before you sign, submit fails and the refund stays requires_signature, so you can try again.

Rules#

  • Only confirmed, finalized or partially_refunded payments can be refunded.
  • The destination is always the verified payer address from the original payment. It can't be changed.
  • The sum of all non-failed refunds can never exceed the payment amount. Concurrent refund requests are serialised, so two can't both pass.
  • When refunds confirm, the payment moves to partially_refunded or refunded and payment.refunded fires.
  • Every refund is recorded in the audit log (refund.requested, refund.confirmed).

Refund object#

FieldDescription
id, payment_idRefund and payment identifiers.
amount, tokenDecimal string in whole tokens.
statuspending · requires_signature · submitted · confirmed · failed
destination_address, wallet_idPayer wallet and source settlement wallet.
transaction_to_signOnly while requires_signature.
transaction_signature, explorer_urlOnce submitted.
reason, failure_messageYour note, and the error if it failed.

Endpoints#

  • POST /v1/payments/:id/refunds — { amount?, reason?, idempotency_key? } (scope refund_payments)
  • GET /v1/payments/:id/refunds · GET /v1/refunds/:id
  • POST /v1/refunds/:id/submit — { signed_transaction }