As per PayPal merchant technical support, recurring PayPal payment agreements that are created with a link like
https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=###
and have an ID starting with "I-" cannot be invoiced using a PayPal API.
Instead, one has to use reference transactions, which can only be created with the PayPal API (as opposed to a link to a 'hosted button' as shown above).
Now, PayPal merchant accounts don't have Reference Transactions enabled by default; one has to call or email PayPal to get that turned on (some guidance available).
Once reference transactions are enabled (took about two weeks of back & forth with PayPal for mine to be enabled, but maybe I'm less lucky than most :^), one can use the Express Checkout API (among others) to create reference transactions.
Reference transactions are not listed in automated SFTP RPP reports that one can sign up for. They do show up on the merchant hub (and their ID starts with a B- instead of an I-), and one can get the email address associated with the reference transaction from the merchant hub from the tool tip that shows up when hovering the mouse over the listing (the payer email address is not shown when looking at the reference transaction details). One can also get the payer information using GetExpressCheckoutDetails in the code that gets called by the returnUrl, passing it the token that gets passed as a parameter to the returnUrl (same token that is used to call CreateBillingAgreement).
Here's php code that handles the creation of a PayPal reference transaction. One single php file to handle it all. I left some (commented out) debugging code in there to help you walk through this if need be.
<?php
require_once( dirname(__FILE__) . '/ppconfig.php' );
/* ppconfig.php should contain something like:
<?php
global $ppApiUser, $ppApiPwd, $ppApiSig;
$ppApiUser = '...';
$ppApiPwd = '...';
$ppApiSig = '...';
?>
*/
//print_r($_GET);
if (!array_key_exists("a", $_GET))
Intro();
else switch ($_GET["a"])
{
case "go":
Setup();
break;
case "cf":
Confirmed();
break;
case "cx":
Cancelled();
break;
default:
Intro();
break;
}
function Intro()
{
echo("<p>Please <a href='" . BaseUrl() . "?a=go'>click here</a> to create a PayPal billing agreement.</p>");
}
function Setup()
{
$post = [
'PAYMENTREQUEST_0_PAYMENTACTION' => 'AUTHORIZATION',
'PAYMENTREQUEST_0_AMT' => '0',
'PAYMENTREQUEST_0_CURRENCYCODE' => 'USD',
'L_BILLINGTYPE0' => 'MerchantInitiatedBilling',
'L_BILLINGAGREEMENTDESCRIPTION0' => 'Monthly Fee',
'cancelUrl' => BaseUrl() . '?a=cx',
'returnUrl' => BaseUrl() . '?a=cf'
];
$post = SetupPostArray($post, 'SetExpressCheckout');
//echo "<p>query: " . http_build_query($post) . "</p>";
$parsedResponse = DoCurl($post);
// example response: Array ( [TOKEN] => EC-9WG24287H6582094R [TIMESTAMP] => 2021-05-17T18:14:09Z [CORRELATIONID] => 37010d4454fac [ACK] => Success [VERSION] => 86 [BUILD] => 55627781 )
if ($parsedResponse['ACK'] === "Success")
Redirect("https://www.paypal.com/cgi-bin/webscr?cmd=_express-checkout&token=" . $parsedResponse['TOKEN']);
else
echo("<p>There was an issue creating your PayPal billing agreement; please forward this to us: SetExpressCheckout response = " . print_r($parsedResponse, true) . "</p>");
}
function Confirmed()
{
//$callerIp = $_SERVER['REMOTE_ADDR'];
//$callerName = gethostbyaddr ( $callerIp );
//echo("<p>Caller ip: $callerIp Caller name: $callerName</p>"); returns my own address
// sample request url: https://xxx.php?a=cf&token=EC-6F958498XP432134J
// (paypal parameter same as cancel)
$details = GetExpressCheckoutDetails($_GET["token"]);
//echo("<p>Payer email: " . $details['EMAIL'] . " Payer ID: " . $details['PAYERID'] . "</p>");
$post = SetupPostArray([ 'TOKEN' => $_GET["token"] ], 'CreateBillingAgreement');
$parsedResponse = DoCurl($post);
// sample response to create billing agreement: Array ( [BILLINGAGREEMENTID] => B-4EM76674LS64xxxxx [TIMESTAMP] => 2021-05-17T17:51:59Z [CORRELATIONID] => 3be427e93xxxx [ACK] => Success [VERSION] => 86 [BUILD] => 55627781 )
if ($parsedResponse['ACK'] === "Success")
{
echo("<p>You successfully setup a pre-authorized payment (" . $parsedResponse['BILLINGAGREEMENTID'] . "). Thank you.</p>");
}
else
echo("<p>There was an issue creating your PayPal billing agreement; please forward this to us: CreateBillingAgreement = " . print_r($parsedResponse, true) . "</p>");
}
function Cancelled()
{
// sample request url: https://xxx.php?a=cx&token=EC-6F958498XP432134J
// (paypal parameter same as success)
echo("<p>It looks like you cancelled the creation of your PayPal pre-authorized payment (how could you!) Please <a href='" . BaseUrl() . "?a=go'>click here</a> to try again.</p>");
}
function GetExpressCheckoutDetails($token)
{
$post = SetupPostArray([ 'TOKEN' => $token ], 'GetExpressCheckoutDetails');
//echo "<p>GetExpressCheckoutDetails query: " . http_build_query($post) . "</p>";
$parsedResponse = DoCurl($post);
// sample response to GetExpressCheckoutDetails: Array ( [TOKEN] => EC-87S04858V0280572C [BILLINGAGREEMENTACCEPTEDSTATUS] => 1 [CHECKOUTSTATUS] => PaymentActionNotInitiated [TIMESTAMP] => 2021-05-19T15:16:06Z [CORRELATIONID] => f5d17498exxxx [ACK] => Success [VERSION] => 86 [BUILD] => 55627781 [EMAIL] => xxxx@gmail.com [PAYERID] => xxxx [PAYERSTATUS] => verified [FIRSTNAME] => xxx [LASTNAME] => xxx [COUNTRYCODE] => xx [SHIPTONAME] => xxx xxx [SHIPTOSTREET] => xxx Dr [SHIPTOCITY] => xxx [SHIPTOSTATE] => xx [SHIPTOZIP] => xxxx [SHIPTOCOUNTRYCODE] => xx [SHIPTOCOUNTRYNAME] => xxx [ADDRESSSTATUS] => Confirmed [CURRENCYCODE] => USD [AMT] => 0.00 [ITEMAMT] => 0.00 [SHIPPINGAMT] => 0.00 [HANDLINGAMT] => 0.00 [TAXAMT] => 0.00 [INSURANCEAMT] => 0.00 [SHIPDISCAMT] => 0.00 [INSURANCEOPTIONOFFERED] => false [PAYMENTREQUEST_0_CURRENCYCODE] => USD [PAYMENTREQUEST_0_AMT] => 0.00 [PAYMENTREQUEST_0_ITEMAMT] => 0.00 [PAYMENTREQUEST_0_SHIPPINGAMT] => 0.00 [PAYMENTREQUEST_0_HANDLINGAMT] => 0.00 [PAYMENTREQUEST_0_TAXAMT] => 0.00 [PAYMENTREQUEST_0_INSURANCEAMT] => 0.00 [PAYMENTREQUEST_0_SHIPDISCAMT] => 0.00 [PAYMENTREQUEST_0_SELLERPAYPALACCOUNTID] => xxx@xxx.com [PAYMENTREQUEST_0_INSURANCEOPTIONOFFERED] => false [PAYMENTREQUEST_0_SHIPTONAME] => xxx xxx [PAYMENTREQUEST_0_SHIPTOSTREET] => xxx Dr [PAYMENTREQUEST_0_SHIPTOCITY] => xxx [PAYMENTREQUEST_0_SHIPTOSTATE] => xx [PAYMENTREQUEST_0_SHIPTOZIP] => xxxxx [PAYMENTREQUEST_0_SHIPTOCOUNTRYCODE] => xx [PAYMENTREQUEST_0_SHIPTOCOUNTRYNAME] => xxx [PAYMENTREQUEST_0_ADDRESSSTATUS] => Confirmed [PAYMENTREQUESTINFO_0_ERRORCODE] => 0 )
//echo("<p>GetExpressCheckoutDetails response: " . print_r($parsedResponse, true) . "</p>");
return $parsedResponse;
}
function SetupPostArray($post, $method)
{
global $ppApiUser, $ppApiPwd, $ppApiSig;
$post['USER'] = $ppApiUser;
$post['PWD'] = $ppApiPwd;
$post['SIGNATURE'] = $ppApiSig;
$post['METHOD'] = $method;
$post['VERSION'] = '86';
return $post;
}
function DoCurl($post)
{
$ch = curl_init("https://api-3t.paypal.com/nvp");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post));
$response = curl_exec($ch);
curl_close($ch);
//echo("<p>Raw response: '$response'</p>");
// example response: Array ( [TOKEN] => EC-9WG24287H6582094R [TIMESTAMP] => 2021-05-17T18:14:09Z [CORRELATIONID] => 37010d445xxxx [ACK] => Success [VERSION] => 86 [BUILD] => 55627781 )
parse_str($response, $parsedResponse);
return $parsedResponse;
}
function Redirect($url)
{
echo "<script type='text/javascript'>
window.location = '$url';
</script>";
}
function BaseUrl()
{
//return strtok($_SERVER["REQUEST_URI"], '?'); // see https://stackoverflow.com/a/6975045/68936
return strtok("https://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]", '?'); // see stackoverflow.com/a/6975045, stackoverflow.com/a/6768831
}
?>