4

I have a Webhook on my Xero Account for Invoices. When a new invoice is created I want to access the Xero Api using the data from the Webhook to Send the user the invoice email. I have all the code for this but my issue is that the Webhook expects a response of 200 and when I call the Xero Api code from within the webhook I get an error on Xero saying the response contained a body. This makes sense as I'm submitting and getting data from the Api.

So how can I make my requests to the Xero Api without interfering with the Xero Webhook response?

Webhook Code:

<?php
    ///////////////////////////////////////////////////////////////////////////
    // WEBHOOK AUTHENTICATION - START
    ///////////////////////////////////////////////////////////////////////////
    //hook section
    $rawPayload = file_get_contents("php://input");
    // Update your webhooks key here
    $webhookKey = 'myWebhookKey';

    // Compute the payload with HMACSHA256 with base64 encoding
    $computedSignatureKey = base64_encode(
        hash_hmac('sha256', $rawPayload, $webhookKey, true)
    );

    // Signature key from Xero request
    $xeroSignatureKey = $_SERVER['HTTP_X_XERO_SIGNATURE'];

    $isEqual = false;

    if (hash_equals($computedSignatureKey, $xeroSignatureKey)) {
        $isEqual = true;
        http_response_code(200);
        
        // getting and passing the data to the api functionality
        $data = json_decode($rawPayload);
        xero_api($data);
    } else {
        http_response_code(401);
    }
    ///////////////////////////////////////////////////////////////////////////
    // WEBHOOK AUTHENTICATION - END
    ///////////////////////////////////////////////////////////////////////////
?>

Api code:

<?php
///////////////////////////////////////////////////////////////////////////
// XERO API FUNCITONALITY - START
///////////////////////////////////////////////////////////////////////////
function xero_api($data) {
    if ($data->events[0]->eventType === 'CREATE') {
        $resourseId = $data->events[0]->resourceId;

        ///////////////////////////////////////////////////////////////////////////
        // GET XERO CREDENTIALS - START
        ///////////////////////////////////////////////////////////////////////////
        global $wpdb;

        $xeroKeys = $wpdb->get_row("SELECT * FROM {$wpdb->prefix}xero_keys WHERE ID = 0", ARRAY_A);

        $clientId = $xeroKeys['client_id'];
        $clientSecret = $xeroKeys['client_secret'];
        $refreshToken = $xeroKeys['refresh_token'];
        $tenantId = $xeroKeys['tenant_id'];
        ///////////////////////////////////////////////////////////////////////////
        // GET XERO CREDENTIALS - END
        ///////////////////////////////////////////////////////////////////////////

        ///////////////////////////////////////////////////////////////////////////
        // GET ACCESS TOKEN AND GENERATE NEW REFRESH TOKEN - START
        ///////////////////////////////////////////////////////////////////////////
        $args = array(
            'headers' => array(
                'grant_type' => 'refresh_token',
            ),
            'body' => array(
                'grant_type' => 'refresh_token',
                'refresh_token' => $refreshToken,
                'client_id' => $clientId,
                'client_secret' => $clientSecret
            )
        );

        $refreshTokenRes = wp_remote_post('https://identity.xero.com/connect/token?=', $args);
        $refreshTokenBody = json_decode($refreshTokenRes['body']);

        if (isset($refreshTokenBody->refresh_token) && isset($refreshTokenBody->access_token)) {
            $updateTokens = $wpdb->update(
                $wpdb->prefix . 'xero_keys',
                array(
                    'refresh_token' => $refreshTokenBody->refresh_token,
                    'access_token' => $refreshTokenBody->access_token
                ),
                array('ID' => 0),
                array('%s', '%s'),
                array('%d')
            );
        }
        ///////////////////////////////////////////////////////////////////////////
        // GET ACCESS TOKEN AND GENERATE NEW REFRESH TOKEN - End
        ///////////////////////////////////////////////////////////////////////////

        $args = array(
            'headers' => array(
                'xero-tenant-id' => $tenantId,
                'Authorization' => 'Bearer ' . $refreshTokenBody->access_token,
                'Accept' => 'application/json',
                'Content-Type' => 'application/json'
            ),
        ); 

        $response = wp_remote_post('https://api.xero.com/api.xro/2.0/Invoices/' . $resourseId . '/Email', $args);
    }
}
///////////////////////////////////////////////////////////////////////////
// XERO API FUNCITONALITY - END
///////////////////////////////////////////////////////////////////////////
?>
Reece
  • 2,581
  • 10
  • 42
  • 90
  • 2
    Can't speak to your code specifically, but best practice workflow around webhooks would be: 1) Receive webhook 2) Validate webhook payload using `x-xero-signature` header 3) Save webhook payload in a db/queue 4) Respond with 200 status code ...and have a separate process to run through your db/queue to perform work on the received payload. Trying to do work before you've responded to the webhook is risky because you can't guarantee the Xero API will respond within the 5 second webhook time limit. – rustyskates Dec 01 '20 at 20:12
  • @rustyskates Thanks for the advice. But when you say "have a separate process to run through your db/queue" does this mean using a cron job or is there another method to this? I'm using WordPress btw. – Reece Dec 02 '20 at 09:20

