Fragfrag
Guide

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 at next_attempt_at.
  • succeeded — funds delivered. tx_hash populated.
  • 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 failed and refund.failed fires

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}/cancel is 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_refund MCP 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.

snippet.tsx
https://frag.cash/refund/{refund_id}/status

Or fetch the JSON directly from your own UI or email service:

snippet.tsx
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.

snippet.tsx
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.

snippet.tsx
// 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",
});
snippet.tsx
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.

snippet.tsx
// 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 });
}
snippet.tsx
// 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.

snippet.tsx
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:

snippet.tsx
# 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"