On hold
Your WordPress plugin is on hold: what each rejection reason actually means
The plugin review team's email is short and vague on purpose. Here is what each reason actually means, what triggers it, and what genuinely gets you unblocked.
You submitted a plugin to wordpress.org. Two weeks later, an email arrives from plugins@wordpress.org telling you your plugin has been held for the following reasons, followed by three or four short paragraphs that sound like they should be self-explanatory and are not.
I have been on the receiving end of that email. Twice, on the same plugin, for two different reasons. What follows is what each reason actually means, what triggers it, and what got me unblocked — including the two things I got wrong and had to pay for in weeks of waiting.
Two things to know before anything else.
Do not chase them. Their email says it, and it is true: status requests slow down the queue. Review takes one to six weeks. There is no expedite.
Do not argue. The review team is not a support desk you can negotiate with. Every minute spent explaining why your case is different is a minute not spent fixing it. I watched myself want to do this. It never helps.
First, understand how the review actually works
Three properties of the process explain most of the confusion.
They re-review the whole plugin, every time. They do not diff your resubmission against the previous version. A new upload is a fresh review of everything. This cuts both ways: a problem they did not mention the first time can be raised on the second pass, and a detailed changelog in your reply is wasted effort — they were not comparing.
They read the source code. Not just the readme. Not just the headers. Moving a check somewhere less obvious, renaming a function, or obfuscating a limit does not work, and it converts a technical objection into a trust problem.
Their email is deliberately terse. They handle a large queue with volunteers. The brevity is not indifference — it means every clause carries weight. Which brings me to the single most useful habit I found: re-read their email as its own pass, clause by clause, after you think you are done. The worst thing I missed was hiding in a subordinate clause I had skimmed twice.
Guideline 5, “trialware”
The most common rejection, and the most misunderstood. Their wording is close to this:
Plugins may not limit functionality that runs entirely on the user's own installation.
What triggers it
A feature that runs entirely on the user's own server, artificially capped or locked, with a paid tier to unlock it.
The textbook case — mine — is a “bring your own API key” mode capped at N uses per month. The user supplies the key. The user pays the AI provider. The computation costs the plugin author nothing. So the limit exists for no reason other than to sell the removal of a limit, and that is exactly what Guideline 5 forbids.
Note what is not the problem: charging money is fine. Having a Pro version is fine. What is forbidden is shipping working local code and then blocking it.
What passes
- Remove the limit on anything that runs locally.
- Move the feature server-side, at your own cost. Then the limit reflects a real expense, and Guideline 6 explicitly allows it — their own example is a hosted spam-detection service.
- Sell support, updates, or a hosted service. Sell anything except the unlocking of code that already runs on the user's machine.
What worked best, and it surprised me
My first fix kept the local mode and simply removed its cap, running it alongside a new managed service. It was compliant. It was also fragile to explain, because it left a reviewer having to evaluate two code paths and take my word on which one the pricing applied to.
The version that went through removed the local mode entirely. If there is no local code, Guideline 5 does not apply at all. A simpler compliance position beats a defensible one.
This is not a patch. For me it meant deleting the bring-your-own-key mode, standing up a Cloudflare Worker holding my own API key, wiring licence webhooks with signature validation, adding per-install identification and quota storage, and rewriting the plugin's entire generation path. Knowing the answer took an hour. Shipping it took days.
Name and trademark collision
This one cost me two rounds. It is worth reading even if your name feels safe.
It is not just the name
not just in the name — includes … the URLs of this plugin
I renamed the plugin in the header, in the readme, and across every page of its website. I then submitted a version where the old name survived in four URLs: the Plugin URI, two links in the settings screen, and one link in the readme.
That was the exact string I had told them was fixed.
Check all of it: displayed name, slug, text domain, Plugin URI, every link in your admin UI, the readme, the contributor username, and the domain name itself.
And when you rename, a plain grep is not enough. Three live occurrences survived a sweep I had declared clean:
- The name split across an HTML tag. My site logo was written
Plug<span class="dot">Forge</span>. No search for the full name finds that — and it was the single most visible element on every page. - An even older slug. Dozens of links still pointed at the plugin's first name, abandoned two renames earlier, including the main call-to-action. Search for every dead name, not just the most recent one.
- What goes out over the network. A background worker was sending the old name in its
User-Agentto every third-party site it touched. Invisible on screen, entirely real to the servers receiving it.
The second refusal: the replacement name has to be yours
Here is the part almost nobody writes about. I renamed, resubmitted, and was refused again:
X is already used as a distinctive name by unrelated technology projects, while this submission does not establish that you own that name. Its use could therefore confuse users. Permission from another owner would not resolve this issue.
Three hard lessons in one paragraph.
The conflict is not limited to WordPress. My rejected name collided with no plugin in the directory. It collided with unrelated technology projects elsewhere. Checking a name means searching beyond wordpress.org — GitHub, npm, SaaS products, trademark registries.
There is nothing to negotiate. Permission from the owner does not unblock it, and buying the matching domain after the fact establishes nothing. Once you get that email, the name is dead. Do not spend a cycle defending it.
They will propose replacements themselves, usually built on your own developer handle: yourhandle-ai-alt-text.
Also worth knowing: their explicit counter-example is that adding a generic word — Advanced, Simple, Pro — to an existing name does not resolve a collision. And I can add one from experience: a portmanteau of two components does not automatically pass either. Mine was refused.
The way through
A purely descriptive name cannot collide with anyone's brand. The safest move is to take the descriptive half of their own suggestion and drop the handle — they wrote it, so they can hardly call it misleading.
And to avoid a third one-to-six-week round trip: put your fallback in the same email. “If you consider that name too generic, please reserve yourhandle-… instead.” They will act on the alternative rather than start another cycle.
To check whether a slug is free, without an account:
https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&request[slug]=YOUR-SLUG
{"error":"Plugin not found."} means it is available. Note that wordpress.org/plugins/YOUR-SLUG/ returns a 301 to the search page for an unknown slug, and 200 when it is taken — so a redirect is not proof of a broken link.
The Contributors line
The owner of the wordpress.org account submitting the plugin must appear in the Contributors: line of the readme. Missing it triggers an ownership warning.
Trivial to fix, and frequently missed precisely because the line already exists with other names in it — a framework author, a billing SDK — so it does not look empty.
One subtlety I hit: if you are resolving a trademark issue, check that line too. Their email listed “the plugin contributor's username” among the places they check for confusing similarity. Keeping a contributor handle built on the rejected name would have contradicted the entire rename.
Broken Plugin URI
The URL in your plugin header must respond, and must point to a page about this plugin specifically. Not your agency homepage. Not a 404.
Two distinct failures get confused here:
- the URL returns 404 → a technical fix
- the URL contains the conflicting name → this is a trademark problem, see above
And the part that catches people: verify it live, not in the file. A URL corrected in your source but never deployed is still a rejection. Before you reply, actually open every URL your submission contains.
Undocumented external services
Every outbound call must be documented in an External Services section of your readme: what is sent, when, and to which provider.
This section is read closely. It is also where I made the subtlest mistake of the whole process.
Write it last, after your architecture has stopped moving. My first version still described a mode I had already deleted. A description that does not match the submitted code is worse than no description at all — it reads as concealment rather than oversight.
The same applies to any privacy policy you link from that section. Mine still said the plugin sent an image URL long after the code had switched to sending the image itself. Two official documents contradicting each other about what leaves the user's server is exactly the kind of discrepancy this section exists to catch. I found it an hour before resubmitting, on a final pass, after everything else had been declared clean.
A secret shipped in the public ZIP
A systematic check before any submission.
Some licensing SDKs write a plaintext secret into your main plugin file. The generated code sometimes says so itself, in a comment along the lines of “delete this line before uploading”.
- ZIP generated by the SDK's own build tool → the secret is stripped automatically
- ZIP assembled by hand → the secret ships to a public repository
If you build your own archive, unzip it and grep it before you upload. It takes ten seconds and the failure mode is permanent.
“This category is crowded”
Sometimes they add a remark that your category is saturated. This is not a compliance point and does not block approval on its own.
What works in response is a verifiable technical difference, not a marketing claim. Resist “better than X” — it is unfalsifiable, and a review team reading it will trust the rest of your submission less.
How to reply
Their instruction, and it is worth following literally: keep it short, and do not list your changes. They re-review everything from scratch, so a changelog is noise.
What the reply needs:
- Confirmation of what changed, in a sentence or two — the position, not the diff
- If a rename is involved, an explicit request to reserve the new slug — they do not do it on their own. “Please reserve the slug
x” works - Your fallback option, if there is any doubt
And on the mechanics: upload the corrected version through the “Add your plugin” page while logged into the submitting account. Not as an email attachment. There is a page on make.wordpress.org from 2015 that says otherwise; it is out of date. The “Text Domain” warning you get on upload is normal until they reserve your slug.
The checklist I now run before resubmitting
Everything below came from getting one of them wrong.
- Every URL in the submission opens, live, right now — header, readme, admin screens
- No dead name anywhere: name, slug, text domain, contributor handle, URLs, domain
- Dead names include every previous name, not just the last one
- Search for names split across markup, not just the whole string
- Check what leaves over the network: user agents, headers, email templates
- The External Services section matches the code that is actually in the ZIP
- Any linked privacy policy matches it too
- Your account handle is in
Contributors: - No secret in the built ZIP — unzip it and grep it
- Their rejection email re-read clause by clause, as a separate pass
- The plugin installed from the built ZIP and actually run, not just read
That last one deserves its own section.
The habit that mattered more than any guideline
Thirteen review passes over my own code missed three blocking bugs. Every one of them surfaced in the first five minutes of actually running the thing.
One was fatal in a way I would never have caught by reading: the plugin sent only the image URL to the AI service, which meant the service had to fetch the image from the user's site. On any site not reachable from the internet — local development, password-protected staging, maintenance mode — nothing worked at all. If the reviewer had tested it locally, it would have failed in front of them.
Another: a service my own notes described as deployed did not exist on my hosting account. Twelve passes of reading had not noticed. One real HTTP request did.
Read the code to find what is wrong. Run it to find what is missing. They are different searches, and only one of them tells you the truth.
What this actually costs
For a plain compliance fix — documentation, headers, contributors, URLs — a few hours is realistic.
For Guideline 5, be honest with yourself: you are changing your product's business model and probably building server-side infrastructure you did not have. Between my first rejection and my final resubmission there were nine days, two rejected names, one Cloudflare Worker, a webhook integration, and three bugs that only appeared when I stopped reading and started running.
The guidelines are not arbitrary, and once you see the pattern behind them they are consistent: the directory hosts free software that works, and everything is judged against that.
I do this for other plugin authors — every point in the rejection email addressed, the guidelines they have not raised yet checked too, and the reply written for you to send. What that involves.