1 Answers1

4

The problem is that you need to separate these calls.

As the comment to your question said, you need to first deal with the webhook, then queue the job that will trigger your API. Something like this would do

<?php
    ///////////////////////////////////////////////////////////////////////////
    // WEBHOOK AUTHENTICATION - START
    ///////////////////////////////////////////////////////////////////////////
    //hook section
    $rawPayload = file_get_contents("php://input");
    // Update your webhooks key here
    $webhookKey = 'myWebhookKey';

    // Compute the payload with HMACSHA256 with base64 encoding
    $computedSignatureKey = base64_encode(
        hash_hmac('sha256', $rawPayload, $webhookKey, true)
    );

    // Signature key from Xero request
    $xeroSignatureKey = $_SERVER['HTTP_X_XERO_SIGNATURE'];

    $isEqual = false;

    if (hash_equals($computedSignatureKey, $xeroSignatureKey)) {
        $isEqual = true;
        http_response_code(200);
        
        // getting and passing the data to the api functionality
        $data = json_decode($rawPayload);

        wp_schedule_single_event(
            time() + 10,
            'send_xero_api_call',
            ['data' => $data]
        );
    } else {
        http_response_code(401);
    }
    ///////////////////////////////////////////////////////////////////////////
    // WEBHOOK AUTHENTICATION - END
    ///////////////////////////////////////////////////////////////////////////

Then you need to register a WP Cron

add_action('send_xero_api_call', 'xero_api_cron', 10);

