Simple WordPress Ko-Fi integration without plugins

Setup the ko-fi webhook on ko-fi.com

Setup your Ko-Fi API at https://ko-fi.com/manage/webhooks

Screenshot of the ko-fi API setup page where the webhook URL is set, and where the verification token is generated

Choose an endpoint for you webhook. I’m using “/kofi-donations” as an example, but you can use whatever you want. We’ll set it up on wordpress next.
Take note of the verification token, we’ll need it later to validate each webhook request we receive.

Create the webhook endpoint in WordPress

Right now “<your-website>/kofi-donations” should return a 404 error, because we still need to setup wordpress to actually use that URL.
Add to your functions.php the following code

add_action('init', function() {
    if (isset($_SERVER['REQUEST_URI']) && $_SERVER['REQUEST_URI'] === '/kofi-donations') {
        handle_kofi_webhook();
        exit;
    }
});

With this function we are simply telling wordpress to run the “handle_kofi_webhook()” function whenever there’s a request to the “/kofi-donations” URI.

Handle the webhook verification

Instructions from the Ko-Fi API page:

The data is sent (posted) with a content type of application/x-www-form-urlencoded. A field named ‘data’ contains the payment infomation as a JSON string.

Your listener should return a status code of 200. If we don’t receive this status code, we’ll retry a reasonable number of times with the same message_id.The data is sent (posted) with a content type of application/x-www-form-urlencoded. A field named ‘data’ contains the payment infomation as a JSON string.

Your listener should return a status code of 200. If we don’t receive this status code, we’ll retry a reasonable number of times with the same message_id.

First of all we need to check that the request type to the webhook endpoint is POST, and then that there’s a field named “data”.
If the “data” field exists and isn’t empty we can check its content to verify if it is legitimate.

As we can see from the payload example, ko-fi sends with every post requests the verification token we generated at the beginning. We’ll compare the code we generated before with this code, and if they match we can trust that the message is coming from ko-fi.

data = {
  "verification_token": "########-####-####-####-############",
  "message_id": "b1ac68e2-42e0-4a9a-bf43-ceee37d786d1",
  "timestamp": "2024-11-22T13:23:35Z",
  "type": "Donation",
  "is_public": true,
  "from_name": "Jo Example",
  "message": "Good luck with the integration!",
  "amount": "3.00",
  "url": "https://ko-fi.com/Home/CoffeeShop?txid=00000000-1111-2222-3333-444444444444",
  "email": "[email protected]",
  "currency": "USD",
  "is_subscription_payment": false,
  "is_first_subscription_payment": false,
  "kofi_transaction_id": "00000000-1111-2222-3333-444444444444",
  "shop_items": null,
  "tier_name": null,
  "shipping": null
}

If the token we generated and the token we received with the post request match, we return a 200 HTTP status code to confirm ko-fi.com we received the message.
If the tokens don’t match we scrap the payload

function handle_kofi_webhook() {
    $data = (isset($_POST) && isset($_POST["data"]))?$_POST["data"]:"";
    if (!empty($data)) {
    	$json = json_decode(stripslashes($data), true);

        $kofi_secret = '########-####-####-####-############'; //Add your Ko-Fi secret
        if (isset($json['verification_token']) && (! empty($json['verification_token'])) && ($json['verification_token'] === $kofi_secret)) {
	    // Do stuff
            header('HTTP/1.1 200 OK');
            echo json_encode(['message' => 'Webhook received successfully', 'data' => $json]);
        } else {
            header('HTTP/1.1 401 Unauthorized');
            echo json_encode(['error' => 'Unauthorized']);
        }
    } else {
        header('HTTP/1.1 400 Bad Request');
        echo json_encode(['error' => 'Missing data field']);
    }

    exit;
}

Store the payload data in a custom WordPress post type

Instead of storing the payload data directly in the database in a custom table, we are going to leverage WordPress’ post types, and their functions.
Let’s register the “kofi_donation” post type.

'label' => 'Ko-fi Donations'

The label is the string shown in the admin menu, you can set this to whatever you want, we are not actually going to see this value.

'supports' => ['title', 'custom-fields'],

