How do I create a custom api endpoint?

I’m sharing with you an API that I created for a plugin that I am developing, the code is open source and modify as you want. This should give you a basic idea and get you started.

This API file itself allow me to query the server remotely ofcourse you’ll need a db table to store/verify API access tokens and a class for each method e.g. get users, set role, etc.

<?php

if ( ! defined( 'ABSPATH' ) ) {
    exit; // Exit if accessed directly
}

/**
 * MC_API class
 */
class MC_API {

    private     $pretty_print = false;
    private     $is_valid_request = false;

    public      $key_data = null;
    public      $permissions = null;

    private     $log_requests = true;
    private     $data = array();

    public      $endpoint;
    public      $api_vars;

    /**
     * Constructor
     */
    public function __construct() {

        add_action( 'init',         array( $this, 'add_endpoint'    ) );
        add_action( 'wp',           array( $this, 'process_query'   ), -1 );
        add_filter( 'query_vars',   array( $this, 'query_vars'      ) );

        $this->pretty_print = defined( 'JSON_PRETTY_PRINT' ) ? JSON_PRETTY_PRINT : null;

        // Allow API request logging to be turned off
        $this->log_requests = apply_filters( 'mc_api_log_requests', $this->log_requests );
    }

    /**
     * Add API endpoint
     */
    public static function add_endpoint() {
        add_rewrite_endpoint( 'mc-api', EP_ALL );
    }

    /**
     * Determines the kind of query requested and also ensure it is a valid query
     */
    private function set_query_mode() {
        global $wp_query;

        // Whitelist our query options
        $accepted = apply_filters( 'mc_api_valid_query_modes', array(
            'get_user',
            'update_user',
            'update_usermeta',
            'delete_user',
            'bulk_delete_users',
            'generate_key',
            'revoke_key',
            'get_permissions',
            'set_role',
            'set_credits',
            'add_credits',
            'deduct_credits',
            'transfer_credits',
            'set_user_status',
            'friend_request',
            'friend_cancel',
            'friend_approve',
            'friend_reject',
            'friend_delete',
            'follow',
            'unfollow',
            'get_info'
        ) );

        $query = isset( $wp_query->query_vars['mc-api'] ) ? $wp_query->query_vars['mc-api'] : null;

        // Make sure our query is valid
        if ( ! in_array( $query, $accepted ) ) {
            $this->send_error( 'invalid_query' );
        }

        $this->endpoint = $query;
    }

    /**
     * Registers query vars for API access
     */
    public function query_vars( $vars ) {

        $this->api_vars = array(
            'format',
            'consumer_key',
            'consumer_secret',
            'user',
            'users',
            'user1',
            'user2',
            'fields',
            'values',
            'id',
            'limit',
            'permissions',
            'role',
            'amount',
            'status'
        );

        $this->api_vars = apply_filters( 'mc_api_query_vars', $this->api_vars );

        foreach( $this->api_vars as $var ) {
            $vars[] = $var;
        }

        return $vars;
    }

    /**
     * Validate the API request
     */
    private function validate_request() {
        global $wp_query;

        $consumer_key       = isset( $wp_query->query_vars['consumer_key'] ) ? $wp_query->query_vars['consumer_key'] : null;
        $consumer_secret    = isset( $wp_query->query_vars['consumer_secret'] ) ? $wp_query->query_vars['consumer_secret'] : null;

        if ( ! $consumer_key || ! $consumer_secret ) {
            $this->send_error( 'missing_auth' );
        }

        $user = $this->get_user_by_consumer_key( $consumer_key );
        if ( ! $user ) {
            $this->send_error( 'invalid_auth', 401 );
        }

        // Compare provided hash with stored database hash
        if ( ! hash_equals( $user->consumer_secret, $consumer_secret ) ) {
            $this->send_error( 'invalid_auth', 401 );
        }

        // Check that user did not exceed API limit
        if ( $user->access_limit && $user->queries >= $user->access_limit ) {
            $this->send_error( 'exceeded_limit', 401 );
        }

        /**
         * User does not have API manager capability, so we need to ensure that he is querying an endpoint that
         * is possible with his API key permissions
         */
        $can_read = array(
            'get_user',
        );

        if ( $user->permissions == 'read' && ! in_array( $this->endpoint, $can_read ) ) {
            $this->send_error( 'invalid_permissions', 401 );
        }

        // This is a valid request
        $this->is_valid_request = true;
        $this->key_data         = $user;

        $this->update_last_access();
    }

