onComplete gives you everything you listed, including the donor’s custom message (cm). You just grab it and POST it to your PHP endpoint. Here’s a clean, working example.
<div id="paypal-donate-button-container"></div>
<script src="https://www.paypalobjects.com/donate/sdk/donate-sdk.js" charset="UTF-8"></script>
<script>
PayPal.Donation.Button({
env: 'sandbox', // switch to 'production' when live
hosted_button_id: 'YOUR_SANDBOX_HOSTED_BUTTON_ID',
image: {
src: 'https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif',
title: 'PayPal - The safer, easier way to pay online!',
alt: 'Donate with PayPal button'
},
onComplete: function (params) {
// params contains: tx, st, amt, cc, cm, item_number, item_name
// Send it to your server
fetch('/paypal-complete.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tx: params.tx,
st: params.st,
amt: params.amt,
cc: params.cc,
cm: params.cm, // <-- donor’s custom message
item_number: params.item_number,
item_name: params.item_name
})
})
.then(() => location.href = '/thank-you.html')
.catch(() => location.href = '/thank-you.html'); // show thanks either way
}
}).render('#paypal-donate-button-container');
</script>
Receives the JSON, stores it, and (recommended) verifies the payment.
<?php
// /paypal-complete.php
// 1) Read JSON payload
$raw = file_get_contents('php://input');
$data = json_decode($raw, true) ?: [];
// 2) Pull fields (with basic safety)
$tx = $data['tx'] ?? '';
$st = $data['st'] ?? '';
$amt = $data['amt'] ?? '';
$cc = $data['cc'] ?? '';
$cm = $data['cm'] ?? ''; // donor message
$item_number = $data['item_number'] ?? '';
$item_name = $data['item_name'] ?? '';
// 3) Persist immediately (DB/log) so nothing is lost
// Example log (replace with real DB insert):
file_put_contents(__DIR__ . '/paypal_donations.log',
date('c') . " | tx=$tx st=$st amt=$amt $cc | item=$item_number/$item_name | cm=" . str_replace(["\n","\r"], ' ', $cm) . PHP_EOL,
FILE_APPEND
);
// 4) (Recommended) Verify the transaction server-to-server
// Option A: PDT (Payments Data Transfer)
$useSandbox = true; // false in production
$pdtIdentityToken = 'YOUR_PDT_IDENTITY_TOKEN';
if ($tx && $pdtIdentityToken) {
$endpoint = $useSandbox
? 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr'
: 'https://ipnpb.paypal.com/cgi-bin/webscr';
$payload = http_build_query([
'cmd' => '_notify-synch',
'tx' => $tx,
'at' => $pdtIdentityToken
]);
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => false,
CURLOPT_SSL_VERIFYPEER => true,
]);
$res = curl_exec($ch);
curl_close($ch);
if ($res !== false && strncmp($res, "SUCCESS", 7) === 0) {
// Parse key=value lines after the first line
$lines = explode("\n", $res);
array_shift($lines);
$pdt = [];
foreach ($lines as $line) {
$parts = explode("=", $line, 2);
if (count($parts) === 2) $pdt[urldecode($parts[0])] = urldecode($parts[1]);
}
// Sanity checks (examples):
// if (($pdt['payment_status'] ?? '') === 'Completed'
// && ($pdt['mc_gross'] ?? '') == $amt
// && ($pdt['mc_currency'] ?? '') == $cc
// && ($pdt['txn_id'] ?? '') == $tx) { /* mark as verified */ }
http_response_code(204); // all good
exit;
}
}
// If verification isn’t configured yet, still return 202 so your JS can move on.
// (But do not mark donation as "confirmed" in your DB until verified.)
http_response_code(202);
cm is the donor’s typed message. You get it as params.cm, and you can POST it as shown.
onComplete is handy, but it’s client-side. Always verify server-side (PDT like above, or IPN webhook) before you mark a donation as confirmed.
If you also want to pass your own metadata through the button, use the custom variable in the button config; it will arrive in PDT/IPN.
Same-origin? No CORS drama. Different domain? Enable CORS on your PHP route.
If you want, I can add a tiny MySQL table + insert snippet so you can save the donation and message cleanly.