ScreenshotNeo

BlogHow-to

PDFShift API Integration in WordPress: Save Posts as PDFs

Build a server-side WordPress export that converts a post to PDF with PDFShift, then streams or stores the result safely.

By the ScreenshotNeo team4 October 202613 min read

To save a WordPress post as a PDF with PDFShift, retrieve the post on the server, prepare HTML for the document, and send that HTML to https://api.pdfshift.io/v3/convert/pdf in a JSON POST request. Put your PDFShift API key in the X-API-Key header, then either stream the returned PDF bytes to the visitor or save them in WordPress-controlled storage. Keep the key out of browser JavaScript.

This guide uses WordPress’s HTTP API and a user-requested download flow. The PHP is an implementation example based on the documented APIs; it is not a tested plugin. Review the permission checks, HTML template, download headers, and storage behavior for your site before deployment.

1. Choose the input and output mode

Choose whether the PDF should represent the public page or the post content assembled by WordPress. PDFShift accepts a URL or raw HTML as source. A public URL is convenient when the rendered page is the document you want. Raw HTML is useful for private posts or when you want to control the document template without asking PDFShift to fetch the page. PDFShift recommends raw HTML as a way to avoid a separate fetch of the document and its resources.

Choice Use it when Tradeoff
Public URL The public rendered page is the desired PDF. PDFShift must fetch the URL. A private or login-protected page may not be available to it.
Raw HTML You need private post content or a deliberate PDF layout. You must build a complete HTML document and make its styles and assets usable during conversion.
PDF binary response You want to stream bytes or store the file yourself. Your request handler must correctly deliver or persist binary data.
Temporary URL response A downstream process expects JSON containing a file URL. Copy the file to durable storage promptly; the documented temporary URL remains available for two days.

For a direct download or a durable WordPress media copy, omit filename and webhook so the API returns the PDF body as binary. PDFShift documents JSON output with a temporary PDF URL when filename or webhook is supplied.

2. Prepare WordPress and the API key

  1. Install this as a small site plugin or add the code to an existing plugin. Avoid putting API credentials in a theme that may be distributed.
  2. Store the PDFShift key in server configuration, such as a constant in wp-config.php or an environment-backed configuration loaded by your deployment. Do not expose it in HTML, a shortcode response, or front-end JavaScript.
  3. Decide who may export each post. The example checks the post type and the current user’s capability for that post. Adjust the capability policy if exports should be available to a different audience.
  4. Choose a trigger: a logged-in user action is easiest to reason about for a download. Automatic generation on every content change is also possible, but adds decisions about stale files, repeated API calls, and retention.
// In wp-config.php, configure this on the server. Keep the real key out of source control.
define( 'PDFSHIFT_API_KEY', 'replace-with-your-server-side-key' );

WordPress provides wp_remote_post() for outbound POST requests. It returns a response array or a WP_Error. If you accept a destination URL from a user, use wp_safe_remote_post() and validate the destination. This example uses a fixed API endpoint.

3. Add a protected post download endpoint

The following plugin-style example adds an authenticated admin-post action. It verifies a nonce, loads a post by ID, checks read permission, builds a minimal HTML document using the stored post content, requests a PDF, and sends the resulting bytes as a download. The post content is rendered through WordPress’s content filters and escaped as HTML output; site-specific shortcodes, external stylesheets, images, and fonts may need additional handling in your template.

<?php
/**
 * Plugin Name: PDFShift Post Export
 */

add_action( 'admin_post_pdfshift_export_post', 'my_pdfshift_export_post' );

