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

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

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

The results shown are actual running code using the 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 are from: [Flex plan:] £32p/m for up to 350 transactions p/m, then 12p per transaction above that.

Written: Aug-2022 PHP: 7+ Sagepay.js: v1

Step 1 Code:

Step 1 : Create a merchant session key (MSK):

sagepay-own-form.php :: Curling sagepay to get a merchant session key
// Keys taken from
$ikey = "hJYxsw7HLbj40cB8udES8CDRFLhuJ8G54O6rDpUXvE6hYDrria";
$ipwd = "o2iHSrFybYMZpmWOQMuhsXP52V4fBtpuSDshrKDSWsBY1OiN6hwd9Kb12z4j5Us5u";

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

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

$curl = curl_init();
curl_setopt_array($curl, array(
    CURLOPT_URL            => "",
    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);


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

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

Step 1 Result:

  • Response
  • MSK:18C40F32-CB06-4AFC-B753-093B4031AA30
  • Step 2 Code:

    Now we have the 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 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/sagepay-form.js and css/sagepay-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.

    sagepay-own-form.php :: Custom Card input form
    echo /**@lang HTML */ "              
        <form action='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 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 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 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 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)' 
            <div class='field'>
                <label for='billing_country'>Country</label>
                    <select id='billing_country' name='billing_country' data-card-details='billing_country' onChange='doChange(this)'>
                        <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>
            <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 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 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>
            <input type='hidden' name='card-identifier'>
            MSK:<input type='text' readonly name='merchantSessionKey' value='$msk'>
            <div id='submit-container'>
                <button type='submit' id='submit_but' onClick='doShowTarget()' disabled='true'>Make Payment</button>
        <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>

    sagepay-own-form.php :: form submit-tokenizer
    echo /**@lang Javascript */ "
        <script src=''></script>
        <script src=''></script>
                .addEventListener('click', function(e) { 
                    e.preventDefault(); // to prevent form submission
                    sagepayOwnForm({ merchantSessionKey: '$msk' })
                            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;
                                else { 
                                    console.log('ERROR: '+JSON.stringify(result));
                                    removeClassName(dge('messages'), 'hidden');
                                    removeClassName(dge('firstnm'),      'warning');
                                    removeClassName(dge('lastnm'),       'warning');
                                    removeClassName(dge('expiry'),       'warning');
                                    removeClassName(dge('card-number'),  '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() {

    Step 2 Result:

    HINT: Use the Test card options listed below (e.g. 4929000000006 - expires 12/22, CVC 123, etc)

    warning Never use real cards on this test page.

    Test Profile:help_outline


    Clicking Make Payment will make the sagepay.js call the form action page and into the step 3 target below

    expand_more Test card options:

  • See opayo/test-sandbox for more information
  • Step 3 Code:

    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 via a curl POST request.

    In response to the curl, it is possible that 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.
    AUTHENTICATED The 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.
    REGISTERED3D-Secure checks failed or were not performed, but the card details are still secured at Sage Pay. Only returned if TxType is AUTHENTICATE.
    PPREDIRECT The customer needs to be redirected to PayPal
    MALFORMEDInput message was missing fields or badly formatted – normally will only occur during development.
    3DAUTHThe customer needs to be directed to their card issuer for 3D-Authentication
    INVALIDTransaction was not registered because although the POST format was valid, some information supplied was invalid. e.g. incorrect vendor name or currency.
    ERRORA 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.

    sagepay-own-form.php :: target iframe for demo
    echo "<iframe name='formtarget' id='formtarget' class='hidden'></iframe>";

    mysagepay-processor2.php :: payment request processor
    if (isset($_GET['confirmed'])) {
        echo "<p>Transaction completed</p>";
        echo "<script>alert('custom alert message here - see Step 3 code / mysagepay-processor2');</script>";
    $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
    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)
    $curl = curl_init();
    $postfields = '{' .
        '"transactionType" : "Payment",' .
        '"paymentMethod"   : {' .
        '    "card" : {' .
        '        "merchantSessionKey" : "'.$merchantSessionKey.'",' .
        '        "cardIdentifier"     : "'.$cardIdentifier.'"' .
        '    }' .
        '},' .
        '"vendorTxCode"     : "'.$vcd.'",' .
        '"amount"           :  '.$amount.',' .
        '"currency"         : "'.$currency.'",' .
        '"customerFirstName": "'.$firstName.'",' .
        '"customerLastName" : "'.$lastName.'",' .
        '"description"      : " 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            => "",
        CURLOPT_SSL_VERIFYPEER => false,
        CURLOPT_SSL_VERIFYHOST => false,
        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>";
        echo "</PRE>";
    if ( strcmp($err, "")!=0 ) echo "<LI>err:</LI><PRE>$err</PRE><HR>";
    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
        $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...'/>
    if ( isset($response['errors']) ) {
        foreach($errors as $e=>$msg) {
            echo "<LI>ERRORS: $e = [$desc] [$prop] [$code]";

    Step 3 Result:

    sagepay-processor2.php output in the sagepay-own-form.php iframe:

    Waiting for form submission from above


    New Comment

    NOTE: (Put code blocks in [[[ and ]]] markup to be formatted.)

    (Posted comments will be checked by the administrator before being published)

    Sun Dec 04, 22 11:31:41