custom form
Create an MSK Generate input form Post card details
sagepay form
php javascript opayo sagepay payment-gateways
First written: Jun-2023
last changed Mar-2024

Using PHP to set up collect a payment using a custom input form with Elavon/Opayo [previously SagePay]

This is a walk through of the PHP & JavaScript code required for collecting a card payment method using the payment gateway.

This code is based on an example found on opayo/integrate-your-own-form#step-2 with additional example code from phppot.com.

The results shown are actual running code using the Elavon/SagePay/Opayo Sandbox account (opayo/test-sandbox).

The code here is using the Opayo developers test environment and will not collect real funds.

    warning WARNING: This code is not 100% working code and is still being worked out how to implement fully - handle with care

Opayo fees in the UK don't seem to be published any more, but were around £32p/m for up to 350 transactions p/m, then 12p per transaction above that (as of June 2023).

Create an MSK

Step 1 : Create a merchant session key (MSK)

custom-form.php
echo "<div style='background-color:red; color:white; padding:10px'><i class='material-icons'>info</i> ";
if ( 1==1 ) {
    echo "This is a test/demonstrator of the results of calling on opayo for making payments; the environment is a sandbox ";
    echo "and no transactions are actually made.";
    echo "<p id='linkback'>";
    echo "<BR>Please refer to <a href='../../Articles/opayo/docs'>the accompanying documentation</a> for a code walk through and explanation.";
    echo "</p>";
}
echo "</div>";

$hms = date("YmdHis");
$testprofile = "";
if( isset($_GET['testprofile']) ) $testprofile = $_GET['testprofile'];
$extra_checks = "";


echo "<h1>Step 1</h1>";
//--CodeStep1:start
// Keys taken from https://developer-eu.elavon.com/docs/opayo/test-sandbox
$ikey = "hJYxsw7HLbj40cB8udES8CDRFLhuJ8G54O6rDpUXvE6hYDrria";
$ipwd = "o2iHSrFybYMZpmWOQMuhsXP52V4fBtpuSDshrKDSWsBY1OiN6hwd9Kb12z4j5Us5u";
$vendorName = "sandbox";


if( isset($testprofile) and strcmp($testprofile, "Extra Checks") == 0 ) { // See Step 2 input form
    $ikey = "dq9w6WkkdD2y8k3t4olqu8H6a0vtt3IY7VEsGhAtacbCZ2b5Ud";
    $ipwd = "hno3JTEwDHy7hJckU4WuxfeTrjD0N92pIaituQBw5Mtj7RG3V8zOdHCSPKwJ02wAV";
    $vendorName = "sandboxEC";
    $extra_checks = " selected";
}

$key = base64_encode("$ikey:$ipwd");


$curl = curl_init();
if ( 1 ) {
    curl_setopt_array($curl, array(
        CURLOPT_URL            => "https://pi-test.sagepay.com/api/v1/merchant-session-keys",
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_SSL_VERIFYPEER => false,
        CURLOPT_SSL_VERIFYHOST => false,
        CURLOPT_CUSTOMREQUEST  => "POST",
        CURLOPT_POSTFIELDS     => '{ "vendorName": "' . $vendorName . '" }',
        CURLOPT_HTTPHEADER     => array(
            "Authorization: Basic " . $key,
            "Cache-Control: no-cache",
            "Content-Type: application/json"
        )
    ));


    $responseJson = curl_exec($curl);
    $response = json_decode($responseJson, true); // false will allow you to do $response->merchantSessionKey, true will allow you to do $response['merchantSessionKey']
    $err = curl_error($curl);
}
curl_close($curl);


echo "<LI>Response<PRE>$responseJson</PRE></LI>";
if( $err ) echo "Err<PRE>$err</PRE>";

$msk = $response['merchantSessionKey'];
echo "<LI>MSK:$msk</LI>";

Generate input form

Now we have the Elavon/SagePay merchantSessionKey, we need to create our own custom form with any fields you like, as long as the card number, expiry date, CVV/CVC, billing address (line, town/city, country, postal-code) are included.

This form will also include a hidden card-identifier field, that is populated by the submit button before the form is submitted, and uses the sagepayOwnForm() (from the sagepay.js) function to tokenize the card details. You will notice that the card number, expiry, and csv do not include <input name=''> parameters and are therefore not posted to the processor - this is deliberate to keep them secure - opting to pass the information via the card-identifier that sage computes and populates before the posting.

The form also includes any fancy footwork you want to make it appear professional - in this ones case, we are adding card-type background-images to the card-number when it's set, and a CVV background-image to assist the UX. We also have some JavaScript around the card-number to format it once entered, the expiry date to improve the UX (allowing dates without the slash to be given, etc). We also use some inputmode and pattern parameters to some of the inputs to improve mobile UX (giving an appropriate keyboard when necessary).

We have also included some CSS to provide a responsive UX and to use a smaller screen footprint through side-by-side fields.

Note the special handling of the card brand logo, which would have had the image on the card-number field, but this is blocked from on mobile devices, so we've had to overlay another field to handle this.

You can find the example footwork in js/custom-form.js and css/custom-form.css. (Note, you would want to use your own card logo images rather than rely on third-party copies that may be removed unexpectedly).

