Refunds
Every refund runs through one dispatcher — atomic claim, adapter send, exponential retries, signed webhook fanout, and a public status page for your customer.
Lifecycle
A refund moves through these statuses:
pending— created, waiting to be claimed (or missing a destination address).processing— adapter has accepted the send; on-chain broadcast in flight.retry_scheduled— last attempt failed; retry queued atnext_attempt_at.succeeded— funds delivered.tx_hashpopulated.failed— retry budget exhausted (5 attempts).cancelled— merchant cancelled before dispatch.
Retry schedule
Failed attempts are retried on an exponential schedule with a global kill-switch (PAYOUTS_GLOBAL_KILL=1) and a per-merchant pause control. Attempts:
- #1 — immediate (inline right after refund insert)
- #2 — +1 minute
- #3 — +5 minutes
- #4 — +30 minutes
- #5 — +2 hours
- #6 — +12 hours, then marked
failedandrefund.failedfires
The retry queue is drained by POST /api/public/cron/refund-retry, scheduled via pg_cron with the x-cron-secret header.
Webhook events
Subscribe on any endpoint (dashboard → Webhooks or POST /v1/webhook-endpoints). Events are HMAC-SHA256 signed with the endpoint secret. See the quickstart for the verification snippet.
refund.created— a merchant, an API call, or the expiry sweeper enqueued a refund.refund.processing— first dispatch attempt is in flight.refund.paid— delivered on-chain (or via the off-ramp adapter).refund.failed— retry budget exhausted.
Auto-refund triggers
The same enqueueRefund path is used by every trigger, so idempotency keys, retries, and webhooks all behave identically:
- Merchant clicks Cancel or Refund on a partially-funded intent.
POST /v1/payment-intents/{id}/cancelis called with legs already received.- The expiry cron closes an unpaid intent that has partial funds.
- NOWPayments reports an overpayment or excess partial.
- Merchant hits
POST /v1/refunds. - An AI agent calls the
create_refundMCP tool.
Payer-facing status page
Every refund has a public status page you can link customers to. It polls every 4s and shows a 3-step progress bar with the tx hash and a redacted destination tail. Never requires authentication.
https://frag.cash/refund/{refund_id}/statusOr fetch the JSON directly from your own UI or email service:
import { FragmentPay } from "@fragmentpay/server";
const frag = new FragmentPay({ apiKey: process.env.FRAG_SECRET_KEY! });
// Safe to expose — no PII, destination redacted to last 6 chars
const status = await frag.refunds.status(refundId);
// { status: "succeeded", tx_hash: "0x…", destination_tail: "…a1b2c3", terminal: true }Creating a refund (with or without a destination)
Pass an explicit destination when you already know the payer address, or omit it and let Frag auto-resolve from the confirmed payer legs on the intent. Either way, the refund flows through the same dispatcher / retry / webhook pipeline.
import { FragmentPay } from "@fragmentpay/server";
const frag = new FragmentPay({ apiKey: process.env.FRAG_SECRET_KEY! });
// A) Explicit destination — fastest path.
await frag.refunds.create({
paymentIntentId: intent.id,
amountUsd: 25.0,
destination: "0xPayer…",
reason: "customer_request",
idempotencyKey: `refund_${intent.id}_full`,
});
// B) Escrow-backed: omit destination, Frag auto-fills from the payer leg(s).
// Same call path used by the expiry sweeper and MCP agents.
await frag.refunds.create({
paymentIntentId: intent.id,
amountUsd: 5.0,
reason: "auto_expiry",
idempotencyKey: `refund_${intent.id}_expiry`,
});If no payer address can be derived, the refund parks in pending with failure_reason: "missing_destination" until a merchant supplies one from the dashboard. Nothing is silently dropped — every leg is either auto-swapped to your settlement wallet, held in the Frag escrow pool (dust legs under $2.00), or refundable to the original payer.
Retrying a refund (same or different address)
A refund in failed, retry_scheduled or pending can be pushed back onto the dispatcher immediately. Supply destination to redirect it to a different address than the one on file — useful when the original payer address bounced, is a contract that can't receive the settlement asset, or the customer gave you a new wallet. Terminal refunds (succeeded, cancelled) return 409.
// Retry to the address already on the refund
await frag.refunds.retry(refundId);
// Retry to a different address
await frag.refunds.retry(refundId, {
destination: "0xNewPayerWallet…",
reason: "address_bounced",
});curl -X POST https://frag.cash/api/public/v1/refunds/$REFUND_ID/retry \
-H "Authorization: Bearer $FRAG_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "destination": "0xNewPayerWallet", "reason": "address_bounced" }'The same action is available in the dashboard under Refunds → retry (same address) and retry to… (new address), and to AI agents through the retry_refund MCP tool. Attempt counters and webhook events (refund.processing, refund.paid, refund.failed) behave exactly as they do for automatic retries.
Querying & refetching from your client
Merchant-authenticated history calls belong on your server — never ship FRAG_SECRET_KEY to the browser. Proxy through a route handler and refetch on demand from React.
// app/api/history/route.ts (Next.js) — server only
import { FragmentPay } from "@fragmentpay/server";
const frag = new FragmentPay({ apiKey: process.env.FRAG_SECRET_KEY! });
export async function GET() {
const [{ data: intents }, { data: refunds }, { data: payouts }] = await Promise.all([
frag.paymentIntents.list({ limit: 50 }),
frag.refunds.list({ limit: 50 }), // filter with { status: "processing" }
frag.payouts.list({ limit: 50 }),
]);
return Response.json({ intents, refunds, payouts });
}// components/history-panel.tsx
import { useQuery, useQueryClient } from "@tanstack/react-query";
export function HistoryPanel() {
const qc = useQueryClient();
const { data, refetch, isFetching } = useQuery({
queryKey: ["frag", "history"],
queryFn: () => fetch("/api/history").then((r) => r.json()),
staleTime: 15_000,
});
return (
<>
<button onClick={() => refetch()} disabled={isFetching}>
{isFetching ? "Refreshing…" : "Refresh"}
</button>
{/* Invalidate right after issuing a refund so the list updates instantly */}
<button onClick={() => qc.invalidateQueries({ queryKey: ["frag", "history"] })}>
Invalidate
</button>
<pre>{JSON.stringify(data, null, 2)}</pre>
</>
);
}For customer-facing pages, use the unauthenticated @fragmentpay/react hooks. They stop polling automatically once the resource is terminal.
import {
useFragmentIntent,
useFragmentRefund,
refundPhase,
} from "@fragmentpay/react";
export function OrderStatus({ intentId, refundId }: {
intentId: string; refundId?: string;
}) {
const intent = useFragmentIntent(intentId, { intervalMs: 2500 });
const refund = useFragmentRefund(refundId ?? null);
return (
<>
<div>Payment: {intent.data?.status ?? "…"}</div>
{refund.data && (
<div>
Refund: <b>{refund.data.phase}</b>{" "}
{/* queued | sent | completed | failed */}
{refund.data.tx_hash && (
<a href={`https://explorer/${refund.data.tx_hash}`}>view tx</a>
)}
</div>
)}
</>
);
}Or hit the endpoints directly:
# Merchant (server-only)
curl -H "Authorization: Bearer $FRAG_SECRET_KEY" \
"https://frag.cash/api/public/v1/refunds?status=processing&limit=50"
# Payer-facing (no auth, 120 req/min per IP)
curl "https://frag.cash/api/public/v1/checkout/$INTENT_ID"
curl "https://frag.cash/api/public/refunds/$REFUND_ID/status"