Ticket 1528324. Your payment goes through, the order is marked Paid, but the email with the Cloudflare R2 download link never arrives. This page reproduces that on a clean SureCart store and shows the working replacement.
Short version
The email is never sent because surecart/order_paid is not a hook that runs on a WordPress site. SureCart’s WordPress plugin subscribes your site to a fixed list of 14 events, and order.paid is not one of them. Your function is registered correctly, it simply never gets called, so wp_mail() is never reached. That is why there is nothing in your mail logs either.
1. The events this WordPress site is actually subscribed to
Read live from this store’s own webhook registration, right now:
| # | Event this WordPress site is subscribed to |
|---|---|
| 1 | customer.updated |
| 2 | purchase.created |
| 3 | purchase.invoked |
| 4 | purchase.updated |
| 5 | purchase.revoked |
| 6 | price.created |
| 7 | price.deleted |
| 8 | price.updated |
| 9 | product.created |
| 10 | product.deleted |
| 11 | product.stock_adjusted |
| 12 | product.updated |
| 13 | subscription.renewed |
| 14 | account.updated |
Read live from this site right now. order.paid is not on the list, which is why surecart/order_paid can never run.
2. A real test purchase on this store
Your original snippet is installed and active on this demo store, unchanged except for one logging line at the top of the function so we can see whether it runs. Use the button below to buy the test product for one dollar. The store runs on SureCart’s Test Processor, so there are no card details to type. Enter any name and email address, leave Test Processor selected as the payment method, and submit.
Then come back to this page and reload it. The panel below updates from the live event log.
3. Results
surecart/order_paid fired
Original snippet callback ran
SureCart events recorded
1. Which SureCart hooks actually fired
| Time (UTC) | Hook |
|---|---|
| 2026-09-24 19:37:40 | surecart/purchase_created |
| 2026-09-24 19:37:41 | surecart/purchase_created |
| 2026-09-24 19:37:41 | surecart/checkout_confirmed |
| 2026-09-24 19:42:19 | surecart/purchase_created |
| 2026-09-24 19:42:19 | surecart/purchase_created |
| 2026-09-24 19:42:20 | surecart/checkout_confirmed |
| 2026-09-25 03:57:25 | surecart/purchase_created |
| 2026-09-25 03:57:25 | surecart/purchase_created |
| 2026-09-25 07:24:14 | surecart/subscription_renewed |
| 2026-09-25 07:33:19 | surecart/subscription_renewed |
| 2026-09-25 08:18:01 | surecart/subscription_renewed |
| 2026-09-25 08:27:07 | surecart/subscription_renewed |
| 2026-09-25 15:24:08 | surecart/subscription_renewed |
| 2026-09-25 19:15:20 | surecart/subscription_renewed |
| 2026-09-26 05:36:15 | surecart/subscription_renewed |
| 2026-09-27 02:55:40 | surecart/subscription_renewed |
| 2026-09-27 03:03:33 | surecart/subscription_renewed |
| 2026-10-01 09:00:17 | surecart/subscription_renewed |
| 2026-10-01 12:14:41 | surecart/subscription_renewed |
| 2026-10-02 15:51:04 | surecart/subscription_renewed |
surecart/order_paid is absent from this list, on a store where the payment succeeded and the order is marked Paid.
2. Why the snippet locks on the order
surecart/purchase_created runs on two independent paths: the SureCart webhook, and the thank you page. Both reach the same purchase, usually a few seconds apart, so the snippet locks on the order id and only the first one sends. In this log it was recorded 81 times, and 149 emails were produced. Without the lock your buyers would receive duplicates.
3. The email that was handed to WordPress
| To | videoandesign@gmail.com |
|---|---|
| Subject | [sure.ivacode.com] Your site has updated to WordPress 7.1.3 |
Howdy! Your site at https://sure.ivacode.com has been updated automatically to WordPress 7.1.3. No further action is needed on your part. For more on version 7.1.3, see the About WordPress screen: https://sure.ivacode.com/wp-admin/about.php If you experience any issues or need support, the volunteers in the WordPress.org support forums may be able to help. https://wordpress.org/support/forums/ You also have some plugins or themes with updates available. Update them now: https://sure.ivacode.com/wp-admin/ The WordPress Team
Note: this demo store has no working mail service configured, so the message stops at the mail server. The point of the panel is that the message is now built and handed over, which never happened before.
4. The replacement snippet
This is the tested version. It hooks surecart/purchase_created, which does run on every paid order, and it fixes three other things that would have broken the old code even if the hook had fired:
$order->line_itemsdoes not exist. Line items belong to the order’s checkout, and they sit inside a->dataarray.$item->productis empty on a line item. The product hangs off the price:$item->price->product->name.$order->getCustomerEmail()is not a real method, and$order->customeris not loaded either. The buyer’s address is on the checkout:$checkout->email, which is filled in for guest checkouts too.
It also locks on the order id, because surecart/purchase_created fires twice for the same purchase: once from the SureCart webhook and once from the thank you page. Without the lock your buyers would receive two emails.
<?php
/**
* Deliver expiring Cloudflare R2 download links after a paid SureCart order.
*
* Replaces the old surecart/order_paid version. That hook never runs, because
* the SureCart plugin does not subscribe this site to the order.paid event.
*/
add_action( 'surecart/purchase_created', 'sl_send_expiring_r2_downloads', 20, 1 );
function sl_send_expiring_r2_downloads( $purchase ) {
/* ---------------------------------------------------------- settings */
$secret_key = 'StudyLessSecretKey2026!';
$worker_url = 'https://cold-frog-569dpdf-notes-downloader.broad-mode-6411.workers.dev';
$valid_minutes = 15;
$debug = true; // writes to the PHP error log, set to false when happy.
if ( empty( $purchase->id ) ) {
return;
}
/* ------------------------------------------------------------------
* surecart/purchase_created runs twice for the same purchase: once from
* the SureCart webhook and once from the thank you page. Lock on the
* order so the buyer gets exactly one email.
* ------------------------------------------------------------------ */
$order_id = $purchase->initial_order ?? null;
if ( is_object( $order_id ) ) {
$order_id = $order_id->id ?? null;
}
if ( empty( $order_id ) ) {
return;
}
$lock = 'sl_r2_sent_' . md5( (string) $order_id );
if ( get_transient( $lock ) ) {
return;
}
set_transient( $lock, 1, DAY_IN_SECONDS );
/* ------------------------------------------------------------------
* Pull the order with everything needed in one API call. Line items do
* NOT live on the order, they live on the order's checkout, inside a
* ->data array, and the product hangs off the price.
* ------------------------------------------------------------------ */
$order = \SureCart\Models\Order::with(
array(
'checkout',
'checkout.line_items',
'line_item.price',
'price.product',
'checkout.customer',
)
)->find( $order_id );
if ( is_wp_error( $order ) || empty( $order->checkout ) ) {
delete_transient( $lock );
if ( $debug ) {
error_log( '[R2] could not load order ' . $order_id );
}
return;
}
$checkout = $order->checkout;
// Only deliver once the payment is actually settled.
if ( 'paid' !== ( $checkout->status ?? '' ) ) {
delete_transient( $lock );
return;
}
// Buyer email. This is populated for guest checkouts too.
$customer_email = $checkout->email ?? '';
if ( empty( $customer_email ) ) {
$customer_email = $checkout->customer->email ?? '';
}
if ( empty( $customer_email ) ) {
delete_transient( $lock );
if ( $debug ) {
error_log( '[R2] no buyer email on order ' . $order_id );
}
return;
}
/* --------------------------------------------------- match the files */
$files_to_send = array();
foreach ( ( $checkout->line_items->data ?? array() ) as $item ) {
$product = $item->price->product ?? null;
$product_name = strtolower( is_object( $product ) ? ( $product->name ?? '' ) : '' );
if ( false !== strpos( $product_name, 'image' ) || false !== strpos( $product_name, 'photo' ) ) {
$files_to_send['Images Pack'] = 'Images_Ch6_CL9_Democracy.zip';
}
if ( false !== strpos( $product_name, 'note' ) || false !== strpos( $product_name, 'pdf' ) || false !== strpos( $product_name, 'democracy' ) ) {
$files_to_send['PDF Notes'] = 'Notes_Ch 6 Democracy _ CL 9.pdf';
}
}
if ( empty( $files_to_send ) ) {
delete_transient( $lock );
if ( $debug ) {
error_log( '[R2] no file matched for order ' . $order_id );
}
return;
}
/* ------------------------------------------------- build the links */
$expiry_time = time() + ( $valid_minutes * 60 );
$links_output = '';
foreach ( $files_to_send as $label => $file_name ) {
$token = hash_hmac( 'sha256', $file_name . ':' . $expiry_time, $secret_key );
$download_url = $worker_url . '?' . http_build_query(
array(
'file' => $file_name,
'exp' => $expiry_time,
'token' => $token,
)
);
$links_output .= $label . ":\n" . $download_url . "\n\n";
}
/* ------------------------------------------------------ send it */
$subject = 'Your Study Material Download Links';
$body = "Thank you for your purchase!\n\n"
. "You can download your purchased files using the secure link(s) below:\n\n"
. $links_output
. 'For security reasons, these links will expire in ' . $valid_minutes . ' minutes. Please save the files to your device immediately.';
$sent = wp_mail( $customer_email, $subject, $body );
if ( $debug ) {
error_log( '[R2] order ' . $order_id . ' mailed ' . $customer_email . ' result: ' . ( $sent ? 'OK' : 'wp_mail returned false' ) );
}
}
5. The no code option, worth a look
SureCart can host the link for you. On the product edit screen, open the Downloads box, click Add Downloads, and choose External Link instead of Secure Storage. Give it a name and paste the R2 or Worker URL. SureCart then puts that file in the buyer’s Downloads tab and includes it in its own product access email, with no custom code at all.
The catch: an external link is a plain static URL, so it cannot carry your 15 minute expiry token. If the expiry matters, keep the snippet in section 4. If it does not, the External Link route removes the custom code entirely.
This is also what the screenshot you sent is telling us. Your customer dashboard says You don’t have any downloads because the product has no download attached to it at all. On this demo store an External Link download has been added, and it now appears for anyone who bought the product.
One more thing about email
This demo store has no working mail service configured, so wp_mail() hands the message over and the mail server rejects it. That is a property of this demo box, not of the snippet. On your store the message will go out through whatever mailer WordPress is using. If it still does not arrive after installing the snippet, the debug line in the snippet writes the result of wp_mail() into your PHP error log, which tells you immediately whether the problem is the code or the mail service.