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
- Step 2: register the REST API route and endpoint
- Step 3: secure the endpoint using GitHub’s X-Hub-Signature-256 header
- Step 4: do something
- Step 5: testing and debugging
- Conclusion
Step 1: set-up the webhook with GitHub
Login to GitHub and navigate to your repository. Click the repository’s “Settings” menu item.

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

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

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

- 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
- Content type – select
application/json
- 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. - 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

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.