skip to Main Content

I am trying to implement a PayPal subscription flow where user click on a PayPal subscription button that I have created via the dashboard.

In the back-end, I listen to the PAYMENT.SALE.COMPLETED webhook that is triggered when a subscription billing is successful. Unfortunately the webhook doesn’t send me much infos so that I can retrieve the user and item in my DB linked to the just billed subscription.
This would allow me to securely show private content to that user.

Here is the webhook content sent by payPal (sorry for the length):

const response = {
        id: 'WH-4W487015EX264720U-32N35125TV248784B',
        event_version: '1.0',
        create_time: '2021-04-26T08:24:41.436Z',
        resource_type: 'sale',
        event_type: 'PAYMENT.SALE.COMPLETED',
        summary: 'Payment completed for EUR 6.9 EUR',
        resource: {
            billing_agreement_id: 'I-T2HP99MJTS1T',
            amount: {
                total: '6.90',
                currency: 'EUR',
                details: {
                    subtotal: '6.90'
                }
            },
            payment_mode: 'INSTANT_TRANSFER',
            update_time: '2021-04-26T08:23:59Z',
            create_time: '2021-04-26T08:23:59Z',
            protection_eligibility_type: 'ITEM_NOT_RECEIVED_ELIGIBLE,UNAUTHORIZED_PAYMENT_ELIGIBLE',
            transaction_fee: {
                currency: 'EUR',
                value: '0.48'
            },
            protection_eligibility: 'ELIGIBLE',
            links: [
                {
                    method: 'GET',
                    rel: 'self',
                    href: 'https://api.sandbox.paypal.com/v1/payments/sale/6R7481343K8159132'
                },
                {
                    method: 'POST',
                    rel: 'refund',
                    href: 'https://api.sandbox.paypal.com/v1/payments/sale/6R7481343K8159132/refund'
                }
            ],
            id: '6R7481343K8159132',
            state: 'completed',
            invoice_number: ''
        },
        links: [
            {
                href: 'https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-4W487015EX264720U-32N35125TV248784B',
                rel: 'self',
                method: 'GET'
            },
            {
                href: 'https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-4W487015EX264720U-32N35125TV248784B/resend',
                rel: 'resend',
                method: 'POST'
            }
        ],
    }

I have tried to GET the /v1/payments/sale/:id but it didn’t bring me much informations.

I have also checked other stack overflow threads on the subject but it wasn’t of any help.
I also don’t want to use success callbacks provided in the front-end SDK because they are not as secure as a webhook (connection can close before triggering the callback see this gitlab issue)

How can I be aware that a user was billed for his subscription ?

2

