Your Lovable app sends a PayPal refund. The request times out, so the app shows Refund failed and offers the support agent a Retry button.
They click it. PayPal answers 422 CAPTURE_FULLY_REFUNDED because the first request worked and the money is already on its way back.
Short answer
A PayPal refund can succeed even when your app never receives the success response. Retry the same request with the same PayPal-Request-Id, inspect the specific issue inside any 422 response, and reconcile PayPal's state before marking the refund failed. Test the CAPTURE_FULLY_REFUNDED branch before launch, not with a real customer payment.
That is the branch AI app builders rarely generate on the first pass. Lovable, Bolt, v0, and Replit can produce the order and refund endpoints quickly. The happy path is usually fine. The ambiguous response is where local state and PayPal state split.
Why a 422 can mean the money is already back
PayPal refunds a captured payment through:
POST /v2/payments/captures/{capture_id}/refund
A successful refund normally returns 201 Created. But your process can lose that response after PayPal commits the refund:
your app ── refund request ──> PayPal
your app <── 201 Created ──X connection drops
Your database still says refund_pending. The browser saw a timeout. A generic retry creates a new request, and PayPal now sees a capture whose refundable balance is zero:
{
"name": "UNPROCESSABLE_ENTITY",
"details": [
{
"issue": "CAPTURE_FULLY_REFUNDED",
"description": "The capture has already been fully refunded."
}
]
}
That response is not equivalent to REFUND_AMOUNT_EXCEEDED, REFUND_NOT_PERMITTED_DUE_TO_CHARGEBACK, or a permission failure. They may all arrive as 422, but they require different product behavior.
If generated code checks only response.ok, every one becomes the same red banner:
const response = await fetch(`/api/paypal/refund/${captureId}`, {
method: "POST",
});
if (!response.ok) {
throw new Error("Refund failed");
}
The HTTP branch is correct and the business conclusion is not.
The first fix is PayPal-Request-Id
PayPal's REST APIs use PayPal-Request-Id as the idempotency key for POST calls. Generate one identifier for the refund operation, store it with the local refund record, and reuse it when the outcome is unknown:
const requestId = refund.paypalRequestId;
await fetch(`${PAYPAL_API}/v2/payments/captures/${captureId}/refund`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"PayPal-Request-Id": requestId,
},
body: JSON.stringify({
amount: { currency_code: "USD", value: "25.00" },
}),
});
Do not generate a new UUID inside every retry attempt. That converts one business operation into several unrelated PayPal requests.
PayPal documents the intended recovery path: if a request times out, retry it with the same request ID. PayPal returns the latest status without performing the operation twice.
Idempotency is necessary, but it does not remove the need to understand the 422. Your app may encounter a fully refunded capture created by a dashboard action, another support tool, an older request outside the idempotency retention window, or a different service. External state can already be ahead of your database.
The invariant to test
Do not assert only that the refund endpoint returned 201. Assert the final business state:
partial refund accepted
PayPal refund status = COMPLETED
local refund status = completed
refundable balance = original amount - 25.00
capture already fully refunded
PayPal issue = CAPTURE_FULLY_REFUNDED
local state = reconcile_required, not generic failure
automatic retry = stopped
support UI = "Already refunded" after reconciliation
The safe branch is reconcile, then decide. Read the capture or known refund resource, compare it with the local record, and make the update idempotent. Do not tell an agent to blindly convert every CAPTURE_FULLY_REFUNDED response into success. The capture may have been refunded by another system, and your app still needs the correct refund ID, amount, and status.
Force the branch with a PayPal twin
The FetchSandbox PayPal Payments twin ships a curated workflow named capture_and_refund:
- Read capture
2GG279541U471931P - Request a partial refund of $25.00 USD
- Expect
201 Created - Emit
PAYMENT.CAPTURE.REFUNDED
Its refund_not_allowed scenario changes the refund step to the exact failure shape:
refund_not_allowed:
description: "Refund fails — capture already fully refunded"
overrides:
- operation_id: PostCapturesRefund
response_status: 422
error_code: CAPTURE_FULLY_REFUNDED
Connect the FetchSandbox MCP server, then ask Cursor, Claude, or another MCP-capable agent:
Run paypal-payments capture_and_refund under the refund_not_allowed
scenario. Show me the exact 422 body and create acceptance criteria
for my refund handler. Then verify my app stops automatic retries and
reconciles before it labels the refund failed. Give me the run receipt.
The receipt proves the failure branch was exercised: API calls in order, the 422 response, webhook events, timings, and the run verdict. Your application test supplies the other half by asserting its local refund state and UI.
For CI, run the same scenario from the CLI:
fetchsandbox run <sandbox-id> capture_and_refund \
--scenario refund_not_allowed \
--json
The agent can explore the failure locally over MCP. The pipeline can keep the same branch from regressing later.
Why the PayPal sandbox is not enough
The PayPal sandbox remains part of final validation. It proves your credentials, registered webhook, and provider connection.
It is a poor tool for repeatedly producing one narrow state on demand. Current Lovable and Bolt guides eventually recommend deploying a public endpoint and even issuing a real low-value payment before refunding it. That verifies connectivity, but it makes a business-state edge case slow and manual:
- create or locate a refundable capture
- make the first refund complete
- arrange an ambiguous local outcome
- retry against the same capture
- inspect the exact issue code
- reset everything and repeat after the next code change
A service twin does not replace PayPal. It moves this deterministic failure test earlier, while the agent is still writing the integration.
Questions people ask
How should a Lovable app retry a PayPal refund after a timeout?
Persist one PayPal-Request-Id for the refund operation and reuse it. Do not create a new idempotency key for each network attempt. If the result is still ambiguous, read PayPal's current state before changing your local record.
What does CAPTURE_FULLY_REFUNDED mean?
PayPal has no refundable balance left on that capture. The original refund may have succeeded, the capture may have been refunded elsewhere, or several partial refunds may already equal the captured amount. Treat it as a state-reconciliation case, not as a generic transport failure.
Should my app return success when PayPal returns 422?
Not automatically. Parse details[].issue, reconcile the capture or refund, and only mark the local operation complete when the external amount and status match what your app intended.
Can I test PayPal webhooks inside a Bolt preview?
The preview can make outbound API calls, but an inbound PayPal webhook needs a publicly reachable HTTPS endpoint. A service twin lets the agent exercise the provider lifecycle and error contract before deployment; use the deployed endpoint and PayPal sandbox for the final webhook connectivity check.
Does FetchSandbox replace the PayPal sandbox?
No. FetchSandbox provides repeatable pre-production states and receipts. Use PayPal's sandbox afterward to validate the real provider connection and credentials.
Before the first real refund
Ask for proof of three things:
- The same refund retry reuses
PayPal-Request-Id - The handler distinguishes
CAPTURE_FULLY_REFUNDEDfrom other 422 issues - The local record reconciles to PayPal instead of displaying a false failure
The app builder can write the endpoint in minutes. The acceptance test is what keeps a timed-out response from turning into a support incident.
Start with the PayPal service twin, or see the broader workflow for testing integrations in AI-built apps.