I'm trying to create a custom API endpoint in WordPress, and I need to redirect requests to a virtual page in the root of WordPress to an actual page that ships with my plug-in. So basically, all requests to the one page are actually routed to the other.

http://mysite.com/my-api.php => http://mysite.com/wp-content/plugins/my-plugin/my-api.php

The point of this is to make the url for the API endpoint as short as possible (similar to http://mysite.com/xmlrpc.php but to ship the actual API endpoint file with the plug-in rather than requiring the user to move files around in their installation and/or hack core.

My first stab was to add a custom rewrite rule. However, this had two problems.

  1. The endpoint always had a trailing slash. It became http://mysite.com/my-api.php/
  2. My rewrite rule was only ever partially applied. It wouldn't redirect to wp-content/plugins..., it would redirect to index.php&wp-content/plugins.... This lead to WordPress displaying either a page not found error or just defaulting to the homepage.

Ideas? Suggestions?

9 Answers 9


There are two types of rewrite rules in WordPress: internal rules (stored in the database and parsed by WP::parse_request()), and external rules (stored in .htaccess and parsed by Apache). You can choose either way, depending on how much of WordPress you need in your called file.

External Rules:

The external rule is the easiest to set up and to follow. It will execute my-api.php in your plugin directory, without loading anything from WordPress.

add_action( 'init', 'wpse9870_init_external' );
function wpse9870_init_external()
    global $wp_rewrite;
    $plugin_url = plugins_url( 'my-api.php', __FILE__ );
    $plugin_url = substr( $plugin_url, strlen( home_url() ) + 1 );
    // The pattern is prefixed with '^'
    // The substitution is prefixed with the "home root", at least a '/'
    // This is equivalent to appending it to `non_wp_rules`
    $wp_rewrite->add_external_rule( 'my-api.php$', $plugin_url );

Internal Rules:

The internal rule requires some more work: first we add a rewrite rule that adds a query vars, then we make this query var public, and then we need to check for the existence of this query var to pass the control to our plugin file. By the time we do this, the usual WordPress initialization will have happened (we break away right before the regular post query).

add_action( 'init', 'wpse9870_init_internal' );
function wpse9870_init_internal()
    add_rewrite_rule( 'my-api.php$', 'index.php?wpse9870_api=1', 'top' );

add_filter( 'query_vars', 'wpse9870_query_vars' );
function wpse9870_query_vars( $query_vars )
    $query_vars[] = 'wpse9870_api';
    return $query_vars;

add_action( 'parse_request', 'wpse9870_parse_request' );
function wpse9870_parse_request( &$wp )
    if ( array_key_exists( 'wpse9870_api', $wp->query_vars ) ) {
        include 'my-api.php';
  • 4
    I just want to add that its important to go to the Permalinks page and click "Save Changes" in the WP-Admin. I was playing with this for an hour before thinking that I needed to refresh the permalinks... Unless someone knows a function that can do that?
    – ethanpil
    Commented May 28, 2013 at 18:21
  • For the external rule: Because the path to my web root had a whitespace character, this caused apache to fall over. Whitespaces need to be escaped if they exist in the path to your wordpress installation.
    – Willster
    Commented Jun 4, 2013 at 9:41
  • 1
    Works, but I can't seem to access any passed query variables with get_query_vars() in my-api.php. I checked what variables are loaded. And the only var that is set is a a WP object called $wp. How do I access or tranform this into a WP_Query object so I can access the passed variables with get_query_vars()?
    – Jules
    Commented Aug 13, 2013 at 10:24
  • 1
    @Jules: When you include a file, it gets executed in the current scope. In this case, it is the wpse9870_parse_request function, which only has the $wp parameter. It's possible that the global $wp_query object hasn't been set at this time, so get_query_var() will not work. However, you are lucky: $wp is the class that contains the query_vars member that you need - I use it myself in the above code.
    – Jan Fabry
    Commented Aug 13, 2013 at 15:49
  • 1
    trying to create an external rewrite rules. added your fist piece of code but I am still getting 404. btw: flushed rewrite rules
    – Sisir
    Commented Nov 30, 2013 at 8:36

This worked for me. I never ever touch the rewrite API, but am always up to push myself in new directions. The following worked on my test server for 3.0 located in a sub folder of localhost. I don't for see any issue if WordPress is installed in web root.

Just drop this code in a plugin and upload the file named "taco-kittens.php" directly in the plugin folder. You will need write a hard flush for your permalinks. I think they say the best time to do this is on plugin activation.

function taco_kitten_rewrite() {
    $url = str_replace( trailingslashit( site_url() ), '', plugins_url( '/taco-kittens.php', __FILE__ ) );
    add_rewrite_rule( 'taco-kittens\\.php$', $url, 'top' );
add_action( 'wp_loaded', 'taco_kitten_rewrite' );

Best wishes, -Mike

  • 1
    I got an access denied error while trying this code. I suspect my server or WP didn't like the absolute URL. This, on the other hand, worked fine: add_rewrite_rule( 'taco-kittens', 'wp-content/plugins/taco-kittens.php', 'top' );
    – Jules
    Commented Aug 13, 2013 at 9:46
  • Can you please let me know, what I should put in taco-kittens.php, I do not have knowledge of .htaccess or url rewrite . Commented May 4, 2016 at 10:47

Any reason not to do something like this instead?


Then just hook your plugin into 'init' and check for that get variable. If it exists, do what your plugin needs to do and die()

  • 5
    That would work, but I'm trying to provide a very clear distinction between the query variables and the actual endpoint. There might be other query args in the future, and I don't want users to mix things up.
    – EAMann
    Commented Feb 20, 2011 at 4:53
  • What if you kept the rewrite, but rewrote it to the GET var? You might also look at how the rewrite for robots.txt works. It might help you figure out how to avoid the redirect to my-api.php/ Commented Feb 20, 2011 at 5:34
  • It is perfect solution if you don't care robots like API calls. Commented Mar 11, 2017 at 17:43

I may not be understanding you questions fully, but would a simple shortcode solve your issue?


  1. Have the client create a page i.e. http://mysite.com/my-api
  2. Have the client add a shortcode in that page i.e. [my-api-shortcode]

The new page acts as an API end point and your shortcode sends requests to your plugin code in http://mysite.com/wp-content/plugins/my-plugin/my-api.php

( of course this means that my-api.php would have the shortcode defined )

You can probably automate steps 1 and 2 via the plugin.


I haven't dealt with rewrite that much, yet, so this is probably a little rough, but it seems to work:

function api_rewrite($wp_rewrite) {
    $wp_rewrite->non_wp_rules['my-api\.php'] = 'wp-content/plugins/my-plugin/my-api.php';
    file_put_contents(ABSPATH.'.htaccess', $wp_rewrite->mod_rewrite_rules() );

It works if you hook this into 'generate_rewrite_rules', but there must be a better way, as you don't want to rewrite .htaccess on each page load.
Seems like i can't stop editing my own posts...it should probably rather go into you activate callback and reference global $wp_rewrite instead. And then remove the entry from non_wp_rules and output to .htaccess again in you deactivate callback.

And finally, the writing to .htaccess should be a bit more sophisticated, you want to only replace the wordpress section in there.


I had a similar requirement and wanted to create several end-points based on unique slugs that pointed to content generated by the plugin.

Have a look at the source for my plugin: https://wordpress.org/extend/plugins/picasa-album-uploader/

The technique I used starts by adding a filter for the_posts to examine the incoming request. If the plugin should handle it, a dummy post is generated and an action is added for template_redirect.

When the template_redirect action is called, it must result in outputting the entire contents of the page to be displayed and exit or it should return with no output generated. See the code in wp_include/template-loader.php and you'll see why.


I'm using another approach which consists in forcing the home page to load a custom title, content and page template.

The solution is very neat since it can be implemented when a user follows a friendly link such as http://example.com/?plugin_page=myfakepage

It is very easy to implement and should allow for unlimited pages.

Code and instructions here: Generate a custom/fake/virtual Wordpress page on the fly


it's a production readey example , first create virtual page class:

class VirtualPage

    private $query;
    private $title;
    private $content;
    private $template;
    private $wp_post;

    function __construct($query = '/index2', $template = 'page', $title = 'Untitled')
        $this->query = filter_var($query, FILTER_SANITIZE_URL);

    function getQuery()
        return $this->query;

    function getTemplate()
        return $this->template;

    function getTitle()
        return $this->title;

    function setTitle($title)
        $this->title = filter_var($title, FILTER_SANITIZE_STRING);

        return $this;

    function setContent($content)
        $this->content = $content;

        return $this;

    function setTemplate($template)
        $this->template = $template;

        return $this;

    public function updateWpQuery()

        global $wp, $wp_query;

        // Update the main query
        $wp_query->current_post = $this->wp_post->ID;
        $wp_query->found_posts = 1;
        $wp_query->is_page = true;//important part
        $wp_query->is_singular = true;//important part
        $wp_query->is_single = false;
        $wp_query->is_attachment = false;
        $wp_query->is_archive = false;
        $wp_query->is_category = false;
        $wp_query->is_tag = false;
        $wp_query->is_tax = false;
        $wp_query->is_author = false;
        $wp_query->is_date = false;
        $wp_query->is_year = false;
        $wp_query->is_month = false;
        $wp_query->is_day = false;
        $wp_query->is_time = false;
        $wp_query->is_search = false;
        $wp_query->is_feed = false;
        $wp_query->is_comment_feed = false;
        $wp_query->is_trackback = false;
        $wp_query->is_home = false;
        $wp_query->is_embed = false;
        $wp_query->is_404 = false;
        $wp_query->is_paged = false;
        $wp_query->is_admin = false;
        $wp_query->is_preview = false;
        $wp_query->is_robots = false;
        $wp_query->is_posts_page = false;
        $wp_query->is_post_type_archive = false;
        $wp_query->max_num_pages = 1;
        $wp_query->post = $this->wp_post;
        $wp_query->posts = array($this->wp_post);
        $wp_query->post_count = 1;
        $wp_query->queried_object = $this->wp_post;
        $wp_query->queried_object_id = $this->wp_post->ID;
        $wp_query->query_vars['error'] = '';

        $GLOBALS['wp_query'] = $wp_query;

        $wp->query = array();


    public function createPage()
        if (is_null($this->wp_post)) {
            $post = new stdClass();
            $post->ID = -99;
            $post->ancestors = array(); // 3.6
            $post->comment_status = 'closed';
            $post->comment_count = 0;
            $post->filter = 'raw';
            $post->guid = home_url($this->query);
            $post->is_virtual = true;
            $post->menu_order = 0;
            $post->pinged = '';
            $post->ping_status = 'closed';
            $post->post_title = $this->title;
            $post->post_name = sanitize_title($this->template); // append random number to avoid clash
            $post->post_content = $this->content ?: '';
            $post->post_excerpt = '';
            $post->post_parent = 0;
            $post->post_type = 'page';
            $post->post_status = 'publish';
            $post->post_date = current_time('mysql');
            $post->post_date_gmt = current_time('mysql', 1);
            $post->modified = $post->post_date;
            $post->modified_gmt = $post->post_date_gmt;
            $post->post_password = '';
            $post->post_content_filtered = '';
            $post->post_author = is_user_logged_in() ? get_current_user_id() : 0;
            $post->post_content = '';
            $post->post_mime_type = '';
            $post->to_ping = '';

            $this->wp_post = new WP_Post($post);

            wp_cache_add(-99, $this->wp_post, 'posts');


        return $this->wp_post;

In the next step hook template_redirect action and handle your virtual page like below

    add_action( 'template_redirect', function () {

                    switch ( get_query_var( 'name' ,'') ) {

                        case 'contact':
                            // http://yoursite/contact  ==> loads page-contact.php
                            $page = new VirtualPage( "/contact", 'contact',__('Contact Me') );

                        case 'archive':
                            // http://yoursite/archive  ==> loads page-archive.php
                            $page = new VirtualPage( "/archive", 'archive' ,__('Archives'));

                        case 'blog':
                            // http://yoursite/blog  ==> loads page-blog.php
                            $page = new VirtualPage( "/blog", 'blog' ,__('Blog'));


            } );

I'm using an approach similar to Xavi Esteve's above, which stopped working due to a WordPress upgrade as far as I could tell in the second half of 2013.

It's documented in great detail here: https://stackoverflow.com/questions/17960649/wordpress-plugin-generating-virtual-pages-and-using-theme-template

The key part of my approach is using the existing template so the resulting page looks like it's part of the site; I wanted it to be as compatible as possible with all themes, hopefully across WordPress releases. Time will tell if I was right!

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.