/wp-json/. Exclude the thank-you, donor account and donation pages, exclude js.stripe.com and paypal.com/sdk/js from every JavaScript optimization, and read your webhook delivery logs to prove the rest.This guide is for whoever maintains the site, often a freelance developer with a dozen client installs on a dozen cache setups. Every vendor setting below was checked against that vendor's documentation or plugin source on 1 October 2026. If a label does not match your screen, follow the link.
Does page caching break donation forms?
Rarely the charge, often everything around it. A page cache serves one stored copy of a page to every visitor, so anything per-visitor or short-lived goes stale. That shows up in four ways.
1. Expired nonces and signed tokens
Many plugins put a WordPress nonce in the form and check it on submit. Nonces expire. The WordPress handbook says a nonce's "actual lifetime is thus variable between 12 and 24 hours". A two-day-old cached page hands every visitor a dead nonce. If the plugin sends it to the REST API in an X-WP-Nonce header, WordPress core answers 403 "Cookie check failed", which is why cached-nonce failures look like a firewall problem.
2. Stale totals and stale settings
A cached thermometer shows the total from when the page was cached. Worse, most donation plugins print the Stripe publishable key and PayPal client ID into the page. Switch from test to live keys without purging, and visitors keep receiving the old keys.
3. JavaScript optimization breaking the payment scripts
Minify, combine, defer and "delay until user interaction" all rewrite how scripts load. Both processors say plainly that their scripts must load from their own servers. Stripe's documentation says Stripe.js "should always be loaded directly from https://js.stripe.com, rather than included in a bundle or hosted yourself". PayPal's says: "Load the JavaScript SDK directly from https://www.paypal.com/sdk/js. Don't include it in a bundle or host it yourself." Delay-JS features add a second failure: the card field stays empty until the visitor scrolls or taps.
4. Security layers blocking webhooks
Webhooks are POST requests from Stripe's and PayPal's servers. No cache in this guide stores POST requests by default. The problem is the bot protection or firewall that ships alongside the cache, covered below.
Do cache plugins cache the WordPress REST API or webhooks?
Not by default, with one notable exception: LiteSpeed Cache caches GET requests to the REST API out of the box. None of them cache POST requests, which is what webhooks are.
| Layer | POST requests (webhooks) | REST API GET requests | Respects DONOTCACHEPAGE |
|---|---|---|---|
| WP Rocket | Not cached | Not cached. "REST API requests are not cached by default" | Yes |
| LiteSpeed Cache | Not cached unless the server admin enables POST caching | Cached. Cache REST API defaults to ON | Yes (plugin source, v7.9.1) |
| W3 Total Cache | Not cached (plugin source, v2.10.6) | Not cached. The REST API setting defaults to "Don't cache"; caching it is a Pro option | Yes |
| WP Super Cache | Not cached (plugin source, v3.1.4) | Not cached. The plugin logs "REST API detected. Caching disabled." | Yes |
| Cloudflare (default) | Not cached | Not cached: "The Cloudflare CDN does not cache HTML or JSON by default." | No, it reads HTTP headers only |
| Cloudflare APO | Not cached (GET and HEAD only) | Not cached as HTML | No. "APO ignores Origin Cache Control for caching on the Edge" |
The APO row matters. DONOTCACHEPAGE is the constant plugins define to tell page caches "skip this page". APO reads neither it nor the origin's Cache-Control header, so on an APO site only a Cloudflare rule keeps a page out of the edge cache.
Which URLs should I exclude from caching on a donation site?
Exclude these, in this order of importance:
- The thank-you or confirmation page, if your plugin uses one. A cached confirmation can show one donor another donor's result.
- The donor account or portal page, for example
/my-donations/. It is personal, and its login form usually carries a nonce. - The donation form page itself, unless you know your plugin's form is cache-safe (ours is; see below). If the form carries a nonce, this exclusion is mandatory.
- The plugin's REST namespace, for example
/wp-json/donor-merchant/, on LiteSpeed and on any host or CDN rule that caches JSON. - Any URL carrying a one-time token in its query string.
Purge everything, CDN included, after changing payment keys, test mode or campaign goals.
Where do I add the exclusions in each cache plugin?
| Plugin | Exclude a URL from the page cache | Exclude Stripe and PayPal from JavaScript optimization |
|---|---|---|
| WP Rocket | Advanced Rules tab, Never Cache URL(s) box. One path per line; (.*) works as a wildcard, for example /donate/(.*). Docs | File Optimization tab. Add js.stripe.com and paypal.com/sdk/js to the Excluded JavaScript Files box under Delay JavaScript execution, to the exclusion box under Load JavaScript deferred (domains and keywords are accepted), and to the Combine exclusion list if Combine is on |
| LiteSpeed Cache | LiteSpeed Cache, Cache, Excludes, Do Not Cache URIs. Partial paths match; ^ anchors the start and $ requires an exact end. Docs | Page Optimization, JS Settings: JS Deferred/Delayed Excludes and JS Excludes. Partial strings, no wildcards. Docs |
| W3 Total Cache | Performance, Page Cache, Advanced section, Never cache the following pages. Docs | Performance, Minify, Advanced, Never minify the following JS files. External scripts are only pulled into minify if listed under "Include external files/libraries", which is empty by default. The optional Delay Scripts feature only delays what you put in its Delay list, so keep Stripe and PayPal out of it |
| WP Super Cache | Settings, WP Super Cache, Advanced, Rejected URL Strings, then Save Strings | Nothing to do. WP Super Cache has no JavaScript optimization features |
Two vendor details worth knowing before you start:
- WP Rocket: "By excluding a page from cache, you are also excluding it from all other optimizations." Putting the donation page in Never Cache URL(s) therefore also stops Delay JavaScript on that page. WP Rocket also ships automatic dynamic exclusion lists but does not publish whether Stripe or PayPal are on them, so add both yourself. A duplicate entry is harmless.
- LiteSpeed Cache: its predefined JS Excludes list already contains
js.stripe.comandpaypal.com/sdk/js, which protects them from minify and combine. The separate predefined list for deferred and delayed scripts does not contain either one in version 7.9.1. If you set Load JS Deferred to Delayed, add both by hand.
How do I exclude donation pages on Cloudflare and managed hosts?
On plain Cloudflare without "Cache Everything" or APO, HTML is not cached at the edge, so you need a rule only if HTML caching is on. Create it under Cache Rules, Create rule, match URI Path starting with your donation, thank-you and portal paths, and under Cache eligibility select Bypass cache. Cloudflare's own APO documentation recommends Cache Rules over Page Rules for controlling APO.
Cloudflare's Rocket Loader defers every script on the page. Its per-script opt-out, a data-cfasync="false" attribute, usually cannot be added to a plugin's scripts, so use a Configuration Rule to turn Rocket Loader off for the donation page, as the Rocket Loader docs suggest when one page is affected.
| Host | Where cache exclusions live |
|---|---|
| WP Engine | User Portal, Sites, your environment, Cache, + Add new exclusion (URL path or query arg). WP Engine notes the option "is not available for all accounts"; otherwise ask support. Docs |
| Kinsta | Ask support. Kinsta's docs say "the Kinsta Support team can configure rules to bypass the cache for specific pages on your site". Docs |
| SiteGround | Speed Optimizer plugin, Caching, the pencil icon next to Exclude URLs from Caching. * works as a wildcard. SiteGround's cache also honors a cache-control: no-cache response header. Docs |
Why are Stripe or PayPal webhooks blocked by Cloudflare?
Because Cloudflare's security features, not its cache, see a server-to-server POST with no browser and treat it as a bot. Three settings cause most of it:
- Bot Fight Mode (the free-plan bot setting). Cloudflare warns that it "may challenge API or mobile app traffic", and that "You cannot bypass or skip Bot Fight Mode using WAF custom rules or Page Rules." If webhooks fail with Bot Fight Mode on, your options are to turn it off, or move to Super Bot Fight Mode (Pro plan and above), which can be skipped for your webhook paths.
- Under Attack mode. Its interstitial needs JavaScript to pass, which a webhook sender cannot run. Cloudflare's Under Attack docs call it "one of the last resorts". If you must use it, a Configuration Rule can turn "I'm Under Attack" off for paths starting with
/wp-json/. - WAF managed rules, custom rules and rate limits. Use a custom rule with the Skip action scoped to your two webhook paths. Stripe publishes its webhook IP addresses if you want the rule tighter.
Off Cloudflare, a host firewall or a security plugin option like "disable REST API for logged-out users" blocks every webhook silently. Fix it with an allow rule for the webhook paths, never by turning off signature verification.
How do I check that webhooks are getting through?
Read the processor's delivery log. It records what your server actually answered.
- Stripe: in Workbench, open Webhooks, select your endpoint, then the Event deliveries tab. Each event shows Delivered, Pending or Failed, with its HTTP status code. Stripe retries for up to three days in live mode.
- PayPal: open the Live or Sandbox Webhooks Events dashboard, log in and choose your app. Received events show a green check mark and pending ones a yellow exclamation point, for the last 30 days, with a Resend button. PayPal retries a non-2xx response up to 25 times over three days.
| Response in the log | What it usually means on a cached, protected site |
|---|---|
| 200 | Delivered. Caching and security are not your problem. |
| 301 or 302 | A redirect: http to https, www to non-www, or a trailing slash rule. Stripe says: "We consider redirect responses to webhook requests as failures." Register the final URL. |
| 400 | Usually your plugin rejecting the signature, meaning the request reached WordPress. Check the signing secret, not the cache. |
| 403 | Something refused the request before or inside WordPress: Bot Fight Mode, a WAF rule, a host firewall or a security plugin. Look in that layer's event log at the exact timestamp. |
| 503 | The server, or something in front of it, said "unavailable": WordPress maintenance mode during an update returns 503, as do many host resource limits. Retries usually recover it if it is brief. |
A quick manual test: send an empty POST to the webhook URL with curl -i -X POST https://yourdomain.org/wp-json/donor-merchant/v1/webhook/stripe. A healthy Donor Merchant site answers with a 400 and the JSON message "Invalid signature." (or "Webhook secret not configured." if no secret is saved). An HTML page, a challenge page or a 403 means a layer in front of WordPress answered instead. A 400 from your laptop does not prove Stripe's servers get the same treatment, but a 403 proves something is blocking.
If events were lost for longer than the retry window, our guide to Stripe donations not showing in WordPress covers recording them by hand, and the Giving Tuesday technical checklist puts this check alongside the other launch-day ones.
How does Donor Merchant behave on a cached page?
As of version 2.5.4:
- The donation form carries no WordPress nonce, and the form's script sends no
X-WP-Nonceheader, so a cached form cannot hit the 12 to 24 hour nonce expiry. - The anti-spam timing check is cache-safe by design. The form carries a render timestamp (
dm_t) and its signature (dm_ts), signed with a stored plugin secret rather than a WordPress salt a security plugin might rotate. The server enforces only a minimum fill time (3 seconds by default), never a maximum, so an old cached timestamp simply looks older and passes. - The donation form page is deliberately left cacheable. It does not define
DONOTCACHEPAGE. The trade-off: a[donor_merchant_progress]thermometer or the form's built-in campaign bar shows the total as of the last purge, and the Stripe publishable key and PayPal client ID are printed into the page. Purge after changing keys or switching test mode. - The donor portal opts out of caching. It defines
DONOTCACHEPAGE, which WP Rocket, LiteSpeed Cache, W3 Total Cache and WP Super Cache all honor, and sends no-cache headers when the page has not started sending output yet. Its magic-link request form does use a nonce. On Cloudflare APO, which ignores both signals, add a Cache Rule bypass for your portal path (/my-donations/by default), or a cached copy will stop sending sign-in links once its nonce expires. - Everything else is a REST route under
/wp-json/donor-merchant/v1/./donate,/verify,/paypal/capture,/paypal/activate,/webhook/stripeand/webhook/paypalare all POST, so no default cache stores them. Receipt and statement links are GET requests that send no-cache headers; on LiteSpeed, where REST caching is on by default, add/wp-json/donor-merchant/to Do Not Cache URIs anyway.
So the Donor Merchant minimum is: exclude the portal page, exclude the REST namespace on LiteSpeed, keep Stripe and PayPal out of every JavaScript optimizer, and let the two webhook paths through your firewall. Excluding the donation page is optional unless you want a live thermometer. Setup is in the webhooks docs.
We have not tested every cache, host and CDN combination. Always reread your delivery logs after a change.
js.stripe.com and paypal.com/sdk/js to every JavaScript exclusion box your cache plugin has, purge, and load the donation page in a private window to confirm the card field appears without scrolling. If you manage many client sites and want a second pair of eyes, that is what our support plans are for. The plugin stays free either way.