Skip to content

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.

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.


HookPurposeDefault Behavior
read_offline_pre_exportShort-circuit export to background queuenull (continue synchronous)
read_offline_can_exportGate export permissionEditor+ (edit_others_posts)
read_offline_export_lock_keyCustomize dedupe lock keyread_offline:lock:{format}:{post_id}
read_offline_rest_responseShape success response payloadStandard format
read_offline_cache_keyCache key for export artifactsread_offline:{format}:{post_id}:{args_hash}
read_offline_cache_ttlCache lifetime in seconds12 hours
HookWhen FiredParameters
read_offline_export_requestedExport endpoint called$post_id, $format, $args, $request
read_offline_export_completedExport succeeded (200 response)$post_id, $format, $args, $export_data
read_offline_export_failedExport failed (error/4xx/5xx)$post_id, $format, $args, $response

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 job
add_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 );

Restrict exports to administrators only or check custom capabilities.

// Admin-only exports
add_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 type
add_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 );

Track export usage, log analytics, or integrate with external systems.

// Log all export requests
add_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 analytics
add_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 failures
add_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 );

Add custom fields or modify response structure.

// Add download tracking URL
add_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 response
add_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 );

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 entirely
add_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 );

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 posts
add_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 );

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 premium
function 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 users
add_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 request
add_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 features
add_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 enforcement
add_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 );

GET /wp-json/read-offline/v1/export
  • id (required): Post ID to export
  • format (optional): Export format (pdf, epub, md). Default: pdf
  • Additional format-specific args (margins, toc, etc.)
{
"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"
}
}
  • 403 Forbidden: Capability check failed
  • 409 Conflict: Duplicate export already in progress
  • 429 Too Many Requests: Rate limit or quota exceeded (custom)
  • 500 Internal Server Error: Export generation failed

  1. Return Early: In read_offline_pre_export, return null to continue or a WP_REST_Response/WP_Error to short-circuit
  2. Preserve Default Behavior: Most filters pass a default value—modify only when needed
  3. Check User Context: Many use cases vary by user role/capability
  4. Handle Errors: Wrap integrations in try/catch and return meaningful errors
  5. Test Lifecycle: Verify all three lifecycle actions fire correctly
  6. Respect Lock Keys: If disabling deduplication, ensure your system handles concurrent requests
  7. Cache Wisely: Adjust TTL based on post status and update frequency

Enable WordPress debug logging to see hook execution:

// In wp-config.php
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
// Log hook calls
add_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 );


For questions or issues with queue-aware hooks: