How to set-up a GitHub webhook to WordPress REST API endpoint

In a previous post I detailed how to register a WordPress REST API route and endpoint. In this post I detail how to set-up a GitHub webhook to call a WordPress REST API endpoint whenever the GitHub repository is pushed to. The endpoint can do whatever you need it to do, in my example I parse an OPML file and list the podcasts I’m subscribed to on my Podcasts page.

Step 1: set-up the webhook with GitHub

Login to GitHub and navigate to your repository. Click the repository’s “Settings” menu item.

Screenshot showing where to find the Settings menu item.
Screenshot showing where to find the Settings menu item.

Now on the settings page, click the “Webhooks” menu item.

Screenshot showing where to find the Webhooks menu item.
Screenshot showing where to find the Webhooks menu item.

Now on the webhooks page, click the “Add webhook” button.

Screenshot showing where to find the Add webhook button.
Screenshot showing where to find the Add webhook button.

Now on the add new webhook page, enter the details for your REST API endpoint.

Screenshot showing where to enter details of REST API endpoint.
Screenshot showing where to enter details of REST API endpoint.
  1. Payload URL – this where the payload will be delivered and is the REST API route that will be created later. In the example on this page, I use https://corenominal.com/wp-json/corenominal/github_webhook_podcasts
  2. Content type – select application/json
  3. Secret – this is the string that will be used to secure the endpoint and validate the call originates from GitHub. In this example I use top-secret-string, but in production you should use a stronger secret, such as a UUID.
  4. Click the Add webhook button to complete the set-up

Step 2: register the REST API route and endpoint

With the webhook created, it’s time to create the REST API route and endpoint. The following code can exist in either a WordPress plugin, or within your WordPress theme’s functions.php file.

// Register the route: /wp-json/corenominal/github_webhook_podcasts
// See: https://developer.wordpress.org/rest-api/extending-the-rest-api/routes-and-endpoints/
function corenominal_podcasts_register_route(){
    register_rest_route( 'corenominal', '/github_webhook_podcasts', array(
        'methods' => 'POST',
        'callback' => 'corenominal_podcasts_github_webhook',
        'show_in_index' => false,
        'permission_callback' => '__return_true',
    ));
}
add_action( 'rest_api_init', 'corenominal_podcasts_register_route' );

A breakdown of the above code:

  • Line 3 is the function name, which matches that provided to the add_action call on line 11. Name your function how you see fit.
  • Line 4 calls the register_rest_route function, supplying the namespace, the route, and an array of arguments. Combined, the namespace and route make up the payload URL. E.g.
    domain + 'wp-json' + namespace + route
    In my case, the result being:
    corenominal.com/wp-json/corenominal/github_webhook_podcasts
  • Line 5 declares that the route will receive POST calls. This is the method used by GitHub when sending the webhook payload.
  • Line 6 declares the callback endpoint function. Code below.
  • Line 7 declares that the route should be hidden from the index. By default, an index of all REST API routes is available by sending a GET request to yourdomain.com/wp-json/
  • Line 8 declares that no specific WordPress role is required to access the endpoint.
  • Line 11 calls the WordPress add_action function and registers our new route when WordPress initialises the REST API.

Step 3: secure the endpoint using GitHub’s X-Hub-Signature-256 header

With the route registered, it’s time to create the endpoint function and secure it. For brevity, the example below only contains code relevant to the GitHub webhook and securing the endpoint.

