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";
};
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",
},
}),
}
);
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."
}
]
}
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;
}
}
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
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:
- Reads capture
2GG279541U471931P - Requests a partial refund of
$25.00 USD - Expects
201 Created - 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."
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.
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
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:
- FetchSandbox for repeatable failure branches during development
- PayPal sandbox for final credentials and webhook connectivity
- 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
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_REFUNDEDstops 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)