function my_pdfshift_export_post() {
    if ( ! is_user_logged_in() ) {
        auth_redirect();
    }

    $post_id = isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : 0;
    check_admin_referer( 'pdfshift_export_' . $post_id );

    $post = get_post( $post_id );
    if ( ! $post || 'publish' !== $post->post_status && ! current_user_can( 'read_post', $post_id ) ) {
        wp_die( esc_html__( 'This post cannot be exported.', 'my-site' ), '', array( 'response' => 403 ) );
    }
    if ( ! current_user_can( 'read_post', $post_id ) ) {
        wp_die( esc_html__( 'You cannot access this post.', 'my-site' ), '', array( 'response' => 403 ) );
    }

    if ( ! defined( 'PDFSHIFT_API_KEY' ) || '' === PDFSHIFT_API_KEY ) {
        wp_die( esc_html__( 'PDF export is not configured.', 'my-site' ), '', array( 'response' => 500 ) );
    }

    // Render the stored post body. For a custom PDF design, replace this template.
    $content = apply_filters( 'the_content', $post->post_content );
    $html = '<!doctype html><html><head><meta charset="utf-8">'
        . '<meta name="viewport" content="width=device-width, initial-scale=1">'
        . '<title>' . esc_html( get_the_title( $post ) ) . '</title>'
        . '<style>body{font:16px/1.6 sans-serif;max-width:760px;margin:40px auto;padding:0 24px;color:#222}img{max-width:100%;height:auto}h1,h2,h3{line-height:1.25}a{color:#1457a6}</style>'
        . '</head><body><h1>' . esc_html( get_the_title( $post ) ) . '</h1>'
        . $content . '</body></html>';

    $response = wp_remote_post(
        'https://api.pdfshift.io/v3/convert/pdf',
        array(
            'headers' => array(
                'Content-Type' => 'application/json',
                'X-API-Key'    => PDFSHIFT_API_KEY,
            ),
            'body'        => wp_json_encode( array( 'source' => $html ) ),
            'timeout'     => 60,
            'redirection' => 0,
        )
    );

    if ( is_wp_error( $response ) ) {
        error_log( 'PDFShift transport error: ' . $response->get_error_message() );
        wp_die( esc_html__( 'The PDF service could not be reached. Please try again.', 'my-site' ), '', array( 'response' => 502 ) );
    }

    $status = wp_remote_retrieve_response_code( $response );
    $pdf    = wp_remote_retrieve_body( $response );
    if ( 200 !== $status || '' === $pdf ) {
        // Do not send an API error body with a .pdf filename.
        error_log( 'PDFShift returned HTTP status ' . (int) $status );
        wp_die( esc_html__( 'PDF generation failed. Please try again later.', 'my-site' ), '', array( 'response' => 502 ) );
    }

    $filename = sanitize_file_name( get_post_field( 'post_name', $post_id ) . '.pdf' );
    nocache_headers();
    header( 'Content-Type: application/pdf' );
    header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
    header( 'Content-Length: ' . strlen( $pdf ) );
    echo $pdf; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- PDF binary response.
    exit;
}

function my_pdfshift_export_link( $post_id ) {
    $url = add_query_arg(
        array(
            'action'  => 'pdfshift_export_post',
            'post_id' => absint( $post_id ),
        ),
        admin_url( 'admin-post.php' )
    );
    return wp_nonce_url( $url, 'pdfshift_export_' . absint( $post_id ) );
}
?>

Render a link where an authorized user can see it, for example in a template or a shortcode callback:

<?php if ( current_user_can( 'read_post', get_the_ID() ) ) : ?>
  <a href="<?php echo esc_url( my_pdfshift_export_link( get_the_ID() ) ); ?>">Download this post as PDF</a>
<?php endif; ?>

The example requires a logged-in user because it uses the authenticated admin_post_ hook. For public exports, use a separate design with a narrowly scoped signed token or another abuse-resistant access policy; do not simply remove authorization and leave an expensive public endpoint open.

Important adjustments before production

  • Permission policy: the example’s checks protect private content. Confirm that your WordPress version and custom post types support the desired read_post capability behavior. A simpler strict policy is to require current_user_can( 'edit_post', $post_id ) for editorial exports.
  • Content source: the_content filters can run shortcode or plugin code. If that is not appropriate for the export, build the document from the post fields you explicitly allow.
  • HTML template: add only styles and assets needed in the PDF. Relative asset paths may not resolve as expected in a separately rendered document; use suitable absolute asset URLs or inline styles. Private media URLs may need another approach.
  • Output headers: ensure no theme/plugin output has started before sending headers. If your site has output buffering or compression behavior, account for it in the download handler.
  • Error reporting: the example logs only a status or transport message. In production, keep logs private and avoid logging API keys, full private HTML, or sensitive API response bodies.

