Get Plugin

Customer Downloads

Customer Downloads

Easy PDF Invoices for WooCommerce gives customers three places to download their PDFs: the My Account orders screen, the order received (Thank You) page, and an optional download link in the order email body. This page covers each surface, how authentication works, and how to customise the experience.

My Account > Orders

A Download invoice / Download receipt / Download credit note button appears in the actions column of the My Account orders list, next to the standard View action.

The button label adapts to the document type — customers see "Download receipt" on completed orders and "Download invoice" on processing orders. Cancelled and failed orders don't show the button (no document was generated).

The integration uses WooCommerce's woocommerce_my_account_my_orders_actions filter, so the button respects any theme or plugin that customises the actions column.

Order received (Thank You) page

After checkout, a Download invoice / Download receipt button appears below the order summary on the Thank You page.

This works for:

  • Logged-in customers — uses standard WordPress authentication.
  • Guest customers — uses the order key from the page URL for authentication.
The button hooks into woocommerce_thankyou at priority 20 so it sits below the rest of the order details rather than at the top.

Configure this from Settings > Invoices > Emails > Attachment method:

  • Attachment only (default) — no link in the body.
  • Download link only — link in the body, no PDF attachment.
  • Both — link and attachment.
  • None — neither.
When enabled, the link appears either before or after the order table (configurable). It uses inline HTML styling so it looks native in WooCommerce-themed emails. Plain-text emails get a plain-text URL.

Authentication

The download endpoint is ?epdi_download={order_id} plus an authentication token. The plugin checks three sources in order:

  • Capability check. If the requesting user has manage_woocommerce, they pass — admins can download any order's PDF.
  • Order ownership. If the requesting user is logged in and the order's customer_id matches their user ID, they pass.
  • Order key. For guests, the URL must include &key={order_key} matching the order's key. The match uses hash_equals() to prevent timing attacks.
If none of the three match, the request returns a 403.

The order key is the same one WooCommerce uses for guest order access (e.g., on the order received page and in transactional emails). It's URL-safe, time-stable, and tied to the order — re-issuing the order key invalidates all previous download links.

Rate limiting

The download endpoint rate-limits to 10 downloads per minute per identity:

  • Logged-in users — keyed on user ID.
  • Guests — keyed on a SHA-256 hash of the IP address. The raw IP is never stored.
Hit the limit and the endpoint returns a 429 with a Retry-After: 60 header. The button shows a "Try again in a moment" message and re-enables after 60 seconds.

Adjust the limits programmatically:

// Allow 30 downloads per 5 minutes instead
add_filter( 'epdi_download_rate_limit', function() {
    return 30;
} );

add_filter( 'epdi_download_rate_window', function() {
    return 300; // seconds
} );

Lazy generation

If a customer downloads an order that doesn't have a PDF yet (for example, an order placed before the plugin was installed), the plugin generates the PDF on the fly during the download request:

  • Customer clicks Download.
  • The plugin checks for an existing PDF.
  • If missing, it allocates a number, renders the PDF, and saves it.
  • Streams the file to the customer.
Subsequent downloads of the same order use the cached file directly.

This is why the Backfill tool is optional — even without backfill, customers will trigger generation themselves the first time they click Download.

Streaming and headers

Downloads stream with proper HTTP headers:

  • Content-Type: application/pdf
  • Content-Disposition: attachment; filename="invoice-{number}.pdf"
  • Content-Length: {bytes}
  • Cache-Control: no-cache, must-revalidate, max-age=0 (via nocache_headers())
  • X-Content-Type-Options: nosniff
The nosniff header prevents browsers from MIME-sniffing the response into something else. Combined with the explicit Content-Type, this is hardening against download-based XSS attacks.

Path traversal protection

Filenames in the URL are never trusted. The plugin reads the order's _epdi_pdf_path meta and resolves it through Storage::resolve_path(), which:

  • Constructs the full filesystem path from the upload directory.
  • Calls realpath() to resolve symlinks and .. segments.
  • Verifies the resolved path starts with the upload directory.
Any attempt to break out of the upload directory results in a 404. There's no API for the customer to specify a path or filename — only an order ID.

Customising the labels

The button labels are translatable via the easy-pdf-invoices-for-woocommerce text domain. To change the wording without translating:

add_filter( 'epdi_download_button_label', function( $label, $document_type, $order ) {
    if ( $document_type === 'receipt' ) {
        return __( 'Download your tax receipt', 'your-theme' );
    }
    return $label;
}, 10, 3 );

Customising the URL

If you'd rather route downloads through a custom URL structure (e.g., /my-account/invoices/{id} instead of ?epdi_download={id}), use the epdi_download_url filter:

add_filter( 'epdi_download_url', function( $url, $order ) {
    return home_url( "/my-account/invoices/{$order->get_id()}" );
}, 10, 2 );

You'll need to register a custom rewrite rule and a controller that proxies to the original endpoint. This is rarely needed but available for stores with strict URL conventions.

Customising the download button position

My Account orders

To move the button to the start of the actions column:

add_filter( 'woocommerce_my_account_my_orders_actions', function( $actions, $order ) {
    if ( isset( $actions['epdi_download'] ) ) {
        $download = array( 'epdi_download' => $actions['epdi_download'] );
        unset( $actions['epdi_download'] );
        return array_merge( $download, $actions );
    }
    return $actions;
}, 11, 2 );

Thank You page

To change the priority (default 20):

remove_action( 'woocommerce_thankyou', array( \EasyPDFInvoices\Frontend\ThankYouPage::class, 'render' ), 20 );
add_action( 'woocommerce_thankyou', array( \EasyPDFInvoices\Frontend\ThankYouPage::class, 'render' ), 5 );

Email body

The download link respects WooCommerce's email hook positions. Settings > Invoices > Emails > Download link position toggles between before (woocommerce_email_before_order_table) and after (woocommerce_email_after_order_table).

For finer control, hook your own callback to woocommerce_email_before_order_table and call \EasyPDFInvoices\Email\EmailLink::render( $order, $email ) from inside it.

Hiding the button for specific orders

Use the epdi_show_download_button filter:

// Hide the button for B2B orders that have separate invoicing
add_filter( 'epdi_show_download_button', function( $show, $order, $context ) {
    if ( $order->get_meta( '_b2b_invoiced_separately' ) === 'yes' ) {
        return false;
    }
    return $show;
}, 10, 3 );

The $context argument is one of my_account, thank_you, or email, so you can hide it on one surface but keep it on another.

Troubleshooting

"The button shows but clicking it does nothing"

Check the browser developer tools network tab. The download endpoint should return a 200 with application/pdf. If you see:

  • 403 — authentication failed. The user isn't logged in as the order owner, doesn't have manage_woocommerce, and the order key in the URL doesn't match. Re-check the link.
  • 404 — the order doesn't exist or has no PDF and lazy generation failed. Check the WooCommerce log for errors.
  • 429 — rate limited. Wait 60 seconds.
  • 500 — server error. Check the WooCommerce log.

"Guests can't download"

Verify the email template includes &key={order_key} in the download URL. Some email plugins or third-party templates strip URL parameters during sanitisation. Check the raw email source.

"The PDF downloads with a weird filename"

The filename is generated from the document type and the formatted display number, e.g., invoice-INV-2026-000042.pdf. To change the convention, use epdi_pdf_filename:

add_filter( 'epdi_pdf_filename', function( $filename, $order, $document_type ) {
    return sprintf( 'order-%d-%s.pdf', $order->get_id(), $document_type );
}, 10, 3 );