Queue-Aware REST Hooks
Read Offline 2.2.7 introduces a comprehensive hook system for the REST API export endpoint (/wp-json/read-offline/v1/export). These hooks enable background processing, custom capability checks, lifecycle monitoring, and response customization.
Overview
Section titled “Overview”The queue-aware hooks layer sits between WordPress REST API dispatch and Read Offline’s internal export controller. It provides:
- Background Processing: Short-circuit synchronous exports to enqueue jobs
- Capability Control: Override default permission checks (Editor+)
- Lifecycle Signals: Monitor export requests, completions, and failures
- Deduplication: Prevent concurrent exports of the same post/format
- Response Shaping: Customize API response payloads
- Cache Helpers: Filters for cache key and TTL customization
All hooks are 100% backwards compatible and optional—Read Offline works normally without any integration.
Available Hooks
Section titled “Available Hooks”Filters
Section titled “Filters”| Hook | Purpose | Default Behavior |
|---|---|---|
read_offline_pre_export | Short-circuit export to background queue | null (continue synchronous) |
read_offline_can_export | Gate export permission | Editor+ (edit_others_posts) |
read_offline_export_lock_key | Customize dedupe lock key | read_offline:lock:{format}:{post_id} |
read_offline_rest_response | Shape success response payload | Standard format |
read_offline_cache_key | Cache key for export artifacts | read_offline:{format}:{post_id}:{args_hash} |
read_offline_cache_ttl | Cache lifetime in seconds | 12 hours |
Actions
Section titled “Actions”| Hook | When Fired | Parameters |
|---|---|---|
read_offline_export_requested | Export endpoint called | $post_id, $format, $args, $request |
read_offline_export_completed | Export succeeded (200 response) | $post_id, $format, $args, $export_data |
read_offline_export_failed | Export failed (error/4xx/5xx) | $post_id, $format, $args, $response |
Examples
Section titled “Examples”1. Short-Circuit to Background Queue
Section titled “1. Short-Circuit to Background Queue”Replace synchronous export with a queued job returning HTTP 202 (Accepted).
add_filter( 'read_offline_pre_export', function( $result, $post_id, $format, $args, $request ) { // Enqueue to your background job system (Action Scheduler, WP Cron, etc.) as_enqueue_async_action( 'my_read_offline_export_job', array( 'post_id' => $post_id, 'format' => $format, 'args' => $args, ), 'read-offline-exports' );
// Return 202 Accepted with job reference return new WP_REST_Response( array( 'status' => 'queued', 'message' => 'Export queued for background processing', 'post_id' => $post_id, 'format' => $format, 'job_id' => as_get_scheduled_actions( array( 'hook' => 'my_read_offline_export_job', 'status' => 'pending', 'args' => array( 'post_id' => $post_id, 'format' => $format ), ), 'ids' )[0] ?? null, ), 202 );}, 10, 5 );
// Handle the background jobadd_action( 'my_read_offline_export_job', function( $job_args ) { // Process export in background $exporter = Read_Offline_Export::get_instance(); $result = $exporter->export_single( $job_args['post_id'], $job_args['format'], $job_args['args'] );
// Store result, send notification, etc. update_post_meta( $job_args['post_id'], '_read_offline_export_url', $result['url'] );
// Email user when ready wp_mail( get_the_author_meta( 'user_email', get_post_field( 'post_author', $job_args['post_id'] ) ), 'Export Ready', sprintf( 'Your %s export is ready: %s', $job_args['format'], $result['url'] ) );}, 10, 1 );2. Custom Capability Check
Section titled “2. Custom Capability Check”Restrict exports to administrators only or check custom capabilities.
// Admin-only exportsadd_filter( 'read_offline_can_export', function( $allowed, $post_id, $format, $args, $request ) { return current_user_can( 'manage_options' );}, 10, 5 );
// Author-only exports (users can only export their own posts)add_filter( 'read_offline_can_export', function( $allowed, $post_id, $format, $args, $request ) { $post = get_post( $post_id ); return current_user_can( 'edit_post', $post_id ) && ( get_current_user_id() === (int) $post->post_author );}, 10, 5 );
// Custom capability per post typeadd_filter( 'read_offline_can_export', function( $allowed, $post_id, $format, $args, $request ) { $post_type = get_post_type( $post_id );
return match( $post_type ) { 'page' => current_user_can( 'export_pages' ), 'book' => current_user_can( 'export_books' ), 'article' => current_user_can( 'export_articles' ), default => current_user_can( 'edit_others_posts' ), };}, 10, 5 );3. Lifecycle Monitoring
Section titled “3. Lifecycle Monitoring”Track export usage, log analytics, or integrate with external systems.
// Log all export requestsadd_action( 'read_offline_export_requested', function( $post_id, $format, $args, $request ) { error_log( sprintf( '[Read Offline] Export requested: Post #%d, Format: %s, User: %d, IP: %s', $post_id, $format, get_current_user_id(), sanitize_text_field( $_SERVER['REMOTE_ADDR'] ?? 'unknown' ) ) );}, 10, 4 );
// Track successful exports in analyticsadd_action( 'read_offline_export_completed', function( $post_id, $format, $args, $export_data ) { // Send to analytics service if ( function_exists( 'amplitude_track' ) ) { amplitude_track( 'export_completed', array( 'post_id' => $post_id, 'format' => $format, 'filesize' => $export_data['size'] ?? 0, 'duration' => timer_stop( 0, 3 ), ) ); }
// Update post meta for reporting $count = (int) get_post_meta( $post_id, '_export_count', true ); update_post_meta( $post_id, '_export_count', $count + 1 ); update_post_meta( $post_id, '_last_export_format', $format ); update_post_meta( $post_id, '_last_export_date', current_time( 'mysql' ) );}, 10, 4 );
// Alert on export failuresadd_action( 'read_offline_export_failed', function( $post_id, $format, $args, $response ) { $error_message = is_wp_error( $response ) ? $response->get_error_message() : 'Unknown error';
// Send to error tracking service if ( function_exists( 'sentry_capture_message' ) ) { sentry_capture_message( "Export failed: Post #$post_id, Format: $format", array( 'level' => 'error', 'extra' => array( 'post_id' => $post_id, 'format' => $format, 'error' => $error_message, ), ) ); }
// Email admin on failures wp_mail( get_option( 'admin_email' ), 'Read Offline Export Failed', sprintf( "Export failed for post #%d (format: %s)\nError: %s", $post_id, $format, $error_message ) );}, 10, 4 );4. Response Customization
Section titled “4. Response Customization”Add custom fields or modify response structure.
// Add download tracking URLadd_filter( 'read_offline_rest_response', function( $payload, $post_id, $format, $args, $request ) { if ( isset( $payload['export']['url'] ) ) { // Replace direct URL with tracking URL $payload['export']['download_url'] = add_query_arg( array( 'track' => 'true', 'post_id' => $post_id, 'format' => $format, ), $payload['export']['url'] );
// Add expiration timestamp (24 hours) $payload['export']['expires_at'] = gmdate( 'c', time() + DAY_IN_SECONDS ); }
return $payload;}, 10, 5 );
// Add post metadata to responseadd_filter( 'read_offline_rest_response', function( $payload, $post_id, $format, $args, $request ) { $post = get_post( $post_id );
$payload['post'] = array( 'id' => $post_id, 'title' => get_the_title( $post_id ), 'author' => get_the_author_meta( 'display_name', $post->post_author ), 'date' => get_the_date( 'c', $post_id ), 'permalink' => get_permalink( $post_id ), );
return $payload;}, 10, 5 );5. Custom Deduplication Strategy
Section titled “5. Custom Deduplication Strategy”Modify how concurrent requests are prevented.
// User-specific lock (allow concurrent exports from different users)add_filter( 'read_offline_export_lock_key', function( $key, $post_id, $format, $args ) { $user_id = get_current_user_id(); return "read_offline:lock:$format:$post_id:user_$user_id";}, 10, 4 );
// Disable deduplication entirelyadd_filter( 'read_offline_export_lock_key', '__return_false' );
// Arguments-aware lock (different args = different exports)add_filter( 'read_offline_export_lock_key', function( $key, $post_id, $format, $args ) { $args_hash = md5( wp_json_encode( $args ) ); return "read_offline:lock:$format:$post_id:$args_hash";}, 10, 4 );6. Cache Key Customization
Section titled “6. Cache Key Customization”Control how export artifacts are cached.
// Include user role in cache key (different cache per role)add_filter( 'read_offline_cache_key', function( $key, $post_id, $format, $args ) { $user = wp_get_current_user(); $role = ! empty( $user->roles ) ? $user->roles[0] : 'guest';
return sprintf( 'read_offline:%s:%d:%s:%s', $format, $post_id, $role, md5( wp_json_encode( $args ) ) );}, 10, 4 );
// Shorter TTL for draft postsadd_filter( 'read_offline_cache_ttl', function( $ttl, $post_id, $format, $args ) { $status = get_post_status( $post_id );
return match( $status ) { 'draft' => 1 * HOUR_IN_SECONDS, 'pending' => 2 * HOUR_IN_SECONDS, 'publish' => 24 * HOUR_IN_SECONDS, default => 12 * HOUR_IN_SECONDS, };}, 10, 4 );Complete Example: Premium Export System
Section titled “Complete Example: Premium Export System”Combine multiple hooks for a comprehensive premium export feature.
/** * Premium Export System * - Free users: queued exports (background) * - Premium users: instant exports * - Track usage quota * - Custom response with metadata */
// Check if user is premiumfunction is_premium_user( $user_id = null ) { $user_id = $user_id ?: get_current_user_id(); return get_user_meta( $user_id, 'premium_member', true );}
// Background processing for free usersadd_filter( 'read_offline_pre_export', function( $result, $post_id, $format, $args, $request ) { // Premium users get instant exports if ( is_premium_user() ) { return null; // Continue with synchronous export }
// Free users: queue and return 202 $job_id = as_enqueue_async_action( 'premium_export_job', array( 'post_id' => $post_id, 'format' => $format, 'args' => $args, 'user_id' => get_current_user_id(), ), 'premium-exports' );
return new WP_REST_Response( array( 'status' => 'queued', 'message' => 'Export queued. You will be notified when ready.', 'post_id' => $post_id, 'format' => $format, 'job_id' => $job_id, 'upgrade_url' => home_url( '/premium' ), ), 202 );}, 10, 5 );
// Track quotas on requestadd_action( 'read_offline_export_requested', function( $post_id, $format, $args, $request ) { $user_id = get_current_user_id();
// Increment usage counter $count = (int) get_user_meta( $user_id, 'export_count_this_month', true ); update_user_meta( $user_id, 'export_count_this_month', $count + 1 );
// Log to analytics do_action( 'premium_analytics_track', 'export_requested', array( 'user_id' => $user_id, 'post_id' => $post_id, 'format' => $format, 'plan' => is_premium_user( $user_id ) ? 'premium' : 'free', ) );}, 10, 4 );
// Enhance response with premium featuresadd_filter( 'read_offline_rest_response', function( $payload, $post_id, $format, $args, $request ) { $user_id = get_current_user_id();
// Add usage stats $payload['usage'] = array( 'exports_this_month' => (int) get_user_meta( $user_id, 'export_count_this_month', true ), 'plan' => is_premium_user( $user_id ) ? 'premium' : 'free', 'quota_limit' => is_premium_user( $user_id ) ? 'unlimited' : 10, );
// Premium users get extra metadata if ( is_premium_user( $user_id ) ) { $payload['premium'] = array( 'processing_time' => timer_stop( 0, 3 ), 'direct_download' => true, 'priority' => 'instant', ); }
return $payload;}, 10, 5 );
// Capability check with quota enforcementadd_filter( 'read_offline_can_export', function( $allowed, $post_id, $format, $args, $request ) { if ( ! $allowed ) { return false; // Respect base permission check }
$user_id = get_current_user_id();
// Premium users: unlimited if ( is_premium_user( $user_id ) ) { return true; }
// Free users: check quota $count = (int) get_user_meta( $user_id, 'export_count_this_month', true ); $limit = 10;
if ( $count >= $limit ) { return new WP_Error( 'export_quota_exceeded', sprintf( 'You have reached your monthly limit of %d exports. Please upgrade to premium.', $limit ), array( 'status' => 429 ) ); }
return true;}, 10, 5 );REST Endpoint Reference
Section titled “REST Endpoint Reference”Endpoint
Section titled “Endpoint”GET /wp-json/read-offline/v1/exportParameters
Section titled “Parameters”id(required): Post ID to exportformat(optional): Export format (pdf,epub,md). Default:pdf- Additional format-specific args (margins, toc, etc.)
Standard Response (200 OK)
Section titled “Standard Response (200 OK)”{ "status": "ok", "format": "pdf", "post_id": 123, "export": { "file": "my-post.pdf", "url": "https://example.com/wp-content/uploads/read-offline/my-post.pdf", "path": "/var/www/html/wp-content/uploads/read-offline/my-post.pdf", "size": 245678, "filename": "my-post.pdf" }}Error Responses
Section titled “Error Responses”403 Forbidden: Capability check failed409 Conflict: Duplicate export already in progress429 Too Many Requests: Rate limit or quota exceeded (custom)500 Internal Server Error: Export generation failed
Best Practices
Section titled “Best Practices”- Return Early: In
read_offline_pre_export, returnnullto continue or aWP_REST_Response/WP_Errorto short-circuit - Preserve Default Behavior: Most filters pass a default value—modify only when needed
- Check User Context: Many use cases vary by user role/capability
- Handle Errors: Wrap integrations in try/catch and return meaningful errors
- Test Lifecycle: Verify all three lifecycle actions fire correctly
- Respect Lock Keys: If disabling deduplication, ensure your system handles concurrent requests
- Cache Wisely: Adjust TTL based on post status and update frequency
Debugging
Section titled “Debugging”Enable WordPress debug logging to see hook execution:
// In wp-config.phpdefine( 'WP_DEBUG', true );define( 'WP_DEBUG_LOG', true );
// Log hook callsadd_action( 'read_offline_export_requested', function( $post_id, $format ) { error_log( "Export requested: Post #$post_id, Format: $format" );}, 10, 2 );
add_action( 'read_offline_export_completed', function( $post_id, $format ) { error_log( "Export completed: Post #$post_id, Format: $format" );}, 10, 2 );
add_action( 'read_offline_export_failed', function( $post_id, $format, $args, $response ) { error_log( "Export failed: Post #$post_id, Format: $format, Error: " . ( is_wp_error( $response ) ? $response->get_error_message() : 'Unknown' ) );}, 10, 4 );Further Reading
Section titled “Further Reading”Support
Section titled “Support”For questions or issues with queue-aware hooks:
📦 Source: soderlind/read-offline · Edit on GitHub