When clicking on the Make Payment button, the form will be processed and then submitted to the next script (mysagepay-processor.php). For readability, I have also used a <form target='...'> to allow the output of the php call to be directed to the current page; whether this is a good idea or not is yet to be known.

Note, we are still missing google/apple pay and PayPal options.

custom-form.php :: Custom Card input form
echo /**@lang HTML */ "              
    <p style='color:red'><i class='material-icons' style='vertical-align: middle; color: red'>warning</i> 
            Never use real cards on this test page.

    <div class='inputform'>
        <div>
            <h2>Input form</h2>
            <form action='functions/mysagepay-processor2.php' target='formtarget' method='POST' class='payment_form'>
            
                Test Profile:<i class='material-icons handcursor noselect' onClick='showHelp(this)'>help_outline</i>
                <select id='testprofile' name='testprofile' onChange='doChangeProfile(this)'><option>Basic</option><option$extra_checks>Extra Checks</option></select>
                <div id='profilehelp' class='hidden'></div>
                
                                          
                <div class='field field50'>
                    <label for='firstnm'>First Name</label>
                    <div><input id='firstnm' name='firstnm' data-card-details='cardholder-firstname'
                            onChange='doChange(this)' onKeyUp='doChange(this)'></div>
                </div>
                
                <div class='field field50'>
                    <label for=''>Last Name</label>
                    <div><input id='lastnm' name='lastnm' data-card-details='cardholder-lastname'
                            onChange='doChange(this)' onKeyUp='doChange(this)'></div>
                </div>
                
                <div class='field field50'>
                    <label for='card-number'>Card</label>
                    <div><input id='card-number' type='text' inputmode='numeric' data-card-details='card-number' 
                                placeholder='4242 4242 4242 4242' 
                                onChange='doChange(this)' pattern='[0-9\s]*'>
                          <div class='cardlogo'><div></div></div>
                    </div>
                </div>
                
                <div class='field field25'>
                    <label for='expiry'>Expiry</label>
                    <div><input id='expiry' type='text' data-card-details='expiry-date' 
                                placeholder='MM/YY' onChange='doChange(this)' pattern='[0-9\/\s]*'></div>
                </div>
                
                <div class='field field25'>
                    <label for='security-code'>CVV</label>
                    <div><input id='security-code' type='text' data-card-details='security-code'
                                onChange='doChange(this)' onKeyUp='doChange(this)' 
                                placeholder='123'></div>
                </div>
                
                <div class='field'>
                    <label for='billing_country'>Country</label>
                    <div>
                        <select id='billing_country' name='billing_country' data-card-details='billing_country' onChange='doChange(this)'>
                            <option></option>
                            <option value='AF'>Afghanistan</option>
                            <option value='AX'>Åland Islands</option>
                            <option value='AL'>Albania</option> 
                            .
                            .
                            .       <option value='ZM'>Zambia</option>
                            <option value='ZW'>Zimbabwe</option></select>
                        </select>
                    </div>
                </div>
                
                <div class='field'>
                    <label for='billing_address'>Address Line 1</label>
                    <div><input id='billing_address' name='billing_address' data-card-details='billing_address'
                            onChange='doChange(this)' onKeyUp='doChange(this)'></div>
                </div>
                
                <div class='field field50'>
                    <label for='billing_city'>Town/City</label>
                    <div><input id='billing_city' name='billing_city' data-card-details='billing_city'
                            onChange='doChange(this)' onKeyUp='doChange(this)'></div>
                </div>
                
                <div class='field field50'>
                    <label for='billing_zip' id='billing_zip_label'>Postal Code</label>
                    <div><input id='billing_zip' name='billing_zip' data-card-details='billing_zip' 
                            onChange='doChange(this)' onKeyUp='doChange(this)'></div>
                </div>
                
                <input type='hidden' name='card-identifier'>
                <BR><BR><HR>
                MSK:<input type='text' readonly name='merchantSessionKey' value='$msk'>
                <br>
                <br>
                
                <div id='submit-container'>
                    <button type='submit' id='submit_but' onClick='doShowTarget()' disabled='true'>Make Payment</button>
                    <div id='submitbutmsg'>Please fill in card details to enable the payment button</div>
                </div>
            </form>
        </div>
        <div>
        " . getCardHelperTable() . "
        </div>
    </div>
    <br>
    
    <div id='messages' class='warningBg hidden'></div>
    
    <p>Clicking Make Payment will make the sagepay.js call the form action page and into the step 3 target below</p>
    
    ";