function xero_api_cron($data) {
    if ($data->events[0]->eventType === 'CREATE') {
        $resourseId = $data->events[0]->resourceId;

        ///////////////////////////////////////////////////////////////////////////
        // GET XERO CREDENTIALS - START
        ///////////////////////////////////////////////////////////////////////////
        global $wpdb;

        $xeroKeys = $wpdb->get_row("SELECT * FROM {$wpdb->prefix}xero_keys WHERE ID = 0", ARRAY_A);

        $clientId = $xeroKeys['client_id'];
        $clientSecret = $xeroKeys['client_secret'];
        $refreshToken = $xeroKeys['refresh_token'];
        $tenantId = $xeroKeys['tenant_id'];
        ///////////////////////////////////////////////////////////////////////////
        // GET XERO CREDENTIALS - END
        ///////////////////////////////////////////////////////////////////////////

        ///////////////////////////////////////////////////////////////////////////
        // GET ACCESS TOKEN AND GENERATE NEW REFRESH TOKEN - START
        ///////////////////////////////////////////////////////////////////////////
        $args = array(
            'headers' => array(
                'grant_type' => 'refresh_token',
            ),
            'body' => array(
                'grant_type' => 'refresh_token',
                'refresh_token' => $refreshToken,
                'client_id' => $clientId,
                'client_secret' => $clientSecret
            )
        );

        $refreshTokenRes = wp_remote_post('https://identity.xero.com/connect/token?=', $args);
        $refreshTokenBody = json_decode($refreshTokenRes['body']);

        if (isset($refreshTokenBody->refresh_token) && isset($refreshTokenBody->access_token)) {
            $updateTokens = $wpdb->update(
                $wpdb->prefix . 'xero_keys',
                array(
                    'refresh_token' => $refreshTokenBody->refresh_token,
                    'access_token' => $refreshTokenBody->access_token
                ),
                array('ID' => 0),
                array('%s', '%s'),
                array('%d')
            );
        }
        ///////////////////////////////////////////////////////////////////////////
        // GET ACCESS TOKEN AND GENERATE NEW REFRESH TOKEN - End
        ///////////////////////////////////////////////////////////////////////////

        $args = array(
            'headers' => array(
                'xero-tenant-id' => $tenantId,
                'Authorization' => 'Bearer ' . $refreshTokenBody->access_token,
                'Accept' => 'application/json',
                'Content-Type' => 'application/json'
            ),
        ); 

        $response = wp_remote_post('https://api.xero.com/api.xro/2.0/Invoices/' . $resourseId . '/Email', $args);
    }
}

Something to this extent. The $data argument should contain the data you passed (not 100% sure how it looks for you). Also, you'll need to check the throttling by the API, so you might adjust the time of the execution of your job. And make sure you have somewhere stored if your background job finished successfully.

One thing I suggest, since you are storing sensitive info in your DB (access and refresh tokens), is to encrypt them when storing them in the DB, and decrypt when fetching them.

You can check how I implemented background jobs in my plugin

https://github.com/dingo-d/woo-solo-api/tree/develop/src/BackgroundJobs https://github.com/dingo-d/woo-solo-api/blob/develop/src/Request/SoloApiRequest.php#L428-L438

You can use WP Queue lib from deliciousbrains: https://github.com/deliciousbrains/wp-queue/

It's a wrapper around WP Cron with custom DB tables for handling the queues, which will allow you to check if the jobs executed correctly or not.

dingo_d
  • 11,160
  • 11
  • 73
  • 132
  • 1
    Hey, thanks for the response. I will try this out tomorrow and let you know how it goes. – Reece Dec 06 '20 at 18:46
  • 1
    This works perfectly without interfering with my webhook thanks!!! I just need to work on my api code a little and all should be good – Reece Dec 07 '20 at 16:40
  • 1
    Awesome, glad I could help :) – dingo_d Dec 07 '20 at 17:19
  • I think you missed defining the amount of parameters the function can have inside the add_action. I tried to edit but the edit is too small to be accepted – Reece Dec 07 '20 at 17:41
  • It depends on how many parameters you want to pass. For instance [here](https://github.com/dingo-d/woo-solo-api/blob/develop/src/BackgroundJobs/SendCustomerEmail.php#L48-L54), I am spreading the `$args` argument. I passed the 5 arguments [here](https://github.com/dingo-d/woo-solo-api/blob/develop/src/Request/SoloApiRequest.php#L428-L438). You can do the same in your call. In the example I passed just one. – dingo_d Dec 07 '20 at 18:16
  • I'm just passing the one PHP Object. So I added 1 to the accepted args. This is correct right? – Reece Dec 07 '20 at 18:23
  • Yes. In your hook `add_action('send_xero_api_call', 'xero_api_cron', 10);` the number of arguments is 1 by default (after the priority). – dingo_d Dec 07 '20 at 18:24
  • I've been always defining the single argument. Thanks for letting me know. – Reece Dec 10 '20 at 12:35