The endpoints
Everything hangs off https://api.password.es. Two answer and one does not yet — and the one that does not says so in its own response.
- POST/v1/generate Generates one or several passwords and returns their analysis.
- GET/openapi.json The description of the API, in OpenAPI 3.1.
-
POST/mcp
MCP server for assistants. One tool:
generate_password. - POST/v1/check 501 Does not exist yet. The error says what is missing and where to go meanwhile.
Generating a password
The body is optional: without it you get 16 characters with all four types on. With it, whatever you ask for.
curl -X POST https://api.password.es/v1/generate \
-H 'content-type: application/json' \
-d '{"length":20,"exclude_ambiguous":true}'
{
"passwords": [
"P#f.aK+w4pcsx]}Gx;*>"
],
"analysis": {
"length": 20,
"pool": 83,
"bits": 127.50078862693852,
"log10_guesses": 38.08053185185735,
"crack_time_log10_seconds": 26.08053185185735,
"crack_time": {
"value": "3.8 × 10¹⁸",
"unit": "years"
},
"level": 4,
"level_scale": "time",
"ceiling": false
},
"notice": "Generated on someone else's machine, which is a security antipattern even though we store nothing. For a password you will actually use, the generator at https://password.es/en/ runs entirely in your browser and sends nothing.",
"_meta": {
"plan": "anonymous",
"lang": {
"messages": "en",
"links": "en"
},
"limits": {
"burst": {
"limit": 60,
"window_seconds": 60
}
},
"quota": {
"limit": null,
"remaining": null,
"reset": "2026-09-01T00:00:00.000Z"
},
"docs": "https://password.es/api/"
}
}
The parameters
All of them optional. The table is in English and identical across all eighteen languages, on purpose: whoever integrates an API types the field names exactly as written, and eighteen translations of exclude_ambiguous would be debt, not scope.
| Field | Type | Default | Notes |
|---|---|---|---|
| length | integer 4–64 | 16 | How many characters. The same range as the generator on this site. |
| count | integer 1–20 | 1 | How many passwords to return. passwords is always an array, including with count 1. |
| lower | boolean | true | Include a–z (26 characters). |
| upper | boolean | true | Include A–Z (26 characters). |
| digits | boolean | true | Include 0–9 (10 characters). |
| symbols | boolean | true | Include ~!@#$%^&*()_+-=[]{};:,./<>? — the same 27 as the slider on the home page, no more. |
| exclude_ambiguous | boolean | false | Drops 0 O 1 I l | o. Six of them in practice, not seven: | is not in the symbol set to begin with. That is why pool reads 83 above instead of 89. |
| no_repeats | boolean | false | Avoids adjacent repeated characters. Not an absolute guarantee: it retries ten times, exactly as the web generator does. At length 64 that lets a repeat through about 0.1% of the time. |
| lang | string | en | Which language to answer in. One of the site's eighteen. Also accepted as ?lang= in the URL, which wins over this field; without either, Accept-Language is read. An unknown value is not an error — it falls back to English. See the section below: messages exist in English and Spanish, links in all eighteen. |
What each number means
Same idea: the reference in English, the explanation beside it. None of these numbers is new — they all come from the same engine that draws the meter on the home page.
| Field | Notes |
|---|---|
| passwords | An array of strings, always — including with count 1. |
| analysis.length | How many characters came back. |
| analysis.pool | The size of the alphabet the password was drawn from. |
| analysis.bits | H = L·log2(N), the same formula as the home page. With no_repeats it becomes log2(N)+(L-1)·log2(N-1). |
| analysis.log10_guesses | The expected work, as a base-10 logarithm: half the keyspace. |
| analysis.crack_time_log10_seconds | At 1012 guesses/s, offline, fast hash. The same attack model as the rest of the site. |
| analysis.crack_time | The same figure in words. unit follows the answer language: years, años… |
| analysis.level | 0–4. The same scale as the checker, ever since the site unified the two it used to have. |
| analysis.level_scale | "time". Says where the level came from, so that a future divergence is visible instead of having to be inferred by comparing numbers. |
| analysis.ceiling | false: the server generated the password, so the figure is exact and not a ceiling. The same flag the checker uses. |
| notice | The antipattern warning, in every single response. |
| _meta.plan | "anonymous". The only lane there is; the others arrive with accounts. |
| _meta.lang | { "messages", "links" } — which language each half actually came back in. They can differ, and that is why the API says so instead of leaving you to guess. |
| _meta.limits.burst | The rate limit actually enforced: limit requests per window_seconds. |
| _meta.quota | The reserved daily-quota slot. limit and remaining are null because nobody counts daily requests yet. |
| _meta.docs | A link back to the documentation. |
The answer language
It answers in English by default, which is what someone integrating without saying anything expects. Three ways to change it, and the first one wins if they disagree: ?lang= in the URL, "lang" in the body, and the Accept-Language header.
There is an asymmetry worth knowing here: messages exist in English and Spanish; links, in all eighteen languages of the site. Asking for German gets you German links and messages still in English.
curl -X POST 'https://api.password.es/v1/generate?lang=de' \
-H 'content-type: application/json' \
-d '{"length":20}'
"_meta": {
"lang": { "messages": "en", "links": "de" }
}
You do not have to guess: every response declares in _meta.lang what was applied to each half. And a language that does not exist is not an error — it falls back to English, and _meta.lang says so.
Checking a password: not yet
/v1/check returns 501. It is neither a bug nor an oversight: it is deliberate, and the response explains what is missing and where to go meanwhile. checker_url points at the site's checker in the language you asked for.
curl -X POST https://api.password.es/v1/check \
-H 'content-type: application/json' \
-d '{"password":"x"}'
{
"error": "not_implemented",
"message": "/v1/check does not exist yet. Returning the same numbers as the password.es checker requires its very same pattern engine, and that costs between 11 ms and 3.6 s of CPU per request depending on the input: it is waiting on a plan cap decision and on a length cap. Meanwhile the web checker does exactly this in your browser, sending nothing: https://password.es/en/checker/",
"checker_url": "https://password.es/en/checker/",
"docs": "https://password.es/api/",
"_meta": { "…": "igual que arriba" }
}
The errors
They all share one shape: a short error for the code, a message in prose —which is what an AI assistant reads out to its user—, sometimes the field that caused it, a docs link and the same _meta as always.
| HTTP | error | Notes |
|---|---|---|
| 400 | invalid_length | length outside 4–64. field names it. |
| 400 | invalid_count | count outside 1–20. field names it. |
| 400 | empty_alphabet | All four character types turned off, so there is no alphabet to draw from. No field: it is the combination, not one parameter. |
| 400 | unknown_parameter | A parameter this endpoint does not accept. field gives the offending name. |
| 429 | rate_limited | Over 60 requests in a minute. Carries Retry-After and RateLimit-* headers, and limit / window_seconds in the body. |
| 501 | not_implemented | Only from /v1/check. Carries checker_url, pointing at the web checker in the answer language. |
The limits
One, and it is the one actually enforced: 60 requests per minute per IP. You do not have to take this page's word for it — the number travels in every response, inside _meta.limits.burst.
Go over it and the answer is a 429 with Retry-After and RateLimit-* headers, plus a message in prose telling you what to do. It carries no link to sign-up or pricing, because there is no sign-up and no pricing.
In _meta you will also find a quota block with both values at null. That is deliberate: it is the slot reserved for when accounts exist, and it is empty because nobody counts daily requests today. A limit announced and not enforced is worse than announcing none.
The MCP server
MCP is the protocol assistants like Claude or ChatGPT use to reach external tools. Point your assistant at this address and it will generate passwords with these very numbers instead of making them up. No sign-up, no key, and the same limit of 60 requests per minute.
It is a stateless server, which is worth knowing if you come from other MCP servers: the POST is answered with JSON and no stream is opened, Mcp-Session-Id is neither issued nor expected, a notification is answered with 202 and no body, and the GET returns 405. The 2025-06-18 specification allows all of this explicitly.
curl -X POST https://api.password.es/mcp \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"generate_password",
"arguments":{"length":20,"lang":"es"}}}'
It publishes a single tool, generate_password, taking the same parameters as the table above plus lang. There is no check_password_strength, and there will not be one while /v1/check does not exist: a tool that always returns an error is not a tool, it is a broken promise inside an assistant's catalogue.
The warning that this is an antipattern travels in the tool description and in every result. That is deliberate: it is what the assistant ends up reading out to whoever asked for the password.
The documentation machines read
Besides this page there is an OpenAPI 3.1 description, and that one is published: api.password.es/openapi.json. It is what a client generator, an editor with autocompletion or an agent reads to learn which fields exist without anyone telling it. It describes the same parameters as the table above, the error codes, and why quota is empty.