https://stripe.com/docs/payments/capture-later
In the stripe documentation above, they say that if you want to capture a payment hold to set the capture_method to manual and then when you're ready to finalize the charge, to send a capture request with the final amount.
I'm not seeing any documentation in the laravel cashier's implementation on how to do this.
however when I look in the code, I see that I can pass options that get merged in and I could set this I hope?
trait Billable
{
/**
* Make a "one off" charge on the customer for the given amount.
*
* #param int $amount
* #param string $paymentMethod
* #param array $options
* #return \Laravel\Cashier\Payment
*
* #throws \Laravel\Cashier\Exceptions\PaymentActionRequired
* #throws \Laravel\Cashier\Exceptions\PaymentFailure
*/
public function charge($amount, $paymentMethod, array $options = [])
{
$options = array_merge([
'confirmation_method' => 'automatic',
'confirm' => true,
'currency' => $this->preferredCurrency(),
], $options);
$options['amount'] = $amount;
$options['payment_method'] = $paymentMethod;
if ($this->stripe_id) {
$options['customer'] = $this->stripe_id;
}
$payment = new Payment(
StripePaymentIntent::create($options, $this->stripeOptions())
);
$payment->validate();
return $payment;
}
...
/**
* Create a new SetupIntent instance.
*
* #param array $options
* #return \Stripe\SetupIntent
*/
public function createSetupIntent(array $options = [])
{
return StripeSetupIntent::create(
$options, $this->stripeOptions()
);
}
but that seems to set the payment_method and not the capture_method... I feel like this would cause problems if both were set.
As I'm writing this, I think the answer is to use the createSetupIntent function with my desired parameters then save the response so I can reference the intent later and finalize the charge.
Has anyone done this? Rather has anyone reading this done it themselves? and if so, is this the right solution?
Although Laravel Cashier is amazing to start you on a subscription based model for your application, I always found Stripe's SDK way more useful, intuitive, and easy to use. Their documentation is amazing, I would suggest using the SDK itself.
Given that, you can continue to use Cashier for everything else and for this particular scenario you can make use of the SDK. For your case you'll have to create a PaymentIntent and like you said, set the capture_method to manual.
Here is an example using Stripe SDK (If you are using Cashier, you already have the package):
$stripe = new \Stripe\StripeClient(
'YOUR_SECRET_KEY'
);
$stripe->paymentIntents->create([
'amount' => 2000,
'currency' => 'usd',
'payment_method_types' => ['card'],
'capture_method' => 'manual'
]);
Related
My problem is that billing agreements are successfully executed even if the setup fee is not paid. Looking at the logs, the IPN event notifiying that the setup fee failed and the agreement is cancelled typically takes 5-10 minutes to arrive, which is an insane amount of delay.
I am using the official PayPal PHP SDK at https://github.com/paypal/PayPal-PHP-SDK. It was deprecated a month ago, but its replacement is marked "not ready for production".
Billing plan details, with the intent to charge a $29.99/yr subscription. Setup fee is used to guarantee initial payment.
Per the 2 step process documented in https://paypal.github.io/PayPal-PHP-SDK/sample/, with the wrapping try/catch blocks removed for legibility:
// Step 1: https://paypal.github.io/PayPal-PHP-SDK/sample/doc/billing/CreateBillingAgreementWithPayPal.html
use PayPal\Api\Agreement;
use PayPal\Api\MerchantPreferences;
use PayPal\Api\Payer;
use PayPal\Api\Plan;
/**
* #var \PayPal\Rest\ApiContext $apiContext
*/
$plan = Plan::get('EXAMPLE-PLAN-ID', $apiContext);
$agreement = new Agreement();
date_default_timezone_set('America/Los_Angeles');
$agreement->setName($plan->getName())
->setDescription($plan->getDescription())
// I'm not sure why +1 hour is used here, but that's how it is in the codebase.
->setStartDate(date('c', strtotime("+1 hour", time())));
$agreement->setPlan($plan);
/**
* ------------------------------------------------------------------------------------------
* I think overriding should be optional since they currently precisely match the given
* plan's data. So for this particular plan, if I deleted everything between these comment
* blocks, nothing bad should happen.
* ------------------------------------------------------------------------------------------
*/
$preferences = new MerchantPreferences();
$preferences->setReturnUrl("https://www.example.com/actually-a-valid-site")
->setCancelUrl("https://www.example.com/actually-a-valid-site")
->setAutoBillAmount('no')
->setInitialFailAmountAction('CANCEL');
$agreement->setOverrideMerchantPreferences($preferences);
/**
* ------------------------------------------------------------------------------------------
* ------------------------------------------------------------------------------------------
*/
$payer = new Payer();
$payer->setPaymentMethod('paypal');
$agreement->setPayer($payer);
$agreement = $agreement->create($apiContext);
$approvalUrl = $agreement->getApprovalLink();
// This takes us to PayPal to login and confirm payment.
header("Location: ".$approvalUrl);
// Step 2: https://paypal.github.io/PayPal-PHP-SDK/sample/doc/billing/ExecuteAgreement.html
use PayPal\Api\Agreement;
/**
* #var \PayPal\Rest\ApiContext $apiContext
*/
try {
$agreement = new Agreement();
$agreement->execute($_GET['token'], $apiContext);
$agreement = Agreement::get($agreement->getId(), $apiContext);
/**
* I assume at this point the agreement is executed successfully. Yet, the setup fee does not
* have to be paid for us to get here. This behavior is verified on live.
*/
} catch (\Exception $e) {
// Do something.
}
I'm at a loss for what I'm doing wrong that would cause the billing agreement to execute even without the setup fee being paid. Help would be appreciated!
Here's how to create the Plan that was used:
use PayPal\Api\Currency;
use PayPal\Api\MerchantPreferences;
use PayPal\Api\Patch;
use PayPal\Api\PatchRequest;
use PayPal\Api\PaymentDefinition;
use PayPal\Api\Plan;
use PayPal\Common\PayPalModel;
$plan = new Plan();
$plan->setName('Test Name')
->setDescription('Test Description')
->setType('INFINITE');
$payment_definition = new PaymentDefinition();
$payment_definition->setName('Regular Payments')
->setType('REGULAR')
->setFrequency('YEAR')
->setFrequencyInterval(1)
->setCycles('0')
->setAmount(new Currency(['value' => '29.99', 'currency' => 'USD']));
$merchant_preferences = new MerchantPreferences();
$merchant_preferences->setReturnUrl'https://insert.actual.url.here')
->setCancelUrl('https://insert.actual.url.here')
->setAutoBillAmount('NO')
->setInitialFailAmountAction('CANCEL')
->setMaxFailAttempts('1')
->setSetupFee(new Currency(['value' => '29.99', 'currency' => 'USD']));
$plan->setPaymentDefinitions([$payment_definition]);
$plan->setMerchantPreferences($merchant_preferences);
$request = clone $plan;
try {
/**
* #var \Paypal\Rest\ApiContext $apiContext
*/
$plan->create($apiContext);
$patch = new Patch();
$value = new PayPalModel(['state' => 'ACTIVE']);
$patch->setOp('replace')
->setPath('/')
->setValue($value);
$patchRequest = new PatchRequest();
$patchRequest->addPatch($patch);
if (!$plan->update($patchRequest, $apiContext)) {
throw new \Exception("Failed to apply patch to plan.");
}
// Done.
} catch (\Exception $e) {
// Some error handling.
exit;
}
The replacement SDK is https://github.com/paypal/Checkout-PHP-SDK , which does not include any billing agreement or subscription use cases. For use cases not covered by that SDK , you should use a direct HTTPS integration. This is documented here: https://developer.paypal.com/docs/api/rest-sdks/
The code you are trying to use is for an obsolete SDK for an obsolete API (old version of billing agreements, not compatible with new subscriptions).
Here is the API you should integrate, with no SDK: https://developer.paypal.com/docs/subscriptions/
Turns out an exception is not supposed to be thrown for usual agreement execution. To check whether the setup was paid, check the value of $agreement->getState() after executing the agreement.
I'm playing around with the GuzzleHttp client, GuzzleCacheMiddleware and Memcached.
The setup is calling the same url with different parameters.
This results in one! memcached hit, so I think the memcached key is created from the url and only the url.
Can I somehow change this behaviour, so the key includes a md5 of the parameters?
You would have to create your own CacheStrategy class. For example you can extend PrivateCacheStrategy class and override getCacheKey method which is responsible for creating the cache key.
https://github.com/Kevinrob/guzzle-cache-middleware/blob/master/src/Strategy/PrivateCacheStrategy.php#L123
You are right that it creates storage key based on only the URL and request method.
Decided to look into it. You are right that it needs GreedyCacheStrategy because it literally caches everything regardless of any RFC standards.
Custom class for cache key creating.
class ParamsGreedyCacheStrategy extends GreedyCacheStrategy
{
/**
* Ignoring any headers, just straight up cache key based on method, URI, request body/params
*
* #param RequestInterface $request
* #param KeyValueHttpHeader|null $varyHeaders
* #return string
*/
protected function getCacheKey(RequestInterface $request, KeyValueHttpHeader $varyHeaders = null)
{
return hash(
'sha256',
'greedy' . $request->getMethod() . $request->getUri() . $request->getBody()
);
}
}
Creating requests. I used Laravel caching here, you can use memcached. I also allow POST HTTP method to be cached, because by default only GET is being cached!
$handlerStack = HandlerStack::create();
$cacheMiddleware = new CacheMiddleware(
new ParamsGreedyCacheStrategy(
new LaravelCacheStorage(
Cache::store('file')
),
10
)
);
// Not documented, but if you look at the source code they have methods for setting allowed HTTP methods. By default, only GET is allowed (per standards).
$cacheMiddleware->setHttpMethods(['GET' => true, 'POST' => true]);
$handlerStack->push(
$cacheMiddleware,
'cache'
);
$client = new Client([
'base_uri' => 'https://example.org',
'http_errors' => false,
'handler' => $handlerStack
]);
for($i = 0; $i < 4; $i++) {
$response = $client->post('/test', [
'form_params' => ['val' => $i]
]);
// Middleware attaches 'X-Kevinrob-Cache' header that let's us know if we hit the cache or not!
dump($response->getHeader('X-Kevinrob-Cache'));
}
I am using the PHP Google MyBusiness API for my application. I have business reviews.
Now I want to get any replies related to a particular review. I am able to post a reply, but I want to get replies (responses) for the review using PHP GMB API.
How do I do so?
see developers.google.com in the response you get reviewReply object which holds your reply
{
"reviewId": string,
"reviewer": {
object(Reviewer)
},
"starRating": enum(StarRating),
"comment": string,
"createTime": string,
"updateTime": string,
"reviewReply": {
object(ReviewReply)
},
}
more info
to get the review use the get method of Google_Service_MyBusiness_AccountsLocationsReviews_Resource
class Google_Service_MyBusiness_AccountsLocationsReviews_Resource extends Google_Service_Resource
{
/**
* Returns the specified review. This operation is only valid if the specified
* location is verified. Returns `NOT_FOUND` if the review does not exist, or
* has been deleted. (reviews.get)
*
* #param string $name The name of the review to fetch.
* #param array $optParams Optional parameters.
* #return Google_Service_MyBusiness_Review
*/
public function get($name, $optParams = array())
{
$params = array('name' => $name);
$params = array_merge($params, $optParams);
return $this->call('get', array($params), "Google_Service_MyBusiness_Review");
}
I'm trying to create a web-based email client which gets all email data from google mail API. I'm using Slim3 for creating a restful API interface. To access google APIs, I'm using Google-API-PHP-Client (Google does have a rest API access and I really like it but I still haven't figured out how the authorization would work without using the PHP-client-library).
My main problem is how do I structure authentication part of it as google uses Oauth2 for login which gives a code. I can use a simple token based auth in Slim but then how do I achieve the following:
Authentication/Authorization with google.
Identifying new vs returning users.
Maintaining & Retaining both access and refresh tokens from google and local APIs
Since the API will be used on both mobile clients and web-browser, I can't use PHP's default sessions - I'm relying on database driven custom tokens.
How do I structure the APIs?
One way was to use google's token as the only token in the app - but it keeps changing every hour so How do I identify the user from token - calling google API for every incoming call doesn't seem like a graceful solution.
Any leads/links would be really helpful.
Thanks in advance
Note that there are 2 parts:
Authorization
Authentication
I recently created this very lightweight class for Authorization using Google, accessing its REST API. It is self-explanatory with the comments.
/**
* Class \Aptic\Login\OpenID\Google
* #package Aptic\Login\OpenID
* #author Nick de Jong, Aptic
*
* Very lightweight class used to login using Google accounts.
*
* One-time configuration:
* 1. Define what the inpoint redirect URIs will be where Google will redirect to upon succesfull login. It must
* be static without wildcards; but can be multiple as long as each on is statically defined.
* 2. Define what payload-data this URI could use. For example, the final URI to return to (the caller).
* 3. Create a Google Project through https://console.developers.google.com/projectselector/apis/credentials
* 4. Create a Client ID OAth 2.0 with type 'webapp' through https://console.developers.google.com/projectselector/apis/credentials
* 5. Store the 'Client ID', 'Client Secret' and defined 'Redirect URIs' (the latter one as defined in Step 1).
*
* Usage to login and obtain user data:
* 1. Instantiate a class using your stored Client ID, Client Secret and a Redirect URI.
* 2. To login, create a button or link with the result of ->getGoogleLoginPageURI() as target. You can insert
* an array of payload data in one of the parameters your app wants to know upon returning from Google.
* 3. At the Redirect URI, invoke ->getDataFromLoginRedirect(). It will return null on failure,
* or an array on success. The array contains:
* - sub string Google ID. Technically an email is not unique within Google's realm, a sub is.
* - email string
* - name string
* - given_name string
* - family_name string
* - locale string
* - picture string URI
* - hdomain string GSuite domain, if applicable.
* Additionally, the inpoint can recognize a Google redirect by having the first 6 characters of the 'state' GET
* parameter to be 'google'. This way, multiple login mechanisms can use the same redirect inpoint.
*/
class Google {
protected $clientID = '';
protected $clientSecret = '';
protected $redirectURI = '';
public function __construct($vClientID, $vClientSecret, $vRedirectURI) {
$this->clientID = $vClientID;
$this->clientSecret = $vClientSecret;
$this->redirectURI = $vRedirectURI;
if (substr($vRedirectURI, 0, 7) != 'http://' && substr($vRedirectURI, 0, 8) != 'https://') $this->redirectURI = 'https://'.$this->redirectURI;
}
/**
* #param string $vSuggestedEmail
* #param string $vHostedDomain Either a GSuite hosted domain, * to only allow GSuite domains but accept all, or null to allow any login.
* #param array $aPayload Payload data to be returned in getDataFromLoginRedirect() result-data on succesfull login. Keys are not stored, only values. Example usage: Final URI to return to after succesfull login (some frontend).
* #return string
*/
public function getGoogleLoginPageURI($vSuggestedEmail = null, $vHostedDomain = '*', $aPayload = []) {
$vLoginEndpoint = 'https://accounts.google.com/o/oauth2/v2/auth';
$vLoginEndpoint .= '?state=google-'.self::encodePayload($aPayload);
$vLoginEndpoint .= '&prompt=consent'; // or: select_account
$vLoginEndpoint .= '&response_type=code';
$vLoginEndpoint .= '&scope=openid+email+profile';
$vLoginEndpoint .= '&access_type=offline';
$vLoginEndpoint .= '&client_id='.$this->clientID;
$vLoginEndpoint .= '&redirect_uri='.$this->redirectURI;
if ($vSuggestedEmail) $vLoginEndpoint .= '&login_hint='.$vSuggestedEmail;
if ($vHostedDomain) $vLoginEndpoint .= '&hd='.$vHostedDomain;
return($vLoginEndpoint);
}
/**
* Call this function directly from the redirect URI, which is invoked after a call to getGoogleLoginPageURL().
* You can either provide the code/state GET parameters manually, otherwise it will be retrieved from GET automatically.
* Returns an array with:
* - sub string Google ID. Technically an email is not unique within Google's realm, a sub is.
* - email string
* - name string
* - given_name string
* - family_name string
* - locale string
* - picture string URI
* - hdomain string G Suite domain
* - payload array The payload originally provided to ->getGoogleLoginPageURI()
* #param null|string $vCode
* #param null|string $vState
* #return null|array
*/
public function getDataFromLoginRedirect($vCode = null, $vState = null) {
$vTokenEndpoint = 'https://www.googleapis.com/oauth2/v4/token';
if ($vCode === null) $vCode = $_GET['code'];
if ($vState === null) $vState = $_GET['state'];
if (substr($vState, 0, 7) !== 'google-') {
trigger_error('Invalid state-parameter from redirect-URI. Softfail on login.', E_USER_WARNING);
return(null);
}
$aPostData = [
'code' => $vCode,
'client_id' => $this->clientID,
'client_secret' => $this->clientSecret,
'redirect_uri' => $this->redirectURI,
'grant_type' => 'authorization_code'
];
curl_setopt_array($hConn = curl_init($vTokenEndpoint), [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => false,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_USERAGENT => defined('PROJECT_ID') && defined('API_CUR_VERSION') ? PROJECT_ID.' '.API_CUR_VERSION : 'Aptic\Login\OpenID\Google PHP-class',
CURLOPT_AUTOREFERER => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_POST => 1
]);
curl_setopt($hConn, CURLOPT_POSTFIELDS, http_build_query($aPostData));
$aResult = json_decode(curl_exec($hConn), true);
curl_close($hConn);
if (is_array($aResult) && array_key_exists('access_token', $aResult) && array_key_exists('refresh_token', $aResult) && array_key_exists('expires_in', $aResult)) {
$aUserData = explode('.', $aResult['id_token']); // Split JWT-token
$aUserData = json_decode(base64_decode($aUserData[1]), true); // Decode JWT-claims from part-1 (without verification by part-0).
if ($aUserData['exp'] < time()) {
trigger_error('Received an expired ID-token. Softfail on login.', E_USER_WARNING);
return(null);
}
$aRet = [
// 'access_token' => $aResult['access_token'],
// 'expires_in' => $aResult['expires_in'],
// 'refresh_token' => $aResult['refresh_token'],
'sub' => array_key_exists('sub', $aUserData) ? $aUserData['sub'] : '',
'email' => array_key_exists('email', $aUserData) ? $aUserData['email'] : '',
'name' => array_key_exists('name', $aUserData) ? $aUserData['name'] : '',
'given_name' => array_key_exists('given_name', $aUserData) ? $aUserData['given_name'] : '',
'family_name' => array_key_exists('family_name', $aUserData) ? $aUserData['family_name'] : '',
'locale' => array_key_exists('locale', $aUserData) ? $aUserData['locale'] : '',
'picture' => array_key_exists('picture', $aUserData) ? $aUserData['picture'] : '',
'hdomain' => array_key_exists('hd', $aUserData) ? $aUserData['hd'] : '',
'payload' => self::decodePayload($vState)
];
return($aRet);
} else {
trigger_error('OpenID Connect Login failed.', E_USER_WARNING);
return(null);
}
}
protected static function encodePayload($aPayload) {
$aPayloadHEX = [];
foreach($aPayload as $vPayloadEntry) $aPayloadHEX[] = bin2hex($vPayloadEntry);
return(implode('-', $aPayloadHEX));
}
/**
* You generally do not need to call this method from outside this class; only if you
* need your payload *before* calling ->getDataFromLoginRedirect().
* #param string $vStateParameter
* #return array
*/
public static function decodePayload($vStateParameter) {
$aPayload = explode('-', $vStateParameter);
$aRetPayload = [];
for($i=1; $i<count($aPayload); $i++) $aRetPayload[] = hex2bin($aPayload[$i]);
return($aRetPayload);
}
}
As soon as the function getDataFromLoginRedirect does return user data, your user is Authorized. This means you can now issue your own internal authentication token.
So, for Authentication, maintain your own data table of users with either sub or email as primary identifier and issue tokens for them, with appropriate expire mechanisms. The Google tokens themselves do not necessarily be stored, as they are only required for subsequent Google API calls; which depend on your use case. For your own application though, your own token mechanism will suffice for authentication.
To get back to your questions:
Authentication/Authorization with google.
Described above.
Identifying new vs returning users.
Can be determined by the existence of the user in your data table.
Maintaining & Retaining both access and refresh tokens from google and local APIs
Ask yourself the question whether you really need to. If so, you could either refresh upon every x requests, or refresh once the expiry time is in less than x minutes (i.e. this will be your application's timeout in that case). If you really require your tokens to remain valid, you should setup a daemon-mechanism that periodically refreshes your users tokens.
Ok, I haven't worked with hosted buttons before, but it makes sense as they are a lot more secure.
I have looked around and been reading the docs (which aren't all that helpful) and best help I have found so far is here; though I am still confused on where exactly to put that code?
Also, I technically don't want a "button", but the idea behind them seems what I want.
All I want to do is use the same query vars every time but just want to change the price - the price is dynamic depending on what the user selects in the form.
Additionally, I don't want a button per se, I would much prefer to redirect the user to paypal with the appropriate data but not sure how to go about doing that whilst setting a dynamic price?
If I didn't have to set a dynamic price I know I could just append the query vars for a hosted button onto a URL and then redirect to that URL, but I need to change the price and hence my question...
Ok, I finally found out that not only does the response from the BMUpdateButton API return the HTML to create a form, it also returns other data as well within the returned array.
Once you make the request it will return an array with three keys as per the BMUpdateButton Response section on the API page linked above.
These are:
WEBSITECODE
HTML code for web pages
EMAILLINK
This is what I was looking for; a plain link you can redirect users to
HOSTEDBUTTONID
The id of the button.
Be advised when altering the contents of a hosted button you need to pass all the details of the button to it as when you created it; so as an example, if you leave out passing it an item name the item name will be blank and Paypal will allow the user to set it.
Also, an important note is that when you update the button details, it isn't just updated for that users session, it updates it within your paypal account - so the new name/price etc will affect all users that attempt to use it.
If you still would like to update the details of the button you can do that with the below:
I personally started with this class:
<?php
class Paypal
{
/**
* Last error message(s)
* #var array
*/
protected $_errors = array();
/**
* API Credentials
* Use the correct credentials for the environment in use (Live / Sandbox)
* #var array
*/
protected $_credentials = array(
'USER' => 'seller_1297608781_biz_api1.lionite.com',
'PWD' => '1297608792',
'SIGNATURE' => 'A3g66.FS3NAf4mkHn3BDQdpo6JD.ACcPc4wMrInvUEqO3Uapovity47p',
);
/**
* API endpoint
* Live - https://api-3t.paypal.com/nvp
* Sandbox - https://api-3t.sandbox.paypal.com/nvp
* #var string
*/
protected $_endPoint = 'https://api-3t.sandbox.paypal.com/nvp';
/**
* API Version
* #var string
*/
protected $_version = '74.0';
/**
* Make API request
*
* #param string $method string API method to request
* #param array $params Additional request parameters
* #return array / boolean Response array / boolean false on failure
*/
public function request($method, $params = array())
{
$this->_errors = array();
if (empty($method)) { //Check if API method is not empty
$this->_errors = array('API method is missing');
return false;
}
//Our request parameters
$requestParams = array(
'METHOD' => $method,
'VERSION' => $this->_version
) + $this->_credentials;
//Building our NVP string
$request = http_build_query($requestParams + $params);
//cURL settings
$curlOptions = array(
CURLOPT_URL => $this->_endPoint,
CURLOPT_VERBOSE => 1,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_CAINFO => dirname(__FILE__) . '/cacert.pem', //CA cert file
CURLOPT_RETURNTRANSFER => 1,
CURLOPT_POST => 1,
CURLOPT_POSTFIELDS => $request
);
$ch = curl_init();
curl_setopt_array($ch, $curlOptions);
//Sending our request - $response will hold the API response
$response = curl_exec($ch);
//Checking for cURL errors
if (curl_errno($ch)) {
$this->_errors = curl_error($ch);
curl_close($ch);
return false;
//Handle errors
} else {
curl_close($ch);
$responseArray = array();
parse_str($response, $responseArray); // Break the NVP string to an array
return $responseArray;
}
}
}
?>
Credit: https://www.smashingmagazine.com/2011/09/getting-started-with-the-paypal-api/
Then I did the below:
include(dirname(__FILE__) . '/includes/paypal.class.php');
$paypal = new Paypal();
// Set our method
$method = 'BMUpdateButton';
// Set our params
$params = array(
'HOSTEDBUTTONID' => 'your_button_id',
'BUTTONTYPE' => 'BUYNOW',
'BUTTONSUBTYPE' => 'SERVICES',
'L_BUTTONVAR0' => 'item_name=Your Description',
'L_BUTTONVAR1' => 'amount=999.00',
'L_BUTTONVAR2' => 'currency_code=AUD',
'L_BUTTONVAR3' => 'cancel_return=http://www.example.com/cancel.html',
'L_BUTTONVAR4' => 'return=http://www.example.com/success.html'
);
// Make request to change button details
$result = $paypal->request($method, $params);
Note that while Paypal say that BUTTONSUBTYPE is optional, you will likely get an error if you don't include it.
You need to use their button designer if you're going to do hosted buttons.
https://www.paypal.com/us/cgi-bin/webscr?cmd=_button-designer
The security comes from IPN verification after the transaction.
See:
https://www.paypal.com/us/cgi-bin/webscr?cmd=p/acc/ipn-info-outside
Validate that IPN call is from PayPal?
The answer on this post is a better way to do it:
Dynamic PayPal button generation - isn't it very insecure?
Button code is HTML form tags based so you would easily convert it to a string query and assemble your dynamic payloads with this format:
https://www.paypal.com/cgi-bin/webscr?cmd=_xclick&business=yourAccount#email.com&item_name=itemNameTest&item_number=123&amount=1¤cy_code=USD&no_shipping=1
Customize it and append vars per the varliables listed HERE, and use it with your redirection method.
Button manager API is more comprehensive except it won't return you a query string URL like this, though the idea behind is identical to an HTML form code snippet