Refunds
When your app collects payment but fails to deliver, the payer should get their money back. PayWeave handles this for you by default — any throw from your payweave.route() handler automatically triggers a refund.
Auto-refund on throw (default)
payweave.route() catches every exception from your handler and:
1. Calls payweave.refund() with reason = err.message (the thrown Error's message).
2. Returns 502 { error: err.message, message: err.message } to the caller.
Write short, machine-readable Error messages so refund records and response codes stay useful for analytics:
payweave.route(app, 'post', '/api/weather', {
price: '0.001',
inputSchema: WeatherInput,
handler: async (_c, body) => {
const res = await fetch(`https://api.weather/?q=${body.city}`);
if (!res.ok) throw new Error('weather_upstream_error'); // ← becomes refund reason
return await res.json();
},
});Opting out
Pass refundOnError: false to disable auto-refund. Throws propagate normally to the framework's error handler — the charge stands.
payweave.route(app, 'post', '/api/debug', {
price: '0.001',
refundOnError: false,
handler: async () => {
throw new Error('work in progress'); // caller sees 500, no refund
},
});Manual refund (fine-grained control)
Disable auto-refund and call payweave.refund() yourself when you need different HTTP status codes or want to refund only specific error kinds.
payweave.route(app, 'post', '/api/weather', {
price: '0.001',
refundOnError: false,
inputSchema: WeatherInput,
handler: async (c, body) => {
try {
return await fetchWeather(body.city);
} catch (err: any) {
if (err.status === 429) {
// Upstream rate-limited us — refund + tell caller to retry
await payweave.refund(c.env, {
transactionHash: c.get('payweaveTransactionHash')!,
reason: 'upstream_rate_limited',
});
return c.json({ error: 'rate_limited', retry_after: 60 }, 429);
}
if (err.status === 400) {
// User's fault (invalid symbol that passed our Zod) — don't refund
return c.json({ error: 'bad_request', message: err.message }, 400);
}
throw err; // any other failure → framework's 500
}
},
});Refund API
Auto-refund and manual refund both end up calling the same platform endpoint. If you need to refund from outside a handler (e.g. a background job), call it directly:
/api/mpp/app/:appId/refundAuthenticated with the same appSecret Bearer token used for charges. Accepts either a paymentId or a transactionHash.
{
"transactionHash": "0xabc...def",
"reason": "upstream_rate_limited"
}Response
{
"success": true,
"refundId": "f1b2c3d4-...",
"paymentId": "a3cdf4ea-...",
"refundAmountUsd": "0.01000000",
"payerAddress": "did:pkh:eip155:42431:0x...",
"status": "pending"
}Refund status lifecycle
A refund starts pending. The workspace owner reviews and signs it from the dashboard — then it moves through processing to settled (or failed).
What auto-refund does NOT catch
| Scenario | Refunded? | Why |
|---|---|---|
| Handler returns successfully | No | Nothing threw. Payment stands. |
| Body validation failure (400) | N/A | Charge never happened — invalid bodies fail before the handler runs. |
| 402 challenge probe | N/A | No payment taken on the probe. |
| Handler throws (any reason) | Yes, by default | Auto-refund fires with reason = err.message. |
| Handler throws with refundOnError: false | No | Opt-out — throw propagates, charge stands. |
| Handler succeeds but upstream returned bad data | No | Not a throw. Inspect the response and throw manually if it signals failure. |
'upstream_rate_limited' or 'parallel_upstream_error' for your throws. They become both the refund reason (stored on the platform for analytics) and the error field in the 502 response (so clients can branch on them).