// Endpoint
function corenominal_podcasts_github_webhook($request){
    
    // Get secret from header
    $secret = $request->get_header('X-Hub-Signature-256');

    // Test secret is set
    if($secret === null){
        return array('error' => 'No secret');
    }

    // Test secret value
    // For info about securing webhooks see:
    // https://docs.github.com/en/developers/webhooks-and-events/webhooks/securing-your-webhooks
    if($secret != 'sha256=' . hash_hmac('sha256',file_get_contents('php://input'), 'top-secret-string')){
        return array('error' => 'Invalid secret');
    }

A breakdown of the above code:

  • Line 2 is the the function name. The function name should match the callback function name provided when registering the route. The function takes the $request object, which contains the payload data sent by GitHub.
  • Line 5 gets the secret hash value sent by GitHub.
  • Line 8 tests to see if the hash value was provided, if not a response is returned indicating an error in the request.
  • Line 15 tests the secret value provided by GItHub. The hashed value is a result of GitHub using an HMAC hex digest to compute the hash of the payload using the provided secret phrase. Note, GitHub advises that you should never hardcode the secret into your app. See securing your webhooks for more information.

Step 4: do something

Now that the endpoint is secured, it is time to perform the task. As mentioned above, the endpoint can do whatever you want it to do. In the following example code, the endpoint performs a rather jovial task of fetching my podcast subscriptions OPML file from the GitHub repository, before parsing the file and storing the data in my website’s database. This data is then fetched whenever a visitor hits my Podcasts page and a list of the podcasts I subscribe to are presented to the visitor. The full code can be seen below:

<?php if ( ! defined( 'WPINC' ) ) { die('Direct access prohibited!'); }

// Register the route: /wp-json/corenominal/github_webhook_podcasts
// See: https://developer.wordpress.org/rest-api/extending-the-rest-api/routes-and-endpoints/
function corenominal_podcasts_register_route(){
    register_rest_route( 'corenominal', '/github_webhook_podcasts', array(
        'methods' => 'POST',
        'callback' => 'corenominal_podcasts_github_webhook',
        'show_in_index' => false,
        'permission_callback' => '__return_true',
    ));
}
add_action( 'rest_api_init', 'corenominal_podcasts_register_route' );

// Endpoint
function corenominal_podcasts_github_webhook($request){
    
    // Get secret from header
    $secret = $request->get_header('X-Hub-Signature-256');

    // Test secret is set
    if($secret === null){
        return array('error' => 'No secret');
    }

    // Test secret value
    // For info about securing webhooks see:
    // https://docs.github.com/en/developers/webhooks-and-events/webhooks/securing-your-webhooks
    if($secret != 'sha256=' . hash_hmac('sha256',file_get_contents('php://input'), 'top-secret-string')){
        return array('error' => 'Invalid secret');
    }

    // Params into data array
    $params = $request->get_params();

    // Get the latest commit value
    $commit = $params['head_commit']['id'];

    // Fetch the OPML file from github commit
    // Note we can't just fetch the raw file from main
    // as the cache control lasts for ~5 minutes.
    $opmlfile = file_get_contents('https://raw.githubusercontent.com/corenominal/podcasts-opml/'.$commit.'/gnome-podcasts-exported-shows.opml');

    // Test the file was fetched ok
    if($opmlfile === false){
        return array('error' => 'Failed to fetch file');
    } else {
        // Load the file into simple XML
        $opml = simplexml_load_string($opmlfile);
    }

    // Make WP database class available
    global $wpdb;
    
    // Test DB tables exist, else create them
    corenominal_podcasts_db_init();

    // Count for inserted rows
    $inserted = 0;

    // Process the OPML file
    foreach ($opml->body->outline as $podcast) {
            
        // Attributes to array
        $podcast = current($podcast->attributes());
        
        // Test row for podcast exists in db
        $sql = 'SELECT * FROM podcasts WHERE xmlUrl = %s';
        $row = $wpdb->get_row( $wpdb->prepare( $sql, $podcast['xmlUrl']), ARRAY_A );
        if($row === null){
            
            // Get xml/rss feed for the podcast
            // Note: this can fail due to uncontrollable factors
            // such as feeds located behind Cloudflare etc.
            $feed = file_get_contents($podcast['xmlUrl']);

            // Set a default image for the podcast
            $image = 'https://corenominal.com/wp-content/uploads/2022/07/podcast-default-image-3000x3000-1-scaled.webp';

            // If the feed was captured ok, test we can grab
            // the podcast's image file, else use a default image.
            if($feed){
                // Test the feed actually contains what we are
                // looking for e.g. '<itunes:image href='
                $pos = strpos($feed,'<itunes:image href="');
                if($pos){
                    // Load the feed into simplexml object
                    $feed = simplexml_load_string($feed);
                    
                    // Extract the itunes image
                    foreach ($feed->channel as $channel) {
                        $itunes = $channel->children('http://www.itunes.com/dtds/podcast-1.0.dtd');
                        $image = (string)$itunes->image->attributes();

                        // Test the image url
                        if(filter_var($image, FILTER_VALIDATE_URL)){
                            // Store the image locally
                            // Get file name from url
                            $path = parse_url($image, PHP_URL_PATH);
                            $filename = basename($path);
                            // Explode the file name to obtain the file extension
                            $filename = explode('.',$filename);
                            // Create new hashed filename to prevent file name conflicts
                            $filename = 'podcasts-' . md5($podcast['title']) . '.' . $filename[1];

                            // Get WP upload paths and URLs
                            $upload = wp_upload_dir();

                            // Get remote image
                            $file = file_get_contents($image);

                            // Test the file
                            if($file){
                                // Write the file with new name
                                file_put_contents($upload['path'].'/'.$filename, $file);

                                // New image url
                                $image = $upload['url'] . '/' . $filename;
                            }
                        }
                    }
                }
            }

            
            // Insert new row
            $wpdb->insert( 
                'podcasts', 
                array( 
                    'title' => $podcast['title'],
                    'type' => $podcast['type'],
                    'xmlUrl' => $podcast['xmlUrl'],
                    'htmlUrl' => $podcast['htmlUrl'],
                    'image' => $image,
                ), 
                array( 
                    '%s', 
                    '%s',
                    '%s',
                    '%s',
                    '%s', 
                ) 
            );

            // Increment count
            $inserted++;
        }

        // Test for podcasts to remove
        
        // Count for deleted rows
        $deleted = 0;

        // Get all podcasts from db
        $podcasts = $wpdb->get_results("SELECT * FROM podcasts",ARRAY_A);
        
        // Test each xmlUrl still exists within OPML file
        foreach($podcasts as $podcast){
            if(strpos($opmlfile,$podcast['xmlUrl']) === false){
                // Delete the row
                $wpdb->delete('podcasts', array( 'xmlUrl' => $podcast['xmlUrl'] ));
                
                // Increment count
                $deleted++;
            }
        }

    }

    // Return params
    return array(
        'Inserted' => $inserted,
        'Deleted' => $deleted,
    );
}

function corenominal_podcasts_db_init(){
    // Make WP database class available
    global $wpdb;

    // Create db table if doesn't already exist
    $sql = "CREATE TABLE IF NOT EXISTS
            `podcasts` (
            `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
            `created_at` timestamp NOT NULL DEFAULT current_timestamp(),
            `title` varchar(255) DEFAULT '',
            `type` varchar(255) DEFAULT '',
            `xmlUrl` varchar(255) DEFAULT '',
            `htmlUrl` varchar(255) DEFAULT '',
            `image` varchar(255) DEFAULT '',
            PRIMARY KEY (`id`)
            ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;";
    $query = $wpdb->query( $sql );
}

Step 5: testing and debugging

Screenshot showing recent webhook deliveries and responses.
Screenshot showing recent webhook deliveries and responses.

GitHub provide a handy tool that shows the requests and responses to all attempted payload deliveries. This can be used to test and debug your endpoint. The “Redeliver” button is especially useful as you can resend requests without having to make a push to your repository.

Conclusion

GitHub webhooks are a great way to trigger events and they have an unlimited number of use cases. If you’ve found this post useful, I’d be super interested to learn how you are planning to use webhooks for your projects. Please comment below and let me know.

    Leave a comment

    Your email address will not be published.