DEV Community

Cover image for Your PayPal Refund Worked. Your Lovable App Says It Failed.
FetchSandbox
FetchSandbox

Posted on Originally published at fetchsandbox.com

Your PayPal Refund Worked. Your Lovable App Says It Failed.

Your app called PayPal's refund endpoint. PayPal completed the operation, but the 201 Created response never reached your server.

The app retries and receives 422 CAPTURE_FULLY_REFUNDED. If the handler treats every non-2xx response as a failed refund, your customer and support team now see opposite versions of reality.

This guide shows the production-safe behavior and how to test it before using a real payment.

What should a PayPal refund handler do after a timeout?

Reuse the same PayPal-Request-Id, then reconcile PayPal's state before marking the refund failed.

PayPal uses PayPal-Request-Id as the idempotency key for REST POST operations. One refund operation should have one stored request ID:

type RefundRecord = {
  id: string;
  captureId: string;
  paypalRequestId: string;
  status: "pending" | "completed" | "reconcile_required" | "failed";
};
Enter fullscreen mode Exit fullscreen mode

Send that ID on the first attempt:

const result = await fetch(
  `${PAYPAL_API}/v2/payments/captures/${refund.captureId}/refund`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${accessToken}`,
      "Content-Type": "application/json",
      "PayPal-Request-Id": refund.paypalRequestId,
    },
    body: JSON.stringify({
      amount: {
        currency_code: "USD",
        value: "25.00",
      },
    }),
  }
);
Enter fullscreen mode Exit fullscreen mode

If the request times out, send the same request ID again. Do not create a new UUID per attempt.

Why does PayPal return CAPTURE_FULLY_REFUNDED?

It means the capture's refundable balance is already zero. Possible causes include:

  • the first request worked but its response was lost
  • another support tool issued the refund
  • the PayPal dashboard issued it
  • several partial refunds already reached the captured total

The response shape is carried inside a broader 422:

{
  "name": "UNPROCESSABLE_ENTITY",
  "details": [
    {
      "issue": "CAPTURE_FULLY_REFUNDED",
      "description": "The capture has already been fully refunded."
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Do not branch on status code alone. 422 can also mean the amount exceeds the remaining balance, a chargeback blocks the refund, or the account cannot perform the operation.

Parse details[].issue, then choose the business response.

The correct state transition

When the issue is CAPTURE_FULLY_REFUNDED, stop automatic retries and move the local operation to reconcile_required.

if (response.status === 422) {
  const body = await response.json();
  const issues = body.details?.map(
    (detail: { issue: string }) => detail.issue
  ) ?? [];

  if (issues.includes("CAPTURE_FULLY_REFUNDED")) {
    await markReconcileRequired(refund.id);
    await reconcilePayPalRefund(refund);
    return;
  }
}
Enter fullscreen mode Exit fullscreen mode

Reconciliation should confirm the external refund ID, amount, currency, and status before completing the local row. Avoid changing CAPTURE_FULLY_REFUNDED directly into success because PayPal may have been updated by a different system.

The invariant is:

PayPal has refunded the intended amount
AND the local operation points to that external state
AND another retry cannot issue or record the refund again
Enter fullscreen mode Exit fullscreen mode

How do I test this in a Lovable or Bolt app?

Use a stateful PayPal service twin to force the failure before deployment.

FetchSandbox includes a curated PayPal Payments workflow called capture_and_refund. Its normal path:

  1. Reads capture 2GG279541U471931P
  2. Requests a partial refund of $25.00 USD
  3. Expects 201 Created
  4. Emits PAYMENT.CAPTURE.REFUNDED

The refund_not_allowed scenario changes the second step to:

response_status: 422
error_code: CAPTURE_FULLY_REFUNDED
error_detail: "The capture has already been fully refunded."
Enter fullscreen mode Exit fullscreen mode

Add FetchSandbox MCP to Cursor, Claude, or another MCP-capable agent and ask:

Run paypal-payments capture_and_refund with refund_not_allowed.
Inspect my handler for the exact 422 issue. Prove it stops retries
and reconciles before reporting a failure. Return the receipt URL.
Enter fullscreen mode Exit fullscreen mode

The run receipt records the request sequence and exact error contract. Your app-level assertion checks the database and UI.

For CI:

fetchsandbox run <sandbox-id> capture_and_refund \
  --scenario refund_not_allowed \
  --json
Enter fullscreen mode Exit fullscreen mode

This gives the generated integration a failure-path acceptance test instead of relying on a code reviewer to infer behavior from the happy path.

Can Bolt preview receive PayPal webhooks?

Not directly. PayPal sends webhooks to a publicly reachable HTTPS endpoint, while Bolt's browser-based preview is not a public webhook destination. Deploy to Bolt Cloud, Netlify, or another host for final PayPal webhook connectivity testing.

The service twin handles a different job: it lets the agent exercise provider state, API errors, and expected webhook events before that deployment. Use both layers:

  1. FetchSandbox for repeatable failure branches during development
  2. PayPal sandbox for final credentials and webhook connectivity
  3. Production only after both paths pass

Does a service twin replace an API mock?

No. A mock is useful when a component needs a shaped response. A twin is useful when correctness depends on what happened in earlier calls.

Refund testing needs state:

captured amount
- previous partial refunds
= remaining refundable amount
Enter fullscreen mode Exit fullscreen mode

That is why CAPTURE_FULLY_REFUNDED is a lifecycle test rather than a fixture test.

Pull-request checklist

Before merging an AI-generated PayPal refund flow, attach evidence that:

  • one refund operation stores one PayPal-Request-Id
  • timeout retries reuse that ID
  • the handler parses details[].issue
  • CAPTURE_FULLY_REFUNDED stops automatic retries
  • local state reconciles before the UI reports success or failure
  • the pipeline can reproduce the same 422 path

The complete canonical guide is Test PayPal Refunds in Lovable Before Production. For the wider workflow, see integration testing for AI-built apps.

Top comments (0)