4. Save the PDF instead of downloading it

For a durable copy, keep the binary response and write it to WordPress-managed storage. Validate that the response is successful before passing bytes to file-handling code. A practical implementation can use WordPress’s upload functions, then create an attachment record; this example shows the core file creation step, but does not create a media-library attachment.

// After the successful status and non-empty $pdf checks in the handler:
$upload = wp_upload_bits( $filename, null, $pdf );
if ( ! empty( $upload['error'] ) ) {
    error_log( 'PDF upload failed: ' . $upload['error'] );
    wp_die( esc_html__( 'The PDF was generated but could not be saved.', 'my-site' ), '', array( 'response' => 500 ) );
}

// $upload['file'] is the server path and $upload['url'] is the public URL.
// Apply your site's access and retention policy before exposing or linking the file.

If you use PDFShift’s filename response mode instead, parse the JSON response, retrieve its temporary file URL from the server, and copy the PDF into storage you control. The documented URL is temporary and remains available for two days, so it should not be treated as permanent storage.

5. Use the public WordPress REST API when appropriate

A local plugin can use get_post() directly and avoid a second WordPress request. A separate service can retrieve posts through the WordPress REST API: the collection route is /wp/v2/posts and an individual post route is /wp/v2/posts/<id>. Its response includes the post’s content field. Access to non-public posts depends on authentication and permissions. Use authenticated access over HTTPS and do not assume a private post is anonymously fetchable.

If PDFShift should convert the fully rendered public page, send that page URL as source. If the desired post is private or you need a controlled layout, fetch or read the content in your trusted server process, compose the HTML, and submit raw HTML instead.

6. cURL, Python, and Node.js request examples

These examples show the same server-side request outside WordPress. They send raw HTML and omit filename, so the successful response is PDF binary. Keep PDFSHIFT_API_KEY in a server-side environment variable. Do not run these with a secret embedded in client-side code.

cURL

export PDFSHIFT_API_KEY='your-api-key'
curl --fail-with-body --request POST \
  --url https://api.pdfshift.io/v3/convert/pdf \
  --header "X-API-Key: $PDFSHIFT_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{"source":"<!doctype html><html><body><h1>Example post</h1><p>PDF content</p></body></html>"}' \
  --output post.pdf

Python

import os
import requests

api_key = os.environ["PDFSHIFT_API_KEY"]
html = """<!doctype html><html><body><h1>Example post</h1>
<p>PDF content</p></body></html>"""

response = requests.post(
    "https://api.pdfshift.io/v3/convert/pdf",
    headers={"X-API-Key": api_key, "Content-Type": "application/json"},
    json={"source": html},
    timeout=60,
)
response.raise_for_status()
if not response.content:
    raise RuntimeError("PDFShift returned an empty response")
with open("post.pdf", "wb") as output:
    output.write(response.content)

Node.js

import { writeFile } from "node:fs/promises";

const apiKey = process.env.PDFSHIFT_API_KEY;
if (!apiKey) throw new Error("Set PDFSHIFT_API_KEY in the server environment");

const html = "<!doctype html><html><body><h1>Example post</h1><p>PDF content</p></body></html>";
const response = await fetch("https://api.pdfshift.io/v3/convert/pdf", {
  method: "POST",
  headers: {
    "X-API-Key": apiKey,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ source: html }),
  signal: AbortSignal.timeout(60_000),
});
if (!response.ok) {
  throw new Error(`PDFShift returned HTTP ${response.status}: ${await response.text()}`);
}
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.length === 0) throw new Error("PDFShift returned an empty response");
await writeFile("post.pdf", bytes);

7. Or skip the browser setup

For a screenshot of a web page instead of a paginated PDF, ScreenshotNeo provides a website screenshot API and MCP server. It can capture a public WordPress post as PNG, JPEG, WebP, or PDF without you running a browser. It is not a replacement for a PDFShift workflow that needs custom post HTML or document-specific PDF behavior.