    /**
     * Get user data and API key information by provided consumer key
     */
    private function get_user_by_consumer_key( $consumer_key ) {
        global $wpdb;

        $user = $wpdb->get_row( $wpdb->prepare("SELECT * FROM {$wpdb->prefix}mc_api_keys WHERE consumer_key = %s", $consumer_key ) );

        if ( is_object( $user ) ) {
            $user->user_id          = absint( $user->user_id );
            $user->key_id           = absint( $user->key_id );
            $user->access_limit     = absint( $user->access_limit );
            $user->queries          = absint( $user->queries );
        }

        return $user;
    }

    /**
     * Updated API Key last access datetime.
     */
    private function update_last_access() {
        global $wpdb;

        $key_id     = $this->key_data->key_id;
        $queries    = $this->key_data->queries + 1;

        $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}mc_api_keys SET last_access = %s, queries = %d WHERE key_id = %d", current_time( 'mysql' ), $queries, $key_id ) );
    }

    /**
     * Listens for the API and then processes the API requests
     */
    public function process_query() {
        global $wp_query;

        // Check if user is not querying API
        if ( ! isset( $wp_query->query_vars['mc-api'] ) )
            return;

        // Check for API var. Get out if not present
        if ( empty( $wp_query->query_vars['mc-api'] ) ) {
            $this->send_error( 'invalid_query' );
        }

        // Determine the kind of query
        $this->set_query_mode();

        // Check for a valid user and set errors if necessary
        $this->validate_request();

        // Only proceed if no errors have been noted
        if( ! $this->is_valid_request ) {
            $this->send_error( 'invalid_auth', 401 );
        }

        // Tell WP we are doing API request
        if( ! defined( 'MC_DOING_API' ) ) {
            define( 'MC_DOING_API', true );
        }

        // Need to collect $this->data before sending it
        $data = array();
        $class_name = str_replace(' ', '_', ucwords( str_replace('_', ' ', $this->endpoint ) ) );
        $class_name = "MC_API_" . $class_name;
        $data = new $class_name();
    }

    /**
     * Before we send the data to output function
     */
    public function send_data( $data ) {
        global $wp_query, $wpdb;

        $this->data = apply_filters( 'mc_api_output_data', $data, $this->endpoint, $this );

        // In case we do not have any data even after filtering
        if ( count( (array) $this->data ) == 0 ) {
            $this->data = array(
                'message'           => __( 'Your API request returned no data.', 'mojocommunity' ),
                'queried_endpoint'  => $this->endpoint
            );
        }

        // Log this API request
        $this->log_request();

        $this->output();
    }

    /**
     * Log a successful API request
     */
    private function log_request() {
        global $wp_query;

        if ( ! $this->log_requests )
            return;

        $log = new MC_API_Log();
    }

    /**
     * The query data is outputted as JSON by default
     */
    private function output( $status_code = 200 ) {

        $format = $this->get_output_format();

        status_header( $status_code );

        do_action( 'mc_api_output_before', $this->data, $this, $format );

        switch ( $format ) :

            case 'json' :
                header( 'Content-Type: application/json' );
                if ( ! empty( $this->pretty_print ) )
                    echo json_encode( $this->data, $this->pretty_print );
                else
                    echo json_encode( $this->data );
                break;

            default :
                // Allow other formats to be added via extensions
                do_action( 'mc_api_output_' . $format, $this->data, $this );
                break;

        endswitch;

        do_action( 'mc_api_output_after', $this->data, $this, $format );

        die();
    }

    /**
     * Generate API key.
     */
    public function generate_api_key( $args = array() ) {
        global $wpdb;

        $user_id            = ( isset( $args['user_id'] ) ) ? absint( $args['user_id'] ) : null;
        $description        = ( isset( $args['description'] ) ) ? $args['description'] : __('Generated via the API', 'mojocommunity' );
        $permissions        = ( isset( $args['permissions'] ) && in_array( $args['permissions'], array( 'read', 'write', 'read_write' ) ) ) ? $args['permissions'] : 'read';
        $access_limit       = ( isset( $args['access_limit'] ) ) ? absint( $args['access_limit'] ) : 0;
        $consumer_key       = 'ck_' . mc_rand_hash();
        $consumer_secret="cs_" . mc_rand_hash();
        $queries            = 0;

        if ( ! $user_id )
            return false;

        $data = array(
            'user_id'           => $user_id,
            'description'       => $description,
            'permissions'       => $permissions,
            'consumer_key'      => $consumer_key,
            'consumer_secret'   => $consumer_secret,
            'truncated_key'     => substr( $consumer_key, -7 ),
            'access_limit'      => $access_limit,
            'queries'           => $queries
        );

        $wpdb->insert(
            $wpdb->prefix . 'mc_api_keys',
            $data,
            array(
                '%d',
                '%s',
                '%s',
                '%s',
                '%s',
                '%s',
                '%d',
                '%d'
            )
        );

        $data = array(
            'user_id'           => $user_id,
            'consumer_key'      => $consumer_key,
            'consumer_secret'   => $consumer_secret,
            'permissions'       => $permissions,
            'access_limit'      => $access_limit
        );

        return $data;
    }

    /**
     * Revokes API access
     */
    public function revoke_api_key( $key_id = 0 ) {
        global $wpdb;

        $key = $wpdb->get_row( $wpdb->prepare( "SELECT user_id, truncated_key FROM {$wpdb->prefix}mc_api_keys WHERE key_id = %d;", $key_id ) );

        if ( ! $key ) {
            return new MC_Error( 'invalid_api_key_id', __( 'The specified API key identifier is invalid.', 'mojocommunity' ) );
        }

        $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}mc_api_keys WHERE key_id = %d;", $key->key_id ) );

        $data = array(
            'user_id'                   => $key->user_id,
            'consumer_key_ending_in'    => $key->truncated_key,
            'success'                   => __( 'The API access has been revoked.', 'mojocommunity' )
        );

        return $data;
    }

    /**
     * Retrieve the output format
     */
    private function get_output_format() {
        global $wp_query;

        $format = isset( $wp_query->query_vars['format'] ) ? $wp_query->query_vars['format'] : 'json';

        return apply_filters( 'mc_api_output_format', $format );
    }

    /**
     * Returns a customized error message for the API query
     */
    public function send_error( $error="", $code = 400 ) {

        switch( $error ) {
            default :
                break;
            case 'invalid_query' :
                $error = __( 'The requested API method is invalid or missing parameters.', 'mojocommunity' );
                break;
            case 'missing_auth' :
                $error = __( 'Your API request could not be authenticated due to missing credentials.', 'mojocommunity' );
                break;
            case 'invalid_auth' :
                $error = __( 'Your API request could not be authenticated due to invalid credentials.', 'mojocommunity' );
                break;
            case 'exceeded_limit' :
                $error = __( 'You have exceeded your API usage limit for this key.', 'mojocommunity' );
                break;
            case 'invalid_permissions' :
                $error = __( 'Your API request could not be authenticated due to invalid permissions.', 'mojocommunity' );
                break;
        }

        $this->data = array(
            'error'             => $error,
            'error_code'        => $code
        );

        $this->output( $code );
    }

}