custom-form.php :: form submit-tokenizer
echo /**@lang Javascript */ "
                
    <script src='https://pi-test.sagepay.com/api/v1/js/sagepay.js'></script>
    <script src='https://pi-test.opayo.co.uk/api/v1/js/sagepay-dropin.js'></script>
    <script>                            
            
        document.querySelector('[type=submit]') 
            .addEventListener('click', function(e) { 
                e.preventDefault(); // to prevent form submission
                dge('messages').innerHTML='';
                addClassName(dge('messages'),'hidden');
                sagepayOwnForm({ merchantSessionKey: '$msk' })
                    .tokeniseCardDetails({ 
                        cardDetails: { 
                            cardholderName: dge('firstnm').value+' '+dge('lastnm').value,
                            cardNumber:     dge('card-number').value.replace(/ /g, '').trim(),
                            expiryDate:     dge('expiry').value.replace(/\//g, ''),
                            securityCode:   dge('security-code').value
                        },
                        onTokenised:function(result) { 
                            if (result.success) { 
                                // Put the token result into card-identifier before posting the <form> to the processing stage...
                                document.querySelector('[name=\"card-identifier\"]').value = result.cardIdentifier;
                                document.querySelector('form').submit(); 
                            } 
                            else { 
                                console.log('ERROR: '+JSON.stringify(result));
                                removeClassName(dge('messages'), 'hidden');
                                dge('messages').innerHTML=JSON.stringify(result);
                                
                                removeClassName(dge('firstnm'),      'warning');
                                removeClassName(dge('lastnm'),       'warning');
                                removeClassName(dge('expiry'),       'warning');
                                removeClassName(dge('card-number'),  'warning');
                                removeClassName(dge('security-code'),'warning');
                                
                                if ( JSON.stringify(result).indexOf('The cardholder name is required')>-1 )          addClassName(dge('firstnm'),'warning');
                                if ( JSON.stringify(result).indexOf('The cardholder name is required')>-1 )          addClassName(dge('lastnm'),'warning'); 
                                if ( JSON.stringify(result).indexOf('The expiry date is invalid')>-1 )               addClassName(dge('expiry'),'warning');
                                if ( JSON.stringify(result).indexOf('The expiry date has to be in MMYY format')>-1 ) addClassName(dge('expiry'),'warning');
                                if ( JSON.stringify(result).indexOf('The card number length is invalid')>-1 )        addClassName(dge('card-number'),'warning');
                                if ( JSON.stringify(result).indexOf('The card number is invalid')>-1 )               addClassName(dge('card-number'),'warning');
                                if ( JSON.stringify(result).indexOf('The security code length is invalid')>-1 )      addClassName(dge('security-code'),'warning');
                            } 
                        } 
                    }); 
                }, false);
                
        // TODO: Find a better way to detect when the credit card number has been auto-filled by a mobile:
        setInterval(function() {
            setCardLogo(dge('card-number'));
        },2500);
        //-----------------------------------------------------------------------------------------
        function addClassName(elem, classN) {
            if ( !elem.className ) elem.className='';
            if ( elem.className=='undefined' ) elem.className='';
            const regex = new RegExp('\\\b'+classN+'\\\b', 'g');
            if (!regex.test(elem.className)) {
                elem.className=(elem.className+' '+classN).trim();
            }
        }
        //-----------------------------------------------------------------------------------------
        function removeClassName(elem, classN) {
            var elemcn=elem.className;
            const regex = new RegExp('\\\b'+classN+'\\\b', 'g');
            var newcn=elemcn.replace(regex, '');
            elem.className=newcn;
        }
        // ---------------------------------------------                
        function doShowTarget() {
            console.log('doShowTarget');
            addClassName(dge('formtarget0'),'hidden');
            removeClassName(dge('formtarget'),'hidden');
            document.getElementById('formtarget').src='';

            setTimeout(function() {
                console.log(document.getElementById('formtarget').contentDocument);
                if ( !document.getElementById('formtarget').contentDocument || document.getElementById('formtarget').contentDocument.getElementsByTagName('body')[0].innerHTML!='') { 
                    document.getElementById('formtarget').scrollIntoView({behavior: 'smooth', block: 'end', inline: 'nearest'});
                }
            },1000);
        }
    </script>

    ";

Post card details

The credit card core details have now been posted through card-identifier, and the rest are passed through regular <form> posted fields, to our next step, mysagepay-processor2 that then requests a payment, adding in amounts and currency information before posting it to sagepay.com via a curl POST request.

In response to the curl, it is possible that Elavon/SagePay returns number of status codes including:(this information has been taken from Sage Pay Direct Integration and Protocol Guidelines 4.00).

OKProcess executed without error and the transaction has been authorised
NOTAUTHEDThe Sage Pay gateway could not authorise the transaction because the details provided by the customer were incorrect, or insufficient funds were available. However, the transaction has completed. Also returned for PayPal transactions in response to the PayPal Completion Post (if Accept=NO was sent to complete PayPal transaction, see Appendix A8).
REJECTEDThe Sage Pay System rejected the transaction because of the fraud screening rules you have set on your account.Note: The bank may have authorised the transaction but your own rule bases for AVS/CV2 or 3D-Secure caused the transaction to be rejected.
AUTHENTICATEDThe 3D-Secure checks were performed successfully, and the card details13. Sage Pay Direct Integration and Protocol and Guidelines 3.00 Page 102 of 115 secured at Sage Pay. Only returned if TxType is AUTHENTICATE.
REGISTERED 3D-Secure checks failed or were not performed, but the card details are still secured at Sage Pay. Only returned if TxType is AUTHENTICATE.
PPREDIRECT
MALFORMED Input message was missing fields or badly formatted – normally will only occur during development.
3DAUTH The customer needs to be directed to their card issuer for 3D-Authentication
INVALID Transaction was not registered because although the POST format was valid, some information supplied was invalid. e.g. incorrect vendor name or currency.
ERROR A problem occurred at Sage Pay which prevented transaction registration. Please notify Sage Pay if a Status of ERROR is seen, together with your Vendor, VendorTxCode and the StatusDetail.

See page 100 of the Sage Pay Direct Integration and Protocol Guidelines 4.00 for more information.

custom-form.php :: target iframe for demo
<?php
 
//--CodeAll:start -all

include_once('functions/test_card_helper_table.php');

// --------------------------------------------

function main() {

    // Keys taken from https://developer-eu.elavon.com/docs/opayo/test-sandbox
    $ikey = "hJYxsw7HLbj40cB8udES8CDRFLhuJ8G54O6rDpUXvE6hYDrria";
    $ipwd = "o2iHSrFybYMZpmWOQMuhsXP52V4fBtpuSDshrKDSWsBY1OiN6hwd9Kb12z4j5Us5u";
    $key = base64_encode("$ikey:$ipwd");

    //--CodeStep1:start
    echo "<div style='background-color:red; color:white; padding:10px'><i class='material-icons'>info</i> ";
    if ( 1==1 ) {
        echo "This is a test/demonstrator of the results of calling on opayo for making payments; the environment is a sandbox ";
        echo "and no transactions are actually made.";
        echo "<p id='linkback'>";
        echo "<BR>Please refer to <a href='../../Articles/opayo/docs'>the accompanying documentation</a> for a code walk through and explanation.";
        echo "</p>";
    }
    echo "</div>";

    $hms = date("YmdHis");
    $testprofile = "";
    if( isset($_GET['testprofile']) ) $testprofile = $_GET['testprofile'];
    $extra_checks = "";


    echo "<h1>Step 1</h1>";
    //--CodeStep1:start
    // Keys taken from https://developer-eu.elavon.com/docs/opayo/test-sandbox
    $ikey = "hJYxsw7HLbj40cB8udES8CDRFLhuJ8G54O6rDpUXvE6hYDrria";
    $ipwd = "o2iHSrFybYMZpmWOQMuhsXP52V4fBtpuSDshrKDSWsBY1OiN6hwd9Kb12z4j5Us5u";
    $vendorName = "sandbox";


    if( isset($testprofile) and strcmp($testprofile, "Extra Checks") == 0 ) { // See Step 2 input form
        $ikey = "dq9w6WkkdD2y8k3t4olqu8H6a0vtt3IY7VEsGhAtacbCZ2b5Ud";
        $ipwd = "hno3JTEwDHy7hJckU4WuxfeTrjD0N92pIaituQBw5Mtj7RG3V8zOdHCSPKwJ02wAV";
        $vendorName = "sandboxEC";
        $extra_checks = " selected";
    }

    $key = base64_encode("$ikey:$ipwd");


    $curl = curl_init();
    if ( 1 ) {
        curl_setopt_array($curl, array(
            CURLOPT_URL            => "https://pi-test.sagepay.com/api/v1/merchant-session-keys",
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_SSL_VERIFYPEER => false,
            CURLOPT_SSL_VERIFYHOST => false,
            CURLOPT_CUSTOMREQUEST  => "POST",
            CURLOPT_POSTFIELDS     => '{ "vendorName": "' . $vendorName . '" }',
            CURLOPT_HTTPHEADER     => array(
                "Authorization: Basic " . $key,
                "Cache-Control: no-cache",
                "Content-Type: application/json"
            )
        ));


        $responseJson = curl_exec($curl);
        $response = json_decode($responseJson, true); // false will allow you to do $response->merchantSessionKey, true will allow you to do $response['merchantSessionKey']
        $err = curl_error($curl);
    }
    curl_close($curl);


    echo "<LI>Response<PRE>$responseJson</PRE></LI>";
    if( $err ) echo "Err<PRE>$err</PRE>";

    $msk = $response['merchantSessionKey'];
    echo "<LI>MSK:$msk</LI>"; //--CodeStep1:end


    $yy = date("y");
    $mm = date("m");
    echo "<p class='info colPaleGreyBg'>HINT: Use the Test card options listed below (e.g. 4929000000006 - expires $mm/$yy, CVC 123, etc)</p>";

    //--CodeStep2a:start
    echo /**@lang HTML */ "              
        <p style='color:red'><i class='material-icons' style='vertical-align: middle; color: red'>warning</i> 
                Never use real cards on this test page.
    
        <div class='inputform'>
            <div>
                <h2>Input form</h2>
                <form action='functions/mysagepay-processor2.php' target='formtarget' method='POST' class='payment_form'>
                
                    Test Profile:<i class='material-icons handcursor noselect' onClick='showHelp(this)'>help_outline</i>
                    <select id='testprofile' name='testprofile' onChange='doChangeProfile(this)'><option>Basic</option><option$extra_checks>Extra Checks</option></select>
                    <div id='profilehelp' class='hidden'></div>
                    
                                              
                    <div class='field field50'>
                        <label for='firstnm'>First Name</label>
                        <div><input id='firstnm' name='firstnm' data-card-details='cardholder-firstname'
                                onChange='doChange(this)' onKeyUp='doChange(this)'></div>
                    </div>
                    
                    <div class='field field50'>
                        <label for=''>Last Name</label>
                        <div><input id='lastnm' name='lastnm' data-card-details='cardholder-lastname'
                                onChange='doChange(this)' onKeyUp='doChange(this)'></div>
                    </div>
                    
                    <div class='field field50'>
                        <label for='card-number'>Card</label>
                        <div><input id='card-number' type='text' inputmode='numeric' data-card-details='card-number' 
                                    placeholder='4242 4242 4242 4242' 
                                    onChange='doChange(this)' pattern='[0-9\s]*'>
                              <div class='cardlogo'><div></div></div>
                        </div>
                    </div>
                    
                    <div class='field field25'>
                        <label for='expiry'>Expiry</label>
                        <div><input id='expiry' type='text' data-card-details='expiry-date' 
                                    placeholder='MM/YY' onChange='doChange(this)' pattern='[0-9\/\s]*'></div>
                    </div>
                    
                    <div class='field field25'>
                        <label for='security-code'>CVV</label>
                        <div><input id='security-code' type='text' data-card-details='security-code'
                                    onChange='doChange(this)' onKeyUp='doChange(this)' 
                                    placeholder='123'></div>
                    </div>
                    
                    <div class='field'>
                        <label for='billing_country'>Country</label>
                        <div>
                            <select id='billing_country' name='billing_country' data-card-details='billing_country' onChange='doChange(this)'>
                                <option></option>
                                <option value='AF'>Afghanistan</option>
                                <option value='AX'>Åland Islands</option>
                                <option value='AL'>Albania</option> 
                                .
                                .
                                .       <option value='ZM'>Zambia</option>
                                <option value='ZW'>Zimbabwe</option></select>
                            </select>
                        </div>
                    </div>
                    
                    <div class='field'>
                        <label for='billing_address'>Address Line 1</label>
                        <div><input id='billing_address' name='billing_address' data-card-details='billing_address'
                                onChange='doChange(this)' onKeyUp='doChange(this)'></div>
                    </div>
                    
                    <div class='field field50'>
                        <label for='billing_city'>Town/City</label>
                        <div><input id='billing_city' name='billing_city' data-card-details='billing_city'
                                onChange='doChange(this)' onKeyUp='doChange(this)'></div>
                    </div>
                    
                    <div class='field field50'>
                        <label for='billing_zip' id='billing_zip_label'>Postal Code</label>
                        <div><input id='billing_zip' name='billing_zip' data-card-details='billing_zip' 
                                onChange='doChange(this)' onKeyUp='doChange(this)'></div>
                    </div>
                    
                    <input type='hidden' name='card-identifier'>
                    <BR><BR><HR>
                    MSK:<input type='text' readonly name='merchantSessionKey' value='$msk'>
                    <br>
                    <br>
                    
                    <div id='submit-container'>
                        <button type='submit' id='submit_but' onClick='doShowTarget()' disabled='true'>Make Payment</button>
                        <div id='submitbutmsg'>Please fill in card details to enable the payment button</div>
                    </div>
                </form>
            </div>
            <div>
            " . getCardHelperTable() . "
            </div>
        </div>
        <br>
        
        <div id='messages' class='warningBg hidden'></div>
        
        <p>Clicking Make Payment will make the sagepay.js call the form action page and into the step 3 target below</p>
        
        ";//--CodeStep2a:end


    //--CodeStep2b:start
    echo /**@lang Javascript */ "
                    
        <script src='https://pi-test.sagepay.com/api/v1/js/sagepay.js'></script>
        <script src='https://pi-test.opayo.co.uk/api/v1/js/sagepay-dropin.js'></script>
        <script>                            
                
            document.querySelector('[type=submit]') 
                .addEventListener('click', function(e) { 
                    e.preventDefault(); // to prevent form submission
                    dge('messages').innerHTML='';
                    addClassName(dge('messages'),'hidden');
                    sagepayOwnForm({ merchantSessionKey: '$msk' })
                        .tokeniseCardDetails({ 
                            cardDetails: { 
                                cardholderName: dge('firstnm').value+' '+dge('lastnm').value,
                                cardNumber:     dge('card-number').value.replace(/ /g, '').trim(),
                                expiryDate:     dge('expiry').value.replace(/\//g, ''),
                                securityCode:   dge('security-code').value
                            },
                            onTokenised:function(result) { 
                                if (result.success) { 
                                    // Put the token result into card-identifier before posting the <form> to the processing stage...
                                    document.querySelector('[name=\"card-identifier\"]').value = result.cardIdentifier;
                                    document.querySelector('form').submit(); 
                                } 
                                else { 
                                    console.log('ERROR: '+JSON.stringify(result));
                                    removeClassName(dge('messages'), 'hidden');
                                    dge('messages').innerHTML=JSON.stringify(result);
                                    
                                    removeClassName(dge('firstnm'),      'warning');
                                    removeClassName(dge('lastnm'),       'warning');
                                    removeClassName(dge('expiry'),       'warning');
                                    removeClassName(dge('card-number'),  'warning');
                                    removeClassName(dge('security-code'),'warning');
                                    
                                    if ( JSON.stringify(result).indexOf('The cardholder name is required')>-1 )          addClassName(dge('firstnm'),'warning');
                                    if ( JSON.stringify(result).indexOf('The cardholder name is required')>-1 )          addClassName(dge('lastnm'),'warning'); 
                                    if ( JSON.stringify(result).indexOf('The expiry date is invalid')>-1 )               addClassName(dge('expiry'),'warning');
                                    if ( JSON.stringify(result).indexOf('The expiry date has to be in MMYY format')>-1 ) addClassName(dge('expiry'),'warning');
                                    if ( JSON.stringify(result).indexOf('The card number length is invalid')>-1 )        addClassName(dge('card-number'),'warning');
                                    if ( JSON.stringify(result).indexOf('The card number is invalid')>-1 )               addClassName(dge('card-number'),'warning');
                                    if ( JSON.stringify(result).indexOf('The security code length is invalid')>-1 )      addClassName(dge('security-code'),'warning');
                                } 
                            } 
                        }); 
                    }, false);
                    
            // TODO: Find a better way to detect when the credit card number has been auto-filled by a mobile:
            setInterval(function() {
                setCardLogo(dge('card-number'));
            },2500);
            //-----------------------------------------------------------------------------------------
            function addClassName(elem, classN) {
                if ( !elem.className ) elem.className='';
                if ( elem.className=='undefined' ) elem.className='';
                const regex = new RegExp('\\\b'+classN+'\\\b', 'g');
                if (!regex.test(elem.className)) {
                    elem.className=(elem.className+' '+classN).trim();
                }
            }
            //-----------------------------------------------------------------------------------------
            function removeClassName(elem, classN) {
                var elemcn=elem.className;
                const regex = new RegExp('\\\b'+classN+'\\\b', 'g');
                var newcn=elemcn.replace(regex, '');
                elem.className=newcn;
            }
            // ---------------------------------------------                
            function doShowTarget() {
                console.log('doShowTarget');
                addClassName(dge('formtarget0'),'hidden');
                removeClassName(dge('formtarget'),'hidden');
                document.getElementById('formtarget').src='';
    
                setTimeout(function() {
                    console.log(document.getElementById('formtarget').contentDocument);
                    if ( !document.getElementById('formtarget').contentDocument || document.getElementById('formtarget').contentDocument.getElementsByTagName('body')[0].innerHTML!='') { 
                        document.getElementById('formtarget').scrollIntoView({behavior: 'smooth', block: 'end', inline: 'nearest'});
                    }
                },1000);
            }
        </script>
    
        ";//--CodeStep2b:end


    //--CodeStep3:start
    echo "<h1>Step 3</h1>";
    echo "<div class='demo'><iframe name='formtarget' id='formtarget' class='hidden'></iframe></div>";//--CodeStep3:end
}
// -------------------------------------------------------------------

echo "<!DOCTYPE html>";
echo "<html>";
echo "<head>";
echo "<link rel='stylesheet preload prefetch' as='style' href='https://fonts.googleapis.com/icon?family=Courier+Prime|Roboto|Material+Icons|Material+Icons+Outlined&display=block'>";
echo "<link rel='stylesheet preload prefetch' as='style' href='css/custom-form.css'>";
echo "<script src='https://methodfish.com/js/v'></script><!-- No download -->";
echo "<link rel='icon' type='image/png' href='https://methodfish.com/images/methodfish.ico'>";
echo "<script type='text/JavaScript' SRC='js/custom-form.js' type='text/javascript'></script>";
echo "<style>";
echo /**@lang CSS */"
body {
    font-family: Roboto;
    font-size: 13pt;
    line-height: 1.6em;
}
.colPaleGreyBg {
    background-color: #d9d8d8!important;
}
.info {
    display: inline-block;
    background-color: #e4f2fb;
    padding: 10px;
    border-radius: 10px;
    width: calc(100% - 20px);
    margin-bottom: 8px;
}
#sp-container {
    width: calc(100% - 20px);
}
iframe, #formtarget0 {
    border: solid 1px red!important;
    -webkit-box-shadow: 5px 5px 30px 0px rgba(255, 0, 0, 0.62);
    -moz-box-shadow: 5px 5px 30px 0px rgba(255, 0, 0, 0.62);
    box-shadow: 5px 5px 30px 0px rgba(255, 0, 0, 0.62);
    height: 320px;
}
iframe, .iframe {
    border: solid 1px red;
    -webkit-box-shadow: 0 1px 11px 1px rgba(255,0,0,.62);
    -moz-box-shadow: 0 1px 11px 1px rgba(255,0,0,.62);
    box-shadow: 0 1px 11px 1px rgba(255,0,0,.62);
    min-height: 100px;
    padding: 10px;
    text-align: center;
    width: calc(100% - 20px);
}
.inputform {
    width:calc(100% - 10px);
    display:flex;
    flex-wrap:wrap;
    gap:10px;
}
.inputform > div {
    flex:1;
    xwidth:calc(50% - 40px);
    xdisplay:inline-block;
    vertical-align:top;
    overflow:auto;
    padding-right:10px;
}
.inputform > div:last-child {
    color:white;
    font-size:80%;
    padding-left:10px;
    background-color:darkgrey;
    max-width:600px;
}
.inputform > div:last-child a {
    color:white;
}
.inputform h2 {
    font-size:16px;
    margin-top:0;
    border-bottom:solid 1px black;
}
@media (max-width:800px) {
    .inputform > div {
        flex-basis:100%;
        width:100vw;
    }
}
.form-group {
    line-height: 40px;
    overflow: auto;
    border-bottom: 1px solid #ddd;
    box-sizing: border-box;
}
.label-column {
    float: left;
    width: 12%;
    max-width: 200px;
    min-width: 100px;
    margin-right: 4px;
    clear: left;
}
.input-column {
    float: left;
    width: 70%;
    min-width: 220px;
}
.input-column input {
    border: none!important;
    border-radius: 0!important;
    padding: 2px 4px!important;
    line-height: 1.3em;
}
input {
    width: calc(100% - 25px);
}
form {
    background-color:#ebebeb;
}
input, select {
    font-family: Roboto;
    font-size: 13pt;
    padding: 9px;
    border: solid 1px grey;
    border-radius: 9px;
}
.input-column input {
    border: none!important;
    border-radius: 0!important;
    padding: 2px 4px!important;
    line-height: 1.3em;
}
button, .button {
    font-size: 10pt;
    padding: 10px;
    margin-top: 10px;
    margin-right: 10px;
    background-color: #11b4ff;
    color: white;
    border: 0;
    cursor: pointer;
    transition: all .25s ease-in-out;
    text-decoration: none;
    min-width: 100px;
    -webkit-touch-callout: none;
    -webkit-user-select: none;
    -khtml-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
    user-select: none;
}
input:read-only {
    background-color: inherit;
}
form>input, form>textarea {
    width: calc(100% - 26px);
    padding: 8px;
    margin: 4px;
    border: solid 1px grey;
    border-radius: 9px;
}
.hidden {
    display: none!important;
}
pre {
    font-family: Courier Prime;
    font-size: 9pt;
    margin: 15px;
    padding: 10px;
    overflow: auto;
    border: solid 1px #b3b0b0;
    width: calc(100% - 100px);
    max-height: 500px;
    line-height: 1.5em;
    background-color: #e7e7e7;
    resize: vertical;
    min-height: 20px;
}
table.testcards td {
    white-space:nowrap;
}
";
echo "</style>";
echo "</head>";
echo "<body>";
main();
echo "<script>";
echo "
if (window.self !== window.top) {
  // if the page is loaded in an iframe, hide the div with id 'myDiv'
  var myDiv = document.getElementById('linkback');
  myDiv.style.display = 'none';
}
";
echo "</script>";
echo "</body>";
echo "</html>";
mysagepay-processor2.php :: payment request processor
<?php
 