One server-side call, using the [ScreenshotNeo API docs](https://screenshotneo.com/docs/):

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/your-post/ -o shot.webp
  • Cookie banners are accepted and removed before capture; newsletter popups and chat widgets are removed too. Each cleanup step can be turned off.
  • Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed; the response headers identify the page verdict and billing status.
  • An MCP server lets AI agents using Claude, Cursor, or another MCP client call screenshot tools.
  • The free plan includes 1,000 screenshots per month with no card. Paid plans start at $5 for 3,000 screenshots.

Start with 1,000 free screenshots a month, with no card.

8. Troubleshooting

Symptom Likely cause Fix
WordPress returns a generic export error The HTTP request returned WP_Error, or PDFShift returned a non-200 status. Log the transport error or status privately. Confirm outbound HTTPS access, the API key, request JSON, and endpoint. Do not stream an error response as a PDF.
The downloaded “PDF” is JSON or an error message The request failed, or filename/webhook selected JSON response behavior. Check the HTTP status before sending bytes. If using URL mode, parse JSON and download the temporary file server-side.
Private post content is missing PDFShift was given a URL it cannot access, or the WordPress caller lacks post permission. Check the WordPress capability gate. For private content, build raw HTML in the trusted WordPress process and send it as source.
Images or styling are missing The composed HTML references relative, inaccessible, or private assets. Use absolute asset URLs accessible to the conversion service, inline small styles where appropriate, and ensure asset access does not require a browser session.
The export times out The conversion or resource loading exceeded the configured request timeout. Reduce unnecessary external resources, use a simpler template, and choose a timeout that fits your web server’s request limits. For longer jobs, consider queueing work and delivering the file when ready.
Download headers fail or output is corrupted PHP output began before the PDF headers or unrelated warnings entered the response body. Keep the handler free of prior output, inspect PHP logs, and send a clean binary response only after successful conversion.
A temporary PDF URL no longer works The temporary hosted file passed its documented two-day availability period. Download and copy it to your own storage soon after conversion if it must persist.
Unauthorized users can export private posts The endpoint’s access policy is too broad or the nonce is missing/misused. Verify the current user’s capability for the specific post on every request. A nonce mitigates request forgery; it does not replace authorization.

9. Performance, reliability, and cost considerations

  • Reduce conversion work: raw HTML avoids a separate fetch of the source page, according to PDFShift’s guidance. Keep the document template and external assets focused on what belongs in the PDF.
  • Set a realistic timeout: the WordPress example uses 60 seconds, but the hosting environment may impose a shorter execution or proxy limit. Align the request and infrastructure limits.
  • Avoid duplicate generation: if the same post is exported repeatedly, you can retain a generated file and define when content edits invalidate it. That is an application design choice; the cited sources do not prescribe a cache policy.
  • Handle failure without corrupting output: check transport errors and HTTP status before setting PDF headers. For workflows that cannot hold a browser request open, queue the work and notify or expose the file after completion.
  • Choose retention deliberately: stream for one-time downloads, save to controlled storage for durable access, or copy PDFShift’s temporary URL result before it expires. Restrict media links if the PDF contains private content.
  • Budget for API use: the supplied PDFShift documentation does not establish pricing, quotas, or a cost estimate. Check current account terms and measure how often your site generates exports before choosing synchronous or automatic generation.

10. FAQ

Can PDFShift convert a post that is not public?

Yes, if your server reads the authorized WordPress post and sends its prepared HTML as the source. A URL conversion requires the target page to be reachable by the conversion service.

Does the example create a Media Library attachment?

No. It streams a download. The storage snippet writes bytes with WordPress’s upload helper but does not register an attachment record; add the appropriate attachment handling if the Media Library entry is required.

Should I make a PDF on every post update?

Only if readers need a pre-generated copy. On-demand generation avoids creating files nobody requests; automatic generation can make delivery immediate but requires an invalidation and retention policy.

Can I use ScreenshotNeo for the same job?

Use ScreenshotNeo when a screenshot or rendered-page PDF of a publicly reachable URL is enough. Use the PDFShift raw-HTML workflow when the PDF should be assembled from private post content or a custom document template.