Take a breath. This is the single most common operational failure in WordPress donation plugins, it happens to every plugin including ours, and it is almost always recoverable in under an hour. Nothing below requires you to be a developer. This guide works whichever donation plugin you run.
Stripe shows the donation but WordPress does not. What is wrong?
A card donation on a WordPress site is really two separate events:
- The charge. The donor's browser talks directly to Stripe. Stripe takes the money. This part almost never fails silently, and it has already succeeded if you can see it in your Stripe dashboard.
- The notification. Stripe then sends a small message back to a specific URL on your website saying "payment abc123 succeeded." Your plugin listens at that URL and writes the donation row, sends the receipt, updates the campaign thermometer.
That second message is the webhook. When people say a donation is "stuck in pending" or "missing," step 1 worked and step 2 did not. A donor reporting this problem on wordpress.org described it precisely: "The Stripe payment is successfully completed. The PaymentIntent status is succeeded. Stripe sends the webhook successfully. GiveWP never completes the donation. Every webhook attempt fails and Stripe keeps retrying." (wordpress.org support thread). That is not a rare edge case. That is the standard shape of this bug.
The good news buried in that report: Stripe retries failed webhook deliveries on a schedule and logs every single attempt along with the HTTP response your server gave back. So the evidence you need already exists, for free, in your Stripe dashboard.
How do I diagnose it? Cheapest check first
Do these in order. Most sites are fixed by step 3.
1. Confirm the charge really succeeded
In Stripe, go to Payments and open the donation. Confirm the PaymentIntent status reads succeeded, not "requires action," "processing" or "incomplete." Write down the charge ID (it starts with ch_ or pi_), the amount, and the exact date and time. You will need that timestamp later, and it is the proof you will quote to the donor.
2. Read the webhook delivery log
Stripe dashboard, Developers, then Webhooks, then click your endpoint. You get a list of every event sent and the HTTP status your site returned. Read the code:
| What the log shows | What it means | Where to look next |
|---|---|---|
| 200 OK | Your site accepted the event. The problem is inside the plugin, not the connection. | Plugin logs, PHP error log, plugin conflict |
| 400 Bad Request | Signature check failed. Almost always a wrong or mismatched signing secret. | Step 3 below |
| 401 or 403 Forbidden | Something is blocking the request before WordPress sees it. | Security plugin, host firewall, WAF |
| 404 Not Found | The endpoint URL is wrong, or the REST API is disabled. | Endpoint URL, REST API blocking |
| 500 Internal Server Error | A fatal PHP error while handling the event. Stripe retries, then gives up. | Host error log, recent plugin or theme update |
| No deliveries at all | No endpoint registered for this mode, or events not selected. | Create the endpoint, subscribe to the events |
3. Check the signing secret matches the mode you are in
This is the most common cause by a wide margin. Stripe keeps test mode and live mode completely separate, with separate keys, separate endpoints and separate signing secrets (the string beginning whsec_). A test-mode signing secret pasted into a live site produces exactly this symptom: real money moves, your site rejects every notification with a 400. Open your plugin's Stripe settings, confirm the live secret key and the live signing secret came from the live-mode dashboard, and that the mode toggle in the plugin is not still set to test.
4. Check for duplicate endpoints
If you have ever migrated hosts, renamed the site, moved from HTTP to HTTPS or switched plugins, you may have two or three endpoints registered for the same mode. Old ones fail, retry and pollute the log. Delete every endpoint that does not point at your current site URL, and keep exactly one per mode.
What on the WordPress side blocks a webhook?
Modern donation plugins receive webhooks as REST API routes, meaning URLs that live under /wp-json/. That has one practical consequence: anything that blocks or hides the REST API silently kills your donations, and it does so without any visible error on the site.
- Security plugins. Wordfence, iThemes/Solid Security, All In One WP Security and similar tools all ship options with names like "Disable REST API for non-logged-in users" or "Block unauthorized REST requests." Turning that on breaks every payment webhook. If a well-meaning volunteer hardened the site last month, start here.
- Host firewalls and WAF rules. Managed hosts and Cloudflare can rate-limit or challenge POST requests to
/wp-json/. A Cloudflare Bot Fight or WAF rule returning a 403 to Stripe looks identical to a security plugin block in the delivery log. Ask your host to check the WAF log for the exact timestamp you wrote down in step 1. - Page caching. A cache can serve a stale confirmation page, so the donor sees "processing" forever even though the gift completed, or it can intercept a listener URL. See the exclusion list below.
- A fatal PHP error. If a plugin or theme update introduced a fatal error in the payment path, your endpoint returns 500, Stripe retries for a while and then stops trying. Ask your host for the PHP error log for that timestamp. This is why you never ship plugin updates during a campaign.
- Mixed static and WordPress setups. If your homepage is a static HTML file and WordPress lives underneath, requests with query strings to
/can be swallowed by the static file before WordPress ever runs. Endpoints served under/wp-json/survive this. Endpoints built as query-string listeners on the homepage do not.
Which URLs should I exclude from caching on a donation site?
Cache exclusions are the fix for a whole family of "it says pending but it went through" reports, and they cost nothing to add. Paste these into your caching plugin's exclusion field: W3 Total Cache calls it Page Cache, Never cache the following pages; WP Rocket calls it Advanced Rules, Never Cache URL(s); LiteSpeed Cache calls it Cache, Excludes, Do Not Cache URIs.
| Exclude | Why |
|---|---|
| Your donation form page, for example /donate/ | A cached form can serve a stale payment session or a stale nonce |
| Your thank-you or confirmation page | Cached confirmations show one donor another donor's result, or freeze on "processing" |
| Your donor portal page, for example /my-donations/ | Personalised per donor and reached by one-time link |
| /wp-json/ | The REST namespace where webhooks and receipt downloads are served |
| Any URL carrying a token, session or payment_intent query string | These are one-time values and must never be cached |
Also switch off "cache logged-in users" while you are in there, and clear the entire cache after any change. If your site uses a CDN, purge that too, since a stale CDN copy can mask a fix you already made at the origin.
Why are my PayPal donations stuck on pending?
PayPal fails differently and looks scarier. One site owner posted: "I have had five donations come through today (yay!) The problem is that two of the donations have a status of paid but the other three donations have been in a pending status for ~7 hours" (wordpress.org support thread). They then asked whether to just mark them complete and whether to contact the donors. Both are the right questions, and the answers matter.
Two distinctions to hold on to:
- Authorization versus capture. An authorization means PayPal has reserved the funds. A capture means the money actually moved. A donation sitting at authorized has not paid you yet, and an uncaptured authorization eventually expires.
- Pending versus paid. PayPal genuinely holds some payments: eCheck or bank-funded gifts that need to clear, unconfirmed addresses, new-seller review, currency conversion, or a receiving preference set to manually accept certain payments. Those clear on PayPal's schedule, often in a few business days, with no action from you.
Check PayPal itself, under Activity, and look at the transaction status there. If PayPal says pending, your website is telling you the truth and there is nothing to fix. If PayPal says completed and your site still says pending, you have the same webhook problem described above, and PayPal has its own webhook delivery log under Developer Dashboard, Apps and Credentials, Webhooks.
A donor was charged but never got a receipt. What do I tell them?
Reply fast and be plain. Something close to this works:
"Thank you for catching this, and I am sorry for the worry. I can confirm your gift of $X on [date] went through and has reached us. What failed was our website's record of it, not the payment. You have not been charged twice and there is nothing you need to do. I am fixing the record now and will email your receipt within 24 hours."
Then actually send the receipt within 24 hours. Do not offer a refund unless the donor asks for one, and do not ask them to give again.
If the record is complete but the email still never arrived
Missing receipts are usually a downstream symptom of the record never completing, so fix that first. But if the donation row exists and shows complete and no email arrived, you have a mail deliverability problem, not a payment problem. Reports of this are common across donation plugins, including the wordpress.org thread "Email receipts are not sending". Three things fix most cases:
- Install an SMTP plugin and send through a real mail service rather than the web server's built-in PHP mail, which many hosts throttle or block outright.
- Never send From an address at a domain you cannot authenticate. Sending as a gmail.com or yahoo.com address from your web server fails alignment checks and lands in spam or gets rejected. Send from your own domain.
- Get SPF and DKIM right for that domain, then send yourself a test and check it arrived in the inbox, not the junk folder. Ask your host or mail provider for the exact records if DNS is not your thing.
How do I record a donation my processor took but my site missed?
Once the money is confirmed in Stripe or PayPal, enter the gift by hand. The one thing people get wrong here is the date. Use the real gift date from the processor, not today's date, so that the donor's history, your campaign totals, your monthly reports and the year-end tax statement all line up with what the donor's bank statement says.
In Donor Merchant, go to Donations, then Add donation. The form takes an editable gift date, an amount, a payment method (check, cash, bank or offline), a reference field where you paste the Stripe charge ID or PayPal transaction ID, an optional campaign, and a checkbox to send the donor their receipt. It matches on email address, so the gift merges into the existing donor record and their lifetime total rather than creating a duplicate. The same screen is what you use for cash and check gifts collected at an event.
Whatever plugin you use, put the processor's transaction ID in the reference field. Six months later, when you are reconciling the year-end statement against your bank deposits, that ID is what saves you.
Tax receipting rules vary by country and by organisation type. Confirm the wording and the timing of your receipts with your accountant or tax adviser rather than relying on any plugin's defaults.
How do I test a donation form end to end before a campaign?
Sandbox testing proves the form works. It does not prove your live keys, live webhook secret and live email are correct, which is exactly where this failure lives. So do a real one.
- Put the site in live mode with live keys and the live signing secret.
- Make a real donation of $1 with your own card, using an email address you can check.
- Confirm the payment appears in the Stripe or PayPal dashboard as succeeded or completed.
- Confirm the donation row appears in WordPress within a minute, with the right amount and campaign.
- Confirm the donor receipt lands in your inbox and the admin alert lands in the organisation's inbox. Check spam folders.
- Open the webhook delivery log and confirm a 200 response.
- Repeat once for a recurring gift if you offer monthly giving, since subscriptions use different events than one-time gifts and can fail independently.
- Refund the $1 from the processor dashboard.
How Donor Merchant handles this class of failure
No plugin is immune to a blocked REST API or a mismatched signing secret, and we are not claiming to be. What we control is how legible the failure is:
- Signed webhooks. Stripe events are verified against the signing secret and PayPal events against the configured webhook ID. A mismatch returns a 400 with the literal message "Invalid signature," and a missing secret returns "Webhook secret not configured." Those exact strings show up in the Stripe delivery log, so the log tells you which of the two problems you have instead of leaving you guessing.
- Everything is a REST route. Webhooks, receipts and annual statements are served under the
donor-merchant/v1namespace, so a static homepage or an aggressive page cache at the site root cannot swallow them. A security plugin blocking/wp-json/still will, which is why that is the first thing to check. - Manual entry keeps the record whole. Add donation accepts the real gift date and merges by email, so a recovered gift lands in the right month, the right campaign and the right year-end statement.
- Donors can self-serve. The donor portal uses a passwordless magic link, so a donor chasing a lost receipt can download it themselves once the record exists.
Donor Merchant is free and GPL on WordPress.org, with no platform fees on top of Stripe or PayPal's own processing fees. Setup guides for both processors, including the webhook step that causes this whole article, are at Stripe setup and PayPal setup.