To compliment the above code, here’s a sample API call/class let’s say get_users query/endpoint.

/**
 * MC_API_Get_User class
 */
class MC_API_Get_User {

        /**
         * Constructor
         */
        public function __construct() {
            global $wpdb, $wp_query;

            $api = MC()->api;

            $user       = ( isset( $wp_query->query_vars['user'] ) ) ? $wp_query->query_vars['user'] : null;
            $fields     = ( isset( $wp_query->query_vars['fields'] ) ) ? $wp_query->query_vars['fields'] : null;

            if ( ! $user ) {
                $api->send_error( 'invalid_query' );
            }

            $data = new MC_User( $user, $fields );

            $api->send_data( $data );
        }

    }

Please note how I use $api->send_data() method which is defined in original API to send/output the data as json. I forgot to say that you need

  • A table with api keys, access limit, assigned user (the basic stuff)
  • A custom post type if you’re willing to log the request details (always good to know who’s sneaking in your api

The above code does multiple verifications which are

  • Ensures that the user provides true valid public and secret keys.

  • Validates that the API key access limit is not hit

  • Ensure that the API key has the permission (read/write) to do the
    specified task. For example, you can setup a readonly API key that
    can just call your public methods e.g. get user information and make
    another key for updating/editing that have read and write
    permissions.

Here’s the database table structure I use for storing API keys hopefully will save you some time doing your own:

CREATE TABLE {$wpdb->prefix}mc_api_keys (
    key_id bigint(20) NOT NULL auto_increment,
    user_id bigint(20) NOT NULL,
    access_limit bigint(20) NOT NULL DEFAULT 0,
    queries bigint(20) NOT NULL DEFAULT 0,
    description longtext NULL,
    permissions varchar(10) NOT NULL,
    consumer_key char(64) NOT NULL,
    consumer_secret char(64) NOT NULL,
    truncated_key char(7) NOT NULL,
    last_access datetime NULL DEFAULT null,
    PRIMARY KEY  (key_id),
    KEY consumer_key (consumer_key),
    KEY consumer_secret (consumer_secret)
) $collate;

And here is the logger class. Responsible for inserting each request as a log in the database.

/**
 * MC_API_Log class
 */
class MC_API_Log {

    protected   $api = null;
    public      $log_id = 0;

    /**
     * Constructor
     */
    public function __construct( $log_id = 0 ) {

        $this->log_id = $log_id;

        if ( $this->log_id > 0 ) {
            $this->init_meta();
        } else {

            $this->api = MC()->api;
            $this->send();
        }
    }

    /**
     * Init all post meta
     */
    public function init_meta() {
        $meta               = get_post_meta( $this->log_id );

        $this->consumer_key = ( isset( $meta['consumer_key'][0] ) ) ? $meta['consumer_key'][0] : null;
        $this->key_id       = ( isset( $meta['key_id'][0] ) )       ? absint( $meta['key_id'][0] ) : null;
        $this->user_id      = ( isset( $meta['user_id'][0] ) )      ? absint( $meta['user_id'][0] ) : 0;
        $this->endpoint     = ( isset( $meta['endpoint'][0] ) )     ? $meta['endpoint'][0] : null;
        $this->user_ip      = ( isset( $meta['user_ip'][0] ) )      ? $meta['user_ip'][0] : '127.0.0.1';
        $this->time         = get_the_time( 'j M Y g:ia', $this->log_id );
    }

    /**
     * Get truncated key
     */
    public function get_key_html() {
        return ( $this->consumer_key ) ? '<a href="#">' . '&hellip;' . esc_html( substr( $this->consumer_key, -7 ) ) . '</a>' : __( 'Invalid key', 'mojocommunity' );
    }

    /**
     * Get user html
     */
    public function get_user_html() {
        $userdata = get_userdata( $this->user_id );
        if ( false === $userdata ) {
            return '';
        }
        return '<a href="' . get_edit_user_link( $this->user_id ) . '">' . $userdata->user_login . '</a>';
    }

    /**
     * Get endpoint html
     */
    public function get_endpoint_html() {
        return ( $this->endpoint ) ? '<code>' . $this->endpoint . '</code>' : null;
    }

    /**
     * Get ID
     */
    public function get_id() {
        return $this->log_id;
    }

    /**
     * Get IP
     */
    public function get_ip() {
        return $this->user_ip;
    }

    /**
     * Get date/time of a request
     */
    public function get_time() {
        return $this->time;
    }

    /**
     * Insert the log
     */
    public function insert_log() {
        global $wp_query;

        if ( ! empty( $this->error ) )
            return;

        $query = array();
        foreach( $this->api->api_vars as $var ) {
            if ( isset( $wp_query->query_vars[ $var ] ) && ! empty( $wp_query->query_vars[ $var ] ) && ! in_array( $var, array( 'consumer_key', 'consumer_secret' ) ) ) {
                $query[ $var ] = $wp_query->query_vars[ $var ];
            }
        }

        if ( http_build_query( $query ) ) {
            $query = '?' . http_build_query( $query );
        } else {
            $query = null;
        }

        $this->post_args = apply_filters( 'mc_new_api_request_args', array(
            'post_author'       => absint( $this->api->key_data->user_id ),
            'post_status'       => 'publish',
            'post_type'         => 'log',
            'comment_status'    => 'closed'
        ) );

        $this->post_meta = apply_filters( 'mc_new_api_request_meta', array(
            'key_id'        => absint( $this->api->key_data->key_id ),
            'user_id'       => absint( $this->api->key_data->user_id ),
            'consumer_key'  => $this->api->key_data->consumer_key,
            'user_ip'       => mc_get_ip(),
            'endpoint'      => $this->api->endpoint . $query
        ) );

        do_action( 'mc_before_insert_api_log', $this->post_meta );

        $this->log_id = mc_insert_post( $this->post_args, $this->post_meta );

        do_action( 'mc_after_insert_api_log', $this->log_id, $this->post_meta );
    }

    /**
     * Send the log
     */
    public function send() {

        $this->insert_log();

    }

}

Here’s the mc_insert_post function I use to insert logs (or other post types)

/**
 * A wrapper function for inserting posts in database
 */
function mc_insert_post( $postarr = array(), $meta_input = array() ) {

    do_action( 'mc_before_insert_post', $postarr, $meta_input );

    if ( ! empty( $meta_input ) && is_array( $meta_input ) )
        $postarr = array_merge( $postarr, array( 'meta_input' => $meta_input ) );

    $post_id = wp_insert_post( $postarr );

    do_action( 'mc_after_insert_post', $post_id, $postarr, $meta_input );

    return $post_id;
}