Answers


  1. Chosen as BEST ANSWER

    We finally found a workaround to make our back-end retrieve the buyer and the item.

    Front-end

    On the subscription button code, we noticed after a lot of trial/errors that the createSubscription method accept promises and that we could use it to send the subscriptionId the the back-end before the payment continues:

    paypal.Buttons({
        style: {...},
        createSubscription: function (data, actions) {
            return actions.subscription.create({
                /* Creates the subscription */
                plan_id: 'P-26J60279VA924454WMCBPBSA',
            }).then(subscriptionId => { // subscriptionId == I-9DH5L3A3JAEB
                return new Promise((res, rej) => {
                    // here we send the subscriptionId to the back-end
                    // and create a pending subscription
                    const body = {subscriptionId, userId, itemId};
                    apiCall('POST', '/subscription', body,() => {
                        // allow to return subscriptionId to paypal
                        resolve(subscriptionId); 
                    })
                });
            });
        },
        onApprove: function (data, actions) {
           // this function was of NO USE
           // it is not safe to call your backend here
           // as connexion can close and paypal doesn't
           // wait after this function to capture payment
           // thus leading to orphaned subscriptions 
           // (paid but not linked to your backend)
        },
    }).render('#paypal-button');
    

    Back-end (webhook handler)

    The back-end wait for the confirmation webhook where webhookResponse.resource.billing_agreement_id is the subscription id and allow to validate the previously created subscription. I don't exactly understand why billing_agreement_id is not named subscrition_id...

    Let me know if it's not clear enougth. I let that as an answer until there is a better way to do it :)


  2. This is my approach to create and verified Paypal subscription payment.

    Firstly follow the Integrate Subscriptions steps from Paypal Developer site.

    Client Side

    html

    <script src="https://www.paypal.com/sdk/js?client-id=<YOUR CLIENT ID>&vault=true&intent=subscription"></script>
    <div id="paypal-button-container"></div>
    

    You can get data from PayPal using the following snippet:

    Javascript

               paypal.Buttons({
                    createSubscription: function( data, actions ) {
                        return actions.subscription.create({
                            'plan_id': '<YOUR SUBSCRIPTION PLAN>' // Creates the subscription
                        });
                    },
                    onApprove: function( data, actions ) {
                        finalize( data, actions );
                    }
                }).render( '#paypal-button-container' ); // Renders the PayPal button
    
                const finalize = async ( data, actions ) => {
                    const rawResponse = await fetch( '/api/paypal-subscription.php', {
                        method: 'POST',
                        headers: {
                            'Accept': 'application/json',
                            'Content-Type': 'application/json'
                        },
                        body: JSON.stringify({ data: data, actions: actions })
                    });
                    const results = await rawResponse.json();
                    if ( results.error == 0 ){
                        console.log( "payment created" );
                    } else {
                        console.log( 'Errore creazione agenzia' );
                    };
                };
    

    When the subscription is confirmed an onApprove event is fired. Inside the function you can call another function to finalize the subscription process. The function have two object: data and action.

    In data object you have a subscriptionID that refers to the unique id of the subscription. You must save this id with the subscription buyer linked to it ( eg: save to database by calling a php file on server using ajax ) .

    Server Side Webhooks

    In the server side you can get data from PayPal. You have to setup a webhooks call in the developer dashboard for the following action (you can select more or all event if you need ).
    BILLING.SUBSCRIPTION.CREATED, BILLING.SUBSCRIPTION.ACTIVATED and for the recurring payment made PAYMENT.SALE.COMPLETED.

    <?php
    $data = json_decode( file_get_contents( "php://input" ), true );
    $data = $data['resource'];
    
    if ( !array_key_exists( 'billing_agreement_id', $data ) ) {
        // Not a payment for a billing agreement
        // handle single payments or:
        die();
    };
    ?>
    

    Keep in mind that: the webhooks simulator doesn’t populate the billing_agreement_id, the key that carry the subscriptioID, referred as id in the other Webhooks calls. I suggest to create in the sandbox a subscription with a daily FREQUENCY with one (1) day interval. With this subscription the PAYMENT.SALE.COMPLETED will be fired immediately. The key to find in PAYMENT.SALE.COMPLETED call is billing_agreement_id.

    Verify Paypal webhook notification

    You also have to verify the authenticity of the notification:

    <php
    // get request headers
    $headers = apache_request_headers();
    
    // get http payload
    $body = file_get_contents( 'php://input' );
    
    // compose signature string: The third part is the ID of the webhook ITSELF(!),
    // NOT the ID of the webhook event sent. You find the ID of the webhook
    // in Paypal's developer backend where you have created the webhook
    $data =
        $headers['Paypal-Transmission-Id'] . '|' .
        $headers['Paypal-Transmission-Time'] . '|' .
        '<WEBHOOK ID FROM THE DEVELOPER DASHBOARD>' . '|' . crc32( $body );
    
    
    // load certificate and extract public key
    $pubKey = openssl_pkey_get_public( file_get_contents( $headers['Paypal-Cert-Url'] ) );
    $key = openssl_pkey_get_details( $pubKey )['key'];
    
    // verify data against provided signature 
    $result = openssl_verify(
        $data,
        base64_decode( $headers['Paypal-Transmission-Sig'] ),
        $key, 'sha256WithRSAEncryption'
    );
    
    
    if ( $result == 1 ) {
        // webhook notification is verified
    } elseif ( $result == 0 ) {
        // webhook notification is NOT verified
    } else {
        // there was an error verifying this
    };
    ?>
    

    The transmission id, the transmission date, the webhook id and a CRC over the HTTP body. The first two can be found in the header of the request, the webhook id in the developer backend (of course, that id will never change), the CRC is calculated like shown below.

    The certificate’s location is in the header, too, so we load it and extract the private key.

    Last thing to watch out for: The name of the algorithm provided by Paypal (again in a header field) is not exactly the same as understood by PHP. Paypal calls it "sha256WithRSA" but openssl_verify will expect "sha256WithRSAEncryption". You can read more about verification precess here

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search