With the “supports” argument we define the core features of our custom post.
We are going to generate a “kofi_donation” post when we receive a valid payload, and store its data in the post’s meta fields.
We are going to define for our custom post type only the title and the custom fields, we don’t care about the other features posts have ('comments', 'revisions', 'trackbacks', 'author', 'excerpt', 'page-attributes', 'thumbnail', etc)

'capability_type' => 'post',
'map_meta_cap' => true,
'capabilities' => [
    'create_posts' => false,
],

With these arguments we copy the default post type capabilities on this custom post type, but then disable the create_posts feature.
This means that if we set to true the show_ui argument, we can’t manually add new posts.
We want only the code to generate new posts when a webhook message is received.

The full custom post type code

add_action('init', function() {
    register_post_type('kofi_donation', [
        'label' => 'Ko-fi Donations',
        'supports' => ['title', 'custom-fields'],
        'capability_type' => 'post',
	'map_meta_cap' => true,
	'capabilities' => [
            'create_posts' => false,
        ],
        'menu_icon' => 'dashicons-coffee',
    ]);
});

Create post and post-meta from the payload data

In the handle_kofi_webhook() function, after validating the verification token, we create a post with our newly defined post-type, and then add to its meta fields all the key-value combinations from the json payload

$post_id = wp_insert_post([
    'post_type' => 'kofi_donation',
    'post_title' => 'Donation from ' . $json['from_name'],
    'post_status' => 'publish',
    ]);

if ($post_id) {
    foreach ($json as $key => $value) {
        if (!is_array($value)) {
            update_post_meta($post_id, $key, $value);
        }
    }
}

Create a custom admin menu to visualize the donations

We wrote the code to receive and then store the Ko-Fi donations data, but now we need to visualize it somehow.
To do so we are going to create a custom admin menu

add_action('admin_menu', function() {
    $menu_label = 'Ko-fi';

    add_menu_page(
        'Ko-fi Donations',         // Page title
        $menu_label,               // Menu title with notification
        'manage_options',          // Capability
        'kofi-donations',          // Menu slug
        'display_kofi_donations',  // Callback function
        'dashicons-coffee',        // Icon
        26                         // Position
    );
});

The important parts of this function are manage_options and display_kofi_donations.
Only users with the “manage_options” permission can see/use this new admin page.
“display_kofi_donations” is the callback function that will populate the ko-fi donations admin page.
The position defined by the integer 26 is right after the “comments” menu. By changing this value you can display the ko-fi admin menu wherver you like.

// Positions
    2 – Dashboard
    4 – Separator
    5 – Posts
    10 – Media
    15 – Links
    20 – Pages
    25 – Comments
    59 – Separator
    60 – Appearance
    65 – Plugins
    70 – Users
    75 – Tools
    80 – Settings
    99 – Separator

Adding notifications to the Ko-Fi menu

It would be nice to be able to see at a first glance if we got new donations. Luckily it’s really easy to implement!

screenshot of the admin menu showing an unread notification on the custom ko-fi menu

In the function that adds the admin menu we add some HTML to display the red notification next to the menu name

$unread_count = get_option('kofi_unread_donations', 0);

if ($unread_count > 0) {
    $menu_label .= ' <span class="update-plugins count-' . $unread_count . '"><span class="plugin-count">' . $unread_count . '</span></span>';
}

And in our handle_kofi_webhook function we add this to increment and save the unread count

$unread_count = get_option('kofi_unread_donations', 0); //Get count. 0 if doesn't exist
$unread_count++; // Add one
update_option('kofi_unread_donations', $unread_count); // Save the new count

Display the data in the custom menu page

screenshot of the ko-fi custom admin page displaying various donations of different types

We created the menu page, but it is totally empty. The callback function we defined before will actually display the data.
A simple delete button will allow us to delete a row without having to manually manipulate the database.

