I am using DocuSign Connect to retrieve webhooks from DocuSign and digest them within my Larave; application. Here is the basic idea.
<?php
namespace App\Http\Controllers;
use App\Http\Middleware\VerifyDocusignWebhookSignature;
use App\Mail\PaymentRequired;
use App\Models\PaymentAttempt;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class DocusignWebhookController extends Controller
{
/**
* Create a new controller instance.
* If a DocuSign Connect key is preset, validate the request.
*/
public function __construct()
{
$this->gocardlessTabs = ['GoCardless Agreement Number', 'GoCardless Amount', 'GoCardless Centre'];
$this->assumedCustomer = 2;
if (config('docusign.connect_key')) {
$this->middleware(VerifyDocusignWebhookSignature::class);
}
}
/**
* Handle an incoming DocuSign webhook.
*/
public function handleWebhook(Request $request)
{
$payload = json_decode($request->getContent(), true);
$shouldProcessWebhook = $this->determineIfEnvelopeRelevant($payload);
if ($shouldProcessWebhook) {
switch ($payload['status']) {
case 'sent':
return $this->handleSentEnvelopeStatus($payload);
break;
case 'completed':
return $this->handleCompletedEnvelopeStatus($payload);
break;
case 'voided':
// ...
break;
default:
}
}
}
}
The logic itself works fine but if you look here:
if (config('docusign.connect_key')) {
$this->middleware(VerifyDocusignWebhookSignature::class);
}
If I specify a connect key I run some middleware to verify the webhook came from DocuSign.
The class to verify the signature came from DocuSign and looks like this:
<?php
namespace App\DocuSign;
/**
* This class is used to validate HMAC keys sent from DocuSign webhooks.
* For more information see: https://developers.docusign.com/platform/webhooks/connect/hmac/
*
* Class taken from: https://developers.docusign.com/platform/webhooks/connect/validate/
*
* Sample headers
* [X-Authorization-Digest, HMACSHA256]
* [X-DocuSign-AccountId, caefc2a3-xxxx-xxxx-xxxx-073c9681515f]
* [X-DocuSign-Signature-1, DfV+OtRSnsuy.....NLXUyTfY=]
* [X-DocuSign-Signature-2, CL9zR6MI/yUa.....O09tpBhk=]
*/
class HmacVerifier
{
/**
* Compute a hmac hash from the given payload.
*
* Useful reference: https://www.php.net/manual/en/function.hash-hmac.php
* NOTE: Currently DocuSign only supports SHA256.
*
* #param string $secret
* #param string $payload
*/
public static function computeHash($secret, $payload)
{
$hexHash = hash_hmac('sha256', $payload, utf8_encode($secret));
$base64Hash = base64_encode(hex2bin($hexHash));
return $base64Hash;
}
/**
* Validate that a given hash is valid.
*
* #param string $secret: the secret known only by our application
* #param string $payload: the payload received from the webhook
* #param string $verify: the string we want to verify in the request header
*/
public static function validateHash($secret, $payload, $verify)
{
return hash_equals($verify, self::computeHash($secret, $payload));
}
}
Now, in order to test this locally I've written a test but whenever I run it, the middleware tells me the webhook isn't valid.
Here is my test class
<?php
namespace Tests\Feature\Http\Middleware;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class VerifyDocusignWebhookSignatureTest extends TestCase
{
use RefreshDatabase, WithFaker;
public function setUp(): void
{
parent::setUp();
config(['docusign.connect_key' => 'probably-best-not-put-on-stack-overflow']);
$this->docusignConnectKey = config('docusign.connect_key');
}
/**
* Given a JSON payload, can we parse it and do what we need to do?
*
* #test
*/
public function it_can_retrieve_a_webhook_with_a_connect_key()
{
Mail::fake();
$payload = '{"status":"sent","documentsUri":"/envelopes/2ba67e2f-0db6-46af-865a-e217c9a1c514/documents","recipientsUri":"/envelopes/2ba67e2f-0db6-46af-865a-e217c9a1c514/recipients","attachmentsUri":"/envelopes/2ba67e2f-0db6-46af-865a-e217c9a1c514/attachments","envelopeUri":"/envelopes/2ba67e2f-0db6-46af-865a-e217c9a1c514","emailSubject":"Please DocuSign: newflex doc test.docx","envelopeId":"2ba67e2f-0db6-46af-865a-e217c9a1c514","signingLocation":"online","customFieldsUri":"/envelopes/2ba67e2f-0db6-46af-865a-e217c9a1c514/custom_fields","notificationUri":"/envelopes/2ba67e2f-0db6-46af-865a-e217c9a1c514/notification","enableWetSign":"true","allowMarkup":"false","allowReassign":"true","createdDateTime":"2022-02-14T11:36:01.18Z","lastModifiedDateTime":"2022-02-14T11:37:48.633Z","initialSentDateTime":"2022-02-14T11:37:49.477Z","sentDateTime":"2022-02-14T11:37:49.477Z","statusChangedDateTime":"2022-02-14T11:37:49.477Z","documentsCombinedUri":"/envelopes/2ba67e2f-0db6-46af-865a-e217c9a1c514/documents/combined","certificateUri":"/envelopes/2ba67e2f-0db6-46af-865a-e217c9a1c514/documents/certificate","templatesUri":"/envelopes/2ba67e2f-0db6-46af-865a-e217c9a1c514/templates","expireEnabled":"true","expireDateTime":"2022-06-14T11:37:49.477Z","expireAfter":"120","sender":{"userName":"Newable eSignature","userId":"f947420b-6897-4f29-80b3-4deeaf73a3c5","accountId":"366e9845-963a-41dd-9061-04f61c921f28","email":"e-signature#newable.co.uk"},"recipients":{"signers":[{"tabs":{"textTabs":[{"validationPattern":"","validationMessage":"","shared":"false","requireInitialOnSharedChange":"false","requireAll":"false","value":"","required":"true","locked":"false","concealValueOnDocument":"false","disableAutoSize":"false","maxLength":"4000","tabLabel":"GoCardless Amount","font":"lucidaconsole","fontColor":"black","fontSize":"size9","localePolicy":{},"documentId":"1","recipientId":"56041698","pageNumber":"1","xPosition":"319","yPosition":"84","width":"84","height":"22","tabId":"207f970c-4d3c-4d0c-be6b-1f3aeecf5f95","tabType":"text"},{"validationPattern":"","validationMessage":"","shared":"false","requireInitialOnSharedChange":"false","requireAll":"false","value":"","required":"true","locked":"false","concealValueOnDocument":"false","disableAutoSize":"false","maxLength":"4000","tabLabel":"GoCardless Centre","font":"lucidaconsole","fontColor":"black","fontSize":"size9","localePolicy":{},"documentId":"1","recipientId":"56041698","pageNumber":"1","xPosition":"324","yPosition":"144","width":"84","height":"22","tabId":"f6919e94-d4b7-4ef4-982d-3fc6c16024ab","tabType":"text"},{"validationPattern":"","validationMessage":"","shared":"false","requireInitialOnSharedChange":"false","requireAll":"false","value":"","required":"true","locked":"false","concealValueOnDocument":"false","disableAutoSize":"false","maxLength":"4000","tabLabel":"GoCardless Agreement Number","font":"lucidaconsole","fontColor":"black","fontSize":"size9","localePolicy":{},"documentId":"1","recipientId":"56041698","pageNumber":"1","xPosition":"332","yPosition":"200","width":"84","height":"22","tabId":"9495a53c-1f5e-42a5-beec-9abcf77b4387","tabType":"text"}]},"creationReason":"sender","isBulkRecipient":"false","requireUploadSignature":"false","name":"Jesse","firstName":"","lastName":"","email":"Jesse.Orange#newable.co.uk","recipientId":"56041698","recipientIdGuid":"246ce44f-0c11-4632-ac24-97f31911594e","requireIdLookup":"false","userId":"b23ada8e-577e-4517-b0fa-e6d8fd440f21","routingOrder":"1","note":"","status":"sent","completedCount":"0","deliveryMethod":"email","totalTabCount":"3","recipientType":"signer"},{"tabs":{"signHereTabs":[{"stampType":"signature","name":"SignHere","tabLabel":"Signature 7ac0c7c8-f838-4674-9e37-10a0df2f81c1","scaleValue":"1","optional":"false","documentId":"1","recipientId":"38774161","pageNumber":"1","xPosition":"161","yPosition":"275","tabId":"371bc702-1a91-4b71-8c77-a2e7abe3210e","tabType":"signhere"}]},"creationReason":"sender","isBulkRecipient":"false","requireUploadSignature":"false","name":"Jesse Orange","firstName":"","lastName":"","email":"jesseorange360#gmail.com","recipientId":"38774161","recipientIdGuid":"844f781c-1516-4a5a-821a-9d8fb2319369","requireIdLookup":"false","userId":"f544f7ff-91bb-4175-894e-b42ce736f273","routingOrder":"2","note":"","status":"created","completedCount":"0","deliveryMethod":"email","totalTabCount":"1","recipientType":"signer"}],"agents":[],"editors":[],"intermediaries":[],"carbonCopies":[],"certifiedDeliveries":[],"inPersonSigners":[],"seals":[],"witnesses":[],"notaries":[],"recipientCount":"2","currentRoutingOrder":"1"},"purgeState":"unpurged","envelopeIdStamping":"true","is21CFRPart11":"false","signerCanSignOnMobile":"true","autoNavigation":"true","isSignatureProviderEnvelope":"false","hasFormDataChanged":"false","allowComments":"true","hasComments":"false","allowViewHistory":"true","envelopeMetadata":{"allowAdvancedCorrect":"true","enableSignWithNotary":"false","allowCorrect":"true"},"anySigner":null,"envelopeLocation":"current_site","isDynamicEnvelope":"false"}';
// Compute a hash as in production this will come from DocuSign
$hash = $this->computeHash($this->docusignConnectKey, $payload);
// Validate the hash as we're going to use it as the header
$this->assertTrue($this->validateHash($this->docusignConnectKey, $payload, $hash));
// Convert this response to an array for the test
$payload = json_decode($payload, true);
// Post as JSON as Laravel only accepts POSTing arrays
$this->postJson(route('webhook-docusign'), $payload, [
'x-docusign-signature-3' => $hash
])->assertStatus(200);
$this->assertDatabaseHas('payment_attempts', [
'envelope_id' => $payload['envelopeId']
]);
Mail::assertNothingSent();
}
/**
* As we're testing we need a way to verify the signature so we're computing the hash.
*/
private function computeHash($secret, $payload)
{
$hexHash = hash_hmac('sha256', $payload, utf8_encode($secret));
$base64Hash = base64_encode(hex2bin($hexHash));
return $base64Hash;
}
/**
* Validate that a given hash is valid.
*
* #param string $secret: the secret known only by our application
* #param string $payload: the payload received from the webhook
* #param string $verify: the string we want to verify in the request header
*/
private function validateHash($secret, $payload, $verify)
{
return hash_equals($verify, self::computeHash($secret, $payload));
}
}
I'm also using webhook.site to compare hashes:
Given this I can tell you that x-docusign-signature-3 matches the hash I generate when I run
$hash = $this->computeHash($this->docusignConnectKey, $payload);
So, my issue surely must stem from the way I'm sending the data back through?
When you compute your own HMAC on the incoming payload (to see if it matches the HMAC that was sent in the header), you must use the incoming payload as is.
In your code:
public function handleWebhook(Request $request)
{
$payload = json_decode($request->getContent(), true);
$shouldProcessWebhook = $this->determineIfEnvelopeRelevant($payload);
you are sending the json decoded payload to your check method. That is not right, you should send the raw payload, as it arrived.
(Decoding, then encoding JSON doesn't necessarily give you the same byte sequence as the original.)
The JSON decode method should only be applied to the payload after you've confirmed that the payload came from DocuSign.
Plus, doing the JSON decode before you've authenticated the sender is a security issue. A bad guy could be trying to send you some bad input. The rule is trust nothing until you've verified the sender (via the HMAC in this case).
Bonus comment
I recommend that you also configure DocuSign Connect webhook's Basic Authentication feature. Basic Authentication is often checked at the web server level. HMAC, since it must be computed, is usually check at the app level. Using both provides solid defense against bad guys.
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'
]);
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