On this page
No Headings
Last updated: June 4, 2026
Refund captured payments to customers after an initial transaction. Common scenarios include:
This integration uses the Payments API v2 to process full or partial refunds with the capture ID from the original payment. Add refund endpoints to your existing PayPal integration with comprehensive error handling and negative testing capabilities.
The code examples on this page use USD and US-based scenarios. PayPal supports multiple currencies and countries. See currency codes and country codes for the full list, and payment methods for country-specific options.
Add the following to your existing server file from the quick start integration.
# Refund a captured payment
curl -X POST https://api-m.sandbox.paypal.com/v2/payments/captures/CAPTURE_ID/refund \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-d '{
"amount": {
"value": "25.00",
"currency_code": "USD"
},
"note_to_payer": "Refund processed"
}'
# Get refund status
curl -X GET https://api-m.sandbox.paypal.com/v2/payments/refunds/REFUND_ID \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ACCESS_TOKEN"// Add these endpoints to your existing server.js from the quick start integration
// The PayPal client and addNegativeTesting helper are already configured
// Process refund
app.post('/api/captures/:captureID/refund', async (req, res) => {
const request = new paypal.payments.CapturesRefundRequest(req.params.captureID);
// Optional: Set refund amount (if not set, refunds full amount)
if (req.body.amount) {
request.requestBody({
amount: {
value: req.body.amount,
currency_code: 'USD'
},
note_to_payer: req.body.note || 'Refund processed'
});
}
// Apply negative testing if enabled (reuse helper from base integration)
addNegativeTesting(request);
try {
const refund = await client.execute(request);
res.json({
id: refund.result.id,
status: refund.result.status,
amount: refund.result.amount.value
});
} catch (err) {
// Handle specific refund errors
if (err.statusCode === 422) {
const errorDetail = err.details?.[0];
if (errorDetail?.issue === 'CAPTURE_FULLY_REFUNDED') {
res.status(400).json({
error: 'Cannot refund - already refunded. Check capture status.',
captureId: req.params.captureID
});
} else if (errorDetail?.issue === 'REFUND_AMOUNT_EXCEEDED') {
res.status(400).json({
error: 'Refund amount exceeds available balance',
maxRefundable: errorDetail.description
});
} else {
res.status(400).json({
error: 'Invalid refund request'
});
}
} else {
res.status(500).json({
error: err.message
});
}
}
});
// Get refund status (optional but useful)
app.get('/api/refunds/:refundID', async (req, res) => {
const request = new paypal.payments.RefundsGetRequest(req.params.refundID);
addNegativeTesting(request);
try {
const refund = await client.execute(request);
res.json({
id: refund.result.id,
status: refund.result.status,
amount: refund.result.amount.value
});
} catch (err) {
res.status(404).json({
error: 'Refund not found'
});
}
});from paypalrestsdk import Api, Refund, Capture
import os
# Configure PayPal SDK
api = Api({
'mode': 'sandbox',
'client_id': os.environ['PAYPAL_CLIENT_ID'],
'client_secret': os.environ['PAYPAL_CLIENT_SECRET']
})
# Process refund
@app.route('/api/captures/<capture_id>/refund', methods=['POST'])
def process_refund(capture_id):
try:
refund_data = {
"amount": {
"value": request.json.get('amount'),
"currency": "USD"
},
"note_to_payer": request.json.get('note', 'Refund processed')
}
# Create refund
refund = Refund({
"capture_id": capture_id,
**refund_data
})
if refund.create():
return jsonify({
"id": refund.id,
"status": refund.state,
"amount": refund.amount.total
})
else:
# Handle errors
error = refund.error
if error.get('name') == 'CAPTURE_FULLY_REFUNDED':
return jsonify({
"error": "Cannot refund - already refunded"
}), 400
elif error.get('name') == 'REFUND_AMOUNT_EXCEEDED':
return jsonify({
"error": "Refund amount exceeds available balance"
}), 400
else:
return jsonify({"error": error.get('message')}), 500
except Exception as e:
return jsonify({"error": str(e)}), 500
# Get refund status
@app.route('/api/refunds/<refund_id>', methods=['GET'])
def get_refund_status(refund_id):
try:
refund = Refund.find(refund_id)
return jsonify({
"id": refund.id,
"status": refund.state,
"amount": refund.amount.total
})
except Exception as e:
return jsonify({"error": "Refund not found"}), 404import com.paypal.core.PayPalEnvironment;
import com.paypal.core.PayPalHttpClient;
import com.paypal.payments.CapturesRefundRequest;
import com.paypal.payments.RefundsGetRequest;
import com.paypal.payments.Refund;
HttpClient httpClient = new PayPalHttpClient(
new SandboxEnvironment(
System.getenv("PAYPAL_CLIENT_ID"),
System.getenv("PAYPAL_CLIENT_SECRET")
)
);
// Process refund
@PostMapping("/api/captures/{captureId}/refund")
public ResponseEntity<?> processRefund(
@PathVariable String captureId,
@RequestBody RefundRequest refundRequest
) {
try {
CapturesRefundRequest request = new CapturesRefundRequest(captureId);
// Set refund amount if provided
if (refundRequest.getAmount() != null) {
RefundRequestBody body = new RefundRequestBody()
.amount(new Money()
.value(refundRequest.getAmount())
.currencyCode("USD")
)
.noteToPayer(refundRequest.getNote() != null ?
refundRequest.getNote() : "Refund processed"
);
request.requestBody(body);
}
HttpResponse<Refund> response = httpClient.execute(request);
Refund refund = response.result();
return ResponseEntity.ok(Map.of(
"id", refund.id(),
"status", refund.status(),
"amount", refund.amount().value()
));
} catch (HttpClientException e) {
if (e.statusCode() == 422) {
String issue = extractIssue(e);
if ("CAPTURE_FULLY_REFUNDED".equals(issue)) {
return ResponseEntity.badRequest().body(
Map.of("error", "Cannot refund - already refunded")
);
} else if ("REFUND_AMOUNT_EXCEEDED".equals(issue)) {
return ResponseEntity.badRequest().body(
Map.of("error", "Refund amount exceeds available balance")
);
}
}
return ResponseEntity.status(500).body(
Map.of("error", e.getMessage())
);
}
}
// Get refund status
@GetMapping("/api/refunds/{refundId}")
public ResponseEntity<?> getRefundStatus(@PathVariable String refundId) {
try {
RefundsGetRequest request = new RefundsGetRequest(refundId);
HttpResponse<Refund> response = httpClient.execute(request);
Refund refund = response.result();
return ResponseEntity.ok(Map.of(
"id", refund.id(),
"status", refund.status(),
"amount", refund.amount().value()
));
} catch (Exception e) {
return ResponseEntity.status(404).body(
Map.of("error", "Refund not found")
);
}
}<?php
use PayPalCheckoutSdk\Core\SandboxEnvironment;
use PayPalCheckoutSdk\Core\PayPalHttpClient;
use PayPalCheckoutSdk\Payments\CapturesRefundRequest;
use PayPalCheckoutSdk\Payments\RefundsGetRequest;
$environment = new SandboxEnvironment(
getenv('PAYPAL_CLIENT_ID'),
getenv('PAYPAL_CLIENT_SECRET')
);
$client = new PayPalHttpClient($environment);
// Process refund
$app->post('/api/captures/{captureID}/refund', function ($request, $response, $args) use ($client) {
$captureID = $args['captureID'];
$body = $request->getParsedBody();
$refundRequest = new CapturesRefundRequest($captureID);
// Set refund amount if provided
if (isset($body['amount'])) {
$refundRequest->body = [
"amount" => [
"value" => $body['amount'],
"currency_code" => "USD"
],
"note_to_payer" => $body['note'] ?? "Refund processed"
];
}
try {
$refundResponse = $client->execute($refundRequest);
$refund = $refundResponse->result;
$response->getBody()->write(json_encode([
"id" => $refund->id,
"status" => $refund->status,
"amount" => $refund->amount->value
]));
return $response->withHeader('Content-Type', 'application/json');
} catch (HttpException $e) {
$statusCode = $e->statusCode;
$errorData = json_decode($e->getMessage(), true);
if ($statusCode === 422) {
$issue = $errorData['details'][0]['issue'] ?? '';
if ($issue === 'CAPTURE_FULLY_REFUNDED') {
$response->getBody()->write(json_encode([
"error" => "Cannot refund - already refunded"
]));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
} else if ($issue === 'REFUND_AMOUNT_EXCEEDED') {
$response->getBody()->write(json_encode([
"error" => "Refund amount exceeds available balance"
]));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
}
$response->getBody()->write(json_encode([
"error" => $e->getMessage()
]));
return $response->withStatus(500)->withHeader('Content-Type', 'application/json');
}
});
// Get refund status
$app->get('/api/refunds/{refundID}', function ($request, $response, $args) use ($client) {
$refundID = $args['refundID'];
try {
$refundRequest = new RefundsGetRequest($refundID);
$refundResponse = $client->execute($refundRequest);
$refund = $refundResponse->result;
$response->getBody()->write(json_encode([
"id" => $refund->id,
"status" => $refund->status,
"amount" => $refund->amount->value
]));
return $response->withHeader('Content-Type', 'application/json');
} catch (Exception $e) {
$response->getBody()->write(json_encode([
"error" => "Refund not found"
]));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}
});require 'paypal-sdk'
# Configure PayPal SDK
PayPal::SDK.configure(
:mode => "sandbox",
:app_id => ENV['PAYPAL_APP_ID'],
:client_id => ENV['PAYPAL_CLIENT_ID'],
:client_secret => ENV['PAYPAL_CLIENT_SECRET']
)
# Process refund
post '/api/captures/:capture_id/refund' do
capture_id = params['capture_id']
request_body = JSON.parse(request.body.read)
begin
refund_data = {
amount: {
value: request_body['amount'],
currency_code: 'USD'
},
note_to_payer: request_body['note'] || 'Refund processed'
}
# Create refund
refund = PayPal::SDK::PaymentsApi::Refund.new(
capture_id: capture_id,
**refund_data
)
if refund.create
content_type :json
{
id: refund.id,
status: refund.status,
amount: refund.amount.value
}.to_json
else
error = refund.error
if error['name'] == 'CAPTURE_FULLY_REFUNDED'
status 400
{ error: 'Cannot refund - already refunded' }.to_json
elsif error['name'] == 'REFUND_AMOUNT_EXCEEDED'
status 400
{ error: 'Refund amount exceeds available balance' }.to_json
else
status 500
{ error: error['message'] }.to_json
end
end
rescue => e
status 500
{ error: e.message }.to_json
end
end
# Get refund status
get '/api/refunds/:refund_id' do
refund_id = params['refund_id']
begin
refund = PayPal::SDK::PaymentsApi::Refund.find(refund_id)
content_type :json
{
id: refund.id,
status: refund.status,
amount: refund.amount.value
}.to_json
rescue => e
status 404
{ error: 'Refund not found' }.to_json
end
end# Test full refund (replace with actual capture ID)
curl -X POST http://localhost:3000/api/captures/3C679366HH908993F/refund \
-H "Content-Type: application/json"
# Expected success response:
# {"id":"WH4YN4SYEDZJA","status":"COMPLETED","amount":"100.00"}
# Test partial refund with note
curl -X POST http://localhost:3000/api/captures/3C679366HH908993F/refund \
-H "Content-Type: application/json" \
-d '{"amount": "25.00", "note": "Partial refund for damaged item"}'
# Test refund status check
curl http://localhost:3000/api/refunds/WH4YN4SYEDZJA
# Expected response:
# {"id":"WH4YN4SYEDZJA","status":"COMPLETED","amount":"25.00"}// Test full refund
const fullRefundResponse = await fetch('http://localhost:3000/api/captures/3C679366HH908993F/refund', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const fullRefund = await fullRefundResponse.json();
console.log('Full refund:', fullRefund);
// Expected: {"id":"WH4YN4SYEDZJA","status":"COMPLETED","amount":"100.00"}
// Test partial refund with note
const partialRefundResponse = await fetch('http://localhost:3000/api/captures/3C679366HH908993F/refund', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: '25.00',
note: 'Partial refund for damaged item'
})
});
const partialRefund = await partialRefundResponse.json();
console.log('Partial refund:', partialRefund);
// Test refund status check
const statusResponse = await fetch('http://localhost:3000/api/refunds/WH4YN4SYEDZJA');
const status = await statusResponse.json();
console.log('Refund status:', status);
// Expected: {"id":"WH4YN4SYEDZJA","status":"COMPLETED","amount":"25.00"}import requests
# Test full refund
full_refund_response = requests.post(
'http://localhost:3000/api/captures/3C679366HH908993F/refund',
headers={'Content-Type': 'application/json'}
)
print('Full refund:', full_refund_response.json())
# Expected: {"id":"WH4YN4SYEDZJA","status":"COMPLETED","amount":"100.00"}
# Test partial refund with note
partial_refund_response = requests.post(
'http://localhost:3000/api/captures/3C679366HH908993F/refund',
headers={'Content-Type': 'application/json'},
json={
'amount': '25.00',
'note': 'Partial refund for damaged item'
}
)
print('Partial refund:', partial_refund_response.json())
# Test refund status check
status_response = requests.get(
'http://localhost:3000/api/refunds/WH4YN4SYEDZJA'
)
print('Refund status:', status_response.json())
# Expected: {"id":"WH4YN4SYEDZJA","status":"COMPLETED","amount":"25.00"}import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URI;
HttpClient client = HttpClient.newHttpClient();
// Test full refund
HttpRequest fullRefundRequest = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:3000/api/captures/3C679366HH908993F/refund"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> fullRefundResponse = client.send(
fullRefundRequest,
HttpResponse.BodyHandlers.ofString()
);
System.out.println("Full refund: " + fullRefundResponse.body());
// Expected: {"id":"WH4YN4SYEDZJA","status":"COMPLETED","amount":"100.00"}
// Test partial refund with note
String partialRefundBody = "{\"amount\":\"25.00\",\"note\":\"Partial refund for damaged item\"}";
HttpRequest partialRefundRequest = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:3000/api/captures/3C679366HH908993F/refund"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(partialRefundBody))
.build();
HttpResponse<String> partialRefundResponse = client.send(
partialRefundRequest,
HttpResponse.BodyHandlers.ofString()
);
System.out.println("Partial refund: " + partialRefundResponse.body());
// Test refund status check
HttpRequest statusRequest = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:3000/api/refunds/WH4YN4SYEDZJA"))
.GET()
.build();
HttpResponse<String> statusResponse = client.send(
statusRequest,
HttpResponse.BodyHandlers.ofString()
);
System.out.println("Refund status: " + statusResponse.body());
// Expected: {"id":"WH4YN4SYEDZJA","status":"COMPLETED","amount":"25.00"}<?php
// Test full refund
$fullRefundResponse = file_get_contents(
'http://localhost:3000/api/captures/3C679366HH908993F/refund',
false,
stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-Type: application/json'
]
])
);
echo "Full refund: " . $fullRefundResponse . "\n";
// Expected: {"id":"WH4YN4SYEDZJA","status":"COMPLETED","amount":"100.00"}
// Test partial refund with note
$partialRefundData = json_encode([
'amount' => '25.00',
'note' => 'Partial refund for damaged item'
]);
$partialRefundResponse = file_get_contents(
'http://localhost:3000/api/captures/3C679366HH908993F/refund',
false,
stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-Type: application/json',
'content' => $partialRefundData
]
])
);
echo "Partial refund: " . $partialRefundResponse . "\n";
// Test refund status check
$statusResponse = file_get_contents(
'http://localhost:3000/api/refunds/WH4YN4SYEDZJA'
);
echo "Refund status: " . $statusResponse . "\n";
// Expected: {"id":"WH4YN4SYEDZJA","status":"COMPLETED","amount":"25.00"}require 'net/http'
require 'json'
# Test full refund
uri = URI('http://localhost:3000/api/captures/3C679366HH908993F/refund')
full_refund_request = Net::HTTP::Post.new(uri)
full_refund_request['Content-Type'] = 'application/json'
full_refund_response = Net::HTTP.start(uri.hostname, uri.port) do |http|
http.request(full_refund_request)
end
puts "Full refund: #{full_refund_response.body}"
# Expected: {"id":"WH4YN4SYEDZJA","status":"COMPLETED","amount":"100.00"}
# Test partial refund with note
partial_refund_request = Net::HTTP::Post.new(uri)
partial_refund_request['Content-Type'] = 'application/json'
partial_refund_request.body = {
amount: '25.00',
note: 'Partial refund for damaged item'
}.to_json
partial_refund_response = Net::HTTP.start(uri.hostname, uri.port) do |http|
http.request(partial_refund_request)
end
puts "Partial refund: #{partial_refund_response.body}"
# Test refund status check
status_uri = URI('http://localhost:3000/api/refunds/WH4YN4SYEDZJA')
status_response = Net::HTTP.get_response(status_uri)
puts "Refund status: #{status_response.body}"
# Expected: {"id":"WH4YN4SYEDZJA","status":"COMPLETED","amount":"25.00"}Use the following best practices to ensure refunds are processed safely, accurately, and in compliance with operational and regulatory requirements.
note_to_payer field for customer clarity.capture.result.id when you capture a payment. Store this value in your database immediately. You'll need it for any future refunds on that transaction.PAYMENT.CAPTURE.REFUNDED events for real-time status updates. Always verify webhook signatures for security.Make sure you have sandbox account credentials for both buyer and seller roles. Complete a test payment to get a valid capture ID.
| Test scenario | Setup | Expected result |
|---|---|---|
| Full refund success | Default settings | Entire payment amount refunded. |
| Partial refund success | Default settings | Specified amount refunded. |
| Multiple partial refund success | Default settings | Each refunds succeeds until limit. |
| Invalid capture ID | Fake IDL XXX123 | 404 error: capture not found. |
| Refund after 3 days | Wait 3 days | Success if within 180 days |
For negative testing:
.env file, set ENABLE_NEGATIVE_TESTING=true and set NEGATIVE_TEST_TYPE to one of the error codes in the table..env file: node server.js.| Test scenario | Error code | Expected result |
|---|---|---|
| Exceed original amount | REFUND_AMOUNT_EXCEEDED | Error: refund amount exceeds capture. |
| Already fully refunded | CAPTURE_FULLY_REFUNDED | Error: already fully refunded. |
| Refund after 180 days | REFUND_NOT_ALLOWED_AFTER_180_DAYS | Error: refund period expired. |
| Permission denied | PERMISSION_DENIED | 403 error: no refund permission. |
| Internal server error | INTERNAL_SERVER_ERROR | Error: 500 error occurred at refund. |
These values are suggested monitoring thresholds for your integration, not performance guarantees from PayPal.
| Metric | Target | Action if below target |
|---|---|---|
| Refund success rate | 98% | Check API errors and validate capture IDs. |
| Refund processing time | <5 seconds | Optimize database queries. |
| Failed refund rate | <2% | Review error logs, check amounts. |
| Refund-to-payment ratio | <5% | Analyze if high - may indicate quality issues. |
| API response time | <2 seconds | Check PayPal API status. |