function display_kofi_donations() {
    // Reset unread counter after viewing the donations
    update_option('kofi_unread_donations', 0);

    $args = array(
        'post_type' => 'kofi_donation',
        'posts_per_page' => -1, // Get all donations
        'post_status' => 'publish'
    );
    $donations_query = new WP_Query($args);

    echo '<div class="wrap">';
    echo '<h1>Ko-fi Donations</h1>';

    if ($donations_query->have_posts()) {
        echo '<table class="wp-list-table widefat fixed striped">';
        echo '<thead>
                <tr>
                    <th>Type</th>
					<th>From</th>
                    <th>Message</th>
                    <th>Amount</th>
                    <th>Timestamp</th>
					<th>Actions</th>
                </tr>
              </thead>';
        echo '<tbody>';

        while ($donations_query->have_posts()) {
            $donations_query->the_post();

            $from_name = get_post_meta(get_the_ID(), 'from_name', true);
	    $type = get_post_meta(get_the_ID(), 'type', true);
            $message = get_post_meta(get_the_ID(), 'message', true);
            $amount = get_post_meta(get_the_ID(), 'amount', true);
            $currency = get_post_meta(get_the_ID(), 'currency', true);
            $timestamp = get_post_meta(get_the_ID(), 'timestamp', true);
            $delete_url = add_query_arg( array(
                'action' => 'delete_kofi_donation', // Custom action
                'post_id' => get_the_ID(),          // Pass the post ID
                'nonce' => wp_create_nonce('delete_kofi_donation_nonce'), // Add nonce for security
            ), admin_url('admin-post.php')); // Use admin-post.php for custom actions

            echo '<tr>';
            echo '<td>' . esc_html($type) . '</td>';
			echo '<td>' . esc_html($from_name) . '</td>';
            echo '<td>' . esc_html($message) . '</td>';
            echo '<td>' . esc_html($amount) . esc_html($currency) .'</td>';
            echo '<td>' . esc_html(date('Y-m-d H:i:s', strtotime($timestamp))) . '</td>';
			echo '<td>';
            echo '<a href="' . esc_url($delete_url) . '" class="button" style="color: red;">Delete</a>';
            echo '</td>';
            echo '</tr>';
        }

        echo '</tbody>';
        echo '</table>';

        wp_reset_postdata(); // Reset post data
    } else {
        echo '<p>No donations received yet.</p>';
    }

    echo '</div>';
}

This is the function that triggers when we click the delete button

add_action('admin_post_delete_kofi_donation', 'handle_delete_kofi_donation');
function handle_delete_kofi_donation() {
    // Check nonce for security
    if (!isset($_GET['nonce']) || !wp_verify_nonce($_GET['nonce'], 'delete_kofi_donation_nonce')) {
        wp_die('Nonce verification failed');
    }

    // Get the post ID from the URL
    if (!isset($_GET['post_id'])) {
        wp_die('Post ID is missing');
    }

    $post_id = intval($_GET['post_id']);

    // Make sure it's a valid donation post
    if (get_post_type($post_id) !== 'kofi_donation') {
        wp_die('Invalid donation post');
    }

    // Delete the post and its metadata
    wp_delete_post($post_id, true); // true ensures the post is permanently deleted

    wp_redirect(admin_url('admin.php?page=kofi-donations&deleted=true'));
    exit;
}

The final complete code

add_action('init', function() {
    if (isset($_SERVER['REQUEST_URI']) && $_SERVER['REQUEST_URI'] === '/kofi-donations') {
        handle_kofi_webhook();
        exit;
    }
});

add_action('init', function() {
    register_post_type('kofi_donation', [
        'label' => 'Ko-fi Donations',
        'supports' => ['title', 'custom-fields'],
        'capability_type' => 'post',
	'map_meta_cap' => true,
	'capabilities' => [
            'create_posts' => false,
        ],
        'menu_icon' => 'dashicons-coffee',
    ]);
});

function handle_kofi_webhook() {
    $data = (isset($_POST) && isset($_POST["data"]))?$_POST["data"]:"";
    if (!empty($data)) {
    	$json = json_decode(stripslashes($data), true);
        
        $kofi_secret = '########-####-####-####-############'; //Add your Ko-Fi secret
        if (isset($json['verification_token']) && (! empty($json['verification_token'])) && ($json['verification_token'] === $kofi_secret)) {
            $post_id = wp_insert_post([
                'post_type' => 'kofi_donation',
                'post_title' => 'Donation from ' . $json['from_name'],
                'post_status' => 'publish',
            ]);

            if ($post_id) {
                foreach ($json as $key => $value) {
                    if (!is_array($value)) {
                        update_post_meta($post_id, $key, $value);
                    }
                }
            }
			$unread_count = get_option('kofi_unread_donations', 0);
			$unread_count++;
			update_option('kofi_unread_donations', $unread_count);
			
            header('HTTP/1.1 200 OK');
            echo json_encode(['message' => 'Webhook received successfully', 'data' => $json]);
        } else {
            header('HTTP/1.1 401 Unauthorized');
            echo json_encode(['error' => 'Unauthorized']);
        }
    } else {
        header('HTTP/1.1 400 Bad Request');
        echo json_encode(['error' => 'Missing data field']);
    }

    exit;
}