//--CodeAll:start -all

include_once('class.const.php');

function getSafePostTx(string $val=null) {
    return (str_replace("&", "&"
        ,str_replace("<", "<"
            ,str_replace(">", ">"
                , strip_tags($val)))));
}


$http="https://";
if ( config::localDeveloper ) $home ="http://localhost/methodfish/public_html";
else $home = $http.$_SERVER['SERVER_NAME'];


$trace="";
if ( 1 ) {
    foreach ($_GET as $k => $v) {
        if (strcmp($k, "t") != 0) $trace .= "<LI>GET ".getSafePostTx("[$k]=[$v]");
    }

    foreach ($_POST as $k => $v) {
        if (strcmp($k, "t") != 0) $trace .= "<LI>POST ".getSafePostTx("[$k]=[$v]");
    }
    if (strcmp($trace, "") != 0) {
        echo "<div style='color:white;background-color:red; padding:8px;'>GET/POST arguments detected:<BR>$trace</div>";
    }
}



if ( 1 ) {
    //--CodeStep3.2:start

    if (isset($_GET['confirmed'])) {
        echo "<p>Transaction completed</p>";
        echo "<script>alert('custom alert message here - see Step 3 code / mysagepay-processor2');</script>";
        exit;
    }


    $merchantSessionKey = getSafePostTx($_POST["merchantSessionKey"]);
    $cardIdentifier     = getSafePostTx($_POST["card-identifier"]);
    $firstName          = getSafePostTx($_POST["firstnm"]);
    $lastName           = getSafePostTx($_POST["lastnm"]);
    $billing_address    = getSafePostTx($_POST["billing_address"]);
    $billing_city       = getSafePostTx($_POST["billing_city"]);
    $billing_zip        = getSafePostTx($_POST["billing_zip"]);
    $billing_country    = getSafePostTx($_POST["billing_country"]);

    $testProfile        = getSafePostTx($_POST['testprofile']);


    $amount   = 100 + random_int(0,100);
    $currency = "GBP";


    // Keys taken from https://developer-eu.elavon.com/docs/opayo/test-sandbox
    $profile="Basic";
    if ( strcmp($testProfile, "Extra Checks")==0 ) $profile="Extra Checks";


    if ( strcmp($profile, "Basic")==0 ) {
        $ikey = "hJYxsw7HLbj40cB8udES8CDRFLhuJ8G54O6rDpUXvE6hYDrria";
        $ipwd = "o2iHSrFybYMZpmWOQMuhsXP52V4fBtpuSDshrKDSWsBY1OiN6hwd9Kb12z4j5Us5u";
        echo "<LI>Basic checks</LI>";
    }
    else {
        $ikey = "dq9w6WkkdD2y8k3t4olqu8H6a0vtt3IY7VEsGhAtacbCZ2b5Ud";
        $ipwd = "hno3JTEwDHy7hJckU4WuxfeTrjD0N92pIaituQBw5Mtj7RG3V8zOdHCSPKwJ02wAV";
        echo "<LI>Extra checks</LI>";
    }
    $key = base64_encode("$ikey:$ipwd");


    // vendorTxCode is your unique transaction reference identifying each separate transaction registration
    // (less than 40 chars, no special characters, and no spaces)
    $vcd="Methodfish-Example".time();


    $curl = curl_init();
    if ( 1 ) {
        $postfields = '{' .
            '"transactionType" : "Payment",' .
            '"paymentMethod"   : {' .
            '    "card" : {' .
            '        "merchantSessionKey" : "' . $merchantSessionKey . '",' .
            '        "cardIdentifier"     : "' . $cardIdentifier . '"' .
            '    }' .
            '},' .
            '"vendorTxCode"     : "' . $vcd . '",' .
            '"amount"           :  ' . $amount . ',' .
            '"currency"         : "' . $currency . '",' .
            '"customerFirstName": "' . $firstName . '",' .
            '"customerLastName" : "' . $lastName . '",' .
            '"description"      : "Methodfish.com Example",' .
            '"apply3DSecure"    : "UseMSPSetting",' .
            '"entryMethod"      : "Ecommerce",' .
            '"billingAddress"   : {' .
            '    "address1"   : "' . $billing_address . '",' .
            '    "city"       : "' . $billing_city . '",' .
            '    "postalCode" : "' . $billing_zip . '",' .
            '    "country"    : "' . $billing_country . '"' .
            '}' .
            '}';

        echo "<LI>POSTFIELDS:<div style='border:solid 1px grey; background-color:#e8e8e8'>" .
            str_replace(",", ",<BR>",
                        str_replace("{", "{<OL>",
                                    str_replace("}", "}</OL>", $postfields))) . "</div>";

        curl_setopt_array($curl, array(
            CURLOPT_URL            => "https://pi-test.sagepay.com/api/v1/transactions",
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_SSL_VERIFYPEER => false,
            CURLOPT_SSL_VERIFYHOST => false,
            CURLOPT_CUSTOMREQUEST  => "POST",
            CURLOPT_POSTFIELDS     => $postfields,
            CURLOPT_HTTPHEADER     => array(
                "Authorization: Basic $key",
                "Cache-Control: no-cache",
                "Content-Type: application/json"
            ),
        ));


        $responseJson = curl_exec($curl);
        $response = json_decode($responseJson, true); // false will allow you to do $response->merchantSessionKey, true will allow you to do $response['merchantSessionKey']
        $err = curl_error($curl);

        echo "<LI>result:</LI><PRE style='border:solid 1px grey; background-color:#e8e8e8; padding:10px'>$responseJson</PRE>";

        if( 1 ) {
            echo "<PRE>";
            print_r($response);
            echo "</PRE>";
        }

        if( strcmp($err, "") != 0 ) echo "<LI>err:</LI><PRE>$err</PRE><HR>";
    }
    curl_close($curl);



    if ( isset($response['description']) and strcmp($response['description'], "Authentication values are missing")==0 ) {
        echo "<LI>Authentication values are missing = check your curl header includes \"Authorization: Basic <encoded key>\"";
    }


    if ( isset($response['description']) and strcmp($response['description'], "Authentication values are missing")==0 ) {
        echo "<LI>Authentication values are missing = check your curl header includes \"Authorization: Basic <encoded key>\"";
    }


    if ( isset($response['status']) and strcmp($response['status'], '3DAuth')==0 ) {

        // See https://developer-eu.elavon.com/docs/opayo/3d-secure-authentication
        $txnid = $response['transactionId'];
        $acsUrl = $response['acsUrl'];
        $paReq  = $response['paReq'];

        echo /** @lang HTML */"
            
            <div style='background-color:white; border:solid 1px grey; padding:10px;'>
                <p>3DAuth requires customer to complete the transaction using the following link</p>
                
                <form action='$acsUrl' method='post' id='pa-form'>
                    <input type='hidden' name='PaReq' value='$paReq'> <!-- [PREVIOUSLY RETURNED PAREQ] -->
                    <input type='hidden' name='TermUrl' value='$home/mysagepay-processor2.php?confirmed=y'> <!-- [ENDPOINT ON YOUR SERVER WHICH HANDLES RESPONSE FROM 3DSECURE PROVIDER] -->
                    <input type='hidden' name='MD' value=''> <!-- [YOUR UNIQUE REFERENCE NUMBER FOR THIS AUTHENTICATION] -->
                    <input type='hidden' name='some-information' value='some-value'> <!-- This demonstrates the field won't go anywhere -->
                    
                    <!-- Prompt the user to submit the 3DAuth form 
                    Alternatively, use auto-submitting javascript
                    e.g. <script>document.addEventListener('DOMContentLoaded',function(){var b=document.getElementById('pa-form');b&&b.submit()})</script>
                    -->
                    
                    <input type='submit' style='padding:8px; background-color:#11b4ff; color:white; border:none; cursor:pointer' value='Continue to Card Verification...'/>
                </form>
            </div>
            
            
            ";
    }


    if ( isset($response['errors']) ) {
        $errors=($response['errors']);

        foreach($errors as $e=>$msg) {
            $desc=$msg['description'];
            $prop=$msg['property'];
            $code=$msg['code'];
            echo "<LI>ERRORS: $e = [$desc] [$prop] [$code]";
        }
    }
    //--CodeStep3.2:end
}

?>
Click here to see this code running


link Click to view a demo

square