add_action('admin_menu', function() {
    $menu_label = 'Ko-fi';
    $unread_count = get_option('kofi_unread_donations', 0);

    if ($unread_count > 0) {
        $menu_label .= ' <span class="update-plugins count-' . $unread_count . '"><span class="plugin-count">' . $unread_count . '</span></span>';
    }

    add_menu_page(
        'Ko-fi Donations',
        $menu_label,
        'manage_options',
        'kofi-donations',
        'display_kofi_donations',
        'dashicons-coffee',
        26
    );
});

function display_kofi_donations() {
    update_option('kofi_unread_donations', 0);

    $args = array(
        'post_type' => 'kofi_donation',
        'posts_per_page' => -1,
        'post_status' => 'publish'
    );
    $donations_query = new WP_Query($args);

    echo '<div class="wrap">';
    echo '<h1>Ko-fi Donations</h1>';

    if ($donations_query->have_posts()) {
        echo '<table class="wp-list-table widefat fixed striped">';
        echo '<thead>
                <tr>
                    <th>Type</th>
					<th>From</th>
                    <th>Message</th>
                    <th>Amount</th>
                    <th>Timestamp</th>
					<th>Actions</th>
                </tr>
              </thead>';
        echo '<tbody>';

        while ($donations_query->have_posts()) {
            $donations_query->the_post();

            // Retrieve custom fields
            $from_name = get_post_meta(get_the_ID(), 'from_name', true);
	    $type = get_post_meta(get_the_ID(), 'type', true);
            $message = get_post_meta(get_the_ID(), 'message', true);
            $amount = get_post_meta(get_the_ID(), 'amount', true);
            $currency = get_post_meta(get_the_ID(), 'currency', true);
            $timestamp = get_post_meta(get_the_ID(), 'timestamp', true);
            $delete_url = add_query_arg( array(
                'action' => 'delete_kofi_donation',
                'post_id' => get_the_ID(),
                'nonce' => wp_create_nonce('delete_kofi_donation_nonce'),
            ), admin_url('admin-post.php'));

            echo '<tr>';
            echo '<td>' . esc_html($type) . '</td>';
			echo '<td>' . esc_html($from_name) . '</td>';
            echo '<td>' . esc_html($message) . '</td>';
            echo '<td>' . esc_html($amount) . esc_html($currency) .'</td>';
            echo '<td>' . esc_html(date('Y-m-d H:i:s', strtotime($timestamp))) . '</td>';
			echo '<td>';
            echo '<a href="' . esc_url($delete_url) . '" class="button" style="color: red;">Delete</a>';
            echo '</td>';
            echo '</tr>';
        }

        echo '</tbody>';
        echo '</table>';

        wp_reset_postdata(); // Reset post data
    } else {
        echo '<p>No donations received yet.</p>';
    }

    echo '</div>';
}

add_action('admin_post_delete_kofi_donation', 'handle_delete_kofi_donation');
function handle_delete_kofi_donation() {
    if (!isset($_GET['nonce']) || !wp_verify_nonce($_GET['nonce'], 'delete_kofi_donation_nonce')) {
        wp_die('Nonce verification failed');
    }

    if (!isset($_GET['post_id'])) {
        wp_die('Post ID is missing');
    }

    $post_id = intval($_GET['post_id']);

    if (get_post_type($post_id) !== 'kofi_donation') {
        wp_die('Invalid donation post');
    }

    wp_delete_post($post_id, true);
    wp_redirect(admin_url('admin.php?page=kofi-donations&deleted=true'));
    exit;
}